Use select2 for user picture identification

This commit is contained in:
thomas girod
2024-08-01 19:02:29 +02:00
parent b0d7bbbb79
commit 48f605dbe0
12 changed files with 235 additions and 151 deletions

View File

@ -1,16 +1,22 @@
from django.conf import settings
from django.db.models import F
from django.urls import reverse
from ninja import Query
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.permissions import IsAuthenticated
from ninja_extra.schemas import PaginatedResponseSchema
from pydantic import NonNegativeInt
from core.models import User
from core.api_permissions import CanView
from core.models import Notification, User
from sas.models import PeoplePictureRelation, Picture
from sas.schemas import PictureFilterSchema, PictureSchema
from sas.schemas import (
IdentifiedUserSchema,
PictureFilterSchema,
PictureSchema,
)
@api_controller("/sas/picture")
@ -48,6 +54,40 @@ class PicturesController(ControllerBase):
.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})
},
)
@api_controller("/sas/relation", tags="User identification on SAS pictures")
class UsersIdentifiedController(ControllerBase):

View File

@ -3,7 +3,8 @@ from datetime import datetime
from ninja import FilterSchema, ModelSchema, Schema
from pydantic import Field, NonNegativeInt
from sas.models import PeoplePictureRelation, Picture
from core.schemas import UserProfileSchema
from sas.models import Picture
class PictureFilterSchema(FilterSchema):
@ -36,12 +37,11 @@ class PictureSchema(ModelSchema):
return obj.get_download_thumb_url()
class PictureCreateRelationSchema(Schema):
user_id: NonNegativeInt
picture_id: NonNegativeInt
class PictureRelationCreationSchema(Schema):
picture: NonNegativeInt
users: list[NonNegativeInt]
class CreatedPictureRelationSchema(ModelSchema):
class Meta:
model = PeoplePictureRelation
fields = ["id", "user", "picture"]
class IdentifiedUserSchema(Schema):
id: int
user: UserProfileSchema

View File

@ -0,0 +1,46 @@
document.addEventListener("alpine:init", () => {
Alpine.data("user_identification", () => ({
identifications: [],
selector: undefined,
async init() {
this.identifications = await (
await fetch(`/api/sas/picture/${picture_id}/identified`)
).json();
this.selector = sithSelect2({
element: $(this.$refs.search),
data_source: remote_data_source("/api/user/search", {
excluded: () => [...this.identifications.map((i) => i.user.id)],
result_converter: (obj) => Object({ ...obj, text: obj.display_name }),
}),
picture_getter: (user) => user.profile_pict,
});
},
async submit_identification() {
const url = `/api/sas/picture/${picture_id}/identified`;
await fetch(url, {
method: "PUT",
body: JSON.stringify(this.selector.val().map((i) => parseInt(i))),
});
// refresh the identified users list
this.identifications = await (await fetch(url)).json();
this.selector.empty().trigger("change");
},
can_be_removed(item) {
return user_is_sas_admin || item.user.id === user_id;
},
async remove(item) {
const res = await fetch(`/api/sas/relation/${item.id}`, {
method: "DELETE",
});
if (res.ok) {
this.identifications = this.identifications.filter(
(i) => i.id !== item.id,
);
}
},
}));
});

View File

@ -4,6 +4,10 @@
<link rel="stylesheet" href="{{ scss('sas/css/picture.scss') }}">
{%- endblock -%}
{%- block additional_js -%}
<script src="{{ static("sas/js/relation.js") }}"></script>
{%- endblock -%}
{% block title %}
{% trans %}SAS{% endtrans %}
{% endblock %}
@ -51,7 +55,7 @@
</div>
{% endif %}
<div class="container">
<div class="container" id="pict">
<div class="main">
<div class="photo">
@ -101,41 +105,46 @@
</div>
<div class="subsection">
<div class="navigation">
<div class="navigation" x-data>
<div id="prev">
{% if previous_pict %}
<a href="{{ url( 'sas:picture', picture_id=previous_pict.id) }}#pict">
<a
href="{{ url( 'sas:picture', picture_id=previous_pict.id) }}#pict"
@keyup.left.window="$el.click()"
>
<div style="background-image: url('{{ previous_pict.get_download_thumb_url() }}');"></div>
</a>
{% endif %}
</div>
<div id="next">
{% if next_pict %}
<a href="{{ url( 'sas:picture', picture_id=next_pict.id) }}#pict">
<a
href="{{ url( 'sas:picture', picture_id=next_pict.id) }}#pict"
@keyup.right.window="$el.click()"
>
<div style="background-image: url('{{ next_pict.get_download_thumb_url() }}');"></div>
</a>
{% endif %}
</div>
</div>
<div class="tags">
<div class="tags" x-data="user_identification" x-cloak>
<h5>{% trans %}People{% endtrans %}</h5>
{% 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">
<form @submit.prevent="submit_identification" >
{% csrf_token %}
<select x-ref="search" multiple="multiple"></select>
<input type="submit" value="{% trans %}Go{% endtrans %}"/>
</form>{% endif %}
<ul>
<template x-for="identification in identifications" :key="identification.id">
<li>
<a class="user" :href="item.user.url">
<img class="profile-pic" :src="item.user.picture" alt="image de profil"/>
<span x-text="item.user.name"></span>
<a class="user" :href="identification.user.profile_url">
<img class="profile-pic" :src="identification.user.profile_pict" alt="image de profil"/>
<span x-text="identification.user.display_name"></span>
</a>
<template x-if="can_be_removed(item)">
<a class="delete clickable" @click="remove(item)">❌</a>
<template x-if="can_be_removed(identification)">
<a class="delete clickable" @click="remove(identification)">❌</a>
</template>
</li>
</template>
@ -148,53 +157,8 @@
{% block script %}
{{ super() }}
<script>
document.addEventListener("alpine:init", () => {
Alpine.data("user_identification", () => ({
items: [
{%- for r in picture.people.select_related("user", "user__profile_pict") -%}
{
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;
}
});
});
const picture_id = {{ picture.id }};
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 }}
</script>
{% 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.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.files import FileView, MultipleImageField, send_file
from core.views.forms import SelectDate
@ -127,18 +127,13 @@ class SASMainView(FormView):
return kwargs
class PictureView(CanViewMixin, DetailView, FormMixin):
class PictureView(CanViewMixin, DetailView):
model = Picture
form_class = RelationForm
pk_url_kwarg = "picture_id"
template_name = "sas/picture.jinja"
def get_initial(self):
return {"picture": self.object}
def get(self, request, *args, **kwargs):
self.object = self.get_object()
self.form = self.get_form()
if "rotate_right" in request.GET:
self.object.rotate(270)
if "rotate_left" in request.GET:
@ -150,41 +145,11 @@ class PictureView(CanViewMixin, DetailView, FormMixin):
return redirect("sas:album", album_id=self.object.parent.id)
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):
kwargs = super().get_context_data(**kwargs)
pictures_qs = Picture.objects.filter(
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()
)
@ -193,9 +158,6 @@ class PictureView(CanViewMixin, DetailView, FormMixin):
)
return kwargs
def get_success_url(self):
return reverse("sas:picture", kwargs={"picture_id": self.object.id})
def send_album(request, album_id):
return send_file(request, album_id, Album)