1 Commits

Author SHA1 Message Date
imperosol
46d9c3822a remove cache calls to fetch user membership 2025-12-20 09:04:18 +01:00
31 changed files with 711 additions and 882 deletions

View File

@@ -26,7 +26,6 @@ from __future__ import annotations
from typing import Iterable, Self
from django.conf import settings
from django.core.cache import cache
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import RegexValidator, validate_email
from django.db import models, transaction
@@ -187,9 +186,6 @@ class Club(models.Model):
self.page.save(force_lock=True)
def delete(self, *args, **kwargs) -> tuple[int, dict[str, int]]:
# Invalidate the cache of this club and of its memberships
for membership in self.members.ongoing().select_related("user"):
cache.delete(f"membership_{self.id}_{membership.user.id}")
self.board_group.delete()
self.members_group.delete()
return super().delete(*args, **kwargs)
@@ -210,24 +206,15 @@ class Club(models.Model):
"""Method to see if that object can be edited by the given user."""
return self.has_rights_in_club(user)
def get_membership_for(self, user: User) -> Membership | None:
"""Return the current membership the given user.
@cached_property
def current_members(self) -> list[Membership]:
return self.members.ongoing().select_related("user").order_by("-role")
Note:
The result is cached.
"""
def get_membership_for(self, user: User) -> Membership | None:
"""Return the current membership of the given user."""
if user.is_anonymous:
return None
membership = cache.get(f"membership_{self.id}_{user.id}")
if membership == "not_member":
return None
if membership is None:
membership = self.members.filter(user=user, end_date=None).first()
if membership is None:
cache.set(f"membership_{self.id}_{user.id}", "not_member")
else:
cache.set(f"membership_{self.id}_{user.id}", membership)
return membership
return next((m for m in self.current_members if m.user_id == user.id), None)
def has_rights_in_club(self, user: User) -> bool:
return user.is_in_group(pk=self.board_group_id)
@@ -295,28 +282,20 @@ class MembershipQuerySet(models.QuerySet):
and add them in the club groups they should be in.
Be aware that this adds three db queries :
one to retrieve the updated memberships,
one to perform group removal and one to perform
group attribution.
- one to retrieve the updated memberships
- one to perform group removal
- and one to perform group attribution.
"""
nb_rows = super().update(**kwargs)
if nb_rows == 0:
# if no row was affected, no need to refresh the cache
# if no row was affected, no need to edit club groups
return 0
cache_memberships = {}
memberships = set(self.select_related("club"))
# delete all User-Group relations and recreate the necessary ones
# It's more concise to write and more reliable
Membership._remove_club_groups(memberships)
Membership._add_club_groups(memberships)
for member in memberships:
cache_key = f"membership_{member.club_id}_{member.user_id}"
if member.end_date is None:
cache_memberships[cache_key] = member
else:
cache_memberships[cache_key] = "not_member"
cache.set_many(cache_memberships)
return nb_rows
def delete(self) -> tuple[int, dict[str, int]]:
@@ -339,12 +318,6 @@ class MembershipQuerySet(models.QuerySet):
nb_rows, rows_counts = super().delete()
if nb_rows > 0:
Membership._remove_club_groups(memberships)
cache.set_many(
{
f"membership_{m.club_id}_{m.user_id}": "not_member"
for m in memberships
}
)
return nb_rows, rows_counts
@@ -408,9 +381,6 @@ class Membership(models.Model):
self._remove_club_groups([self])
if self.end_date is None:
self._add_club_groups([self])
cache.set(f"membership_{self.club_id}_{self.user_id}", self)
else:
cache.set(f"membership_{self.club_id}_{self.user_id}", "not_member")
def get_absolute_url(self):
return reverse("club:club_members", kwargs={"club_id": self.club_id})
@@ -431,7 +401,6 @@ class Membership(models.Model):
def delete(self, *args, **kwargs):
self._remove_club_groups([self])
super().delete(*args, **kwargs)
cache.delete(f"membership_{self.club_id}_{self.user_id}")
@staticmethod
def _remove_club_groups(

View File

@@ -72,25 +72,6 @@ class TestMembershipQuerySet(TestClub):
expected.sort(key=lambda i: i.id)
assert members == expected
def test_update_invalidate_cache(self):
"""Test that the `update` queryset method properly invalidate cache."""
mem_skia = self.simple_board_member.memberships.get(club=self.club)
cache.set(f"membership_{mem_skia.club_id}_{mem_skia.user_id}", mem_skia)
self.simple_board_member.memberships.update(end_date=localtime(now()).date())
assert (
cache.get(f"membership_{mem_skia.club_id}_{mem_skia.user_id}")
== "not_member"
)
mem_richard = self.richard.memberships.get(club=self.club)
cache.set(
f"membership_{mem_richard.club_id}_{mem_richard.user_id}", mem_richard
)
self.richard.memberships.update(role=5)
new_mem = self.richard.memberships.get(club=self.club)
assert new_mem != "not_member"
assert new_mem.role == 5
def test_update_change_club_groups(self):
"""Test that `update` set the user groups accordingly."""
user = baker.make(User)
@@ -112,24 +93,6 @@ class TestMembershipQuerySet(TestClub):
assert not user.groups.contains(members_group)
assert not user.groups.contains(board_group)
def test_delete_invalidate_cache(self):
"""Test that the `delete` queryset properly invalidate cache."""
mem_skia = self.simple_board_member.memberships.get(club=self.club)
mem_comptable = self.president.memberships.get(club=self.club)
cache.set(f"membership_{mem_skia.club_id}_{mem_skia.user_id}", mem_skia)
cache.set(
f"membership_{mem_comptable.club_id}_{mem_comptable.user_id}", mem_comptable
)
# should delete the subscriptions of simple_board_member and president
self.club.members.ongoing().board().delete()
for membership in (mem_skia, mem_comptable):
cached_mem = cache.get(
f"membership_{membership.club_id}_{membership.user_id}"
)
assert cached_mem == "not_member"
def test_delete_remove_from_groups(self):
"""Test that `delete` removes from club groups"""
user = baker.make(User)

View File

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

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 UE
from pedagogy.models import UV
from sas.models import Album, PeoplePictureRelation, Picture
from subscription.models import Subscription
@@ -661,20 +661,20 @@ class Command(BaseCommand):
# Create some data for pedagogy
UE(
UV(
code="PA00",
author=User.objects.get(id=0),
credit_type=settings.SITH_PEDAGOGY_UE_TYPE[3][0],
credit_type=settings.SITH_PEDAGOGY_UV_TYPE[3][0],
manager="Laurent HEYBERGER",
semester=settings.SITH_PEDAGOGY_UE_SEMESTER[3][0],
language=settings.SITH_PEDAGOGY_UE_LANGUAGE[0][0],
semester=settings.SITH_PEDAGOGY_UV_SEMESTER[3][0],
language=settings.SITH_PEDAGOGY_UV_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'UE
* Si accord réalisation dans le cadre de l'UV
* Compte-rendu de l'expérience
* Présentation""",
skills="""* Gérer un projet associatif ou une action éducative en autonomie:
@@ -790,16 +790,16 @@ class Command(BaseCommand):
subscribers = Group.objects.create(name="Cotisants")
subscribers.permissions.add(
*list(perms.filter(codename__in=["add_news", "add_uecomment"]))
*list(perms.filter(codename__in=["add_news", "add_uvcomment"]))
)
old_subscribers = Group.objects.create(name="Anciens cotisants")
old_subscribers.permissions.add(
*list(
perms.filter(
codename__in=[
"view_ue",
"view_uecomment",
"add_uecommentreport",
"view_uv",
"view_uvcomment",
"add_uvcommentreport",
"view_user",
"view_picture",
"view_album",
@@ -875,7 +875,7 @@ class Command(BaseCommand):
pedagogy_admin.permissions.add(
*list(
perms.filter(content_type__app_label="pedagogy")
.exclude(codename__in=["change_uecomment"])
.exclude(codename__in=["change_uvcomment"])
.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 UE
from pedagogy.models import UV
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_ues()
self.create_uvs()
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_ues(self):
def create_uvs(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(
UE(
UV(
code=code,
author=root,
manager=random.choice(teachers),
@@ -229,7 +229,7 @@ class Command(BaseCommand):
hours_TE=random.randint(15, 40),
)
)
UE.objects.bulk_create(uvs, ignore_conflicts=True)
UV.objects.bulk_create(uvs, ignore_conflicts=True)
def create_products(self):
categories = [

View File

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

View File

@@ -22,19 +22,17 @@ from bs4 import BeautifulSoup
from django.contrib.auth.hashers import make_password
from django.contrib.auth.models import Permission
from django.core import mail
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.core.mail import EmailMessage
from django.test import Client, RequestFactory, TestCase
from django.urls import reverse
from django.utils.timezone import now
from django.views.generic import View
from django.views.generic.base import ContextMixin
from model_bakery import baker
from pytest_django.asserts import assertInHTML, assertRedirects
from antispam.models import ToxicDomain
from club.models import Club, Membership
from club.models import Club
from core.baker_recipes import subscriber_user
from core.markdown import markdown
from core.models import AnonymousUser, Group, Page, User, validate_promo
@@ -436,23 +434,6 @@ class TestUserIsInGroup(TestCase):
with self.assertNumQueries(0):
self.public_user.is_in_group(pk=group_not_in.id)
def test_cache_properly_cleared_membership(self):
"""Test that when the membership of a user end,
the cache is properly invalidated.
"""
membership = baker.make(Membership, club=self.club, user=self.public_user)
cache.clear()
self.club.get_membership_for(self.public_user) # this should populate the cache
assert membership == cache.get(
f"membership_{self.club.id}_{self.public_user.id}"
)
membership.end_date = now() - timedelta(minutes=5)
membership.save()
cached_membership = cache.get(
f"membership_{self.club.id}_{self.public_user.id}"
)
assert cached_membership == "not_member"
def test_not_existing_group(self):
"""Test that searching for a not existing group
returns False.

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

View File

@@ -6,7 +6,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-12-19 23:10+0100\n"
"POT-Creation-Date: 2025-12-17 00:03+0100\n"
"PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -218,7 +218,7 @@ msgid "You can not make loops in clubs"
msgstr "Vous ne pouvez pas faire de boucles dans les clubs"
#: club/models.py core/models.py counter/models.py eboutic/models.py
#: election/models.py pedagogy/models.py sas/models.py trombi/models.py
#: election/models.py sas/models.py trombi/models.py
msgid "user"
msgstr "utilisateur"
@@ -470,15 +470,14 @@ msgstr "Méthode de paiement"
#: core/templates/core/file_detail.jinja
#: core/templates/core/file_moderation.jinja
#: core/templates/core/group_detail.jinja core/templates/core/group_list.jinja
#: core/templates/core/page/prop.jinja
#: core/templates/core/macros.jinja core/templates/core/page/prop.jinja
#: core/templates/core/user_account_detail.jinja
#: core/templates/core/user_clubs.jinja core/templates/core/user_edit.jinja
#: core/templates/core/user_godfathers.jinja
#: counter/templates/counter/fragments/create_student_card.jinja
#: counter/templates/counter/last_ops.jinja
#: election/templates/election/election_detail.jinja
#: forum/templates/forum/macros.jinja pedagogy/templates/pedagogy/guide.jinja
#: pedagogy/templates/pedagogy/ue_detail.jinja sas/templates/sas/album.jinja
#: pedagogy/templates/pedagogy/uv_detail.jinja sas/templates/sas/album.jinja
#: sas/templates/sas/moderation.jinja sas/templates/sas/picture.jinja
#: trombi/templates/trombi/detail.jinja
#: trombi/templates/trombi/edit_profile.jinja
@@ -673,7 +672,7 @@ msgstr "Outils"
#: counter/templates/counter/counter_list.jinja
#: election/templates/election/election_detail.jinja
#: forum/templates/forum/macros.jinja pedagogy/templates/pedagogy/guide.jinja
#: pedagogy/templates/pedagogy/ue_detail.jinja sas/templates/sas/album.jinja
#: pedagogy/templates/pedagogy/uv_detail.jinja sas/templates/sas/album.jinja
#: trombi/templates/trombi/detail.jinja
#: trombi/templates/trombi/edit_profile.jinja
msgid "Edit"
@@ -1077,9 +1076,9 @@ msgstr "Liens"
msgid "Our services"
msgstr "Nos services"
#: com/templates/com/news_list.jinja pedagogy/templates/pedagogy/guide.jinja
msgid "UE Guide"
msgstr "Guide des UEs"
#: com/templates/com/news_list.jinja
msgid "UV Guide"
msgstr "Guide des UVs"
#: com/templates/com/news_list.jinja
msgid "Timetable"
@@ -1215,7 +1214,7 @@ msgstr "Descendre"
#: com/templates/com/weekmail_preview.jinja
#: core/templates/core/user_account_detail.jinja
#: pedagogy/templates/pedagogy/ue_detail.jinja
#: pedagogy/templates/pedagogy/uv_detail.jinja
#: trombi/templates/trombi/comment_moderation.jinja
#: trombi/templates/trombi/export.jinja
msgid "Back"
@@ -2063,7 +2062,7 @@ msgstr "Éditer le groupe"
#: core/templates/core/group_edit.jinja core/templates/core/user_edit.jinja
#: core/templates/core/user_group.jinja
#: pedagogy/templates/pedagogy/ue_edit.jinja
#: pedagogy/templates/pedagogy/uv_edit.jinja
msgid "Update"
msgstr "Mettre à jour"
@@ -2788,8 +2787,8 @@ msgid "Subscription stats"
msgstr "Statistiques de cotisation"
#: core/templates/core/user_tools.jinja pedagogy/templates/pedagogy/guide.jinja
msgid "Create UE"
msgstr "Créer UE"
msgid "Create UV"
msgstr "Créer UV"
#: core/templates/core/user_tools.jinja pedagogy/templates/pedagogy/guide.jinja
#: trombi/templates/trombi/detail.jinja
@@ -2881,6 +2880,10 @@ msgstr "Fillot / Fillote"
msgid "Select user"
msgstr "Choisir un utilisateur"
#: core/views/forms.py
msgid "This user does not exist"
msgstr "Cet utilisateur n'existe pas"
#: core/views/forms.py
msgid "You cannot be related to yourself"
msgstr "Vous ne pouvez pas être relié à vous-même"
@@ -3384,7 +3387,7 @@ msgstr "Coffre vidé"
#: counter/templates/counter/cash_summary_list.jinja counter/views/cash.py
#: pedagogy/templates/pedagogy/moderation.jinja
#: pedagogy/templates/pedagogy/ue_detail.jinja
#: pedagogy/templates/pedagogy/uv_detail.jinja
#: trombi/templates/trombi/comment.jinja
#: trombi/templates/trombi/user_tools.jinja
msgid "Comment"
@@ -4402,8 +4405,8 @@ msgid "Do not vote"
msgstr "Ne pas voter"
#: pedagogy/forms.py
msgid "This user has already commented on this UE"
msgstr "Cet utilisateur a déjà commenté cette UE"
msgid "This user has already commented on this UV"
msgstr "Cet utilisateur a déjà commenté cette UV"
#: pedagogy/forms.py
msgid "Accepted reports"
@@ -4415,10 +4418,10 @@ msgstr "Signalements refusés"
#: pedagogy/models.py
msgid ""
"The code of an UE must only contains uppercase characters without accent and "
"The code of an UV must only contains uppercase characters without accent and "
"numbers"
msgstr ""
"Le code d'une UE doit seulement contenir des caractères majuscule sans "
"Le code d'une UV doit seulement contenir des caractères majuscule sans "
"accents et nombres"
#: pedagogy/models.py
@@ -4426,8 +4429,8 @@ msgid "credit type"
msgstr "type de crédit"
#: pedagogy/models.py
msgid "ue manager"
msgstr "gestionnaire d'ue"
msgid "uv manager"
msgstr "gestionnaire d'uv"
#: pedagogy/models.py
msgid "language"
@@ -4478,7 +4481,7 @@ msgid "hours TE"
msgstr "heures TE"
#: pedagogy/models.py
msgid "ue"
msgid "uv"
msgstr "UE"
#: pedagogy/models.py
@@ -4517,6 +4520,10 @@ msgstr "signaler"
msgid "reporter"
msgstr "signalant"
#: pedagogy/templates/pedagogy/guide.jinja
msgid "UE Guide"
msgstr "Guide des UEs"
#: pedagogy/templates/pedagogy/guide.jinja
msgid "A guide of courses available at UTBM."
msgstr "Un guide de tous les cours disponibles à l'UTBM."
@@ -4533,7 +4540,7 @@ msgstr "%(credit_type)s"
#: pedagogy/templates/pedagogy/guide.jinja
#: pedagogy/templates/pedagogy/moderation.jinja
msgid "UE"
msgid "UV"
msgstr "UE"
#: pedagogy/templates/pedagogy/guide.jinja
@@ -4545,16 +4552,16 @@ msgid "Credit type"
msgstr "Type de crédit"
#: pedagogy/templates/pedagogy/guide.jinja
msgid "closed ue"
msgstr "ue fermée"
msgid "closed uv"
msgstr "uv fermée"
#: pedagogy/templates/pedagogy/macros.jinja
msgid " not rated "
msgstr "non noté"
#: pedagogy/templates/pedagogy/moderation.jinja
msgid "UE comment moderation"
msgstr "Modération des commentaires d'UE"
msgid "UV comment moderation"
msgstr "Modération des commentaires d'UV"
#: pedagogy/templates/pedagogy/moderation.jinja
#: rootplace/templates/rootplace/userban.jinja sas/models.py
@@ -4569,96 +4576,96 @@ msgstr "Supprimer commentaire"
msgid "Delete report"
msgstr "Supprimer signalement"
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "UE Details"
msgstr "Détails d'UE"
#: pedagogy/templates/pedagogy/uv_detail.jinja
msgid "UV Details"
msgstr "Détails d'UV"
#: pedagogy/templates/pedagogy/ue_detail.jinja
#: pedagogy/templates/pedagogy/uv_detail.jinja
msgid "CM: "
msgstr "CM : "
#: pedagogy/templates/pedagogy/ue_detail.jinja
#: pedagogy/templates/pedagogy/uv_detail.jinja
msgid "TD: "
msgstr "TD : "
#: pedagogy/templates/pedagogy/ue_detail.jinja
#: pedagogy/templates/pedagogy/uv_detail.jinja
msgid "TP: "
msgstr "TP : "
#: pedagogy/templates/pedagogy/ue_detail.jinja
#: pedagogy/templates/pedagogy/uv_detail.jinja
msgid "TE: "
msgstr "TE : "
#: pedagogy/templates/pedagogy/ue_detail.jinja
#: pedagogy/templates/pedagogy/uv_detail.jinja
msgid "THE: "
msgstr "THE : "
#: pedagogy/templates/pedagogy/ue_detail.jinja
#: pedagogy/templates/pedagogy/uv_detail.jinja
msgid "Global grade"
msgstr "Note globale"
#: pedagogy/templates/pedagogy/ue_detail.jinja
#: pedagogy/templates/pedagogy/uv_detail.jinja
msgid "Utility"
msgstr "Utilité"
#: pedagogy/templates/pedagogy/ue_detail.jinja
#: pedagogy/templates/pedagogy/uv_detail.jinja
msgid "Interest"
msgstr "Intérêt"
#: pedagogy/templates/pedagogy/ue_detail.jinja
#: pedagogy/templates/pedagogy/uv_detail.jinja
msgid "Teaching"
msgstr "Enseignement"
#: pedagogy/templates/pedagogy/ue_detail.jinja
#: pedagogy/templates/pedagogy/uv_detail.jinja
msgid "Work load"
msgstr "Charge de travail"
#: pedagogy/templates/pedagogy/ue_detail.jinja
#: pedagogy/templates/pedagogy/uv_detail.jinja
msgid "Objectives"
msgstr "Objectifs"
#: pedagogy/templates/pedagogy/ue_detail.jinja
#: pedagogy/templates/pedagogy/uv_detail.jinja
msgid "Program"
msgstr "Programme"
#: pedagogy/templates/pedagogy/ue_detail.jinja
#: pedagogy/templates/pedagogy/uv_detail.jinja
msgid "Earned skills"
msgstr "Compétences acquises"
#: pedagogy/templates/pedagogy/ue_detail.jinja
#: pedagogy/templates/pedagogy/uv_detail.jinja
msgid "Key concepts"
msgstr "Concepts clefs"
#: pedagogy/templates/pedagogy/ue_detail.jinja
#: pedagogy/templates/pedagogy/uv_detail.jinja
msgid "UE manager: "
msgstr "Gestionnaire d'UE : "
#: pedagogy/templates/pedagogy/ue_detail.jinja pedagogy/tests/tests.py
#: pedagogy/templates/pedagogy/uv_detail.jinja pedagogy/tests/tests.py
msgid ""
"You already posted a comment on this UE. If you want to comment again, "
"You already posted a comment on this UV. If you want to comment again, "
"please modify or delete your previous comment."
msgstr ""
"Vous avez déjà commenté cette UE. Si vous voulez de nouveau commenter, "
"Vous avez déjà commenté cette UV. Si vous voulez de nouveau commenter, "
"veuillez modifier ou supprimer votre commentaire précédent."
#: pedagogy/templates/pedagogy/ue_detail.jinja
#: pedagogy/templates/pedagogy/uv_detail.jinja
msgid "Leave comment"
msgstr "Laisser un commentaire"
#: pedagogy/templates/pedagogy/ue_detail.jinja
#: pedagogy/templates/pedagogy/uv_detail.jinja
#: trombi/templates/trombi/export.jinja
msgid "Comments"
msgstr "Commentaires"
#: pedagogy/templates/pedagogy/ue_detail.jinja
#: pedagogy/templates/pedagogy/uv_detail.jinja
msgid "This comment has been reported"
msgstr "Ce commentaire a été signalé"
#: pedagogy/templates/pedagogy/ue_detail.jinja
#: pedagogy/templates/pedagogy/uv_detail.jinja
msgid "Report this comment"
msgstr "Signaler ce commentaire"
#: pedagogy/templates/pedagogy/ue_edit.jinja
#: pedagogy/templates/pedagogy/uv_edit.jinja
msgid "Edit UE"
msgstr "Éditer l'UE"

View File

@@ -23,35 +23,35 @@
from django.contrib import admin
from haystack.admin import SearchModelAdmin
from pedagogy.models import UE, UEComment, UECommentReport
from pedagogy.models import UV, UVComment, UVCommentReport
@admin.register(UE)
class UEAdmin(admin.ModelAdmin):
@admin.register(UV)
class UVAdmin(admin.ModelAdmin):
list_display = ("code", "title", "credit_type", "credits", "department")
search_fields = ("code", "title", "department")
autocomplete_fields = ("author",)
@admin.register(UEComment)
class UECommentAdmin(admin.ModelAdmin):
list_display = ("author", "ue", "grade_global", "publish_date")
@admin.register(UVComment)
class UVCommentAdmin(admin.ModelAdmin):
list_display = ("author", "uv", "grade_global", "publish_date")
search_fields = (
"author__username",
"author__first_name",
"author__last_name",
"ue__code",
"uv__code",
)
autocomplete_fields = ("author",)
@admin.register(UECommentReport)
class UECommentReportAdmin(SearchModelAdmin):
list_display = ("reporter", "ue")
@admin.register(UVCommentReport)
class UVCommentReportAdmin(SearchModelAdmin):
list_display = ("reporter", "uv")
search_fields = (
"reporter__username",
"reporter__first_name",
"reporter__last_name",
"comment__ue__code",
"comment__uv__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 UE
from pedagogy.schemas import SimpleUeSchema, UeFilterSchema, UeSchema
from pedagogy.models import UV
from pedagogy.schemas import SimpleUvSchema, UvFilterSchema, UvSchema
from pedagogy.utbm_api import UtbmApiClient
@api_controller("/ue")
class UeController(ControllerBase):
@api_controller("/uv")
class UvController(ControllerBase):
@route.get(
"/{code}",
auth=[ApiKeyAuth(), SessionAuth()],
permissions=[
# this route will almost always be called in the context
# of a UE creation/edition
HasPerm(["pedagogy.add_ue", "pedagogy.change_ue"], op=operator.or_)
# of a UV creation/edition
HasPerm(["pedagogy.add_uv", "pedagogy.change_uv"], op=operator.or_)
],
url_name="fetch_ue_from_utbm",
response=UeSchema,
url_name="fetch_uv_from_utbm",
response=UvSchema,
)
def fetch_from_utbm_api(
self,
@@ -34,20 +34,20 @@ class UeController(ControllerBase):
lang: Query[str] = "fr",
year: Query[Annotated[int, Ge(2010)] | None] = None,
):
"""Fetch UE data from the UTBM API and returns it after some parsing."""
"""Fetch UV data from the UTBM API and returns it after some parsing."""
with UtbmApiClient() as client:
res = client.find_ue(lang, code, year)
res = client.find_uv(lang, code, year)
if res is None:
raise NotFound
return res
@route.get(
"",
response=PaginatedResponseSchema[SimpleUeSchema],
url_name="fetch_ues",
response=PaginatedResponseSchema[SimpleUvSchema],
url_name="fetch_uvs",
auth=[ApiKeyAuth(), SessionAuth()],
permissions=[HasPerm("pedagogy.view_ue")],
permissions=[HasPerm("pedagogy.view_uv")],
)
@paginate(PageNumberPaginationExtra, page_size=100)
def fetch_ue_list(self, search: Query[UeFilterSchema]):
return search.filter(UE.objects.order_by("code").values())
def fetch_uv_list(self, search: Query[UvFilterSchema]):
return search.filter(UV.objects.order_by("code").values())

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 UE, UEComment, UECommentReport
from pedagogy.models import UV, UVComment, UVCommentReport
class UEForm(forms.ModelForm):
"""Form handeling creation and edit of an UE."""
class UVForm(forms.ModelForm):
"""Form handeling creation and edit of an UV."""
class Meta:
model = UE
model = UV
fields = (
"code",
"author",
@@ -82,14 +82,14 @@ class StarList(forms.NumberInput):
return context
class UECommentForm(forms.ModelForm):
"""Form handeling creation and edit of an UEComment."""
class UVCommentForm(forms.ModelForm):
"""Form handeling creation and edit of an UVComment."""
class Meta:
model = UEComment
model = UVComment
fields = (
"author",
"ue",
"uv",
"grade_global",
"grade_utility",
"grade_interest",
@@ -100,7 +100,7 @@ class UECommentForm(forms.ModelForm):
widgets = {
"comment": MarkdownInput,
"author": forms.HiddenInput,
"ue": forms.HiddenInput,
"uv": forms.HiddenInput,
"grade_global": StarList(5),
"grade_utility": StarList(5),
"grade_interest": StarList(5),
@@ -108,35 +108,35 @@ class UECommentForm(forms.ModelForm):
"grade_work_load": StarList(5),
}
def __init__(self, author_id, ue_id, is_creation, *args, **kwargs):
def __init__(self, author_id, uv_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["ue"].queryset = UE.objects.filter(id=ue_id).all()
self.fields["ue"].initial = ue_id
self.fields["uv"].queryset = UV.objects.filter(id=uv_id).all()
self.fields["uv"].initial = uv_id
self.is_creation = is_creation
def clean(self):
self.cleaned_data = super().clean()
ue = self.cleaned_data.get("ue")
uv = self.cleaned_data.get("uv")
author = self.cleaned_data.get("author")
if self.is_creation and ue and author and ue.has_user_already_commented(author):
if self.is_creation and uv and author and uv.has_user_already_commented(author):
self.add_error(
None,
forms.ValidationError(
_("This user has already commented on this UE"), code="invalid"
_("This user has already commented on this UV"), code="invalid"
),
)
return self.cleaned_data
class UECommentReportForm(forms.ModelForm):
"""Form handeling creation and edit of an UEReport."""
class UVCommentReportForm(forms.ModelForm):
"""Form handeling creation and edit of an UVReport."""
class Meta:
model = UECommentReport
model = UVCommentReport
fields = ("comment", "reporter", "reason")
widgets = {
"comment": forms.HiddenInput,
@@ -148,22 +148,22 @@ class UECommentReportForm(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 = UEComment.objects.filter(id=comment_id).all()
self.fields["comment"].queryset = UVComment.objects.filter(id=comment_id).all()
self.fields["comment"].initial = comment_id
class UECommentModerationForm(forms.Form):
class UVCommentModerationForm(forms.Form):
"""Form handeling bulk comment deletion."""
accepted_reports = forms.ModelMultipleChoiceField(
UECommentReport.objects.all(),
UVCommentReport.objects.all(),
label=_("Accepted reports"),
widget=forms.CheckboxSelectMultiple,
required=False,
)
denied_reports = forms.ModelMultipleChoiceField(
UECommentReport.objects.all(),
UVCommentReport.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 UE
from pedagogy.schemas import UeSchema
from pedagogy.models import UV
from pedagogy.schemas import UvSchema
from pedagogy.utbm_api import UtbmApiClient
class Command(BaseCommand):
help = "Update the UE guide"
help = "Update the UV guide"
def handle(self, *args, **options):
seen_ues: set[int] = set()
seen_uvs: set[int] = set()
root_user = User.objects.get(pk=settings.SITH_ROOT_USER_ID)
with UtbmApiClient() as client:
self.stdout.write(
"Fetching UEs from the UTBM API.\n"
"Fetching UVs from the UTBM API.\n"
"This may take a few minutes to complete."
)
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())
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())
fields.remove("id")
fields.remove("code")
for field in fields:
setattr(db_ue, field, getattr(ue, field))
db_ue.save()
setattr(db_uv, field, getattr(uv, field))
db_uv.save()
# if it's a creation, django will set the id when saving,
# 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
# 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
# are considered as closed UEs
UE.objects.exclude(id__in=seen_ues).update(semester="CLOSED")
self.stdout.write(self.style.SUCCESS("UE guide updated successfully"))
UV.objects.exclude(id__in=seen_uvs).update(semester="CLOSED")
self.stdout.write(self.style.SUCCESS("UV guide updated successfully"))

View File

@@ -1,140 +0,0 @@
# 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 UE(models.Model):
"""Contains infos about an UE (course)."""
class UV(models.Model):
"""Contains infos about an UV (course)."""
code = models.CharField(
_("code"),
@@ -47,7 +47,7 @@ class UE(models.Model):
validators.RegexValidator(
regex="([A-Z0-9]+)",
message=_(
"The code of an UE must only contains "
"The code of an UV must only contains "
"uppercase characters without accent and numbers"
),
)
@@ -55,7 +55,7 @@ class UE(models.Model):
)
author = models.ForeignKey(
User,
related_name="ue_created",
related_name="uv_created",
verbose_name=_("author"),
null=False,
blank=False,
@@ -64,23 +64,29 @@ class UE(models.Model):
credit_type = models.CharField(
_("credit type"),
max_length=10,
choices=settings.SITH_PEDAGOGY_UE_TYPE,
default=settings.SITH_PEDAGOGY_UE_TYPE[0][0],
choices=settings.SITH_PEDAGOGY_UV_TYPE,
default=settings.SITH_PEDAGOGY_UV_TYPE[0][0],
)
manager = models.CharField(_("ue manager"), max_length=300)
manager = models.CharField(_("uv manager"), max_length=300)
semester = models.CharField(
_("semester"),
max_length=20,
choices=settings.SITH_PEDAGOGY_UE_SEMESTER,
default=settings.SITH_PEDAGOGY_UE_SEMESTER[0][0],
choices=settings.SITH_PEDAGOGY_UV_SEMESTER,
default=settings.SITH_PEDAGOGY_UV_SEMESTER[0][0],
)
language = models.CharField(
_("language"),
max_length=10,
choices=settings.SITH_PEDAGOGY_UE_LANGUAGE,
default=settings.SITH_PEDAGOGY_UE_LANGUAGE[0][0],
choices=settings.SITH_PEDAGOGY_UV_LANGUAGE,
default=settings.SITH_PEDAGOGY_UV_LANGUAGE[0][0],
)
credits = models.PositiveIntegerField(_("credits"))
credits = models.IntegerField(
_("credits"),
validators=[validators.MinValueValidator(0)],
blank=False,
null=False,
)
# Double star type not implemented yet
department = models.CharField(
_("departmenmt"),
@@ -89,9 +95,9 @@ class UE(models.Model):
default=settings.SITH_PROFILE_DEPARTMENTS[-1][0],
)
# All texts about the UE
# All texts about the UV
title = models.CharField(_("title"), max_length=300)
manager = models.CharField(_("ue manager"), max_length=300)
manager = models.CharField(_("uv manager"), max_length=300)
objectives = models.TextField(_("objectives"))
program = models.TextField(_("program"))
skills = models.TextField(_("skills"))
@@ -99,17 +105,47 @@ class UE(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.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)
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,
)
def __str__(self):
return self.code
def get_absolute_url(self):
return reverse("pedagogy:ue_detail", kwargs={"ue_id": self.id})
return reverse("pedagogy:uv_detail", kwargs={"uv_id": self.id})
def __grade_average_generic(self, field):
comments = self.comments.filter(**{field + "__gte": 0})
@@ -124,7 +160,7 @@ class UE(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 UE, else False.
True if the user has already posted a comment on this UV, else False.
"""
return self.comments.filter(author=user).exists()
@@ -149,66 +185,78 @@ class UE(models.Model):
return self.__grade_average_generic("grade_work_load")
class UECommentQuerySet(models.QuerySet):
class UVCommentQuerySet(models.QuerySet):
def viewable_by(self, user: User) -> Self:
if user.has_perms(["pedagogy.view_uecomment", "pedagogy.view_uecommentreport"]):
# the user can view ue comment reports,
if user.has_perms(["pedagogy.view_uvcomment", "pedagogy.view_uvcommentreport"]):
# the user can view uv comment reports,
# so he can view non-moderated comments
return self
if user.has_perm("pedagogy.view_uecomment"):
if user.has_perm("pedagogy.view_uvcomment"):
return self.filter(reports=None)
return self.filter(author=user)
def annotate_is_reported(self) -> Self:
return self.annotate(
is_reported=Exists(UECommentReport.objects.filter(comment=OuterRef("pk")))
is_reported=Exists(UVCommentReport.objects.filter(comment=OuterRef("pk")))
)
class UEComment(models.Model):
"""A comment about an UE."""
class UVComment(models.Model):
"""A comment about an UV."""
author = models.ForeignKey(
User,
related_name="ue_comments",
related_name="uv_comments",
verbose_name=_("author"),
null=False,
blank=False,
on_delete=models.CASCADE,
)
ue = models.ForeignKey(
UE, related_name="comments", verbose_name=_("ue"), on_delete=models.CASCADE
uv = models.ForeignKey(
UV, related_name="comments", verbose_name=_("uv"), on_delete=models.CASCADE
)
comment = models.TextField(_("comment"), blank=True, default="")
comment = models.TextField(_("comment"), blank=True)
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 = UECommentQuerySet.as_manager()
objects = UVCommentQuerySet.as_manager()
def __str__(self):
return f"{self.ue} - {self.author}"
return f"{self.uv} - {self.author}"
def save(self, *args, **kwargs):
if self.publish_date is None:
@@ -220,32 +268,30 @@ class UEComment(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 UE results features ?
# - go on and finally implement the UV results features ?
# - or fuck go back and remove this model ?
class UEResult(models.Model):
"""Results got to an UE.
class UVResult(models.Model):
"""Results got to an UV.
Views will be implemented after the first release
Will list every UE done by an user
Linked to user and ue
Contains a grade settings.SITH_PEDAGOGY_UE_RESULT_GRADE
Will list every UV done by an user
Linked to user
uv
Contains a grade settings.SITH_PEDAGOGY_UV_RESULT_GRADE
a semester (P/A)20xx.
"""
ue = models.ForeignKey(
UE, related_name="results", verbose_name=_("ue"), on_delete=models.CASCADE
uv = models.ForeignKey(
UV, related_name="results", verbose_name=_("uv"), on_delete=models.CASCADE
)
user = models.ForeignKey(
User,
related_name="ue_results",
verbose_name=_("user"),
on_delete=models.CASCADE,
User, related_name="uv_results", verbose_name=("user"), on_delete=models.CASCADE
)
grade = models.CharField(
_("grade"),
max_length=10,
choices=settings.SITH_PEDAGOGY_UE_RESULT_GRADE,
default=settings.SITH_PEDAGOGY_UE_RESULT_GRADE[0][0],
choices=settings.SITH_PEDAGOGY_UV_RESULT_GRADE,
default=settings.SITH_PEDAGOGY_UV_RESULT_GRADE[0][0],
)
semester = models.CharField(
_("semester"),
@@ -254,21 +300,21 @@ class UEResult(models.Model):
)
def __str__(self):
return f"{self.user.username} ; {self.ue.code} ; {self.grade}"
return f"{self.user.username} ; {self.uv.code} ; {self.grade}"
class UECommentReport(models.Model):
class UVCommentReport(models.Model):
"""Report an inapropriate comment."""
comment = models.ForeignKey(
UEComment,
UVComment,
related_name="reports",
verbose_name=_("report"),
on_delete=models.CASCADE,
)
reporter = models.ForeignKey(
User,
related_name="reported_ue_comment",
related_name="reported_uv_comment",
verbose_name=_("reporter"),
on_delete=models.CASCADE,
)
@@ -278,5 +324,5 @@ class UECommentReport(models.Model):
return f"{self.reporter.username} : {self.reason}"
@cached_property
def ue(self):
return self.comment.ue
def uv(self):
return self.comment.uv

View File

@@ -7,11 +7,11 @@ 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 UE
from pedagogy.models import UV
class UtbmShortUeSchema(Schema):
"""Short representation of an UE in the UTBM API.
class UtbmShortUvSchema(Schema):
"""Short representation of an UV in the UTBM API.
Notes:
This schema holds only the fields we actually need.
@@ -35,8 +35,8 @@ class WorkloadSchema(Schema):
nbh: int
class SemesterUeState(Schema):
"""The state of the UE during either autumn or spring semester"""
class SemesterUvState(Schema):
"""The state of the UV during either autumn or spring semester"""
model_config = ConfigDict(alias_generator=to_camel)
@@ -44,11 +44,11 @@ class SemesterUeState(Schema):
ouvert: bool
ShortUeList = TypeAdapter(list[UtbmShortUeSchema])
ShortUvList = TypeAdapter(list[UtbmShortUvSchema])
class UtbmFullUeSchema(Schema):
"""Long representation of an UE in the UTBM API."""
class UtbmFullUvSchema(Schema):
"""Long representation of an UV in the UTBM API."""
model_config = ConfigDict(alias_generator=to_camel)
@@ -71,11 +71,11 @@ class UtbmFullUeSchema(Schema):
)
class SimpleUeSchema(ModelSchema):
"""Our minimal representation of an UE."""
class SimpleUvSchema(ModelSchema):
"""Our minimal representation of an UV."""
class Meta:
model = UE
model = UV
fields = [
"id",
"title",
@@ -86,11 +86,11 @@ class SimpleUeSchema(ModelSchema):
]
class UeSchema(ModelSchema):
"""Our complete representation of an UE"""
class UvSchema(ModelSchema):
"""Our complete representation of an UV"""
class Meta:
model = UE
model = UV
fields = [
"id",
"title",
@@ -113,7 +113,7 @@ class UeSchema(ModelSchema):
]
class UeFilterSchema(FilterSchema):
class UvFilterSchema(FilterSchema):
search: Annotated[str | None, FilterLookup("code__icontains")] = None
semester: set[Literal["AUTUMN", "SPRING"]] | None = None
credit_type: Annotated[
@@ -132,12 +132,12 @@ class UeFilterSchema(FilterSchema):
return Q()
if len(value) < 3 or (len(value) < 5 and any(c.isdigit() for c in value)):
# Likely to be an UE code
# Likely to be an UV code
return Q(code__istartswith=value)
qs = list(
SearchQuerySet()
.models(UE)
.models(UV)
.autocomplete(auto=html.escape(value))
.values_list("pk", flat=True)
)
@@ -147,7 +147,7 @@ class UeFilterSchema(FilterSchema):
def filter_semester(self, value: set[str] | None) -> Q:
"""Special filter for the semester.
If either "SPRING" or "AUTUMN" is given, UE that are available
If either "SPRING" or "AUTUMN" is given, UV 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 UE
from pedagogy.models import UV
class IndexSignalProcessor(signals.BaseSignalProcessor):
"""Auto update index on CRUD operations."""
def setup(self):
# 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)
# 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)
def teardown(self):
# 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)
# 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)
class UEIndex(indexes.SearchIndex, indexes.Indexable):
"""Indexer class for UEs."""
class UVIndex(indexes.SearchIndex, indexes.Indexable):
"""Indexer class for UVs."""
text = BigCharFieldIndex(document=True, use_template=True)
auto = indexes.EdgeNgramField(use_template=True)
def get_model(self):
return UE
return UV

View File

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

View File

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

View File

@@ -23,10 +23,10 @@
{% endblock head %}
{% block content %}
{% if user.has_perm("pedagogy.add_ue") %}
{% if user.has_perm("pedagogy.add_uv") %}
<div class="action-bar">
<p>
<a href="{{ url('pedagogy:ue_create') }}">{% trans %}Create UE{% endtrans %}</a>
<a href="{{ url('pedagogy:uv_create') }}">{% trans %}Create UV{% 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="ue_search" x-cloak>
<div class="pedagogy" x-data="uv_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="ue-list">
<table id="uv-list">
<thead>
<tr>
<td>{% trans %}UE{% endtrans %}</td>
<td>{% trans %}UV{% 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_ue") -%}
{%- if user.has_perm("pedagogy.change_uv") -%}
<td>{% trans %}Edit{% endtrans %}</td>
{%- endif -%}
{%- if user.has_perm("pedagogy.delete_ue") -%}
{%- if user.has_perm("pedagogy.delete_uv") -%}
<td>{% trans %}Delete{% endtrans %}</td>
{% endif %}
</tr>
</thead>
<tbody :aria-busy="loading">
<template x-for="ue in ues.results" :key="ue.id">
<template x-for="uv in uvs.results" :key="uv.id">
<tr
@click="window.location.href = `/pedagogy/ue/${ue.id}`"
@click="window.location.href = `/pedagogy/uv/${uv.id}`"
class="clickable"
:class="{closed: ue.semester === 'CLOSED'}"
:class="{closed: uv.semester === 'CLOSED'}"
>
<td><a :href="`/pedagogy/ue/${ue.id}`" x-text="ue.code"></a></td>
<td><a :href="`/pedagogy/uv/${uv.id}`" x-text="uv.code"></a></td>
<td class="title"
x-text="ue.title + (ue.semester === 'CLOSED' ? ' ({% trans %}closed ue{% endtrans %})' : '')"
x-text="uv.title + (uv.semester === 'CLOSED' ? ' ({% trans %}closed uv{% endtrans %})' : '')"
></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="`/pedagogy/ue/${ue.id}/edit`">{% trans %}Edit{% endtrans %}</a></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>
{%- endif -%}
{%- if user.has_perm("pedagogy.delete_ue") -%}
<td><a :href="`/pedagogy/ue/${ue.id}/delete`">{% trans %}Delete{% endtrans %}</a></td>
{%- if user.has_perm("pedagogy.delete_uv") -%}
<td><a :href="`/pedagogy/uv/${uv.id}/delete`">{% trans %}Delete{% endtrans %}</a></td>
{%- endif -%}
</tr>
</template>

View File

@@ -1,7 +1,7 @@
{% extends "core/base.jinja" %}
{% block title %}
{% trans %}UE comment moderation{% endtrans %}
{% trans %}UV comment moderation{% endtrans %}
{% endblock title %}
{% block content %}
@@ -9,7 +9,7 @@
<table>
<thead>
<tr>
<td>{% trans %}UE{% endtrans %}</td>
<td>{% trans %}UV{% 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:ue_detail', ue_id=report.comment.ue_id) }}#{{ report.comment.ue_id }}">{{ report.comment.ue }}</a></td>
<td><a href="{{ url('pedagogy:uv_detail', uv_id=report.comment.uv.id) }}#{{ report.comment.uv.id }}">{{ report.comment.uv }}</a></td>
<td>{{ report.comment.comment|markdown }}</td>
<td>{{ report.reason|markdown }}</td>
<td>

View File

@@ -7,12 +7,12 @@
{% endblock %}
{% block title %}
{% trans %}UE Details{% endtrans %}
{% trans %}UV Details{% endtrans %}
{% endblock %}
{% block content %}
<div class="pedagogy">
<div id="ue_detail">
<div id="uv_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="ue-quick-info-container">
<div class="uv-quick-info-container">
<div class="hours-cm">
<b>{% trans %}CM: {% endtrans %}</b>{{ object.hours_CM }}
</div>
@@ -55,7 +55,7 @@
<br>
<div class="ue-details-container">
<div class="uv-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="ue-infos">
<div class="uv-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 UE. If you want to comment again, please modify or delete your previous comment.{% endtrans %}</p>
<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>
</div>
{% elif user.has_perm("pedagogy.add_uecomment") %}
{% elif user.has_perm("pedagogy.add_uvcomment") %}
<details class="accordion" id="leave_comment">
<summary>{% trans %}Leave comment{% endtrans %}</summary>
<div class="accordion-content">
<form action="{{ url('pedagogy:ue_detail', ue_id=object.id) }}" method="post" enctype="multipart/form-data">
<form action="{{ url('pedagogy:uv_detail', uv_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.ue.errors }}
{{ form.uv.errors }}
{{ form.author }}
{{ form.ue }}
{{ form.uv }}
<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:ue_detail', ue_id=ue.id) }}#{{ comment.id }}"><i class="fa fa-paragraph"></i></a>
<a href="{{ url('pedagogy:uv_detail', uv_id=uv.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 UE
from pedagogy.models import UV
class TestUESearch(TestCase):
"""Test UE guide rights for view and API."""
class TestUVSearch(TestCase):
"""Test UV guide rights for view and API."""
@classmethod
def setUpTestData(cls):
cls.root = User.objects.get(username="root")
cls.url = reverse("api:fetch_ues")
ue_recipe = Recipe(UE, author=cls.root)
ues = [
ue_recipe.prepare(
cls.url = reverse("api:fetch_uvs")
uv_recipe = Recipe(UV, author=cls.root)
uvs = [
uv_recipe.prepare(
code="AP4A",
credit_type="CS",
semester="AUTUMN",
@@ -32,7 +32,7 @@ class TestUESearch(TestCase):
"Concepts fondamentaux et mise en pratique avec le langage C++"
),
),
ue_recipe.prepare(
uv_recipe.prepare(
code="MT01",
credit_type="CS",
semester="AUTUMN",
@@ -40,10 +40,10 @@ class TestUESearch(TestCase):
manager="ben",
title="Intégration1. Algèbre linéaire - Fonctions de deux variables",
),
ue_recipe.prepare(
uv_recipe.prepare(
code="PHYS11", credit_type="CS", semester="AUTUMN", department="TC"
),
ue_recipe.prepare(
uv_recipe.prepare(
code="TNEV",
credit_type="TM",
semester="SPRING",
@@ -51,10 +51,10 @@ class TestUESearch(TestCase):
manager="moss",
title="tnetennba",
),
ue_recipe.prepare(
uv_recipe.prepare(
code="MT10", credit_type="TM", semester="AUTUMN", department="IMSI"
),
ue_recipe.prepare(
uv_recipe.prepare(
code="DA50",
credit_type="TM",
semester="AUTUMN_AND_SPRING",
@@ -62,7 +62,7 @@ class TestUESearch(TestCase):
manager="francky",
),
]
UE.objects.bulk_create(ues)
UV.objects.bulk_create(uvs)
call_command("update_index")
def test_permissions(self):
@@ -93,7 +93,7 @@ class TestUESearch(TestCase):
"""Test that the return data format is correct"""
self.client.force_login(self.root)
res = self.client.get(self.url + "?search=PA00")
ue = UE.objects.get(code="PA00")
uv = UV.objects.get(code="PA00")
assert res.status_code == 200
assert json.loads(res.content) == {
"count": 1,
@@ -101,12 +101,12 @@ class TestUESearch(TestCase):
"previous": None,
"results": [
{
"id": ue.id,
"title": ue.title,
"code": ue.code,
"credit_type": ue.credit_type,
"semester": ue.semester,
"department": ue.department,
"id": uv.id,
"title": uv.title,
"code": uv.code,
"credit_type": uv.credit_type,
"semester": uv.semester,
"department": uv.department,
}
],
}
@@ -114,7 +114,7 @@ class TestUESearch(TestCase):
def test_search_by_text(self):
self.client.force_login(self.root)
for query, expected in (
# UE code search case insensitive
# UV code search case insensitive
("m", {"MT01", "MT10"}),
("M", {"MT01", "MT10"}),
("mt", {"MT01", "MT10"}),
@@ -126,24 +126,24 @@ class TestUESearch(TestCase):
):
res = self.client.get(self.url + f"?search={query}")
assert res.status_code == 200
assert {ue["code"] for ue in json.loads(res.content)["results"]} == expected
assert {uv["code"] for uv 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 = [ue["code"] for ue in json.loads(res.content)["results"]]
codes = [uv["code"] for uv 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 = {ue["code"] for ue in json.loads(res.content)["results"]}
codes = {uv["code"] for uv 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 = {ue["code"] for ue in json.loads(res.content)["results"]}
codes = {uv["code"] for uv in json.loads(res.content)["results"]}
assert codes == {"DA50", "TNEV", "PA00"}
def test_search_multiple_filters(self):
@@ -152,7 +152,7 @@ class TestUESearch(TestCase):
self.url + "?semester=AUTUMN&credit_type=CS&department=TC"
)
assert res.status_code == 200
codes = {ue["code"] for ue in json.loads(res.content)["results"]}
codes = {uv["code"] for uv in json.loads(res.content)["results"]}
assert codes == {"MT01", "PHYS11"}
def test_search_fails(self):
@@ -163,15 +163,15 @@ class TestUESearch(TestCase):
def test_search_pa00_fail(self):
self.client.force_login(self.root)
# Search with UE code
# Search with UV code
response = self.client.get(reverse("pedagogy:guide"), {"search": "IFC"})
self.assertNotContains(response, text="PA00")
# Search with first letter of UE code
# Search with first letter of UV code
response = self.client.get(reverse("pedagogy:guide"), {"search": "I"})
self.assertNotContains(response, text="PA00")
# Search with UE manager
# Search with UV 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 UE, UEComment, UECommentReport
from pedagogy.models import UV, UVComment, UVCommentReport
def create_ue_template(user_id, code="IFC1", exclude_list=None):
"""Factory to help UE creation/update in post requests."""
def create_uv_template(user_id, code="IFC1", exclude_list=None):
"""Factory to help UV creation/update in post requests."""
if exclude_list is None:
exclude_list = []
ue = {
uv = {
"code": code,
"author": user_id,
"credit_type": "TM",
@@ -74,15 +74,15 @@ def create_ue_template(user_id, code="IFC1", exclude_list=None):
* Chaînes de caractères""",
}
for excluded in exclude_list:
ue.pop(excluded)
return ue
uv.pop(excluded)
return uv
# UE class tests
# UV class tests
class TestUECreation(TestCase):
"""Test ue creation."""
class TestUVCreation(TestCase):
"""Test uv creation."""
@classmethod
def setUpTestData(cls):
@@ -90,62 +90,62 @@ class TestUECreation(TestCase):
cls.tutu = User.objects.get(username="tutu")
cls.sli = User.objects.get(username="sli")
cls.guy = User.objects.get(username="guy")
cls.create_ue_url = reverse("pedagogy:ue_create")
cls.create_uv_url = reverse("pedagogy:uv_create")
def test_create_ue_admin_success(self):
def test_create_uv_admin_success(self):
self.client.force_login(self.bibou)
response = self.client.post(
self.create_ue_url, create_ue_template(self.bibou.id)
self.create_uv_url, create_uv_template(self.bibou.id)
)
assert response.status_code == 302
assert UE.objects.filter(code="IFC1").exists()
assert UV.objects.filter(code="IFC1").exists()
def test_create_ue_pedagogy_admin_success(self):
def test_create_uv_pedagogy_admin_success(self):
self.client.force_login(self.tutu)
response = self.client.post(
self.create_ue_url, create_ue_template(self.tutu.id)
self.create_uv_url, create_uv_template(self.tutu.id)
)
assert response.status_code == 302
assert UE.objects.filter(code="IFC1").exists()
assert UV.objects.filter(code="IFC1").exists()
def test_create_ue_unauthorized_fail(self):
def test_create_uv_unauthorized_fail(self):
# Test with anonymous user
response = self.client.post(self.create_ue_url, create_ue_template(0))
response = self.client.post(self.create_uv_url, create_uv_template(0))
assertRedirects(
response, reverse("core:login", query={"next": self.create_ue_url})
response, reverse("core:login", query={"next": self.create_uv_url})
)
# Test with subscribed user
self.client.force_login(self.sli)
response = self.client.post(self.create_ue_url, create_ue_template(self.sli.id))
response = self.client.post(self.create_uv_url, create_uv_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_ue_url, create_ue_template(self.guy.id))
response = self.client.post(self.create_uv_url, create_uv_template(self.guy.id))
assert response.status_code == 403
# Check that the UE has never been created
assert not UE.objects.filter(code="IFC1").exists()
# Check that the UV has never been created
assert not UV.objects.filter(code="IFC1").exists()
def test_create_ue_bad_request_fail(self):
def test_create_uv_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_ue_url, create_ue_template(self.bibou.id)
self.create_uv_url, create_uv_template(self.bibou.id)
)
assert response.status_code == 200
# Remove a required field
response = self.client.post(
self.create_ue_url,
create_ue_template(self.tutu.id, exclude_list=["title"]),
self.create_uv_url,
create_uv_template(self.tutu.id, exclude_list=["title"]),
)
assert response.status_code == 200
# Check that the UE has never been created
assert not UE.objects.filter(code="IFC1").exists()
# Check that the UV hase never been created
assert not UV.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 TestUEDelete(TestCase):
"""Test UE deletion rights."""
class TestUVDelete(TestCase):
"""Test UV deletion rights."""
@classmethod
def setUpTestData(cls):
@@ -180,37 +180,37 @@ class TestUEDelete(TestCase):
cls.tutu = User.objects.get(username="tutu")
cls.sli = User.objects.get(username="sli")
cls.guy = User.objects.get(username="guy")
cls.ue = UE.objects.get(code="PA00")
cls.delete_ue_url = reverse("pedagogy:ue_delete", kwargs={"ue_id": cls.ue.id})
cls.uv = UV.objects.get(code="PA00")
cls.delete_uv_url = reverse("pedagogy:uv_delete", kwargs={"uv_id": cls.uv.id})
def test_ue_delete_root_success(self):
def test_uv_delete_root_success(self):
self.client.force_login(self.bibou)
self.client.post(self.delete_ue_url)
assert not UE.objects.filter(pk=self.ue.pk).exists()
self.client.post(self.delete_uv_url)
assert not UV.objects.filter(pk=self.uv.pk).exists()
def test_ue_delete_pedagogy_admin_success(self):
def test_uv_delete_pedagogy_admin_success(self):
self.client.force_login(self.tutu)
self.client.post(self.delete_ue_url)
assert not UE.objects.filter(pk=self.ue.pk).exists()
self.client.post(self.delete_uv_url)
assert not UV.objects.filter(pk=self.uv.pk).exists()
def test_ue_delete_pedagogy_unauthorized_fail(self):
def test_uv_delete_pedagogy_unauthorized_fail(self):
# Anonymous user
response = self.client.post(self.delete_ue_url)
response = self.client.post(self.delete_uv_url)
assertRedirects(
response, reverse("core:login", query={"next": self.delete_ue_url})
response, reverse("core:login", query={"next": self.delete_uv_url})
)
assert UE.objects.filter(pk=self.ue.pk).exists()
assert UV.objects.filter(pk=self.uv.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_ue_url)
response = self.client.post(self.delete_uv_url)
assert response.status_code == 403
assert UE.objects.filter(pk=self.ue.pk).exists()
assert UV.objects.filter(pk=self.uv.pk).exists()
class TestUEUpdate(TestCase):
"""Test UE update rights."""
class TestUVUpdate(TestCase):
"""Test UV update rights."""
@classmethod
def setUpTestData(cls):
@@ -218,79 +218,79 @@ class TestUEUpdate(TestCase):
cls.tutu = User.objects.get(username="tutu")
cls.sli = User.objects.get(username="sli")
cls.guy = User.objects.get(username="guy")
cls.ue = UE.objects.get(code="PA00")
cls.update_ue_url = reverse("pedagogy:ue_update", kwargs={"ue_id": cls.ue.id})
cls.uv = UV.objects.get(code="PA00")
cls.update_uv_url = reverse("pedagogy:uv_update", kwargs={"uv_id": cls.uv.id})
def test_ue_update_root_success(self):
def test_uv_update_root_success(self):
self.client.force_login(self.bibou)
self.client.post(
self.update_ue_url, create_ue_template(self.bibou.id, code="PA00")
self.update_uv_url, create_uv_template(self.bibou.id, code="PA00")
)
self.ue.refresh_from_db()
assert self.ue.credit_type == "TM"
self.uv.refresh_from_db()
assert self.uv.credit_type == "TM"
def test_ue_update_pedagogy_admin_success(self):
def test_uv_update_pedagogy_admin_success(self):
self.client.force_login(self.tutu)
self.client.post(
self.update_ue_url, create_ue_template(self.bibou.id, code="PA00")
self.update_uv_url, create_uv_template(self.bibou.id, code="PA00")
)
self.ue.refresh_from_db()
assert self.ue.credit_type == "TM"
self.uv.refresh_from_db()
assert self.uv.credit_type == "TM"
def test_ue_update_original_author_does_not_change(self):
def test_uv_update_original_author_does_not_change(self):
self.client.force_login(self.tutu)
response = self.client.post(
self.update_ue_url,
create_ue_template(self.tutu.id, code="PA00"),
self.update_uv_url,
create_uv_template(self.tutu.id, code="PA00"),
)
assert response.status_code == 200
self.ue.refresh_from_db()
assert self.ue.author == self.bibou
self.uv.refresh_from_db()
assert self.uv.author == self.bibou
def test_ue_update_pedagogy_unauthorized_fail(self):
def test_uv_update_pedagogy_unauthorized_fail(self):
# Anonymous user
response = self.client.post(
self.update_ue_url, create_ue_template(self.bibou.id, code="PA00")
self.update_uv_url, create_uv_template(self.bibou.id, code="PA00")
)
assertRedirects(
response, reverse("core:login", query={"next": self.update_ue_url})
response, reverse("core:login", query={"next": self.update_uv_url})
)
# Not subscribed user
self.client.force_login(self.guy)
response = self.client.post(
self.update_ue_url, create_ue_template(self.bibou.id, code="PA00")
self.update_uv_url, create_uv_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_ue_url, create_ue_template(self.bibou.id, code="PA00")
self.update_uv_url, create_uv_template(self.bibou.id, code="PA00")
)
assert response.status_code == 403
# Check that the UE has not changed
self.ue.refresh_from_db()
assert self.ue.credit_type == "OM"
# Check that the UV has not changed
self.uv.refresh_from_db()
assert self.uv.credit_type == "OM"
# UEComment class tests
# UVComment class tests
def create_ue_comment_template(user_id, ue_code="PA00", exclude_list=None):
"""Factory to help UEComment creation/update in post requests."""
def create_uv_comment_template(user_id, uv_code="PA00", exclude_list=None):
"""Factory to help UVComment creation/update in post requests."""
if exclude_list is None:
exclude_list = []
comment = {
"author": user_id,
"ue": UE.objects.get(code=ue_code).id,
"uv": UV.objects.get(code=uv_code).id,
"grade_global": 4,
"grade_utility": 4,
"grade_interest": 4,
"grade_teaching": -1,
"grade_work_load": 2,
"comment": "Superbe UE qui fait vivre la vie associative de l'école",
"comment": "Superbe UV qui fait vivre la vie associative de l'école",
}
for excluded in exclude_list:
comment.pop(excluded)
@@ -298,7 +298,7 @@ def create_ue_comment_template(user_id, ue_code="PA00", exclude_list=None):
class TestUVCommentCreationAndDisplay(TestCase):
"""Test UEComment creation and its display.
"""Test UVComment 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.ue = UE.objects.get(code="PA00")
cls.ue_url = reverse("pedagogy:ue_detail", kwargs={"ue_id": cls.ue.id})
cls.uv = UV.objects.get(code="PA00")
cls.uv_url = reverse("pedagogy:uv_detail", kwargs={"uv_id": cls.uv.id})
def test_create_ue_comment_admin_success(self):
def test_create_uv_comment_admin_success(self):
self.client.force_login(self.bibou)
response = self.client.post(
self.ue_url, create_ue_comment_template(self.bibou.id)
self.uv_url, create_uv_comment_template(self.bibou.id)
)
assertRedirects(response, self.ue_url)
response = self.client.get(self.ue_url)
self.assertContains(response, text="Superbe UE")
assertRedirects(response, self.uv_url)
response = self.client.get(self.uv_url)
self.assertContains(response, text="Superbe UV")
def test_create_ue_comment_pedagogy_admin_success(self):
def test_create_uv_comment_pedagogy_admin_success(self):
self.client.force_login(self.tutu)
response = self.client.post(
self.ue_url, create_ue_comment_template(self.tutu.id)
self.uv_url, create_uv_comment_template(self.tutu.id)
)
self.assertRedirects(response, self.ue_url)
response = self.client.get(self.ue_url)
self.assertContains(response, text="Superbe UE")
self.assertRedirects(response, self.uv_url)
response = self.client.get(self.uv_url)
self.assertContains(response, text="Superbe UV")
def test_create_ue_comment_subscriber_success(self):
def test_create_uv_comment_subscriber_success(self):
self.client.force_login(self.sli)
response = self.client.post(
self.ue_url, create_ue_comment_template(self.sli.id)
self.uv_url, create_uv_comment_template(self.sli.id)
)
self.assertRedirects(response, self.ue_url)
response = self.client.get(self.ue_url)
self.assertContains(response, text="Superbe UE")
self.assertRedirects(response, self.uv_url)
response = self.client.get(self.uv_url)
self.assertContains(response, text="Superbe UV")
def test_create_ue_comment_unauthorized_fail(self):
nb_comments = self.ue.comments.count()
def test_create_uv_comment_unauthorized_fail(self):
nb_comments = self.uv.comments.count()
# Test with anonymous user
response = self.client.post(self.ue_url, create_ue_comment_template(0))
assertRedirects(response, reverse("core:login", query={"next": self.ue_url}))
response = self.client.post(self.uv_url, create_uv_comment_template(0))
assertRedirects(response, reverse("core:login", query={"next": self.uv_url}))
# Test with non subscribed user
self.client.force_login(self.guy)
response = self.client.post(
self.ue_url, create_ue_comment_template(self.guy.id)
self.uv_url, create_uv_comment_template(self.guy.id)
)
assert response.status_code == 403
# Check that no comment has been created
assert self.ue.comments.count() == nb_comments
assert self.uv.comments.count() == nb_comments
def test_create_ue_comment_bad_form_fail(self):
nb_comments = self.ue.comments.count()
def test_create_uv_comment_bad_form_fail(self):
nb_comments = self.uv.comments.count()
self.client.force_login(self.bibou)
response = self.client.post(
self.ue_url,
create_ue_comment_template(self.bibou.id, exclude_list=["grade_global"]),
self.uv_url,
create_uv_comment_template(self.bibou.id, exclude_list=["grade_global"]),
)
assert response.status_code == 200
assert self.ue.comments.count() == nb_comments
assert self.uv.comments.count() == nb_comments
def test_create_ue_comment_twice_fail(self):
def test_create_uv_comment_twice_fail(self):
# Checks that the has_user_already_commented method works proprely
assert not self.ue.has_user_already_commented(self.bibou)
assert not self.uv.has_user_already_commented(self.bibou)
# Create a first comment
self.client.force_login(self.bibou)
self.client.post(self.ue_url, create_ue_comment_template(self.bibou.id))
self.client.post(self.uv_url, create_uv_comment_template(self.bibou.id))
# Checks that the has_user_already_commented method works proprely
assert self.ue.has_user_already_commented(self.bibou)
assert self.uv.has_user_already_commented(self.bibou)
# Create the second comment
comment = create_ue_comment_template(self.bibou.id)
comment = create_uv_comment_template(self.bibou.id)
comment["comment"] = "Twice"
response = self.client.post(self.ue_url, comment)
response = self.client.post(self.uv_url, comment)
assert response.status_code == 200
assert UEComment.objects.filter(comment__contains="Superbe UE").exists()
assert not UEComment.objects.filter(comment__contains="Twice").exists()
assert UVComment.objects.filter(comment__contains="Superbe UV").exists()
assert not UVComment.objects.filter(comment__contains="Twice").exists()
self.assertContains(
response,
_(
"You already posted a comment on this UE. "
"You already posted a comment on this UV. "
"If you want to comment again, "
"please modify or delete your previous comment."
),
)
# Ensure that there is no crash when no ue or no author is given
# Ensure that there is no crash when no uv or no author is given
self.client.post(
self.ue_url, create_ue_comment_template(self.bibou.id, exclude_list=["ue"])
self.uv_url, create_uv_comment_template(self.bibou.id, exclude_list=["uv"])
)
assert response.status_code == 200
self.client.post(
self.ue_url,
create_ue_comment_template(self.bibou.id, exclude_list=["author"]),
self.uv_url,
create_uv_comment_template(self.bibou.id, exclude_list=["author"]),
)
assert response.status_code == 200
class TestUVCommentDelete(TestCase):
"""Test UEComment deletion rights."""
"""Test UVComment deletion rights."""
@classmethod
def setUpTestData(cls):
cls.comment = baker.make(UEComment)
cls.comment = baker.make(UVComment)
def test_ue_comment_delete_success(self):
def test_uv_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_ue")]
User, user_permissions=[Permission.objects.get(codename="view_uv")]
),
self.comment.author,
):
with self.subTest():
self.client.force_login(user)
self.client.post(url)
assert not UEComment.objects.filter(id=self.comment.id).exists()
assert not UVComment.objects.filter(id=self.comment.id).exists()
def test_ue_comment_delete_unauthorized_fail(self):
def test_uv_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 UEComment.objects.filter(id=self.comment.id).exists()
assert UVComment.objects.filter(id=self.comment.id).exists()
class TestUVCommentUpdate(TestCase):
"""Test UEComment update rights."""
"""Test UVComment update rights."""
@classmethod
def setUpTestData(cls):
@@ -457,17 +457,17 @@ class TestUVCommentUpdate(TestCase):
def setUp(self):
# Prepare a comment
comment_kwargs = create_ue_comment_template(self.krophil.id)
comment_kwargs = create_uv_comment_template(self.krophil.id)
comment_kwargs["author"] = self.krophil
comment_kwargs["ue"] = UE.objects.get(id=comment_kwargs["ue"])
self.comment = UEComment(**comment_kwargs)
comment_kwargs["uv"] = UV.objects.get(id=comment_kwargs["uv"])
self.comment = UVComment(**comment_kwargs)
self.comment.save()
# Prepare edit of this comment for post requests
self.comment_edit = create_ue_comment_template(self.krophil.id)
self.comment_edit = create_uv_comment_template(self.krophil.id)
self.comment_edit["comment"] = "Edited"
def test_ue_comment_update_root_success(self):
def test_uv_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_ue_comment_update_author_success(self):
def test_uv_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_ue_comment_update_unauthorized_fail(self):
def test_uv_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_ue_comment_update_original_author_does_not_change(self):
def test_uv_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_ue_comment_template(self.krophil.id)
comment_kwargs = create_uv_comment_template(self.krophil.id)
comment_kwargs["author"] = self.krophil
comment_kwargs["ue"] = UE.objects.get(id=comment_kwargs["ue"])
self.comment_1 = UEComment(**comment_kwargs)
comment_kwargs["uv"] = UV.objects.get(id=comment_kwargs["uv"])
self.comment_1 = UVComment(**comment_kwargs)
self.comment_1.save()
# Prepare another comment
comment_kwargs = create_ue_comment_template(self.krophil.id)
comment_kwargs = create_uv_comment_template(self.krophil.id)
comment_kwargs["author"] = self.krophil
comment_kwargs["ue"] = UE.objects.get(id=comment_kwargs["ue"])
self.comment_2 = UEComment(**comment_kwargs)
comment_kwargs["uv"] = UV.objects.get(id=comment_kwargs["uv"])
self.comment_2 = UVComment(**comment_kwargs)
self.comment_2.save()
# Prepare a comment report for comment 1
self.report_1 = UECommentReport(
self.report_1 = UVCommentReport(
comment=self.comment_1, reporter=self.krophil, reason="C'est moche"
)
self.report_1.save()
self.report_1_bis = UECommentReport(
self.report_1_bis = UVCommentReport(
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 = UECommentReport(
self.report_2 = UVCommentReport(
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 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()
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()
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 UECommentReport.objects.filter(id=self.report_1.id).exists()
assert not UEComment.objects.filter(id=self.comment_1.id).exists()
assert not UVCommentReport.objects.filter(id=self.report_1.id).exists()
assert not UVComment.objects.filter(id=self.comment_1.id).exists()
# Test that the bis report has been deleted
assert not UECommentReport.objects.filter(id=self.report_1_bis.id).exists()
assert not UVCommentReport.objects.filter(id=self.report_1_bis.id).exists()
# Test that the other comment and report still exists
assert UECommentReport.objects.filter(id=self.report_2.id).exists()
assert UEComment.objects.filter(id=self.comment_2.id).exists()
assert UVCommentReport.objects.filter(id=self.report_2.id).exists()
assert UVComment.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 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()
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()
# Test that the bis report has been deleted
assert not UECommentReport.objects.filter(id=self.report_1_bis.id).exists()
assert not UVCommentReport.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 UECommentReport.objects.filter(id=self.report_1.id).exists()
assert not UEComment.objects.filter(id=self.comment_1.id).exists()
assert not UVCommentReport.objects.filter(id=self.report_1.id).exists()
assert not UVComment.objects.filter(id=self.comment_1.id).exists()
# Test that the bis report has been deleted
assert not UECommentReport.objects.filter(id=self.report_1_bis.id).exists()
assert not UVCommentReport.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 UECommentReport.objects.filter(id=self.report_1.id).exists()
assert UEComment.objects.filter(id=self.comment_1.id).exists()
assert not UVCommentReport.objects.filter(id=self.report_1.id).exists()
assert UVComment.objects.filter(id=self.comment_1.id).exists()
# Test that the bis report is still there
assert UECommentReport.objects.filter(id=self.report_1_bis.id).exists()
assert UVCommentReport.objects.filter(id=self.report_1_bis.id).exists()
# Test that the other comment and report still exists
assert UECommentReport.objects.filter(id=self.report_2.id).exists()
assert UEComment.objects.filter(id=self.comment_2.id).exists()
assert UVCommentReport.objects.filter(id=self.report_2.id).exists()
assert UVComment.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 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()
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()
# Test that comments still exists
assert UEComment.objects.filter(id=self.comment_1.id).exists()
assert UEComment.objects.filter(id=self.comment_2.id).exists()
assert UVComment.objects.filter(id=self.comment_1.id).exists()
assert UVComment.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 UECommentReport.objects.filter(id=self.report_2.id).exists()
assert not UEComment.objects.filter(id=self.comment_2.id).exists()
assert not UVCommentReport.objects.filter(id=self.report_2.id).exists()
assert not UVComment.objects.filter(id=self.comment_2.id).exists()
# Test that report 1 has been deleted and it's comment still exists
assert not UECommentReport.objects.filter(id=self.report_1.id).exists()
assert UEComment.objects.filter(id=self.comment_1.id).exists()
assert not UVCommentReport.objects.filter(id=self.report_1.id).exists()
assert UVComment.objects.filter(id=self.comment_1.id).exists()
# Test that report 1 bis is still there
assert UECommentReport.objects.filter(id=self.report_1_bis.id).exists()
assert UVCommentReport.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 UECommentReport.objects.filter(
assert not UVCommentReport.objects.filter(
id__in=[self.report_1.id, self.report_1_bis.id]
).exists()
# Test that comment 1 has been deleted
assert not UEComment.objects.filter(id=self.comment_1.id).exists()
assert not UVComment.objects.filter(id=self.comment_1.id).exists()
# Test that report and comment 2 still exists
assert UECommentReport.objects.filter(id=self.report_2.id).exists()
assert UEComment.objects.filter(id=self.comment_2.id).exists()
assert UVCommentReport.objects.filter(id=self.report_2.id).exists()
assert UVComment.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_ue_comment_template(self.krophil.id)
comment_kwargs = create_uv_comment_template(self.krophil.id)
comment_kwargs["author"] = self.krophil
comment_kwargs["ue"] = UE.objects.get(id=comment_kwargs["ue"])
self.comment = UEComment(**comment_kwargs)
comment_kwargs["uv"] = UV.objects.get(id=comment_kwargs["uv"])
self.comment = UVComment(**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(UECommentReport.objects.all().exists(), success)
self.assertEqual(UVCommentReport.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 UECommentReport.objects.all().exists()
assert not UVCommentReport.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 (
UECommentDeleteView,
UECommentReportCreateView,
UECommentUpdateView,
UECreateView,
UEDeleteView,
UEDetailFormView,
UEGuideView,
UEModerationFormView,
UEUpdateView,
UVCommentDeleteView,
UVCommentReportCreateView,
UVCommentUpdateView,
UVCreateView,
UVDeleteView,
UVDetailFormView,
UVGuideView,
UVModerationFormView,
UVUpdateView,
)
urlpatterns = [
# Urls displaying the actual application for visitors
path("", UEGuideView.as_view(), name="guide"),
path("ue/<int:ue_id>/", UEDetailFormView.as_view(), name="ue_detail"),
path("", UVGuideView.as_view(), name="guide"),
path("uv/<int:uv_id>/", UVDetailFormView.as_view(), name="uv_detail"),
path(
"comment/<int:comment_id>/edit/",
UECommentUpdateView.as_view(),
UVCommentUpdateView.as_view(),
name="comment_update",
),
path(
"comment/<int:comment_id>/delete/",
UECommentDeleteView.as_view(),
UVCommentDeleteView.as_view(),
name="comment_delete",
),
path(
"comment/<int:comment_id>/report/",
UECommentReportCreateView.as_view(),
UVCommentReportCreateView.as_view(),
name="comment_report",
),
# Moderation
path("moderation/", UEModerationFormView.as_view(), name="moderation"),
path("moderation/", UVModerationFormView.as_view(), name="moderation"),
# Administration : Create Update Delete Edit
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"),
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"),
]

View File

@@ -1,4 +1,4 @@
"""Set of functions to interact with the UTBM UE api."""
"""Set of functions to interact with the UTBM UV 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 ShortUeList, UeSchema, UtbmFullUeSchema, UtbmShortUeSchema
from pedagogy.schemas import ShortUvList, UtbmFullUvSchema, UtbmShortUvSchema, UvSchema
class UtbmApiClient(requests.Session):
"""A wrapper around `requests.Session` to perform requests to the UTBM UE API."""
"""A wrapper around `requests.Session` to perform requests to the UTBM UV API."""
BASE_URL = settings.SITH_PEDAGOGY_UTBM_API
_cache = {"short_ues": {}}
_cache = {"short_uvs": {}}
@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_ues(
def fetch_short_uvs(
self, lang: str = "fr", year: int | None = None
) -> list[UtbmShortUeSchema]:
"""Get the list of UEs in their short format from the UTBM API"""
) -> list[UtbmShortUvSchema]:
"""Get the list of UVs in their short format from the UTBM API"""
if year is None:
year = self.current_year
if lang not in self._cache["short_ues"]:
self._cache["short_ues"][lang] = {}
if year not in self._cache["short_ues"][lang]:
if lang not in self._cache["short_uvs"]:
self._cache["short_uvs"][lang] = {}
if year not in self._cache["short_uvs"][lang]:
url = f"{self.BASE_URL}/uvs/{lang}/{year}"
response = self.get(url)
ues = ShortUeList.validate_json(response.content)
self._cache["short_ues"][lang][year] = ues
return self._cache["short_ues"][lang][year]
uvs = ShortUvList.validate_json(response.content)
self._cache["short_uvs"][lang][year] = uvs
return self._cache["short_uvs"][lang][year]
def fetch_ues(
def fetch_uvs(
self, lang: str = "fr", year: int | None = None
) -> Iterator[UeSchema]:
"""Fetch all UEs from the UTBM API, parsed in a format that we can use.
) -> Iterator[UvSchema]:
"""Fetch all UVs from the UTBM API, parsed in a format that we can use.
Warning:
We need infos from the full ue schema, and the UTBM UE API
We need infos from the full uv schema, and the UTBM UV API
has no route to get all of them at once.
We must do one request per UE (for a total of around 730 UEs),
We must do one request per UV (for a total of around 730 UVs),
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_ues = self.fetch_short_ues(lang, year)
# When UEs are common to multiple branches (like most HUMA)
shorts_uvs = self.fetch_short_uvs(lang, year)
# When UVs 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 UE to multiple formations,
# so we just create a single UE, which formation is the one
# of the first UE found in the list.
# 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.
# For example, if we have CC01 (TC), CC01 (IMSI) and CC01 (EDIM),
# we will only keep CC01 (TC).
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)
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)
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
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
if not year:
year = self.current_year
# 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:
# 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:
return None
# 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)
# 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)
def make_clean_ue(short_ue: UtbmShortUeSchema, full_ue: UtbmFullUeSchema) -> UeSchema:
def make_clean_uv(short_uv: UtbmShortUvSchema, full_uv: UtbmFullUvSchema) -> UvSchema:
"""Cleans the data up so that it corresponds to our data representation.
Some of the needed information are in the short ue schema, some
other in the full ue schema.
Some of the needed information are in the short uv schema, some
other in the full uv schema.
Thus we combine those information to obtain a data schema suitable
for our needs.
"""
if full_ue.departement == "Pôle Humanités":
if full_uv.departement == "Pôle Humanités":
department = "HUMA"
else:
department = {
@@ -112,9 +112,9 @@ def make_clean_ue(short_ue: UtbmShortUeSchema, full_ue: UtbmFullUeSchema) -> UeS
"ED": "EDIM",
"AI": "GI",
"AM": "MC",
}.get(short_ue.code_formation, "NA")
}.get(short_uv.code_formation, "NA")
match short_ue.ouvert_printemps, short_ue.ouvert_automne:
match short_uv.ouvert_printemps, short_uv.ouvert_automne:
case True, True:
semester = "AUTUMN_AND_SPRING"
case True, False:
@@ -124,22 +124,22 @@ def make_clean_ue(short_ue: UtbmShortUeSchema, full_ue: UtbmFullUeSchema) -> UeS
case _:
semester = "CLOSED"
return UeSchema(
title=full_ue.libelle or "",
code=full_ue.code,
credit_type=short_ue.code_categorie or "FREE",
return UvSchema(
title=full_uv.libelle or "",
code=full_uv.code,
credit_type=short_uv.code_categorie or "FREE",
semester=semester,
language=short_ue.code_langue.upper(),
credits=full_ue.credits_ects,
language=short_uv.code_langue.upper(),
credits=full_uv.credits_ects,
department=department,
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 "",
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 "",
)

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

View File

@@ -439,7 +439,7 @@ SITH_SUBSCRIPTION_LOCATIONS = [
SITH_COUNTER_BARS = [(1, "MDE"), (2, "Foyer"), (35, "La Gommette")]
SITH_PEDAGOGY_UE_TYPE = [
SITH_PEDAGOGY_UV_TYPE = [
("FREE", _("Free")),
("CS", _("CS")),
("TM", _("TM")),
@@ -451,21 +451,21 @@ SITH_PEDAGOGY_UE_TYPE = [
("EXT", _("EXT")),
]
SITH_PEDAGOGY_UE_SEMESTER = [
SITH_PEDAGOGY_UV_SEMESTER = [
("CLOSED", _("Closed")),
("AUTUMN", _("Autumn")),
("SPRING", _("Spring")),
("AUTUMN_AND_SPRING", _("Autumn and spring")),
]
SITH_PEDAGOGY_UE_LANGUAGE = [
SITH_PEDAGOGY_UV_LANGUAGE = [
("FR", _("French")),
("EN", _("English")),
("DE", _("German")),
("SP", _("Spanish")),
]
SITH_PEDAGOGY_UE_RESULT_GRADE = [
SITH_PEDAGOGY_UV_RESULT_GRADE = [
("A", _("A")),
("B", _("B")),
("C", _("C")),