Merge pull request #809 from ae-utbm/ajax-image-sas

Ajax image sas
This commit is contained in:
thomas girod 2024-09-18 15:03:54 +02:00 committed by GitHub
commit 7458f622f5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1156 additions and 541 deletions

View File

@ -3,16 +3,21 @@ from typing import Annotated
import annotated_types import annotated_types
from django.conf import settings from django.conf import settings
from django.http import HttpResponse from django.http import HttpResponse
from ninja_extra import ControllerBase, api_controller, route from ninja import Query
from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.exceptions import PermissionDenied from ninja_extra.exceptions import PermissionDenied
from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.schemas import PaginatedResponseSchema
from club.models import Mailing from club.models import Mailing
from core.api_permissions import CanView from core.api_permissions import CanView, IsLoggedInCounter, IsOldSubscriber, IsRoot
from core.models import User from core.models import User
from core.schemas import ( from core.schemas import (
FamilyGodfatherSchema, FamilyGodfatherSchema,
MarkdownSchema, MarkdownSchema,
UserFamilySchema, UserFamilySchema,
UserFilterSchema,
UserProfileSchema,
) )
from core.templatetags.renderer import markdown from core.templatetags.renderer import markdown
@ -38,6 +43,18 @@ class MailingListController(ControllerBase):
return data return data
@api_controller("/user", permissions=[IsOldSubscriber | IsRoot | IsLoggedInCounter])
class UserController(ControllerBase):
@route.get("", response=list[UserProfileSchema])
def fetch_profiles(self, pks: Query[set[int]]):
return User.objects.filter(pk__in=pks)
@route.get("/search", response=PaginatedResponseSchema[UserProfileSchema])
@paginate(PageNumberPaginationExtra, page_size=20)
def search_users(self, filters: Query[UserFilterSchema]):
return filters.filter(User.objects.all())
DepthValue = Annotated[int, annotated_types.Ge(0), annotated_types.Le(10)] DepthValue = Annotated[int, annotated_types.Ge(0), annotated_types.Le(10)]
DEFAULT_DEPTH = 4 DEFAULT_DEPTH = 4

View File

@ -42,6 +42,8 @@ from django.http import HttpRequest
from ninja_extra import ControllerBase from ninja_extra import ControllerBase
from ninja_extra.permissions import BasePermission from ninja_extra.permissions import BasePermission
from counter.models import Counter
class IsInGroup(BasePermission): class IsInGroup(BasePermission):
"""Check that the user is in the group whose primary key is given.""" """Check that the user is in the group whose primary key is given."""
@ -120,3 +122,15 @@ class IsOwner(BasePermission):
self, request: HttpRequest, controller: ControllerBase, obj: Any self, request: HttpRequest, controller: ControllerBase, obj: Any
) -> bool: ) -> bool:
return request.user.is_owner(obj) return request.user.is_owner(obj)
class IsLoggedInCounter(BasePermission):
"""Check that a user is logged in a counter."""
def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool:
if "/counter/" not in request.META["HTTP_REFERER"]:
return False
token = request.session.get("counter_token")
if not token:
return False
return Counter.objects.filter(token=token).exists()

View File

@ -49,8 +49,7 @@ class CustomerLookup(RightManagedLookupChannel):
model = Customer model = Customer
def get_query(self, q, request): def get_query(self, q, request):
users = search_user(q) return list(Customer.objects.filter(user__in=search_user(q)))
return [user.customer for user in users]
def format_match(self, obj): def format_match(self, obj):
return obj.user.get_mini_item() return obj.user.get_mini_item()

View File

@ -991,8 +991,8 @@ class SithFile(models.Model):
return user.is_board_member return user.is_board_member
if user.is_com_admin: if user.is_com_admin:
return True return True
if self.is_in_sas and user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID): if self.is_in_sas:
return True return user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID)
return user.id == self.owner_id return user.id == self.owner_id
def can_be_viewed_by(self, user): def can_be_viewed_by(self, user):

View File

@ -1,5 +1,12 @@
from typing import Annotated
from annotated_types import MinLen
from django.contrib.staticfiles.storage import staticfiles_storage from django.contrib.staticfiles.storage import staticfiles_storage
from ninja import ModelSchema, Schema from django.db.models import Q
from django.utils.text import slugify
from haystack.query import SearchQuerySet
from ninja import FilterSchema, ModelSchema, Schema
from pydantic import AliasChoices, Field
from core.models import User from core.models import User
@ -12,10 +19,6 @@ class SimpleUserSchema(ModelSchema):
fields = ["id", "nick_name", "first_name", "last_name"] fields = ["id", "nick_name", "first_name", "last_name"]
class MarkdownSchema(Schema):
text: str
class UserProfileSchema(ModelSchema): class UserProfileSchema(ModelSchema):
"""The necessary information to show a user profile""" """The necessary information to show a user profile"""
@ -42,6 +45,42 @@ class UserProfileSchema(ModelSchema):
return obj.profile_pict.get_download_url() return obj.profile_pict.get_download_url()
class UserFilterSchema(FilterSchema):
search: Annotated[str, MinLen(1)]
exclude: list[int] | None = Field(
None, validation_alias=AliasChoices("exclude", "exclude[]")
)
def filter_search(self, value: str | None) -> Q:
if not value:
return Q()
if len(value) < 3:
# For small queries, full text search isn't necessary
return (
Q(first_name__istartswith=value)
| Q(last_name__istartswith=value)
| Q(nick_name__istartswith=value)
)
return Q(
id__in=list(
SearchQuerySet()
.models(User)
.autocomplete(auto=slugify(value).replace("-", " "))
.order_by("-last_update")
.values_list("pk", flat=True)
)
)
def filter_exclude(self, value: set[int] | None) -> Q:
if not value:
return Q()
return ~Q(id__in=value)
class MarkdownSchema(Schema):
text: str
class FamilyGodfatherSchema(Schema): class FamilyGodfatherSchema(Schema):
godfather: int godfather: int
godchild: int godchild: int

View File

@ -107,3 +107,35 @@ function update_query_string(key, value, action = History.REPLACE, url = null) {
return url; return url;
} }
/**
* Given a paginated endpoint, fetch all the items of this endpoint,
* performing multiple API calls if necessary.
* @param {string} url The paginated endpoint to fetch
* @return {Promise<Object[]>}
*/
async function fetch_paginated(url) {
const max_per_page = 199;
const paginated_url = new URL(url, document.location.origin);
paginated_url.searchParams.set("page_size", max_per_page.toString());
paginated_url.searchParams.set("page", "1");
let first_page = (await ( await fetch(paginated_url)).json());
let results = first_page.results;
const nb_pictures = first_page.count
const nb_pages = Math.ceil(nb_pictures / max_per_page);
if (nb_pages > 1) {
let promises = [];
for (let i = 2; i <= nb_pages; i++) {
paginated_url.searchParams.set("page", i.toString());
promises.push(
fetch(paginated_url).then(res => res.json().then(json => json.results))
);
}
results.push(...await Promise.all(promises))
}
return results;
}

View File

@ -0,0 +1,261 @@
/**
* Builders to use Select2 in our templates.
*
* This comes with two flavours : local data or remote data.
*
* # Local data source
*
* To use local data source, you must define an array
* in your JS code, having the fields `id` and `text`.
*
* ```js
* const data = [
* {id: 1, text: "foo"},
* {id: 2, text: "bar"},
* ];
* document.addEventListener("DOMContentLoaded", () => sithSelect2({
* element: document.getElementById("select2-input"),
* data_source: local_data_source(data)
* }));
* ```
*
* You can also define a callback that return ids to exclude :
*
* ```js
* const data = [
* {id: 1, text: "foo"},
* {id: 2, text: "bar"},
* {id: 3, text: "to exclude"},
* ];
* document.addEventListener("DOMContentLoaded", () => sithSelect2({
* element: document.getElementById("select2-input"),
* data_source: local_data_source(data, {
* excluded: () => data.filter((i) => i.text === "to exclude").map((i) => parseInt(i))
* })
* }));
* ```
*
* # Remote data source
*
* Select2 with remote data sources are similar to those with local
* data, but with some more parameters, like `result_converter`,
* which takes a callback that must return a `Select2Object`.
*
* ```js
* document.addEventListener("DOMContentLoaded", () => sithSelect2({
* element: document.getElementById("select2-input"),
* data_source: remote_data_source("/api/user/search", {
* excluded: () => [1, 2], // exclude users 1 and 2 from the search
* result_converter: (user) => Object({id: user.id, text: user.first_name})
* })
* }));
* ```
*
* # Overrides
*
* Dealing with a select2 may be complex.
* That's why, when defining a select,
* you may add an override parameter,
* in which you can declare any parameter defined in the
* Select2 documentation.
*
* ```js
* document.addEventListener("DOMContentLoaded", () => sithSelect2({
* element: document.getElementById("select2-input"),
* data_source: remote_data_source("/api/user/search", {
* result_converter: (user) => Object({id: user.id, text: user.first_name}),
* overrides: {
* delay: 500
* }
* })
* }));
* ```
*
* # Caveats with exclude
*
* With local data source, select2 evaluates the data only once.
* Thus, modify the exclude after the initialisation is a no-op.
*
* With remote data source, the exclude list will be evaluated
* after each api response.
* It makes it possible to bind the data returned by the callback
* to some reactive data, thus making the exclude list dynamic.
*
* # Images
*
* Sometimes, you would like to display an image besides
* the text on the select items.
* In this case, fill the `picture_getter` option :
*
* ```js
* document.addEventListener("DOMContentLoaded", () => sithSelect2({
* element: document.getElementById("select2-input"),
* data_source: remote_data_source("/api/user/search", {
* result_converter: (user) => Object({id: user.id, text: user.first_name})
* })
* picture_getter: (user) => user.profile_pict,
* }));
* ```
*
* # Binding with alpine
*
* You can declare your select2 component in an Alpine data.
*
* ```html
* <body>
* <div x-data="select2_test">
* <select x-ref="search" x-ref="select"></select>
* <p x-text="current_selection.id"></p>
* <p x-text="current_selection.text"></p>
* </div>
* </body>
*
* <script>
* document.addEventListener("alpine:init", () => {
* Alpine.data("select2_test", () => ({
* selector: undefined,
* current_select: {id: "", text: ""},
*
* init() {
* this.selector = sithSelect2({
* element: $(this.$refs.select),
* data_source: local_data_source(
* [{id: 1, text: "foo"}, {id: 2, text: "bar"}]
* ),
* });
* this.selector.on("select2:select", (event) => {
* // select2 => Alpine signals here
* this.current_select = this.selector.select2("data")
* });
* this.$watch("current_selected" (value) => {
* // Alpine => select2 signals here
* });
* },
* }));
* })
* </script>
*/
/**
* @typedef Select2Object
* @property {number} id
* @property {string} text
*/
/**
* @typedef Select2Options
* @property {Element} element
* @property {Object} data_source
* the data source, built with `local_data_source` or `remote_data_source`
* @property {number[]} excluded A list of ids to exclude from search
* @property {undefined | function(Object): string} picture_getter
* A callback to get the picture field from the API response
* @property {Object | undefined} overrides
* Any other select2 parameter to apply on the config
*/
/**
* @param {Select2Options} options
*/
function sithSelect2(options) {
const elem = $(options.element);
return elem.select2({
theme: elem[0].multiple ? "classic" : "default",
minimumInputLength: 2,
templateResult: select_item_builder(options.picture_getter),
...options.data_source,
...(options.overrides || {}),
});
}
/**
* @typedef LocalSourceOptions
* @property {undefined | function(): number[]} excluded
* A callback to the ids to exclude from the search
*/
/**
* Build a data source for a Select2 from a local array
* @param {Select2Object[]} source The array containing the data
* @param {RemoteSourceOptions} options
*/
function local_data_source(source, options) {
if (!!options.excluded) {
const ids = options.excluded();
return { data: source.filter((i) => !ids.includes(i.id)) };
}
return { data: source };
}
/**
* @typedef RemoteSourceOptions
* @property {undefined | function(): number[]} excluded
* A callback to the ids to exclude from the search
* @property {undefined | function(): Select2Object} result_converter
* A converter for a value coming from the remote api
* @property {undefined | Object} overrides
* Any other select2 parameter to apply on the config
*/
/**
* Build a data source for a Select2 from a remote url
* @param {string} source The url of the endpoint
* @param {RemoteSourceOptions} options
*/
function remote_data_source(source, options) {
jQuery.ajaxSettings.traditional = true;
let params = {
url: source,
dataType: "json",
cache: true,
delay: 250,
data: function (params) {
return {
search: params.term,
exclude: [
...(this.val() || []).map((i) => parseInt(i)),
...(options.excluded ? options.excluded() : []),
],
};
},
};
if (!!options.result_converter) {
params["processResults"] = function (data) {
return { results: data.results.map(options.result_converter) };
};
}
if (!!options.overrides) {
Object.assign(params, options.overrides);
}
return { ajax: params };
}
function item_formatter(user) {
if (user.loading) {
return user.text;
}
}
/**
* Build a function to display the results
* @param {null | function(Object):string} picture_getter
* @return {function(string): jQuery|HTMLElement}
*/
function select_item_builder(picture_getter) {
return (item) => {
const picture =
typeof picture_getter === "function" ? picture_getter(item) : null;
const img_html = picture
? `<img
src="${picture_getter(item)}"
alt="${item.text}"
onerror="this.src = '/static/core/img/unknown.jpg'"
/>`
: "";
return $(`<div class="select-item">
${img_html}
<span class="select-item-text">${item.text}</span>
</div>`);
};
}

View File

@ -615,6 +615,38 @@ a:not(.button) {
} }
} }
.select2 {
margin: 10px 0!important;
max-width: 100%;
min-width: 100%;
ul {
margin: 0;
}
textarea {
background-color: inherit;
}
.select2-container--default {
color: black;
}
}
.select2-results {
.select-item {
display: flex;
flex-direction: row;
gap: 10px;
align-items: center;
img {
max-height: 40px;
border-radius: 50%;
}
}
}
#news_details { #news_details {
display: inline-block; display: inline-block;
margin-top: 20px; margin-top: 20px;
@ -1170,13 +1202,6 @@ u,
} }
} }
/* XXX This seems to be used in the SAS */
#pict {
display: inline-block;
width: 80%;
background: hsl(0, 0%, 20%);
border: solid hsl(0, 0%, 20%) 2px;
}
/*--------------------------------MATMAT-------------------------------*/ /*--------------------------------MATMAT-------------------------------*/
.matmat_results { .matmat_results {
display: flex; display: flex;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -11,6 +11,7 @@
<link rel="stylesheet" href="{{ scss('core/markdown.scss') }}"> <link rel="stylesheet" href="{{ scss('core/markdown.scss') }}">
<link rel="stylesheet" href="{{ scss('core/header.scss') }}"> <link rel="stylesheet" href="{{ scss('core/header.scss') }}">
<link rel="stylesheet" href="{{ scss('core/navbar.scss') }}"> <link rel="stylesheet" href="{{ scss('core/navbar.scss') }}">
<link rel="stylesheet" href="{{ static('vendored/select2/select2.min.css') }}">
{% block jquery_css %} {% block jquery_css %}
{# Thile file is quite heavy (around 250kb), so declaring it in a block allows easy removal #} {# Thile file is quite heavy (around 250kb), so declaring it in a block allows easy removal #}
@ -24,6 +25,9 @@
<script src="{{ static('vendored/jquery/jquery-3.6.2.min.js') }}"></script> <script src="{{ static('vendored/jquery/jquery-3.6.2.min.js') }}"></script>
<!-- Put here to always have acces to those functions on django widgets --> <!-- Put here to always have acces to those functions on django widgets -->
<script src="{{ static('core/js/script.js') }}"></script> <script src="{{ static('core/js/script.js') }}"></script>
<script defer src="{{ static('vendored/select2/select2.min.js') }}"></script>
<script defer src="{{ static('core/js/sith-select2.js') }}"></script>
{% block additional_css %}{% endblock %} {% block additional_css %}{% endblock %}
{% block additional_js %}{% endblock %} {% block additional_js %}{% endblock %}

View File

@ -65,17 +65,29 @@
{{ super() }} {{ super() }}
<script> <script>
/**
* @typedef UserProfile
* @property {number} id
* @property {string} first_name
* @property {string} last_name
* @property {string} nick_name
* @property {string} display_name
* @property {string} profile_url
* @property {string} profile_pict
*/
/** /**
* @typedef Picture * @typedef Picture
* @property {number} id * @property {number} id
* @property {string} name * @property {string} name
* @property {number} size * @property {number} size
* @property {string} date * @property {string} date
* @property {Object} author * @property {UserProfile} owner
* @property {string} full_size_url * @property {string} full_size_url
* @property {string} compressed_url * @property {string} compressed_url
* @property {string} thumb_url * @property {string} thumb_url
* @property {string} album * @property {string} album
* @property {boolean} is_moderated
* @property {boolean} asked_for_removal
*/ */
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
@ -86,7 +98,7 @@
albums: {}, albums: {},
async init() { async init() {
this.pictures = await this.get_pictures(); this.pictures = await fetch_paginated("{{ url("api:pictures") }}" + "?users_identified={{ object.id }}");
this.albums = this.pictures.reduce((acc, picture) => { this.albums = this.pictures.reduce((acc, picture) => {
if (!acc[picture.album]){ if (!acc[picture.album]){
acc[picture.album] = []; acc[picture.album] = [];
@ -97,34 +109,6 @@
this.loading = false; this.loading = false;
}, },
/**
* @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}`;
let first_page = (await ( await fetch(url)).json());
let promises = [first_page.results];
const nb_pictures = first_page.count
const nb_pages = Math.ceil(nb_pictures / max_per_page);
for (let i = 2; 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(){ async download_zip(){
this.is_downloading = true; this.is_downloading = true;
const bar = this.$refs.progress; const bar = this.$refs.progress;

File diff suppressed because it is too large Load Diff

View File

@ -7,7 +7,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-09-17 11:54+0200\n" "POT-Creation-Date: 2024-09-03 15:22+0200\n"
"PO-Revision-Date: 2024-09-17 11:54+0200\n" "PO-Revision-Date: 2024-09-17 11:54+0200\n"
"Last-Translator: Sli <antoine@bartuccio.fr>\n" "Last-Translator: Sli <antoine@bartuccio.fr>\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@ -19,3 +19,6 @@ msgstr ""
#: core/static/user/js/family_graph.js:230 #: core/static/user/js/family_graph.js:230
msgid "family_tree.%(extension)s" msgid "family_tree.%(extension)s"
msgstr "arbre_genealogique.%(extension)s" msgstr "arbre_genealogique.%(extension)s"
#: sas/static/sas/js/picture.js:52
msgid "Couldn't delete picture"
msgstr "Echec de la suppression de la photo"

View File

@ -1,16 +1,18 @@
from django.conf import settings from django.conf import settings
from django.db.models import F from django.db.models import F
from django.urls import reverse
from ninja import Query from ninja import Query
from ninja_extra import ControllerBase, api_controller, paginate, route from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.exceptions import PermissionDenied from ninja_extra.exceptions import NotFound, PermissionDenied
from ninja_extra.pagination import PageNumberPaginationExtra from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.permissions import IsAuthenticated from ninja_extra.permissions import IsAuthenticated
from ninja_extra.schemas import PaginatedResponseSchema from ninja_extra.schemas import PaginatedResponseSchema
from pydantic import NonNegativeInt from pydantic import NonNegativeInt
from core.models import User from core.api_permissions import CanView, IsOwner
from core.models import Notification, User
from sas.models import PeoplePictureRelation, Picture from sas.models import PeoplePictureRelation, Picture
from sas.schemas import PictureFilterSchema, PictureSchema from sas.schemas import IdentifiedUserSchema, PictureFilterSchema, PictureSchema
@api_controller("/sas/picture") @api_controller("/sas/picture")
@ -45,9 +47,56 @@ class PicturesController(ControllerBase):
filters.filter(Picture.objects.viewable_by(user)) filters.filter(Picture.objects.viewable_by(user))
.distinct() .distinct()
.order_by("-parent__date", "date") .order_by("-parent__date", "date")
.select_related("owner")
.annotate(album=F("parent__name")) .annotate(album=F("parent__name"))
) )
@route.get(
"/{picture_id}/identified",
permissions=[IsAuthenticated, CanView],
response=list[IdentifiedUserSchema],
)
def fetch_identifications(self, picture_id: int):
"""Fetch the users that have been identified on the given picture."""
picture = self.get_object_or_exception(Picture, pk=picture_id)
return picture.people.select_related("user")
@route.put("/{picture_id}/identified", permissions=[IsAuthenticated, CanView])
def identify_users(self, picture_id: NonNegativeInt, users: set[NonNegativeInt]):
picture = self.get_object_or_exception(Picture, pk=picture_id)
db_users = list(User.objects.filter(id__in=users))
if len(users) != len(db_users):
raise NotFound
already_identified = set(
picture.people.filter(user_id__in=users).values_list("user_id", flat=True)
)
identified = [u for u in db_users if u.pk not in already_identified]
relations = [
PeoplePictureRelation(user=u, picture_id=picture_id) for u in identified
]
PeoplePictureRelation.objects.bulk_create(relations)
for u in identified:
Notification.objects.get_or_create(
user=u,
viewed=False,
type="NEW_PICTURES",
defaults={
"url": reverse("core:user_pictures", kwargs={"user_id": u.id})
},
)
@route.delete("/{picture_id}", permissions=[IsOwner])
def delete_picture(self, picture_id: int):
self.get_object_or_exception(Picture, pk=picture_id).delete()
@route.patch("/{picture_id}/moderate", permissions=[IsOwner])
def moderate_picture(self, picture_id: int):
picture = self.get_object_or_exception(Picture, pk=picture_id)
picture.is_moderated = True
picture.moderator = self.context.request.user
picture.asked_for_removal = False
picture.save()
@api_controller("/sas/relation", tags="User identification on SAS pictures") @api_controller("/sas/relation", tags="User identification on SAS pictures")
class UsersIdentifiedController(ControllerBase): class UsersIdentifiedController(ControllerBase):

View File

@ -3,7 +3,8 @@ from datetime import datetime
from ninja import FilterSchema, ModelSchema, Schema from ninja import FilterSchema, ModelSchema, Schema
from pydantic import Field, NonNegativeInt from pydantic import Field, NonNegativeInt
from sas.models import PeoplePictureRelation, Picture from core.schemas import UserProfileSchema
from sas.models import Picture
class PictureFilterSchema(FilterSchema): class PictureFilterSchema(FilterSchema):
@ -16,8 +17,9 @@ class PictureFilterSchema(FilterSchema):
class PictureSchema(ModelSchema): class PictureSchema(ModelSchema):
class Meta: class Meta:
model = Picture model = Picture
fields = ["id", "name", "date", "size", "is_moderated"] fields = ["id", "name", "date", "size", "is_moderated", "asked_for_removal"]
owner: UserProfileSchema
full_size_url: str full_size_url: str
compressed_url: str compressed_url: str
thumb_url: str thumb_url: str
@ -36,12 +38,11 @@ class PictureSchema(ModelSchema):
return obj.get_download_thumb_url() return obj.get_download_thumb_url()
class PictureCreateRelationSchema(Schema): class PictureRelationCreationSchema(Schema):
user_id: NonNegativeInt picture: NonNegativeInt
picture_id: NonNegativeInt users: list[NonNegativeInt]
class CreatedPictureRelationSchema(ModelSchema): class IdentifiedUserSchema(Schema):
class Meta: id: int
model = PeoplePictureRelation user: UserProfileSchema
fields = ["id", "user", "picture"]

View File

@ -72,45 +72,31 @@
aspect-ratio: 16/9; aspect-ratio: 16/9;
background: #333333; background: #333333;
> a { position: relative;
img {
width: 100%;
height: 100%;
object-fit: cover;
opacity: 70%;
}
.overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: 40px;
}
> div {
display: flex; display: flex;
position: relative; position: relative;
width: 100%; width: 100%;
height: 100%; height: 100%;
> div {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
font-size: 30px;
color: white;
background-repeat: no-repeat;
background-position: center center;
background-size: cover;
&::before {
position: absolute;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, .3);
} }
} }
} }
}
> #prev > a > div::before {
content: '';
}
> #next > a > div::before {
content: '';
}
}
> .tags { > .tags {
@media (min-width: 1001px) { @media (min-width: 1001px) {
@ -140,6 +126,11 @@
width: 100%; width: 100%;
justify-content: space-between; justify-content: space-between;
&.loader {
margin-top: 10px;
--loading-size: 20px
}
@media (max-width: 1000px) { @media (max-width: 1000px) {
max-width: calc(50% - 5px); max-width: calc(50% - 5px);
} }
@ -299,20 +290,3 @@
} }
} }
} }
.moderation {
box-sizing: border-box;
width: 100%;
border: 2px solid coral;
border-radius: 2px;
padding: 10px;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
> div:last-child {
display: flex;
gap: 20px;
}
}

260
sas/static/sas/js/viewer.js Normal file
View File

@ -0,0 +1,260 @@
/**
* @typedef PictureIdentification
* @property {number} id The actual id of the identification
* @property {UserProfile} user The identified user
*/
/**
* A container for a picture with the users identified on it
* able to prefetch its data.
*/
class PictureWithIdentifications {
identifications = null;
image_loading = false;
identifications_loading = false;
/**
* @param {Picture} picture
*/
constructor(picture) {
Object.assign(this, picture);
}
/**
* @param {Picture} picture
*/
static from_picture(picture) {
return new PictureWithIdentifications(picture);
}
/**
* If not already done, fetch the users identified on this picture and
* populate the identifications field
* @param {?Object=} options
* @return {Promise<void>}
*/
async load_identifications(options) {
if (this.identifications_loading) {
return; // The users are already being fetched.
}
if (!!this.identifications && !options?.force_reload) {
// The users are already fetched
// and the user does not want to force the reload
return;
}
this.identifications_loading = true;
const url = `/api/sas/picture/${this.id}/identified`;
this.identifications = await (await fetch(url)).json();
this.identifications_loading = false;
}
/**
* Preload the photo and the identifications
* @return {Promise<void>}
*/
async preload() {
const img = new Image();
img.src = this.compressed_url;
if (!img.complete) {
this.image_loading = true;
img.addEventListener("load", () => {
this.image_loading = false;
});
}
await this.load_identifications();
}
}
document.addEventListener("alpine:init", () => {
Alpine.data("picture_viewer", () => ({
/**
* All the pictures that can be displayed on this picture viewer
* @type PictureWithIdentifications[]
**/
pictures: [],
/**
* The currently displayed picture
* Default dummy data are pre-loaded to avoid javascript error
* when loading the page at the beginning
* @type PictureWithIdentifications
**/
current_picture: {
is_moderated: true,
id: null,
name: "",
display_name: "",
compressed_url: "",
profile_url: "",
full_size_url: "",
owner: "",
date: new Date(),
identifications: [],
},
/**
* The picture which will be displayed next if the user press the "next" button
* @type ?PictureWithIdentifications
**/
next_picture: null,
/**
* The picture which will be displayed next if the user press the "previous" button
* @type ?PictureWithIdentifications
**/
previous_picture: null,
/**
* The select2 component used to identify users
**/
selector: undefined,
/**
* true if the page is in a loading state, else false
**/
/**
* Error message when a moderation operation fails
* @type string
**/
moderation_error: "",
/**
* 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,
async init() {
this.pictures = (await fetch_paginated(picture_endpoint)).map(
PictureWithIdentifications.from_picture,
);
this.selector = sithSelect2({
element: $(this.$refs.search),
data_source: remote_data_source("/api/user/search", {
excluded: () => [
...(this.current_picture.identifications || []).map(
(i) => i.user.id,
),
],
result_converter: (obj) => Object({ ...obj, text: obj.display_name }),
}),
picture_getter: (user) => user.profile_pict,
});
this.current_picture = this.pictures.find(
(i) => i.id === first_picture_id,
);
this.$watch("current_picture", () => this.update_picture());
window.addEventListener("popstate", async (event) => {
if (!event.state || event.state.sas_picture_id === undefined) {
return;
}
this.pushstate = History.REPLACE;
this.current_picture = this.pictures.find(
(i) => i.id === parseInt(event.state.sas_picture_id),
);
});
this.pushstate = History.REPLACE; /* Avoid first url push */
await this.update_picture();
},
/**
* Update the page.
* Called when the `current_picture` property changes.
*
* The url is modified without reloading the page,
* and the previous picture, the next picture and
* the list of identified users are updated.
*/
async update_picture() {
const update_args = [
{ sas_picture_id: this.current_picture.id },
"",
`/sas/picture/${this.current_picture.id}/`,
];
if (this.pushstate === History.REPLACE) {
window.history.replaceState(...update_args);
this.pushstate = History.PUSH;
} else {
window.history.pushState(...update_args);
}
this.moderation_error = "";
const index = this.pictures.indexOf(this.current_picture);
this.previous_picture = this.pictures[index - 1] || null;
this.next_picture = this.pictures[index + 1] || null;
await this.current_picture.load_identifications();
this.$refs.main_picture?.addEventListener("load", () => {
// once the current picture is loaded,
// start preloading the next and previous pictures
this.next_picture?.preload();
this.previous_picture?.preload();
});
},
async moderate_picture() {
const res = await fetch(
`/api/sas/picture/${this.current_picture.id}/moderate`,
{
method: "PATCH",
},
);
if (!res.ok) {
this.moderation_error = `${gettext("Couldn't moderate picture")} : ${res.statusText}`;
return;
}
this.current_picture.is_moderated = true;
this.current_picture.asked_for_removal = false;
},
async delete_picture() {
const res = await fetch(`/api/sas/picture/${this.current_picture.id}/`, {
method: "DELETE",
});
if (!res.ok) {
this.moderation_error =
gettext("Couldn't delete picture") + " : " + res.statusText;
return;
}
this.pictures.splice(this.pictures.indexOf(this.current_picture), 1);
if (this.pictures.length === 0) {
// The deleted picture was the only one in the list.
// As the album is now empty, go back to the parent page
document.location.href = album_url;
}
this.current_picture = this.next_picture || this.previous_picture;
},
/**
* Send the identification request and update the list of identified users.
*/
async submit_identification() {
const url = `/api/sas/picture/${this.current_picture.id}/identified`;
await fetch(url, {
method: "PUT",
body: JSON.stringify(this.selector.val().map((i) => parseInt(i))),
});
// refresh the identified users list
await this.current_picture.load_identifications({ force_reload: true });
this.selector.empty().trigger("change");
},
/**
* Check if an identification can be removed by the currently logged user
* @param {PictureIdentification} identification
* @return {boolean}
*/
can_be_removed(identification) {
return user_is_sas_admin || identification.user.id === user_id;
},
/**
* Untag a user from the current picture
* @param {PictureIdentification} identification
*/
async remove_identification(identification) {
const res = await fetch(`/api/sas/relation/${identification.id}`, {
method: "DELETE",
});
if (res.ok && Array.isArray(this.current_picture.identifications)) {
this.current_picture.identifications =
this.current_picture.identifications.filter(
(i) => i.id !== identification.id,
);
}
},
}));
});

View File

@ -4,6 +4,10 @@
<link rel="stylesheet" href="{{ scss('sas/css/picture.scss') }}"> <link rel="stylesheet" href="{{ scss('sas/css/picture.scss') }}">
{%- endblock -%} {%- endblock -%}
{%- block additional_js -%}
<script defer src="{{ static("sas/js/viewer.js") }}"></script>
{%- endblock -%}
{% block title %} {% block title %}
{% trans %}SAS{% endtrans %} {% trans %}SAS{% endtrans %}
{% endblock %} {% endblock %}
@ -11,51 +15,56 @@
{% from "sas/macros.jinja" import print_path %} {% from "sas/macros.jinja" import print_path %}
{% block content %} {% block content %}
<main x-data="picture_viewer">
<code> <code>
<a href="{{ url('sas:main') }}">SAS</a> / {{ print_path(picture.parent) }} {{ picture.get_display_name() }} <a href="{{ url('sas:main') }}">SAS</a> / {{ print_path(album) }} <span x-text="current_picture.name"></span>
</code> </code>
<br> <br>
<div class="title"> <div class="title">
<h3>{{ picture.get_display_name() }}</h3> <h3 x-text="current_picture.name"></h3>
<h4>{{ picture.parent.children.filter(id__lte=picture.id).count() }} <h4 x-text="`${pictures.indexOf(current_picture) + 1 } / ${pictures.length}`"></h4>
/ {{ picture.parent.children.count() }}</h4>
</div> </div>
<br> <br>
{% if not picture.is_moderated %} <template x-if="!current_picture.is_moderated">
{% set next = picture.get_next() %} <div class="alert alert-red">
{% if not next %} <div class="alert-main">
{% set next = url('sas:moderation') %} <template x-if="current_picture.asked_for_removal">
{% else %}
{% set next = next.get_absolute_url() + "#pict" %}
{% endif %}
<div class="moderation">
<div>
{% if picture.asked_for_removal %}
<span class="important">{% trans %}Asked for removal{% endtrans %}</span> <span class="important">{% trans %}Asked for removal{% endtrans %}</span>
{% else %} </template>
&nbsp; <p>
{% endif %} {% trans trimmed %}
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.
{% endtrans %}
</p>
</div> </div>
<div> <div>
<a href="{{ url('core:file_moderate', file_id=picture.id) }}?next={{ next }}"> <div>
<button class="btn btn-blue" @click="moderate_picture()">
{% trans %}Moderate{% endtrans %} {% trans %}Moderate{% endtrans %}
</a> </button>
<a href="{{ url('core:file_delete', file_id=picture.id) }}?next={{ next }}"> <button class="btn btn-red" @click.prevent="delete_picture()">
{% trans %}Delete{% endtrans %} {% trans %}Delete{% endtrans %}
</a> </button>
</div>
<p x-show="!!moderation_error" x-text="moderation_error"></p>
</div> </div>
</div> </div>
{% endif %} </template>
<div class="container"> <div class="container" id="pict">
<div class="main"> <div class="main">
<div class="photo"> <div class="photo" :aria-busy="current_picture.image_loading">
<img src="{{ picture.get_download_compressed_url() }}" alt="{{ picture.get_display_name() }}"/> <img
:src="current_picture.compressed_url"
:alt="current_picture.name"
id="main-picture"
x-ref="main_picture"
/>
</div> </div>
<div class="general"> <div class="general">
@ -64,19 +73,17 @@
<div> <div>
<div> <div>
<span>{% trans %}Date: {% endtrans %}</span> <span>{% trans %}Date: {% endtrans %}</span>
<span>{{ picture.date|date(DATETIME_FORMAT) }}</span> <span
x-text="Intl.DateTimeFormat(
'{{ LANGUAGE_CODE }}', {dateStyle: 'long'}
).format(new Date(current_picture.date))"
>
</span>
</div> </div>
<div> <div>
<span>{% trans %}Owner: {% endtrans %}</span> <span>{% trans %}Owner: {% endtrans %}</span>
<a href="{{ picture.owner.get_absolute_url() }}">{{ picture.owner.get_short_name() }}</a> <a :href="current_picture.owner.profile_url" x-text="current_picture.owner.display_name"></a>
</div> </div>
{% if picture.moderator %}
<div>
<span>{% trans %}Moderator: {% endtrans %}</span>
<a href="{{ picture.moderator.get_absolute_url() }}">{{ picture.moderator.get_short_name() }}</a>
</div>
{% endif %}
</div> </div>
</div> </div>
@ -84,14 +91,14 @@
<h5>{% trans %}Tools{% endtrans %}</h5> <h5>{% trans %}Tools{% endtrans %}</h5>
<div> <div>
<div> <div>
<a class="text" href="{{ picture.get_download_url() }}"> <a class="text" :href="current_picture.full_size_url">
{% trans %}HD version{% endtrans %} {% trans %}HD version{% endtrans %}
</a> </a>
<br> <br>
<a class="text danger" href="?ask_removal">{% trans %}Ask for removal{% endtrans %}</a> <a class="text danger" href="?ask_removal">{% trans %}Ask for removal{% endtrans %}</a>
</div> </div>
<div class="buttons"> <div class="buttons">
<a class="button" href="{{ url('sas:picture_edit', picture_id=picture.id) }}">✏️</a> <a class="button" :href="`/sas/picture/${current_picture.id}/edit/`">✏️</a>
<a class="button" href="?rotate_left">↺</a> <a class="button" href="?rotate_left">↺</a>
<a class="button" href="?rotate_right">↻</a> <a class="button" href="?rotate_right">↻</a>
</div> </div>
@ -102,99 +109,72 @@
<div class="subsection"> <div class="subsection">
<div class="navigation"> <div class="navigation">
<div id="prev"> <div id="prev" class="clickable">
{% if previous_pict %} <template x-if="previous_picture">
<a href="{{ url( 'sas:picture', picture_id=previous_pict.id) }}#pict"> <div
<div style="background-image: url('{{ previous_pict.get_download_thumb_url() }}');"></div> @keyup.left.window="current_picture = previous_picture"
</a> @click="current_picture = previous_picture"
{% endif %} >
<img :src="previous_picture.thumb_url" alt="{% trans %}Previous picture{% endtrans %}"/>
<div class="overlay">←</div>
</div> </div>
<div id="next"> </template>
{% if next_pict %} </div>
<a href="{{ url( 'sas:picture', picture_id=next_pict.id) }}#pict"> <div id="next" class="clickable">
<div style="background-image: url('{{ next_pict.get_download_thumb_url() }}');"></div> <template x-if="next_picture">
</a> <div
{% endif %} @keyup.right.window="current_picture = next_picture"
@click="current_picture = next_picture"
>
<img :src="next_picture.thumb_url" alt="{% trans %}Previous picture{% endtrans %}"/>
<div class="overlay">→</div>
</div>
</template>
</div> </div>
</div> </div>
<div class="tags"> <div class="tags">
<h5>{% trans %}People{% endtrans %}</h5> <h5>{% trans %}People{% endtrans %}</h5>
{% if user.was_subscribed %} {% if user.was_subscribed %}
<form action="" method="post" enctype="multipart/form-data"> <form @submit.prevent="submit_identification" x-show="!!selector">
{% csrf_token %} <select x-ref="search" multiple="multiple"></select>
{{ form.as_p() }}
<input type="submit" value="{% trans %}Go{% endtrans %}"/> <input type="submit" value="{% trans %}Go{% endtrans %}"/>
</form> </form>
{% endif %} {% endif %}
<ul x-data="user_identification"> <ul>
<template x-for="item in items" :key="item.id"> <template
x-for="identification in (current_picture.identifications || [])"
:key="identification.id"
>
<li> <li>
<a class="user" :href="item.user.url"> <a class="user" :href="identification.user.profile_url">
<img class="profile-pic" :src="item.user.picture" alt="image de profil"/> <img class="profile-pic" :src="identification.user.profile_pict" alt="image de profil"/>
<span x-text="item.user.name"></span> <span x-text="identification.user.display_name"></span>
</a> </a>
<template x-if="can_be_removed(item)"> <template x-if="can_be_removed(identification)">
<a class="delete clickable" @click="remove(item)">❌</a> <a class="delete clickable" @click="remove_identification(identification)">❌</a>
</template> </template>
</li> </li>
</template> </template>
<template x-if="current_picture.identifications_loading">
{# shadow element that exists only to put the loading wheel below
the list of identified people #}
<li class="loader" aria-busy="true"></li>
</template>
</ul> </ul>
</div> </div>
</div> </div>
</div> </div>
</main>
{% endblock %} {% endblock %}
{% block script %} {% block script %}
{{ super() }} {{ super() }}
<script> <script>
document.addEventListener("alpine:init", () => { const picture_endpoint = "{{ url("api:pictures") + "?album_id=" + album.id|string }}";
Alpine.data("user_identification", () => ({ const album_url = "{{ album.get_absolute_url() }}";
items: [ const first_picture_id = {{ picture.id }}; {# id of the first picture to show after page load #}
{%- for r in picture.people.select_related("user", "user__profile_pict") -%} const user_id = {{ user.id }};
{ const user_is_sas_admin = {{ (user.is_root or user.is_in_group(pk = settings.SITH_GROUP_SAS_ADMIN_ID))|tojson }}
id: {{ r.id }},
user: {
id: {{ r.user.id }},
name: "{{ r.user.get_short_name()|safe }}",
url: "{{ r.user.get_absolute_url() }}",
{% if r.user.profile_pict %}
picture: "{{ r.user.profile_pict.get_download_url() }}",
{% else %}
picture: "{{ static('core/img/unknown.jpg') }}",
{% endif %}
},
},
{%- endfor -%}
],
can_be_removed(item) {
{# If user is root or sas admin, he has the right, at "compile" time.
If not, he can delete only its own identification. #}
{% if user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID) %}
return true;
{% else %}
return item.user.id === {{ user.id }};
{% endif %}
},
async remove(item) {
const res = await fetch(`/api/sas/relation/${item.id}`, {method: "DELETE"});
if (res.ok) {
this.items = this.items.filter((i) => i.id !== item.id)
}
},
}))
});
$(() => {
$(document).keydown((e) => {
switch (e.keyCode) {
case 37:
$('#prev a')[0].click();
break;
case 39:
$('#next a')[0].click();
break;
}
});
});
</script> </script>
{% endblock %} {% endblock %}

View File

@ -25,7 +25,7 @@ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, TemplateView from django.views.generic import DetailView, TemplateView
from django.views.generic.edit import FormMixin, FormView, UpdateView from django.views.generic.edit import FormMixin, FormView, UpdateView
from core.models import Notification, SithFile, User from core.models import SithFile, User
from core.views import CanEditMixin, CanViewMixin from core.views import CanEditMixin, CanViewMixin
from core.views.files import FileView, MultipleImageField, send_file from core.views.files import FileView, MultipleImageField, send_file
from core.views.forms import SelectDate from core.views.forms import SelectDate
@ -127,18 +127,13 @@ class SASMainView(FormView):
return kwargs return kwargs
class PictureView(CanViewMixin, DetailView, FormMixin): class PictureView(CanViewMixin, DetailView):
model = Picture model = Picture
form_class = RelationForm
pk_url_kwarg = "picture_id" pk_url_kwarg = "picture_id"
template_name = "sas/picture.jinja" template_name = "sas/picture.jinja"
def get_initial(self):
return {"picture": self.object}
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
self.object = self.get_object() self.object = self.get_object()
self.form = self.get_form()
if "rotate_right" in request.GET: if "rotate_right" in request.GET:
self.object.rotate(270) self.object.rotate(270)
if "rotate_left" in request.GET: if "rotate_left" in request.GET:
@ -150,51 +145,10 @@ class PictureView(CanViewMixin, DetailView, FormMixin):
return redirect("sas:album", album_id=self.object.parent.id) return redirect("sas:album", album_id=self.object.parent.id)
return super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
self.object = self.get_object()
self.form = self.get_form()
if request.user.is_authenticated and request.user.was_subscribed:
if self.form.is_valid():
for uid in self.form.cleaned_data["users"]:
u = User.objects.filter(id=uid).first()
if not u: # Don't use a non existing user
continue
if PeoplePictureRelation.objects.filter(
user=u, picture=self.form.cleaned_data["picture"]
).exists(): # Avoid existing relation
continue
PeoplePictureRelation(
user=u, picture=self.form.cleaned_data["picture"]
).save()
if not u.notifications.filter(
type="NEW_PICTURES", viewed=False
).exists():
Notification(
user=u,
url=reverse("core:user_pictures", kwargs={"user_id": u.id}),
type="NEW_PICTURES",
).save()
return super().form_valid(self.form)
else:
self.form.add_error(None, _("You do not have the permission to do that"))
return self.form_invalid(self.form)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) return super().get_context_data(**kwargs) | {
pictures_qs = Picture.objects.filter( "album": Album.objects.get(children=self.object)
parent_id=self.object.parent_id }
).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):
return reverse("sas:picture", kwargs={"picture_id": self.object.id})
def send_album(request, album_id): def send_album(request, album_id):

View File

@ -759,4 +759,5 @@ SITH_FRONT_DEP_VERSIONS = {
"https://github.com/vasturiano/three-spritetext": "1.6.5", "https://github.com/vasturiano/three-spritetext": "1.6.5",
"https://github.com/vasturiano/3d-force-graph/": "1.70.19", "https://github.com/vasturiano/3d-force-graph/": "1.70.19",
"https://github.com/vasturiano/d3-force-3d": "3.0.3", "https://github.com/vasturiano/d3-force-3d": "3.0.3",
"https://github.com/select2/select2/": "4.0.13",
} }