Compare commits

...

52 Commits

Author SHA1 Message Date
imperosol
831a32e1c9 WIP 2026-02-02 14:36:29 +01:00
imperosol
b20861b389 get_list_exact_or_404 util function 2026-02-02 14:36:05 +01:00
imperosol
fdb5d7ab09 fix some tests 2026-02-02 14:36:05 +01:00
imperosol
d2281181a0 Migrate albums and pictures to their own tables 2026-02-02 14:36:05 +01:00
thomas girod
5af894060a Merge pull request #1273 from ae-utbm/fix-counter
fix: wrong quantity displayed on click after removing item
2026-01-21 22:42:27 +01:00
679b8dac1c Merge pull request #1278 from ae-utbm/download-picture-fix
Fix image file generation on user image download
2026-01-21 22:04:09 +01:00
Sli
e9eb3dc17d Fix image file generation on user image download
* Add image id on the name to avoid error with images with the exact same date (if we have epoch for example)
* Fix album name due to schema change not reflected here
2026-01-07 17:49:14 +01:00
8c6f7c82c9 Merge pull request #1277 from ae-utbm/pedagogy
Don't craft urls in pedagogy frontend
2025-12-25 17:01:48 +01:00
Sli
6ec1834540 Don't craft urls in pedagogy frontend 2025-12-24 14:48:03 +01:00
thomas girod
086a61f493 Merge pull request #1272 from ae-utbm/update-ue
rename UV to UE
2025-12-24 11:29:32 +01:00
imperosol
53a3dc0060 fix: wrong quantity displayed on click after removing item 2025-12-20 06:47:29 +01:00
Thomas Girod
775a3282dc rename UV to UE 2025-12-19 23:12:02 +01:00
thomas girod
32570ee03d Merge pull request #1266 from ae-utbm/matmat
Refactor Matmatronch
2025-12-18 13:13:28 +01:00
imperosol
2fa3597722 fix display of non field errors 2025-12-18 00:39:08 +01:00
Sli
d484971dad Fix pagination on matmat, don't allow empty matmat search and add htmx pagination 2025-12-17 09:21:52 +01:00
Sli
f24e39ccb7 Fix aria-busy when going backward on pages with htmx pagination 2025-12-17 00:01:48 +01:00
3a57439d6e Merge pull request #1271 from ae-utbm/calendar-colors
Add different colors for recurring events on event calendar
2025-12-16 23:04:03 +01:00
thomas girod
fbe5c741d1 Merge pull request #1270 from ae-utbm/calendar
Use ics rrule for recurrent event
2025-12-16 19:19:06 +01:00
Sli
749cd067da Add different colors for recurring events on event calendar 2025-12-16 17:07:18 +01:00
imperosol
1abfbeb76c use ics rrule for recurrent event 2025-12-16 01:13:17 +01:00
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
thomas girod
d3edcaff14 Merge pull request #1264 from ae-utbm/refactor/user
Refactor some user views
2025-11-26 18:33:35 +01:00
imperosol
8c127a96f7 refactor: user godfathers views 2025-11-25 22:20:43 +01:00
imperosol
55d6e2bbec refactor: PasswordRootChangeView 2025-11-25 20:55:36 +01:00
imperosol
e9fbac8264 test UserPreferencesView 2025-11-25 19:48:45 +01:00
imperosol
1911f2e6dd refactor: remove UserUpdateView.board_only
La variable n'a pas été utilisée depuis 2016
2025-11-25 19:47:52 +01:00
thomas girod
77bdc8dcb5 Merge pull request #1263 from ae-utbm/remove-group
refactor: remove useless Group methods
2025-11-25 16:42:50 +01:00
imperosol
00acdcd1a5 refactor: remove useless Group methods 2025-11-24 18:15:28 +01:00
thomas girod
aa77cfd1c8 Merge pull request #1262 from ae-utbm/refactor/userstats
Refactor/userstats
2025-11-24 18:09:56 +01:00
imperosol
0d4b77ba1c take all purchases for global purchase sum 2025-11-24 17:00:28 +01:00
imperosol
5271783e88 refactor: user stats view 2025-11-24 16:49:22 +01:00
imperosol
4ff4d179a1 refactor: format_timedelta template filter 2025-11-24 16:49:15 +01:00
thomas girod
7cbb3a2c5d Merge pull request #1256 from ae-utbm/remove-is_validated
Database optimisations on counter
2025-11-24 16:46:15 +01:00
thomas girod
a0768d6d7f Merge pull request #1261 from ae-utbm/refactor/index
refactor: `core/views/index.py`
2025-11-24 15:43:36 +01:00
imperosol
f55627a292 refactor: core/views/index.py 2025-11-24 09:25:38 +01:00
thomas girod
4f802ac56e Merge pull request #1260 from ae-utbm/fix-warnings
Fix warnings
2025-11-24 07:43:51 +01:00
thomas girod
16a6e07d4b Merge pull request #1259 from ae-utbm/update-ninja
deps: bump django-ninja to 1.5.0
2025-11-24 07:43:39 +01:00
thomas girod
33d6300131 Merge pull request #1258 from ae-utbm/fix/product-action
fix: product scheduled action on product creation
2025-11-24 07:43:20 +01:00
imperosol
6709befb1f fix timezone issues 2025-11-23 01:30:44 +01:00
imperosol
ddfb88ca2a remove settings.FORM_RENDERER 2025-11-23 01:22:15 +01:00
imperosol
acdb9660f6 deps: bump django-ninja to 1.5.0 2025-11-23 00:48:32 +01:00
imperosol
b60bd3a42b fix: product scheduled action on product creation
cf. issue #1257
2025-11-21 11:13:06 +01:00
imperosol
0c046b6164 translations 2025-11-19 21:03:55 +01:00
imperosol
c588e5117d make Refilling.payment_method a SmallIntegerField 2025-11-19 21:03:55 +01:00
imperosol
ad87617018 remove Refilling.bank 2025-11-19 21:03:55 +01:00
imperosol
56c2c2b70e remove Refilling.is_validated 2025-11-19 21:03:55 +01:00
imperosol
78fe4e52ca make Selling.payment_method a SmallIntegerField 2025-11-19 21:03:55 +01:00
imperosol
2a5893aa79 remove Selling.is_validated 2025-11-19 21:03:55 +01:00
110 changed files with 4211 additions and 2205 deletions

View File

@@ -1,18 +1,16 @@
from typing import Annotated
from annotated_types import MinLen
from django.db.models import Q
from ninja import Field, FilterSchema, ModelSchema
from ninja import FilterLookup, FilterSchema, ModelSchema
from club.models import Club, Membership
from core.schemas import SimpleUserSchema
from core.schemas import NonEmptyStr, SimpleUserSchema
class ClubSearchFilterSchema(FilterSchema):
search: Annotated[str, MinLen(1)] | None = Field(None, q="name__icontains")
search: Annotated[NonEmptyStr | None, FilterLookup("name__icontains")] = None
is_active: bool | None = None
parent_id: int | None = None
parent_name: str | None = Field(None, q="parent__name__icontains")
exclude_ids: set[int] | None = None
def filter_exclude_ids(self, value: set[int] | None):

View File

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

View File

@@ -1,9 +1,9 @@
from datetime import datetime
from typing import Annotated
from ninja import FilterSchema, ModelSchema
from ninja import FilterLookup, FilterSchema, ModelSchema
from ninja_extra import service_resolver
from ninja_extra.context import RouteContext
from pydantic import Field
from club.schemas import ClubProfileSchema
from com.models import News, NewsDate
@@ -11,12 +11,12 @@ from core.markdown import markdown
class NewsDateFilterSchema(FilterSchema):
before: datetime | None = Field(None, q="end_date__lt")
after: datetime | None = Field(None, q="start_date__gt")
club_id: int | None = Field(None, q="news__club_id")
before: Annotated[datetime | None, FilterLookup("end_date__lt")] = None
after: Annotated[datetime | None, FilterLookup("start_date__gt")] = None
club_id: Annotated[int | None, FilterLookup("news__club_id")] = None
news_id: int | None = None
is_published: bool | None = Field(None, q="news__is_published")
title: str | None = Field(None, q="news__title__icontains")
is_published: Annotated[bool | None, FilterLookup("news__is_published")] = None
title: Annotated[str | None, FilterLookup("news__title__icontains")] = None
class NewsSchema(ModelSchema):

View File

@@ -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();

View File

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

View File

@@ -203,7 +203,7 @@
<ul>
<li>
<i class="fa-solid fa-graduation-cap fa-xl"></i>
<a href="{{ url("pedagogy:guide") }}">{% trans %}UV Guide{% endtrans %}</a>
<a href="{{ url("pedagogy:guide") }}">{% trans %}UE Guide{% endtrans %}</a>
</li>
<li>
<i class="fa-solid fa-calendar-days fa-xl"></i>
@@ -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>

View File

@@ -98,9 +98,9 @@ class PageAdmin(admin.ModelAdmin):
@admin.register(SithFile)
class SithFileAdmin(admin.ModelAdmin):
list_display = ("name", "owner", "size", "date", "is_in_sas")
list_display = ("name", "owner", "size", "date")
autocomplete_fields = ("parent", "owner", "moderator")
search_fields = ("name", "parent__name")
search_fields = ("name",)
@admin.register(OperationLog)

View File

@@ -110,7 +110,7 @@ class SithFileController(ControllerBase):
)
@paginate(PageNumberPaginationExtra, page_size=50)
def search_files(self, search: Annotated[str, MinLen(1)]):
return SithFile.objects.filter(is_in_sas=False).filter(name__icontains=search)
return SithFile.objects.filter(name__icontains=search)
@api_controller("/group")

View File

@@ -44,7 +44,7 @@ from core.utils import resize_image
from counter.models import Counter, Product, ProductType, ReturnableProduct, StudentCard
from election.models import Candidature, Election, ElectionList, Role
from forum.models import Forum
from pedagogy.models import UV
from pedagogy.models import UE
from sas.models import Album, PeoplePictureRelation, Picture
from subscription.models import Subscription
@@ -110,7 +110,6 @@ class Command(BaseCommand):
p.save(force_lock=True)
club_root = SithFile.objects.create(name="clubs", owner=root)
sas = SithFile.objects.create(name="SAS", owner=root)
main_club = Club.objects.create(
id=1, name="AE", address="6 Boulevard Anatole France, 90000 Belfort"
)
@@ -661,20 +660,20 @@ class Command(BaseCommand):
# Create some data for pedagogy
UV(
UE(
code="PA00",
author=User.objects.get(id=0),
credit_type=settings.SITH_PEDAGOGY_UV_TYPE[3][0],
credit_type=settings.SITH_PEDAGOGY_UE_TYPE[3][0],
manager="Laurent HEYBERGER",
semester=settings.SITH_PEDAGOGY_UV_SEMESTER[3][0],
language=settings.SITH_PEDAGOGY_UV_LANGUAGE[0][0],
semester=settings.SITH_PEDAGOGY_UE_SEMESTER[3][0],
language=settings.SITH_PEDAGOGY_UE_LANGUAGE[0][0],
department=settings.SITH_PROFILE_DEPARTMENTS[-2][0],
credits=5,
title="Participation dans une association étudiante",
objectives="* Permettre aux étudiants de réaliser, pendant un semestre, un projet culturel ou associatif et de le valoriser.",
program="""* Semestre précédent proposition d'un projet et d'un cahier des charges
* Evaluation par un jury de six membres
* Si accord réalisation dans le cadre de l'UV
* Si accord réalisation dans le cadre de l'UE
* Compte-rendu de l'expérience
* Présentation""",
skills="""* Gérer un projet associatif ou une action éducative en autonomie:
@@ -694,33 +693,21 @@ class Command(BaseCommand):
# SAS
for f in self.SAS_FIXTURE_PATH.glob("*"):
if f.is_dir():
album = Album(
parent=sas,
name=f.name,
owner=root,
is_folder=True,
is_in_sas=True,
is_moderated=True,
)
album.clean()
album.save()
album = Album.objects.create(name=f.name, is_moderated=True)
for p in f.iterdir():
file = resize_image(Image.open(p), 1000, "WEBP")
pict = Picture(
parent=album,
name=p.name,
file=file,
original=file,
owner=root,
is_folder=False,
is_in_sas=True,
is_moderated=True,
mime_type="image/webp",
size=file.size,
)
pict.file.name = p.name
pict.full_clean()
pict.original.name = pict.name
pict.generate_thumbnails()
pict.full_clean()
pict.save()
album.generate_thumbnail()
img_skia = Picture.objects.get(name="skia.jpg")
img_sli = Picture.objects.get(name="sli.jpg")
@@ -790,16 +777,16 @@ class Command(BaseCommand):
subscribers = Group.objects.create(name="Cotisants")
subscribers.permissions.add(
*list(perms.filter(codename__in=["add_news", "add_uvcomment"]))
*list(perms.filter(codename__in=["add_news", "add_uecomment"]))
)
old_subscribers = Group.objects.create(name="Anciens cotisants")
old_subscribers.permissions.add(
*list(
perms.filter(
codename__in=[
"view_uv",
"view_uvcomment",
"add_uvcommentreport",
"view_ue",
"view_uecomment",
"add_uecommentreport",
"view_user",
"view_picture",
"view_album",
@@ -875,7 +862,7 @@ class Command(BaseCommand):
pedagogy_admin.permissions.add(
*list(
perms.filter(content_type__app_label="pedagogy")
.exclude(codename__in=["change_uvcomment"])
.exclude(codename__in=["change_uecomment"])
.values_list("pk", flat=True)
)
)

View File

@@ -23,7 +23,7 @@ from counter.models import (
Selling,
)
from forum.models import Forum, ForumMessage, ForumTopic
from pedagogy.models import UV
from pedagogy.models import UE
from subscription.models import Subscription
@@ -74,7 +74,7 @@ class Command(BaseCommand):
random.sample(old_subscribers, k=min(80, len(old_subscribers))),
)
self.stdout.write("Creating uvs...")
self.create_uvs()
self.create_ues()
self.stdout.write("Creating products...")
self.create_products()
self.stdout.write("Creating sales and refills...")
@@ -192,7 +192,7 @@ class Command(BaseCommand):
memberships = Membership.objects.bulk_create(memberships)
Membership._add_club_groups(memberships)
def create_uvs(self):
def create_ues(self):
root = User.objects.get(username="root")
categories = ["CS", "TM", "OM", "QC", "EC"]
branches = ["TC", "GMC", "GI", "EDIM", "E", "IMSI", "HUMA"]
@@ -207,7 +207,7 @@ class Command(BaseCommand):
+ str(random.randint(10, 90))
)
uvs.append(
UV(
UE(
code=code,
author=root,
manager=random.choice(teachers),
@@ -229,7 +229,7 @@ class Command(BaseCommand):
hours_TE=random.randint(15, 40),
)
)
UV.objects.bulk_create(uvs, ignore_conflicts=True)
UE.objects.bulk_create(uvs, ignore_conflicts=True)
def create_products(self):
categories = [
@@ -350,7 +350,6 @@ class Command(BaseCommand):
date=make_aware(
self.faker.date_time_between(customer.since, localdate())
),
is_validated=True,
)
)
sales.extend(this_customer_sales)

View File

@@ -0,0 +1,27 @@
# Generated by Django 4.2.17 on 2025-01-26 15:01
from typing import TYPE_CHECKING
from django.db import migrations
from django.db.migrations.state import StateApps
if TYPE_CHECKING:
import core.models
def remove_sas_sithfiles(apps: StateApps, schema_editor):
SithFile: type[core.models.SithFile] = apps.get_model("core", "SithFile")
SithFile.objects.filter(is_in_sas=True).delete()
class Migration(migrations.Migration):
dependencies = [
("core", "0047_alter_notification_date_alter_notification_type"),
("sas", "0007_alter_peoplepicturerelation_picture_and_more"),
]
operations = [
migrations.RunPython(
remove_sas_sithfiles, reverse_code=migrations.RunPython.noop, elidable=True
)
]

View File

@@ -0,0 +1,9 @@
# Generated by Django 4.2.17 on 2025-02-14 11:58
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [("core", "0048_remove_sithfiles")]
operations = [migrations.RemoveField(model_name="sithfile", name="is_in_sas")]

View File

@@ -38,7 +38,6 @@ from django.contrib.auth.models import AnonymousUser as AuthAnonymousUser
from django.contrib.auth.models import Group as AuthGroup
from django.contrib.staticfiles.storage import staticfiles_storage
from django.core import validators
from django.core.cache import cache
from django.core.exceptions import PermissionDenied, ValidationError
from django.core.files import File
from django.core.files.base import ContentFile
@@ -77,16 +76,6 @@ class Group(AuthGroup):
def get_absolute_url(self) -> str:
return reverse("core:group_list")
def save(self, *args, **kwargs) -> None:
super().save(*args, **kwargs)
cache.set(f"sith_group_{self.id}", self)
cache.set(f"sith_group_{self.name.replace(' ', '_')}", self)
def delete(self, *args, **kwargs) -> None:
super().delete(*args, **kwargs)
cache.delete(f"sith_group_{self.id}")
cache.delete(f"sith_group_{self.name.replace(' ', '_')}")
def validate_promo(value: int) -> None:
last_promo = get_last_promo()
@@ -844,9 +833,6 @@ class SithFile(models.Model):
on_delete=models.CASCADE,
)
asked_for_removal = models.BooleanField(_("asked for removal"), default=False)
is_in_sas = models.BooleanField(
_("is in the SAS"), default=False, db_index=True
) # Allows to query this flag, updated at each call to save()
class Meta:
verbose_name = _("file")
@@ -855,22 +841,10 @@ class SithFile(models.Model):
return self.get_parent_path() + "/" + self.name
def save(self, *args, **kwargs):
sas = SithFile.objects.filter(id=settings.SITH_SAS_ROOT_DIR_ID).first()
self.is_in_sas = sas in self.get_parent_list() or self == sas
adding = self._state.adding
super().save(*args, **kwargs)
if adding:
self.copy_rights()
if self.is_in_sas:
for user in User.objects.filter(
groups__id__in=[settings.SITH_GROUP_SAS_ADMIN_ID]
):
Notification(
user=user,
url=reverse("sas:moderation"),
type="SAS_MODERATION",
param="1",
).save()
def is_owned_by(self, user: User) -> bool:
if user.is_anonymous:
@@ -883,8 +857,6 @@ class SithFile(models.Model):
return user.is_board_member
if user.is_com_admin:
return True
if self.is_in_sas and user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):
return True
return user.id == self.owner_id
def can_be_viewed_by(self, user: User) -> bool:
@@ -911,8 +883,6 @@ class SithFile(models.Model):
super().clean()
if "/" in self.name:
raise ValidationError(_("Character '/' not authorized in name"))
if self == self.parent:
raise ValidationError(_("Loop in folder tree"), code="loop")
if self == self.parent or (
self.parent is not None and self in self.get_parent_list()
):
@@ -993,18 +963,6 @@ class SithFile(models.Model):
def is_file(self):
return not self.is_folder
@cached_property
def as_picture(self):
from sas.models import Picture
return Picture.objects.filter(id=self.id).first()
@cached_property
def as_album(self):
from sas.models import Album
return Album.objects.filter(id=self.id).first()
def get_parent_list(self):
parents = []
current = self.parent

View File

@@ -1,3 +1,4 @@
from datetime import datetime
from pathlib import Path
from typing import Annotated, Any
@@ -8,12 +9,14 @@ 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)]
class UploadedImage(UploadedFile):
@@ -107,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[]")
)
@@ -136,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

View File

@@ -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 });

View File

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

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

View File

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

View File

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

View File

@@ -78,12 +78,6 @@
{% endif %}
{% endmacro %}
{% macro delete_godfather(user, profile, godfather, is_father) %}
{% if user == profile or user.is_root or user.is_board_member %}
<a class="delete" href="{{ url("core:user_godfathers_delete", user_id=profile.id, godfather_id=godfather.id, is_father=is_father) }}">{% trans %}Delete{% endtrans %}</a>
{% endif %}
{% endmacro %}
{% macro paginate_alpine(page, nb_pages) %}
{# Add pagination buttons for ajax based content with alpine
@@ -157,12 +151,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>
@@ -180,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>
@@ -195,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>
@@ -249,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 }}&amp;
{%- endfor -%}
{%- endif -%}
{%- endfor -%}
{%- for key, value in kwargs.items() -%}
{{ key }}={{ value }}&amp;
{%- endfor -%}
{% endmacro %}

View File

@@ -3,7 +3,7 @@
{% block content %}
{% if target %}
<p>{% trans user=target.get_display_name() %}Change password for {{ user }}{% endtrans %}</p>
<p>{% trans user=form.user.get_display_name() %}Change password for {{ user }}{% endtrans %}</p>
{% endif %}
<form method="post" action="">
{% csrf_token %}

View File

@@ -9,19 +9,17 @@
{% block content %}
<h4>{% trans %}Users{% endtrans %}</h4>
<ul>
{% for i in result.users %}
{% if user.can_view(i) %}
{% for user in users %}
<li>
{{ user_link_with_pict(i) }}
{{ user_link_with_pict(user) }}
</li>
{% endif %}
{% endfor %}
</ul>
<h4>{% trans %}Clubs{% endtrans %}</h4>
<ul>
{% for i in result.clubs %}
{% for club in clubs %}
<li>
<a href="{{ url("club:club_view", club_id=i.id) }}">{{ i }}</a>
<a href="{{ url("club:club_view", club_id=club.id) }}">{{ club }}</a>
</li>
{% endfor %}
</ul>

View File

@@ -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() }}

View File

@@ -29,7 +29,16 @@
<a href="{{ url('core:user_godfathers', user_id=u.id) }}" class="mini_profile_link">
{{ u.get_mini_item() | safe }}
</a>
{{ delete_godfather(user, profile, u, True) }}
{% if user == profile or user.is_root or user.is_board_member %}
<form
method="post"
class="no-margin"
action="{{ url("core:user_godfathers_delete", user_id=profile.id, godfather_id=u.id, is_father=True) }}"
>
{% csrf_token %}
<input type="submit" class="link-like" value="{% trans %}Delete{% endtrans %}">
</form>
{% endif %}
</li>
{% endfor %}
</ul>
@@ -46,7 +55,16 @@
<a href="{{ url('core:user_godfathers', user_id=u.id) }}" class="mini_profile_link">
{{ u.get_mini_item()|safe }}
</a>
{{ delete_godfather(user, profile, u, False) }}
{% if user == profile or user.is_root or user.is_board_member %}
<form
method="post"
class="no-margin"
action="{{ url("core:user_godfathers_delete", user_id=profile.id, godfather_id=u.id, is_father=False) }}"
>
{% csrf_token %}
<input type="submit" class="link-like link-red" value="{% trans %}Delete{% endtrans %}">
</form>
{% endif %}
</li>
{% endfor %}
</ul>

View File

@@ -11,32 +11,35 @@
{% block content %}
<div class="container">
<div class="row">
{% if profile.permanencies %}
{% if total_perm_time %}
<div>
<h3>{% trans %}Permanencies{% endtrans %}</h3>
<div class="flexed">
<div><span>Foyer :</span><span>{{ total_foyer_time }}</span></div>
<div><span>Gommette :</span><span>{{ total_gommette_time }}</span></div>
<div><span>MDE :</span><span>{{ total_mde_time }}</span></div>
<div><b>Total :</b><b>{{ total_perm_time }}</b></div>
{% for perm in perm_time %}
<div>
<span>{{ perm["counter__name"] }} :</span>
<span>{{ perm["total"]|format_timedelta }}</span>
</div>
{% endfor %}
<div><b>Total :</b><b>{{ total_perm_time|format_timedelta }}</b></div>
</div>
</div>
{% endif %}
<div>
<h3>{% trans %}Buyings{% endtrans %}</h3>
<div class="flexed">
<div><span>Foyer :</span><span>{{ total_foyer_buyings }}&nbsp;€</span></div>
<div><span>Gommette :</span><span>{{ total_gommette_buyings }}&nbsp;€</span></div>
<div><span>MDE :</span><span>{{ total_mde_buyings }}&nbsp;€</span></div>
<div><b>Total :</b><b>{{ total_foyer_buyings + total_gommette_buyings + total_mde_buyings }}&nbsp;€</b>
</div>
</div>
</div>
</div>
{% for sum in purchase_sums %}
<div>
<h3>{% trans %}Product top 10{% endtrans %}</h3>
<span>{{ sum["counter__name"] }}</span>
<span>{{ sum["total"] }} €</span>
</div>
{% endfor %}
<div><b>Total : </b><b>{{ total_purchases }} €</b></div>
</div>
</div>
</div>
<div>
<h3>{% trans %}Product top 15{% endtrans %}</h3>
<table>
<thead>
<tr>

View File

@@ -184,18 +184,18 @@
</div>
{% endif %}
{% if user.has_perm("pedagogy.add_uv") or user.has_perm("pedagogy.delete_uvcomment") %}
{% if user.has_perm("pedagogy.add_ue") or user.has_perm("pedagogy.delete_uecomment") %}
<div>
<h4>{% trans %}Pedagogy{% endtrans %}</h4>
<ul>
{% if user.has_perm("pedagogy.add_uv") %}
{% if user.has_perm("pedagogy.add_ue") %}
<li>
<a href="{{ url("pedagogy:uv_create") }}">
{% trans %}Create UV{% endtrans %}
<a href="{{ url("pedagogy:ue_create") }}">
{% trans %}Create UE{% endtrans %}
</a>
</li>
{% endif %}
{% if user.has_perm("pedagogy.delete_uvcomment") %}
{% if user.has_perm("pedagogy.delete_uecomment") %}
<li>
<a href="{{ url("pedagogy:moderation") }}">
{% trans %}Moderate comments{% endtrans %}

View File

@@ -55,31 +55,17 @@ def phonenumber(
return value
@register.filter(name="truncate_time")
def truncate_time(value, time_unit):
"""Remove everything in the time format lower than the specified unit.
Args:
value: the value to truncate
time_unit: the lowest unit to display
"""
value = str(value)
return {
"millis": lambda: value.split(".")[0],
"seconds": lambda: value.rsplit(":", maxsplit=1)[0],
"minutes": lambda: value.split(":", maxsplit=1)[0],
"hours": lambda: value.rsplit(" ")[0],
}[time_unit]()
@register.filter(name="format_timedelta")
def format_timedelta(value: datetime.timedelta) -> str:
value = value - datetime.timedelta(microseconds=value.microseconds)
days = value.days
if days == 0:
return str(value)
remainder = value - datetime.timedelta(days=days)
return ngettext(
"%(nb_days)d day, %(remainder)s", "%(nb_days)d days, %(remainder)s", days
"%(nb_days)d day, %(remainder)s",
"%(nb_days)d days, %(remainder)s",
days,
) % {"nb_days": days, "remainder": str(remainder)}

View File

@@ -35,6 +35,7 @@ from pytest_django.asserts import assertInHTML, assertRedirects
from antispam.models import ToxicDomain
from club.models import Club, Membership
from core.baker_recipes import subscriber_user
from core.markdown import markdown
from core.models import AnonymousUser, Group, Page, User, validate_promo
from core.utils import get_last_promo, get_semester_code, get_start_of_semester
@@ -551,3 +552,10 @@ def test_allow_fragment_mixin():
assert not TestAllowFragmentView.as_view()(request)
request.headers = {"HX-Request": True, **base_headers}
assert TestAllowFragmentView.as_view()(request)
@pytest.mark.django_db
def test_search_view(client: Client):
client.force_login(subscriber_user.make())
response = client.get(reverse("core:search", query={"query": "foo"}))
assert response.status_code == 200

View File

@@ -5,6 +5,7 @@ from typing import Callable
from uuid import uuid4
import pytest
from django.conf import settings
from django.core.cache import cache
from django.core.files.uploadedfile import SimpleUploadedFile, UploadedFile
from django.test import Client, TestCase
@@ -17,8 +18,8 @@ from pytest_django.asserts import assertNumQueries
from core.baker_recipes import board_user, old_subscriber_user, subscriber_user
from core.models import Group, QuickUploadImage, SithFile, User
from core.utils import RED_PIXEL_PNG
from sas.baker_recipes import picture_recipe
from sas.models import Picture
from sith import settings
@pytest.mark.django_db
@@ -30,24 +31,19 @@ class TestImageAccess:
lambda: baker.make(
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
),
lambda: baker.make(
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)]
),
],
)
def test_sas_image_access(self, user_factory: Callable[[], User]):
"""Test that only authorized users can access the sas image."""
user = user_factory()
picture: SithFile = baker.make(
Picture, parent=SithFile.objects.get(pk=settings.SITH_SAS_ROOT_DIR_ID)
)
assert picture.is_owned_by(user)
picture = picture_recipe.make()
assert user.can_edit(picture)
def test_sas_image_access_owner(self):
"""Test that the owner of the image can access it."""
user = baker.make(User)
picture: Picture = baker.make(Picture, owner=user)
assert picture.is_owned_by(user)
picture = picture_recipe.make(owner=user)
assert user.can_edit(picture)
@pytest.mark.parametrize(
"user_factory",
@@ -63,7 +59,41 @@ class TestImageAccess:
user = user_factory()
owner = baker.make(User)
picture: Picture = baker.make(Picture, owner=owner)
assert not picture.is_owned_by(user)
assert not user.can_edit(picture)
@pytest.mark.django_db
class TestUserPicture:
def test_anonymous_user_unauthorized(self, client):
"""An anonymous user shouldn't have access to an user's photo page."""
response = client.get(
reverse(
"core:user_pictures",
kwargs={"user_id": User.objects.get(username="sli").pk},
)
)
assert response.status_code == 403
@pytest.mark.parametrize(
("username", "status"),
[
("guy", 403),
("root", 200),
("skia", 200),
("sli", 200),
],
)
def test_page_is_working(self, client, username, status):
"""Only user that subscribed (or admins) should be able to see the page."""
# Test for simple user
client.force_login(User.objects.get(username=username))
response = client.get(
reverse(
"core:user_pictures",
kwargs={"user_id": User.objects.get(username="sli").pk},
)
)
assert response.status_code == status
# TODO: many tests on the pages:

View File

@@ -0,0 +1,64 @@
from datetime import timedelta
from operator import attrgetter
import pytest
from bs4 import BeautifulSoup
from django.test import Client, TestCase
from django.urls import reverse
from django.utils.timezone import now
from model_bakery import baker, seq
from pytest_django.asserts import assertRedirects
from core.baker_recipes import subscriber_user
from core.models import Notification
@pytest.mark.django_db
class TestNotificationList(TestCase):
@classmethod
def setUpTestData(cls):
cls.user = subscriber_user.make()
url = reverse("core:user_profile", kwargs={"user_id": cls.user.id})
cls.notifs = baker.make(
Notification,
user=cls.user,
url=url,
viewed=False,
date=seq(now() - timedelta(days=1), timedelta(hours=1)),
_quantity=10,
_bulk_create=True,
)
def test_list(self):
self.client.force_login(self.user)
response = self.client.get(reverse("core:notification_list"))
assert response.status_code == 200
soup = BeautifulSoup(response.text, "lxml")
ul = soup.find("ul", id="notifications")
elements = list(ul.find_all("li"))
assert len(elements) == len(self.notifs)
notifs = sorted(self.notifs, key=attrgetter("date"), reverse=True)
for element, notif in zip(elements, notifs, strict=True):
assert element.find("a")["href"] == reverse(
"core:notification", kwargs={"notif_id": notif.id}
)
def test_read_all(self):
self.client.force_login(self.user)
response = self.client.get(
reverse("core:notification_list", query={"read_all": None})
)
assert response.status_code == 200
assert not self.user.notifications.filter(viewed=True).exists()
@pytest.mark.django_db
def test_notification_redirect(client: Client):
user = subscriber_user.make()
url = reverse("core:user_profile", kwargs={"user_id": user.id})
notif = baker.make(Notification, user=user, url=url, viewed=False)
client.force_login(user)
response = client.get(reverse("core:notification", kwargs={"notif_id": notif.id}))
assertRedirects(response, url)
notif.refresh_from_db()
assert notif.viewed is True

View File

@@ -1,3 +1,4 @@
import itertools
from datetime import timedelta
from unittest import mock
@@ -23,9 +24,10 @@ from core.baker_recipes import (
from core.models import AnonymousUser, Group, User
from core.views import UserTabsMixin
from counter.baker_recipes import sale_recipe
from counter.models import Counter, Customer, Refilling, Selling
from counter.models import Counter, Customer, Permanency, Refilling, Selling
from counter.utils import is_logged_in_counter
from eboutic.models import Invoice, InvoiceItem
from sas.models import Picture
class TestSearchUsers(TestCase):
@@ -33,6 +35,7 @@ class TestSearchUsers(TestCase):
def setUpTestData(cls):
# News.author has on_delete=PROTECT, so news must be deleted beforehand
News.objects.all().delete()
Picture.objects.all().delete() # same for pictures
User.objects.all().delete()
user_recipe = Recipe(
User,
@@ -187,11 +190,7 @@ class TestFilterInactive(TestCase):
time_inactive = time_active - timedelta(days=3)
counter, seller = baker.make(Counter), baker.make(User)
sale_recipe = Recipe(
Selling,
counter=counter,
club=counter.club,
seller=seller,
is_validated=True,
Selling, counter=counter, club=counter.club, seller=seller, unit_price=0
)
cls.users = [
@@ -428,3 +427,106 @@ class TestUserQuerySetViewableBy:
user = user_factory()
viewable = User.objects.filter(id__in=[u.id for u in users]).viewable_by(user)
assert not viewable.exists()
@pytest.mark.django_db
def test_user_preferences(client: Client):
user = subscriber_user.make()
client.force_login(user)
url = reverse("core:user_prefs", kwargs={"user_id": user.id})
response = client.get(url)
assert response.status_code == 200
response = client.post(url, {"notify_on_click": "true"})
assertRedirects(response, url)
user.preferences.refresh_from_db()
assert user.preferences.notify_on_click is True
@pytest.mark.django_db
def test_user_stats(client: Client):
user = subscriber_user.make()
baker.make(Refilling, customer=user.customer, amount=99999)
bars = [b[0] for b in settings.SITH_COUNTER_BARS]
baker.make(
Permanency,
end=now() - timedelta(days=5),
start=now() - timedelta(days=5, hours=3),
counter_id=itertools.cycle(bars),
_quantity=5,
_bulk_create=True,
)
sale_recipe.make(
counter_id=itertools.cycle(bars),
customer=user.customer,
unit_price=1,
quantity=1,
_quantity=5,
)
client.force_login(user)
response = client.get(reverse("core:user_stats", kwargs={"user_id": user.id}))
assert response.status_code == 200
@pytest.mark.django_db
class TestChangeUserPassword:
def test_as_root(self, client: Client, admin_user: User):
client.force_login(admin_user)
user = subscriber_user.make()
url = reverse("core:password_root_change", kwargs={"user_id": user.id})
response = client.get(url)
assert response.status_code == 200
response = client.post(
url, {"new_password1": "poutou", "new_password2": "poutou"}
)
assertRedirects(response, reverse("core:password_change_done"))
user.refresh_from_db()
assert user.check_password("poutou") is True
@pytest.mark.django_db
class TestUserGodfather:
@pytest.mark.parametrize("godfather", [True, False])
def test_add_family(self, client: Client, godfather):
user = subscriber_user.make()
other_user = subscriber_user.make()
client.force_login(user)
url = reverse("core:user_godfathers", kwargs={"user_id": user.id})
response = client.get(url)
assert response.status_code == 200
response = client.post(
url,
{"type": "godfather" if godfather else "godchild", "user": other_user.id},
)
assertRedirects(response, url)
if godfather:
assert user.godfathers.contains(other_user)
else:
assert user.godchildren.contains(other_user)
def test_tree(self, client: Client):
user = subscriber_user.make()
client.force_login(user)
response = client.get(
reverse("core:user_godfathers_tree", kwargs={"user_id": user.id})
)
assert response.status_code == 200
def test_remove_family(self, client: Client):
user = subscriber_user.make()
other_user = subscriber_user.make()
user.godfathers.add(other_user)
client.force_login(user)
response = client.post(
reverse(
"core:user_godfathers_delete",
kwargs={
"user_id": user.id,
"godfather_id": other_user.id,
"is_father": True,
},
)
)
assertRedirects(
response, reverse("core:user_godfathers", kwargs={"user_id": user.id})
)
assert not user.godfathers.contains(other_user)

View File

@@ -24,6 +24,7 @@
from django.urls import path, re_path, register_converter
from django.views.generic import RedirectView
from com.views import NewsListView
from core.converters import (
BooleanStringConverter,
FourDigitYearConverter,
@@ -53,6 +54,8 @@ from core.views import (
PagePropView,
PageRevView,
PageView,
PasswordRootChangeView,
SearchView,
SithLoginView,
SithPasswordChangeDoneView,
SithPasswordChangeView,
@@ -76,13 +79,8 @@ from core.views import (
UserUpdateProfileView,
UserView,
delete_user_godfather,
index,
logout,
notification,
password_root_change,
search_json,
search_user_json,
search_view,
send_file,
)
@@ -91,20 +89,18 @@ register_converter(TwoDigitMonthConverter, "mm")
register_converter(BooleanStringConverter, "bool")
urlpatterns = [
path("", index, name="index"),
path("", NewsListView.as_view(), name="index"),
path("notifications/", NotificationList.as_view(), name="notification_list"),
path("notification/<int:notif_id>/", notification, name="notification"),
# Search
path("search/", search_view, name="search"),
path("search_json/", search_json, name="search_json"),
path("search_user/", search_user_json, name="search_user"),
path("search/", SearchView.as_view(), name="search"),
# Login and co
path("login/", SithLoginView.as_view(), name="login"),
path("logout/", logout, name="logout"),
path("password_change/", SithPasswordChangeView.as_view(), name="password_change"),
path(
"password_change/<int:user_id>/",
password_root_change,
PasswordRootChangeView.as_view(),
name="password_root_change",
),
path(

View File

@@ -12,18 +12,23 @@
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
from dataclasses import dataclass
from datetime import date, timedelta
# Image utils
from io import BytesIO
from typing import Final
from typing import Any, Final, Unpack
import PIL
from django.conf import settings
from django.core.files.base import ContentFile
from django.core.files.uploadedfile import UploadedFile
from django.http import HttpRequest
from django.db import models
from django.forms import BaseForm
from django.http import Http404, HttpRequest
from django.shortcuts import get_list_or_404
from django.template.loader import render_to_string
from django.utils.safestring import SafeString
from django.utils.timezone import localdate
from PIL import ExifTags
from PIL.Image import Image, Resampling
@@ -42,6 +47,21 @@ to generate a dummy image that is considered valid nonetheless
"""
@dataclass
class FormFragmentTemplateData[T: BaseForm]:
"""Dataclass used to pre-render form fragments"""
form: T
template: str
context: dict[str, Any]
def render(self, request: HttpRequest) -> SafeString:
# Request is needed for csrf_tokens
return render_to_string(
self.template, context={"form": self.form, **self.context}, request=request
)
def get_start_of_semester(today: date | None = None) -> date:
"""Return the date of the start of the semester of the given date.
If no date is given, return the start date of the current semester.
@@ -205,3 +225,56 @@ def get_client_ip(request: HttpRequest) -> str | None:
return ip
return None
Filterable = models.Model | models.QuerySet | models.Manager
ListFilter = dict[str, list | tuple | set]
def get_list_exact_or_404(klass: Filterable, **kwargs: Unpack[ListFilter]) -> list:
"""Use filter() to return a list of objects from a list of unique keys (like ids)
or raises Http404 if the list has not the same length as the given one.
Work like `get_object_or_404()` but for lists of objects, with some caveats :
- The filter must be a list, a tuple or a set.
- There can't be more than exactly one filter.
- There must be no duplicate in the filter.
- The filter should consist in unique keys (like ids), or it could fail randomly.
klass may be a Model, Manager, or QuerySet object. All other passed
arguments and keyword arguments are used in the filter() query.
Raises:
Http404: If the list is empty or doesn't have as many elements as the keys list.
ValueError: If the first argument is not a Model, Manager, or QuerySet object.
ValueError: If more than one filter is passed.
TypeError: If the given filter is not a list, a tuple or a set.
Examples:
Get all the products with ids 1, 2, 3: ::
products = get_list_exact_or_404(Product, id__in=[1, 2, 3])
Don't work with duplicate ids: ::
products = get_list_exact_or_404(Product, id__in=[1, 2, 3, 3])
# Raises Http404: "The list of keys must contain no duplicates."
"""
if len(kwargs) > 1:
raise ValueError("get_list_exact_or_404() only accepts one filter.")
key, list_filter = next(iter(kwargs.items()))
if not isinstance(list_filter, (list, tuple, set)):
raise TypeError(
f"The given filter must be a list, a tuple or a set, not {type(list_filter)}"
)
if len(list_filter) != len(set(list_filter)):
raise ValueError("The list of keys must contain no duplicates.")
kwargs = {key: list_filter}
obj_list = get_list_or_404(klass, **kwargs)
if len(obj_list) != len(list_filter):
raise Http404(
"The given list of keys doesn't match the number of objects found."
f"Expected {len(list_filter)} items, got {len(obj_list)}."
)
return obj_list

View File

@@ -374,7 +374,7 @@ class FileDeleteView(AllowFragment, CanEditPropMixin, DeleteView):
class FileModerationView(AllowFragment, ListView):
model = SithFile
template_name = "core/file_moderation.jinja"
queryset = SithFile.objects.filter(is_moderated=False, is_in_sas=False)
queryset = SithFile.objects.filter(is_moderated=False)
ordering = "id"
paginate_by = 100

View File

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

View File

@@ -22,106 +22,49 @@
#
#
import json
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.core import serializers
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import F
from django.db.models.query import QuerySet
from django.http import JsonResponse
from django.shortcuts import redirect, render
from django.utils import html
from django.utils.text import slugify
from django.views.generic import ListView
from haystack.query import SearchQuerySet
from django.http import HttpRequest
from django.shortcuts import get_object_or_404, redirect
from django.views.generic import ListView, TemplateView
from club.models import Club
from core.models import Notification, User
from core.schemas import UserFilterSchema
def index(request, context=None):
from com.views import NewsListView
return NewsListView.as_view()(request)
class NotificationList(ListView):
class NotificationList(LoginRequiredMixin, ListView):
model = Notification
template_name = "core/notification_list.jinja"
def get_queryset(self) -> QuerySet[Notification]:
if self.request.user.is_anonymous:
return Notification.objects.none()
# TODO: Bulk update in django 2.2
if "see_all" in self.request.GET:
self.request.user.notifications.filter(viewed=False).update(viewed=True)
return self.request.user.notifications.order_by("-date")[:20]
def notification(request, notif_id):
notif = Notification.objects.filter(id=notif_id).first()
if notif:
def notification(request: HttpRequest, notif_id: int):
notif = get_object_or_404(Notification, id=notif_id)
if notif.type not in settings.SITH_PERMANENT_NOTIFICATIONS:
notif.viewed = True
else:
notif.callback()
notif.save()
return redirect(notif.url)
return redirect("/")
def search_user(query):
try:
# slugify turns everything into ascii and every whitespace into -
# it ends by removing duplicate - (so ' - ' will turn into '-')
# replace('-', ' ') because search is whitespace based
query = slugify(query).replace("-", " ")
# TODO: is this necessary?
query = html.escape(query)
res = (
SearchQuerySet()
.models(User)
.autocomplete(auto=query)
.order_by("-last_login")
.load_all()[:20]
class SearchView(LoginRequiredMixin, TemplateView):
template_name = "core/search.jinja"
def get_context_data(self, **kwargs):
users, clubs = [], []
if query := self.request.GET.get("query"):
users = list(
UserFilterSchema(search=query)
.filter(User.objects.viewable_by(self.request.user))
.order_by(F("last_login").desc(nulls_last=True))
)
return [r.object for r in res]
except TypeError:
return []
def search_club(query, *, as_json=False):
clubs = []
if query:
clubs = Club.objects.filter(name__icontains=query).all()
clubs = clubs[:5]
if as_json:
# Re-loads json to avoid double encoding by JsonResponse, but still benefit from serializers
clubs = json.loads(serializers.serialize("json", clubs, fields=("name")))
else:
clubs = list(clubs)
return clubs
@login_required
def search_view(request):
result = {
"users": search_user(request.GET.get("query", "")),
"clubs": search_club(request.GET.get("query", "")),
}
return render(request, "core/search.jinja", context={"result": result})
@login_required
def search_user_json(request):
result = {"users": search_user(request.GET.get("query", ""))}
return JsonResponse(result)
@login_required
def search_json(request):
result = {
"users": search_user(request.GET.get("query", "")),
"clubs": search_club(request.GET.get("query", ""), as_json=True),
}
return JsonResponse(result)
clubs = list(Club.objects.filter(name__icontains=query)[:5])
return super().get_context_data(**kwargs) | {"users": users, "clubs": clubs}

View File

@@ -22,27 +22,28 @@
#
#
import itertools
from datetime import timedelta
# This file contains all the views that concern the user model
from datetime import date, timedelta
from operator import itemgetter
from smtplib import SMTPException
from django.contrib.auth import login, views
from django.contrib.auth.forms import PasswordChangeForm
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.decorators import login_required
from django.contrib.auth.forms import PasswordChangeForm, SetPasswordForm
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.core.exceptions import PermissionDenied
from django.db.models import DateField, QuerySet
from django.db.models import DateField, F, QuerySet, Sum
from django.db.models.functions import Trunc
from django.forms.models import modelform_factory
from django.http import Http404
from django.shortcuts import get_object_or_404, redirect
from django.template.loader import render_to_string
from django.template.response import TemplateResponse
from django.urls import reverse, reverse_lazy
from django.utils.decorators import method_decorator
from django.utils.safestring import SafeString
from django.utils.translation import gettext as _
from django.views.decorators.http import require_POST
from django.views.generic import (
CreateView,
DeleteView,
@@ -66,9 +67,8 @@ from core.views.forms import (
UserProfileForm,
)
from core.views.mixins import TabedViewMixin, UseFragmentsMixin
from counter.models import Counter, Refilling, Selling
from counter.models import Refilling, Selling
from eboutic.models import Invoice
from subscription.models import Subscription
from trombi.views import UserTrombiForm
@@ -99,21 +99,23 @@ def logout(request):
return views.logout_then_login(request)
def password_root_change(request, user_id):
class PasswordRootChangeView(UserPassesTestMixin, FormView):
"""Allows a root user to change someone's password."""
if not request.user.is_root:
raise PermissionDenied
user = get_object_or_404(User, id=user_id)
if request.method == "POST":
form = views.SetPasswordForm(user=user, data=request.POST)
if form.is_valid():
template_name = "core/password_change.jinja"
form_class = SetPasswordForm
success_url = reverse_lazy("core:password_change_done")
def test_func(self):
return self.request.user.is_root
def get_form_kwargs(self):
user = get_object_or_404(User, id=self.kwargs["user_id"])
return super().get_form_kwargs() | {"user": user}
def form_valid(self, form: SetPasswordForm):
form.save()
return redirect("core:password_change_done")
else:
form = views.SetPasswordForm(user=user)
return TemplateResponse(
request, "core/password_change.jinja", {"form": form, "target": user}
)
return super().form_valid(form)
@method_decorator(check_honeypot, name="post")
@@ -288,10 +290,12 @@ class UserView(UserTabsMixin, CanViewMixin, DetailView):
return kwargs
@require_POST
@login_required
def delete_user_godfather(request, user_id, godfather_id, is_father):
user_is_admin = request.user.is_root or request.user.is_board_member
if user_id != request.user.id and not user_is_admin:
raise PermissionDenied()
raise PermissionDenied
user = get_object_or_404(User, id=user_id)
to_remove = get_object_or_404(User, id=godfather_id)
if is_father:
@@ -353,87 +357,40 @@ class UserStatsView(UserTabsMixin, CanViewMixin, DetailView):
context_object_name = "profile"
template_name = "core/user_stats.jinja"
current_tab = "stats"
queryset = User.objects.exclude(customer=None).select_related("customer")
def dispatch(self, request, *arg, **kwargs):
profile = self.get_object()
if not hasattr(profile, "customer"):
raise Http404
if not (
profile == request.user or request.user.has_perm("counter.view_customer")
):
raise PermissionDenied
return super().dispatch(request, *arg, **kwargs)
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
from django.db.models import Sum
foyer = Counter.objects.filter(name="Foyer").first()
mde = Counter.objects.filter(name="MDE").first()
gommette = Counter.objects.filter(name="La Gommette").first()
semester_start = Subscription.compute_start(d=date.today(), duration=3)
kwargs["perm_time"] = list(
self.object.permanencies.filter(end__isnull=False, counter__type="BAR")
.values("counter", "counter__name")
.annotate(total=Sum(F("end") - F("start"), default=timedelta(seconds=0)))
.order_by("-total")
)
kwargs["total_perm_time"] = sum(
[p.end - p.start for p in self.object.permanencies.exclude(end=None)],
timedelta(),
[perm["total"] for perm in kwargs["perm_time"]], start=timedelta(seconds=0)
)
kwargs["total_foyer_time"] = sum(
[
p.end - p.start
for p in self.object.permanencies.filter(counter=foyer).exclude(
end=None
)
],
timedelta(),
)
kwargs["total_mde_time"] = sum(
[
p.end - p.start
for p in self.object.permanencies.filter(counter=mde).exclude(end=None)
],
timedelta(),
)
kwargs["total_gommette_time"] = sum(
[
p.end - p.start
for p in self.object.permanencies.filter(counter=gommette).exclude(
end=None
)
],
timedelta(),
)
kwargs["total_foyer_buyings"] = sum(
[
b.unit_price * b.quantity
for b in self.object.customer.buyings.filter(
counter=foyer, date__gte=semester_start
)
]
)
kwargs["total_mde_buyings"] = sum(
[
b.unit_price * b.quantity
for b in self.object.customer.buyings.filter(
counter=mde, date__gte=semester_start
)
]
)
kwargs["total_gommette_buyings"] = sum(
[
b.unit_price * b.quantity
for b in self.object.customer.buyings.filter(
counter=gommette, date__gte=semester_start
)
]
kwargs["purchase_sums"] = list(
self.object.customer.buyings.filter(counter__type="BAR")
.values("counter", "counter__name")
.annotate(total=Sum(F("unit_price") * F("quantity")))
.order_by("-total")
)
kwargs["total_purchases"] = sum(s["total"] for s in kwargs["purchase_sums"])
kwargs["top_product"] = (
self.object.customer.buyings.values("product__name")
.annotate(product_sum=Sum("quantity"))
.exclude(product_sum=None)
.order_by("-product_sum")
.all()[:10]
.all()[:15]
)
return kwargs
@@ -465,7 +422,6 @@ class UserUpdateProfileView(UserTabsMixin, CanEditMixin, UpdateView):
form_class = UserProfileForm
current_tab = "edit"
edit_once = ["profile_pict", "date_of_birth", "first_name", "last_name"]
board_only = []
def remove_restricted_fields(self, request):
"""Removes edit_once and board_only fields."""
@@ -474,9 +430,6 @@ class UserUpdateProfileView(UserTabsMixin, CanEditMixin, UpdateView):
request.user.is_board_member or request.user.is_root
):
self.form.fields.pop(i, None)
for i in self.board_only:
if not (request.user.is_board_member or request.user.is_root):
self.form.fields.pop(i, None)
def get(self, request, *args, **kwargs):
self.object = self.get_object()
@@ -528,10 +481,10 @@ class UserPreferencesView(UserTabsMixin, UseFragmentsMixin, CanEditMixin, Update
current_tab = "prefs"
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
pref = self.object.preferences
kwargs.update({"instance": pref})
return kwargs
return super().get_form_kwargs() | {"instance": self.object.preferences}
def get_success_url(self):
return self.request.path
def get_fragment_context_data(self) -> dict[str, SafeString]:
# Avoid cyclic import error

View File

@@ -24,12 +24,6 @@
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
PAYMENT_METHOD = [
("CHECK", _("Check")),
("CASH", _("Cash")),
("CARD", _("Credit card")),
]
class CounterConfig(AppConfig):
name = "counter"

View File

@@ -1,7 +1,7 @@
import json
import math
import uuid
from datetime import date
from datetime import date, datetime, timezone
from dateutil.relativedelta import relativedelta
from django import forms
@@ -136,7 +136,10 @@ class GetUserForm(forms.Form):
class RefillForm(forms.ModelForm):
allowed_refilling_methods = ["CASH", "CARD"]
allowed_refilling_methods = [
Refilling.PaymentMethod.CASH,
Refilling.PaymentMethod.CARD,
]
error_css_class = "error"
required_css_class = "required"
@@ -146,7 +149,7 @@ class RefillForm(forms.ModelForm):
class Meta:
model = Refilling
fields = ["amount", "payment_method", "bank"]
fields = ["amount", "payment_method"]
widgets = {"payment_method": forms.RadioSelect}
def __init__(self, *args, **kwargs):
@@ -160,9 +163,6 @@ class RefillForm(forms.ModelForm):
if self.fields["payment_method"].initial not in self.allowed_refilling_methods:
self.fields["payment_method"].initial = self.allowed_refilling_methods[0]
if "CHECK" not in self.allowed_refilling_methods:
del self.fields["bank"]
class CounterEditForm(forms.ModelForm):
class Meta:
@@ -235,6 +235,19 @@ class ScheduledProductActionForm(forms.ModelForm):
)
return super().clean()
def set_product(self, product: Product):
"""Set the product to which this form's instance is linked.
When this form is linked to a ProductForm in the case of a product's creation,
the product doesn't exist yet, so saving this form as is will result
in having `{"product_id": null}` in the action kwargs.
For the creation to be useful, it may be needed to inject the newly created
product into this form, before saving the latter.
"""
self.product = product
kwargs = json.loads(self.instance.kwargs) | {"product_id": self.product.id}
self.instance.kwargs = json.dumps(kwargs)
class BaseScheduledProductActionFormSet(BaseModelFormSet):
def __init__(self, *args, product: Product, **kwargs):
@@ -321,11 +334,19 @@ class ProductForm(forms.ModelForm):
def is_valid(self):
return super().is_valid() and self.action_formset.is_valid()
def save(self, *args, **kwargs):
ret = super().save(*args, **kwargs)
self.instance.counters.set(self.cleaned_data["counters"])
def save(self, *args, **kwargs) -> Product:
product = super().save(*args, **kwargs)
product.counters.set(self.cleaned_data["counters"])
for form in self.action_formset:
# if it's a creation, the product given in the formset
# wasn't a persisted instance.
# So if we tried to persist the scheduled actions in the current state,
# they would be linked to no product, thus be completely useless
# To make it work, we have to replace
# the initial product with a persisted one
form.set_product(product)
self.action_formset.save()
return ret
return product
class ReturnableProductForm(forms.ModelForm):
@@ -369,7 +390,6 @@ class EticketForm(forms.ModelForm):
class CloseCustomerAccountForm(forms.Form):
user = forms.ModelChoiceField(
label=_("Refound this account"),
help_text=None,
required=True,
widget=AutoCompleteSelectUser,
queryset=User.objects.all(),
@@ -489,13 +509,14 @@ class InvoiceCallForm(forms.Form):
def __init__(self, *args, month: date, **kwargs):
super().__init__(*args, **kwargs)
self.month = month
month_start = datetime(month.year, month.month, month.day, tzinfo=timezone.utc)
self.clubs = list(
Club.objects.filter(
Exists(
Selling.objects.filter(
club=OuterRef("pk"),
date__gte=month,
date__lte=month + relativedelta(months=1),
date__gte=month_start,
date__lte=month_start + relativedelta(months=1),
)
)
).annotate(

View File

@@ -119,7 +119,6 @@ class Command(BaseCommand):
quantity=1,
unit_price=account.amount,
date=now(),
is_validated=True,
)
for account in accounts
]

View File

@@ -0,0 +1,84 @@
# Generated by Django 5.2.8 on 2025-11-19 17:59
from django.db import migrations, models
from django.db.migrations.state import StateApps
from django.db.models import Case, When
def migrate_selling_payment_method(apps: StateApps, schema_editor):
# 0 <=> SITH_ACCOUNT is the default value, so no need to migrate it
Selling = apps.get_model("counter", "Selling")
Selling.objects.filter(payment_method_str="CARD").update(payment_method=1)
def migrate_selling_payment_method_reverse(apps: StateApps, schema_editor):
Selling = apps.get_model("counter", "Selling")
Selling.objects.filter(payment_method=1).update(payment_method_str="CARD")
def migrate_refilling_payment_method(apps: StateApps, schema_editor):
Refilling = apps.get_model("counter", "Refilling")
Refilling.objects.update(
payment_method=Case(
When(payment_method_str="CARD", then=0),
When(payment_method_str="CASH", then=1),
When(payment_method_str="CHECK", then=2),
)
)
def migrate_refilling_payment_method_reverse(apps: StateApps, schema_editor):
Refilling = apps.get_model("counter", "Refilling")
Refilling.objects.update(
payment_method_str=Case(
When(payment_method=0, then="CARD"),
When(payment_method=1, then="CASH"),
When(payment_method=2, then="CHECK"),
)
)
class Migration(migrations.Migration):
dependencies = [("counter", "0034_alter_selling_date_selling_date_month_idx")]
operations = [
migrations.RemoveField(model_name="selling", name="is_validated"),
migrations.RemoveField(model_name="refilling", name="is_validated"),
migrations.RemoveField(model_name="refilling", name="bank"),
migrations.RenameField(
model_name="selling",
old_name="payment_method",
new_name="payment_method_str",
),
migrations.AddField(
model_name="selling",
name="payment_method",
field=models.PositiveSmallIntegerField(
choices=[(0, "Sith account"), (1, "Credit card")],
default=0,
verbose_name="payment method",
),
),
migrations.RunPython(
migrate_selling_payment_method, migrate_selling_payment_method_reverse
),
migrations.RemoveField(model_name="selling", name="payment_method_str"),
migrations.RenameField(
model_name="refilling",
old_name="payment_method",
new_name="payment_method_str",
),
migrations.AddField(
model_name="refilling",
name="payment_method",
field=models.PositiveSmallIntegerField(
choices=[(0, "Credit card"), (1, "Cash"), (2, "Check")],
default=0,
verbose_name="payment method",
),
),
migrations.RunPython(
migrate_refilling_payment_method, migrate_refilling_payment_method_reverse
),
migrations.RemoveField(model_name="refilling", name="payment_method_str"),
]

View File

@@ -44,7 +44,6 @@ from club.models import Club
from core.fields import ResizedImageField
from core.models import Group, Notification, User
from core.utils import get_start_of_semester
from counter.apps import PAYMENT_METHOD
from counter.fields import CurrencyField
from subscription.models import Subscription
@@ -80,7 +79,8 @@ class CustomerQuerySet(models.QuerySet):
)
money_out = Subquery(
Selling.objects.filter(
customer=OuterRef("pk"), payment_method="SITH_ACCOUNT"
customer=OuterRef("pk"),
payment_method=Selling.PaymentMethod.SITH_ACCOUNT,
)
.values("customer_id")
.annotate(res=Sum(F("unit_price") * F("quantity"), default=0))
@@ -731,6 +731,11 @@ class RefillingQuerySet(models.QuerySet):
class Refilling(models.Model):
"""Handle the refilling."""
class PaymentMethod(models.IntegerChoices):
CARD = 0, _("Credit card")
CASH = 1, _("Cash")
CHECK = 2, _("Check")
counter = models.ForeignKey(
Counter, related_name="refillings", blank=False, on_delete=models.CASCADE
)
@@ -745,16 +750,9 @@ class Refilling(models.Model):
Customer, related_name="refillings", blank=False, on_delete=models.CASCADE
)
date = models.DateTimeField(_("date"))
payment_method = models.CharField(
_("payment method"),
max_length=255,
choices=PAYMENT_METHOD,
default="CARD",
payment_method = models.PositiveSmallIntegerField(
_("payment method"), choices=PaymentMethod, default=PaymentMethod.CARD
)
bank = models.CharField(
_("bank"), max_length=255, choices=settings.SITH_COUNTER_BANK, default="OTHER"
)
is_validated = models.BooleanField(_("is validated"), default=False)
objects = RefillingQuerySet.as_manager()
@@ -771,10 +769,9 @@ class Refilling(models.Model):
if not self.date:
self.date = timezone.now()
self.full_clean()
if not self.is_validated:
if self._state.adding:
self.customer.amount += self.amount
self.customer.save()
self.is_validated = True
if self.customer.user.preferences.notify_on_refill:
Notification(
user=self.customer.user,
@@ -814,6 +811,10 @@ class SellingQuerySet(models.QuerySet):
class Selling(models.Model):
"""Handle the sellings."""
class PaymentMethod(models.IntegerChoices):
SITH_ACCOUNT = 0, _("Sith account")
CARD = 1, _("Credit card")
# We make sure that sellings have a way begger label than any product name is allowed to
label = models.CharField(_("label"), max_length=128)
product = models.ForeignKey(
@@ -850,13 +851,9 @@ class Selling(models.Model):
on_delete=models.SET_NULL,
)
date = models.DateTimeField(_("date"), db_index=True)
payment_method = models.CharField(
_("payment method"),
max_length=255,
choices=[("SITH_ACCOUNT", _("Sith account")), ("CARD", _("Credit card"))],
default="SITH_ACCOUNT",
payment_method = models.PositiveSmallIntegerField(
_("payment method"), choices=PaymentMethod, default=PaymentMethod.SITH_ACCOUNT
)
is_validated = models.BooleanField(_("is validated"), default=False)
objects = SellingQuerySet.as_manager()
@@ -875,10 +872,12 @@ class Selling(models.Model):
if not self.date:
self.date = timezone.now()
self.full_clean()
if not self.is_validated:
if (
self._state.adding
and self.payment_method == self.PaymentMethod.SITH_ACCOUNT
):
self.customer.amount -= self.quantity * self.unit_price
self.customer.save(allow_negative=allow_negative)
self.is_validated = True
user = self.customer.user
if user.was_subscribed:
if (
@@ -948,7 +947,9 @@ class Selling(models.Model):
def is_owned_by(self, user: User) -> bool:
if user.is_anonymous:
return False
return self.payment_method != "CARD" and user.is_owner(self.counter)
return self.payment_method != self.PaymentMethod.CARD and user.is_owner(
self.counter
)
def can_be_viewed_by(self, user: User) -> bool:
if (
@@ -958,7 +959,7 @@ class Selling(models.Model):
return user == self.customer.user
def delete(self, *args, **kwargs):
if self.payment_method == "SITH_ACCOUNT":
if self.payment_method == Selling.PaymentMethod.SITH_ACCOUNT:
self.customer.amount += self.quantity * self.unit_price
self.customer.save()
super().delete(*args, **kwargs)

View File

@@ -1,13 +1,12 @@
from datetime import datetime
from typing import Annotated, Self
from annotated_types import MinLen
from django.urls import reverse
from ninja import Field, FilterSchema, ModelSchema, Schema
from ninja import FilterLookup, FilterSchema, ModelSchema, Schema
from pydantic import model_validator
from club.schemas import SimpleClubSchema
from core.schemas import GroupSchema, SimpleUserSchema
from core.schemas import GroupSchema, NonEmptyStr, SimpleUserSchema
from counter.models import Counter, Product, ProductType
@@ -21,7 +20,7 @@ class CounterSchema(ModelSchema):
class CounterFilterSchema(FilterSchema):
search: Annotated[str, MinLen(1)] = Field(None, q="name__icontains")
search: Annotated[NonEmptyStr | None, FilterLookup("name__icontains")] = None
class SimplifiedCounterSchema(ModelSchema):
@@ -93,18 +92,18 @@ class ProductSchema(ModelSchema):
class ProductFilterSchema(FilterSchema):
search: Annotated[str, MinLen(1)] | None = Field(
None, q=["name__icontains", "code__icontains"]
)
is_archived: bool | None = Field(None, q="archived")
buying_groups: set[int] | None = Field(None, q="buying_groups__in")
product_type: set[int] | None = Field(None, q="product_type__in")
club: set[int] | None = Field(None, q="club__in")
counter: set[int] | None = Field(None, q="counters__in")
search: Annotated[
NonEmptyStr | None, FilterLookup(["name__icontains", "code__icontains"])
] = None
is_archived: Annotated[bool | None, FilterLookup("archived")] = None
buying_groups: Annotated[set[int] | None, FilterLookup("buying_groups__in")] = None
product_type: Annotated[set[int] | None, FilterLookup("product_type__in")] = None
club: Annotated[set[int] | None, FilterLookup("club__in")] = None
counter: Annotated[set[int] | None, FilterLookup("counters__in")] = None
class SaleFilterSchema(FilterSchema):
before: datetime | None = Field(None, q="date__lt")
after: datetime | None = Field(None, q="date__gt")
counters: set[int] | None = Field(None, q="counter__in")
products: set[int] | None = Field(None, q="product__in")
before: Annotated[datetime | None, FilterLookup("date__lt")] = None
after: Annotated[datetime | None, FilterLookup("date__gt")] = None
counters: Annotated[set[int] | None, FilterLookup("counter__in")] = None
products: Annotated[set[int] | None, FilterLookup("product__in")] = None

View File

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

View File

@@ -51,7 +51,7 @@
<td>{{ loop.index }}</td>
<td>{{ barman.name }} {% if barman.nickname %}({{ barman.nickname }}){% endif %}</td>
<td>{{ barman.promo or '' }}</td>
<td>{{ barman.perm_sum|format_timedelta|truncate_time("millis") }}</td>
<td>{{ barman.perm_sum|format_timedelta }}</td>
</tr>
{% endfor %}
</tbody>
@@ -73,7 +73,7 @@
<td>{{ loop.index }}</td>
<td>{{ barman.name }} {% if barman.nickname %}({{ barman.nickname }}){% endif %}</td>
<td>{{ barman.promo or '' }}</td>
<td>{{ barman.perm_sum|format_timedelta|truncate_time("millis") }}</td>
<td>{{ barman.perm_sum|format_timedelta }}</td>
</tr>
{% endfor %}
</tbody>

View File

@@ -116,7 +116,6 @@ class TestAccountDumpCommand(TestAccountDump):
operation: Selling = customer.buyings.order_by("date").last()
assert operation.unit_price == initial_amount
assert operation.counter_id == settings.SITH_COUNTER_ACCOUNT_DUMP_ID
assert operation.is_validated is True
dump = customer.dumps.last()
assert dump.dump_operation == operation

View File

@@ -11,8 +11,12 @@ from model_bakery import baker
from core.models import Group, User
from counter.baker_recipes import counter_recipe, product_recipe
from counter.forms import ScheduledProductActionForm, ScheduledProductActionFormSet
from counter.models import ScheduledProductAction
from counter.forms import (
ProductForm,
ScheduledProductActionForm,
ScheduledProductActionFormSet,
)
from counter.models import Product, ScheduledProductAction
@pytest.mark.django_db
@@ -34,6 +38,39 @@ def test_edit_product(client: Client):
assert res.status_code == 200
@pytest.mark.django_db
def test_create_actions_alongside_product():
"""The form should work when the product and the actions are created alongside."""
# non-persisted instance
product: Product = product_recipe.prepare(_save_related=True)
trigger_at = now() + timedelta(minutes=10)
form = ProductForm(
data={
"name": "foo",
"description": "bar",
"product_type": product.product_type_id,
"club": product.club_id,
"code": "FOO",
"purchase_price": 1.0,
"selling_price": 1.0,
"special_selling_price": 1.0,
"limit_age": 0,
"form-TOTAL_FORMS": "2",
"form-INITIAL_FORMS": "0",
"form-0-task": "counter.tasks.archive_product",
"form-0-trigger_at": trigger_at,
},
)
assert form.is_valid()
product = form.save()
action = ScheduledProductAction.objects.last()
assert action.clocked.clocked_time == trigger_at
assert action.enabled is True
assert action.one_off is True
assert action.task == "counter.tasks.archive_product"
assert action.kwargs == json.dumps({"product_id": product.id})
@pytest.mark.django_db
class TestProductActionForm:
def test_single_form_archive(self):

View File

@@ -53,7 +53,7 @@ def set_age(user: User, age: int):
def force_refill_user(user: User, amount: Decimal | int):
baker.make(Refilling, amount=amount, customer=user.customer, is_validated=False)
baker.make(Refilling, amount=amount, customer=user.customer)
class TestFullClickBase(TestCase):
@@ -115,18 +115,10 @@ class TestRefilling(TestFullClickBase):
) -> HttpResponse:
used_client = client if client is not None else self.client
return used_client.post(
reverse(
"counter:refilling_create",
kwargs={"customer_id": user.pk},
),
{
"amount": str(amount),
"payment_method": "CASH",
"bank": "OTHER",
},
reverse("counter:refilling_create", kwargs={"customer_id": user.pk}),
{"amount": str(amount), "payment_method": Refilling.PaymentMethod.CASH},
HTTP_REFERER=reverse(
"counter:click",
kwargs={"counter_id": counter.id, "user_id": user.pk},
"counter:click", kwargs={"counter_id": counter.id, "user_id": user.pk}
),
)
@@ -149,11 +141,7 @@ class TestRefilling(TestFullClickBase):
"counter:refilling_create",
kwargs={"customer_id": self.customer.pk},
),
{
"amount": "10",
"payment_method": "CASH",
"bank": "OTHER",
},
{"amount": "10", "payment_method": "CASH"},
)
self.client.force_login(self.club_admin)

View File

@@ -298,7 +298,6 @@ def test_update_balance():
_quantity=len(customers),
unit_price=10,
quantity=1,
payment_method="SITH_ACCOUNT",
_save_related=True,
),
*sale_recipe.prepare(
@@ -306,14 +305,12 @@ def test_update_balance():
_quantity=3,
unit_price=5,
quantity=2,
payment_method="SITH_ACCOUNT",
_save_related=True,
),
sale_recipe.prepare(
customer=customers[4],
quantity=1,
unit_price=50,
payment_method="SITH_ACCOUNT",
_save_related=True,
),
*sale_recipe.prepare(
@@ -324,7 +321,7 @@ def test_update_balance():
_quantity=len(customers),
unit_price=50,
quantity=1,
payment_method="CARD",
payment_method=Selling.PaymentMethod.CARD,
_save_related=True,
),
]

View File

@@ -6,7 +6,7 @@ from django.contrib.auth.models import Permission
from django.core.exceptions import ValidationError
from django.test import Client
from django.urls import reverse
from django.utils.timezone import localdate
from django.utils.timezone import now
from model_bakery import baker
from pytest_django.asserts import assertRedirects
@@ -57,7 +57,7 @@ def test_invoice_call_view(client: Client, query: dict | None):
@pytest.mark.django_db
def test_invoice_call_form():
Selling.objects.all().delete()
month = localdate() - relativedelta(months=1)
month = now() - relativedelta(months=1)
clubs = baker.make(Club, _quantity=2)
recipe = sale_recipe.extend(date=month, customer=baker.make(Customer, amount=10000))
recipe.make(club=clubs[0], quantity=2, unit_price=200)

View File

@@ -12,7 +12,7 @@
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
from datetime import timedelta
from datetime import datetime, timedelta
from django.conf import settings
from django.contrib.auth.mixins import PermissionRequiredMixin, UserPassesTestMixin
@@ -23,6 +23,7 @@ from django.forms.models import modelform_factory
from django.shortcuts import get_object_or_404
from django.urls import reverse, reverse_lazy
from django.utils import timezone
from django.utils.timezone import get_current_timezone
from django.utils.translation import gettext as _
from django.views.generic import DetailView, ListView, TemplateView
from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView
@@ -285,7 +286,13 @@ class CounterStatView(PermissionRequiredMixin, DetailView):
def get_context_data(self, **kwargs):
"""Add stats to the context."""
counter: Counter = self.object
semester_start = get_start_of_semester()
start_date = get_start_of_semester()
semester_start = datetime(
start_date.year,
start_date.month,
start_date.day,
tzinfo=get_current_timezone(),
)
office_hours = counter.get_top_barmen()
kwargs = super().get_context_data(**kwargs)
kwargs.update(

View File

@@ -12,7 +12,7 @@
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
from datetime import datetime
from datetime import datetime, timezone
from urllib.parse import urlencode
from dateutil.relativedelta import relativedelta
@@ -63,19 +63,18 @@ class InvoiceCallView(
"""Add sums to the context."""
kwargs = super().get_context_data(**kwargs)
kwargs["months"] = Selling.objects.datetimes("date", "month", order="DESC")
start_date = self.get_month()
month = self.get_month()
start_date = datetime(month.year, month.month, month.day, tzinfo=timezone.utc)
end_date = start_date + relativedelta(months=1)
kwargs["sum_cb"] = Refilling.objects.filter(
payment_method="CARD",
is_validated=True,
payment_method=Refilling.PaymentMethod.CARD,
date__gte=start_date,
date__lte=end_date,
).aggregate(res=Sum("amount", default=0))["res"]
kwargs["sum_cb"] += (
Selling.objects.filter(
payment_method="CARD",
is_validated=True,
payment_method=Selling.PaymentMethod.CARD,
date__gte=start_date,
date__lte=end_date,
)

View File

@@ -177,7 +177,7 @@ from django.conf import settings
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("pedagogy", "0003_alter_uv_language"),
("pedagogy", "0003_alter_ue_language"),
]
operations = [
@@ -215,11 +215,12 @@ On modifie donc le modèle :
```python
from django.db import models
from core.models import User
from pedagogy.models import UV
from pedagogy.models import UE
class UserUe(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
ue = models.ForeignKey(UV, on_delete=models.CASCADE)
ue = models.ForeignKey(UE, on_delete=models.CASCADE)
```
On refait la commande `makemigrations` et on obtient :
@@ -237,7 +238,7 @@ class Migration(migrations.Migration):
model_name="userue",
name="ue",
field=models.ForeignKey(
on_delete=models.deletion.CASCADE, to="pedagogy.uv"
on_delete=models.deletion.CASCADE, to="pedagogy.ue"
),
),
]
@@ -280,7 +281,7 @@ python ./manage.py squasmigrations <app> <migration de début (incluse)> <migrat
Par exemple, dans notre cas, ça donnera :
```bash
python ./manage.py squasmigrations pedagogy 0004 0005
python ./manage.py squashmigrations pedagogy 0004 0005
```
La commande vous donnera ceci :
@@ -292,7 +293,7 @@ class Migration(migrations.Migration):
replaces = [("pedagogy", "0004_userue"), ("pedagogy", "0005_alter_userue_ue")]
dependencies = [
("pedagogy", "0003_alter_uv_language"),
("pedagogy", "0003_alter_ue_language"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
@@ -312,7 +313,7 @@ class Migration(migrations.Migration):
(
"ue",
models.ForeignKey(
on_delete=models.deletion.CASCADE, to="pedagogy.uv"
on_delete=models.deletion.CASCADE, to="pedagogy.ue"
),
),
(

View File

@@ -263,3 +263,35 @@ avec un unique champ permettant de sélectionner des groupes.
Par défaut, seuls les utilisateurs avec la permission
`auth.change_permission` auront accès à ce formulaire
(donc, normalement, uniquement les utilisateurs Root).
```mermaid
sequenceDiagram
participant A as Utilisateur
participant B as ReverseProxy
participant C as MarkdownImage
participant D as Model
A->>B: GET /page/foo
B->>C: GET /page/foo
C-->>B: La page, avec les urls
B-->>A: La page, avec les urls
alt image publique
A->>B: GET markdown/public/2025/img.webp
B-->>A: img.webp
end
alt image privée
A->>B: GET markdown_image/{id}
B->>C: GET markdown_image/{id}
C->>D: user.can_view(image)
alt l'utilisateur a le droit de voir l'image
D-->>C: True
C-->>B: 200 (avec le X-Accel-Redirect)
B-->>A: img.webp
end
alt l'utilisateur n'a pas le droit de l'image
D-->>C: False
C-->>B: 403
B-->>A: 403
end
end
```

View File

@@ -110,7 +110,9 @@ class Basket(models.Model):
)["total"]
)
def generate_sales(self, counter, seller: User, payment_method: str):
def generate_sales(
self, counter, seller: User, payment_method: Selling.PaymentMethod
):
"""Generate a list of sold items corresponding to the items
of this basket WITHOUT saving them NOR deleting the basket.
@@ -251,8 +253,7 @@ class Invoice(models.Model):
customer=customer,
operator=self.user,
amount=i.product_unit_price * i.quantity,
payment_method="CARD",
bank="OTHER",
payment_method=Refilling.PaymentMethod.CARD,
date=self.date,
)
new.save()
@@ -267,8 +268,7 @@ class Invoice(models.Model):
customer=customer,
unit_price=i.product_unit_price,
quantity=i.quantity,
payment_method="CARD",
is_validated=True,
payment_method=Selling.PaymentMethod.CARD,
date=self.date,
)
new.save()

View File

@@ -108,12 +108,22 @@ def test_eboutic_basket_expiry(
client.force_login(customer.user)
for date in sellings:
if sellings:
sale_recipe.make(
customer=customer, counter=eboutic, date=date, is_validated=True
customer=customer,
counter=eboutic,
date=iter(sellings),
_quantity=len(sellings),
_bulk_create=True,
)
if refillings:
refill_recipe.make(
customer=customer,
counter=eboutic,
date=iter(refillings),
_quantity=len(refillings),
_bulk_create=True,
)
for date in refillings:
refill_recipe.make(customer=customer, counter=eboutic, date=date)
assert (
f'x-data="basket({int(expected.timestamp() * 1000) if expected else "null"})"'

View File

@@ -114,13 +114,13 @@ class TestPaymentSith(TestPaymentBase):
"quantity"
)
assert len(sellings) == 2
assert sellings[0].payment_method == "SITH_ACCOUNT"
assert sellings[0].payment_method == Selling.PaymentMethod.SITH_ACCOUNT
assert sellings[0].quantity == 1
assert sellings[0].unit_price == self.snack.selling_price
assert sellings[0].counter.type == "EBOUTIC"
assert sellings[0].product == self.snack
assert sellings[1].payment_method == "SITH_ACCOUNT"
assert sellings[1].payment_method == Selling.PaymentMethod.SITH_ACCOUNT
assert sellings[1].quantity == 2
assert sellings[1].unit_price == self.beer.selling_price
assert sellings[1].counter.type == "EBOUTIC"
@@ -198,13 +198,13 @@ class TestPaymentCard(TestPaymentBase):
"quantity"
)
assert len(sellings) == 2
assert sellings[0].payment_method == "CARD"
assert sellings[0].payment_method == Selling.PaymentMethod.CARD
assert sellings[0].quantity == 1
assert sellings[0].unit_price == self.snack.selling_price
assert sellings[0].counter.type == "EBOUTIC"
assert sellings[0].product == self.snack
assert sellings[1].payment_method == "CARD"
assert sellings[1].payment_method == Selling.PaymentMethod.CARD
assert sellings[1].quantity == 2
assert sellings[1].unit_price == self.beer.selling_price
assert sellings[1].counter.type == "EBOUTIC"

View File

@@ -275,7 +275,9 @@ class EbouticPayWithSith(CanViewMixin, SingleObjectMixin, View):
return redirect("eboutic:payment_result", "failure")
eboutic = get_eboutic()
sales = basket.generate_sales(eboutic, basket.user, "SITH_ACCOUNT")
sales = basket.generate_sales(
eboutic, basket.user, Selling.PaymentMethod.SITH_ACCOUNT
)
try:
with transaction.atomic():
# Selling.save has some important business logic in it.

View File

@@ -25,13 +25,12 @@ import warnings
from datetime import timedelta
from typing import Final, Optional
from django.conf import settings
from django.core.files.base import ContentFile
from django.core.management.base import BaseCommand
from django.utils import timezone
from club.models import Club, Membership
from core.models import Group, Page, SithFile, User
from core.models import Group, Page, User
from core.utils import RED_PIXEL_PNG
from sas.models import Album, PeoplePictureRelation, Picture
from subscription.models import Subscription
@@ -91,13 +90,8 @@ class Command(BaseCommand):
self.NB_CLUBS = options["club_count"]
root = User.objects.filter(username="root").first()
sas = SithFile.objects.get(id=settings.SITH_SAS_ROOT_DIR_ID)
self.galaxy_album = Album.objects.create(
name="galaxy-register-file",
owner=root,
is_moderated=True,
is_in_sas=True,
parent=sas,
name="galaxy-register-file", owner=root, is_moderated=True
)
self.make_clubs()
@@ -285,14 +279,10 @@ class Command(BaseCommand):
owner=u,
name=f"galaxy-picture {u} {i // self.NB_USERS}",
is_moderated=True,
is_folder=False,
parent=self.galaxy_album,
is_in_sas=True,
file=ContentFile(RED_PIXEL_PNG),
original=ContentFile(RED_PIXEL_PNG),
compressed=ContentFile(RED_PIXEL_PNG),
thumbnail=ContentFile(RED_PIXEL_PNG),
mime_type="image/png",
size=len(RED_PIXEL_PNG),
)
)
self.picts[i].file.name = self.picts[i].name

File diff suppressed because it is too large Load Diff

54
matmat/forms.py Normal file
View 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"))

View File

@@ -1,12 +1,18 @@
{% if is_fragment %}
{% extends "core/base_fragment.jinja" %}
{% else %}
{% extends "core/base.jinja" %}
{% from "core/macros.jinja" import user_mini_profile, paginate_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 %}

View File

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

View File

@@ -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"),
]

View File

@@ -16,192 +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.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.db.models import F
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.views import search_user
from core.views.forms import SelectDate
# Enum to select search type
from core.models import User, UserQuerySet
from core.schemas import UserFilterSchema
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 = search_user(self.valid_form["quick"])
else:
q = []
if not self.can_see_hidden and len(q) > 0:
q = [user for user in q if user.is_viewable]
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)

View File

@@ -23,35 +23,35 @@
from django.contrib import admin
from haystack.admin import SearchModelAdmin
from pedagogy.models import UV, UVComment, UVCommentReport
from pedagogy.models import UE, UEComment, UECommentReport
@admin.register(UV)
class UVAdmin(admin.ModelAdmin):
@admin.register(UE)
class UEAdmin(admin.ModelAdmin):
list_display = ("code", "title", "credit_type", "credits", "department")
search_fields = ("code", "title", "department")
autocomplete_fields = ("author",)
@admin.register(UVComment)
class UVCommentAdmin(admin.ModelAdmin):
list_display = ("author", "uv", "grade_global", "publish_date")
@admin.register(UEComment)
class UECommentAdmin(admin.ModelAdmin):
list_display = ("author", "ue", "grade_global", "publish_date")
search_fields = (
"author__username",
"author__first_name",
"author__last_name",
"uv__code",
"ue__code",
)
autocomplete_fields = ("author",)
@admin.register(UVCommentReport)
class UVCommentReportAdmin(SearchModelAdmin):
list_display = ("reporter", "uv")
@admin.register(UECommentReport)
class UECommentReportAdmin(SearchModelAdmin):
list_display = ("reporter", "ue")
search_fields = (
"reporter__username",
"reporter__first_name",
"reporter__last_name",
"comment__uv__code",
"comment__ue__code",
)
autocomplete_fields = ("reporter",)

View File

@@ -10,23 +10,23 @@ from ninja_extra.pagination import PageNumberPaginationExtra, PaginatedResponseS
from api.auth import ApiKeyAuth
from api.permissions import HasPerm
from pedagogy.models import UV
from pedagogy.schemas import SimpleUvSchema, UvFilterSchema, UvSchema
from pedagogy.models import UE
from pedagogy.schemas import SimpleUeSchema, UeFilterSchema, UeSchema
from pedagogy.utbm_api import UtbmApiClient
@api_controller("/uv")
class UvController(ControllerBase):
@api_controller("/ue")
class UeController(ControllerBase):
@route.get(
"/{code}",
auth=[ApiKeyAuth(), SessionAuth()],
permissions=[
# this route will almost always be called in the context
# of a UV creation/edition
HasPerm(["pedagogy.add_uv", "pedagogy.change_uv"], op=operator.or_)
# of a UE creation/edition
HasPerm(["pedagogy.add_ue", "pedagogy.change_ue"], op=operator.or_)
],
url_name="fetch_uv_from_utbm",
response=UvSchema,
url_name="fetch_ue_from_utbm",
response=UeSchema,
)
def fetch_from_utbm_api(
self,
@@ -34,20 +34,20 @@ class UvController(ControllerBase):
lang: Query[str] = "fr",
year: Query[Annotated[int, Ge(2010)] | None] = None,
):
"""Fetch UV data from the UTBM API and returns it after some parsing."""
"""Fetch UE data from the UTBM API and returns it after some parsing."""
with UtbmApiClient() as client:
res = client.find_uv(lang, code, year)
res = client.find_ue(lang, code, year)
if res is None:
raise NotFound
return res
@route.get(
"",
response=PaginatedResponseSchema[SimpleUvSchema],
url_name="fetch_uvs",
response=PaginatedResponseSchema[SimpleUeSchema],
url_name="fetch_ues",
auth=[ApiKeyAuth(), SessionAuth()],
permissions=[HasPerm("pedagogy.view_uv")],
permissions=[HasPerm("pedagogy.view_ue")],
)
@paginate(PageNumberPaginationExtra, page_size=100)
def fetch_uv_list(self, search: Query[UvFilterSchema]):
return search.filter(UV.objects.order_by("code").values())
def fetch_ue_list(self, search: Query[UeFilterSchema]):
return search.filter(UE.objects.order_by("code"))

View File

@@ -26,14 +26,14 @@ from django.utils.translation import gettext_lazy as _
from core.models import User
from core.views.widgets.markdown import MarkdownInput
from pedagogy.models import UV, UVComment, UVCommentReport
from pedagogy.models import UE, UEComment, UECommentReport
class UVForm(forms.ModelForm):
"""Form handeling creation and edit of an UV."""
class UEForm(forms.ModelForm):
"""Form handeling creation and edit of an UE."""
class Meta:
model = UV
model = UE
fields = (
"code",
"author",
@@ -82,14 +82,14 @@ class StarList(forms.NumberInput):
return context
class UVCommentForm(forms.ModelForm):
"""Form handeling creation and edit of an UVComment."""
class UECommentForm(forms.ModelForm):
"""Form handeling creation and edit of an UEComment."""
class Meta:
model = UVComment
model = UEComment
fields = (
"author",
"uv",
"ue",
"grade_global",
"grade_utility",
"grade_interest",
@@ -100,7 +100,7 @@ class UVCommentForm(forms.ModelForm):
widgets = {
"comment": MarkdownInput,
"author": forms.HiddenInput,
"uv": forms.HiddenInput,
"ue": forms.HiddenInput,
"grade_global": StarList(5),
"grade_utility": StarList(5),
"grade_interest": StarList(5),
@@ -108,35 +108,35 @@ class UVCommentForm(forms.ModelForm):
"grade_work_load": StarList(5),
}
def __init__(self, author_id, uv_id, is_creation, *args, **kwargs):
def __init__(self, author_id, ue_id, is_creation, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["author"].queryset = User.objects.filter(id=author_id).all()
self.fields["author"].initial = author_id
self.fields["uv"].queryset = UV.objects.filter(id=uv_id).all()
self.fields["uv"].initial = uv_id
self.fields["ue"].queryset = UE.objects.filter(id=ue_id).all()
self.fields["ue"].initial = ue_id
self.is_creation = is_creation
def clean(self):
self.cleaned_data = super().clean()
uv = self.cleaned_data.get("uv")
ue = self.cleaned_data.get("ue")
author = self.cleaned_data.get("author")
if self.is_creation and uv and author and uv.has_user_already_commented(author):
if self.is_creation and ue and author and ue.has_user_already_commented(author):
self.add_error(
None,
forms.ValidationError(
_("This user has already commented on this UV"), code="invalid"
_("This user has already commented on this UE"), code="invalid"
),
)
return self.cleaned_data
class UVCommentReportForm(forms.ModelForm):
"""Form handeling creation and edit of an UVReport."""
class UECommentReportForm(forms.ModelForm):
"""Form handeling creation and edit of an UEReport."""
class Meta:
model = UVCommentReport
model = UECommentReport
fields = ("comment", "reporter", "reason")
widgets = {
"comment": forms.HiddenInput,
@@ -148,22 +148,22 @@ class UVCommentReportForm(forms.ModelForm):
super().__init__(*args, **kwargs)
self.fields["reporter"].queryset = User.objects.filter(id=reporter_id).all()
self.fields["reporter"].initial = reporter_id
self.fields["comment"].queryset = UVComment.objects.filter(id=comment_id).all()
self.fields["comment"].queryset = UEComment.objects.filter(id=comment_id).all()
self.fields["comment"].initial = comment_id
class UVCommentModerationForm(forms.Form):
class UECommentModerationForm(forms.Form):
"""Form handeling bulk comment deletion."""
accepted_reports = forms.ModelMultipleChoiceField(
UVCommentReport.objects.all(),
UECommentReport.objects.all(),
label=_("Accepted reports"),
widget=forms.CheckboxSelectMultiple,
required=False,
)
denied_reports = forms.ModelMultipleChoiceField(
UVCommentReport.objects.all(),
UECommentReport.objects.all(),
label=_("Denied reports"),
widget=forms.CheckboxSelectMultiple,
required=False,

View File

@@ -2,36 +2,36 @@ from django.conf import settings
from django.core.management import BaseCommand
from core.models import User
from pedagogy.models import UV
from pedagogy.schemas import UvSchema
from pedagogy.models import UE
from pedagogy.schemas import UeSchema
from pedagogy.utbm_api import UtbmApiClient
class Command(BaseCommand):
help = "Update the UV guide"
help = "Update the UE guide"
def handle(self, *args, **options):
seen_uvs: set[int] = set()
seen_ues: set[int] = set()
root_user = User.objects.get(pk=settings.SITH_ROOT_USER_ID)
with UtbmApiClient() as client:
self.stdout.write(
"Fetching UVs from the UTBM API.\n"
"Fetching UEs from the UTBM API.\n"
"This may take a few minutes to complete."
)
for uv in client.fetch_uvs():
db_uv = UV.objects.filter(code=uv.code).first()
if db_uv is None:
db_uv = UV(code=uv.code, author=root_user)
fields = list(UvSchema.model_fields.keys())
for ue in client.fetch_ues():
db_ue = UE.objects.filter(code=ue.code).first()
if db_ue is None:
db_ue = UE(code=ue.code, author=root_user)
fields = list(UeSchema.model_fields.keys())
fields.remove("id")
fields.remove("code")
for field in fields:
setattr(db_uv, field, getattr(uv, field))
db_uv.save()
setattr(db_ue, field, getattr(ue, field))
db_ue.save()
# if it's a creation, django will set the id when saving,
# so at this point, a db_uv will always have an id
seen_uvs.add(db_uv.id)
# UVs that are in database but have not been returned by the API
# so at this point, a db_ue will always have an id
seen_ues.add(db_ue.id)
# UEs that are in database but have not been returned by the API
# are considered as closed UEs
UV.objects.exclude(id__in=seen_uvs).update(semester="CLOSED")
self.stdout.write(self.style.SUCCESS("UV guide updated successfully"))
UE.objects.exclude(id__in=seen_ues).update(semester="CLOSED")
self.stdout.write(self.style.SUCCESS("UE guide updated successfully"))

View File

@@ -0,0 +1,140 @@
# Generated by Django 4.2.20 on 2025-04-08 10:12
import django.core.validators
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("pedagogy", "0003_alter_uv_language"),
]
operations = [
migrations.RenameModel(old_name="UV", new_name="UE"),
migrations.RenameModel(old_name="UVComment", new_name="UEComment"),
migrations.RenameModel(old_name="UVCommentReport", new_name="UECommentReport"),
migrations.RenameModel(old_name="UVResult", new_name="UEResult"),
migrations.RenameField(model_name="ueresult", old_name="uv", new_name="ue"),
migrations.RenameField(model_name="uecomment", old_name="uv", new_name="ue"),
migrations.AlterField(
model_name="ue",
name="credits",
field=models.PositiveIntegerField(verbose_name="credits"),
),
migrations.AlterField(
model_name="ue",
name="hours_CM",
field=models.PositiveIntegerField(default=0, verbose_name="hours CM"),
),
migrations.AlterField(
model_name="ue",
name="hours_TD",
field=models.PositiveIntegerField(default=0, verbose_name="hours TD"),
),
migrations.AlterField(
model_name="ue",
name="hours_TE",
field=models.PositiveIntegerField(default=0, verbose_name="hours TE"),
),
migrations.AlterField(
model_name="ue",
name="hours_THE",
field=models.PositiveIntegerField(default=0, verbose_name="hours THE"),
),
migrations.AlterField(
model_name="ue",
name="hours_TP",
field=models.PositiveIntegerField(default=0, verbose_name="hours TP"),
),
migrations.AlterField(
model_name="ue",
name="manager",
field=models.CharField(max_length=300, verbose_name="ue manager"),
),
migrations.AlterField(
model_name="ueresult",
name="ue",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="results",
to="pedagogy.ue",
verbose_name="ue",
),
),
migrations.AlterField(
model_name="ue",
name="author",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="ue_created",
to=settings.AUTH_USER_MODEL,
verbose_name="author",
),
),
migrations.AlterField(
model_name="ue",
name="code",
field=models.CharField(
max_length=10,
unique=True,
validators=[
django.core.validators.RegexValidator(
message=(
"The code of an UE must only contains "
"uppercase characters without accent and numbers"
),
regex="([A-Z0-9]+)",
)
],
verbose_name="code",
),
),
migrations.AlterField(
model_name="uecomment",
name="author",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="ue_comments",
to=settings.AUTH_USER_MODEL,
verbose_name="author",
),
),
migrations.AlterField(
model_name="uecomment",
name="comment",
field=models.TextField(blank=True, default="", verbose_name="comment"),
),
migrations.AlterField(
model_name="uecomment",
name="ue",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="comments",
to="pedagogy.ue",
verbose_name="ue",
),
),
migrations.AlterField(
model_name="uecommentreport",
name="reporter",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="reported_ue_comment",
to=settings.AUTH_USER_MODEL,
verbose_name="reporter",
),
),
migrations.AlterField(
model_name="ueresult",
name="user",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="ue_results",
to=settings.AUTH_USER_MODEL,
verbose_name="user",
),
),
]

View File

@@ -36,8 +36,8 @@ from core.models import User
# Create your models here.
class UV(models.Model):
"""Contains infos about an UV (course)."""
class UE(models.Model):
"""Contains infos about an UE (course)."""
code = models.CharField(
_("code"),
@@ -47,7 +47,7 @@ class UV(models.Model):
validators.RegexValidator(
regex="([A-Z0-9]+)",
message=_(
"The code of an UV must only contains "
"The code of an UE must only contains "
"uppercase characters without accent and numbers"
),
)
@@ -55,7 +55,7 @@ class UV(models.Model):
)
author = models.ForeignKey(
User,
related_name="uv_created",
related_name="ue_created",
verbose_name=_("author"),
null=False,
blank=False,
@@ -64,29 +64,23 @@ class UV(models.Model):
credit_type = models.CharField(
_("credit type"),
max_length=10,
choices=settings.SITH_PEDAGOGY_UV_TYPE,
default=settings.SITH_PEDAGOGY_UV_TYPE[0][0],
choices=settings.SITH_PEDAGOGY_UE_TYPE,
default=settings.SITH_PEDAGOGY_UE_TYPE[0][0],
)
manager = models.CharField(_("uv manager"), max_length=300)
manager = models.CharField(_("ue manager"), max_length=300)
semester = models.CharField(
_("semester"),
max_length=20,
choices=settings.SITH_PEDAGOGY_UV_SEMESTER,
default=settings.SITH_PEDAGOGY_UV_SEMESTER[0][0],
choices=settings.SITH_PEDAGOGY_UE_SEMESTER,
default=settings.SITH_PEDAGOGY_UE_SEMESTER[0][0],
)
language = models.CharField(
_("language"),
max_length=10,
choices=settings.SITH_PEDAGOGY_UV_LANGUAGE,
default=settings.SITH_PEDAGOGY_UV_LANGUAGE[0][0],
choices=settings.SITH_PEDAGOGY_UE_LANGUAGE,
default=settings.SITH_PEDAGOGY_UE_LANGUAGE[0][0],
)
credits = models.IntegerField(
_("credits"),
validators=[validators.MinValueValidator(0)],
blank=False,
null=False,
)
# Double star type not implemented yet
credits = models.PositiveIntegerField(_("credits"))
department = models.CharField(
_("departmenmt"),
@@ -95,9 +89,9 @@ class UV(models.Model):
default=settings.SITH_PROFILE_DEPARTMENTS[-1][0],
)
# All texts about the UV
# All texts about the UE
title = models.CharField(_("title"), max_length=300)
manager = models.CharField(_("uv manager"), max_length=300)
manager = models.CharField(_("ue manager"), max_length=300)
objectives = models.TextField(_("objectives"))
program = models.TextField(_("program"))
skills = models.TextField(_("skills"))
@@ -105,47 +99,17 @@ class UV(models.Model):
# Hours types CM, TD, TP, THE and TE
# Kind of dirty but I have nothing else in mind for now
hours_CM = models.IntegerField(
_("hours CM"),
validators=[validators.MinValueValidator(0)],
blank=False,
null=False,
default=0,
)
hours_TD = models.IntegerField(
_("hours TD"),
validators=[validators.MinValueValidator(0)],
blank=False,
null=False,
default=0,
)
hours_TP = models.IntegerField(
_("hours TP"),
validators=[validators.MinValueValidator(0)],
blank=False,
null=False,
default=0,
)
hours_THE = models.IntegerField(
_("hours THE"),
validators=[validators.MinValueValidator(0)],
blank=False,
null=False,
default=0,
)
hours_TE = models.IntegerField(
_("hours TE"),
validators=[validators.MinValueValidator(0)],
blank=False,
null=False,
default=0,
)
hours_CM = models.PositiveIntegerField(_("hours CM"), default=0)
hours_TD = models.PositiveIntegerField(_("hours TD"), default=0)
hours_TP = models.PositiveIntegerField(_("hours TP"), default=0)
hours_THE = models.PositiveIntegerField(_("hours THE"), default=0)
hours_TE = models.PositiveIntegerField(_("hours TE"), default=0)
def __str__(self):
return self.code
def get_absolute_url(self):
return reverse("pedagogy:uv_detail", kwargs={"uv_id": self.id})
return reverse("pedagogy:ue_detail", kwargs={"ue_id": self.id})
def __grade_average_generic(self, field):
comments = self.comments.filter(**{field + "__gte": 0})
@@ -160,7 +124,7 @@ class UV(models.Model):
This function checks that no other comment has been posted by a specified user.
Returns:
True if the user has already posted a comment on this UV, else False.
True if the user has already posted a comment on this UE, else False.
"""
return self.comments.filter(author=user).exists()
@@ -185,78 +149,66 @@ class UV(models.Model):
return self.__grade_average_generic("grade_work_load")
class UVCommentQuerySet(models.QuerySet):
class UECommentQuerySet(models.QuerySet):
def viewable_by(self, user: User) -> Self:
if user.has_perms(["pedagogy.view_uvcomment", "pedagogy.view_uvcommentreport"]):
# the user can view uv comment reports,
if user.has_perms(["pedagogy.view_uecomment", "pedagogy.view_uecommentreport"]):
# the user can view ue comment reports,
# so he can view non-moderated comments
return self
if user.has_perm("pedagogy.view_uvcomment"):
if user.has_perm("pedagogy.view_uecomment"):
return self.filter(reports=None)
return self.filter(author=user)
def annotate_is_reported(self) -> Self:
return self.annotate(
is_reported=Exists(UVCommentReport.objects.filter(comment=OuterRef("pk")))
is_reported=Exists(UECommentReport.objects.filter(comment=OuterRef("pk")))
)
class UVComment(models.Model):
"""A comment about an UV."""
class UEComment(models.Model):
"""A comment about an UE."""
author = models.ForeignKey(
User,
related_name="uv_comments",
related_name="ue_comments",
verbose_name=_("author"),
null=False,
blank=False,
on_delete=models.CASCADE,
)
uv = models.ForeignKey(
UV, related_name="comments", verbose_name=_("uv"), on_delete=models.CASCADE
ue = models.ForeignKey(
UE, related_name="comments", verbose_name=_("ue"), on_delete=models.CASCADE
)
comment = models.TextField(_("comment"), blank=True)
comment = models.TextField(_("comment"), blank=True, default="")
grade_global = models.IntegerField(
_("global grade"),
validators=[validators.MinValueValidator(-1), validators.MaxValueValidator(4)],
blank=False,
null=False,
default=-1,
)
grade_utility = models.IntegerField(
_("utility grade"),
validators=[validators.MinValueValidator(-1), validators.MaxValueValidator(4)],
blank=False,
null=False,
default=-1,
)
grade_interest = models.IntegerField(
_("interest grade"),
validators=[validators.MinValueValidator(-1), validators.MaxValueValidator(4)],
blank=False,
null=False,
default=-1,
)
grade_teaching = models.IntegerField(
_("teaching grade"),
validators=[validators.MinValueValidator(-1), validators.MaxValueValidator(4)],
blank=False,
null=False,
default=-1,
)
grade_work_load = models.IntegerField(
_("work load grade"),
validators=[validators.MinValueValidator(-1), validators.MaxValueValidator(4)],
blank=False,
null=False,
default=-1,
)
publish_date = models.DateTimeField(_("publish date"), blank=True)
objects = UVCommentQuerySet.as_manager()
objects = UECommentQuerySet.as_manager()
def __str__(self):
return f"{self.uv} - {self.author}"
return f"{self.ue} - {self.author}"
def save(self, *args, **kwargs):
if self.publish_date is None:
@@ -268,30 +220,32 @@ class UVComment(models.Model):
# to use this model.
# However, it seems that the implementation finally didn't happen.
# It should be discussed, when possible, of what to do with that :
# - go on and finally implement the UV results features ?
# - go on and finally implement the UE results features ?
# - or fuck go back and remove this model ?
class UVResult(models.Model):
"""Results got to an UV.
class UEResult(models.Model):
"""Results got to an UE.
Views will be implemented after the first release
Will list every UV done by an user
Linked to user
uv
Contains a grade settings.SITH_PEDAGOGY_UV_RESULT_GRADE
Will list every UE done by an user
Linked to user and ue
Contains a grade settings.SITH_PEDAGOGY_UE_RESULT_GRADE
a semester (P/A)20xx.
"""
uv = models.ForeignKey(
UV, related_name="results", verbose_name=_("uv"), on_delete=models.CASCADE
ue = models.ForeignKey(
UE, related_name="results", verbose_name=_("ue"), on_delete=models.CASCADE
)
user = models.ForeignKey(
User, related_name="uv_results", verbose_name=("user"), on_delete=models.CASCADE
User,
related_name="ue_results",
verbose_name=_("user"),
on_delete=models.CASCADE,
)
grade = models.CharField(
_("grade"),
max_length=10,
choices=settings.SITH_PEDAGOGY_UV_RESULT_GRADE,
default=settings.SITH_PEDAGOGY_UV_RESULT_GRADE[0][0],
choices=settings.SITH_PEDAGOGY_UE_RESULT_GRADE,
default=settings.SITH_PEDAGOGY_UE_RESULT_GRADE[0][0],
)
semester = models.CharField(
_("semester"),
@@ -300,21 +254,21 @@ class UVResult(models.Model):
)
def __str__(self):
return f"{self.user.username} ; {self.uv.code} ; {self.grade}"
return f"{self.user.username} ; {self.ue.code} ; {self.grade}"
class UVCommentReport(models.Model):
class UECommentReport(models.Model):
"""Report an inapropriate comment."""
comment = models.ForeignKey(
UVComment,
UEComment,
related_name="reports",
verbose_name=_("report"),
on_delete=models.CASCADE,
)
reporter = models.ForeignKey(
User,
related_name="reported_uv_comment",
related_name="reported_ue_comment",
verbose_name=_("reporter"),
on_delete=models.CASCADE,
)
@@ -324,5 +278,5 @@ class UVCommentReport(models.Model):
return f"{self.reporter.username} : {self.reason}"
@cached_property
def uv(self):
return self.comment.uv
def ue(self):
return self.comment.ue

View File

@@ -1,17 +1,18 @@
from typing import Literal
from typing import Annotated, Literal
from django.db.models import Q
from django.urls import reverse
from django.utils import html
from haystack.query import SearchQuerySet
from ninja import FilterSchema, ModelSchema, Schema
from ninja import FilterLookup, FilterSchema, ModelSchema, Schema
from pydantic import AliasPath, ConfigDict, Field, TypeAdapter
from pydantic.alias_generators import to_camel
from pedagogy.models import UV
from pedagogy.models import UE
class UtbmShortUvSchema(Schema):
"""Short representation of an UV in the UTBM API.
class UtbmShortUeSchema(Schema):
"""Short representation of an UE in the UTBM API.
Notes:
This schema holds only the fields we actually need.
@@ -35,8 +36,8 @@ class WorkloadSchema(Schema):
nbh: int
class SemesterUvState(Schema):
"""The state of the UV during either autumn or spring semester"""
class SemesterUeState(Schema):
"""The state of the UE during either autumn or spring semester"""
model_config = ConfigDict(alias_generator=to_camel)
@@ -44,11 +45,11 @@ class SemesterUvState(Schema):
ouvert: bool
ShortUvList = TypeAdapter(list[UtbmShortUvSchema])
ShortUeList = TypeAdapter(list[UtbmShortUeSchema])
class UtbmFullUvSchema(Schema):
"""Long representation of an UV in the UTBM API."""
class UtbmFullUeSchema(Schema):
"""Long representation of an UE in the UTBM API."""
model_config = ConfigDict(alias_generator=to_camel)
@@ -71,11 +72,11 @@ class UtbmFullUvSchema(Schema):
)
class SimpleUvSchema(ModelSchema):
"""Our minimal representation of an UV."""
class SimpleUeSchema(ModelSchema):
"""Our minimal representation of an UE."""
class Meta:
model = UV
model = UE
fields = [
"id",
"title",
@@ -85,12 +86,28 @@ class SimpleUvSchema(ModelSchema):
"department",
]
detail_url: str
edit_url: str
delete_url: str
class UvSchema(ModelSchema):
"""Our complete representation of an UV"""
@staticmethod
def resolve_detail_url(obj: UE) -> str:
return reverse("pedagogy:ue_detail", kwargs={"ue_id": obj.id})
@staticmethod
def resolve_edit_url(obj: UE) -> str:
return reverse("pedagogy:ue_update", kwargs={"ue_id": obj.id})
@staticmethod
def resolve_delete_url(obj: UE) -> str:
return reverse("pedagogy:ue_delete", kwargs={"ue_id": obj.id})
class UeSchema(ModelSchema):
"""Our complete representation of an UE"""
class Meta:
model = UV
model = UE
fields = [
"id",
"title",
@@ -113,14 +130,15 @@ class UvSchema(ModelSchema):
]
class UvFilterSchema(FilterSchema):
search: str | None = Field(None, q="code__icontains")
class UeFilterSchema(FilterSchema):
search: Annotated[str | None, FilterLookup("code__icontains")] = None
semester: set[Literal["AUTUMN", "SPRING"]] | None = None
credit_type: set[Literal["CS", "TM", "EC", "OM", "QC"]] | None = Field(
None, q="credit_type__in"
)
credit_type: Annotated[
set[Literal["CS", "TM", "EC", "OM", "QC"]] | None,
FilterLookup("credit_type__in"),
] = None
language: str = "FR"
department: set[str] | None = Field(None, q="department__in")
department: Annotated[set[str] | None, FilterLookup("department__in")] = None
def filter_search(self, value: str | None) -> Q:
"""Special filter for the search text.
@@ -131,12 +149,12 @@ class UvFilterSchema(FilterSchema):
return Q()
if len(value) < 3 or (len(value) < 5 and any(c.isdigit() for c in value)):
# Likely to be an UV code
# Likely to be an UE code
return Q(code__istartswith=value)
qs = list(
SearchQuerySet()
.models(UV)
.models(UE)
.autocomplete(auto=html.escape(value))
.values_list("pk", flat=True)
)
@@ -146,7 +164,7 @@ class UvFilterSchema(FilterSchema):
def filter_semester(self, value: set[str] | None) -> Q:
"""Special filter for the semester.
If either "SPRING" or "AUTUMN" is given, UV that are available
If either "SPRING" or "AUTUMN" is given, UE that are available
during "AUTUMN_AND_SPRING" will be filtered.
"""
if not value:

View File

@@ -25,28 +25,28 @@ from django.db import models
from haystack import indexes, signals
from core.search_indexes import BigCharFieldIndex
from pedagogy.models import UV
from pedagogy.models import UE
class IndexSignalProcessor(signals.BaseSignalProcessor):
"""Auto update index on CRUD operations."""
def setup(self):
# Listen only to the ``UV`` model.
models.signals.post_save.connect(self.handle_save, sender=UV)
models.signals.post_delete.connect(self.handle_delete, sender=UV)
# Listen only to the ``UE`` model.
models.signals.post_save.connect(self.handle_save, sender=UE)
models.signals.post_delete.connect(self.handle_delete, sender=UE)
def teardown(self):
# Disconnect only to the ``UV`` model.
models.signals.post_save.disconnect(self.handle_save, sender=UV)
models.signals.post_delete.disconnect(self.handle_delete, sender=UV)
# Disconnect only to the ``UE`` model.
models.signals.post_save.disconnect(self.handle_save, sender=UE)
models.signals.post_delete.disconnect(self.handle_delete, sender=UE)
class UVIndex(indexes.SearchIndex, indexes.Indexable):
"""Indexer class for UVs."""
class UEIndex(indexes.SearchIndex, indexes.Indexable):
"""Indexer class for UEs."""
text = BigCharFieldIndex(document=True, use_template=True)
auto = indexes.EdgeNgramField(use_template=True)
def get_model(self):
return UV
return UE

View File

@@ -1,12 +1,12 @@
import { History, getCurrentUrlParams, updateQueryString } from "#core:utils/history";
import { uvFetchUvList } from "#openapi";
import { ueFetchUeList } from "#openapi";
const pageDefault = 1;
const pageSizeDefault = 100;
document.addEventListener("alpine:init", () => {
Alpine.data("uv_search", () => ({
uvs: {
Alpine.data("ue_search", () => ({
ues: {
count: 0,
next: null,
previous: null,
@@ -103,16 +103,12 @@ document.addEventListener("alpine:init", () => {
args[param] = value;
}
}
this.uvs = (
await uvFetchUvList({
query: args,
})
).data;
this.ues = (await ueFetchUeList({ query: args })).data;
this.loading = false;
},
maxPage() {
return Math.ceil(this.uvs.count / this.page_size);
return Math.ceil(this.ues.count / this.page_size);
},
}));
});

View File

@@ -50,7 +50,7 @@ $large-devices: 992px;
}
}
#uv-list {
#ue-list {
font-size: 1.1em;
overflow-wrap: break-word;
@@ -164,10 +164,10 @@ $large-devices: 992px;
}
}
#uv_detail {
#ue_detail {
color: #062f38;
.uv-quick-info-container {
.ue-quick-info-container {
display: grid;
grid-template-columns: 20% 20% 20% 20% auto;
grid-template-rows: auto auto;
@@ -254,20 +254,20 @@ $large-devices: 992px;
}
}
.uv-details-container {
.ue-details-container {
display: grid;
grid-template-columns: 150px 100px auto;
grid-template-rows: 156px 1fr;
grid-template-areas:
"grade grade-stars uv-infos"
". . uv-infos";
"grade grade-stars ue-infos"
". . ue-infos";
@media screen and (max-width: $large-devices) {
grid-template-columns: 50% 50%;
grid-template-rows: auto auto;
grid-template-areas:
"grade grade-stars"
"uv-infos uv-infos";
"ue-infos ue-infos";
}
}
@@ -290,8 +290,8 @@ $large-devices: 992px;
font-weight: bold;
}
.uv-infos {
grid-area: uv-infos;
.ue-infos {
grid-area: ue-infos;
padding-left: 10px;
}

View File

@@ -23,10 +23,10 @@
{% endblock head %}
{% block content %}
{% if user.has_perm("pedagogy.add_uv") %}
{% if user.has_perm("pedagogy.add_ue") %}
<div class="action-bar">
<p>
<a href="{{ url('pedagogy:uv_create') }}">{% trans %}Create UV{% endtrans %}</a>
<a href="{{ url('pedagogy:ue_create') }}">{% trans %}Create UE{% endtrans %}</a>
</p>
<p>
<a href="{{ url('pedagogy:moderation') }}">{% trans %}Moderate comments{% endtrans %}</a>
@@ -34,7 +34,7 @@
</div>
<br/>
{% endif %}
<div class="pedagogy" x-data="uv_search" x-cloak>
<div class="pedagogy" x-data="ue_search" x-cloak>
<form id="search_form">
<div class="search-form-container">
<div class="search-bar">
@@ -89,43 +89,43 @@
</div>
</div>
</form>
<table id="uv-list">
<table id="ue-list">
<thead>
<tr>
<td>{% trans %}UV{% endtrans %}</td>
<td>{% trans %}UE{% endtrans %}</td>
<td>{% trans %}Title{% endtrans %}</td>
<td>{% trans %}Department{% endtrans %}</td>
<td>{% trans %}Credit type{% endtrans %}</td>
<td><i class="fa fa-leaf"></i></td>
<td><i class="fa-regular fa-sun"></i></td>
{%- if user.has_perm("pedagogy.change_uv") -%}
{%- if user.has_perm("pedagogy.change_ue") -%}
<td>{% trans %}Edit{% endtrans %}</td>
{%- endif -%}
{%- if user.has_perm("pedagogy.delete_uv") -%}
{%- if user.has_perm("pedagogy.delete_ue") -%}
<td>{% trans %}Delete{% endtrans %}</td>
{% endif %}
</tr>
</thead>
<tbody :aria-busy="loading">
<template x-for="uv in uvs.results" :key="uv.id">
<template x-for="ue in ues.results" :key="ue.id">
<tr
@click="window.location.href = `/pedagogy/uv/${uv.id}`"
@click="window.location.href = ue.detail_url"
class="clickable"
:class="{closed: uv.semester === 'CLOSED'}"
:class="{closed: ue.semester === 'CLOSED'}"
>
<td><a :href="`/pedagogy/uv/${uv.id}`" x-text="uv.code"></a></td>
<td><a :href="`/pedagogy/ue/${ue.id}`" x-text="ue.code"></a></td>
<td class="title"
x-text="uv.title + (uv.semester === 'CLOSED' ? ' ({% trans %}closed uv{% endtrans %})' : '')"
x-text="ue.title + (ue.semester === 'CLOSED' ? ' ({% trans %}closed ue{% endtrans %})' : '')"
></td>
<td x-text="uv.department"></td>
<td x-text="uv.credit_type"></td>
<td><i :class="uv.semester.includes('AUTUMN') && 'fa fa-leaf'"></i></td>
<td><i :class="uv.semester.includes('SPRING') && 'fa-regular fa-sun'"></i></td>
{%- if user.has_perm("pedagogy.change_uv") -%}
<td><a :href="`/pedagogy/uv/${uv.id}/edit`">{% trans %}Edit{% endtrans %}</a></td>
<td x-text="ue.department"></td>
<td x-text="ue.credit_type"></td>
<td><i :class="ue.semester.includes('AUTUMN') && 'fa fa-leaf'"></i></td>
<td><i :class="ue.semester.includes('SPRING') && 'fa-regular fa-sun'"></i></td>
{%- if user.has_perm("pedagogy.change_ue") -%}
<td><a :href="ue.edit_url">{% trans %}Edit{% endtrans %}</a></td>
{%- endif -%}
{%- if user.has_perm("pedagogy.delete_uv") -%}
<td><a :href="`/pedagogy/uv/${uv.id}/delete`">{% trans %}Delete{% endtrans %}</a></td>
{%- if user.has_perm("pedagogy.delete_ue") -%}
<td><a :href="ue.delete_url">{% trans %}Delete{% endtrans %}</a></td>
{%- endif -%}
</tr>
</template>

View File

@@ -1,7 +1,7 @@
{% extends "core/base.jinja" %}
{% block title %}
{% trans %}UV comment moderation{% endtrans %}
{% trans %}UE comment moderation{% endtrans %}
{% endblock title %}
{% block content %}
@@ -9,7 +9,7 @@
<table>
<thead>
<tr>
<td>{% trans %}UV{% endtrans %}</td>
<td>{% trans %}UE{% endtrans %}</td>
<td>{% trans %}Comment{% endtrans %}</td>
<td>{% trans %}Reason{% endtrans %}</td>
<td>{% trans %}Action{% endtrans %}</td>
@@ -22,7 +22,7 @@
<form action="{{ url('pedagogy:moderation') }}" method="post" enctype="multipart/form-data">
{% csrf_token %}
<tr>
<td><a href="{{ url('pedagogy:uv_detail', uv_id=report.comment.uv.id) }}#{{ report.comment.uv.id }}">{{ report.comment.uv }}</a></td>
<td><a href="{{ url('pedagogy:ue_detail', ue_id=report.comment.ue_id) }}#{{ report.comment.ue_id }}">{{ report.comment.ue }}</a></td>
<td>{{ report.comment.comment|markdown }}</td>
<td>{{ report.reason|markdown }}</td>
<td>

View File

@@ -7,12 +7,12 @@
{% endblock %}
{% block title %}
{% trans %}UV Details{% endtrans %}
{% trans %}UE Details{% endtrans %}
{% endblock %}
{% block content %}
<div class="pedagogy">
<div id="uv_detail">
<div id="ue_detail">
<button onclick='(function(){
// If comes from the guide page, go back with history
if (document.referrer.replace(/\?(.+)/gm,"").endsWith(`{{ url("pedagogy:guide") }}`)){
@@ -25,7 +25,7 @@
<h1>{{ object.code }} - {{ object.title }}</h1>
<br>
<div class="uv-quick-info-container">
<div class="ue-quick-info-container">
<div class="hours-cm">
<b>{% trans %}CM: {% endtrans %}</b>{{ object.hours_CM }}
</div>
@@ -55,7 +55,7 @@
<br>
<div class="uv-details-container">
<div class="ue-details-container">
<div class="grade">
<p>{% trans %}Global grade{% endtrans %}</p>
<p>{% trans %}Utility{% endtrans %}</p>
@@ -70,7 +70,7 @@
<p>{{ display_star(object.grade_teaching_average) }}</p>
<p>{{ display_star(object.grade_work_load_average) }}</p>
</div>
<div class="uv-infos">
<div class="ue-infos">
<p><b>{% trans %}Objectives{% endtrans %}</b></p>
<p>{{ object.objectives|markdown }}</p>
<p><b>{% trans %}Program{% endtrans %}</b></p>
@@ -86,21 +86,21 @@
<br>
{% if object.has_user_already_commented(user) %}
<div id="leave_comment_not_allowed">
<p>{% trans %}You already posted a comment on this UV. If you want to comment again, please modify or delete your previous comment.{% endtrans %}</p>
<p>{% trans %}You already posted a comment on this UE. If you want to comment again, please modify or delete your previous comment.{% endtrans %}</p>
</div>
{% elif user.has_perm("pedagogy.add_uvcomment") %}
{% elif user.has_perm("pedagogy.add_uecomment") %}
<details class="accordion" id="leave_comment">
<summary>{% trans %}Leave comment{% endtrans %}</summary>
<div class="accordion-content">
<form action="{{ url('pedagogy:uv_detail', uv_id=object.id) }}" method="post" enctype="multipart/form-data">
<form action="{{ url('pedagogy:ue_detail', ue_id=object.id) }}" method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="leave-comment-grid-container">
<div class="form-stars">
{{ form.author.errors }}
{{ form.uv.errors }}
{{ form.ue.errors }}
{{ form.author }}
{{ form.uv }}
{{ form.ue }}
<div class="input-stars">
<label for="{{ form.grade_global.id_for_label }}">{{ form.grade_global.label }} :</label>
@@ -170,7 +170,7 @@
<div class="comment">
<div class="anchor">
<a href="{{ url('pedagogy:uv_detail', uv_id=uv.id) }}#{{ comment.id }}"><i class="fa fa-paragraph"></i></a>
<a href="{{ url('pedagogy:ue_detail', ue_id=ue.id) }}#{{ comment.id }}"><i class="fa fa-paragraph"></i></a>
</div>
{{ comment.comment|markdown }}
</div>

View File

@@ -9,19 +9,19 @@ from model_bakery.recipe import Recipe
from core.baker_recipes import subscriber_user
from core.models import Group, User
from pedagogy.models import UV
from pedagogy.models import UE
class TestUVSearch(TestCase):
"""Test UV guide rights for view and API."""
class TestUESearch(TestCase):
"""Test UE guide rights for view and API."""
@classmethod
def setUpTestData(cls):
cls.root = User.objects.get(username="root")
cls.url = reverse("api:fetch_uvs")
uv_recipe = Recipe(UV, author=cls.root)
uvs = [
uv_recipe.prepare(
cls.url = reverse("api:fetch_ues")
ue_recipe = Recipe(UE, author=cls.root)
ues = [
ue_recipe.prepare(
code="AP4A",
credit_type="CS",
semester="AUTUMN",
@@ -32,7 +32,7 @@ class TestUVSearch(TestCase):
"Concepts fondamentaux et mise en pratique avec le langage C++"
),
),
uv_recipe.prepare(
ue_recipe.prepare(
code="MT01",
credit_type="CS",
semester="AUTUMN",
@@ -40,10 +40,10 @@ class TestUVSearch(TestCase):
manager="ben",
title="Intégration1. Algèbre linéaire - Fonctions de deux variables",
),
uv_recipe.prepare(
ue_recipe.prepare(
code="PHYS11", credit_type="CS", semester="AUTUMN", department="TC"
),
uv_recipe.prepare(
ue_recipe.prepare(
code="TNEV",
credit_type="TM",
semester="SPRING",
@@ -51,10 +51,10 @@ class TestUVSearch(TestCase):
manager="moss",
title="tnetennba",
),
uv_recipe.prepare(
ue_recipe.prepare(
code="MT10", credit_type="TM", semester="AUTUMN", department="IMSI"
),
uv_recipe.prepare(
ue_recipe.prepare(
code="DA50",
credit_type="TM",
semester="AUTUMN_AND_SPRING",
@@ -62,7 +62,7 @@ class TestUVSearch(TestCase):
manager="francky",
),
]
UV.objects.bulk_create(uvs)
UE.objects.bulk_create(ues)
call_command("update_index")
def test_permissions(self):
@@ -93,7 +93,7 @@ class TestUVSearch(TestCase):
"""Test that the return data format is correct"""
self.client.force_login(self.root)
res = self.client.get(self.url + "?search=PA00")
uv = UV.objects.get(code="PA00")
ue = UE.objects.get(code="PA00")
assert res.status_code == 200
assert json.loads(res.content) == {
"count": 1,
@@ -101,12 +101,19 @@ class TestUVSearch(TestCase):
"previous": None,
"results": [
{
"id": uv.id,
"title": uv.title,
"code": uv.code,
"credit_type": uv.credit_type,
"semester": uv.semester,
"department": uv.department,
"id": ue.id,
"title": ue.title,
"code": ue.code,
"credit_type": ue.credit_type,
"semester": ue.semester,
"department": ue.department,
"detail_url": reverse(
"pedagogy:ue_detail", kwargs={"ue_id": ue.id}
),
"edit_url": reverse("pedagogy:ue_update", kwargs={"ue_id": ue.id}),
"delete_url": reverse(
"pedagogy:ue_delete", kwargs={"ue_id": ue.id}
),
}
],
}
@@ -114,7 +121,7 @@ class TestUVSearch(TestCase):
def test_search_by_text(self):
self.client.force_login(self.root)
for query, expected in (
# UV code search case insensitive
# UE code search case insensitive
("m", {"MT01", "MT10"}),
("M", {"MT01", "MT10"}),
("mt", {"MT01", "MT10"}),
@@ -126,24 +133,24 @@ class TestUVSearch(TestCase):
):
res = self.client.get(self.url + f"?search={query}")
assert res.status_code == 200
assert {uv["code"] for uv in json.loads(res.content)["results"]} == expected
assert {ue["code"] for ue in json.loads(res.content)["results"]} == expected
def test_search_by_credit_type(self):
self.client.force_login(self.root)
res = self.client.get(self.url + "?credit_type=CS")
assert res.status_code == 200
codes = [uv["code"] for uv in json.loads(res.content)["results"]]
codes = [ue["code"] for ue in json.loads(res.content)["results"]]
assert codes == ["AP4A", "MT01", "PHYS11"]
res = self.client.get(self.url + "?credit_type=CS&credit_type=OM")
assert res.status_code == 200
codes = {uv["code"] for uv in json.loads(res.content)["results"]}
codes = {ue["code"] for ue in json.loads(res.content)["results"]}
assert codes == {"AP4A", "MT01", "PHYS11", "PA00"}
def test_search_by_semester(self):
self.client.force_login(self.root)
res = self.client.get(self.url + "?semester=SPRING")
assert res.status_code == 200
codes = {uv["code"] for uv in json.loads(res.content)["results"]}
codes = {ue["code"] for ue in json.loads(res.content)["results"]}
assert codes == {"DA50", "TNEV", "PA00"}
def test_search_multiple_filters(self):
@@ -152,7 +159,7 @@ class TestUVSearch(TestCase):
self.url + "?semester=AUTUMN&credit_type=CS&department=TC"
)
assert res.status_code == 200
codes = {uv["code"] for uv in json.loads(res.content)["results"]}
codes = {ue["code"] for ue in json.loads(res.content)["results"]}
assert codes == {"MT01", "PHYS11"}
def test_search_fails(self):
@@ -163,15 +170,15 @@ class TestUVSearch(TestCase):
def test_search_pa00_fail(self):
self.client.force_login(self.root)
# Search with UV code
# Search with UE code
response = self.client.get(reverse("pedagogy:guide"), {"search": "IFC"})
self.assertNotContains(response, text="PA00")
# Search with first letter of UV code
# Search with first letter of UE code
response = self.client.get(reverse("pedagogy:guide"), {"search": "I"})
self.assertNotContains(response, text="PA00")
# Search with UV manager
# Search with UE manager
response = self.client.get(reverse("pedagogy:guide"), {"search": "GILLES"})
self.assertNotContains(response, text="PA00")

View File

@@ -33,14 +33,14 @@ from pytest_django.asserts import assertRedirects
from core.baker_recipes import old_subscriber_user, subscriber_user
from core.models import Notification, User
from pedagogy.models import UV, UVComment, UVCommentReport
from pedagogy.models import UE, UEComment, UECommentReport
def create_uv_template(user_id, code="IFC1", exclude_list=None):
"""Factory to help UV creation/update in post requests."""
def create_ue_template(user_id, code="IFC1", exclude_list=None):
"""Factory to help UE creation/update in post requests."""
if exclude_list is None:
exclude_list = []
uv = {
ue = {
"code": code,
"author": user_id,
"credit_type": "TM",
@@ -74,15 +74,15 @@ def create_uv_template(user_id, code="IFC1", exclude_list=None):
* Chaînes de caractères""",
}
for excluded in exclude_list:
uv.pop(excluded)
return uv
ue.pop(excluded)
return ue
# UV class tests
# UE class tests
class TestUVCreation(TestCase):
"""Test uv creation."""
class TestUECreation(TestCase):
"""Test ue creation."""
@classmethod
def setUpTestData(cls):
@@ -90,62 +90,62 @@ class TestUVCreation(TestCase):
cls.tutu = User.objects.get(username="tutu")
cls.sli = User.objects.get(username="sli")
cls.guy = User.objects.get(username="guy")
cls.create_uv_url = reverse("pedagogy:uv_create")
cls.create_ue_url = reverse("pedagogy:ue_create")
def test_create_uv_admin_success(self):
def test_create_ue_admin_success(self):
self.client.force_login(self.bibou)
response = self.client.post(
self.create_uv_url, create_uv_template(self.bibou.id)
self.create_ue_url, create_ue_template(self.bibou.id)
)
assert response.status_code == 302
assert UV.objects.filter(code="IFC1").exists()
assert UE.objects.filter(code="IFC1").exists()
def test_create_uv_pedagogy_admin_success(self):
def test_create_ue_pedagogy_admin_success(self):
self.client.force_login(self.tutu)
response = self.client.post(
self.create_uv_url, create_uv_template(self.tutu.id)
self.create_ue_url, create_ue_template(self.tutu.id)
)
assert response.status_code == 302
assert UV.objects.filter(code="IFC1").exists()
assert UE.objects.filter(code="IFC1").exists()
def test_create_uv_unauthorized_fail(self):
def test_create_ue_unauthorized_fail(self):
# Test with anonymous user
response = self.client.post(self.create_uv_url, create_uv_template(0))
response = self.client.post(self.create_ue_url, create_ue_template(0))
assertRedirects(
response, reverse("core:login", query={"next": self.create_uv_url})
response, reverse("core:login", query={"next": self.create_ue_url})
)
# Test with subscribed user
self.client.force_login(self.sli)
response = self.client.post(self.create_uv_url, create_uv_template(self.sli.id))
response = self.client.post(self.create_ue_url, create_ue_template(self.sli.id))
assert response.status_code == 403
# Test with non subscribed user
self.client.force_login(self.guy)
response = self.client.post(self.create_uv_url, create_uv_template(self.guy.id))
response = self.client.post(self.create_ue_url, create_ue_template(self.guy.id))
assert response.status_code == 403
# Check that the UV has never been created
assert not UV.objects.filter(code="IFC1").exists()
# Check that the UE has never been created
assert not UE.objects.filter(code="IFC1").exists()
def test_create_uv_bad_request_fail(self):
def test_create_ue_bad_request_fail(self):
self.client.force_login(self.tutu)
# Test with wrong user id (if someone cheats on the hidden input)
response = self.client.post(
self.create_uv_url, create_uv_template(self.bibou.id)
self.create_ue_url, create_ue_template(self.bibou.id)
)
assert response.status_code == 200
# Remove a required field
response = self.client.post(
self.create_uv_url,
create_uv_template(self.tutu.id, exclude_list=["title"]),
self.create_ue_url,
create_ue_template(self.tutu.id, exclude_list=["title"]),
)
assert response.status_code == 200
# Check that the UV hase never been created
assert not UV.objects.filter(code="IFC1").exists()
# Check that the UE has never been created
assert not UE.objects.filter(code="IFC1").exists()
@pytest.mark.django_db
@@ -171,8 +171,8 @@ def test_guide_anonymous_permission_denied(client: Client):
assert res.status_code == 302
class TestUVDelete(TestCase):
"""Test UV deletion rights."""
class TestUEDelete(TestCase):
"""Test UE deletion rights."""
@classmethod
def setUpTestData(cls):
@@ -180,37 +180,37 @@ class TestUVDelete(TestCase):
cls.tutu = User.objects.get(username="tutu")
cls.sli = User.objects.get(username="sli")
cls.guy = User.objects.get(username="guy")
cls.uv = UV.objects.get(code="PA00")
cls.delete_uv_url = reverse("pedagogy:uv_delete", kwargs={"uv_id": cls.uv.id})
cls.ue = UE.objects.get(code="PA00")
cls.delete_ue_url = reverse("pedagogy:ue_delete", kwargs={"ue_id": cls.ue.id})
def test_uv_delete_root_success(self):
def test_ue_delete_root_success(self):
self.client.force_login(self.bibou)
self.client.post(self.delete_uv_url)
assert not UV.objects.filter(pk=self.uv.pk).exists()
self.client.post(self.delete_ue_url)
assert not UE.objects.filter(pk=self.ue.pk).exists()
def test_uv_delete_pedagogy_admin_success(self):
def test_ue_delete_pedagogy_admin_success(self):
self.client.force_login(self.tutu)
self.client.post(self.delete_uv_url)
assert not UV.objects.filter(pk=self.uv.pk).exists()
self.client.post(self.delete_ue_url)
assert not UE.objects.filter(pk=self.ue.pk).exists()
def test_uv_delete_pedagogy_unauthorized_fail(self):
def test_ue_delete_pedagogy_unauthorized_fail(self):
# Anonymous user
response = self.client.post(self.delete_uv_url)
response = self.client.post(self.delete_ue_url)
assertRedirects(
response, reverse("core:login", query={"next": self.delete_uv_url})
response, reverse("core:login", query={"next": self.delete_ue_url})
)
assert UV.objects.filter(pk=self.uv.pk).exists()
assert UE.objects.filter(pk=self.ue.pk).exists()
for user in baker.make(User), subscriber_user.make():
with self.subTest():
self.client.force_login(user)
response = self.client.post(self.delete_uv_url)
response = self.client.post(self.delete_ue_url)
assert response.status_code == 403
assert UV.objects.filter(pk=self.uv.pk).exists()
assert UE.objects.filter(pk=self.ue.pk).exists()
class TestUVUpdate(TestCase):
"""Test UV update rights."""
class TestUEUpdate(TestCase):
"""Test UE update rights."""
@classmethod
def setUpTestData(cls):
@@ -218,79 +218,79 @@ class TestUVUpdate(TestCase):
cls.tutu = User.objects.get(username="tutu")
cls.sli = User.objects.get(username="sli")
cls.guy = User.objects.get(username="guy")
cls.uv = UV.objects.get(code="PA00")
cls.update_uv_url = reverse("pedagogy:uv_update", kwargs={"uv_id": cls.uv.id})
cls.ue = UE.objects.get(code="PA00")
cls.update_ue_url = reverse("pedagogy:ue_update", kwargs={"ue_id": cls.ue.id})
def test_uv_update_root_success(self):
def test_ue_update_root_success(self):
self.client.force_login(self.bibou)
self.client.post(
self.update_uv_url, create_uv_template(self.bibou.id, code="PA00")
self.update_ue_url, create_ue_template(self.bibou.id, code="PA00")
)
self.uv.refresh_from_db()
assert self.uv.credit_type == "TM"
self.ue.refresh_from_db()
assert self.ue.credit_type == "TM"
def test_uv_update_pedagogy_admin_success(self):
def test_ue_update_pedagogy_admin_success(self):
self.client.force_login(self.tutu)
self.client.post(
self.update_uv_url, create_uv_template(self.bibou.id, code="PA00")
self.update_ue_url, create_ue_template(self.bibou.id, code="PA00")
)
self.uv.refresh_from_db()
assert self.uv.credit_type == "TM"
self.ue.refresh_from_db()
assert self.ue.credit_type == "TM"
def test_uv_update_original_author_does_not_change(self):
def test_ue_update_original_author_does_not_change(self):
self.client.force_login(self.tutu)
response = self.client.post(
self.update_uv_url,
create_uv_template(self.tutu.id, code="PA00"),
self.update_ue_url,
create_ue_template(self.tutu.id, code="PA00"),
)
assert response.status_code == 200
self.uv.refresh_from_db()
assert self.uv.author == self.bibou
self.ue.refresh_from_db()
assert self.ue.author == self.bibou
def test_uv_update_pedagogy_unauthorized_fail(self):
def test_ue_update_pedagogy_unauthorized_fail(self):
# Anonymous user
response = self.client.post(
self.update_uv_url, create_uv_template(self.bibou.id, code="PA00")
self.update_ue_url, create_ue_template(self.bibou.id, code="PA00")
)
assertRedirects(
response, reverse("core:login", query={"next": self.update_uv_url})
response, reverse("core:login", query={"next": self.update_ue_url})
)
# Not subscribed user
self.client.force_login(self.guy)
response = self.client.post(
self.update_uv_url, create_uv_template(self.bibou.id, code="PA00")
self.update_ue_url, create_ue_template(self.bibou.id, code="PA00")
)
assert response.status_code == 403
# Simply subscribed user
self.client.force_login(self.sli)
response = self.client.post(
self.update_uv_url, create_uv_template(self.bibou.id, code="PA00")
self.update_ue_url, create_ue_template(self.bibou.id, code="PA00")
)
assert response.status_code == 403
# Check that the UV has not changed
self.uv.refresh_from_db()
assert self.uv.credit_type == "OM"
# Check that the UE has not changed
self.ue.refresh_from_db()
assert self.ue.credit_type == "OM"
# UVComment class tests
# UEComment class tests
def create_uv_comment_template(user_id, uv_code="PA00", exclude_list=None):
"""Factory to help UVComment creation/update in post requests."""
def create_ue_comment_template(user_id, ue_code="PA00", exclude_list=None):
"""Factory to help UEComment creation/update in post requests."""
if exclude_list is None:
exclude_list = []
comment = {
"author": user_id,
"uv": UV.objects.get(code=uv_code).id,
"ue": UE.objects.get(code=ue_code).id,
"grade_global": 4,
"grade_utility": 4,
"grade_interest": 4,
"grade_teaching": -1,
"grade_work_load": 2,
"comment": "Superbe UV qui fait vivre la vie associative de l'école",
"comment": "Superbe UE qui fait vivre la vie associative de l'école",
}
for excluded in exclude_list:
comment.pop(excluded)
@@ -298,7 +298,7 @@ def create_uv_comment_template(user_id, uv_code="PA00", exclude_list=None):
class TestUVCommentCreationAndDisplay(TestCase):
"""Test UVComment creation and its display.
"""Test UEComment creation and its display.
Display and creation are the same view.
"""
@@ -309,124 +309,124 @@ class TestUVCommentCreationAndDisplay(TestCase):
cls.tutu = User.objects.get(username="tutu")
cls.sli = User.objects.get(username="sli")
cls.guy = User.objects.get(username="guy")
cls.uv = UV.objects.get(code="PA00")
cls.uv_url = reverse("pedagogy:uv_detail", kwargs={"uv_id": cls.uv.id})
cls.ue = UE.objects.get(code="PA00")
cls.ue_url = reverse("pedagogy:ue_detail", kwargs={"ue_id": cls.ue.id})
def test_create_uv_comment_admin_success(self):
def test_create_ue_comment_admin_success(self):
self.client.force_login(self.bibou)
response = self.client.post(
self.uv_url, create_uv_comment_template(self.bibou.id)
self.ue_url, create_ue_comment_template(self.bibou.id)
)
assertRedirects(response, self.uv_url)
response = self.client.get(self.uv_url)
self.assertContains(response, text="Superbe UV")
assertRedirects(response, self.ue_url)
response = self.client.get(self.ue_url)
self.assertContains(response, text="Superbe UE")
def test_create_uv_comment_pedagogy_admin_success(self):
def test_create_ue_comment_pedagogy_admin_success(self):
self.client.force_login(self.tutu)
response = self.client.post(
self.uv_url, create_uv_comment_template(self.tutu.id)
self.ue_url, create_ue_comment_template(self.tutu.id)
)
self.assertRedirects(response, self.uv_url)
response = self.client.get(self.uv_url)
self.assertContains(response, text="Superbe UV")
self.assertRedirects(response, self.ue_url)
response = self.client.get(self.ue_url)
self.assertContains(response, text="Superbe UE")
def test_create_uv_comment_subscriber_success(self):
def test_create_ue_comment_subscriber_success(self):
self.client.force_login(self.sli)
response = self.client.post(
self.uv_url, create_uv_comment_template(self.sli.id)
self.ue_url, create_ue_comment_template(self.sli.id)
)
self.assertRedirects(response, self.uv_url)
response = self.client.get(self.uv_url)
self.assertContains(response, text="Superbe UV")
self.assertRedirects(response, self.ue_url)
response = self.client.get(self.ue_url)
self.assertContains(response, text="Superbe UE")
def test_create_uv_comment_unauthorized_fail(self):
nb_comments = self.uv.comments.count()
def test_create_ue_comment_unauthorized_fail(self):
nb_comments = self.ue.comments.count()
# Test with anonymous user
response = self.client.post(self.uv_url, create_uv_comment_template(0))
assertRedirects(response, reverse("core:login", query={"next": self.uv_url}))
response = self.client.post(self.ue_url, create_ue_comment_template(0))
assertRedirects(response, reverse("core:login", query={"next": self.ue_url}))
# Test with non subscribed user
self.client.force_login(self.guy)
response = self.client.post(
self.uv_url, create_uv_comment_template(self.guy.id)
self.ue_url, create_ue_comment_template(self.guy.id)
)
assert response.status_code == 403
# Check that no comment has been created
assert self.uv.comments.count() == nb_comments
assert self.ue.comments.count() == nb_comments
def test_create_uv_comment_bad_form_fail(self):
nb_comments = self.uv.comments.count()
def test_create_ue_comment_bad_form_fail(self):
nb_comments = self.ue.comments.count()
self.client.force_login(self.bibou)
response = self.client.post(
self.uv_url,
create_uv_comment_template(self.bibou.id, exclude_list=["grade_global"]),
self.ue_url,
create_ue_comment_template(self.bibou.id, exclude_list=["grade_global"]),
)
assert response.status_code == 200
assert self.uv.comments.count() == nb_comments
assert self.ue.comments.count() == nb_comments
def test_create_uv_comment_twice_fail(self):
def test_create_ue_comment_twice_fail(self):
# Checks that the has_user_already_commented method works proprely
assert not self.uv.has_user_already_commented(self.bibou)
assert not self.ue.has_user_already_commented(self.bibou)
# Create a first comment
self.client.force_login(self.bibou)
self.client.post(self.uv_url, create_uv_comment_template(self.bibou.id))
self.client.post(self.ue_url, create_ue_comment_template(self.bibou.id))
# Checks that the has_user_already_commented method works proprely
assert self.uv.has_user_already_commented(self.bibou)
assert self.ue.has_user_already_commented(self.bibou)
# Create the second comment
comment = create_uv_comment_template(self.bibou.id)
comment = create_ue_comment_template(self.bibou.id)
comment["comment"] = "Twice"
response = self.client.post(self.uv_url, comment)
response = self.client.post(self.ue_url, comment)
assert response.status_code == 200
assert UVComment.objects.filter(comment__contains="Superbe UV").exists()
assert not UVComment.objects.filter(comment__contains="Twice").exists()
assert UEComment.objects.filter(comment__contains="Superbe UE").exists()
assert not UEComment.objects.filter(comment__contains="Twice").exists()
self.assertContains(
response,
_(
"You already posted a comment on this UV. "
"You already posted a comment on this UE. "
"If you want to comment again, "
"please modify or delete your previous comment."
),
)
# Ensure that there is no crash when no uv or no author is given
# Ensure that there is no crash when no ue or no author is given
self.client.post(
self.uv_url, create_uv_comment_template(self.bibou.id, exclude_list=["uv"])
self.ue_url, create_ue_comment_template(self.bibou.id, exclude_list=["ue"])
)
assert response.status_code == 200
self.client.post(
self.uv_url,
create_uv_comment_template(self.bibou.id, exclude_list=["author"]),
self.ue_url,
create_ue_comment_template(self.bibou.id, exclude_list=["author"]),
)
assert response.status_code == 200
class TestUVCommentDelete(TestCase):
"""Test UVComment deletion rights."""
"""Test UEComment deletion rights."""
@classmethod
def setUpTestData(cls):
cls.comment = baker.make(UVComment)
cls.comment = baker.make(UEComment)
def test_uv_comment_delete_success(self):
def test_ue_comment_delete_success(self):
url = reverse("pedagogy:comment_delete", kwargs={"comment_id": self.comment.id})
for user in (
baker.make(User, is_superuser=True),
baker.make(
User, user_permissions=[Permission.objects.get(codename="view_uv")]
User, user_permissions=[Permission.objects.get(codename="view_ue")]
),
self.comment.author,
):
with self.subTest():
self.client.force_login(user)
self.client.post(url)
assert not UVComment.objects.filter(id=self.comment.id).exists()
assert not UEComment.objects.filter(id=self.comment.id).exists()
def test_uv_comment_delete_unauthorized_fail(self):
def test_ue_comment_delete_unauthorized_fail(self):
url = reverse("pedagogy:comment_delete", kwargs={"comment_id": self.comment.id})
# Anonymous user
@@ -441,11 +441,11 @@ class TestUVCommentDelete(TestCase):
assert response.status_code == 403
# Check that the comment still exists
assert UVComment.objects.filter(id=self.comment.id).exists()
assert UEComment.objects.filter(id=self.comment.id).exists()
class TestUVCommentUpdate(TestCase):
"""Test UVComment update rights."""
"""Test UEComment update rights."""
@classmethod
def setUpTestData(cls):
@@ -457,17 +457,17 @@ class TestUVCommentUpdate(TestCase):
def setUp(self):
# Prepare a comment
comment_kwargs = create_uv_comment_template(self.krophil.id)
comment_kwargs = create_ue_comment_template(self.krophil.id)
comment_kwargs["author"] = self.krophil
comment_kwargs["uv"] = UV.objects.get(id=comment_kwargs["uv"])
self.comment = UVComment(**comment_kwargs)
comment_kwargs["ue"] = UE.objects.get(id=comment_kwargs["ue"])
self.comment = UEComment(**comment_kwargs)
self.comment.save()
# Prepare edit of this comment for post requests
self.comment_edit = create_uv_comment_template(self.krophil.id)
self.comment_edit = create_ue_comment_template(self.krophil.id)
self.comment_edit["comment"] = "Edited"
def test_uv_comment_update_root_success(self):
def test_ue_comment_update_root_success(self):
self.client.force_login(self.bibou)
response = self.client.post(
reverse("pedagogy:comment_update", kwargs={"comment_id": self.comment.id}),
@@ -477,7 +477,7 @@ class TestUVCommentUpdate(TestCase):
self.comment.refresh_from_db()
self.assertEqual(self.comment.comment, self.comment_edit["comment"])
def test_uv_comment_update_author_success(self):
def test_ue_comment_update_author_success(self):
self.client.force_login(self.krophil)
response = self.client.post(
reverse("pedagogy:comment_update", kwargs={"comment_id": self.comment.id}),
@@ -487,7 +487,7 @@ class TestUVCommentUpdate(TestCase):
self.comment.refresh_from_db()
self.assertEqual(self.comment.comment, self.comment_edit["comment"])
def test_uv_comment_update_unauthorized_fail(self):
def test_ue_comment_update_unauthorized_fail(self):
url = reverse("pedagogy:comment_update", kwargs={"comment_id": self.comment.id})
# Anonymous user
response = self.client.post(url, self.comment_edit)
@@ -506,7 +506,7 @@ class TestUVCommentUpdate(TestCase):
self.comment.refresh_from_db()
self.assertNotEqual(self.comment.comment, self.comment_edit["comment"])
def test_uv_comment_update_original_author_does_not_change(self):
def test_ue_comment_update_original_author_does_not_change(self):
self.client.force_login(self.bibou)
self.comment_edit["author"] = User.objects.get(username="root").id
@@ -531,31 +531,31 @@ class TestUVModerationForm(TestCase):
def setUp(self):
# Prepare a comment
comment_kwargs = create_uv_comment_template(self.krophil.id)
comment_kwargs = create_ue_comment_template(self.krophil.id)
comment_kwargs["author"] = self.krophil
comment_kwargs["uv"] = UV.objects.get(id=comment_kwargs["uv"])
self.comment_1 = UVComment(**comment_kwargs)
comment_kwargs["ue"] = UE.objects.get(id=comment_kwargs["ue"])
self.comment_1 = UEComment(**comment_kwargs)
self.comment_1.save()
# Prepare another comment
comment_kwargs = create_uv_comment_template(self.krophil.id)
comment_kwargs = create_ue_comment_template(self.krophil.id)
comment_kwargs["author"] = self.krophil
comment_kwargs["uv"] = UV.objects.get(id=comment_kwargs["uv"])
self.comment_2 = UVComment(**comment_kwargs)
comment_kwargs["ue"] = UE.objects.get(id=comment_kwargs["ue"])
self.comment_2 = UEComment(**comment_kwargs)
self.comment_2.save()
# Prepare a comment report for comment 1
self.report_1 = UVCommentReport(
self.report_1 = UECommentReport(
comment=self.comment_1, reporter=self.krophil, reason="C'est moche"
)
self.report_1.save()
self.report_1_bis = UVCommentReport(
self.report_1_bis = UECommentReport(
comment=self.comment_1, reporter=self.krophil, reason="C'est moche 2"
)
self.report_1_bis.save()
# Prepare a comment report for comment 2
self.report_2 = UVCommentReport(
self.report_2 = UECommentReport(
comment=self.comment_2, reporter=self.krophil, reason="C'est moche"
)
self.report_2.save()
@@ -593,11 +593,11 @@ class TestUVModerationForm(TestCase):
assert response.status_code == 302
# Test that nothing has changed
assert UVCommentReport.objects.filter(id=self.report_1.id).exists()
assert UVComment.objects.filter(id=self.comment_1.id).exists()
assert UVCommentReport.objects.filter(id=self.report_1_bis.id).exists()
assert UVCommentReport.objects.filter(id=self.report_2.id).exists()
assert UVComment.objects.filter(id=self.comment_2.id).exists()
assert UECommentReport.objects.filter(id=self.report_1.id).exists()
assert UEComment.objects.filter(id=self.comment_1.id).exists()
assert UECommentReport.objects.filter(id=self.report_1_bis.id).exists()
assert UECommentReport.objects.filter(id=self.report_2.id).exists()
assert UEComment.objects.filter(id=self.comment_2.id).exists()
def test_delete_comment(self):
self.client.force_login(self.bibou)
@@ -607,14 +607,14 @@ class TestUVModerationForm(TestCase):
assert response.status_code == 302
# Test that the comment and it's associated report has been deleted
assert not UVCommentReport.objects.filter(id=self.report_1.id).exists()
assert not UVComment.objects.filter(id=self.comment_1.id).exists()
assert not UECommentReport.objects.filter(id=self.report_1.id).exists()
assert not UEComment.objects.filter(id=self.comment_1.id).exists()
# Test that the bis report has been deleted
assert not UVCommentReport.objects.filter(id=self.report_1_bis.id).exists()
assert not UECommentReport.objects.filter(id=self.report_1_bis.id).exists()
# Test that the other comment and report still exists
assert UVCommentReport.objects.filter(id=self.report_2.id).exists()
assert UVComment.objects.filter(id=self.comment_2.id).exists()
assert UECommentReport.objects.filter(id=self.report_2.id).exists()
assert UEComment.objects.filter(id=self.comment_2.id).exists()
def test_delete_comment_bulk(self):
self.client.force_login(self.bibou)
@@ -625,12 +625,12 @@ class TestUVModerationForm(TestCase):
assert response.status_code == 302
# Test that comments and their associated reports has been deleted
assert not UVCommentReport.objects.filter(id=self.report_1.id).exists()
assert not UVComment.objects.filter(id=self.comment_1.id).exists()
assert not UVCommentReport.objects.filter(id=self.report_2.id).exists()
assert not UVComment.objects.filter(id=self.comment_2.id).exists()
assert not UECommentReport.objects.filter(id=self.report_1.id).exists()
assert not UEComment.objects.filter(id=self.comment_1.id).exists()
assert not UECommentReport.objects.filter(id=self.report_2.id).exists()
assert not UEComment.objects.filter(id=self.comment_2.id).exists()
# Test that the bis report has been deleted
assert not UVCommentReport.objects.filter(id=self.report_1_bis.id).exists()
assert not UECommentReport.objects.filter(id=self.report_1_bis.id).exists()
def test_delete_comment_with_bis(self):
# Test case if two reports targets the same comment and are both deleted
@@ -642,10 +642,10 @@ class TestUVModerationForm(TestCase):
assert response.status_code == 302
# Test that the comment and it's associated report has been deleted
assert not UVCommentReport.objects.filter(id=self.report_1.id).exists()
assert not UVComment.objects.filter(id=self.comment_1.id).exists()
assert not UECommentReport.objects.filter(id=self.report_1.id).exists()
assert not UEComment.objects.filter(id=self.comment_1.id).exists()
# Test that the bis report has been deleted
assert not UVCommentReport.objects.filter(id=self.report_1_bis.id).exists()
assert not UECommentReport.objects.filter(id=self.report_1_bis.id).exists()
def test_delete_report(self):
self.client.force_login(self.bibou)
@@ -655,14 +655,14 @@ class TestUVModerationForm(TestCase):
assert response.status_code == 302
# Test that the report has been deleted and that the comment still exists
assert not UVCommentReport.objects.filter(id=self.report_1.id).exists()
assert UVComment.objects.filter(id=self.comment_1.id).exists()
assert not UECommentReport.objects.filter(id=self.report_1.id).exists()
assert UEComment.objects.filter(id=self.comment_1.id).exists()
# Test that the bis report is still there
assert UVCommentReport.objects.filter(id=self.report_1_bis.id).exists()
assert UECommentReport.objects.filter(id=self.report_1_bis.id).exists()
# Test that the other comment and report still exists
assert UVCommentReport.objects.filter(id=self.report_2.id).exists()
assert UVComment.objects.filter(id=self.comment_2.id).exists()
assert UECommentReport.objects.filter(id=self.report_2.id).exists()
assert UEComment.objects.filter(id=self.comment_2.id).exists()
def test_delete_report_bulk(self):
self.client.force_login(self.bibou)
@@ -679,12 +679,12 @@ class TestUVModerationForm(TestCase):
assert response.status_code == 302
# Test that every reports has been deleted
assert not UVCommentReport.objects.filter(id=self.report_1.id).exists()
assert not UVCommentReport.objects.filter(id=self.report_1_bis.id).exists()
assert not UVCommentReport.objects.filter(id=self.report_2.id).exists()
assert not UECommentReport.objects.filter(id=self.report_1.id).exists()
assert not UECommentReport.objects.filter(id=self.report_1_bis.id).exists()
assert not UECommentReport.objects.filter(id=self.report_2.id).exists()
# Test that comments still exists
assert UVComment.objects.filter(id=self.comment_1.id).exists()
assert UVComment.objects.filter(id=self.comment_2.id).exists()
assert UEComment.objects.filter(id=self.comment_1.id).exists()
assert UEComment.objects.filter(id=self.comment_2.id).exists()
def test_delete_mixed(self):
self.client.force_login(self.bibou)
@@ -698,15 +698,15 @@ class TestUVModerationForm(TestCase):
assert response.status_code == 302
# Test that report 2 and his comment has been deleted
assert not UVCommentReport.objects.filter(id=self.report_2.id).exists()
assert not UVComment.objects.filter(id=self.comment_2.id).exists()
assert not UECommentReport.objects.filter(id=self.report_2.id).exists()
assert not UEComment.objects.filter(id=self.comment_2.id).exists()
# Test that report 1 has been deleted and it's comment still exists
assert not UVCommentReport.objects.filter(id=self.report_1.id).exists()
assert UVComment.objects.filter(id=self.comment_1.id).exists()
assert not UECommentReport.objects.filter(id=self.report_1.id).exists()
assert UEComment.objects.filter(id=self.comment_1.id).exists()
# Test that report 1 bis is still there
assert UVCommentReport.objects.filter(id=self.report_1_bis.id).exists()
assert UECommentReport.objects.filter(id=self.report_1_bis.id).exists()
def test_delete_mixed_with_bis(self):
self.client.force_login(self.bibou)
@@ -720,16 +720,16 @@ class TestUVModerationForm(TestCase):
assert response.status_code == 302
# Test that report 1 and 1 bis has been deleted
assert not UVCommentReport.objects.filter(
assert not UECommentReport.objects.filter(
id__in=[self.report_1.id, self.report_1_bis.id]
).exists()
# Test that comment 1 has been deleted
assert not UVComment.objects.filter(id=self.comment_1.id).exists()
assert not UEComment.objects.filter(id=self.comment_1.id).exists()
# Test that report and comment 2 still exists
assert UVCommentReport.objects.filter(id=self.report_2.id).exists()
assert UVComment.objects.filter(id=self.comment_2.id).exists()
assert UECommentReport.objects.filter(id=self.report_2.id).exists()
assert UEComment.objects.filter(id=self.comment_2.id).exists()
class TestUVCommentReportCreate(TestCase):
@@ -743,10 +743,10 @@ class TestUVCommentReportCreate(TestCase):
self.tutu = User.objects.get(username="tutu")
# Prepare a comment
comment_kwargs = create_uv_comment_template(self.krophil.id)
comment_kwargs = create_ue_comment_template(self.krophil.id)
comment_kwargs["author"] = self.krophil
comment_kwargs["uv"] = UV.objects.get(id=comment_kwargs["uv"])
self.comment = UVComment(**comment_kwargs)
comment_kwargs["ue"] = UE.objects.get(id=comment_kwargs["ue"])
self.comment = UEComment(**comment_kwargs)
self.comment.save()
def create_report_test(self, username: str, *, success: bool):
@@ -763,7 +763,7 @@ class TestUVCommentReportCreate(TestCase):
assert response.status_code == 302
else:
assert response.status_code == 403
self.assertEqual(UVCommentReport.objects.all().exists(), success)
self.assertEqual(UECommentReport.objects.all().exists(), success)
def test_create_report_root_success(self):
self.create_report_test("root", success=True)
@@ -783,7 +783,7 @@ class TestUVCommentReportCreate(TestCase):
url, {"comment": self.comment.id, "reporter": 0, "reason": "C'est moche"}
)
assertRedirects(response, reverse("core:login", query={"next": url}))
assert not UVCommentReport.objects.all().exists()
assert not UECommentReport.objects.all().exists()
def test_notifications(self):
assert not self.tutu.notifications.filter(type="PEDAGOGY_MODERATION").exists()

View File

@@ -24,40 +24,40 @@
from django.urls import path
from pedagogy.views import (
UVCommentDeleteView,
UVCommentReportCreateView,
UVCommentUpdateView,
UVCreateView,
UVDeleteView,
UVDetailFormView,
UVGuideView,
UVModerationFormView,
UVUpdateView,
UECommentDeleteView,
UECommentReportCreateView,
UECommentUpdateView,
UECreateView,
UEDeleteView,
UEDetailFormView,
UEGuideView,
UEModerationFormView,
UEUpdateView,
)
urlpatterns = [
# Urls displaying the actual application for visitors
path("", UVGuideView.as_view(), name="guide"),
path("uv/<int:uv_id>/", UVDetailFormView.as_view(), name="uv_detail"),
path("", UEGuideView.as_view(), name="guide"),
path("ue/<int:ue_id>/", UEDetailFormView.as_view(), name="ue_detail"),
path(
"comment/<int:comment_id>/edit/",
UVCommentUpdateView.as_view(),
UECommentUpdateView.as_view(),
name="comment_update",
),
path(
"comment/<int:comment_id>/delete/",
UVCommentDeleteView.as_view(),
UECommentDeleteView.as_view(),
name="comment_delete",
),
path(
"comment/<int:comment_id>/report/",
UVCommentReportCreateView.as_view(),
UECommentReportCreateView.as_view(),
name="comment_report",
),
# Moderation
path("moderation/", UVModerationFormView.as_view(), name="moderation"),
path("moderation/", UEModerationFormView.as_view(), name="moderation"),
# Administration : Create Update Delete Edit
path("uv/create/", UVCreateView.as_view(), name="uv_create"),
path("uv/<int:uv_id>/delete/", UVDeleteView.as_view(), name="uv_delete"),
path("uv/<int:uv_id>/edit/", UVUpdateView.as_view(), name="uv_update"),
path("ue/create/", UECreateView.as_view(), name="ue_create"),
path("ue/<int:ue_id>/delete/", UEDeleteView.as_view(), name="ue_delete"),
path("ue/<int:ue_id>/edit/", UEUpdateView.as_view(), name="ue_update"),
]

View File

@@ -1,4 +1,4 @@
"""Set of functions to interact with the UTBM UV api."""
"""Set of functions to interact with the UTBM UE api."""
from typing import Iterator
@@ -6,14 +6,14 @@ import requests
from django.conf import settings
from django.utils.functional import cached_property
from pedagogy.schemas import ShortUvList, UtbmFullUvSchema, UtbmShortUvSchema, UvSchema
from pedagogy.schemas import ShortUeList, UeSchema, UtbmFullUeSchema, UtbmShortUeSchema
class UtbmApiClient(requests.Session):
"""A wrapper around `requests.Session` to perform requests to the UTBM UV API."""
"""A wrapper around `requests.Session` to perform requests to the UTBM UE API."""
BASE_URL = settings.SITH_PEDAGOGY_UTBM_API
_cache = {"short_uvs": {}}
_cache = {"short_ues": {}}
@cached_property
def current_year(self) -> int:
@@ -22,83 +22,83 @@ class UtbmApiClient(requests.Session):
response = self.get(url)
return response.json()[-1]["annee"]
def fetch_short_uvs(
def fetch_short_ues(
self, lang: str = "fr", year: int | None = None
) -> list[UtbmShortUvSchema]:
"""Get the list of UVs in their short format from the UTBM API"""
) -> list[UtbmShortUeSchema]:
"""Get the list of UEs in their short format from the UTBM API"""
if year is None:
year = self.current_year
if lang not in self._cache["short_uvs"]:
self._cache["short_uvs"][lang] = {}
if year not in self._cache["short_uvs"][lang]:
if lang not in self._cache["short_ues"]:
self._cache["short_ues"][lang] = {}
if year not in self._cache["short_ues"][lang]:
url = f"{self.BASE_URL}/uvs/{lang}/{year}"
response = self.get(url)
uvs = ShortUvList.validate_json(response.content)
self._cache["short_uvs"][lang][year] = uvs
return self._cache["short_uvs"][lang][year]
ues = ShortUeList.validate_json(response.content)
self._cache["short_ues"][lang][year] = ues
return self._cache["short_ues"][lang][year]
def fetch_uvs(
def fetch_ues(
self, lang: str = "fr", year: int | None = None
) -> Iterator[UvSchema]:
"""Fetch all UVs from the UTBM API, parsed in a format that we can use.
) -> Iterator[UeSchema]:
"""Fetch all UEs from the UTBM API, parsed in a format that we can use.
Warning:
We need infos from the full uv schema, and the UTBM UV API
We need infos from the full ue schema, and the UTBM UE API
has no route to get all of them at once.
We must do one request per UV (for a total of around 730 UVs),
We must do one request per UE (for a total of around 730 UEs),
which takes a lot of time.
Hopefully, there seems to be no rate-limit, so an error
in the middle of the process isn't likely to occur.
"""
if year is None:
year = self.current_year
shorts_uvs = self.fetch_short_uvs(lang, year)
# When UVs are common to multiple branches (like most HUMA)
shorts_ues = self.fetch_short_ues(lang, year)
# When UEs are common to multiple branches (like most HUMA)
# the UTBM API duplicates them for every branch.
# We have no way in our db to link a UV to multiple formations,
# so we just create a single UV, which formation is the one
# of the first UV found in the list.
# We have no way in our db to link a UE to multiple formations,
# so we just create a single UE, which formation is the one
# of the first UE found in the list.
# For example, if we have CC01 (TC), CC01 (IMSI) and CC01 (EDIM),
# we will only keep CC01 (TC).
unique_short_uvs = {}
for uv in shorts_uvs:
if uv.code not in unique_short_uvs:
unique_short_uvs[uv.code] = uv
for uv in unique_short_uvs.values():
uv_url = f"{self.BASE_URL}/uv/{lang}/{year}/{uv.code}/{uv.code_formation}"
response = requests.get(uv_url)
full_uv = UtbmFullUvSchema.model_validate_json(response.content)
yield make_clean_uv(uv, full_uv)
unique_short_ues = {}
for ue in shorts_ues:
if ue.code not in unique_short_ues:
unique_short_ues[ue.code] = ue
for ue in unique_short_ues.values():
ue_url = f"{self.BASE_URL}/uv/{lang}/{year}/{ue.code}/{ue.code_formation}"
response = requests.get(ue_url)
full_ue = UtbmFullUeSchema.model_validate_json(response.content)
yield make_clean_ue(ue, full_ue)
def find_uv(self, lang: str, code: str, year: int | None = None) -> UvSchema | None:
"""Find an UV from the UTBM API."""
# query the UV list
def find_uu(self, lang: str, code: str, year: int | None = None) -> UeSchema | None:
"""Find an UE from the UTBM API."""
# query the UE list
if not year:
year = self.current_year
# the UTBM API has no way to fetch a single short uv,
# and short uvs contain infos that we need and are not
# in the full uv schema, so we must fetch everything.
short_uvs = self.fetch_short_uvs(lang, year)
short_uv = next((uv for uv in short_uvs if uv.code == code), None)
if short_uv is None:
# the UTBM API has no way to fetch a single short ue,
# and short ues contain infos that we need and are not
# in the full ue schema, so we must fetch everything.
short_ues = self.fetch_short_ues(lang, year)
short_ue = next((ue for ue in short_ues if ue.code == code), None)
if short_ue is None:
return None
# get detailed information about the UV
uv_url = f"{self.BASE_URL}/uv/{lang}/{year}/{code}/{short_uv.code_formation}"
response = requests.get(uv_url)
full_uv = UtbmFullUvSchema.model_validate_json(response.content)
return make_clean_uv(short_uv, full_uv)
# get detailed information about the UE
ue_url = f"{self.BASE_URL}/uv/{lang}/{year}/{code}/{short_ue.code_formation}"
response = requests.get(ue_url)
full_ue = UtbmFullUeSchema.model_validate_json(response.content)
return make_clean_ue(short_ue, full_ue)
def make_clean_uv(short_uv: UtbmShortUvSchema, full_uv: UtbmFullUvSchema) -> UvSchema:
def make_clean_ue(short_ue: UtbmShortUeSchema, full_ue: UtbmFullUeSchema) -> UeSchema:
"""Cleans the data up so that it corresponds to our data representation.
Some of the needed information are in the short uv schema, some
other in the full uv schema.
Some of the needed information are in the short ue schema, some
other in the full ue schema.
Thus we combine those information to obtain a data schema suitable
for our needs.
"""
if full_uv.departement == "Pôle Humanités":
if full_ue.departement == "Pôle Humanités":
department = "HUMA"
else:
department = {
@@ -112,9 +112,9 @@ def make_clean_uv(short_uv: UtbmShortUvSchema, full_uv: UtbmFullUvSchema) -> UvS
"ED": "EDIM",
"AI": "GI",
"AM": "MC",
}.get(short_uv.code_formation, "NA")
}.get(short_ue.code_formation, "NA")
match short_uv.ouvert_printemps, short_uv.ouvert_automne:
match short_ue.ouvert_printemps, short_ue.ouvert_automne:
case True, True:
semester = "AUTUMN_AND_SPRING"
case True, False:
@@ -124,22 +124,22 @@ def make_clean_uv(short_uv: UtbmShortUvSchema, full_uv: UtbmFullUvSchema) -> UvS
case _:
semester = "CLOSED"
return UvSchema(
title=full_uv.libelle or "",
code=full_uv.code,
credit_type=short_uv.code_categorie or "FREE",
return UeSchema(
title=full_ue.libelle or "",
code=full_ue.code,
credit_type=short_ue.code_categorie or "FREE",
semester=semester,
language=short_uv.code_langue.upper(),
credits=full_uv.credits_ects,
language=short_ue.code_langue.upper(),
credits=full_ue.credits_ects,
department=department,
hours_THE=next((i.nbh for i in full_uv.activites if i.code == "THE"), 0) // 60,
hours_TD=next((i.nbh for i in full_uv.activites if i.code == "TD"), 0) // 60,
hours_TP=next((i.nbh for i in full_uv.activites if i.code == "TP"), 0) // 60,
hours_TE=next((i.nbh for i in full_uv.activites if i.code == "TE"), 0) // 60,
hours_CM=next((i.nbh for i in full_uv.activites if i.code == "CM"), 0) // 60,
manager=full_uv.respo_automne or full_uv.respo_printemps or "",
objectives=full_uv.objectifs or "",
program=full_uv.programme or "",
skills=full_uv.acquisition_competences or "",
key_concepts=full_uv.acquisition_notions or "",
hours_THE=next((i.nbh for i in full_ue.activites if i.code == "THE"), 0) // 60,
hours_TD=next((i.nbh for i in full_ue.activites if i.code == "TD"), 0) // 60,
hours_TP=next((i.nbh for i in full_ue.activites if i.code == "TP"), 0) // 60,
hours_TE=next((i.nbh for i in full_ue.activites if i.code == "TE"), 0) // 60,
hours_CM=next((i.nbh for i in full_ue.activites if i.code == "CM"), 0) // 60,
manager=full_ue.respo_automne or full_ue.respo_printemps or "",
objectives=full_ue.objectifs or "",
program=full_ue.programme or "",
skills=full_ue.acquisition_competences or "",
key_concepts=full_ue.acquisition_notions or "",
)

View File

@@ -38,39 +38,39 @@ from core.auth.mixins import PermissionOrAuthorRequiredMixin
from core.models import Notification, User
from core.views import DetailFormView
from pedagogy.forms import (
UVCommentForm,
UVCommentModerationForm,
UVCommentReportForm,
UVForm,
UECommentForm,
UECommentModerationForm,
UECommentReportForm,
UEForm,
)
from pedagogy.models import UV, UVComment, UVCommentReport
from pedagogy.models import UE, UEComment, UECommentReport
class UVDetailFormView(PermissionRequiredMixin, DetailFormView):
"""Display every comment of an UV and detailed infos about it.
class UEDetailFormView(PermissionRequiredMixin, DetailFormView):
"""Display every comment of an UE and detailed infos about it.
Allow to comment the UV.
Allow to comment the UE.
"""
model = UV
pk_url_kwarg = "uv_id"
template_name = "pedagogy/uv_detail.jinja"
form_class = UVCommentForm
permission_required = "pedagogy.view_uv"
model = UE
pk_url_kwarg = "ue_id"
template_name = "pedagogy/ue_detail.jinja"
form_class = UECommentForm
permission_required = "pedagogy.view_ue"
def has_permission(self):
if self.request.method == "POST" and not self.request.user.has_perm(
"pedagogy.add_uvcomment"
"pedagogy.add_uecomment"
):
# if it's a POST request, the user is trying to add a new UVComment
# thus he also needs the "add_uvcomment" permission
# if it's a POST request, the user is trying to add a new UEComment
# thus he also needs the "add_uecomment" permission
return False
return super().has_permission()
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs["author_id"] = self.request.user.id
kwargs["uv_id"] = self.object.id
kwargs["ue_id"] = self.object.id
kwargs["is_creation"] = True
return kwargs
@@ -89,68 +89,68 @@ class UVDetailFormView(PermissionRequiredMixin, DetailFormView):
}
def get_success_url(self):
# once the new uv comment has been saved
# once the new ue comment has been saved
# redirect to the same page we are currently
return self.request.path
class UVCommentUpdateView(PermissionOrAuthorRequiredMixin, UpdateView):
class UECommentUpdateView(PermissionOrAuthorRequiredMixin, UpdateView):
"""Allow edit of a given comment."""
model = UVComment
form_class = UVCommentForm
model = UEComment
form_class = UECommentForm
pk_url_kwarg = "comment_id"
template_name = "core/edit.jinja"
permission_required = "pedagogy.change_uvcomment"
permission_required = "pedagogy.change_uecomment"
author_field = "author"
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs["author_id"] = self.object.author_id
kwargs["uv_id"] = self.object.uv_id
kwargs["ue_id"] = self.object.ue_id
kwargs["is_creation"] = False
return kwargs
def get_success_url(self):
return reverse("pedagogy:uv_detail", kwargs={"uv_id": self.object.uv_id})
return reverse("pedagogy:ue_detail", kwargs={"ue_id": self.object.ue_id})
class UVCommentDeleteView(PermissionOrAuthorRequiredMixin, DeleteView):
"""Allow delete of a given comment."""
class UECommentDeleteView(PermissionOrAuthorRequiredMixin, DeleteView):
"""Allow to delete a given comment."""
model = UVComment
model = UEComment
pk_url_kwarg = "comment_id"
template_name = "core/delete_confirm.jinja"
permission_required = "pedagogy.delete_uvcomment"
permission_required = "pedagogy.delete_uecomment"
author_field = "author"
def get_success_url(self):
return reverse("pedagogy:uv_detail", kwargs={"uv_id": self.object.uv_id})
return reverse("pedagogy:ue_detail", kwargs={"ue_id": self.object.ue_id})
class UVGuideView(PermissionRequiredMixin, TemplateView):
"""UV guide main page."""
class UEGuideView(PermissionRequiredMixin, TemplateView):
"""UE guide main page."""
template_name = "pedagogy/guide.jinja"
permission_required = "pedagogy.view_uv"
permission_required = "pedagogy.view_ue"
class UVCommentReportCreateView(PermissionRequiredMixin, CreateView):
"""Create a new report for an inapropriate comment."""
class UECommentReportCreateView(PermissionRequiredMixin, CreateView):
"""Create a new report for an inappropriate comment."""
model = UVCommentReport
form_class = UVCommentReportForm
model = UECommentReport
form_class = UECommentReportForm
template_name = "core/edit.jinja"
permission_required = "pedagogy.add_uvcommentreport"
permission_required = "pedagogy.add_uecommentreport"
def dispatch(self, request, *args, **kwargs):
self.uv_comment = get_object_or_404(UVComment, pk=kwargs["comment_id"])
self.ue_comment = get_object_or_404(UEComment, pk=kwargs["comment_id"])
return super().dispatch(request, *args, **kwargs)
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs["reporter_id"] = self.request.user.id
kwargs["comment_id"] = self.uv_comment.id
kwargs["comment_id"] = self.ue_comment.id
return kwargs
def form_valid(self, form):
@@ -172,35 +172,35 @@ class UVCommentReportCreateView(PermissionRequiredMixin, CreateView):
return resp
def get_success_url(self):
return reverse("pedagogy:uv_detail", kwargs={"uv_id": self.uv_comment.uv_id})
return reverse("pedagogy:ue_detail", kwargs={"ue_id": self.ue_comment.ue_id})
class UVModerationFormView(PermissionRequiredMixin, FormView):
class UEModerationFormView(PermissionRequiredMixin, FormView):
"""Moderation interface (Privileged)."""
form_class = UVCommentModerationForm
form_class = UECommentModerationForm
template_name = "pedagogy/moderation.jinja"
permission_required = "pedagogy.delete_uvcomment"
permission_required = "pedagogy.delete_uecomment"
success_url = reverse_lazy("pedagogy:moderation")
def form_valid(self, form):
form_clean = form.clean()
accepted = form_clean.get("accepted_reports", [])
if len(accepted) > 0: # delete the reported comments
UVComment.objects.filter(reports__in=accepted).delete()
UEComment.objects.filter(reports__in=accepted).delete()
denied = form_clean.get("denied_reports", [])
if len(denied) > 0: # delete the comments themselves
UVCommentReport.objects.filter(id__in={d.id for d in denied}).delete()
UECommentReport.objects.filter(id__in={d.id for d in denied}).delete()
return super().form_valid(form)
class UVCreateView(PermissionRequiredMixin, CreateView):
"""Add a new UV (Privileged)."""
class UECreateView(PermissionRequiredMixin, CreateView):
"""Add a new UE (Privileged)."""
model = UV
form_class = UVForm
template_name = "pedagogy/uv_edit.jinja"
permission_required = "pedagogy.add_uv"
model = UE
form_class = UEForm
template_name = "pedagogy/ue_edit.jinja"
permission_required = "pedagogy.add_ue"
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
@@ -208,24 +208,24 @@ class UVCreateView(PermissionRequiredMixin, CreateView):
return kwargs
class UVDeleteView(PermissionRequiredMixin, DeleteView):
"""Allow to delete an UV (Privileged)."""
class UEDeleteView(PermissionRequiredMixin, DeleteView):
"""Allow to delete an UE (Privileged)."""
model = UV
pk_url_kwarg = "uv_id"
model = UE
pk_url_kwarg = "ue_id"
template_name = "core/delete_confirm.jinja"
permission_required = "pedagogy.delete_uv"
permission_required = "pedagogy.delete_ue"
success_url = reverse_lazy("pedagogy:guide")
class UVUpdateView(PermissionRequiredMixin, UpdateView):
"""Allow to edit an UV (Privilegied)."""
class UEUpdateView(PermissionRequiredMixin, UpdateView):
"""Allow to edit an UE (Privilegied)."""
model = UV
form_class = UVForm
pk_url_kwarg = "uv_id"
template_name = "pedagogy/uv_edit.jinja"
permission_required = "pedagogy.change_uv"
model = UE
form_class = UEForm
pk_url_kwarg = "ue_id"
template_name = "pedagogy/ue_edit.jinja"
permission_required = "pedagogy.change_ue"
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()

View File

@@ -20,8 +20,8 @@ license = { text = "GPL-3.0-only" }
requires-python = "<4.0,>=3.12"
dependencies = [
"django>=5.2.8,<6.0.0",
"django-ninja>=1.4.5,<2.0.0",
"django-ninja-extra>=0.30.2,<1.0.0",
"django-ninja>=1.5.0,<6.0.0",
"django-ninja-extra>=0.30.6",
"Pillow>=12.0.0,<13.0.0",
"mistune>=3.1.4,<4.0.0",
"django-jinja<3.0.0,>=2.11.0",

View File

@@ -114,7 +114,6 @@ class TestMergeUser(TestCase):
seller=self.root,
unit_price=2,
quantity=2,
payment_method="SITH_ACCOUNT",
).save()
Selling(
label="barbar",
@@ -125,7 +124,6 @@ class TestMergeUser(TestCase):
seller=self.root,
unit_price=2,
quantity=4,
payment_method="SITH_ACCOUNT",
).save()
today = localtime(now()).date()
# both subscriptions began last month and shall end in 5 months
@@ -197,7 +195,6 @@ class TestMergeUser(TestCase):
seller=self.root,
unit_price=2,
quantity=4,
payment_method="SITH_ACCOUNT",
).save()
data = {"user1": self.to_keep.id, "user2": self.to_delete.id}
res = self.client.post(reverse("rootplace:merge"), data)
@@ -225,7 +222,6 @@ class TestMergeUser(TestCase):
seller=self.root,
unit_price=2,
quantity=4,
payment_method="SITH_ACCOUNT",
).save()
data = {"user1": self.to_keep.id, "user2": self.to_delete.id}
res = self.client.post(reverse("rootplace:merge"), data)

View File

@@ -20,9 +20,9 @@ from sas.models import Album, PeoplePictureRelation, Picture, PictureModerationR
@admin.register(Picture)
class PictureAdmin(admin.ModelAdmin):
list_display = ("name", "parent", "date", "size", "is_moderated")
list_display = ("name", "parent", "is_moderated")
search_fields = ("name",)
autocomplete_fields = ("owner", "parent", "edit_groups", "view_groups", "moderator")
autocomplete_fields = ("owner", "parent", "moderator")
@admin.register(PeoplePictureRelation)
@@ -33,9 +33,9 @@ class PeoplePictureRelationAdmin(admin.ModelAdmin):
@admin.register(Album)
class AlbumAdmin(admin.ModelAdmin):
list_display = ("name", "parent", "date", "owner", "is_moderated")
list_display = ("name", "parent")
search_fields = ("name",)
autocomplete_fields = ("owner", "parent", "edit_groups", "view_groups")
autocomplete_fields = ("parent", "edit_groups", "view_groups")
@admin.register(PictureModerationRequest)

View File

@@ -2,8 +2,10 @@ from typing import Any, Literal
from django.conf import settings
from django.core.exceptions import ValidationError
from django.shortcuts import get_list_or_404
from django.urls import reverse
from ninja import Body, File, Query
from ninja import Body, Query, UploadedFile
from ninja.errors import HttpError
from ninja.security import SessionAuth
from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.exceptions import NotFound, PermissionDenied
@@ -16,11 +18,12 @@ from api.permissions import (
CanAccessLookup,
CanEdit,
CanView,
HasPerm,
IsInGroup,
IsRoot,
)
from core.models import Notification, User
from core.schemas import UploadedImage
from core.utils import get_list_exact_or_404
from sas.models import Album, PeoplePictureRelation, Picture
from sas.schemas import (
AlbumAutocompleteSchema,
@@ -28,6 +31,7 @@ from sas.schemas import (
AlbumSchema,
IdentifiedUserSchema,
ModerationRequestSchema,
MoveAlbumSchema,
PictureFilterSchema,
PictureSchema,
)
@@ -69,6 +73,48 @@ class AlbumController(ControllerBase):
Album.objects.viewable_by(self.context.request.user).order_by("-date")
)
@route.patch("/parent", permissions=[IsAuthenticated])
def change_album_parent(self, payload: list[MoveAlbumSchema]):
"""Change parents of albums
Note:
For this operation to work, the user must be authorized
to edit both the moved albums and their new parent.
"""
user: User = self.context.request.user
albums: list[Album] = get_list_exact_or_404(
Album, pk__in={a.id for a in payload}
)
if not user.has_perm("sas.change_album"):
unauthorized = [a.id for a in albums if not user.can_edit(a)]
raise PermissionDenied(
f"You can't move the following albums : {unauthorized}"
)
parents: list[Album] = get_list_exact_or_404(
Album, pk__in={a.new_parent_id for a in payload}
)
if not user.has_perm("sas.change_album"):
unauthorized = [a.id for a in parents if not user.can_edit(a)]
raise PermissionDenied(
f"You can't move to the following albums : {unauthorized}"
)
id_to_new_parent = {i.id: i.new_parent_id for i in payload}
for album in albums:
album.parent_id = id_to_new_parent[album.id]
# known caveat : moving an album won't move it's thumbnail.
# E.g. if the album foo/bar is moved to foo/baz,
# the thumbnail will still be foo/bar/thumb.webp
# This has no impact for the end user
# and doing otherwise would be hard for us to implement,
# because we would then have to manage rollbacks on fail.
Album.objects.bulk_update(albums, fields=["parent_id"])
@route.delete("", permissions=[HasPerm("sas.delete_album")])
def delete_album(self, album_ids: list[int]):
# known caveat : deleting an album doesn't delete the pictures on the disk.
# It's a db only operation.
albums: list[Album] = get_list_or_404(Album, pk__in=album_ids)
@api_controller("/sas/picture")
class PicturesController(ControllerBase):
@@ -96,7 +142,7 @@ class PicturesController(ControllerBase):
return (
filters.filter(Picture.objects.viewable_by(user))
.distinct()
.order_by("-parent__date", "date")
.order_by("-parent__event_date", "created_at")
.select_related("owner", "parent")
)
@@ -110,27 +156,25 @@ class PicturesController(ControllerBase):
},
url_name="upload_picture",
)
def upload_picture(self, album_id: Body[int], picture: File[UploadedImage]):
def upload_picture(self, album_id: Body[int], picture: UploadedFile):
album = self.get_object_or_exception(Album, pk=album_id)
user = self.context.request.user
self_moderate = user.has_perm("sas.moderate_sasfile")
new = Picture(
parent=album,
name=picture.name,
file=picture,
original=picture,
owner=user,
is_moderated=self_moderate,
is_folder=False,
mime_type=picture.content_type,
)
if self_moderate:
new.moderator = user
try:
new.generate_thumbnails()
try:
new.full_clean()
new.save()
except ValidationError as e:
return self.create_response({"detail": dict(e)}, status_code=409)
raise HttpError(status_code=409, message=str(e)) from e
new.save()
@route.get(
"/{picture_id}/identified",

View File

@@ -1,18 +1,35 @@
from django.core.files.uploadedfile import SimpleUploadedFile
from model_bakery import seq
from model_bakery.recipe import Recipe
from sas.models import Picture
from core.utils import RED_PIXEL_PNG
from sas.models import Album, Picture
album_recipe = Recipe(
Album,
name=seq("Album "),
thumbnail=SimpleUploadedFile(
name="thumb.webp", content=b"", content_type="image/webp"
),
)
picture_recipe = Recipe(
Picture,
is_in_sas=True,
is_folder=False,
is_moderated=True,
name=seq("Picture "),
original=SimpleUploadedFile(
# compressed and thumbnail are generated on save (except if bulk creating).
# For this step no to fail, original must be a valid image.
name="img.png",
content=RED_PIXEL_PNG,
content_type="image/png",
),
compressed=SimpleUploadedFile(
name="img.webp", content=b"", content_type="image/webp"
),
thumbnail=SimpleUploadedFile(
name="img.webp", content=b"", content_type="image/webp"
),
)
"""A SAS Picture fixture.
Warnings:
If you don't `bulk_create` this, you need
to explicitly set the parent album, or it won't work
"""
"""A SAS Picture fixture."""

View File

@@ -48,13 +48,12 @@ class PictureEditForm(forms.ModelForm):
class AlbumEditForm(forms.ModelForm):
class Meta:
model = Album
fields = ["name", "date", "file", "parent", "edit_groups"]
fields = ["name", "date", "thumbnail", "parent", "edit_groups"]
widgets = {
"parent": AutoCompleteSelectAlbum,
"edit_groups": AutoCompleteSelectMultipleGroup,
}
name = forms.CharField(max_length=Album.NAME_MAX_LENGTH, label=_("file name"))
date = forms.DateField(label=_("Date"), widget=SelectDate, required=True)
recursive = forms.BooleanField(label=_("Apply rights recursively"), required=False)

View File

@@ -0,0 +1,357 @@
# Generated by Django 4.2.17 on 2025-01-22 21:53
import collections
import itertools
import logging
from typing import TYPE_CHECKING
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
from django.db.migrations.state import StateApps
import sas.models
if TYPE_CHECKING:
import core.models
# NB : tous les commentaires sont écrits en français,
# parce qu'on est sur des opérations qui sont complexes,
# et qui sont surtout DANGEREUSES.
# Ici, la clarté des explications prime sur toute autre considération.
def copy_albums_and_pictures(apps: StateApps, schema_editor):
SithFile: type[core.models.SithFile] = apps.get_model("core", "SithFile")
Album: type[sas.models.Album] = apps.get_model("sas", "Album")
Picture: type[sas.models.Picture] = apps.get_model("sas", "Picture")
logger = logging.getLogger("django")
# Il y a environ 1800 albums, 257k photos et 488k identifications
# d'utilisateurs dans la db de prod.
# En supposant qu'une insertion prenne 10ms (ce qui est très optimiste),
# migrer tous les enregistrements de la db prendrait plus de 2h.
# C'est trop long.
# Mais d'un autre côté, j'ai pas assez confiance dans les capacités de nos
# machines pour charger presque un million d'objets en mémoire.
# Pour faire un compromis, les albums sont migrés individuellement un à un,
# mais tous les objets liés à ces albums
# (photos, groupes de vue, groupe d'édition, identification d'utilisateurs)
# sont migrés en tas.
#
# Ordre des opérations :
# 1. On migre les albums 1 à 1 (il y en a 1800, donc c'est relativement court)
# 2. On migre les photos par paquet de 2500 (soit ~une centaine d'opérations)
# 3. On migre tous les groupes de vue et tous les groupes d'édition des albums
#
# Au total, la migration devrait demander aux alentours de 2000 insertions,
# ce qui est un compromis acceptable entre une migration
# pas trop longue et une RAM pas trop surchargée.
#
# Pour ce qui est de la répartition des tables, quatre nouvelles tables
# sont créées : sas_album, sas_picture,
# sas_pictureviewgroups et sas_picture_editgroups.
# Tous les albums et toutes les photos qui sont dans core_sithfile
# vont être copiés dans ces tables.
# Comme les albums sont migrés un à un, ils recevront une nouvelle
# clef primaire.
# Pour les photos, en revanche, c'est beaucoup plus sûr de leur donner
# le même id que celui qu'il y avait dans core_sithfile.
#
# Les identifications des photos ne sont pas migrées pour l'instant.
# Ce qu'on va faire, c'est qu'on va changer la contrainte de clef étrangère
# sur la colonne des photos pour pointer vers sas_picture
# au lieu de core_sithfile.
# Cependant, pour que ça marche,
# il faut qu'au moment où ce changement est effectué,
# toutes les clefs primaires référencées existent à la fois dans
# les deux tables, sinon les contraintes d'intégrité ne sont pas respectées.
# La migration de ce fichier va donc s'occuper de créer les nouvelles tables
# et d'y copier les données nécessaires.
# Puis une deuxième migration s'occupera de changer les contraintes.
# Et enfin une troisième migration supprimera les anciennes données.
#
# Pavé César
albums = SithFile.objects.filter(is_in_sas=True, is_folder=True).prefetch_related(
"view_groups", "edit_groups"
)
old_albums = collections.deque(
albums.filter(parent_id=settings.SITH_SAS_ROOT_DIR_ID)
)
# Changement de représentation en DB.
# Dans l'ancien système, un fichier était dans le SAS si
# un fichier spécial (le SAS_ROOT) était parmi ses ancêtres.
# Comme maintenant les fichiers du SAS sont dans des tables à part,
# il ne peut plus y avoir de confusion.
# Les photos ont donc obligatoirement un parent (qui est un album)
# et les albums peuvent avoir un parent null.
# Un album sans parent est considéré comme se trouvant à la racine
# de l'arborescence.
# En quelque sorte, None est le nouveau SITH_SAS_ROOT_DIR_ID
album_id_old_to_new = {settings.SITH_SAS_ROOT_DIR_ID: None}
logger.info(f"migrating {albums.count()} albums")
while len(old_albums) > 0:
# Comme les albums référencent leur parent, les albums doivent être migrés
# par ordre croissant de profondeur dans l'arborescence.
# Chaque album est donc pris par la gauche de la file
# et ses enfants ajoutés sur la droite.
old_album = old_albums.popleft()
old_albums.extend(list(albums.filter(parent=old_album)))
new_album = Album.objects.create(
parent_id=album_id_old_to_new[old_album.parent_id],
event_date=old_album.date.date(),
name=old_album.name,
thumbnail=(old_album.file or None),
is_moderated=old_album.is_moderated,
)
# on garde un dictionnaire qui associe les id des albums dans l'ancienne table
# à leur id dans la nouvelle table, pour pouvoir recréer
# les liens de parenté entre albums
album_id_old_to_new[old_album.id] = new_album.id
pictures = SithFile.objects.filter(is_in_sas=True, is_folder=False)
nb_pictures = pictures.count()
logger.info(f"migrating {nb_pictures} pictures")
for i, pictures_batch in enumerate(itertools.batched(pictures, 2500), start=1):
Picture.objects.bulk_create(
[
Picture(
id=p.id,
name=p.name,
parent_id=album_id_old_to_new[p.parent_id],
thumbnail=p.thumbnail,
compressed=p.compressed,
original=p.file,
owner_id=p.owner_id,
created_at=p.date,
is_moderated=p.is_moderated,
asked_for_removal=p.asked_for_removal,
moderator_id=p.moderator_id,
)
for p in pictures_batch
]
)
logger.info(f"Migrated {min(i * 2500, nb_pictures)} / {nb_pictures} pictures")
logger.info("Migrating album groups")
albums = SithFile.objects.filter(is_in_sas=True, is_folder=True).exclude(
id=settings.SITH_SAS_ROOT_DIR_ID
)
Album.edit_groups.through.objects.bulk_create(
[
Album.view_groups.through(
album=album_id_old_to_new[g.sithfile_id], group_id=g.group_id
)
for g in SithFile.view_groups.through.objects.filter(sithfile__in=albums)
]
)
Album.edit_groups.through.objects.bulk_create(
[
Album.view_groups.through(
album=album_id_old_to_new[g.sithfile_id], group_id=g.group_id
)
for g in SithFile.view_groups.through.objects.filter(sithfile__in=albums)
]
)
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("core", "0044_alter_userban_options"),
("sas", "0005_alter_sasfile_options"),
]
operations = [
# les relations et les demandes de modération étaient liées à SithFile,
# via le model proxy Picture.
# Pour que la migration marche malgré la disparition du modèle Proxy,
# on change la relation pour qu'elle pointe directement vers SithFile
migrations.AlterField(
model_name="peoplepicturerelation",
name="picture",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="people",
to="core.sithfile",
verbose_name="picture",
),
),
migrations.AlterField(
model_name="picturemoderationrequest",
name="picture",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="moderation_requests",
to="core.sithfile",
verbose_name="Picture",
),
),
migrations.DeleteModel(name="Album"),
migrations.DeleteModel(name="Picture"),
migrations.DeleteModel(name="SasFile"),
migrations.CreateModel(
name="Album",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"thumbnail",
models.FileField(
max_length=256,
upload_to=sas.models.get_thumbnail_directory,
verbose_name="thumbnail",
),
),
("name", models.CharField(max_length=100, verbose_name="name")),
(
"event_date",
models.DateField(
default=django.utils.timezone.localdate,
help_text="The date on which the photos in this album were taken",
verbose_name="event date",
),
),
(
"is_moderated",
models.BooleanField(default=False, verbose_name="is moderated"),
),
(
"edit_groups",
models.ManyToManyField(
related_name="editable_albums",
to="core.group",
verbose_name="edit groups",
),
),
(
"parent",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="children",
to="sas.album",
verbose_name="parent",
),
),
(
"view_groups",
models.ManyToManyField(
related_name="viewable_albums",
to="core.group",
verbose_name="view groups",
),
),
],
options={"verbose_name": "album"},
),
migrations.CreateModel(
name="Picture",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"thumbnail",
models.FileField(
unique=True,
upload_to=sas.models.get_thumbnail_directory,
verbose_name="thumbnail",
max_length=256,
),
),
("name", models.CharField(max_length=256, verbose_name="file name")),
(
"original",
models.FileField(
unique=True,
upload_to=sas.models.get_directory,
verbose_name="original image",
max_length=256,
),
),
(
"compressed",
models.FileField(
unique=True,
upload_to=sas.models.get_compressed_directory,
verbose_name="compressed image",
max_length=256,
),
),
("created_at", models.DateTimeField(default=django.utils.timezone.now)),
(
"is_moderated",
models.BooleanField(default=False, verbose_name="is moderated"),
),
(
"asked_for_removal",
models.BooleanField(
default=False, verbose_name="asked for removal"
),
),
(
"moderator",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="moderated_pictures",
to=settings.AUTH_USER_MODEL,
),
),
(
"owner",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="owned_pictures",
to=settings.AUTH_USER_MODEL,
verbose_name="owner",
),
),
(
"parent",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="pictures",
to="sas.album",
verbose_name="album",
),
),
],
options={"abstract": False, "verbose_name": "picture"},
),
migrations.AddConstraint(
model_name="picture",
constraint=models.UniqueConstraint(
fields=("name", "parent"), name="sas_picture_unique_per_album"
),
),
migrations.AddConstraint(
model_name="album",
constraint=models.UniqueConstraint(
fields=("name", "parent"), name="unique_album_name_if_same_parent"
),
),
migrations.RunPython(
copy_albums_and_pictures,
reverse_code=migrations.RunPython.noop,
elidable=True,
),
]

View File

@@ -0,0 +1,31 @@
# Generated by Django 4.2.17 on 2025-01-25 23:50
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("sas", "0006_move_the_whole_sas")]
operations = [
migrations.AlterField(
model_name="peoplepicturerelation",
name="picture",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="people",
to="sas.picture",
verbose_name="picture",
),
),
migrations.AlterField(
model_name="picturemoderationrequest",
name="picture",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="moderation_requests",
to="sas.picture",
verbose_name="Picture",
),
),
]

View File

@@ -18,29 +18,52 @@ from __future__ import annotations
import contextlib
from io import BytesIO
from pathlib import Path
from typing import ClassVar, Self
from typing import TYPE_CHECKING, ClassVar, Self
from django.conf import settings
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.core.files.base import ContentFile
from django.db import models
from django.db.models import Exists, OuterRef, Q
from django.db.models.deletion import Collector
from django.urls import reverse
from django.utils import timezone
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from PIL import Image
from core.models import Notification, SithFile, User
from core.models import Group, Notification, User
from core.utils import exif_auto_rotate, resize_image
if TYPE_CHECKING:
from django.db.models.fields.files import FieldFile
class SasFile(SithFile):
"""Proxy model for any file in the SAS.
def get_directory(instance: SasFile, filename: str):
return f"./{instance.parent_path}/{filename}"
def get_compressed_directory(instance: SasFile, filename: str):
return f"./.compressed/{instance.parent_path}/{filename}"
def get_thumbnail_directory(instance: SasFile, filename: str):
if isinstance(instance, Album):
_, extension = filename.rsplit(".", 1)
filename = f"{instance.name}/thumb.{extension}"
return f"./.thumbnails/{instance.parent_path}/{filename}"
class SasFile(models.Model):
"""Abstract model for SAS files
May be used to have logic that should be shared by both
[Picture][sas.models.Picture] and [Album][sas.models.Album].
"""
class Meta:
proxy = True
abstract = True
permissions = [
("moderate_sasfile", "Can moderate SAS files"),
("view_unmoderated_sasfile", "Can view not moderated SAS files"),
@@ -65,6 +88,169 @@ class SasFile(SithFile):
def can_be_edited_by(self, user):
return user.has_perm("sas.change_sasfile")
@cached_property
def parent_path(self) -> str:
"""The parent location in the SAS album tree (e.g. `SAS/foo/bar`)."""
return "/".join(["SAS", *[p.name for p in self.parent_list]])
@cached_property
def parent_list(self) -> list[Album]:
"""The ancestors of this SAS object.
The result is ordered from the direct parent to the farthest one.
"""
parents = []
current = self.parent
while current is not None:
parents.append(current)
current = current.parent
return parents
class AlbumQuerySet(models.QuerySet):
def viewable_by(self, user: User) -> Self:
"""Filter the albums that this user can view.
Warning:
Calling this queryset method may add several additional requests.
"""
if user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):
return self.all()
if user.was_subscribed:
return self.filter(is_moderated=True)
# known bug : if all children of an album are also albums
# then this album is excluded, even if one of the sub-albums should be visible.
# The fs-like navigation is likely to be half-broken for non-subscribers,
# but that's ok, since non-subscribers are expected to see only the albums
# containing pictures on which they have been identified (hence, very few).
# Most, if not all, of their albums will be displayed on the
# `latest albums` section of the SAS.
# Moreover, they will still see all of their picture in their profile.
return self.filter(
Exists(Picture.objects.filter(parent_id=OuterRef("pk")).viewable_by(user))
)
class Album(SasFile):
NAME_MAX_LENGTH: ClassVar[int] = 50
name = models.CharField(_("name"), max_length=100)
parent = models.ForeignKey(
"self",
related_name="children",
verbose_name=_("parent"),
null=True,
blank=True,
on_delete=models.CASCADE,
)
thumbnail = models.FileField(
upload_to=get_thumbnail_directory,
verbose_name=_("thumbnail"),
max_length=256,
blank=True,
)
view_groups = models.ManyToManyField(
Group, related_name="viewable_albums", verbose_name=_("view groups"), blank=True
)
edit_groups = models.ManyToManyField(
Group, related_name="editable_albums", verbose_name=_("edit groups"), blank=True
)
event_date = models.DateField(
_("event date"),
help_text=_("The date on which the photos in this album were taken"),
default=timezone.localdate,
blank=True,
)
is_moderated = models.BooleanField(_("is moderated"), default=False)
objects = AlbumQuerySet.as_manager()
class Meta:
verbose_name = _("album")
constraints = [
models.UniqueConstraint(
fields=["name", "parent"],
name="unique_album_name_if_same_parent",
# TODO : add `nulls_distinct=True` after upgrading to django>=5.0
)
]
def __str__(self):
return f"Album {self.name}"
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
for user in User.objects.filter(
groups__id__in=[settings.SITH_GROUP_SAS_ADMIN_ID]
):
Notification(
user=user,
url=reverse("sas:moderation"),
type="SAS_MODERATION",
param="1",
).save()
def get_absolute_url(self):
return reverse("sas:album", kwargs={"album_id": self.id})
def clean(self):
super().clean()
if "/" in self.name:
raise ValidationError(_("Character '/' not authorized in name"))
if self.parent_id is not None and (
self.id == self.parent_id or self in self.parent_list
):
raise ValidationError(_("Loop in album tree"), code="loop")
if self.thumbnail:
try:
Image.open(BytesIO(self.thumbnail.read()))
except Image.UnidentifiedImageError as e:
raise ValidationError(_("This is not a valid album thumbnail")) from e
def delete(self, *args, **kwargs):
"""Delete the album, all of its children and all linked disk files"""
collector = Collector(using="default")
collector.collect([self])
albums: set[Album] = collector.data[Album]
pictures: set[Picture] = collector.data[Picture]
files: list[FieldFile] = [
*[a.thumbnail for a in albums],
*[p.thumbnail for p in pictures],
*[p.compressed for p in pictures],
*[p.original for p in pictures],
]
# `bool(f)` checks that the file actually exists on the disk
files = [f for f in files if bool(f)]
folders = {Path(f.path).parent for f in files}
res = super().delete(*args, **kwargs)
# once the model instances have been deleted,
# delete the actual files.
for file in files:
# save=False ensures that django doesn't recreate the db record,
# which would make the whole deletion pointless
# cf. https://docs.djangoproject.com/en/stable/ref/models/fields/#django.db.models.fields.files.FieldFile.delete
file.delete(save=False)
for folder in folders:
# now that the files are deleted, remove the empty folders
if folder.is_dir() and next(folder.iterdir(), None) is None:
folder.rmdir()
return res
def get_download_url(self):
return reverse("sas:album_preview", kwargs={"album_id": self.id})
def generate_thumbnail(self):
p = (
self.pictures.exclude(thumbnail="").order_by("?").first()
or self.children.exclude(thumbnail="").order_by("?").first()
)
if p:
# The file is loaded into memory to duplicate it.
# It may not be the most efficient way, but thumbnails are
# usually quite small, so it's still ok
self.thumbnail = ContentFile(p.thumbnail.read(), name="thumb.webp")
self.save()
class PictureQuerySet(models.QuerySet):
def viewable_by(self, user: User) -> Self:
@@ -80,23 +266,65 @@ class PictureQuerySet(models.QuerySet):
return self.filter(people__user_id=user.id, is_moderated=True)
class SASPictureManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(is_in_sas=True, is_folder=False)
class Picture(SasFile):
name = models.CharField(_("file name"), max_length=256)
parent = models.ForeignKey(
Album,
related_name="pictures",
verbose_name=_("album"),
on_delete=models.CASCADE,
)
thumbnail = models.FileField(
upload_to=get_thumbnail_directory,
verbose_name=_("thumbnail"),
max_length=256,
unique=True,
)
original = models.FileField(
upload_to=get_directory,
verbose_name=_("original image"),
max_length=256,
unique=True,
)
compressed = models.FileField(
upload_to=get_compressed_directory,
verbose_name=_("compressed image"),
max_length=256,
unique=True,
)
created_at = models.DateTimeField(default=timezone.now)
owner = models.ForeignKey(
User,
related_name="owned_pictures",
verbose_name=_("owner"),
on_delete=models.PROTECT,
)
is_moderated = models.BooleanField(_("is moderated"), default=False)
asked_for_removal = models.BooleanField(_("asked for removal"), default=False)
moderator = models.ForeignKey(
User,
related_name="moderated_pictures",
null=True,
blank=True,
on_delete=models.SET_NULL,
)
objects = PictureQuerySet.as_manager()
class Meta:
proxy = True
verbose_name = _("picture")
constraints = [
models.UniqueConstraint(
fields=["name", "parent"], name="sas_picture_unique_per_album"
)
]
objects = SASPictureManager.from_queryset(PictureQuerySet)()
def __str__(self):
return self.name
@property
def is_vertical(self):
with open(settings.MEDIA_ROOT / self.file.name, "rb") as f:
im = Image.open(BytesIO(f.read()))
(w, h) = im.size
return (w / h) < 1
def get_absolute_url(self):
return reverse("sas:picture", kwargs={"picture_id": self.id})
def get_download_url(self):
return reverse("sas:download", kwargs={"picture_id": self.id})
@@ -107,41 +335,34 @@ class Picture(SasFile):
def get_download_thumb_url(self):
return reverse("sas:download_thumb", kwargs={"picture_id": self.id})
def get_absolute_url(self):
return reverse("sas:picture", kwargs={"picture_id": self.id})
@property
def is_vertical(self):
# original, compressed and thumbnail image have all three the same ratio,
# so the smallest one is used to tell if the image is vertical
im = Image.open(BytesIO(self.thumbnail.read()))
(w, h) = im.size
return w < h
def generate_thumbnails(self, *, overwrite=False):
im = Image.open(BytesIO(self.file.read()))
def generate_thumbnails(self):
im = Image.open(self.original)
with contextlib.suppress(Exception):
im = exif_auto_rotate(im)
# convert the compressed image and the thumbnail into webp
# The original image keeps its original type, because it's not
# meant to be shown on the website, but rather to keep the real image
# for less frequent cases (like downloading the pictures of an user)
extension = self.mime_type.split("/")[-1]
# the HD version of the image doesn't need to be optimized, because :
# - it isn't frequently queried
# - optimizing large images takes a lot time, which greatly hinders the UX
# - optimizing large images takes a lot of time, which greatly hinders the UX
# - photographers usually already optimize their images
file = resize_image(im, max(im.size), extension, optimize=False)
thumb = resize_image(im, 200, "webp")
compressed = resize_image(im, 1200, "webp")
if overwrite:
self.file.delete()
self.thumbnail.delete()
self.compressed.delete()
new_extension_name = str(Path(self.name).with_suffix(".webp"))
self.file = file
self.file.name = self.name
new_extension_name = str(Path(self.original.name).with_suffix(".webp"))
self.thumbnail = thumb
self.thumbnail.name = new_extension_name
self.compressed = compressed
self.compressed.name = new_extension_name
def rotate(self, degree):
for attr in ["file", "compressed", "thumbnail"]:
name = self.__getattribute__(attr).name
with open(settings.MEDIA_ROOT / name, "r+b") as file:
for field in self.original, self.compressed, self.thumbnail:
with open(field.file, "r+b") as file:
if file:
im = Image.open(BytesIO(file.read()))
file.seek(0)
@@ -154,110 +375,6 @@ class Picture(SasFile):
progressive=True,
)
def get_next(self):
if self.is_moderated:
pictures_qs = self.parent.children.filter(
is_moderated=True,
asked_for_removal=False,
is_folder=False,
id__gt=self.id,
)
else:
pictures_qs = Picture.objects.filter(id__gt=self.id, is_moderated=False)
return pictures_qs.order_by("id").first()
def get_previous(self):
if self.is_moderated:
pictures_qs = self.parent.children.filter(
is_moderated=True,
asked_for_removal=False,
is_folder=False,
id__lt=self.id,
)
else:
pictures_qs = Picture.objects.filter(id__lt=self.id, is_moderated=False)
return pictures_qs.order_by("-id").first()
class AlbumQuerySet(models.QuerySet):
def viewable_by(self, user: User) -> Self:
"""Filter the albums that this user can view.
Warning:
Calling this queryset method may add several additional requests.
"""
if user.has_perm("sas.moderate_sasfile"):
return self.all()
if user.was_subscribed:
return self.filter(Q(is_moderated=True) | Q(owner=user))
# known bug : if all children of an album are also albums
# then this album is excluded, even if one of the sub-albums should be visible.
# The fs-like navigation is likely to be half-broken for non-subscribers,
# but that's ok, since non-subscribers are expected to see only the albums
# containing pictures on which they have been identified (hence, very few).
# Most, if not all, of their albums will be displayed on the
# `latest albums` section of the SAS.
# Moreover, they will still see all of their picture in their profile.
return self.filter(
Exists(Picture.objects.filter(parent_id=OuterRef("pk")).viewable_by(user))
)
class SASAlbumManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(is_in_sas=True, is_folder=True)
class Album(SasFile):
NAME_MAX_LENGTH: ClassVar[int] = 50
"""Maximum length of an album's name.
[SithFile][core.models.SithFile] have a maximum length
of 256 characters.
However, this limit is too high for albums.
Names longer than 50 characters are harder to read
and harder to display on the SAS page.
It is to be noted, though, that this does not
add or modify any db behaviour.
It's just a constant to be used in views and forms.
"""
class Meta:
proxy = True
objects = SASAlbumManager.from_queryset(AlbumQuerySet)()
@property
def children_pictures(self):
return Picture.objects.filter(parent=self)
@property
def children_albums(self):
return Album.objects.filter(parent=self)
def get_absolute_url(self):
if self.id == settings.SITH_SAS_ROOT_DIR_ID:
return reverse("sas:main")
return reverse("sas:album", kwargs={"album_id": self.id})
def get_download_url(self):
return reverse("sas:album_preview", kwargs={"album_id": self.id})
def generate_thumbnail(self):
p = (
self.children_pictures.order_by("?").first()
or self.children_albums.exclude(file=None)
.exclude(file="")
.order_by("?")
.first()
)
if p and p.file:
image = resize_image(Image.open(BytesIO(p.file.read())), 200, "webp")
self.file = image
self.file.name = f"{self.name}/thumb.webp"
self.save()
def sas_notification_callback(notif: Notification):
count = Picture.objects.filter(is_moderated=False).count()

View File

@@ -2,20 +2,19 @@ from datetime import datetime
from pathlib import Path
from typing import Annotated
from annotated_types import MinLen
from django.urls import reverse
from ninja import FilterSchema, ModelSchema, Schema
from ninja import FilterLookup, FilterSchema, ModelSchema, Schema
from pydantic import Field, NonNegativeInt
from core.schemas import SimpleUserSchema, UserProfileSchema
from core.schemas import NonEmptyStr, SimpleUserSchema, UserProfileSchema
from sas.models import Album, Picture, PictureModerationRequest
class AlbumFilterSchema(FilterSchema):
search: Annotated[str, MinLen(1)] | None = Field(None, q="name__icontains")
before_date: datetime | None = Field(None, q="event_date__lte")
after_date: datetime | None = Field(None, q="event_date__gte")
parent_id: int | None = Field(None, q="parent_id")
search: Annotated[NonEmptyStr | None, FilterLookup("name__icontains")] = None
before_date: Annotated[datetime | None, FilterLookup("event_date__lte")] = None
after_date: Annotated[datetime | None, FilterLookup("event_date__gte")] = None
parent_id: Annotated[int | None, FilterLookup("parent_id")] = None
class SimpleAlbumSchema(ModelSchema):
@@ -56,20 +55,27 @@ class AlbumAutocompleteSchema(ModelSchema):
@staticmethod
def resolve_path(obj: Album) -> str:
return str(Path(obj.get_parent_path()) / obj.name)
return str(Path(obj.parent_path) / obj.name)
class MoveAlbumSchema(Schema):
id: int
new_parent_id: int
class PictureFilterSchema(FilterSchema):
before_date: datetime | None = Field(None, q="date__lte")
after_date: datetime | None = Field(None, q="date__gte")
users_identified: set[int] | None = Field(None, q="people__user_id__in")
album_id: int | None = Field(None, q="parent_id")
before_date: Annotated[datetime | None, FilterLookup("date__lte")] = None
after_date: Annotated[datetime | None, FilterLookup("date__gte")] = None
users_identified: Annotated[
set[int] | None, FilterLookup("people__user_id__in")
] = None
album_id: Annotated[int | None, FilterLookup("parent_id")] = None
class PictureSchema(ModelSchema):
class Meta:
model = Picture
fields = ["id", "name", "date", "size", "is_moderated", "asked_for_removal"]
fields = ["id", "name", "created_at", "is_moderated", "asked_for_removal"]
owner: UserProfileSchema
sas_url: str

View File

@@ -125,3 +125,108 @@ document.addEventListener("alpine:init", () => {
},
}));
});
// Todo: migrate to alpine.js if we have some time
// $("form#upload_form").submit(function (event) {
// const formData = new FormData($(this)[0]);
//
// if (!formData.get("album_name") && !formData.get("images").name) return false;
//
// if (!formData.get("images").name) {
// return true;
// }
//
// event.preventDefault();
//
// let errorList = this.querySelector("#upload_form ul.errorlist.nonfield");
// if (errorList === null) {
// errorList = document.createElement("ul");
// errorList.classList.add("errorlist", "nonfield");
// this.insertBefore(errorList, this.firstElementChild);
// }
//
// while (errorList.childElementCount > 0)
// errorList.removeChild(errorList.firstElementChild);
//
// let progress = this.querySelector("progress");
// if (progress === null) {
// progress = document.createElement("progress");
// progress.value = 0;
// const p = document.createElement("p");
// p.appendChild(progress);
// this.insertBefore(p, this.lastElementChild);
// }
//
// let dataHolder;
//
// if (formData.get("album_name")) {
// dataHolder = new FormData();
// dataHolder.set("csrfmiddlewaretoken", "{{ csrf_token }}");
// dataHolder.set("album_name", formData.get("album_name"));
// $.ajax({
// method: "POST",
// url: "{{ url('sas:album_upload', album_id=object.id) }}",
// data: dataHolder,
// processData: false,
// contentType: false,
// success: onSuccess,
// });
// }
//
// const images = formData.getAll("images");
// const imagesCount = images.length;
// let completeCount = 0;
//
// const poolSize = 1;
// const imagePool = [];
//
// while (images.length > 0 && imagePool.length < poolSize) {
// const image = images.shift();
// imagePool.push(image);
// sendImage(image);
// }
//
// function sendImage(image) {
// dataHolder = new FormData();
// dataHolder.set("csrfmiddlewaretoken", "{{ csrf_token }}");
// dataHolder.set("images", image);
//
// $.ajax({
// method: "POST",
// url: "{{ url('sas:album_upload', album_id=object.id) }}",
// data: dataHolder,
// processData: false,
// contentType: false,
// })
// .fail(onSuccess.bind(undefined, image))
// .done(onSuccess.bind(undefined, image))
// .always(next.bind(undefined, image));
// }
//
// function next(image, _, __) {
// const index = imagePool.indexOf(image);
// const nextImage = images.shift();
//
// if (index !== -1) {
// imagePool.splice(index, 1);
// }
//
// if (nextImage) {
// imagePool.push(nextImage);
// sendImage(nextImage);
// }
// }
//
// function onSuccess(image, data, _, __) {
// let errors = [];
//
// if ($(data.responseText).find(".errorlist.nonfield")[0])
// errors = Array.from($(data.responseText).find(".errorlist.nonfield")[0].children);
//
// while (errors.length > 0) errorList.appendChild(errors.shift());
//
// progress.value = ++completeCount / imagesCount;
// if (progress.value === 1 && errorList.children.length === 0)
// document.location.reload();
// }
// });

View File

@@ -31,10 +31,10 @@ document.addEventListener("alpine:init", () => {
await Promise.all(
this.downloadPictures.map(async (p: PictureSchema) => {
const imgName = `${p.album}/IMG_${p.date.replace(/[:\-]/g, "_")}${p.name.slice(p.name.lastIndexOf("."))}`;
const imgName = `${p.album.name}/IMG_${p.id}_${p.created_at.replace(/[:\-]/g, "_")}${p.name.slice(p.name.lastIndexOf("."))}`;
return zipWriter.add(imgName, new HttpReader(p.full_size_url), {
level: 9,
lastModDate: new Date(p.date),
lastModDate: new Date(p.created_at),
onstart: incrementProgressBar,
});
}),

View File

@@ -142,7 +142,8 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
// biome-ignore lint/style/useNamingConvention: api is in snake_case
full_size_url: "",
owner: "",
date: new Date(),
// biome-ignore lint/style/useNamingConvention: api is in snake_case
created_at: new Date(),
identifications: [] as IdentifiedUserSchema[],
},
/**

View File

@@ -20,7 +20,7 @@
{% block content %}
<code>
<a href="{{ url('sas:main') }}">SAS</a> / {{ print_path(album.parent) }} {{ album.get_display_name() }}
<a href="{{ url('sas:main') }}">SAS</a> / {{ print_path(album.parent) }} {{ album.name }}
</code>
{% set is_sas_admin = user.can_edit(album) %}
@@ -30,7 +30,7 @@
<form action="" method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="album-navbar">
<h3>{{ album.get_display_name() }}</h3>
<h3>{{ album.name }}</h3>
<div class="toolbar">
<a href="{{ url('sas:album_edit', album_id=album.id) }}">{% trans %}Edit{% endtrans %}</a>
@@ -40,17 +40,17 @@
</div>
</div>
{% if clipboard %}
<div class="clipboard">
{% trans %}Clipboard: {% endtrans %}
<ul>
{% for f in clipboard %}
<li>{{ f.get_full_path() }}</li>
{% endfor %}
</ul>
<input name="clear" type="submit" value="{% trans %}Clear clipboard{% endtrans %}">
</div>
{% endif %}
{# {% if clipboard %}#}
{# <div class="clipboard">#}
{# {% trans %}Clipboard: {% endtrans %}#}
{# <ul>#}
{# {% for f in clipboard["albums"] %}#}
{# <li>{{ f.get_full_path() }}</li>#}
{# {% endfor %}#}
{# </ul>#}
{# <input name="clear" type="submit" value="{% trans %}Clear clipboard{% endtrans %}">#}
{# </div>#}
{# {% endif %}#}
{% endif %}
{% if show_albums %}
@@ -73,8 +73,8 @@
<div class="text">{% trans %}To be moderated{% endtrans %}</div>
</template>
</div>
{% if is_sas_admin %}
<input type="checkbox" name="file_list" :value="album.id">
{% if edit_mode %}
<input type="checkbox" name="album_list" :value="album.id">
{% endif %}
</a>
</template>
@@ -100,7 +100,7 @@
</template>
</div>
{% if is_sas_admin %}
<input type="checkbox" name="file_list" :value="picture.id">
<input type="checkbox" name="picture_list" :value="picture.id">
{% endif %}
</a>
</template>
@@ -120,9 +120,9 @@
{% csrf_token %}
<div class="inputs">
<p>
<label for="{{ upload_form.images.id_for_label }}">{{ upload_form.images.label }} :</label>
{{ upload_form.images|add_attr("x-ref=pictures") }}
<span class="helptext">{{ upload_form.images.help_text }}</span>
<label for="{{ form.images.id_for_label }}">{{ form.images.label }} :</label>
{{ form.images|add_attr("x-ref=pictures") }}
<span class="helptext">{{ form.images.help_text }}</span>
</p>
<input type="submit" value="{% trans %}Upload{% endtrans %}" />
<progress x-ref="progress" x-show="sending"></progress>

Some files were not shown because too many files have changed in this diff Show More