From aacfba679d49c957bcab13fc62afe0dd4df4e62f Mon Sep 17 00:00:00 2001 From: Sli Date: Tue, 8 Sep 2026 09:24:42 +0200 Subject: [PATCH 1/5] Use HTMX in UE details --- core/static/bundled/base-bundle-index.ts | 1 + pedagogy/models.py | 6 +- pedagogy/static/pedagogy/css/pedagogy.scss | 19 +- .../pedagogy/fragments/comment_report.jinja | 31 ++ .../pedagogy/fragments/ue_comment_form.jinja | 71 +++++ .../fragments/ue_details/comments.jinja | 91 ++++++ .../pedagogy/fragments/ue_details/form.jinja | 10 + .../pedagogy/fragments/ue_details/grade.jinja | 27 ++ pedagogy/templates/pedagogy/macros.jinja | 2 +- pedagogy/templates/pedagogy/ue_detail.jinja | 281 +++++------------- pedagogy/urls.py | 10 +- pedagogy/views.py | 109 ++++--- 12 files changed, 406 insertions(+), 252 deletions(-) create mode 100644 pedagogy/templates/pedagogy/fragments/comment_report.jinja create mode 100644 pedagogy/templates/pedagogy/fragments/ue_comment_form.jinja create mode 100644 pedagogy/templates/pedagogy/fragments/ue_details/comments.jinja create mode 100644 pedagogy/templates/pedagogy/fragments/ue_details/form.jinja create mode 100644 pedagogy/templates/pedagogy/fragments/ue_details/grade.jinja diff --git a/core/static/bundled/base-bundle-index.ts b/core/static/bundled/base-bundle-index.ts index 53e3cc94..98a6d4f5 100644 --- a/core/static/bundled/base-bundle-index.ts +++ b/core/static/bundled/base-bundle-index.ts @@ -70,6 +70,7 @@ htmx.registerExtension("aria-busy", { }); htmx.config.transitions = true; + Object.assign(window, { htmx }); /** diff --git a/pedagogy/models.py b/pedagogy/models.py index 7333037a..84f6bab4 100644 --- a/pedagogy/models.py +++ b/pedagogy/models.py @@ -125,7 +125,11 @@ class UE(models.Model): Returns: True if the user has already posted a comment on this UE, else False. """ - return self.comments.filter(author=user).exists() + self._has_user_commented = getattr(self, "_has_user_commented", {}) + self._has_user_commented[user] = self._has_user_commented.get( + user, self.comments.filter(author=user).exists() + ) + return self._has_user_commented[user] @cached_property def grade_global_average(self): diff --git a/pedagogy/static/pedagogy/css/pedagogy.scss b/pedagogy/static/pedagogy/css/pedagogy.scss index e94fa813..ca7cc368 100644 --- a/pedagogy/static/pedagogy/css/pedagogy.scss +++ b/pedagogy/static/pedagogy/css/pedagogy.scss @@ -69,6 +69,7 @@ $pedagogy-white-text: #f0f0f0; input[type="checkbox"] { display: none; } + margin-top: 10px; margin-bottom: 0; color: white; @@ -128,14 +129,14 @@ $pedagogy-white-text: #f0f0f0; grid-area: hours-the; } - #leave_comment_not_allowed { + .leave-comment-not-allowed { p { text-align: center; color: red; } } - #leave_comment { + .leave-comment { .leave-comment-grid-container { display: grid; grid-template-columns: 270px auto; @@ -168,10 +169,6 @@ $pedagogy-white-text: #f0f0f0; .input-stars { margin-top: 20px; } - - input[type="submit"] { - float: right; - } } .ue-details-container { @@ -333,8 +330,9 @@ $pedagogy-white-text: #f0f0f0; padding-left: 10px; } - .actions { + .action { float: right; + margin-top: 0; } } @@ -402,6 +400,7 @@ $pedagogy-white-text: #f0f0f0; a { color: $pedagogy-white-text; + cursor: pointer; } a:hover { @@ -443,4 +442,10 @@ details.accordion>.accordion-content { background-color: $white-color; border-color: $pedagogy-orange; border-right: none; +} + +.buttons { + display: flex; + justify-content: flex-end; + align-items: center; } \ No newline at end of file diff --git a/pedagogy/templates/pedagogy/fragments/comment_report.jinja b/pedagogy/templates/pedagogy/fragments/comment_report.jinja new file mode 100644 index 00000000..39f9e6f6 --- /dev/null +++ b/pedagogy/templates/pedagogy/fragments/comment_report.jinja @@ -0,0 +1,31 @@ +
+ {% csrf_token %} + {{ form.non_field_errors() }} + {{ form.reason.errors }} + {{ form.reason }} + + {# Hidden fields #} + {{ form.reporter }} + {{ form.comment }} + +
+ + + + +
+
\ No newline at end of file diff --git a/pedagogy/templates/pedagogy/fragments/ue_comment_form.jinja b/pedagogy/templates/pedagogy/fragments/ue_comment_form.jinja new file mode 100644 index 00000000..7ec115be --- /dev/null +++ b/pedagogy/templates/pedagogy/fragments/ue_comment_form.jinja @@ -0,0 +1,71 @@ +
+ {% if form.is_creation %} +
+ {% trans %}Leave comment{% endtrans %} +
+ {% endif %} +
+ {% csrf_token %} +
+
+ {{ form.non_field_errors() }} + {{ form.author.errors }} + {{ form.ue.errors }} + + {{ form.author }} + {{ form.ue }} + +
+ + {{ form.grade_global.errors }} + {{ form.grade_global }} +
+ +
+ + {{ form.grade_utility.errors }} + {{ form.grade_utility }} +
+ +
+ + {{ form.grade_interest.errors }} + {{ form.grade_interest }} +
+ +
+ + {{ form.grade_teaching.errors }} + {{ form.grade_teaching }} +
+ +
+ + {{ form.grade_work_load.errors }} + {{ form.grade_work_load }} +
+
+
+ + {{ form.comment.errors }} + {{ form.comment }} +
+
+
+ +
+
+ + {% if form.is_creation %} +
+
+ {% endif %} + +
+ +
diff --git a/pedagogy/templates/pedagogy/fragments/ue_details/comments.jinja b/pedagogy/templates/pedagogy/fragments/ue_details/comments.jinja new file mode 100644 index 00000000..04aee21a --- /dev/null +++ b/pedagogy/templates/pedagogy/fragments/ue_details/comments.jinja @@ -0,0 +1,91 @@ +{% from "pedagogy/macros.jinja" import display_star %} +{% from "core/macros.jinja" import user_profile_link %} + +{% if comments %} +

{% trans %}Comments{% endtrans %}

+
+{% endif %} + +
+ {% for comment in comments %} + +
+ +
+
+

{% trans %}Global grade{% endtrans %}

+

{% trans %}Utility{% endtrans %}

+

{% trans %}Interest{% endtrans %}

+

{% trans %}Teaching{% endtrans %}

+

{% trans %}Work load{% endtrans %}

+
+
+

{{ display_star(comment.grade_global) }}

+

{{ display_star(comment.grade_utility) }}

+

{{ display_star(comment.grade_interest) }}

+

{{ display_star(comment.grade_teaching) }}

+

{{ display_star(comment.grade_work_load) }}

+
+
+
+ +
+
+ +
+ {{ comment.comment|markdown }} +
+ +
+ {% if comment.is_reported %} +

+ {% trans %}This comment has been reported{% endtrans %} +

+ {% endif %} + + {% if comment.author_id == user.id or user.has_perm("pedagogy.change_comment") %} + + {% endif %} + {% if comment.author_id == user.id or user.has_perm("pedagogy.delete_comment") %} +
+ {% csrf_token %} + +
+ {% endif %} +
+ +
+ + +

{{ comment.publish_date.strftime('%d/%m/%Y') }}

+ +

{{ user_profile_link(comment.author) }}

+
+ +
+ {% endfor %} +
diff --git a/pedagogy/templates/pedagogy/fragments/ue_details/form.jinja b/pedagogy/templates/pedagogy/fragments/ue_details/form.jinja new file mode 100644 index 00000000..7414f4b5 --- /dev/null +++ b/pedagogy/templates/pedagogy/fragments/ue_details/form.jinja @@ -0,0 +1,10 @@ +{% if object.has_user_already_commented(user) %} +
+

{% trans %}You already posted a comment on this UE. If you want to comment again, please modify or delete your previous comment.{% endtrans %}

+
+
+{% endif %} + +{% if not object.has_user_already_commented(user) and user.has_perm("pedagogy.add_uecomment") %} + {{ add_comment_form }} +{% endif %} diff --git a/pedagogy/templates/pedagogy/fragments/ue_details/grade.jinja b/pedagogy/templates/pedagogy/fragments/ue_details/grade.jinja new file mode 100644 index 00000000..7f507863 --- /dev/null +++ b/pedagogy/templates/pedagogy/fragments/ue_details/grade.jinja @@ -0,0 +1,27 @@ +
+
+

{% trans %}Global grade{% endtrans %}

+

{% trans %}Utility{% endtrans %}

+

{% trans %}Interest{% endtrans %}

+

{% trans %}Teaching{% endtrans %}

+

{% trans %}Work load{% endtrans %}

+
+
+

{{ display_star(object.grade_global_average) }}

+

{{ display_star(object.grade_utility_average) }}

+

{{ display_star(object.grade_interest_average) }}

+

{{ display_star(object.grade_teaching_average) }}

+

{{ display_star(object.grade_work_load_average) }}

+
+
+

{% trans %}Objectives{% endtrans %}

+

{{ object.objectives|markdown }}

+

{% trans %}Program{% endtrans %}

+

{{ object.program|markdown }}

+

{% trans %}Earned skills{% endtrans %}

+

{{ object.skills|markdown }}

+

{% trans %}Key concepts{% endtrans %}

+

{{ object.key_concepts|markdown }}

+

{% trans %}UE manager: {% endtrans %}{{ object.manager }}

+
+
diff --git a/pedagogy/templates/pedagogy/macros.jinja b/pedagogy/templates/pedagogy/macros.jinja index ffad4d1d..9f332655 100644 --- a/pedagogy/templates/pedagogy/macros.jinja +++ b/pedagogy/templates/pedagogy/macros.jinja @@ -13,4 +13,4 @@ {% trans %} not rated {% endtrans %} {% endif %} -{%- endmacro %} \ No newline at end of file +{%- endmacro %} diff --git a/pedagogy/templates/pedagogy/ue_detail.jinja b/pedagogy/templates/pedagogy/ue_detail.jinja index 93423afd..deb8fdf2 100644 --- a/pedagogy/templates/pedagogy/ue_detail.jinja +++ b/pedagogy/templates/pedagogy/ue_detail.jinja @@ -1,220 +1,91 @@ -{% extends "core/base.jinja" %} -{% from "core/macros.jinja" import user_profile_link %} {% from "pedagogy/macros.jinja" import display_star %} -{% block additional_css %} - -{% endblock %} +{% if is_fragment %} -{% block title %} - {% trans %}UE Details{% endtrans %} -{% endblock %} + + {% include "pedagogy/fragments/ue_details/grade.jinja" %} + -{% block content %} -
-
- + + {% include "pedagogy/fragments/ue_details/form.jinja" %} + -

{{ object.code }} - {{ object.title }}

-
-
-
- {% trans %}CM: {% endtrans %}{{ object.hours_CM }} -
-
- {% trans %}TD: {% endtrans %}{{ object.hours_TD }} -
-
- {% trans %}TP: {% endtrans %}{{ object.hours_TP }} -
-
- {% trans %}TE: {% endtrans %}{{ object.hours_TE }} -
-
- {% trans %}THE: {% endtrans %}{{ object.hours_THE }} -
+ + {% include "pedagogy/fragments/ue_details/comments.jinja" %} + -
- {{ object.department }} -
-
- {{ object.credit_type }} -
-
- {{ object.get_semester_display() }} -
-
+{% else %} + {% extends "core/base.jinja" %} -
+ {% block additional_css %} + + {% endblock %} -
-
-

{% trans %}Global grade{% endtrans %}

-

{% trans %}Utility{% endtrans %}

-

{% trans %}Interest{% endtrans %}

-

{% trans %}Teaching{% endtrans %}

-

{% trans %}Work load{% endtrans %}

-
-
-

{{ display_star(object.grade_global_average) }}

-

{{ display_star(object.grade_utility_average) }}

-

{{ display_star(object.grade_interest_average) }}

-

{{ display_star(object.grade_teaching_average) }}

-

{{ display_star(object.grade_work_load_average) }}

-
-
-

{% trans %}Objectives{% endtrans %}

-

{{ object.objectives|markdown }}

-

{% trans %}Program{% endtrans %}

-

{{ object.program|markdown }}

-

{% trans %}Earned skills{% endtrans %}

-

{{ object.skills|markdown }}

-

{% trans %}Key concepts{% endtrans %}

-

{{ object.key_concepts|markdown }}

-

{% trans %}UE manager: {% endtrans %}{{ object.manager }}

-
-
+ {% block title %} + {% trans %}UE Details{% endtrans %} + {% endblock %} -
- {% if object.has_user_already_commented(user) %} -
-

{% trans %}You already posted a comment on this UE. If you want to comment again, please modify or delete your previous comment.{% endtrans %}

-
- {% elif user.has_perm("pedagogy.add_uecomment") %} -
- {% trans %}Leave comment{% endtrans %} -
-
- {% csrf_token %} -
-
- {{ form.non_field_errors() }} - {{ form.author.errors }} - {{ form.ue.errors }} + {% block content %} - {{ form.author }} - {{ form.ue }} +
+
+ -
- - {{ form.grade_global.errors }} - {{ form.grade_global }} -
- -
- - {{ form.grade_utility.errors }} - {{ form.grade_utility }} -
- -
- - {{ form.grade_interest.errors }} - {{ form.grade_interest }} -
- -
- - {{ form.grade_teaching.errors }} - {{ form.grade_teaching }} -
- -
- - {{ form.grade_work_load.errors }} - {{ form.grade_work_load }} -
-
-
- - {{ form.comment.errors }} - {{ form.comment }} -
-
-

- +

{{ object.code }} - {{ object.title }}

+
+
+
+ {% trans %}CM: {% endtrans %}{{ object.hours_CM }}
-
- {% endif %} -
- - {% if comments %} -

{% trans %}Comments{% endtrans %}

- {% for comment in comments %} -
- -
-
-

{% trans %}Global grade{% endtrans %}

-

{% trans %}Utility{% endtrans %}

-

{% trans %}Interest{% endtrans %}

-

{% trans %}Teaching{% endtrans %}

-

{% trans %}Work load{% endtrans %}

-
-
-

{{ display_star(comment.grade_global) }}

-

{{ display_star(comment.grade_utility) }}

-

{{ display_star(comment.grade_interest) }}

-

{{ display_star(comment.grade_teaching) }}

-

{{ display_star(comment.grade_work_load) }}

-
-
-
- -
-
- -
- {{ comment.comment|markdown }} -
- -
- {% if comment.is_reported %} -

- {% trans %}This comment has been reported{% endtrans %} -

- {% endif %} - - {% if comment.author_id == user.id or user.has_perm("pedagogy.change_comment") %} -

- - {% trans %}Edit{% endtrans %} - - {% endif %} - {% if comment.author_id == user.id or user.has_perm("pedagogy.delete_comment") %} - - {% trans %}Delete{% endtrans %} - -

- {% endif %} -
- -
- - -

{{ comment.publish_date.strftime('%d/%m/%Y') }}

- -

{{ user_profile_link(comment.author) }}

-
- +
+ {% trans %}TD: {% endtrans %}{{ object.hours_TD }} +
+
+ {% trans %}TP: {% endtrans %}{{ object.hours_TP }} +
+
+ {% trans %}TE: {% endtrans %}{{ object.hours_TE }} +
+
+ {% trans %}THE: {% endtrans %}{{ object.hours_THE }}
- {% endfor %} - {% endif %} +
+ {{ object.department }} +
+
+ {{ object.credit_type }} +
+
+ {{ object.get_semester_display() }} +
+
+ +
+ +
+ {% include "pedagogy/fragments/ue_details/grade.jinja" %} +
+ +
+ +
+ {% include "pedagogy/fragments/ue_details/form.jinja" %} +
+ +
+ {% include "pedagogy/fragments/ue_details/comments.jinja" %} +
+ +
- -{% endblock %} + {% endblock %} +{% endif %} diff --git a/pedagogy/urls.py b/pedagogy/urls.py index 9edcf861..4631df90 100644 --- a/pedagogy/urls.py +++ b/pedagogy/urls.py @@ -24,12 +24,13 @@ from django.urls import path from pedagogy.views import ( + UECommentCreateView, UECommentDeleteView, UECommentReportCreateView, UECommentUpdateView, UECreateView, UEDeleteView, - UEDetailFormView, + UEDetailView, UEGuideView, UEModerationFormView, UEUpdateView, @@ -38,7 +39,12 @@ from pedagogy.views import ( urlpatterns = [ # Urls displaying the actual application for visitors path("", UEGuideView.as_view(), name="guide"), - path("ue//", UEDetailFormView.as_view(), name="ue_detail"), + path("ue//", UEDetailView.as_view(), name="ue_detail"), + path( + "ue//comment", + UECommentCreateView.as_view(), + name="comment_create", + ), path( "comment//edit/", UECommentUpdateView.as_view(), diff --git a/pedagogy/views.py b/pedagogy/views.py index 4f21248e..ba2157f9 100644 --- a/pedagogy/views.py +++ b/pedagogy/views.py @@ -16,7 +16,7 @@ # details. # # You should have received a copy of the GNU General Public License along with -# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple +# this program; if not, write to the Free Software Foundation, Inc., 59 Temple # Place - Suite 330, Boston, MA 02111-1307, USA. # # @@ -26,9 +26,11 @@ from django.contrib.auth.mixins import PermissionRequiredMixin from django.db.models import Exists, OuterRef from django.shortcuts import get_object_or_404 from django.urls import reverse, reverse_lazy +from django.utils.functional import cached_property from django.views.generic import ( CreateView, DeleteView, + DetailView, FormView, TemplateView, UpdateView, @@ -36,7 +38,7 @@ from django.views.generic import ( from core.auth.mixins import PermissionOrAuthorRequiredMixin from core.models import Notification, User -from core.views import DetailFormView +from core.views.mixins import AllowFragment, FragmentMixin, UseFragmentsMixin from pedagogy.forms import ( UECommentForm, UECommentModerationForm, @@ -46,37 +48,56 @@ from pedagogy.forms import ( from pedagogy.models import UE, UEComment, UECommentReport -class UEDetailFormView(PermissionRequiredMixin, DetailFormView): - """Display every comment of an UE and detailed infos about it. - - Allow to comment the UE. - """ - - model = UE - pk_url_kwarg = "ue_id" - template_name = "pedagogy/ue_detail.jinja" +class UECommentCreateView(PermissionRequiredMixin, FragmentMixin, CreateView): + model = UEComment + template_name = "pedagogy/fragments/ue_comment_form.jinja" form_class = UECommentForm - permission_required = "pedagogy.view_ue" + permission_required = "pedagogy.add_uecomment" + object = None # Avoid initialisation bug with FragmentMixin + + @cached_property + def ue(self): + return get_object_or_404(UE, id=self.kwargs.get("ue_id")) def has_permission(self): - if self.request.method == "POST" and not self.request.user.has_perm( - "pedagogy.add_uecomment" - ): - # if it's a POST request, the user is trying to add a new UEComment - # thus he also needs the "add_uecomment" permission + if self.ue.has_user_already_commented(self.request.user): 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["ue_id"] = self.ue.id kwargs["is_creation"] = True return kwargs - def form_valid(self, form): - form.save() - return super().form_valid(form) + def get_context_data(self, **kwargs): + return super().get_context_data(**kwargs) | { + "action": reverse("pedagogy:comment_create", kwargs={"ue_id": self.ue.id}), + "object": self.ue, + } + + def get_success_url(self): + return reverse("pedagogy:ue_detail", kwargs={"ue_id": self.ue.id}) + + +class UEDetailView( + PermissionRequiredMixin, UseFragmentsMixin, AllowFragment, DetailView +): + """Display every comment of an UE and detailed infos about it.""" + + model = UE + pk_url_kwarg = "ue_id" + template_name = "pedagogy/ue_detail.jinja" + permission_required = "pedagogy.view_ue" + fragments = { + "add_comment_form": UECommentCreateView, + } + + def get_fragment_data(self): + return { + "add_comment_form": {"ue_id": self.object.id}, + } def get_context_data(self, **kwargs): return super().get_context_data(**kwargs) | { @@ -85,22 +106,17 @@ class UEDetailFormView(PermissionRequiredMixin, DetailFormView): .annotate_is_reported() .select_related("author") .order_by("-publish_date") - ) + ), } - def get_success_url(self): - # once the new ue comment has been saved - # redirect to the same page we are currently - return self.request.path - -class UECommentUpdateView(PermissionOrAuthorRequiredMixin, UpdateView): +class UECommentUpdateView(PermissionOrAuthorRequiredMixin, AllowFragment, UpdateView): """Allow edit of a given comment.""" model = UEComment form_class = UECommentForm pk_url_kwarg = "comment_id" - template_name = "core/edit.jinja" + template_name = "pedagogy/fragments/ue_comment_form.jinja" permission_required = "pedagogy.change_uecomment" author_field = "author" @@ -111,11 +127,18 @@ class UECommentUpdateView(PermissionOrAuthorRequiredMixin, UpdateView): kwargs["is_creation"] = False return kwargs + def get_context_data(self, **kwargs): + return super().get_context_data(**kwargs) | { + "action": reverse( + "pedagogy:comment_update", kwargs={"comment_id": self.object.id} + ) + } + def get_success_url(self): - return reverse("pedagogy:ue_detail", kwargs={"ue_id": self.object.ue_id}) + return reverse("pedagogy:ue_detail", kwargs={"ue_id": self.object.ue.id}) -class UECommentDeleteView(PermissionOrAuthorRequiredMixin, DeleteView): +class UECommentDeleteView(PermissionOrAuthorRequiredMixin, AllowFragment, DeleteView): """Allow to delete a given comment.""" model = UEComment @@ -124,8 +147,13 @@ class UECommentDeleteView(PermissionOrAuthorRequiredMixin, DeleteView): permission_required = "pedagogy.delete_uecomment" author_field = "author" + def form_valid(self, form): + response = super().form_valid(form) + response.headers["HX-Trigger"] = "CommentUpdate" + return response + def get_success_url(self): - return reverse("pedagogy:ue_detail", kwargs={"ue_id": self.object.ue_id}) + return reverse("pedagogy:ue_detail", kwargs={"ue_id": self.object.ue.id}) class UEGuideView(PermissionRequiredMixin, TemplateView): @@ -135,24 +163,34 @@ class UEGuideView(PermissionRequiredMixin, TemplateView): permission_required = "pedagogy.view_ue" -class UECommentReportCreateView(PermissionRequiredMixin, CreateView): +class UECommentReportCreateView(PermissionRequiredMixin, AllowFragment, CreateView): """Create a new report for an inappropriate comment.""" model = UECommentReport form_class = UECommentReportForm - template_name = "core/edit.jinja" + template_name = "pedagogy/fragments/comment_report.jinja" permission_required = "pedagogy.add_uecommentreport" def dispatch(self, request, *args, **kwargs): self.ue_comment = get_object_or_404(UEComment, pk=kwargs["comment_id"]) return super().dispatch(request, *args, **kwargs) + @cached_property + def ue_detail_url(self): + return reverse("pedagogy:ue_detail", kwargs={"ue_id": self.ue_comment.ue.id}) + def get_form_kwargs(self): kwargs = super().get_form_kwargs() kwargs["reporter_id"] = self.request.user.id kwargs["comment_id"] = self.ue_comment.id return kwargs + def get_context_data(self, **kwargs): + return super().get_context_data() | { + "comment_id": self.ue_comment.id, + "ue_detail_url": self.ue_detail_url, + } + def form_valid(self, form): resp = super().form_valid(form) # Send a message to moderation admins @@ -168,11 +206,10 @@ class UECommentReportCreateView(PermissionRequiredMixin, CreateView): url=reverse("pedagogy:moderation"), type="PEDAGOGY_MODERATION", ) - return resp def get_success_url(self): - return reverse("pedagogy:ue_detail", kwargs={"ue_id": self.ue_comment.ue_id}) + return self.ue_detail_url class UEModerationFormView(PermissionRequiredMixin, FormView): From e80a43193f210eb6fdcfe29b8761869cd416acc2 Mon Sep 17 00:00:00 2001 From: Sli Date: Wed, 9 Sep 2026 14:35:44 +0200 Subject: [PATCH 2/5] Fix ue comment view permission Alternative queryset --- pedagogy/models.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/pedagogy/models.py b/pedagogy/models.py index 84f6bab4..bfbdb43c 100644 --- a/pedagogy/models.py +++ b/pedagogy/models.py @@ -26,6 +26,7 @@ from django.conf import settings from django.core import validators from django.db import models from django.db.models import Exists, OuterRef +from django.db.models.query_utils import Q from django.urls import reverse from django.utils import timezone from django.utils.functional import cached_property @@ -159,7 +160,14 @@ class UECommentQuerySet(models.QuerySet): # so he can view non-moderated comments return self if user.has_perm("pedagogy.view_uecomment"): - return self.filter(reports=None) + return self.filter( + Q(author=user) + | ~Exists( + UECommentReport.objects.filter( + ~Q(reporter=user), comment=OuterRef("pk") + ) + ) + ).distinct() return self.filter(author=user) def annotate_is_reported(self) -> Self: @@ -261,7 +269,7 @@ class UEResult(models.Model): class UECommentReport(models.Model): - """Report an inapropriate comment.""" + """Report an inappropriate comment.""" comment = models.ForeignKey( UEComment, From f3ba4fb31cbb5ba498d8b1ecef13ff0c0635642d Mon Sep 17 00:00:00 2001 From: Sli Date: Sat, 12 Sep 2026 22:30:18 +0200 Subject: [PATCH 3/5] Fix UE tests --- pedagogy/models.py | 10 ++- pedagogy/tests/tests.py | 138 +++++++++++++++++++++++++++++----------- 2 files changed, 106 insertions(+), 42 deletions(-) diff --git a/pedagogy/models.py b/pedagogy/models.py index bfbdb43c..309a80f5 100644 --- a/pedagogy/models.py +++ b/pedagogy/models.py @@ -124,13 +124,11 @@ 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 UE or is anonymous, else False. """ - self._has_user_commented = getattr(self, "_has_user_commented", {}) - self._has_user_commented[user] = self._has_user_commented.get( - user, self.comments.filter(author=user).exists() - ) - return self._has_user_commented[user] + if user.is_anonymous: + return True + return self.comments.filter(author=user).exists() @cached_property def grade_global_average(self): diff --git a/pedagogy/tests/tests.py b/pedagogy/tests/tests.py index b638516e..fab023fd 100644 --- a/pedagogy/tests/tests.py +++ b/pedagogy/tests/tests.py @@ -27,7 +27,6 @@ from django.conf import settings from django.contrib.auth.models import Permission from django.test import Client, TestCase from django.urls import reverse -from django.utils.translation import gettext_lazy as _ from model_bakery import baker from pytest_django.asserts import assertRedirects @@ -278,13 +277,17 @@ class TestUEUpdate(TestCase): # UEComment class tests -def create_ue_comment_template(user_id, ue_code="PA00", exclude_list=None): +def create_ue_comment_template( + user_id: int, + ue: int | str = "PA00", + exclude_list: list[str] | None = None, +): """Factory to help UEComment creation/update in post requests.""" if exclude_list is None: exclude_list = [] comment = { "author": user_id, - "ue": UE.objects.get(code=ue_code).id, + "ue": UE.objects.get(code=ue).id if isinstance(ue, str) else ue, "grade_global": 4, "grade_utility": 4, "grade_interest": 4, @@ -297,12 +300,46 @@ def create_ue_comment_template(user_id, ue_code="PA00", exclude_list=None): return comment -class TestUVCommentCreationAndDisplay(TestCase): - """Test UEComment creation and its display. +class TestUVCommentDisplay(TestCase): + @classmethod + def setUpTestData(cls): + cls.admin = baker.make(User, is_superuser=True) + cls.ue = baker.make(UE) + cls.ue_url = reverse("pedagogy:ue_detail", kwargs={"ue_id": cls.ue.id}) - Display and creation are the same view. - """ + def test_access_succses(self): + self.client.force_login(self.admin) + assert self.client.get(self.ue_url).status_code == 200 + pedagogy_admin = baker.make( + User, user_permissions=[Permission.objects.get(codename="view_ue")] + ) + self.client.force_login(pedagogy_admin) + assert self.client.get(self.ue_url).status_code == 200 + + self.client.force_login(subscriber_user.make()) + assert self.client.get(self.ue_url).status_code == 200 + + def test_access_fail(self): + # Anonymous user + assertRedirects( + self.client.get(self.ue_url), + reverse("core:login", query={"next": self.ue_url}), + ) + + # Unauthorized user + self.client.force_login(baker.make(User)) + assert self.client.get(self.ue_url).status_code == 403 + + def test_access_not_found(self): + self.client.force_login(self.admin) + res = self.client.get( + reverse("pedagogy:ue_detail", kwargs={"ue_id": UE.objects.last().id + 1}) + ) + assert res.status_code == 404 + + +class TestUECommentCreation(TestCase): @classmethod def setUpTestData(cls): cls.bibou = User.objects.get(username="root") @@ -311,11 +348,14 @@ class TestUVCommentCreationAndDisplay(TestCase): 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.comment_create_url = reverse( + "pedagogy:comment_create", kwargs={"ue_id": cls.ue.id} + ) def test_create_ue_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.comment_create_url, create_ue_comment_template(self.bibou.id) ) assertRedirects(response, self.ue_url) response = self.client.get(self.ue_url) @@ -324,7 +364,7 @@ class TestUVCommentCreationAndDisplay(TestCase): def test_create_ue_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.comment_create_url, create_ue_comment_template(self.tutu.id) ) self.assertRedirects(response, self.ue_url) response = self.client.get(self.ue_url) @@ -333,7 +373,7 @@ class TestUVCommentCreationAndDisplay(TestCase): def test_create_ue_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.comment_create_url, create_ue_comment_template(self.sli.id) ) self.assertRedirects(response, self.ue_url) response = self.client.get(self.ue_url) @@ -342,7 +382,7 @@ class TestUVCommentCreationAndDisplay(TestCase): def test_create_ue_empty_comment_fail(self): self.client.force_login(self.tutu) response = self.client.post( - self.ue_url, + self.comment_create_url, { "author": self.tutu.id, "ue": UE.objects.get(code="PA00").id, @@ -360,24 +400,40 @@ class TestUVCommentCreationAndDisplay(TestCase): def test_create_ue_comment_unauthorized_fail(self): nb_comments = self.ue.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.comment_create_url, create_ue_comment_template(0) + ) + assertRedirects( + response, reverse("core:login", query={"next": self.comment_create_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.comment_create_url, create_ue_comment_template(self.guy.id) ) assert response.status_code == 403 # Check that no comment has been created assert self.ue.comments.count() == nb_comments + def test_create_ue_comment_ue_not_exist_fails(self): + self.client.force_login(self.bibou) + not_existing_id = UE.objects.all().last().id + 1 + response = self.client.post( + reverse("pedagogy:comment_create", kwargs={"ue_id": not_existing_id}), + create_ue_comment_template( + self.bibou.id, + ue=not_existing_id, + ), + ) + assert response.status_code == 404 + def test_create_ue_comment_bad_form_fail(self): nb_comments = self.ue.comments.count() self.client.force_login(self.bibou) response = self.client.post( - self.ue_url, + self.comment_create_url, create_ue_comment_template(self.bibou.id, exclude_list=["grade_global"]), ) @@ -385,45 +441,55 @@ class TestUVCommentCreationAndDisplay(TestCase): assert self.ue.comments.count() == nb_comments def test_create_ue_comment_twice_fail(self): - # Checks that the has_user_already_commented method works proprely + # Checks that the has_user_already_commented method works properly assert not self.ue.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.comment_create_url, create_ue_comment_template(self.bibou.id) + ) - # Checks that the has_user_already_commented method works proprely + # Checks that the has_user_already_commented method works properly assert self.ue.has_user_already_commented(self.bibou) # Create the second comment comment = create_ue_comment_template(self.bibou.id) comment["comment"] = "Twice" - response = self.client.post(self.ue_url, comment) - assert response.status_code == 200 + response = self.client.post(self.comment_create_url, comment) + assert response.status_code == 403 assert UEComment.objects.filter(comment__contains="Superbe UE").exists() assert not UEComment.objects.filter(comment__contains="Twice").exists() - self.assertContains( - response, - _( - "You already posted a comment on this UE. " - "If you want to comment again, " - "please modify or delete your previous comment." - ), - ) + + def test_create_ue_comment_wrong_args(self): + self.client.force_login(self.bibou) # Ensure that there is no crash when no ue or no author is given - self.client.post( - self.ue_url, create_ue_comment_template(self.bibou.id, exclude_list=["ue"]) + response = self.client.post( + self.comment_create_url, + create_ue_comment_template(self.bibou.id, exclude_list=["ue"]), ) assert response.status_code == 200 - self.client.post( - self.ue_url, + assert not self.ue.has_user_already_commented(self.bibou) + + response = self.client.post( + self.comment_create_url, create_ue_comment_template(self.bibou.id, exclude_list=["author"]), ) assert response.status_code == 200 + assert not self.ue.has_user_already_commented(self.bibou) + + # Ensure that we can't push the wrong UE id + other_ue = baker.make(UE) + response = self.client.post( + self.comment_create_url, + create_ue_comment_template(self.bibou.id, ue=other_ue.id), + ) + assert response.status_code == 200 + assert not self.ue.has_user_already_commented(self.bibou) -class TestUVCommentDelete(TestCase): +class TestUECommentDelete(TestCase): """Test UEComment deletion rights.""" @classmethod @@ -462,7 +528,7 @@ class TestUVCommentDelete(TestCase): assert UEComment.objects.filter(id=self.comment.id).exists() -class TestUVCommentUpdate(TestCase): +class TestUECommentUpdate(TestCase): """Test UEComment update rights.""" @classmethod @@ -536,7 +602,7 @@ class TestUVCommentUpdate(TestCase): self.assertEqual(self.comment.author, self.krophil) -class TestUVModerationForm(TestCase): +class TestUEModerationForm(TestCase): """Assert access rights and if the form works well.""" @classmethod @@ -750,7 +816,7 @@ class TestUVModerationForm(TestCase): assert UEComment.objects.filter(id=self.comment_2.id).exists() -class TestUVCommentReportCreate(TestCase): +class TestUECommentReportCreate(TestCase): """Test report creation view. Assert access rights and if you can create with it. From 02df76c31ff6a87bcfebcb15e44d5110854ce2f6 Mon Sep 17 00:00:00 2001 From: Sli Date: Sat, 12 Sep 2026 23:37:11 +0200 Subject: [PATCH 4/5] Add comment view permission tests --- pedagogy/tests/tests.py | 69 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 64 insertions(+), 5 deletions(-) diff --git a/pedagogy/tests/tests.py b/pedagogy/tests/tests.py index fab023fd..ae9c4bda 100644 --- a/pedagogy/tests/tests.py +++ b/pedagogy/tests/tests.py @@ -306,18 +306,31 @@ class TestUVCommentDisplay(TestCase): cls.admin = baker.make(User, is_superuser=True) cls.ue = baker.make(UE) cls.ue_url = reverse("pedagogy:ue_detail", kwargs={"ue_id": cls.ue.id}) + cls.pedagogy_admin = baker.make( + User, + user_permissions=[ + Permission.objects.get(codename="view_ue"), + Permission.objects.get(codename="view_uecomment"), + Permission.objects.get(codename="view_uecommentreport"), + ], + ) + cls.subscriber = subscriber_user.make() + comments = baker.make(UEComment, ue=cls.ue, _quantity=10) + baker.make( + UECommentReport, + comment=iter(comments[5:]), + _quantity=len(comments[5:]), + _bulk_create=True, + ) def test_access_succses(self): self.client.force_login(self.admin) assert self.client.get(self.ue_url).status_code == 200 - pedagogy_admin = baker.make( - User, user_permissions=[Permission.objects.get(codename="view_ue")] - ) - self.client.force_login(pedagogy_admin) + self.client.force_login(self.pedagogy_admin) assert self.client.get(self.ue_url).status_code == 200 - self.client.force_login(subscriber_user.make()) + self.client.force_login(self.subscriber) assert self.client.get(self.ue_url).status_code == 200 def test_access_fail(self): @@ -338,6 +351,52 @@ class TestUVCommentDisplay(TestCase): ) assert res.status_code == 404 + def test_comments_normal_user(self): + # Normal user only see + # * Unreported comments + # * Comments that he wrote but were reported + # * Comments that he himself reported + + self.client.force_login(self.subscriber) + comments = self.client.get(self.ue_url).context_data.get("comments", []) + assert len(comments) == 5 + assert all(not comment.reports.exists() for comment in comments) + + # Make user comment + user_comment = baker.make(UEComment, ue=self.ue, author=self.subscriber) + comments = self.client.get(self.ue_url).context_data.get("comments", []) + assert len(comments) == 6 + assert all(not comment.reports.exists() for comment in comments) + assert user_comment in comments + + # Report user comment + baker.make(UECommentReport, comment=user_comment) + comments = self.client.get(self.ue_url).context_data.get("comments", []) + assert len(comments) == 6 + assert not all(not comment.reports.exists() for comment in comments) + assert user_comment in comments + + # Report someone's else comment + comment_reported_by_user = baker.make(UEComment, ue=self.ue) + baker.make( + UECommentReport, comment=comment_reported_by_user, reporter=self.subscriber + ) + comments = self.client.get(self.ue_url).context_data.get("comments", []) + assert len(comments) == 7 + assert comment_reported_by_user in comments + + def test_comments_pedagogy_admin(self): + # Pedagogy admin sees everything + self.client.force_login(self.pedagogy_admin) + comments = self.client.get(self.ue_url).context_data.get("comments", []) + assert len(comments) == 10 + + def test_comments_admin(self): + # Admin sees everything + self.client.force_login(self.admin) + comments = self.client.get(self.ue_url).context_data.get("comments", []) + assert len(comments) == 10 + class TestUECommentCreation(TestCase): @classmethod From 464f38af23a4680c2e2f7a46f95cddfca4c4ded6 Mon Sep 17 00:00:00 2001 From: Sli Date: Sun, 13 Sep 2026 21:51:52 +0200 Subject: [PATCH 5/5] Fix review comments --- .../pedagogy/fragments/comment_report.jinja | 6 ++-- pedagogy/tests/tests.py | 35 +++++++++++-------- pedagogy/views.py | 13 +++---- 3 files changed, 28 insertions(+), 26 deletions(-) diff --git a/pedagogy/templates/pedagogy/fragments/comment_report.jinja b/pedagogy/templates/pedagogy/fragments/comment_report.jinja index 39f9e6f6..9ebdfd97 100644 --- a/pedagogy/templates/pedagogy/fragments/comment_report.jinja +++ b/pedagogy/templates/pedagogy/fragments/comment_report.jinja @@ -15,9 +15,9 @@ {{ form.reporter }} {{ form.comment }} -
+
\ No newline at end of file diff --git a/pedagogy/tests/tests.py b/pedagogy/tests/tests.py index ae9c4bda..62574d88 100644 --- a/pedagogy/tests/tests.py +++ b/pedagogy/tests/tests.py @@ -25,6 +25,7 @@ from typing import Callable import pytest from django.conf import settings from django.contrib.auth.models import Permission +from django.db.models import Max from django.test import Client, TestCase from django.urls import reverse from model_bakery import baker @@ -316,10 +317,11 @@ class TestUVCommentDisplay(TestCase): ) cls.subscriber = subscriber_user.make() comments = baker.make(UEComment, ue=cls.ue, _quantity=10) + cls.unreported, cls.reported = comments[:5], comments[5:] baker.make( UECommentReport, - comment=iter(comments[5:]), - _quantity=len(comments[5:]), + comment=iter(cls.reported), + _quantity=len(cls.reported), _bulk_create=True, ) @@ -347,7 +349,10 @@ class TestUVCommentDisplay(TestCase): def test_access_not_found(self): self.client.force_login(self.admin) res = self.client.get( - reverse("pedagogy:ue_detail", kwargs={"ue_id": UE.objects.last().id + 1}) + reverse( + "pedagogy:ue_detail", + kwargs={"ue_id": UE.objects.aggregate(id=Max("id"))["id"] + 1}, + ) ) assert res.status_code == 404 @@ -359,22 +364,20 @@ class TestUVCommentDisplay(TestCase): self.client.force_login(self.subscriber) comments = self.client.get(self.ue_url).context_data.get("comments", []) - assert len(comments) == 5 - assert all(not comment.reports.exists() for comment in comments) + assert len(comments) == len(self.unreported) + assert set(comments) == set(self.unreported) # Make user comment user_comment = baker.make(UEComment, ue=self.ue, author=self.subscriber) comments = self.client.get(self.ue_url).context_data.get("comments", []) - assert len(comments) == 6 - assert all(not comment.reports.exists() for comment in comments) - assert user_comment in comments + assert len(comments) == len(self.unreported) + 1 + assert set(comments) == {*self.unreported, user_comment} # Report user comment baker.make(UECommentReport, comment=user_comment) comments = self.client.get(self.ue_url).context_data.get("comments", []) - assert len(comments) == 6 - assert not all(not comment.reports.exists() for comment in comments) - assert user_comment in comments + assert len(comments) == len(self.unreported) + 1 + assert set(comments) == {*self.unreported, user_comment} # Report someone's else comment comment_reported_by_user = baker.make(UEComment, ue=self.ue) @@ -382,8 +385,12 @@ class TestUVCommentDisplay(TestCase): UECommentReport, comment=comment_reported_by_user, reporter=self.subscriber ) comments = self.client.get(self.ue_url).context_data.get("comments", []) - assert len(comments) == 7 - assert comment_reported_by_user in comments + assert len(comments) == len(self.unreported) + 2 + assert set(comments) == { + *self.unreported, + user_comment, + comment_reported_by_user, + } def test_comments_pedagogy_admin(self): # Pedagogy admin sees everything @@ -478,7 +485,7 @@ class TestUECommentCreation(TestCase): def test_create_ue_comment_ue_not_exist_fails(self): self.client.force_login(self.bibou) - not_existing_id = UE.objects.all().last().id + 1 + not_existing_id = UE.objects.aggregate(id=Max("id"))["id"] + 1 response = self.client.post( reverse("pedagogy:comment_create", kwargs={"ue_id": not_existing_id}), create_ue_comment_template( diff --git a/pedagogy/views.py b/pedagogy/views.py index ba2157f9..4e54ae1d 100644 --- a/pedagogy/views.py +++ b/pedagogy/views.py @@ -106,7 +106,7 @@ class UEDetailView( .annotate_is_reported() .select_related("author") .order_by("-publish_date") - ), + ) } @@ -135,7 +135,7 @@ class UECommentUpdateView(PermissionOrAuthorRequiredMixin, AllowFragment, Update } def get_success_url(self): - return reverse("pedagogy:ue_detail", kwargs={"ue_id": self.object.ue.id}) + return reverse("pedagogy:ue_detail", kwargs={"ue_id": self.object.ue_id}) class UECommentDeleteView(PermissionOrAuthorRequiredMixin, AllowFragment, DeleteView): @@ -147,13 +147,8 @@ class UECommentDeleteView(PermissionOrAuthorRequiredMixin, AllowFragment, Delete permission_required = "pedagogy.delete_uecomment" author_field = "author" - def form_valid(self, form): - response = super().form_valid(form) - response.headers["HX-Trigger"] = "CommentUpdate" - return response - def get_success_url(self): - return reverse("pedagogy:ue_detail", kwargs={"ue_id": self.object.ue.id}) + return reverse("pedagogy:ue_detail", kwargs={"ue_id": self.object.ue_id}) class UEGuideView(PermissionRequiredMixin, TemplateView): @@ -177,7 +172,7 @@ class UECommentReportCreateView(PermissionRequiredMixin, AllowFragment, CreateVi @cached_property def ue_detail_url(self): - return reverse("pedagogy:ue_detail", kwargs={"ue_id": self.ue_comment.ue.id}) + return reverse("pedagogy:ue_detail", kwargs={"ue_id": self.ue_comment.ue_id}) def get_form_kwargs(self): kwargs = super().get_form_kwargs()