5 Commits

Author SHA1 Message Date
imperosol
a68f16ba9d add tests 2025-11-30 19:12:37 +01:00
imperosol
1a99f4096e make matmatronch form more readable 2025-11-30 19:12:37 +01:00
imperosol
559a904e0d refactor: Matmatronch 2025-11-30 19:11:51 +01:00
imperosol
fca6a58c5e feat: querystring jinja macro 2025-11-30 16:55:44 +01:00
imperosol
39c3e11d88 extract matmat forms into their own file 2025-11-29 14:48:30 +01:00
21 changed files with 236 additions and 424 deletions

View File

@@ -4,16 +4,15 @@ from dateutil.relativedelta import relativedelta
from django.conf import settings from django.conf import settings
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
from django.contrib.syndication.views import add_domain from django.contrib.syndication.views import add_domain
from django.db.models import Count, OuterRef, QuerySet, Subquery from django.db.models import F, QuerySet
from django.http import HttpRequest from django.http import HttpRequest
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from ical.calendar import Calendar from ical.calendar import Calendar
from ical.calendar_stream import IcsCalendarStream from ical.calendar_stream import IcsCalendarStream
from ical.event import Event from ical.event import Event
from ical.types import Frequency, Recur
from com.models import News, NewsDate from com.models import NewsDate
from core.models import User from core.models import User
@@ -43,9 +42,9 @@ class IcsCalendar:
with open(cls._INTERNAL_CALENDAR, "wb") as f: with open(cls._INTERNAL_CALENDAR, "wb") as f:
_ = f.write( _ = f.write(
cls.ics_from_queryset( cls.ics_from_queryset(
News.objects.filter( NewsDate.objects.filter(
is_published=True, news__is_published=True,
dates__end_date__gte=timezone.now() - relativedelta(months=6), end_date__gte=timezone.now() - (relativedelta(months=6)),
) )
) )
) )
@@ -54,35 +53,24 @@ class IcsCalendar:
@classmethod @classmethod
def get_unpublished(cls, user: User) -> bytes: def get_unpublished(cls, user: User) -> bytes:
return cls.ics_from_queryset( return cls.ics_from_queryset(
News.objects.viewable_by(user).filter( NewsDate.objects.viewable_by(user).filter(
is_published=False, news__is_published=False,
dates__end_date__gte=timezone.now() - relativedelta(months=6), end_date__gte=timezone.now() - (relativedelta(months=6)),
) ),
) )
@classmethod @classmethod
def ics_from_queryset(cls, queryset: QuerySet[News]) -> bytes: def ics_from_queryset(cls, queryset: QuerySet[NewsDate]) -> bytes:
calendar = Calendar() calendar = Calendar()
date_subquery = NewsDate.objects.filter(news=OuterRef("pk")).order_by( for news_date in queryset.annotate(news_title=F("news__title")):
"start_date"
)
queryset = queryset.annotate(
start=Subquery(date_subquery.values("start_date")[:1]),
end=Subquery(date_subquery.values("end_date")[:1]),
nb_dates=Count("dates"),
)
for news in queryset:
event = Event( event = Event(
summary=news.title, summary=news_date.news_title,
description=news.summary, start=news_date.start_date,
dtstart=news.start, end=news_date.end_date,
dtend=news.end,
url=as_absolute_url( url=as_absolute_url(
reverse("com:news_detail", kwargs={"news_id": news.id}) reverse("com:news_detail", kwargs={"news_id": news_date.news_id})
), ),
) )
if news.nb_dates > 1:
event.rrule = Recur(freq=Frequency.WEEKLY, count=news.nb_dates)
calendar.events.append(event) calendar.events.append(event)
return IcsCalendarStream.calendar_to_ics(calendar).encode("utf-8") return IcsCalendarStream.calendar_to_ics(calendar).encode("utf-8")

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_clear") }}">{% trans %}Matmatronch{% endtrans %}</a> <a href="{{ url("matmat:search") }}">{% 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,3 +1,4 @@
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Annotated, Any from typing import Annotated, Any
@@ -8,12 +9,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 FilterSchema, ModelSchema, Schema, UploadedFile from ninja import FilterLookup, FilterSchema, ModelSchema, Schema, UploadedFile
from pydantic import AliasChoices, Field from pydantic import AliasChoices, Field, field_validator
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 is_image from core.utils import get_last_promo, is_image
NonEmptyStr = Annotated[str, MinLen(1)] NonEmptyStr = Annotated[str, MinLen(1)]
@@ -109,7 +110,11 @@ class GroupSchema(ModelSchema):
class UserFilterSchema(FilterSchema): class UserFilterSchema(FilterSchema):
search: Annotated[str, MinLen(1)] 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
exclude: list[int] | None = Field( exclude: list[int] | None = Field(
None, validation_alias=AliasChoices("exclude", "exclude[]") None, validation_alias=AliasChoices("exclude", "exclude[]")
) )
@@ -138,6 +143,13 @@ 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,6 +143,15 @@ 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,15 +114,6 @@
} }
} }
&-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,9 +195,8 @@
} }
} }
} }
}
form .link-like { &.delete {
margin-top: 10px; margin-top: 10px;
display: block; display: block;
text-align: center; text-align: center;
@@ -210,6 +209,7 @@
} }
} }
} }
}
>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_clear') }}">{% trans %}Matmatronch{% endtrans %}</a></li> <li><a href="{{ url('matmat:search') }}">{% 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,6 +78,12 @@
{% 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
@@ -151,12 +157,12 @@
{% if current_page.has_previous() %} {% if current_page.has_previous() %}
<a <a
{% if use_htmx -%} {% if use_htmx -%}
hx-get="?page={{ current_page.previous_page_number() }}" hx-get="?{{ querystring(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="?page={{ current_page.previous_page_number() }}" href="?{{ querystring(page=current_page.previous_page_number()) }}"
{%- endif -%} {%- endif -%}
> >
<button> <button>
@@ -174,12 +180,12 @@
{% else %} {% else %}
<a <a
{% if use_htmx -%} {% if use_htmx -%}
hx-get="?page={{ i }}" hx-get="?{{ querystring(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="?page={{ i }}" href="?{{ querystring(page=i) }}"
{%- endif -%} {%- endif -%}
> >
<button>{{ i }}</button> <button>{{ i }}</button>
@@ -189,12 +195,12 @@
{% if current_page.has_next() %} {% if current_page.has_next() %}
<a <a
{% if use_htmx -%} {% if use_htmx -%}
hx-get="?page={{ current_page.next_page_number() }}" hx-get="?querystring(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="?page={{ current_page.next_page_number() }}" href="?querystring(page=current_page.next_page_number())"
{%- endif -%} {%- endif -%}
><button> ><button>
<i class="fa fa-caret-right"></i> <i class="fa fa-caret-right"></i>
@@ -243,3 +249,17 @@
}"></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=form.user.get_display_name() %}Change password for {{ user }}{% endtrans %}</p> <p>{% trans user=target.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="profile-fields"> <div class="fields-centered">
{%- 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="profile-fields"> <div class="fields-centered">
{%- 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,16 +29,7 @@
<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>
{% if user == profile or user.is_root or user.is_board_member %} {{ delete_godfather(user, profile, u, True) }}
<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>
@@ -55,16 +46,7 @@
<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>
{% if user == profile or user.is_root or user.is_board_member %} {{ delete_godfather(user, profile, u, False) }}
<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,19 +427,6 @@ 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()
@@ -463,68 +450,3 @@ 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,7 +54,6 @@ from core.views import (
PagePropView, PagePropView,
PageRevView, PageRevView,
PageView, PageView,
PasswordRootChangeView,
SearchView, SearchView,
SithLoginView, SithLoginView,
SithPasswordChangeDoneView, SithPasswordChangeDoneView,
@@ -81,6 +80,7 @@ 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>/",
PasswordRootChangeView.as_view(), password_root_change,
name="password_root_change", name="password_root_change",
), ),
path( path(

View File

@@ -303,6 +303,7 @@ 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(),
@@ -314,6 +315,8 @@ 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,9 +29,8 @@ 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.decorators import login_required from django.contrib.auth.forms import PasswordChangeForm
from django.contrib.auth.forms import PasswordChangeForm, SetPasswordForm from django.contrib.auth.mixins import LoginRequiredMixin
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
@@ -39,11 +38,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,
@@ -99,23 +98,21 @@ def logout(request):
return views.logout_then_login(request) return views.logout_then_login(request)
class PasswordRootChangeView(UserPassesTestMixin, FormView): def password_root_change(request, user_id):
"""Allows a root user to change someone's password.""" """Allows a root user to change someone's password."""
if not request.user.is_root:
template_name = "core/password_change.jinja" raise PermissionDenied
form_class = SetPasswordForm user = get_object_or_404(User, id=user_id)
success_url = reverse_lazy("core:password_change_done") if request.method == "POST":
form = views.SetPasswordForm(user=user, data=request.POST)
def test_func(self): if form.is_valid():
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 super().form_valid(form) return redirect("core:password_change_done")
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")
@@ -290,12 +287,10 @@ 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:
@@ -422,6 +417,7 @@ 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."""
@@ -430,6 +426,9 @@ 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()
@@ -481,10 +480,10 @@ class UserPreferencesView(UserTabsMixin, UseFragmentsMixin, CanEditMixin, Update
current_tab = "prefs" current_tab = "prefs"
def get_form_kwargs(self): def get_form_kwargs(self):
return super().get_form_kwargs() | {"instance": self.object.preferences} kwargs = super().get_form_kwargs()
pref = self.object.preferences
def get_success_url(self): kwargs.update({"instance": pref})
return self.request.path return kwargs
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-24 11:05+0100\n" "POT-Creation-Date: 2025-11-30 18:23+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,6 +4384,10 @@ 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"
@@ -4392,22 +4396,6 @@ 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"

48
matmat/forms.py Normal file
View File

@@ -0,0 +1,48 @@
#
# 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,12 +1,13 @@
{% 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 result_exists %} {% if paginator.count > 0 %}
<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 %}
@@ -24,42 +25,12 @@
<hr> <hr>
{% endif %} {% endif %}
<h2>{% trans %}Search user{% endtrans %}</h2> <h2>{% trans %}Search user{% endtrans %}</h2>
<h3>{% trans %}Search by profile{% endtrans %}</h3> <form action="{{ url('matmat:search') }}" method="get">
<form action="{{ url('matmat:search') }}" method="post" enctype="multipart/form-data"> <fieldset class="fields-centered">
{% csrf_token %} {{ form }}
{% for field in form %} </fieldset>
{% if field.name not in ('phone', 'quick') %} <div class="fields-centered">
<p> <input class="btn btn-blue" type="submit" value="{% trans %}Search{% endtrans %}" />
{{ field.errors }} </div>
<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 +1,35 @@
# 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,16 +23,8 @@
from django.urls import path from django.urls import path
from matmat.views import ( from matmat.views import MatmatronchView
SearchClearFormView,
SearchNormalFormView,
SearchQuickFormView,
SearchReverseFormView,
)
urlpatterns = [ urlpatterns = [
path("", SearchNormalFormView.as_view(), name="search"), path("", MatmatronchView.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,191 +20,44 @@
# 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.http.response import HttpResponseRedirect from django.views.generic import ListView
from django.urls import reverse from django.views.generic.edit import FormMixin
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 from core.models import User, UserQuerySet
from core.schemas import UserFilterSchema from core.schemas import UserFilterSchema
from core.views.forms import SelectDate from matmat.forms import SearchForm
# Enum to select search type
class SearchType(Enum): class MatmatronchView(FormerSubscriberMixin, FormMixin, ListView):
NORMAL = 1
REVERSE = 2
QUICK = 3
# Custom form
class SearchForm(forms.ModelForm):
class Meta:
model = User model = User
fields = [ paginate_by = 20
"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"
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")
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)
return super().dispatch(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
return self.get(request, *args, **kwargs)
def get_context_data(self, **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 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): def get(self, request, *args, **kwargs):
view = SearchFormListView.as_view() self.form = self.get_form()
return view(request, *args, **kwargs) return super().get(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): def get_initial(self):
init = self.session.get("matmat_search_form", {}) return self.request.GET
if not init:
init["department"] = ""
return init
def get_form_kwargs(self):
res = super().get_form_kwargs()
if self.request.GET:
res["data"] = self.request.GET
return res
class SearchNormalFormView(SearchFormView): def get_queryset(self) -> UserQuerySet:
search_type = SearchType.NORMAL 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 get_context_data(self, **kwargs):
class SearchReverseFormView(SearchFormView): return super().get_context_data(form=self.form, **kwargs)
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"))