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
from django.conf import settings
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.pagination import PageNumberPaginationExtra
from ninja_extra.schemas import PaginatedResponseSchema
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.schemas import (
FamilyGodfatherSchema,
MarkdownSchema,
UserFamilySchema,
UserFilterSchema,
UserProfileSchema,
)
from core.templatetags.renderer import markdown
@ -38,6 +43,18 @@ class MailingListController(ControllerBase):
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)]
DEFAULT_DEPTH = 4

View File

@ -42,6 +42,8 @@ from django.http import HttpRequest
from ninja_extra import ControllerBase
from ninja_extra.permissions import BasePermission
from counter.models import Counter
class IsInGroup(BasePermission):
"""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
) -> bool:
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
def get_query(self, q, request):
users = search_user(q)
return [user.customer for user in users]
return list(Customer.objects.filter(user__in=search_user(q)))
def format_match(self, obj):
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 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
@ -12,10 +19,6 @@ class SimpleUserSchema(ModelSchema):
fields = ["id", "nick_name", "first_name", "last_name"]
class MarkdownSchema(Schema):
text: str
class UserProfileSchema(ModelSchema):
"""The necessary information to show a user profile"""
@ -42,6 +45,42 @@ class UserProfileSchema(ModelSchema):
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):
godfather: int
godchild: int

View File

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

View File

@ -616,36 +616,36 @@ a:not(.button) {
}
.select2 {
.select2 {
margin: 0;
max-width: 100%;
min-width: 100%;
ul {
margin: 0;
max-width: 100%;
min-width: 100%;
}
ul {
margin: 0;
}
textarea {
background-color: inherit;
}
textarea {
background-color: inherit;
}
.select2-container--default {
color: black;
}
}
.select2-results {
.select-item {
display: flex;
flex-direction: row;
gap: 10px;
align-items: center;
.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%;
}
img {
max-height: 40px;
border-radius: 50%;
}
}
}
#news_details {
display: inline-block;

View File

@ -11,6 +11,7 @@
<link rel="stylesheet" href="{{ scss('core/markdown.scss') }}">
<link rel="stylesheet" href="{{ scss('core/header.scss') }}">
<link rel="stylesheet" href="{{ scss('core/navbar.scss') }}">
<link rel="stylesheet" href="{{ static('core/select2/select2.min.css') }}">
{% block jquery_css %}
{# 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>
<!-- Put here to always have acces to those functions on django widgets -->
<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_js %}{% endblock %}

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)