Skip to content

Commit a195871

Browse files
authored
Removed question.courses field (#2741)
* Dropped always-on homework permissions featureflag * Answer model permissions refactoring * Dropped legacy multi-course questions * Question breadcrumbs code cleanup * Simpler syntax for User.add_perm(), now it uses the same perm path as User.has_perm()
1 parent 0ba4f49 commit a195871

38 files changed

+300
-241
lines changed

conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"apps.products.fixtures",
1515
"apps.users.fixtures",
1616
"apps.lms.factory",
17+
"apps.homework.factory",
1718
"apps.dashamail.fixtures",
1819
"core.fixtures",
1920
]

src/apps/homework/admin/question/admin.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,9 @@
1313
class QuestionAdmin(ModelAdmin):
1414
list_display = [
1515
"name",
16-
"courses_list",
16+
"course",
1717
]
1818
fields = [
19-
"courses",
20-
"course",
2119
"module",
2220
"lesson",
2321
"name",
@@ -30,11 +28,16 @@ class QuestionAdmin(ModelAdmin):
3028
save_as = True
3129
form = QuestionForm
3230

33-
def get_queryset(self, request: HttpRequest) -> QuerySet[Question]:
34-
return super().get_queryset(request).for_admin() # type: ignore
31+
@admin.display(description=_("Course"))
32+
def course(self, question: Question) -> str:
33+
lesson = question.lesson_set.select_related("module", "module__course", "module__course__group").first()
34+
if lesson is None:
35+
return "-"
3536

36-
def courses_list(self, question: Question) -> str:
37-
return ", ".join([course.name for course in question.courses.all()])
37+
return str(lesson.module.course)
38+
39+
def get_queryset(self, request: HttpRequest) -> QuerySet[Question]:
40+
return super().get_queryset(request).prefetch_related("lesson_set")
3841

3942
@admin.action(description=_("Dispatch crosscheck"))
4043
def dispatch_crosscheck(self, request: Request, queryset: QuerySet) -> None:

src/apps/homework/admin/question/form.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,24 +4,20 @@
44

55
from apps.homework.models import Question
66
from apps.lms.models import Lesson, Module
7-
from apps.products.models import Course
87
from core.admin.forms import ModelForm
98
from core.current_user import get_current_user
109
from core.tasks import write_admin_log
1110

1211

1312
class QuestionForm(ModelForm):
14-
course = forms.ModelChoiceField(label=_("Course"), queryset=Course.objects.for_admin(), required=False)
1513
module = forms.ModelChoiceField(label=_("Module"), queryset=Module.objects.for_admin(), required=False)
1614
lesson = forms.ModelChoiceField(label=_("Lesson"), queryset=Lesson.objects.for_admin(), required=False)
1715

1816
class Meta:
1917
model = Question
2018
fields = [
21-
"courses",
2219
"name",
2320
"deadline",
24-
"course",
2521
"module",
2622
"lesson",
2723
"text",
Lines changed: 6 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,58 +1,21 @@
1-
from typing import Any
2-
3-
from django.conf import settings
41
from rest_framework import permissions
52
from rest_framework.request import Request
63
from rest_framework.views import APIView
74

8-
from apps.homework.models import Question
9-
10-
11-
def get_all_purcased_user_ids(question: Question) -> frozenset[int]:
12-
user_ids: set[int] = set()
13-
for course in question.courses.all():
14-
user_ids.update(course.get_purchased_users().values_list("id", flat=True))
15-
16-
return frozenset(user_ids)
17-
18-
19-
class ShouldHavePurchasedCoursePermission(permissions.BasePermission):
20-
def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool:
21-
return (
22-
request.user.has_perm("homework.see_all_questions")
23-
or settings.DISABLE_HOMEWORK_PERMISSIONS_CHECKING
24-
or (request.user.id in get_all_purcased_user_ids(obj))
25-
)
26-
5+
from apps.homework.models import Answer, Reaction
276

28-
class ShouldHavePurchasedQuestionCoursePermission(permissions.BasePermission):
29-
def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool:
30-
return (
31-
request.user.has_perm("homework.see_all_questions")
32-
or settings.DISABLE_HOMEWORK_PERMISSIONS_CHECKING
33-
or (request.user.id in get_all_purcased_user_ids(obj.question))
34-
)
357

36-
37-
class ShouldBeAuthorOrReadOnly(permissions.BasePermission):
38-
def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool:
8+
class AuthorOrReadonly(permissions.BasePermission):
9+
def has_object_permission(self, request: Request, view: APIView, obj: Answer | Reaction) -> bool:
3910
if request.method in permissions.SAFE_METHODS:
4011
return True
4112

4213
return obj.author == request.user
4314

4415

45-
class AnswerShouldBeEditable(permissions.BasePermission):
46-
def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool:
47-
if request.method in permissions.SAFE_METHODS:
48-
return True
49-
50-
return obj.is_editable
51-
52-
53-
class MayChangeAnswerOnlyWithoutDescendants(permissions.BasePermission):
54-
def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool:
16+
class IsEditable(permissions.BasePermission):
17+
def has_object_permission(self, request: Request, view: APIView, answer: Answer) -> bool:
5518
if request.method in permissions.SAFE_METHODS:
5619
return True
5720

58-
return obj.get_first_level_descendants().count() == 0
21+
return answer.is_editable

src/apps/homework/api/serializers/question.py

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -40,24 +40,19 @@ class Meta:
4040

4141
@extend_schema_field(lazy_serializer("apps.lms.api.serializers.LMSCourseSerializer")())
4242
def get_course(self, question: Question) -> dict:
43-
from apps.homework.breadcrumbs import get_lesson
4443
from apps.lms.api.serializers import LMSCourseSerializer
4544

46-
lesson = get_lesson(question, user=self.context["request"].user)
45+
course = question.get_course(user=self.context["request"].user)
4746

48-
course = question.courses.first()
49-
if lesson is not None:
50-
course = lesson.module.course
47+
if course is None:
48+
course = question.get_legacy_course()
5149

5250
return LMSCourseSerializer(course).data
5351

5452
@extend_schema_field(lazy_serializer("apps.lms.api.serializers.BreadcrumbsSerializer")())
5553
def get_breadcrumbs(self, question: Question) -> dict | None:
56-
from apps.homework.breadcrumbs import get_lesson
5754
from apps.lms.api.serializers import BreadcrumbsSerializer
5855

59-
lesson = get_lesson(question, user=self.context["request"].user)
60-
if lesson is None:
61-
return None
56+
lesson = question.get_lesson(user=self.context["request"].user)
6257

63-
return BreadcrumbsSerializer(lesson).data
58+
return BreadcrumbsSerializer(lesson).data if lesson is not None else None

src/apps/homework/api/views.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,18 @@
44

55
from apps.homework.api import serializers
66
from apps.homework.api.filtersets import AnswerCommentFilterSet, AnswerCrossCheckFilterSet
7-
from apps.homework.api.permissions import ShouldHavePurchasedCoursePermission
87
from apps.homework.api.serializers import CrossCheckSerializer
98
from apps.homework.models import Answer, AnswerCrossCheck, AnswerImage, Question
109

1110

1211
class QuestionView(generics.RetrieveAPIView):
1312
queryset = Question.objects.all()
1413
serializer_class = serializers.QuestionDetailSerializer
15-
permission_classes = [ShouldHavePurchasedCoursePermission]
1614
lookup_field = "slug"
1715

1816
def get_queryset(self) -> QuerySet[Question]:
1917
queryset = super().get_queryset()
2018

21-
if self.request.user.is_anonymous:
22-
return queryset.none()
23-
2419
return queryset.for_user(self.request.user) # type: ignore
2520

2621

src/apps/homework/api/viewsets.py

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,16 @@
44
from django.utils.decorators import method_decorator
55
from django.utils.functional import cached_property
66
from drf_spectacular.utils import extend_schema
7-
from rest_framework.exceptions import MethodNotAllowed
7+
from rest_framework.exceptions import MethodNotAllowed, PermissionDenied
88
from rest_framework.generics import get_object_or_404
99
from rest_framework.permissions import IsAuthenticated
1010
from rest_framework.request import Request
1111
from rest_framework.response import Response
1212

1313
from apps.homework.api.filtersets import AnswerFilterSet
1414
from apps.homework.api.permissions import (
15-
AnswerShouldBeEditable,
16-
MayChangeAnswerOnlyWithoutDescendants,
17-
ShouldBeAuthorOrReadOnly,
18-
ShouldHavePurchasedQuestionCoursePermission,
15+
AuthorOrReadonly,
16+
IsEditable,
1917
)
2018
from apps.homework.api.serializers import (
2119
AnswerCreateSerializer,
@@ -25,12 +23,13 @@
2523
ReactionCreateSerializer,
2624
ReactionDetailedSerializer,
2725
)
28-
from apps.homework.models import Answer
26+
from apps.homework.models import Answer, Question
2927
from apps.homework.models.answer import AnswerQuerySet
3028
from apps.homework.models.reaction import Reaction
3129
from apps.homework.services import ReactionCreator
3230
from apps.homework.services.answer_creator import AnswerCreator
3331
from apps.homework.services.answer_remover import AnswerRemover
32+
from apps.users.models import User
3433
from core.api.mixins import DisablePaginationWithQueryParamMixin
3534
from core.viewsets import AppViewSet, CreateDeleteAppViewSet
3635

@@ -60,17 +59,15 @@ class AnswerViewSet(DisablePaginationWithQueryParamMixin, AppViewSet):
6059

6160
lookup_field = "slug"
6261
permission_classes = [
63-
IsAuthenticated
64-
& ShouldHavePurchasedQuestionCoursePermission
65-
& ShouldBeAuthorOrReadOnly
66-
& AnswerShouldBeEditable
67-
& MayChangeAnswerOnlyWithoutDescendants,
62+
IsAuthenticated & AuthorOrReadonly & IsEditable,
6863
]
6964
filterset_class = AnswerFilterSet
7065

7166
@extend_schema(request=AnswerCreateSerializer, responses=AnswerTreeSerializer)
7267
def create(self, request: Request, *args: Any, **kwargs: dict[str, Any]) -> Response:
7368
"""Create an answer"""
69+
self._check_question_permissions(user=self.user, question_slug=request.data["question"])
70+
7471
answer = AnswerCreator(
7572
question_slug=request.data["question"],
7673
parent_slug=request.data.get("parent"),
@@ -119,9 +116,9 @@ def get_queryset(self) -> AnswerQuerySet:
119116
return queryset.with_children_count().order_by("created").prefetch_reactions()
120117

121118
def limit_queryset_to_user(self, queryset: AnswerQuerySet) -> AnswerQuerySet:
122-
if self.action != "retrieve" and not self.request.user.has_perm("homework.see_all_answers"):
119+
if self.action != "retrieve" and not self.user.has_perm("homework.see_all_answers"):
123120
# Each user may access any answer knowing its slug
124-
return queryset.for_user(self.request.user) # type: ignore
121+
return queryset.for_user(self.user)
125122

126123
return queryset
127124

@@ -131,14 +128,23 @@ def limit_queryset_for_list(self, queryset: AnswerQuerySet) -> AnswerQuerySet:
131128

132129
return queryset
133130

131+
@staticmethod
132+
def _check_question_permissions(user: User, question_slug: str) -> None:
133+
if not Question.objects.for_user(user).filter(slug=question_slug).exists():
134+
raise PermissionDenied()
135+
136+
@property
137+
def user(self) -> User:
138+
return self.request.user # type: ignore
139+
134140

135141
class ReactionViewSet(CreateDeleteAppViewSet):
136142
queryset = Reaction.objects.for_viewset()
137143
serializer_class = ReactionDetailedSerializer
138144
serializer_action_classes = {
139145
"create": ReactionCreateSerializer,
140146
}
141-
permission_classes = [IsAuthenticated & ShouldBeAuthorOrReadOnly]
147+
permission_classes = [IsAuthenticated & AuthorOrReadonly]
142148

143149
lookup_field = "slug"
144150

src/apps/homework/breadcrumbs.py

Lines changed: 0 additions & 32 deletions
This file was deleted.

src/apps/homework/factory.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from apps.homework.models import Question
2+
from apps.products.models import Course
3+
from core.test.factory import FixtureFactory, register
4+
5+
6+
@register
7+
def question(self: FixtureFactory, course: Course | None = None, **kwargs: dict) -> Question:
8+
question = self.mixer.blend("homework.Question", _legacy_course=None, **kwargs)
9+
10+
if course is not None:
11+
module = self.module(course=course)
12+
self.lesson(module=module, question=question)
13+
14+
return question
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Generated by Django 4.2.23 on 2025-08-18 10:28
2+
import django.contrib.postgres.fields
3+
import django.db.models.deletion
4+
from django.db import migrations, models
5+
6+
7+
# type: ignore
8+
def populate_legacy_course_id(apps, schema_editor): # NOQA: ARG001
9+
for question in apps.get_model("homework.Question").objects.all():
10+
course_ids = question.courses.values_list("id", flat=True)
11+
if course_ids.exists():
12+
question._legacy_courses = list(course_ids)
13+
question.save(update_fields=["_legacy_courses"])
14+
15+
16+
class Migration(migrations.Migration):
17+
dependencies = [
18+
("homework", "0023_answer_indexes"),
19+
]
20+
21+
operations = [
22+
migrations.AddField(
23+
model_name="question",
24+
name="_legacy_courses",
25+
field=django.contrib.postgres.fields.ArrayField(base_field=models.PositiveIntegerField(), blank=True, null=True, size=None),
26+
),
27+
migrations.RunPython(populate_legacy_course_id),
28+
migrations.RemoveField(
29+
model_name="question",
30+
name="courses",
31+
),
32+
]

0 commit comments

Comments
 (0)