mirror of
https://github.com/ae-utbm/sith.git
synced 2025-12-13 02:41:20 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3edcaff14 | ||
|
|
8c127a96f7 | ||
|
|
55d6e2bbec | ||
|
|
e9fbac8264 | ||
|
|
1911f2e6dd |
@@ -211,7 +211,7 @@
|
||||
</li>
|
||||
<li>
|
||||
<i class="fa-solid fa-magnifying-glass fa-xl"></i>
|
||||
<a href="{{ url("matmat:search") }}">{% trans %}Matmatronch{% endtrans %}</a>
|
||||
<a href="{{ url("matmat:search_clear") }}">{% trans %}Matmatronch{% endtrans %}</a>
|
||||
</li>
|
||||
<li>
|
||||
<i class="fa-solid fa-check-to-slot fa-xl"></i>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any
|
||||
|
||||
@@ -9,12 +8,12 @@ from django.urls import reverse
|
||||
from django.utils.text import slugify
|
||||
from django.utils.translation import gettext as _
|
||||
from haystack.query import SearchQuerySet
|
||||
from ninja import FilterLookup, FilterSchema, ModelSchema, Schema, UploadedFile
|
||||
from pydantic import AliasChoices, Field, field_validator
|
||||
from ninja import FilterSchema, ModelSchema, Schema, UploadedFile
|
||||
from pydantic import AliasChoices, Field
|
||||
from pydantic_core.core_schema import ValidationInfo
|
||||
|
||||
from core.models import Group, QuickUploadImage, SithFile, User
|
||||
from core.utils import get_last_promo, is_image
|
||||
from core.utils import is_image
|
||||
|
||||
NonEmptyStr = Annotated[str, MinLen(1)]
|
||||
|
||||
@@ -110,11 +109,7 @@ class GroupSchema(ModelSchema):
|
||||
|
||||
|
||||
class UserFilterSchema(FilterSchema):
|
||||
search: Annotated[str, MinLen(1)] | None = None
|
||||
role: Annotated[str, FilterLookup("role__icontains")] | None = None
|
||||
department: str | None = None
|
||||
promo: int | None = None
|
||||
date_of_birth: datetime | None = None
|
||||
search: Annotated[str, MinLen(1)]
|
||||
exclude: list[int] | None = Field(
|
||||
None, validation_alias=AliasChoices("exclude", "exclude[]")
|
||||
)
|
||||
@@ -143,13 +138,6 @@ class UserFilterSchema(FilterSchema):
|
||||
return Q()
|
||||
return ~Q(id__in=value)
|
||||
|
||||
@field_validator("promo", mode="after")
|
||||
@classmethod
|
||||
def validate_promo(cls, value: int) -> int:
|
||||
if not 0 < value <= get_last_promo():
|
||||
raise ValueError(f"{value} is not a valid promo")
|
||||
return value
|
||||
|
||||
|
||||
class MarkdownSchema(Schema):
|
||||
text: str
|
||||
|
||||
@@ -143,15 +143,6 @@ form {
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
|
||||
.fields-centered {
|
||||
padding: 10px 10px 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--nf-input-size) 10px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.helptext {
|
||||
margin-top: .25rem;
|
||||
margin-bottom: .25rem;
|
||||
|
||||
@@ -114,6 +114,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
&-fields {
|
||||
padding: 10px 10px 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--nf-input-size) 10px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&-field {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -195,18 +195,18 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.delete {
|
||||
margin-top: 10px;
|
||||
display: block;
|
||||
text-align: center;
|
||||
color: orangered;
|
||||
form .link-like {
|
||||
margin-top: 10px;
|
||||
display: block;
|
||||
text-align: center;
|
||||
color: orangered;
|
||||
|
||||
@media (max-width: 375px) {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
@media (max-width: 375px) {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<details name="navbar" class="menu">
|
||||
<summary class="head">{% trans %}Services{% endtrans %}</summary>
|
||||
<ul class="content">
|
||||
<li><a href="{{ url('matmat:search') }}">{% trans %}Matmatronch{% endtrans %}</a></li>
|
||||
<li><a href="{{ url('matmat:search_clear') }}">{% trans %}Matmatronch{% endtrans %}</a></li>
|
||||
<li><a href="{{ url('core:file_list') }}">{% trans %}Files{% endtrans %}</a></li>
|
||||
<li><a href="{{ url('pedagogy:guide') }}">{% trans %}Pedagogy{% endtrans %}</a></li>
|
||||
</ul>
|
||||
|
||||
@@ -78,12 +78,6 @@
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro delete_godfather(user, profile, godfather, is_father) %}
|
||||
{% if user == profile or user.is_root or user.is_board_member %}
|
||||
<a class="delete" href="{{ url("core:user_godfathers_delete", user_id=profile.id, godfather_id=godfather.id, is_father=is_father) }}">{% trans %}Delete{% endtrans %}</a>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro paginate_alpine(page, nb_pages) %}
|
||||
{# Add pagination buttons for ajax based content with alpine
|
||||
|
||||
@@ -157,12 +151,12 @@
|
||||
{% if current_page.has_previous() %}
|
||||
<a
|
||||
{% if use_htmx -%}
|
||||
hx-get="?{{ querystring(page=current_page.previous_page_number()) }}"
|
||||
hx-get="?page={{ current_page.previous_page_number() }}"
|
||||
hx-swap="innerHTML"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
{%- else -%}
|
||||
href="?{{ querystring(page=current_page.previous_page_number()) }}"
|
||||
href="?page={{ current_page.previous_page_number() }}"
|
||||
{%- endif -%}
|
||||
>
|
||||
<button>
|
||||
@@ -180,12 +174,12 @@
|
||||
{% else %}
|
||||
<a
|
||||
{% if use_htmx -%}
|
||||
hx-get="?{{ querystring(page=i) }}"
|
||||
hx-get="?page={{ i }}"
|
||||
hx-swap="innerHTML"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
{%- else -%}
|
||||
href="?{{ querystring(page=i) }}"
|
||||
href="?page={{ i }}"
|
||||
{%- endif -%}
|
||||
>
|
||||
<button>{{ i }}</button>
|
||||
@@ -195,12 +189,12 @@
|
||||
{% if current_page.has_next() %}
|
||||
<a
|
||||
{% if use_htmx -%}
|
||||
hx-get="?querystring(page=current_page.next_page_number())"
|
||||
hx-get="?page={{ current_page.next_page_number() }}"
|
||||
hx-swap="innerHTML"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
{%- else -%}
|
||||
href="?querystring(page=current_page.next_page_number())"
|
||||
href="?page={{ current_page.next_page_number() }}"
|
||||
{%- endif -%}
|
||||
><button>
|
||||
<i class="fa fa-caret-right"></i>
|
||||
@@ -249,17 +243,3 @@
|
||||
}"></div>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{% macro querystring() %}
|
||||
{%- for key, values in request.GET.lists() -%}
|
||||
{%- if key not in kwargs -%}
|
||||
{%- for value in values -%}
|
||||
{{ key }}={{ value }}&
|
||||
{%- endfor -%}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{%- for key, value in kwargs.items() -%}
|
||||
{{ key }}={{ value }}&
|
||||
{%- endfor -%}
|
||||
{% endmacro %}
|
||||
@@ -3,7 +3,7 @@
|
||||
{% block content %}
|
||||
|
||||
{% if target %}
|
||||
<p>{% trans user=target.get_display_name() %}Change password for {{ user }}{% endtrans %}</p>
|
||||
<p>{% trans user=form.user.get_display_name() %}Change password for {{ user }}{% endtrans %}</p>
|
||||
{% endif %}
|
||||
<form method="post" action="">
|
||||
{% csrf_token %}
|
||||
|
||||
@@ -114,7 +114,7 @@
|
||||
|
||||
|
||||
{# All fields #}
|
||||
<div class="fields-centered">
|
||||
<div class="profile-fields">
|
||||
{%- for field in form -%}
|
||||
{%- if field.name in ["quote","profile_pict","avatar_pict","scrub_pict","is_viewable","forum_signature"] -%}
|
||||
{%- continue -%}
|
||||
@@ -133,7 +133,7 @@
|
||||
</div>
|
||||
|
||||
{# Textareas #}
|
||||
<div class="fields-centered">
|
||||
<div class="profile-fields">
|
||||
{%- for field in [form.quote, form.forum_signature] -%}
|
||||
<div class="profile-field">
|
||||
{{ field.label_tag() }}
|
||||
|
||||
@@ -29,7 +29,16 @@
|
||||
<a href="{{ url('core:user_godfathers', user_id=u.id) }}" class="mini_profile_link">
|
||||
{{ u.get_mini_item() | safe }}
|
||||
</a>
|
||||
{{ delete_godfather(user, profile, u, True) }}
|
||||
{% if user == profile or user.is_root or user.is_board_member %}
|
||||
<form
|
||||
method="post"
|
||||
class="no-margin"
|
||||
action="{{ url("core:user_godfathers_delete", user_id=profile.id, godfather_id=u.id, is_father=True) }}"
|
||||
>
|
||||
{% csrf_token %}
|
||||
<input type="submit" class="link-like" value="{% trans %}Delete{% endtrans %}">
|
||||
</form>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
@@ -46,7 +55,16 @@
|
||||
<a href="{{ url('core:user_godfathers', user_id=u.id) }}" class="mini_profile_link">
|
||||
{{ u.get_mini_item()|safe }}
|
||||
</a>
|
||||
{{ delete_godfather(user, profile, u, False) }}
|
||||
{% if user == profile or user.is_root or user.is_board_member %}
|
||||
<form
|
||||
method="post"
|
||||
class="no-margin"
|
||||
action="{{ url("core:user_godfathers_delete", user_id=profile.id, godfather_id=u.id, is_father=False) }}"
|
||||
>
|
||||
{% csrf_token %}
|
||||
<input type="submit" class="link-like link-red" value="{% trans %}Delete{% endtrans %}">
|
||||
</form>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
@@ -427,6 +427,19 @@ class TestUserQuerySetViewableBy:
|
||||
assert not viewable.exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_user_preferences(client: Client):
|
||||
user = subscriber_user.make()
|
||||
client.force_login(user)
|
||||
url = reverse("core:user_prefs", kwargs={"user_id": user.id})
|
||||
response = client.get(url)
|
||||
assert response.status_code == 200
|
||||
response = client.post(url, {"notify_on_click": "true"})
|
||||
assertRedirects(response, url)
|
||||
user.preferences.refresh_from_db()
|
||||
assert user.preferences.notify_on_click is True
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_user_stats(client: Client):
|
||||
user = subscriber_user.make()
|
||||
@@ -450,3 +463,68 @@ def test_user_stats(client: Client):
|
||||
client.force_login(user)
|
||||
response = client.get(reverse("core:user_stats", kwargs={"user_id": user.id}))
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestChangeUserPassword:
|
||||
def test_as_root(self, client: Client, admin_user: User):
|
||||
client.force_login(admin_user)
|
||||
user = subscriber_user.make()
|
||||
url = reverse("core:password_root_change", kwargs={"user_id": user.id})
|
||||
response = client.get(url)
|
||||
assert response.status_code == 200
|
||||
response = client.post(
|
||||
url, {"new_password1": "poutou", "new_password2": "poutou"}
|
||||
)
|
||||
assertRedirects(response, reverse("core:password_change_done"))
|
||||
user.refresh_from_db()
|
||||
assert user.check_password("poutou") is True
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestUserGodfather:
|
||||
@pytest.mark.parametrize("godfather", [True, False])
|
||||
def test_add_family(self, client: Client, godfather):
|
||||
user = subscriber_user.make()
|
||||
other_user = subscriber_user.make()
|
||||
client.force_login(user)
|
||||
url = reverse("core:user_godfathers", kwargs={"user_id": user.id})
|
||||
response = client.get(url)
|
||||
assert response.status_code == 200
|
||||
response = client.post(
|
||||
url,
|
||||
{"type": "godfather" if godfather else "godchild", "user": other_user.id},
|
||||
)
|
||||
assertRedirects(response, url)
|
||||
if godfather:
|
||||
assert user.godfathers.contains(other_user)
|
||||
else:
|
||||
assert user.godchildren.contains(other_user)
|
||||
|
||||
def test_tree(self, client: Client):
|
||||
user = subscriber_user.make()
|
||||
client.force_login(user)
|
||||
response = client.get(
|
||||
reverse("core:user_godfathers_tree", kwargs={"user_id": user.id})
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_remove_family(self, client: Client):
|
||||
user = subscriber_user.make()
|
||||
other_user = subscriber_user.make()
|
||||
user.godfathers.add(other_user)
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
reverse(
|
||||
"core:user_godfathers_delete",
|
||||
kwargs={
|
||||
"user_id": user.id,
|
||||
"godfather_id": other_user.id,
|
||||
"is_father": True,
|
||||
},
|
||||
)
|
||||
)
|
||||
assertRedirects(
|
||||
response, reverse("core:user_godfathers", kwargs={"user_id": user.id})
|
||||
)
|
||||
assert not user.godfathers.contains(other_user)
|
||||
|
||||
@@ -54,6 +54,7 @@ from core.views import (
|
||||
PagePropView,
|
||||
PageRevView,
|
||||
PageView,
|
||||
PasswordRootChangeView,
|
||||
SearchView,
|
||||
SithLoginView,
|
||||
SithPasswordChangeDoneView,
|
||||
@@ -80,7 +81,6 @@ from core.views import (
|
||||
delete_user_godfather,
|
||||
logout,
|
||||
notification,
|
||||
password_root_change,
|
||||
send_file,
|
||||
)
|
||||
|
||||
@@ -100,7 +100,7 @@ urlpatterns = [
|
||||
path("password_change/", SithPasswordChangeView.as_view(), name="password_change"),
|
||||
path(
|
||||
"password_change/<int:user_id>/",
|
||||
password_root_change,
|
||||
PasswordRootChangeView.as_view(),
|
||||
name="password_root_change",
|
||||
),
|
||||
path(
|
||||
|
||||
@@ -303,7 +303,6 @@ class UserGodfathersForm(forms.Form):
|
||||
)
|
||||
user = forms.ModelChoiceField(
|
||||
label=_("Select user"),
|
||||
help_text=None,
|
||||
required=True,
|
||||
widget=AutoCompleteSelectUser,
|
||||
queryset=User.objects.all(),
|
||||
@@ -315,8 +314,6 @@ class UserGodfathersForm(forms.Form):
|
||||
|
||||
def clean_user(self):
|
||||
other_user = self.cleaned_data.get("user")
|
||||
if not other_user:
|
||||
raise ValidationError(_("This user does not exist"))
|
||||
if other_user == self.target_user:
|
||||
raise ValidationError(_("You cannot be related to yourself"))
|
||||
return other_user
|
||||
|
||||
@@ -29,8 +29,9 @@ from operator import itemgetter
|
||||
from smtplib import SMTPException
|
||||
|
||||
from django.contrib.auth import login, views
|
||||
from django.contrib.auth.forms import PasswordChangeForm
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.auth.forms import PasswordChangeForm, SetPasswordForm
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db.models import DateField, F, QuerySet, Sum
|
||||
from django.db.models.functions import Trunc
|
||||
@@ -38,11 +39,11 @@ from django.forms.models import modelform_factory
|
||||
from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.template.loader import render_to_string
|
||||
from django.template.response import TemplateResponse
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.safestring import SafeString
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.decorators.http import require_POST
|
||||
from django.views.generic import (
|
||||
CreateView,
|
||||
DeleteView,
|
||||
@@ -98,21 +99,23 @@ def logout(request):
|
||||
return views.logout_then_login(request)
|
||||
|
||||
|
||||
def password_root_change(request, user_id):
|
||||
class PasswordRootChangeView(UserPassesTestMixin, FormView):
|
||||
"""Allows a root user to change someone's password."""
|
||||
if not request.user.is_root:
|
||||
raise PermissionDenied
|
||||
user = get_object_or_404(User, id=user_id)
|
||||
if request.method == "POST":
|
||||
form = views.SetPasswordForm(user=user, data=request.POST)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
return redirect("core:password_change_done")
|
||||
else:
|
||||
form = views.SetPasswordForm(user=user)
|
||||
return TemplateResponse(
|
||||
request, "core/password_change.jinja", {"form": form, "target": user}
|
||||
)
|
||||
|
||||
template_name = "core/password_change.jinja"
|
||||
form_class = SetPasswordForm
|
||||
success_url = reverse_lazy("core:password_change_done")
|
||||
|
||||
def test_func(self):
|
||||
return self.request.user.is_root
|
||||
|
||||
def get_form_kwargs(self):
|
||||
user = get_object_or_404(User, id=self.kwargs["user_id"])
|
||||
return super().get_form_kwargs() | {"user": user}
|
||||
|
||||
def form_valid(self, form: SetPasswordForm):
|
||||
form.save()
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
@method_decorator(check_honeypot, name="post")
|
||||
@@ -287,10 +290,12 @@ class UserView(UserTabsMixin, CanViewMixin, DetailView):
|
||||
return kwargs
|
||||
|
||||
|
||||
@require_POST
|
||||
@login_required
|
||||
def delete_user_godfather(request, user_id, godfather_id, is_father):
|
||||
user_is_admin = request.user.is_root or request.user.is_board_member
|
||||
if user_id != request.user.id and not user_is_admin:
|
||||
raise PermissionDenied()
|
||||
raise PermissionDenied
|
||||
user = get_object_or_404(User, id=user_id)
|
||||
to_remove = get_object_or_404(User, id=godfather_id)
|
||||
if is_father:
|
||||
@@ -417,7 +422,6 @@ class UserUpdateProfileView(UserTabsMixin, CanEditMixin, UpdateView):
|
||||
form_class = UserProfileForm
|
||||
current_tab = "edit"
|
||||
edit_once = ["profile_pict", "date_of_birth", "first_name", "last_name"]
|
||||
board_only = []
|
||||
|
||||
def remove_restricted_fields(self, request):
|
||||
"""Removes edit_once and board_only fields."""
|
||||
@@ -426,9 +430,6 @@ class UserUpdateProfileView(UserTabsMixin, CanEditMixin, UpdateView):
|
||||
request.user.is_board_member or request.user.is_root
|
||||
):
|
||||
self.form.fields.pop(i, None)
|
||||
for i in self.board_only:
|
||||
if not (request.user.is_board_member or request.user.is_root):
|
||||
self.form.fields.pop(i, None)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
@@ -480,10 +481,10 @@ class UserPreferencesView(UserTabsMixin, UseFragmentsMixin, CanEditMixin, Update
|
||||
current_tab = "prefs"
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
pref = self.object.preferences
|
||||
kwargs.update({"instance": pref})
|
||||
return kwargs
|
||||
return super().get_form_kwargs() | {"instance": self.object.preferences}
|
||||
|
||||
def get_success_url(self):
|
||||
return self.request.path
|
||||
|
||||
def get_fragment_context_data(self) -> dict[str, SafeString]:
|
||||
# Avoid cyclic import error
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-30 18:23+0100\n"
|
||||
"POT-Creation-Date: 2025-11-24 11:05+0100\n"
|
||||
"PO-Revision-Date: 2016-07-18\n"
|
||||
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
|
||||
"Language-Team: AE info <ae.info@utbm.fr>\n"
|
||||
@@ -4384,10 +4384,6 @@ msgstr "Galaxie de %(user_name)s"
|
||||
msgid "This citizen has not yet joined the galaxy"
|
||||
msgstr "Ce citoyen n'a pas encore rejoint la galaxie"
|
||||
|
||||
#: matmat/forms.py
|
||||
msgid "Last/First name or nickname"
|
||||
msgstr "Nom de famille, prénom ou surnom"
|
||||
|
||||
#: matmat/templates/matmat/search_form.jinja
|
||||
msgid "Search user"
|
||||
msgstr "Rechercher un utilisateur"
|
||||
@@ -4396,6 +4392,22 @@ msgstr "Rechercher un utilisateur"
|
||||
msgid "Results"
|
||||
msgstr "Résultats"
|
||||
|
||||
#: matmat/templates/matmat/search_form.jinja
|
||||
msgid "Search by profile"
|
||||
msgstr "Recherche par profil"
|
||||
|
||||
#: matmat/templates/matmat/search_form.jinja
|
||||
msgid "Inverted search"
|
||||
msgstr "Recherche inversée"
|
||||
|
||||
#: matmat/templates/matmat/search_form.jinja
|
||||
msgid "Quick search"
|
||||
msgstr "Recherche rapide"
|
||||
|
||||
#: matmat/views.py
|
||||
msgid "Last/First name or nickname"
|
||||
msgstr "Nom de famille, prénom ou surnom"
|
||||
|
||||
#: pedagogy/forms.py
|
||||
msgid "Do not vote"
|
||||
msgstr "Ne pas voter"
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
#
|
||||
# Copyright 2025
|
||||
# - Maréchal <thomas.girod@utbm.fr>
|
||||
#
|
||||
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
|
||||
# http://ae.utbm.fr.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify it under
|
||||
# the terms of the GNU General Public License a published by the Free Software
|
||||
# Foundation; either version 3 of the License, or (at your option) any later
|
||||
# version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License along with
|
||||
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
|
||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
||||
#
|
||||
#
|
||||
from typing import Any
|
||||
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from core.models import User
|
||||
from core.views.forms import SelectDate
|
||||
|
||||
|
||||
class SearchForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ["promo", "role", "department", "semester", "date_of_birth"]
|
||||
widgets = {"date_of_birth": SelectDate}
|
||||
|
||||
name = forms.CharField(
|
||||
label=_("Last/First name or nickname"), min_length=1, max_length=255
|
||||
)
|
||||
field_order = ["name", "promo", "role", "department", "semester", "date_of_birth"]
|
||||
|
||||
def __init__(self, *args, initial: dict[str, Any], **kwargs):
|
||||
super().__init__(*args, initial=initial, **kwargs)
|
||||
for key in self.fields:
|
||||
self.fields[key].required = False
|
||||
if key not in initial:
|
||||
self.fields[key].initial = None
|
||||
@@ -1,13 +1,12 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
{% from "core/macros.jinja" import user_mini_profile, paginate_jinja %}
|
||||
{% from "core/macros.jinja" import user_mini_profile, paginate_jinja with context %}
|
||||
|
||||
{% block title %}
|
||||
{% trans %}Search user{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if paginator.count > 0 %}
|
||||
{% if result_exists %}
|
||||
<h2>{% trans %}Results{% endtrans %}</h2>
|
||||
<div class="matmat_results">
|
||||
{% for user in object_list %}
|
||||
@@ -25,12 +24,42 @@
|
||||
<hr>
|
||||
{% endif %}
|
||||
<h2>{% trans %}Search user{% endtrans %}</h2>
|
||||
<form action="{{ url('matmat:search') }}" method="get">
|
||||
<fieldset class="fields-centered">
|
||||
{{ form }}
|
||||
</fieldset>
|
||||
<div class="fields-centered">
|
||||
<input class="btn btn-blue" type="submit" value="{% trans %}Search{% endtrans %}" />
|
||||
</div>
|
||||
<h3>{% trans %}Search by profile{% endtrans %}</h3>
|
||||
<form action="{{ url('matmat:search') }}" method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
{% for field in form %}
|
||||
{% if field.name not in ('phone', 'quick') %}
|
||||
<p>
|
||||
{{ field.errors }}
|
||||
<label for="{{ field.id_for_label }}">{{ field.label }}</label>
|
||||
{{ field }}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<p><input type="submit" value="{% trans %}Search{% endtrans %}" /></p>
|
||||
</form>
|
||||
<h3>{% trans %}Inverted search{% endtrans %}</h3>
|
||||
<form action="{{ url('matmat:search_reverse') }}" method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<p>
|
||||
{{ form.phone.errors }}
|
||||
<label for="{{ form.phone.id_for_label }}">{{ form.phone.label }}</label>
|
||||
{{ form.phone }}
|
||||
<p><input type="submit" value="{% trans %}Search{% endtrans %}" /></p>
|
||||
</p>
|
||||
</form>
|
||||
<h3>{% trans %}Quick search{% endtrans %}</h3>
|
||||
<form action="{{ url('matmat:search_quick') }}" method="post">
|
||||
{% csrf_token %}
|
||||
<p>
|
||||
{{ form.quick.errors }}
|
||||
<label for="{{ form.quick.id_for_label }}">{{ form.quick.label }}</label>
|
||||
{{ form.quick }}
|
||||
<p><input type="submit" value="{% trans %}Search{% endtrans %}" /></p>
|
||||
</p>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
{{ super() }}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,35 +1 @@
|
||||
# Create your tests here.
|
||||
|
||||
from django.core.management import call_command
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from model_bakery import baker
|
||||
|
||||
from com.models import News
|
||||
from core.baker_recipes import subscriber_user
|
||||
from core.models import User
|
||||
|
||||
|
||||
class TestMatmatronch(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
News.objects.all().delete()
|
||||
User.objects.all().delete()
|
||||
users = [
|
||||
baker.prepare(User, promo=17),
|
||||
baker.prepare(User, promo=17),
|
||||
baker.prepare(User, promo=17, department="INFO"),
|
||||
baker.prepare(User, promo=18, department="INFO"),
|
||||
]
|
||||
cls.users = User.objects.bulk_create(users)
|
||||
call_command("update_index", "core", "--remove")
|
||||
|
||||
def test_search(self):
|
||||
self.client.force_login(subscriber_user.make())
|
||||
response = self.client.get(reverse("matmat:search"))
|
||||
assert response.status_code == 200
|
||||
response = self.client.get(
|
||||
reverse("matmat:search", query={"promo": 17, "department": "INFO"})
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert list(response.context_data["object_list"]) == [self.users[2]]
|
||||
|
||||
@@ -23,8 +23,16 @@
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from matmat.views import MatmatronchView
|
||||
from matmat.views import (
|
||||
SearchClearFormView,
|
||||
SearchNormalFormView,
|
||||
SearchQuickFormView,
|
||||
SearchReverseFormView,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path("", MatmatronchView.as_view(), name="search"),
|
||||
path("", SearchNormalFormView.as_view(), name="search"),
|
||||
path("reverse/", SearchReverseFormView.as_view(), name="search_reverse"),
|
||||
path("quick/", SearchQuickFormView.as_view(), name="search_quick"),
|
||||
path("clear/", SearchClearFormView.as_view(), name="search_clear"),
|
||||
]
|
||||
|
||||
199
matmat/views.py
199
matmat/views.py
@@ -20,44 +20,191 @@
|
||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
||||
#
|
||||
#
|
||||
from ast import literal_eval
|
||||
from enum import Enum
|
||||
|
||||
from django import forms
|
||||
from django.db.models import F
|
||||
from django.views.generic import ListView
|
||||
from django.views.generic.edit import FormMixin
|
||||
from django.http.response import HttpResponseRedirect
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import ListView, View
|
||||
from django.views.generic.detail import SingleObjectMixin
|
||||
from django.views.generic.edit import FormView
|
||||
from phonenumber_field.widgets import RegionalPhoneNumberWidget
|
||||
|
||||
from core.auth.mixins import FormerSubscriberMixin
|
||||
from core.models import User, UserQuerySet
|
||||
from core.models import User
|
||||
from core.schemas import UserFilterSchema
|
||||
from matmat.forms import SearchForm
|
||||
from core.views.forms import SelectDate
|
||||
|
||||
# Enum to select search type
|
||||
|
||||
|
||||
class MatmatronchView(FormerSubscriberMixin, FormMixin, ListView):
|
||||
class SearchType(Enum):
|
||||
NORMAL = 1
|
||||
REVERSE = 2
|
||||
QUICK = 3
|
||||
|
||||
|
||||
# Custom form
|
||||
|
||||
|
||||
class SearchForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
"first_name",
|
||||
"last_name",
|
||||
"nick_name",
|
||||
"role",
|
||||
"department",
|
||||
"semester",
|
||||
"promo",
|
||||
"date_of_birth",
|
||||
"phone",
|
||||
]
|
||||
widgets = {
|
||||
"date_of_birth": SelectDate,
|
||||
"phone": RegionalPhoneNumberWidget,
|
||||
}
|
||||
|
||||
quick = forms.CharField(label=_("Last/First name or nickname"), max_length=255)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
for key in self.fields:
|
||||
self.fields[key].required = False
|
||||
|
||||
@property
|
||||
def cleaned_data_json(self):
|
||||
data = self.cleaned_data
|
||||
for key, val in data.items():
|
||||
if key in ("date_of_birth", "phone") and val is not None:
|
||||
data[key] = str(val)
|
||||
return data
|
||||
|
||||
|
||||
# Views
|
||||
|
||||
|
||||
class SearchFormListView(FormerSubscriberMixin, SingleObjectMixin, ListView):
|
||||
model = User
|
||||
paginate_by = 20
|
||||
ordering = ["-id"]
|
||||
paginate_by = 12
|
||||
template_name = "matmat/search_form.jinja"
|
||||
form_class = SearchForm
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
self.form = self.get_form()
|
||||
return super().get(request, *args, **kwargs)
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
self.form_class = kwargs["form"]
|
||||
self.search_type = kwargs["search_type"]
|
||||
self.session = request.session
|
||||
self.last_search = self.session.get("matmat_search_result", str([]))
|
||||
self.last_search = literal_eval(self.last_search)
|
||||
self.valid_form = kwargs.get("valid_form")
|
||||
|
||||
def get_initial(self):
|
||||
return self.request.GET
|
||||
self.init_query = self.model.objects
|
||||
self.can_see_hidden = True
|
||||
if not (request.user.is_board_member or request.user.is_root):
|
||||
self.can_see_hidden = False
|
||||
self.init_query = self.init_query.filter(is_viewable=True)
|
||||
|
||||
def get_form_kwargs(self):
|
||||
res = super().get_form_kwargs()
|
||||
if self.request.GET:
|
||||
res["data"] = self.request.GET
|
||||
return res
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get_queryset(self) -> UserQuerySet:
|
||||
if not self.form.is_valid():
|
||||
return User.objects.none()
|
||||
data = self.form.cleaned_data
|
||||
data["search"] = data.get("name")
|
||||
filters = UserFilterSchema(**{key: val for key, val in data.items() if val})
|
||||
qs = User.objects.viewable_by(self.request.user).select_related("profile_pict")
|
||||
return filters.filter(qs).order_by(F("last_login").desc(nulls_last=True))
|
||||
def post(self, request, *args, **kwargs):
|
||||
return self.get(request, *args, **kwargs)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return super().get_context_data(form=self.form, **kwargs)
|
||||
self.object = None
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
kwargs["form"] = self.form_class
|
||||
kwargs["result_exists"] = self.result_exists
|
||||
return kwargs
|
||||
|
||||
def get_queryset(self):
|
||||
q = self.init_query
|
||||
if self.valid_form is not None:
|
||||
if self.search_type == SearchType.REVERSE:
|
||||
q = q.filter(phone=self.valid_form["phone"]).all()
|
||||
elif self.search_type == SearchType.QUICK:
|
||||
if self.valid_form["quick"].strip():
|
||||
q = list(
|
||||
UserFilterSchema(search=self.valid_form["quick"])
|
||||
.filter(User.objects.viewable_by(self.request.user))
|
||||
.order_by(F("last_login").desc(nulls_last=True))
|
||||
)
|
||||
else:
|
||||
q = []
|
||||
else:
|
||||
search_dict = {}
|
||||
for key, value in self.valid_form.items():
|
||||
if key not in ("phone", "quick") and not (
|
||||
value == "" or value is None
|
||||
):
|
||||
search_dict[key + "__icontains"] = value
|
||||
q = q.filter(**search_dict).all()
|
||||
else:
|
||||
q = q.filter(pk__in=self.last_search).all()
|
||||
if isinstance(q, list):
|
||||
self.result_exists = len(q) > 0
|
||||
else:
|
||||
self.result_exists = q.exists()
|
||||
self.last_search = []
|
||||
for user in q:
|
||||
self.last_search.append(user.id)
|
||||
self.session["matmat_search_result"] = str(self.last_search)
|
||||
return q
|
||||
|
||||
|
||||
class SearchFormView(FormerSubscriberMixin, FormView):
|
||||
"""Allows users to search inside the user list."""
|
||||
|
||||
form_class = SearchForm
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
self.session = request.session
|
||||
self.init_query = User.objects
|
||||
kwargs["form"] = self.get_form()
|
||||
kwargs["search_type"] = self.search_type
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
view = SearchFormListView.as_view()
|
||||
return view(request, *args, **kwargs)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
form = self.get_form()
|
||||
view = SearchFormListView.as_view()
|
||||
if form.is_valid():
|
||||
kwargs["valid_form"] = form.clean()
|
||||
request.session["matmat_search_form"] = form.cleaned_data_json
|
||||
return view(request, *args, **kwargs)
|
||||
|
||||
def get_initial(self):
|
||||
init = self.session.get("matmat_search_form", {})
|
||||
if not init:
|
||||
init["department"] = ""
|
||||
return init
|
||||
|
||||
|
||||
class SearchNormalFormView(SearchFormView):
|
||||
search_type = SearchType.NORMAL
|
||||
|
||||
|
||||
class SearchReverseFormView(SearchFormView):
|
||||
search_type = SearchType.REVERSE
|
||||
|
||||
|
||||
class SearchQuickFormView(SearchFormView):
|
||||
search_type = SearchType.QUICK
|
||||
|
||||
|
||||
class SearchClearFormView(FormerSubscriberMixin, View):
|
||||
"""Clear SearchFormView and redirect to it."""
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
super().dispatch(request, *args, **kwargs)
|
||||
if "matmat_search_form" in request.session:
|
||||
request.session.pop("matmat_search_form")
|
||||
if "matmat_search_result" in request.session:
|
||||
request.session.pop("matmat_search_result")
|
||||
return HttpResponseRedirect(reverse("matmat:search"))
|
||||
|
||||
Reference in New Issue
Block a user