5 Commits

Author SHA1 Message Date
thomas girod
d3edcaff14 Merge pull request #1264 from ae-utbm/refactor/user
Refactor some user views
2025-11-26 18:33:35 +01:00
imperosol
8c127a96f7 refactor: user godfathers views 2025-11-25 22:20:43 +01:00
imperosol
55d6e2bbec refactor: PasswordRootChangeView 2025-11-25 20:55:36 +01:00
imperosol
e9fbac8264 test UserPreferencesView 2025-11-25 19:48:45 +01:00
imperosol
1911f2e6dd refactor: remove UserUpdateView.board_only
La variable n'a pas été utilisée depuis 2016
2025-11-25 19:47:52 +01:00
20 changed files with 399 additions and 223 deletions

View File

@@ -211,7 +211,7 @@
</li> </li>
<li> <li>
<i class="fa-solid fa-magnifying-glass fa-xl"></i> <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>
<li> <li>
<i class="fa-solid fa-check-to-slot fa-xl"></i> <i class="fa-solid fa-check-to-slot fa-xl"></i>

View File

@@ -1,4 +1,3 @@
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Annotated, Any from typing import Annotated, Any
@@ -9,12 +8,12 @@ from django.urls import reverse
from django.utils.text import slugify from django.utils.text import slugify
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from haystack.query import SearchQuerySet from haystack.query import SearchQuerySet
from ninja import FilterLookup, FilterSchema, ModelSchema, Schema, UploadedFile from ninja import FilterSchema, ModelSchema, Schema, UploadedFile
from pydantic import AliasChoices, Field, field_validator from pydantic import AliasChoices, Field
from pydantic_core.core_schema import ValidationInfo from pydantic_core.core_schema import ValidationInfo
from core.models import Group, QuickUploadImage, SithFile, User 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)] NonEmptyStr = Annotated[str, MinLen(1)]
@@ -110,11 +109,7 @@ class GroupSchema(ModelSchema):
class UserFilterSchema(FilterSchema): class UserFilterSchema(FilterSchema):
search: Annotated[str, MinLen(1)] | None = None search: Annotated[str, MinLen(1)]
role: Annotated[str, FilterLookup("role__icontains")] | None = None
department: str | None = None
promo: int | None = None
date_of_birth: datetime | None = None
exclude: list[int] | None = Field( exclude: list[int] | None = Field(
None, validation_alias=AliasChoices("exclude", "exclude[]") None, validation_alias=AliasChoices("exclude", "exclude[]")
) )
@@ -143,13 +138,6 @@ class UserFilterSchema(FilterSchema):
return Q() return Q()
return ~Q(id__in=value) 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): class MarkdownSchema(Schema):
text: str text: str

View File

@@ -143,15 +143,6 @@ form {
line-height: 1; line-height: 1;
white-space: nowrap; 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 { .helptext {
margin-top: .25rem; margin-top: .25rem;
margin-bottom: .25rem; margin-bottom: .25rem;

View File

@@ -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 { &-field {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;

View File

@@ -195,8 +195,9 @@
} }
} }
} }
}
&.delete { form .link-like {
margin-top: 10px; margin-top: 10px;
display: block; display: block;
text-align: center; text-align: center;
@@ -209,7 +210,6 @@
} }
} }
} }
}
>a.mini_profile_link { >a.mini_profile_link {
display: none; display: none;

View File

@@ -23,7 +23,7 @@
<details name="navbar" class="menu"> <details name="navbar" class="menu">
<summary class="head">{% trans %}Services{% endtrans %}</summary> <summary class="head">{% trans %}Services{% endtrans %}</summary>
<ul class="content"> <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('core:file_list') }}">{% trans %}Files{% endtrans %}</a></li>
<li><a href="{{ url('pedagogy:guide') }}">{% trans %}Pedagogy{% endtrans %}</a></li> <li><a href="{{ url('pedagogy:guide') }}">{% trans %}Pedagogy{% endtrans %}</a></li>
</ul> </ul>

View File

@@ -78,12 +78,6 @@
{% endif %} {% endif %}
{% endmacro %} {% 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) %} {% macro paginate_alpine(page, nb_pages) %}
{# Add pagination buttons for ajax based content with alpine {# Add pagination buttons for ajax based content with alpine
@@ -157,12 +151,12 @@
{% if current_page.has_previous() %} {% if current_page.has_previous() %}
<a <a
{% if use_htmx -%} {% if use_htmx -%}
hx-get="?{{ querystring(page=current_page.previous_page_number()) }}" hx-get="?page={{ current_page.previous_page_number() }}"
hx-swap="innerHTML" hx-swap="innerHTML"
hx-target="#content" hx-target="#content"
hx-push-url="true" hx-push-url="true"
{%- else -%} {%- else -%}
href="?{{ querystring(page=current_page.previous_page_number()) }}" href="?page={{ current_page.previous_page_number() }}"
{%- endif -%} {%- endif -%}
> >
<button> <button>
@@ -180,12 +174,12 @@
{% else %} {% else %}
<a <a
{% if use_htmx -%} {% if use_htmx -%}
hx-get="?{{ querystring(page=i) }}" hx-get="?page={{ i }}"
hx-swap="innerHTML" hx-swap="innerHTML"
hx-target="#content" hx-target="#content"
hx-push-url="true" hx-push-url="true"
{%- else -%} {%- else -%}
href="?{{ querystring(page=i) }}" href="?page={{ i }}"
{%- endif -%} {%- endif -%}
> >
<button>{{ i }}</button> <button>{{ i }}</button>
@@ -195,12 +189,12 @@
{% if current_page.has_next() %} {% if current_page.has_next() %}
<a <a
{% if use_htmx -%} {% if use_htmx -%}
hx-get="?querystring(page=current_page.next_page_number())" hx-get="?page={{ current_page.next_page_number() }}"
hx-swap="innerHTML" hx-swap="innerHTML"
hx-target="#content" hx-target="#content"
hx-push-url="true" hx-push-url="true"
{%- else -%} {%- else -%}
href="?querystring(page=current_page.next_page_number())" href="?page={{ current_page.next_page_number() }}"
{%- endif -%} {%- endif -%}
><button> ><button>
<i class="fa fa-caret-right"></i> <i class="fa fa-caret-right"></i>
@@ -249,17 +243,3 @@
}"></div> }"></div>
{% endif %} {% endif %}
{% endmacro %} {% endmacro %}
{% macro querystring() %}
{%- for key, values in request.GET.lists() -%}
{%- if key not in kwargs -%}
{%- for value in values -%}
{{ key }}={{ value }}&amp;
{%- endfor -%}
{%- endif -%}
{%- endfor -%}
{%- for key, value in kwargs.items() -%}
{{ key }}={{ value }}&amp;
{%- endfor -%}
{% endmacro %}

View File

@@ -3,7 +3,7 @@
{% block content %} {% block content %}
{% if target %} {% 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 %} {% endif %}
<form method="post" action=""> <form method="post" action="">
{% csrf_token %} {% csrf_token %}

View File

@@ -114,7 +114,7 @@
{# All fields #} {# All fields #}
<div class="fields-centered"> <div class="profile-fields">
{%- for field in form -%} {%- for field in form -%}
{%- if field.name in ["quote","profile_pict","avatar_pict","scrub_pict","is_viewable","forum_signature"] -%} {%- if field.name in ["quote","profile_pict","avatar_pict","scrub_pict","is_viewable","forum_signature"] -%}
{%- continue -%} {%- continue -%}
@@ -133,7 +133,7 @@
</div> </div>
{# Textareas #} {# Textareas #}
<div class="fields-centered"> <div class="profile-fields">
{%- for field in [form.quote, form.forum_signature] -%} {%- for field in [form.quote, form.forum_signature] -%}
<div class="profile-field"> <div class="profile-field">
{{ field.label_tag() }} {{ field.label_tag() }}

View File

@@ -29,7 +29,16 @@
<a href="{{ url('core:user_godfathers', user_id=u.id) }}" class="mini_profile_link"> <a href="{{ url('core:user_godfathers', user_id=u.id) }}" class="mini_profile_link">
{{ u.get_mini_item() | safe }} {{ u.get_mini_item() | safe }}
</a> </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> </li>
{% endfor %} {% endfor %}
</ul> </ul>
@@ -46,7 +55,16 @@
<a href="{{ url('core:user_godfathers', user_id=u.id) }}" class="mini_profile_link"> <a href="{{ url('core:user_godfathers', user_id=u.id) }}" class="mini_profile_link">
{{ u.get_mini_item()|safe }} {{ u.get_mini_item()|safe }}
</a> </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> </li>
{% endfor %} {% endfor %}
</ul> </ul>

View File

@@ -427,6 +427,19 @@ class TestUserQuerySetViewableBy:
assert not viewable.exists() 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 @pytest.mark.django_db
def test_user_stats(client: Client): def test_user_stats(client: Client):
user = subscriber_user.make() user = subscriber_user.make()
@@ -450,3 +463,68 @@ def test_user_stats(client: Client):
client.force_login(user) client.force_login(user)
response = client.get(reverse("core:user_stats", kwargs={"user_id": user.id})) response = client.get(reverse("core:user_stats", kwargs={"user_id": user.id}))
assert response.status_code == 200 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)

View File

@@ -54,6 +54,7 @@ from core.views import (
PagePropView, PagePropView,
PageRevView, PageRevView,
PageView, PageView,
PasswordRootChangeView,
SearchView, SearchView,
SithLoginView, SithLoginView,
SithPasswordChangeDoneView, SithPasswordChangeDoneView,
@@ -80,7 +81,6 @@ from core.views import (
delete_user_godfather, delete_user_godfather,
logout, logout,
notification, notification,
password_root_change,
send_file, send_file,
) )
@@ -100,7 +100,7 @@ urlpatterns = [
path("password_change/", SithPasswordChangeView.as_view(), name="password_change"), path("password_change/", SithPasswordChangeView.as_view(), name="password_change"),
path( path(
"password_change/<int:user_id>/", "password_change/<int:user_id>/",
password_root_change, PasswordRootChangeView.as_view(),
name="password_root_change", name="password_root_change",
), ),
path( path(

View File

@@ -303,7 +303,6 @@ class UserGodfathersForm(forms.Form):
) )
user = forms.ModelChoiceField( user = forms.ModelChoiceField(
label=_("Select user"), label=_("Select user"),
help_text=None,
required=True, required=True,
widget=AutoCompleteSelectUser, widget=AutoCompleteSelectUser,
queryset=User.objects.all(), queryset=User.objects.all(),
@@ -315,8 +314,6 @@ class UserGodfathersForm(forms.Form):
def clean_user(self): def clean_user(self):
other_user = self.cleaned_data.get("user") other_user = self.cleaned_data.get("user")
if not other_user:
raise ValidationError(_("This user does not exist"))
if other_user == self.target_user: if other_user == self.target_user:
raise ValidationError(_("You cannot be related to yourself")) raise ValidationError(_("You cannot be related to yourself"))
return other_user return other_user

View File

@@ -29,8 +29,9 @@ from operator import itemgetter
from smtplib import SMTPException from smtplib import SMTPException
from django.contrib.auth import login, views from django.contrib.auth import login, views
from django.contrib.auth.forms import PasswordChangeForm from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.forms import PasswordChangeForm, SetPasswordForm
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db.models import DateField, F, QuerySet, Sum from django.db.models import DateField, F, QuerySet, Sum
from django.db.models.functions import Trunc 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.http import Http404
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.template.response import TemplateResponse
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.utils.safestring import SafeString from django.utils.safestring import SafeString
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django.views.decorators.http import require_POST
from django.views.generic import ( from django.views.generic import (
CreateView, CreateView,
DeleteView, DeleteView,
@@ -98,21 +99,23 @@ def logout(request):
return views.logout_then_login(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.""" """Allows a root user to change someone's password."""
if not request.user.is_root:
raise PermissionDenied template_name = "core/password_change.jinja"
user = get_object_or_404(User, id=user_id) form_class = SetPasswordForm
if request.method == "POST": success_url = reverse_lazy("core:password_change_done")
form = views.SetPasswordForm(user=user, data=request.POST)
if form.is_valid(): 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() form.save()
return redirect("core:password_change_done") return super().form_valid(form)
else:
form = views.SetPasswordForm(user=user)
return TemplateResponse(
request, "core/password_change.jinja", {"form": form, "target": user}
)
@method_decorator(check_honeypot, name="post") @method_decorator(check_honeypot, name="post")
@@ -287,10 +290,12 @@ class UserView(UserTabsMixin, CanViewMixin, DetailView):
return kwargs return kwargs
@require_POST
@login_required
def delete_user_godfather(request, user_id, godfather_id, is_father): def delete_user_godfather(request, user_id, godfather_id, is_father):
user_is_admin = request.user.is_root or request.user.is_board_member user_is_admin = request.user.is_root or request.user.is_board_member
if user_id != request.user.id and not user_is_admin: if user_id != request.user.id and not user_is_admin:
raise PermissionDenied() raise PermissionDenied
user = get_object_or_404(User, id=user_id) user = get_object_or_404(User, id=user_id)
to_remove = get_object_or_404(User, id=godfather_id) to_remove = get_object_or_404(User, id=godfather_id)
if is_father: if is_father:
@@ -417,7 +422,6 @@ class UserUpdateProfileView(UserTabsMixin, CanEditMixin, UpdateView):
form_class = UserProfileForm form_class = UserProfileForm
current_tab = "edit" current_tab = "edit"
edit_once = ["profile_pict", "date_of_birth", "first_name", "last_name"] edit_once = ["profile_pict", "date_of_birth", "first_name", "last_name"]
board_only = []
def remove_restricted_fields(self, request): def remove_restricted_fields(self, request):
"""Removes edit_once and board_only fields.""" """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 request.user.is_board_member or request.user.is_root
): ):
self.form.fields.pop(i, None) 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): def get(self, request, *args, **kwargs):
self.object = self.get_object() self.object = self.get_object()
@@ -480,10 +481,10 @@ class UserPreferencesView(UserTabsMixin, UseFragmentsMixin, CanEditMixin, Update
current_tab = "prefs" current_tab = "prefs"
def get_form_kwargs(self): def get_form_kwargs(self):
kwargs = super().get_form_kwargs() return super().get_form_kwargs() | {"instance": self.object.preferences}
pref = self.object.preferences
kwargs.update({"instance": pref}) def get_success_url(self):
return kwargs return self.request.path
def get_fragment_context_data(self) -> dict[str, SafeString]: def get_fragment_context_data(self) -> dict[str, SafeString]:
# Avoid cyclic import error # Avoid cyclic import error

View File

@@ -6,7 +6,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "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" "PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n" "Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@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" msgid "This citizen has not yet joined the galaxy"
msgstr "Ce citoyen n'a pas encore rejoint la galaxie" 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 #: matmat/templates/matmat/search_form.jinja
msgid "Search user" msgid "Search user"
msgstr "Rechercher un utilisateur" msgstr "Rechercher un utilisateur"
@@ -4396,6 +4392,22 @@ msgstr "Rechercher un utilisateur"
msgid "Results" msgid "Results"
msgstr "Résultats" 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 #: pedagogy/forms.py
msgid "Do not vote" msgid "Do not vote"
msgstr "Ne pas voter" msgstr "Ne pas voter"

View File

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

View File

@@ -1,13 +1,12 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{% from "core/macros.jinja" import user_mini_profile, paginate_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 %} {% block title %}
{% trans %}Search user{% endtrans %} {% trans %}Search user{% endtrans %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{% if paginator.count > 0 %} {% if result_exists %}
<h2>{% trans %}Results{% endtrans %}</h2> <h2>{% trans %}Results{% endtrans %}</h2>
<div class="matmat_results"> <div class="matmat_results">
{% for user in object_list %} {% for user in object_list %}
@@ -25,12 +24,42 @@
<hr> <hr>
{% endif %} {% endif %}
<h2>{% trans %}Search user{% endtrans %}</h2> <h2>{% trans %}Search user{% endtrans %}</h2>
<form action="{{ url('matmat:search') }}" method="get"> <h3>{% trans %}Search by profile{% endtrans %}</h3>
<fieldset class="fields-centered"> <form action="{{ url('matmat:search') }}" method="post" enctype="multipart/form-data">
{{ form }} {% csrf_token %}
</fieldset> {% for field in form %}
<div class="fields-centered"> {% if field.name not in ('phone', 'quick') %}
<input class="btn btn-blue" type="submit" value="{% trans %}Search{% endtrans %}" /> <p>
</div> {{ 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> </form>
{% endblock %} {% endblock %}
{% block script %}
{{ super() }}
{% endblock %}

View File

@@ -1,35 +1 @@
# Create your tests here. # 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]]

View File

@@ -23,8 +23,16 @@
from django.urls import path from django.urls import path
from matmat.views import MatmatronchView from matmat.views import (
SearchClearFormView,
SearchNormalFormView,
SearchQuickFormView,
SearchReverseFormView,
)
urlpatterns = [ 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"),
] ]

View File

@@ -20,44 +20,191 @@
# Place - Suite 330, Boston, MA 02111-1307, USA. # 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.db.models import F
from django.views.generic import ListView from django.http.response import HttpResponseRedirect
from django.views.generic.edit import FormMixin 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.auth.mixins import FormerSubscriberMixin
from core.models import User, UserQuerySet from core.models import User
from core.schemas import UserFilterSchema 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 model = User
paginate_by = 20 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
ordering = ["-id"]
paginate_by = 12
template_name = "matmat/search_form.jinja" template_name = "matmat/search_form.jinja"
form_class = SearchForm
def get(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
self.form = self.get_form() self.form_class = kwargs["form"]
return super().get(request, *args, **kwargs) 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): self.init_query = self.model.objects
return self.request.GET 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): return super().dispatch(request, *args, **kwargs)
res = super().get_form_kwargs()
if self.request.GET:
res["data"] = self.request.GET
return res
def get_queryset(self) -> UserQuerySet: def post(self, request, *args, **kwargs):
if not self.form.is_valid(): return self.get(request, *args, **kwargs)
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 get_context_data(self, **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"))