Skip to content

Commit d493a9f

Browse files
authored
Homework crosschecks now work between neighbour questions (#2751)
Other improvements: * Reduced side effect probability in the TestUtilsMixin.update() method * QuestionAdmin speedup * Minor test readility improvement
1 parent 5d47f2e commit d493a9f

12 files changed

+199
-57
lines changed

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

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,23 @@
1-
from django.db.models import QuerySet
1+
from typing import no_type_check
2+
3+
from django.db.models import OuterRef, QuerySet, Subquery
24
from django.http import HttpRequest
35
from django.utils.translation import gettext_lazy as _
46
from rest_framework.request import Request
57

68
from apps.homework import tasks
79
from apps.homework.admin.question.form import QuestionForm
810
from apps.homework.models import Question
11+
from apps.products.models import Course
912
from core.admin import ModelAdmin, admin
1013

1114

1215
@admin.register(Question)
1316
class QuestionAdmin(ModelAdmin):
1417
list_display = [
1518
"name",
16-
"course",
19+
"product",
20+
"tariff",
1721
]
1822
fields = [
1923
"module",
@@ -29,15 +33,29 @@ class QuestionAdmin(ModelAdmin):
2933
form = QuestionForm
3034

3135
@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 "-"
36+
def product(self, question: Question) -> str:
37+
return getattr(question, "product_name", "-") or "-"
3638

37-
return str(lesson.module.course)
39+
@admin.display(description=_("Tariff Name"))
40+
def tariff(self, question: Question) -> str:
41+
return getattr(question, "course_name", "-") or "-"
3842

43+
@no_type_check
3944
def get_queryset(self, request: HttpRequest) -> QuerySet[Question]:
40-
return super().get_queryset(request).prefetch_related("lesson_set")
45+
queryset = super().get_queryset(request)
46+
47+
tariff = Course.objects.filter(
48+
modules__lesson__question=OuterRef("pk"),
49+
).values("tariff_name")[:1]
50+
51+
group = Course.objects.filter(
52+
modules__lesson__question=OuterRef("pk"),
53+
).values("group__name")[:1]
54+
55+
return queryset.annotate(
56+
course_name=Subquery(tariff),
57+
product_name=Subquery(group),
58+
)
4159

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

src/apps/homework/factory.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@
44

55

66
@register
7-
def question(self: FixtureFactory, course: Course | None = None, **kwargs: dict) -> Question:
8-
question = self.mixer.blend("homework.Question", _legacy_course=None, **kwargs)
7+
def question(self: FixtureFactory, course: Course | None = None, name: str | None = None, **kwargs: dict) -> Question:
8+
question = Question.objects.create(
9+
name=name or f"Please {self.faker.bs()} two times",
10+
**kwargs,
11+
)
912

1013
if course is not None:
1114
module = self.module(course=course)

src/apps/homework/services/question_crosscheck_dispatcher.py

Lines changed: 43 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,67 +4,89 @@
44

55
from apps.homework.models import Answer, AnswerCrossCheck, Question
66
from apps.homework.services.answer_crosscheck_dispatcher import AnswerCrossCheckDispatcher
7+
from apps.lms.models import Lesson
78
from apps.mailing.tasks import send_mail
89
from apps.users.models import User
910
from core.services import BaseService
1011

1112

1213
@dataclass
1314
class QuestionCrossCheckDispatcher(BaseService):
14-
question: Question
15-
answers_per_user: int = 3
15+
"""Dispatches crosschecks for given question (and its neighbours).
1616
17-
def __post_init__(self) -> None:
18-
self.dispatcher = AnswerCrossCheckDispatcher(
19-
answers=self.get_answers_to_check(),
20-
answers_per_user=self.answers_per_user,
21-
)
17+
To improve readability, the user story is split in 2 services:
18+
- QuestionCrossCheckDispatcher (this one) finds the answers to mix between students
19+
- AnswerCrossCheckDispatcher mixes them between students
20+
"""
2221

23-
self.checks: list[AnswerCrossCheck] = list()
22+
question: Question
23+
answers_per_user: int = 3
2424

2525
def act(self) -> int:
26-
self.dispatch_crosschecks()
26+
self.crosschecks = self.dispatch_crosschecks()
2727
self.notify_users()
2828

2929
return self.get_users_to_notify().count()
3030

31-
def dispatch_crosschecks(self) -> None:
32-
self.checks = self.dispatcher()
31+
def dispatch_crosschecks(self) -> list[AnswerCrossCheck]:
32+
dispatcher = AnswerCrossCheckDispatcher(
33+
answers=self.get_answers_to_check(),
34+
answers_per_user=self.answers_per_user,
35+
)
36+
return dispatcher()
3337

3438
def notify_users(self) -> None:
3539
for user in self.get_users_to_notify():
36-
user_checks_list = self.get_checks_for_user(user)
40+
user_crosschecks_list = self.get_crosschecks_for_user(user)
3741
send_mail.delay(
3842
to=user.email,
3943
template_id="new-answers-to-check",
40-
ctx=self.get_notification_context(user_checks_list),
44+
ctx=self.get_notification_context(user_crosschecks_list),
4145
disable_antispam=True,
4246
)
4347

4448
def get_users_to_notify(self) -> QuerySet[User]:
45-
return User.objects.filter(pk__in=[check.checker_id for check in self.checks])
49+
return User.objects.filter(pk__in=[check.checker_id for check in self.crosschecks])
4650

47-
def get_checks_for_user(self, user: User) -> list[AnswerCrossCheck]:
48-
return [check for check in self.checks if check.checker == user]
51+
def get_crosschecks_for_user(self, user: User) -> list[AnswerCrossCheck]:
52+
return [crosscheck for crosscheck in self.crosschecks if crosscheck.checker == user]
4953

5054
def get_answers_to_check(self) -> QuerySet[Answer]:
55+
questions = self.get_questions_to_check()
5156
return (
52-
Answer.objects.filter(question=self.question)
57+
Answer.objects.filter(question__in=questions)
5358
.root_only()
5459
.exclude(
5560
do_not_crosscheck=True,
5661
)
5762
)
5863

64+
def get_questions_to_check(self) -> QuerySet[Question]:
65+
"""
66+
Questions with the same name that belong to the same product.group
67+
"""
68+
current_lesson = Lesson.objects.filter(question=self.question).first()
69+
if current_lesson is None:
70+
return Question.objects.filter(pk=self.question.pk)
71+
72+
same_group_lessons = Lesson.objects.filter(
73+
module__course__group=current_lesson.module.course.group,
74+
question__name__iexact=self.question.name,
75+
)
76+
77+
return Question.objects.filter(
78+
pk__in=same_group_lessons.values_list("question_id"),
79+
)
80+
5981
@staticmethod
60-
def get_notification_context(checks: list[AnswerCrossCheck]) -> dict:
82+
def get_notification_context(crosschecks: list[AnswerCrossCheck]) -> dict:
6183
answers = list()
6284

63-
for check in checks:
85+
for crosscheck in crosschecks:
6486
answers.append(
6587
{
66-
"url": check.answer.get_absolute_url(),
67-
"text": str(check.answer),
88+
"url": crosscheck.answer.get_absolute_url(),
89+
"text": str(crosscheck.answer),
6890
}
6991
)
7092

src/apps/homework/tests/homework/api/answers/tests_answer_create.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ def test_404_for_not_purchased_users(api, question):
214214

215215

216216
@pytest.mark.usefixtures("_no_purchase")
217-
def test_ok_for_users_that_purchased_another_course_from_the_group(api, question, course, factory):
217+
def test_ok_for_users_that_purchased_another_course_from_the_same_group(api, question, course, factory):
218218
"""Create a purchase of the item with the same group as the course, and check if user can post an answer"""
219219
another_course = factory.course(group=course.group)
220220
factory.order(item=another_course, user=api.user, is_paid=True)
@@ -229,6 +229,23 @@ def test_ok_for_users_that_purchased_another_course_from_the_group(api, question
229229
)
230230

231231

232+
@pytest.mark.usefixtures("_no_purchase")
233+
def test_parent_answer_ok_for_users_that_purchased_another_course_from_the_same_group(api, question, course, another_answer, factory):
234+
"""Same as above, but for the non-root answer"""
235+
another_course = factory.course(group=course.group)
236+
factory.order(item=another_course, user=api.user, is_paid=True)
237+
238+
api.post(
239+
"/api/v2/homework/answers/",
240+
{
241+
"question": question.slug,
242+
"parent": another_answer.slug,
243+
"content": JSON,
244+
},
245+
expected_status_code=201,
246+
)
247+
248+
232249
@pytest.mark.usefixtures("_no_purchase")
233250
@pytest.mark.parametrize(
234251
"permission",
@@ -275,7 +292,7 @@ def test_ok_for_superusers(api, question):
275292
assert created.study is None
276293

277294

278-
def test_404_if_user_has_not_purchase_record_at_all(api, question, purchase):
295+
def test_404_if_user_has_no_order_at_all(api, question, purchase):
279296
purchase.delete()
280297

281298
api.post(

src/apps/homework/tests/homework/cross_check/conftest.py

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,41 @@
66

77

88
@pytest.fixture
9-
def dispatcher():
10-
return AnswerCrossCheckDispatcher
9+
def module(factory, course):
10+
return factory.module(course=course)
1111

1212

1313
@pytest.fixture
14-
def question_dispatcher(question):
15-
return QuestionCrossCheckDispatcher(question=question, answers_per_user=1)
14+
def another_module(factory, another_course):
15+
return factory.module(course=another_course)
16+
17+
18+
@pytest.fixture
19+
def lesson(factory, module):
20+
return factory.lesson(module=module)
1621

1722

1823
@pytest.fixture
19-
def question(factory):
20-
return factory.question()
24+
def another_lesson(factory, another_module):
25+
return factory.lesson(module=another_module)
26+
27+
28+
@pytest.fixture
29+
def question(factory, lesson):
30+
question = factory.question()
31+
32+
lesson.update(question=question)
33+
34+
return question
35+
36+
37+
@pytest.fixture
38+
def another_question(factory, another_lesson):
39+
question = factory.question()
40+
41+
another_lesson.update(question=question)
42+
43+
return question
2144

2245

2346
@pytest.fixture(autouse=True)
@@ -26,3 +49,22 @@ def answers(mixer, question, user, another_user):
2649
mixer.blend("homework.Answer", question=question, author=user, parent=None),
2750
mixer.blend("homework.Answer", question=question, author=another_user, parent=None),
2851
]
52+
53+
54+
@pytest.fixture
55+
def answers_to_another_question(mixer, another_question):
56+
even_more_users = mixer.cycle(2).blend("users.User")
57+
return [
58+
mixer.blend("homework.Answer", question=another_question, author=even_more_users[0], parent=None),
59+
mixer.blend("homework.Answer", question=another_question, author=even_more_users[1], parent=None),
60+
]
61+
62+
63+
@pytest.fixture
64+
def answer_dispatcher():
65+
return AnswerCrossCheckDispatcher
66+
67+
68+
@pytest.fixture
69+
def question_dispatcher(question):
70+
return QuestionCrossCheckDispatcher(question=question, answers_per_user=1)

src/apps/homework/tests/homework/cross_check/tests_crosscheck_dispatcher.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66

77

88
@pytest.fixture
9-
def dispatcher(dispatcher, answers):
10-
return dispatcher(answers=answers, answers_per_user=1)
9+
def dispatcher(answer_dispatcher, answers):
10+
return answer_dispatcher(answers=answers, answers_per_user=1)
1111

1212

1313
@pytest.mark.repeat(10)

src/apps/homework/tests/homework/cross_check/tests_dispatch_answers_of_the_same_user.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ def answers(mixer, question, user, another_user):
1616

1717

1818
@pytest.fixture
19-
def dispatcher(dispatcher, answers):
20-
return dispatcher(answers=answers)
19+
def dispatcher(answer_dispatcher, answers):
20+
return answer_dispatcher(answers=answers)
2121

2222

2323
@pytest.fixture

src/apps/homework/tests/homework/cross_check/tests_get_answer_to_check.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@
99

1010

1111
@pytest.fixture
12-
def get(dispatcher, answers):
12+
def get(answer_dispatcher, answers):
1313
def _get(user):
14-
service = dispatcher(answers=answers)
14+
service = answer_dispatcher(answers=answers)
1515

1616
return service.get_answer_to_check(user)
1717

src/apps/homework/tests/homework/cross_check/tests_get_crosscheck_count.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,21 @@ def get_crosscheck_count(answer, dispatcher):
99
return answer.crosscheck_count
1010

1111

12-
def test_no_crosschecks(dispatcher, answers):
13-
dispatcher = dispatcher(answers=answers)
12+
def test_no_crosschecks(answer_dispatcher, answers):
13+
dispatcher = answer_dispatcher(answers=answers)
1414

1515
assert get_crosscheck_count(answers[0], dispatcher) == 0
1616

1717

18-
def test_no_crosschecks_from_non_dispatched_users(dispatcher, mixer, answers):
18+
def test_no_crosschecks_from_non_dispatched_users(answer_dispatcher, mixer, answers):
1919
mixer.blend("homework.AnswerCrossCheck", answer=answers[1])
20-
dispatcher = dispatcher(answers)
20+
dispatcher = answer_dispatcher(answers)
2121

2222
assert get_crosscheck_count(answers[1], dispatcher) == 0
2323

2424

25-
def test_crosschecks(dispatcher, mixer, answers):
25+
def test_crosschecks(answer_dispatcher, mixer, answers):
2626
mixer.blend("homework.AnswerCrossCheck", answer=answers[1], checker=answers[0].author)
27-
dispatcher = dispatcher(answers)
27+
dispatcher = answer_dispatcher(answers)
2828

2929
assert get_crosscheck_count(answers[1], dispatcher) == 1

0 commit comments

Comments
 (0)