mirror of
https://github.com/ae-utbm/sith.git
synced 2025-12-21 07:13:21 +00:00
Compare commits
14 Commits
dependabot
...
fix-counte
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53a3dc0060 | ||
|
|
32570ee03d | ||
|
|
2fa3597722 | ||
|
d484971dad
|
|||
|
f24e39ccb7
|
|||
| 3a57439d6e | |||
|
|
fbe5c741d1 | ||
|
749cd067da
|
|||
|
|
1abfbeb76c | ||
|
|
a68f16ba9d | ||
|
|
1a99f4096e | ||
|
|
559a904e0d | ||
|
|
fca6a58c5e | ||
|
|
39c3e11d88 |
@@ -4,15 +4,16 @@ from dateutil.relativedelta import relativedelta
|
||||
from django.conf import settings
|
||||
from django.contrib.sites.models import Site
|
||||
from django.contrib.syndication.views import add_domain
|
||||
from django.db.models import F, QuerySet
|
||||
from django.db.models import Count, OuterRef, QuerySet, Subquery
|
||||
from django.http import HttpRequest
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from ical.calendar import Calendar
|
||||
from ical.calendar_stream import IcsCalendarStream
|
||||
from ical.event import Event
|
||||
from ical.types import Frequency, Recur
|
||||
|
||||
from com.models import NewsDate
|
||||
from com.models import News, NewsDate
|
||||
from core.models import User
|
||||
|
||||
|
||||
@@ -42,9 +43,9 @@ class IcsCalendar:
|
||||
with open(cls._INTERNAL_CALENDAR, "wb") as f:
|
||||
_ = f.write(
|
||||
cls.ics_from_queryset(
|
||||
NewsDate.objects.filter(
|
||||
news__is_published=True,
|
||||
end_date__gte=timezone.now() - (relativedelta(months=6)),
|
||||
News.objects.filter(
|
||||
is_published=True,
|
||||
dates__end_date__gte=timezone.now() - relativedelta(months=6),
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -53,24 +54,35 @@ class IcsCalendar:
|
||||
@classmethod
|
||||
def get_unpublished(cls, user: User) -> bytes:
|
||||
return cls.ics_from_queryset(
|
||||
NewsDate.objects.viewable_by(user).filter(
|
||||
news__is_published=False,
|
||||
end_date__gte=timezone.now() - (relativedelta(months=6)),
|
||||
),
|
||||
News.objects.viewable_by(user).filter(
|
||||
is_published=False,
|
||||
dates__end_date__gte=timezone.now() - relativedelta(months=6),
|
||||
)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def ics_from_queryset(cls, queryset: QuerySet[NewsDate]) -> bytes:
|
||||
def ics_from_queryset(cls, queryset: QuerySet[News]) -> bytes:
|
||||
calendar = Calendar()
|
||||
for news_date in queryset.annotate(news_title=F("news__title")):
|
||||
date_subquery = NewsDate.objects.filter(news=OuterRef("pk")).order_by(
|
||||
"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(
|
||||
summary=news_date.news_title,
|
||||
start=news_date.start_date,
|
||||
end=news_date.end_date,
|
||||
summary=news.title,
|
||||
description=news.summary,
|
||||
dtstart=news.start,
|
||||
dtend=news.end,
|
||||
url=as_absolute_url(
|
||||
reverse("com:news_detail", kwargs={"news_id": news_date.news_id})
|
||||
reverse("com:news_detail", kwargs={"news_id": news.id})
|
||||
),
|
||||
)
|
||||
if news.nb_dates > 1:
|
||||
event.rrule = Recur(freq=Frequency.WEEKLY, count=news.nb_dates)
|
||||
calendar.events.append(event)
|
||||
|
||||
return IcsCalendarStream.calendar_to_ics(calendar).encode("utf-8")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { makeUrl } from "#core:utils/api";
|
||||
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
|
||||
import { Calendar, type EventClickArg } from "@fullcalendar/core";
|
||||
import { Calendar, type EventClickArg, type EventContentArg } from "@fullcalendar/core";
|
||||
import type { EventImpl } from "@fullcalendar/core/internal";
|
||||
import enLocale from "@fullcalendar/core/locales/en-gb";
|
||||
import frLocale from "@fullcalendar/core/locales/fr";
|
||||
@@ -25,6 +25,11 @@ export class IcsCalendar extends inheritHtmlElement("div") {
|
||||
private canDelete = false;
|
||||
private helpUrl = "";
|
||||
|
||||
// Hack variable to detect recurring events
|
||||
// The underlying ics library doesn't include any info about rrules
|
||||
// That's why we have to detect those events ourselves
|
||||
private recurrenceMap: Map<string, EventImpl> = new Map();
|
||||
|
||||
attributeChangedCallback(name: string, _oldValue?: string, newValue?: string) {
|
||||
if (name === "locale") {
|
||||
this.locale = newValue;
|
||||
@@ -95,6 +100,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
|
||||
|
||||
refreshEvents() {
|
||||
this.click(); // Remove focus from popup
|
||||
this.recurrenceMap.clear(); // Avoid double detection of the same non recurring event
|
||||
this.calendar.refetchEvents();
|
||||
}
|
||||
|
||||
@@ -153,12 +159,24 @@ export class IcsCalendar extends inheritHtmlElement("div") {
|
||||
}
|
||||
|
||||
async getEventSources() {
|
||||
const tagRecurringEvents = (eventData: EventImpl) => {
|
||||
// This functions tags events with a similar event url
|
||||
// We rely on the fact that the event url is always the same
|
||||
// for recurring events and always different for single events
|
||||
const firstEvent = this.recurrenceMap.get(eventData.url);
|
||||
if (firstEvent !== undefined) {
|
||||
eventData.extendedProps.isRecurring = true;
|
||||
firstEvent.extendedProps.isRecurring = true; // Don't forget the first event
|
||||
}
|
||||
this.recurrenceMap.set(eventData.url, eventData);
|
||||
};
|
||||
return [
|
||||
{
|
||||
url: `${await makeUrl(calendarCalendarInternal)}`,
|
||||
format: "ics",
|
||||
className: "internal",
|
||||
cache: false,
|
||||
eventDataTransform: tagRecurringEvents,
|
||||
},
|
||||
{
|
||||
url: `${await makeUrl(calendarCalendarUnpublished)}`,
|
||||
@@ -166,6 +184,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
|
||||
color: "red",
|
||||
className: "unpublished",
|
||||
cache: false,
|
||||
eventDataTransform: tagRecurringEvents,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -361,6 +380,14 @@ export class IcsCalendar extends inheritHtmlElement("div") {
|
||||
event.jsEvent.preventDefault();
|
||||
this.createEventDetailPopup(event);
|
||||
},
|
||||
eventClassNames: (classNamesEvent: EventContentArg) => {
|
||||
const classes: string[] = [];
|
||||
if (classNamesEvent.event.extendedProps?.isRecurring) {
|
||||
classes.push("recurring");
|
||||
}
|
||||
|
||||
return classes;
|
||||
},
|
||||
});
|
||||
this.calendar.render();
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
--event-details-border-radius: 4px;
|
||||
--event-details-box-shadow: 0px 6px 20px 4px rgb(0 0 0 / 16%);
|
||||
--event-details-max-width: 600px;
|
||||
--event-recurring-internal-color: #6f69cd;
|
||||
--event-recurring-unpublished-color: orange;
|
||||
}
|
||||
|
||||
ics-calendar {
|
||||
@@ -147,3 +149,28 @@ ics-calendar {
|
||||
opacity: 0;
|
||||
transition: opacity 500ms ease-out;
|
||||
}
|
||||
|
||||
// We have to override the color set by the lib in the html
|
||||
// Hence the !important tag everywhere
|
||||
.internal.recurring {
|
||||
.fc-daygrid-event-dot {
|
||||
border-color: var(--event-recurring-internal-color) !important;
|
||||
}
|
||||
|
||||
&.fc-daygrid-block-event {
|
||||
background-color: var(--event-recurring-internal-color) !important;
|
||||
border-color: var(--event-recurring-internal-color) !important;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.unpublished.recurring {
|
||||
.fc-daygrid-event-dot {
|
||||
border-color: var(--event-recurring-unpublished-color) !important;
|
||||
}
|
||||
|
||||
&.fc-daygrid-block-event {
|
||||
background-color: var(--event-recurring-unpublished-color) !important;
|
||||
border-color: var(--event-recurring-unpublished-color) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,7 +211,7 @@
|
||||
</li>
|
||||
<li>
|
||||
<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>
|
||||
<i class="fa-solid fa-check-to-slot fa-xl"></i>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any
|
||||
|
||||
@@ -8,12 +9,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 FilterSchema, ModelSchema, Schema, UploadedFile
|
||||
from pydantic import AliasChoices, Field
|
||||
from ninja import FilterLookup, FilterSchema, ModelSchema, Schema, UploadedFile
|
||||
from pydantic import AliasChoices, Field, field_validator
|
||||
from pydantic_core.core_schema import ValidationInfo
|
||||
|
||||
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)]
|
||||
|
||||
@@ -109,7 +110,11 @@ class GroupSchema(ModelSchema):
|
||||
|
||||
|
||||
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(
|
||||
None, validation_alias=AliasChoices("exclude", "exclude[]")
|
||||
)
|
||||
@@ -138,6 +143,13 @@ 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
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import htmx from "htmx.org";
|
||||
|
||||
document.body.addEventListener("htmx:beforeRequest", (event) => {
|
||||
event.target.ariaBusy = true;
|
||||
event.detail.target.ariaBusy = true;
|
||||
});
|
||||
|
||||
document.body.addEventListener("htmx:afterRequest", (event) => {
|
||||
event.originalTarget.ariaBusy = null;
|
||||
document.body.addEventListener("htmx:beforeSwap", (event) => {
|
||||
event.detail.target.ariaBusy = null;
|
||||
});
|
||||
|
||||
Object.assign(window, { htmx });
|
||||
|
||||
@@ -143,6 +143,15 @@ 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,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 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -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_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('pedagogy:guide') }}">{% trans %}Pedagogy{% endtrans %}</a></li>
|
||||
</ul>
|
||||
|
||||
@@ -151,12 +151,13 @@
|
||||
{% if current_page.has_previous() %}
|
||||
<a
|
||||
{% if use_htmx -%}
|
||||
hx-get="?page={{ current_page.previous_page_number() }}"
|
||||
hx-get="?{{ querystring(page=current_page.previous_page_number()) }}"
|
||||
hx-swap="innerHTML"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
hx-trigger="click, keyup[key=='ArrowLeft'] from:body"
|
||||
{%- else -%}
|
||||
href="?page={{ current_page.previous_page_number() }}"
|
||||
href="?{{ querystring(page=current_page.previous_page_number()) }}"
|
||||
{%- endif -%}
|
||||
>
|
||||
<button>
|
||||
@@ -174,12 +175,12 @@
|
||||
{% else %}
|
||||
<a
|
||||
{% if use_htmx -%}
|
||||
hx-get="?page={{ i }}"
|
||||
hx-get="?{{ querystring(page=i) }}"
|
||||
hx-swap="innerHTML"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
{%- else -%}
|
||||
href="?page={{ i }}"
|
||||
href="?{{ querystring(page=i) }}"
|
||||
{%- endif -%}
|
||||
>
|
||||
<button>{{ i }}</button>
|
||||
@@ -189,12 +190,13 @@
|
||||
{% if current_page.has_next() %}
|
||||
<a
|
||||
{% if use_htmx -%}
|
||||
hx-get="?page={{ current_page.next_page_number() }}"
|
||||
hx-get="?{{querystring(page=current_page.next_page_number())}}"
|
||||
hx-swap="innerHTML"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
hx-trigger="click, keyup[key=='ArrowRight'] from:body"
|
||||
{%- else -%}
|
||||
href="?page={{ current_page.next_page_number() }}"
|
||||
href="?{{querystring(page=current_page.next_page_number())}}"
|
||||
{%- endif -%}
|
||||
><button>
|
||||
<i class="fa fa-caret-right"></i>
|
||||
@@ -243,3 +245,17 @@
|
||||
}"></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 %}
|
||||
@@ -114,7 +114,7 @@
|
||||
|
||||
|
||||
{# All fields #}
|
||||
<div class="profile-fields">
|
||||
<div class="fields-centered">
|
||||
{%- 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="profile-fields">
|
||||
<div class="fields-centered">
|
||||
{%- for field in [form.quote, form.forum_signature] -%}
|
||||
<div class="profile-field">
|
||||
{{ field.label_tag() }}
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
</div>
|
||||
<ul>
|
||||
<li x-show="getBasketSize() === 0">{% trans %}This basket is empty{% endtrans %}</li>
|
||||
<template x-for="(item, index) in Object.values(basket)">
|
||||
<template x-for="(item, index) in Object.values(basket)" :key="item.product.id">
|
||||
<li>
|
||||
<template x-for="error in item.errors">
|
||||
<div class="alert alert-red" x-text="error">
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-24 11:05+0100\n"
|
||||
"POT-Creation-Date: 2025-12-17 00:03+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,6 +4384,14 @@ 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/forms.py
|
||||
msgid "Empty search"
|
||||
msgstr "Recherche vide"
|
||||
|
||||
#: matmat/templates/matmat/search_form.jinja
|
||||
msgid "Search user"
|
||||
msgstr "Rechercher un utilisateur"
|
||||
@@ -4392,22 +4400,6 @@ 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"
|
||||
|
||||
54
matmat/forms.py
Normal file
54
matmat/forms.py
Normal file
@@ -0,0 +1,54 @@
|
||||
#
|
||||
# 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 Software Foundation, Inc., 59 Temple
|
||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
||||
#
|
||||
#
|
||||
from typing import Any
|
||||
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
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
|
||||
|
||||
def clean(self):
|
||||
data = self.cleaned_data
|
||||
if all(data[key] in self.fields[key].empty_values for key in self.fields):
|
||||
raise ValidationError(_("Empty search"))
|
||||
@@ -1,12 +1,18 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
{% from "core/macros.jinja" import user_mini_profile, paginate_jinja %}
|
||||
{% if is_fragment %}
|
||||
{% extends "core/base_fragment.jinja" %}
|
||||
{% else %}
|
||||
{% extends "core/base.jinja" %}
|
||||
{% endif %}
|
||||
|
||||
{% from "core/macros.jinja" import user_mini_profile, paginate_htmx with context %}
|
||||
|
||||
|
||||
{% block title %}
|
||||
{% trans %}Search user{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if result_exists %}
|
||||
{% if paginator.count > 0 %}
|
||||
<h2>{% trans %}Results{% endtrans %}</h2>
|
||||
<div class="matmat_results">
|
||||
{% for user in object_list %}
|
||||
@@ -19,47 +25,24 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if page_obj.has_other_pages() %}
|
||||
{{ paginate_jinja(page_obj, paginator) }}
|
||||
{{ paginate_htmx(page_obj, paginator) }}
|
||||
{% endif %}
|
||||
<hr>
|
||||
{% endif %}
|
||||
<h2>{% trans %}Search user{% endtrans %}</h2>
|
||||
<h3>{% trans %}Search by profile{% endtrans %}</h3>
|
||||
<form action="{{ url('matmat:search') }}" method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<form action="{{ url('matmat:search') }}" method="get">
|
||||
{{ form.non_field_errors() }}
|
||||
<fieldset class="fields-centered">
|
||||
{% for field in form %}
|
||||
{% if field.name not in ('phone', 'quick') %}
|
||||
<p>
|
||||
{{ field.errors }}
|
||||
<label for="{{ field.id_for_label }}">{{ field.label }}</label>
|
||||
<div>
|
||||
{{ field.label_tag() }}
|
||||
{{ field }}
|
||||
</p>
|
||||
{% endif %}
|
||||
{{ field.errors }}
|
||||
</div>
|
||||
{% 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>
|
||||
</fieldset>
|
||||
<div class="fields-centered">
|
||||
<input class="btn btn-blue" type="submit" value="{% trans %}Search{% endtrans %}" />
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
{{ super() }}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1 +1,59 @@
|
||||
# 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]]
|
||||
|
||||
def test_empty_search(self):
|
||||
self.client.force_login(subscriber_user.make())
|
||||
response = self.client.get(reverse("matmat:search"))
|
||||
assert response.status_code == 200
|
||||
assert list(response.context_data["object_list"]) == []
|
||||
assert not response.context_data["form"].is_valid()
|
||||
|
||||
response = self.client.get(
|
||||
reverse(
|
||||
"matmat:search",
|
||||
query={
|
||||
"promo": "",
|
||||
"role": "",
|
||||
"department": "",
|
||||
"semester": "",
|
||||
"date_of_birth": "",
|
||||
},
|
||||
)
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert list(response.context_data["object_list"]) == []
|
||||
assert not response.context_data["form"].is_valid()
|
||||
assert "Recherche vide" in response.context_data["form"].non_field_errors()
|
||||
|
||||
@@ -23,16 +23,8 @@
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from matmat.views import (
|
||||
SearchClearFormView,
|
||||
SearchNormalFormView,
|
||||
SearchQuickFormView,
|
||||
SearchReverseFormView,
|
||||
)
|
||||
from matmat.views import MatmatronchView
|
||||
|
||||
urlpatterns = [
|
||||
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"),
|
||||
path("", MatmatronchView.as_view(), name="search"),
|
||||
]
|
||||
|
||||
198
matmat/views.py
198
matmat/views.py
@@ -16,195 +16,49 @@
|
||||
# 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
|
||||
# this program; if not, write to the Free Software Foundation, Inc., 59 Temple
|
||||
# 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.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 django.views.generic import ListView
|
||||
from django.views.generic.edit import FormMixin
|
||||
|
||||
from core.auth.mixins import FormerSubscriberMixin
|
||||
from core.models import User
|
||||
from core.models import User, UserQuerySet
|
||||
from core.schemas import UserFilterSchema
|
||||
from core.views.forms import SelectDate
|
||||
|
||||
# Enum to select search type
|
||||
from core.views.mixins import AllowFragment
|
||||
from matmat.forms import SearchForm
|
||||
|
||||
|
||||
class SearchType(Enum):
|
||||
NORMAL = 1
|
||||
REVERSE = 2
|
||||
QUICK = 3
|
||||
|
||||
|
||||
# Custom form
|
||||
|
||||
|
||||
class SearchForm(forms.ModelForm):
|
||||
class Meta:
|
||||
class MatmatronchView(AllowFragment, FormerSubscriberMixin, FormMixin, ListView):
|
||||
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
|
||||
ordering = ["-id"]
|
||||
paginate_by = 12
|
||||
paginate_by = 20
|
||||
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
|
||||
|
||||
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)
|
||||
self.form = self.get_form()
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
def get_initial(self):
|
||||
init = self.session.get("matmat_search_form", {})
|
||||
if not init:
|
||||
init["department"] = ""
|
||||
return init
|
||||
return self.request.GET
|
||||
|
||||
def get_form_kwargs(self):
|
||||
res = super().get_form_kwargs()
|
||||
if self.request.GET:
|
||||
res["data"] = self.request.GET
|
||||
return res
|
||||
|
||||
class SearchNormalFormView(SearchFormView):
|
||||
search_type = SearchType.NORMAL
|
||||
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))
|
||||
|
||||
|
||||
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"))
|
||||
def get_context_data(self, **kwargs):
|
||||
return super().get_context_data(form=self.form, **kwargs)
|
||||
|
||||
@@ -83,7 +83,7 @@ tests = [
|
||||
docs = [
|
||||
"mkdocs<2.0.0,>=1.6.1",
|
||||
"mkdocs-material>=9.6.23,<10.0.0",
|
||||
"mkdocstrings>=0.30.1,<2.0.0",
|
||||
"mkdocstrings>=0.30.1,<1.0.0",
|
||||
"mkdocstrings-python>=1.18.2,<2.0.0",
|
||||
"mkdocs-include-markdown-plugin>=7.2.0,<8.0.0",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user