mirror of
https://github.com/ae-utbm/sith.git
synced 2025-12-21 15:23:21 +00:00
Compare commits
1 Commits
remove-clu
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af11cbdc96 |
@@ -26,6 +26,7 @@ from __future__ import annotations
|
|||||||
from typing import Iterable, Self
|
from typing import Iterable, Self
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.core.cache import cache
|
||||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||||
from django.core.validators import RegexValidator, validate_email
|
from django.core.validators import RegexValidator, validate_email
|
||||||
from django.db import models, transaction
|
from django.db import models, transaction
|
||||||
@@ -186,6 +187,9 @@ class Club(models.Model):
|
|||||||
self.page.save(force_lock=True)
|
self.page.save(force_lock=True)
|
||||||
|
|
||||||
def delete(self, *args, **kwargs) -> tuple[int, dict[str, int]]:
|
def delete(self, *args, **kwargs) -> tuple[int, dict[str, int]]:
|
||||||
|
# Invalidate the cache of this club and of its memberships
|
||||||
|
for membership in self.members.ongoing().select_related("user"):
|
||||||
|
cache.delete(f"membership_{self.id}_{membership.user.id}")
|
||||||
self.board_group.delete()
|
self.board_group.delete()
|
||||||
self.members_group.delete()
|
self.members_group.delete()
|
||||||
return super().delete(*args, **kwargs)
|
return super().delete(*args, **kwargs)
|
||||||
@@ -206,15 +210,24 @@ class Club(models.Model):
|
|||||||
"""Method to see if that object can be edited by the given user."""
|
"""Method to see if that object can be edited by the given user."""
|
||||||
return self.has_rights_in_club(user)
|
return self.has_rights_in_club(user)
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def current_members(self) -> list[Membership]:
|
|
||||||
return self.members.ongoing().select_related("user").order_by("-role")
|
|
||||||
|
|
||||||
def get_membership_for(self, user: User) -> Membership | None:
|
def get_membership_for(self, user: User) -> Membership | None:
|
||||||
"""Return the current membership of the given user."""
|
"""Return the current membership the given user.
|
||||||
|
|
||||||
|
Note:
|
||||||
|
The result is cached.
|
||||||
|
"""
|
||||||
if user.is_anonymous:
|
if user.is_anonymous:
|
||||||
return None
|
return None
|
||||||
return next((m for m in self.current_members if m.user_id == user.id), None)
|
membership = cache.get(f"membership_{self.id}_{user.id}")
|
||||||
|
if membership == "not_member":
|
||||||
|
return None
|
||||||
|
if membership is None:
|
||||||
|
membership = self.members.filter(user=user, end_date=None).first()
|
||||||
|
if membership is None:
|
||||||
|
cache.set(f"membership_{self.id}_{user.id}", "not_member")
|
||||||
|
else:
|
||||||
|
cache.set(f"membership_{self.id}_{user.id}", membership)
|
||||||
|
return membership
|
||||||
|
|
||||||
def has_rights_in_club(self, user: User) -> bool:
|
def has_rights_in_club(self, user: User) -> bool:
|
||||||
return user.is_in_group(pk=self.board_group_id)
|
return user.is_in_group(pk=self.board_group_id)
|
||||||
@@ -282,20 +295,28 @@ class MembershipQuerySet(models.QuerySet):
|
|||||||
and add them in the club groups they should be in.
|
and add them in the club groups they should be in.
|
||||||
|
|
||||||
Be aware that this adds three db queries :
|
Be aware that this adds three db queries :
|
||||||
|
one to retrieve the updated memberships,
|
||||||
- one to retrieve the updated memberships
|
one to perform group removal and one to perform
|
||||||
- one to perform group removal
|
group attribution.
|
||||||
- and one to perform group attribution.
|
|
||||||
"""
|
"""
|
||||||
nb_rows = super().update(**kwargs)
|
nb_rows = super().update(**kwargs)
|
||||||
if nb_rows == 0:
|
if nb_rows == 0:
|
||||||
# if no row was affected, no need to edit club groups
|
# if no row was affected, no need to refresh the cache
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
cache_memberships = {}
|
||||||
memberships = set(self.select_related("club"))
|
memberships = set(self.select_related("club"))
|
||||||
# delete all User-Group relations and recreate the necessary ones
|
# delete all User-Group relations and recreate the necessary ones
|
||||||
|
# It's more concise to write and more reliable
|
||||||
Membership._remove_club_groups(memberships)
|
Membership._remove_club_groups(memberships)
|
||||||
Membership._add_club_groups(memberships)
|
Membership._add_club_groups(memberships)
|
||||||
|
for member in memberships:
|
||||||
|
cache_key = f"membership_{member.club_id}_{member.user_id}"
|
||||||
|
if member.end_date is None:
|
||||||
|
cache_memberships[cache_key] = member
|
||||||
|
else:
|
||||||
|
cache_memberships[cache_key] = "not_member"
|
||||||
|
cache.set_many(cache_memberships)
|
||||||
return nb_rows
|
return nb_rows
|
||||||
|
|
||||||
def delete(self) -> tuple[int, dict[str, int]]:
|
def delete(self) -> tuple[int, dict[str, int]]:
|
||||||
@@ -318,6 +339,12 @@ class MembershipQuerySet(models.QuerySet):
|
|||||||
nb_rows, rows_counts = super().delete()
|
nb_rows, rows_counts = super().delete()
|
||||||
if nb_rows > 0:
|
if nb_rows > 0:
|
||||||
Membership._remove_club_groups(memberships)
|
Membership._remove_club_groups(memberships)
|
||||||
|
cache.set_many(
|
||||||
|
{
|
||||||
|
f"membership_{m.club_id}_{m.user_id}": "not_member"
|
||||||
|
for m in memberships
|
||||||
|
}
|
||||||
|
)
|
||||||
return nb_rows, rows_counts
|
return nb_rows, rows_counts
|
||||||
|
|
||||||
|
|
||||||
@@ -381,6 +408,9 @@ class Membership(models.Model):
|
|||||||
self._remove_club_groups([self])
|
self._remove_club_groups([self])
|
||||||
if self.end_date is None:
|
if self.end_date is None:
|
||||||
self._add_club_groups([self])
|
self._add_club_groups([self])
|
||||||
|
cache.set(f"membership_{self.club_id}_{self.user_id}", self)
|
||||||
|
else:
|
||||||
|
cache.set(f"membership_{self.club_id}_{self.user_id}", "not_member")
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse("club:club_members", kwargs={"club_id": self.club_id})
|
return reverse("club:club_members", kwargs={"club_id": self.club_id})
|
||||||
@@ -401,6 +431,7 @@ class Membership(models.Model):
|
|||||||
def delete(self, *args, **kwargs):
|
def delete(self, *args, **kwargs):
|
||||||
self._remove_club_groups([self])
|
self._remove_club_groups([self])
|
||||||
super().delete(*args, **kwargs)
|
super().delete(*args, **kwargs)
|
||||||
|
cache.delete(f"membership_{self.club_id}_{self.user_id}")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _remove_club_groups(
|
def _remove_club_groups(
|
||||||
|
|||||||
@@ -72,6 +72,25 @@ class TestMembershipQuerySet(TestClub):
|
|||||||
expected.sort(key=lambda i: i.id)
|
expected.sort(key=lambda i: i.id)
|
||||||
assert members == expected
|
assert members == expected
|
||||||
|
|
||||||
|
def test_update_invalidate_cache(self):
|
||||||
|
"""Test that the `update` queryset method properly invalidate cache."""
|
||||||
|
mem_skia = self.simple_board_member.memberships.get(club=self.club)
|
||||||
|
cache.set(f"membership_{mem_skia.club_id}_{mem_skia.user_id}", mem_skia)
|
||||||
|
self.simple_board_member.memberships.update(end_date=localtime(now()).date())
|
||||||
|
assert (
|
||||||
|
cache.get(f"membership_{mem_skia.club_id}_{mem_skia.user_id}")
|
||||||
|
== "not_member"
|
||||||
|
)
|
||||||
|
|
||||||
|
mem_richard = self.richard.memberships.get(club=self.club)
|
||||||
|
cache.set(
|
||||||
|
f"membership_{mem_richard.club_id}_{mem_richard.user_id}", mem_richard
|
||||||
|
)
|
||||||
|
self.richard.memberships.update(role=5)
|
||||||
|
new_mem = self.richard.memberships.get(club=self.club)
|
||||||
|
assert new_mem != "not_member"
|
||||||
|
assert new_mem.role == 5
|
||||||
|
|
||||||
def test_update_change_club_groups(self):
|
def test_update_change_club_groups(self):
|
||||||
"""Test that `update` set the user groups accordingly."""
|
"""Test that `update` set the user groups accordingly."""
|
||||||
user = baker.make(User)
|
user = baker.make(User)
|
||||||
@@ -93,6 +112,24 @@ class TestMembershipQuerySet(TestClub):
|
|||||||
assert not user.groups.contains(members_group)
|
assert not user.groups.contains(members_group)
|
||||||
assert not user.groups.contains(board_group)
|
assert not user.groups.contains(board_group)
|
||||||
|
|
||||||
|
def test_delete_invalidate_cache(self):
|
||||||
|
"""Test that the `delete` queryset properly invalidate cache."""
|
||||||
|
mem_skia = self.simple_board_member.memberships.get(club=self.club)
|
||||||
|
mem_comptable = self.president.memberships.get(club=self.club)
|
||||||
|
cache.set(f"membership_{mem_skia.club_id}_{mem_skia.user_id}", mem_skia)
|
||||||
|
cache.set(
|
||||||
|
f"membership_{mem_comptable.club_id}_{mem_comptable.user_id}", mem_comptable
|
||||||
|
)
|
||||||
|
|
||||||
|
# should delete the subscriptions of simple_board_member and president
|
||||||
|
self.club.members.ongoing().board().delete()
|
||||||
|
|
||||||
|
for membership in (mem_skia, mem_comptable):
|
||||||
|
cached_mem = cache.get(
|
||||||
|
f"membership_{membership.club_id}_{membership.user_id}"
|
||||||
|
)
|
||||||
|
assert cached_mem == "not_member"
|
||||||
|
|
||||||
def test_delete_remove_from_groups(self):
|
def test_delete_remove_from_groups(self):
|
||||||
"""Test that `delete` removes from club groups"""
|
"""Test that `delete` removes from club groups"""
|
||||||
user = baker.make(User)
|
user = baker.make(User)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { makeUrl } from "#core:utils/api";
|
import { makeUrl } from "#core:utils/api";
|
||||||
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
|
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
|
||||||
import { Calendar, type EventClickArg, type EventContentArg } from "@fullcalendar/core";
|
import { Calendar, type EventClickArg } from "@fullcalendar/core";
|
||||||
import type { EventImpl } from "@fullcalendar/core/internal";
|
import type { EventImpl } from "@fullcalendar/core/internal";
|
||||||
import enLocale from "@fullcalendar/core/locales/en-gb";
|
import enLocale from "@fullcalendar/core/locales/en-gb";
|
||||||
import frLocale from "@fullcalendar/core/locales/fr";
|
import frLocale from "@fullcalendar/core/locales/fr";
|
||||||
@@ -25,11 +25,6 @@ export class IcsCalendar extends inheritHtmlElement("div") {
|
|||||||
private canDelete = false;
|
private canDelete = false;
|
||||||
private helpUrl = "";
|
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) {
|
attributeChangedCallback(name: string, _oldValue?: string, newValue?: string) {
|
||||||
if (name === "locale") {
|
if (name === "locale") {
|
||||||
this.locale = newValue;
|
this.locale = newValue;
|
||||||
@@ -100,7 +95,6 @@ export class IcsCalendar extends inheritHtmlElement("div") {
|
|||||||
|
|
||||||
refreshEvents() {
|
refreshEvents() {
|
||||||
this.click(); // Remove focus from popup
|
this.click(); // Remove focus from popup
|
||||||
this.recurrenceMap.clear(); // Avoid double detection of the same non recurring event
|
|
||||||
this.calendar.refetchEvents();
|
this.calendar.refetchEvents();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,24 +153,12 @@ export class IcsCalendar extends inheritHtmlElement("div") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getEventSources() {
|
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 [
|
return [
|
||||||
{
|
{
|
||||||
url: `${await makeUrl(calendarCalendarInternal)}`,
|
url: `${await makeUrl(calendarCalendarInternal)}`,
|
||||||
format: "ics",
|
format: "ics",
|
||||||
className: "internal",
|
className: "internal",
|
||||||
cache: false,
|
cache: false,
|
||||||
eventDataTransform: tagRecurringEvents,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: `${await makeUrl(calendarCalendarUnpublished)}`,
|
url: `${await makeUrl(calendarCalendarUnpublished)}`,
|
||||||
@@ -184,7 +166,6 @@ export class IcsCalendar extends inheritHtmlElement("div") {
|
|||||||
color: "red",
|
color: "red",
|
||||||
className: "unpublished",
|
className: "unpublished",
|
||||||
cache: false,
|
cache: false,
|
||||||
eventDataTransform: tagRecurringEvents,
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -380,14 +361,6 @@ export class IcsCalendar extends inheritHtmlElement("div") {
|
|||||||
event.jsEvent.preventDefault();
|
event.jsEvent.preventDefault();
|
||||||
this.createEventDetailPopup(event);
|
this.createEventDetailPopup(event);
|
||||||
},
|
},
|
||||||
eventClassNames: (classNamesEvent: EventContentArg) => {
|
|
||||||
const classes: string[] = [];
|
|
||||||
if (classNamesEvent.event.extendedProps?.isRecurring) {
|
|
||||||
classes.push("recurring");
|
|
||||||
}
|
|
||||||
|
|
||||||
return classes;
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
this.calendar.render();
|
this.calendar.render();
|
||||||
|
|
||||||
|
|||||||
@@ -18,8 +18,6 @@
|
|||||||
--event-details-border-radius: 4px;
|
--event-details-border-radius: 4px;
|
||||||
--event-details-box-shadow: 0px 6px 20px 4px rgb(0 0 0 / 16%);
|
--event-details-box-shadow: 0px 6px 20px 4px rgb(0 0 0 / 16%);
|
||||||
--event-details-max-width: 600px;
|
--event-details-max-width: 600px;
|
||||||
--event-recurring-internal-color: #6f69cd;
|
|
||||||
--event-recurring-unpublished-color: orange;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ics-calendar {
|
ics-calendar {
|
||||||
@@ -149,28 +147,3 @@ ics-calendar {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 500ms ease-out;
|
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>
|
||||||
<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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import htmx from "htmx.org";
|
import htmx from "htmx.org";
|
||||||
|
|
||||||
document.body.addEventListener("htmx:beforeRequest", (event) => {
|
document.body.addEventListener("htmx:beforeRequest", (event) => {
|
||||||
event.detail.target.ariaBusy = true;
|
event.target.ariaBusy = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
document.body.addEventListener("htmx:beforeSwap", (event) => {
|
document.body.addEventListener("htmx:afterRequest", (event) => {
|
||||||
event.detail.target.ariaBusy = null;
|
event.originalTarget.ariaBusy = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
Object.assign(window, { htmx });
|
Object.assign(window, { htmx });
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -151,13 +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"
|
||||||
hx-trigger="click, keyup[key=='ArrowLeft'] from:body"
|
|
||||||
{%- else -%}
|
{%- else -%}
|
||||||
href="?{{ querystring(page=current_page.previous_page_number()) }}"
|
href="?page={{ current_page.previous_page_number() }}"
|
||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
>
|
>
|
||||||
<button>
|
<button>
|
||||||
@@ -175,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>
|
||||||
@@ -190,13 +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"
|
||||||
hx-trigger="click, keyup[key=='ArrowRight'] from:body"
|
|
||||||
{%- 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>
|
||||||
@@ -245,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 }}&
|
|
||||||
{%- endfor -%}
|
|
||||||
{%- endif -%}
|
|
||||||
{%- endfor -%}
|
|
||||||
{%- for key, value in kwargs.items() -%}
|
|
||||||
{{ key }}={{ value }}&
|
|
||||||
{%- endfor -%}
|
|
||||||
{% endmacro %}
|
|
||||||
@@ -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() }}
|
||||||
|
|||||||
@@ -22,17 +22,19 @@ from bs4 import BeautifulSoup
|
|||||||
from django.contrib.auth.hashers import make_password
|
from django.contrib.auth.hashers import make_password
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
from django.core import mail
|
from django.core import mail
|
||||||
|
from django.core.cache import cache
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.mail import EmailMessage
|
from django.core.mail import EmailMessage
|
||||||
from django.test import Client, RequestFactory, TestCase
|
from django.test import Client, RequestFactory, TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.utils.timezone import now
|
||||||
from django.views.generic import View
|
from django.views.generic import View
|
||||||
from django.views.generic.base import ContextMixin
|
from django.views.generic.base import ContextMixin
|
||||||
from model_bakery import baker
|
from model_bakery import baker
|
||||||
from pytest_django.asserts import assertInHTML, assertRedirects
|
from pytest_django.asserts import assertInHTML, assertRedirects
|
||||||
|
|
||||||
from antispam.models import ToxicDomain
|
from antispam.models import ToxicDomain
|
||||||
from club.models import Club
|
from club.models import Club, Membership
|
||||||
from core.baker_recipes import subscriber_user
|
from core.baker_recipes import subscriber_user
|
||||||
from core.markdown import markdown
|
from core.markdown import markdown
|
||||||
from core.models import AnonymousUser, Group, Page, User, validate_promo
|
from core.models import AnonymousUser, Group, Page, User, validate_promo
|
||||||
@@ -434,6 +436,23 @@ class TestUserIsInGroup(TestCase):
|
|||||||
with self.assertNumQueries(0):
|
with self.assertNumQueries(0):
|
||||||
self.public_user.is_in_group(pk=group_not_in.id)
|
self.public_user.is_in_group(pk=group_not_in.id)
|
||||||
|
|
||||||
|
def test_cache_properly_cleared_membership(self):
|
||||||
|
"""Test that when the membership of a user end,
|
||||||
|
the cache is properly invalidated.
|
||||||
|
"""
|
||||||
|
membership = baker.make(Membership, club=self.club, user=self.public_user)
|
||||||
|
cache.clear()
|
||||||
|
self.club.get_membership_for(self.public_user) # this should populate the cache
|
||||||
|
assert membership == cache.get(
|
||||||
|
f"membership_{self.club.id}_{self.public_user.id}"
|
||||||
|
)
|
||||||
|
membership.end_date = now() - timedelta(minutes=5)
|
||||||
|
membership.save()
|
||||||
|
cached_membership = cache.get(
|
||||||
|
f"membership_{self.club.id}_{self.public_user.id}"
|
||||||
|
)
|
||||||
|
assert cached_membership == "not_member"
|
||||||
|
|
||||||
def test_not_existing_group(self):
|
def test_not_existing_group(self):
|
||||||
"""Test that searching for a not existing group
|
"""Test that searching for a not existing group
|
||||||
returns False.
|
returns False.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
msgid ""
|
msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2025-12-17 00:03+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,14 +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/forms.py
|
|
||||||
msgid "Empty search"
|
|
||||||
msgstr "Recherche vide"
|
|
||||||
|
|
||||||
#: 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"
|
||||||
@@ -4400,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"
|
||||||
|
|||||||
@@ -1,54 +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 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,18 +1,12 @@
|
|||||||
{% if is_fragment %}
|
{% extends "core/base.jinja" %}
|
||||||
{% extends "core/base_fragment.jinja" %}
|
{% from "core/macros.jinja" import user_mini_profile, paginate_jinja %}
|
||||||
{% else %}
|
|
||||||
{% extends "core/base.jinja" %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% from "core/macros.jinja" import user_mini_profile, paginate_htmx 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,24 +19,47 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% if page_obj.has_other_pages() %}
|
{% if page_obj.has_other_pages() %}
|
||||||
{{ paginate_htmx(page_obj, paginator) }}
|
{{ paginate_jinja(page_obj, paginator) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<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>
|
||||||
{{ form.non_field_errors() }}
|
<form action="{{ url('matmat:search') }}" method="post" enctype="multipart/form-data">
|
||||||
<fieldset class="fields-centered">
|
{% csrf_token %}
|
||||||
{% for field in form %}
|
{% for field in form %}
|
||||||
<div>
|
{% if field.name not in ('phone', 'quick') %}
|
||||||
{{ field.label_tag() }}
|
<p>
|
||||||
{{ field }}
|
|
||||||
{{ field.errors }}
|
{{ field.errors }}
|
||||||
</div>
|
<label for="{{ field.id_for_label }}">{{ field.label }}</label>
|
||||||
{% endfor %}
|
{{ field }}
|
||||||
</fieldset>
|
</p>
|
||||||
<div class="fields-centered">
|
{% endif %}
|
||||||
<input class="btn btn-blue" type="submit" value="{% trans %}Search{% endtrans %}" />
|
{% endfor %}
|
||||||
</div>
|
<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 %}
|
||||||
|
|||||||
@@ -1,59 +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]]
|
|
||||||
|
|
||||||
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,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"),
|
||||||
]
|
]
|
||||||
|
|||||||
202
matmat/views.py
202
matmat/views.py
@@ -16,49 +16,195 @@
|
|||||||
# details.
|
# details.
|
||||||
#
|
#
|
||||||
# You should have received a copy of the GNU General Public License along with
|
# 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
|
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
|
||||||
# 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 core.views.mixins import AllowFragment
|
from core.views.forms import SelectDate
|
||||||
from matmat.forms import SearchForm
|
|
||||||
|
# Enum to select search type
|
||||||
|
|
||||||
|
|
||||||
class MatmatronchView(AllowFragment, FormerSubscriberMixin, FormMixin, ListView):
|
class SearchType(Enum):
|
||||||
|
NORMAL = 1
|
||||||
|
REVERSE = 2
|
||||||
|
QUICK = 3
|
||||||
|
|
||||||
|
|
||||||
|
# Custom form
|
||||||
|
|
||||||
|
|
||||||
|
class SearchForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = [
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"nick_name",
|
||||||
|
"role",
|
||||||
|
"department",
|
||||||
|
"semester",
|
||||||
|
"promo",
|
||||||
|
"date_of_birth",
|
||||||
|
"phone",
|
||||||
|
]
|
||||||
|
widgets = {
|
||||||
|
"date_of_birth": SelectDate,
|
||||||
|
"phone": RegionalPhoneNumberWidget,
|
||||||
|
}
|
||||||
|
|
||||||
|
quick = forms.CharField(label=_("Last/First name or nickname"), max_length=255)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
for key in self.fields:
|
||||||
|
self.fields[key].required = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cleaned_data_json(self):
|
||||||
|
data = self.cleaned_data
|
||||||
|
for key, val in data.items():
|
||||||
|
if key in ("date_of_birth", "phone") and val is not None:
|
||||||
|
data[key] = str(val)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
# Views
|
||||||
|
|
||||||
|
|
||||||
|
class SearchFormListView(FormerSubscriberMixin, SingleObjectMixin, ListView):
|
||||||
model = User
|
model = User
|
||||||
paginate_by = 20
|
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"))
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ tests = [
|
|||||||
docs = [
|
docs = [
|
||||||
"mkdocs<2.0.0,>=1.6.1",
|
"mkdocs<2.0.0,>=1.6.1",
|
||||||
"mkdocs-material>=9.6.23,<10.0.0",
|
"mkdocs-material>=9.6.23,<10.0.0",
|
||||||
"mkdocstrings>=0.30.1,<1.0.0",
|
"mkdocstrings>=0.30.1,<2.0.0",
|
||||||
"mkdocstrings-python>=1.18.2,<2.0.0",
|
"mkdocstrings-python>=1.18.2,<2.0.0",
|
||||||
"mkdocs-include-markdown-plugin>=7.2.0,<8.0.0",
|
"mkdocs-include-markdown-plugin>=7.2.0,<8.0.0",
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user