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

@ -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

@ -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) < 4:
# 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

@ -227,7 +227,6 @@ function remote_data_source(source, options) {
if (!!options.overrides) { if (!!options.overrides) {
Object.assign(params, options.overrides); Object.assign(params, options.overrides);
} }
console.log(params);
return { ajax: params }; return { ajax: params };
} }

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('core/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('core/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

@ -1,16 +1,22 @@
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
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")
@ -48,6 +54,40 @@ class PicturesController(ControllerBase):
.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})
},
)
@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):
@ -36,12 +37,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

@ -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') }}"> <link rel="stylesheet" href="{{ scss('sas/css/picture.scss') }}">
{%- endblock -%} {%- endblock -%}
{%- block additional_js -%}
<script src="{{ static("sas/js/relation.js") }}"></script>
{%- endblock -%}
{% block title %} {% block title %}
{% trans %}SAS{% endtrans %} {% trans %}SAS{% endtrans %}
{% endblock %} {% endblock %}
@ -51,7 +55,7 @@
</div> </div>
{% endif %} {% endif %}
<div class="container"> <div class="container" id="pict">
<div class="main"> <div class="main">
<div class="photo"> <div class="photo">
@ -101,41 +105,46 @@
</div> </div>
<div class="subsection"> <div class="subsection">
<div class="navigation"> <div class="navigation" x-data>
<div id="prev"> <div id="prev">
{% if previous_pict %} {% 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> <div style="background-image: url('{{ previous_pict.get_download_thumb_url() }}');"></div>
</a> </a>
{% endif %} {% endif %}
</div> </div>
<div id="next"> <div id="next">
{% if next_pict %} {% 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> <div style="background-image: url('{{ next_pict.get_download_thumb_url() }}');"></div>
</a> </a>
{% endif %} {% endif %}
</div> </div>
</div> </div>
<div class="tags"> <div class="tags" x-data="user_identification" x-cloak>
<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" >
{% csrf_token %} {% csrf_token %}
{{ form.as_p() }} <select x-ref="search" multiple="multiple"></select>
<input type="submit" value="{% trans %}Go{% endtrans %}"/> <input type="submit" value="{% trans %}Go{% endtrans %}"/>
</form> </form>{% endif %}
{% endif %} <ul>
<ul x-data="user_identification"> <template x-for="identification in identifications" :key="identification.id">
<template x-for="item in items" :key="item.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)">❌</a>
</template> </template>
</li> </li>
</template> </template>
@ -148,53 +157,8 @@
{% block script %} {% block script %}
{{ super() }} {{ super() }}
<script> <script>
document.addEventListener("alpine:init", () => { const picture_id = {{ picture.id }};
Alpine.data("user_identification", () => ({ const user_id = {{ user.id }};
items: [ const user_is_sas_admin = {{ (user.is_root or user.is_in_group(pk = settings.SITH_GROUP_SAS_ADMIN_ID))|tojson }}
{%- 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;
}
});
});
</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,41 +145,11 @@ 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) kwargs = super().get_context_data(**kwargs)
pictures_qs = Picture.objects.filter( pictures_qs = Picture.objects.filter(
parent_id=self.object.parent_id parent_id=self.object.parent_id
).viewable_by(self.request.user) ).viewable_by(self.request.user)
kwargs["form"] = self.form
kwargs["next_pict"] = ( kwargs["next_pict"] = (
pictures_qs.filter(id__gt=self.object.id).order_by("id").first() pictures_qs.filter(id__gt=self.object.id).order_by("id").first()
) )
@ -193,9 +158,6 @@ class PictureView(CanViewMixin, DetailView, FormMixin):
) )
return kwargs 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):
return send_file(request, album_id, Album) return send_file(request, album_id, Album)