Skip to content

Commit e9b4521

Browse files
f213claude
andauthored
Raw JSON in Answers (#2744)
* Added json `content` field to the answer * More straightforward serializers during answer creation * Linter now fails on django warnings --------- Co-authored-by: Claude <[email protected]>
1 parent a195871 commit e9b4521

File tree

13 files changed

+296
-69
lines changed

13 files changed

+296
-69
lines changed

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ lint:
1414
poetry run ruff check src
1515
poetry run mypy src
1616
$(manage) makemigrations --check --no-input --dry-run
17-
$(manage) check
17+
$(manage) check --fail-level WARNING
1818
$(manage) spectacular --api-version v1 --fail-on-warn > /dev/null
1919
poetry run toml-sort pyproject.toml --check
2020
poetry run pymarkdown scan README.md

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

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from drf_spectacular.utils import extend_schema_field
22
from rest_framework import serializers
3+
from rest_framework.exceptions import ValidationError
34

45
from apps.homework.api.serializers.question import QuestionSerializer
56
from apps.homework.api.serializers.reaction import ReactionDetailedSerializer
@@ -12,6 +13,7 @@
1213
class AnswerSerializer(serializers.ModelSerializer):
1314
author = UserSafeSerializer()
1415
text = MarkdownField()
16+
legacy_text = MarkdownField(source="text")
1517
src = serializers.CharField(source="text")
1618
parent = SoftField(source="parent.slug") # type: ignore
1719
question = serializers.CharField(source="question.slug")
@@ -29,6 +31,8 @@ class Meta:
2931
"author",
3032
"parent",
3133
"text",
34+
"legacy_text",
35+
"content",
3236
"src",
3337
"has_descendants",
3438
"is_editable",
@@ -104,18 +108,28 @@ class Meta:
104108
"question",
105109
"parent",
106110
"text",
111+
"content",
107112
]
108113

109114

110115
class AnswerUpdateSerializer(serializers.ModelSerializer):
111-
"""For swagger only"""
112-
113116
class Meta:
114117
model = Answer
115118
fields = [
116119
"text",
120+
"content",
117121
]
118122

123+
def validate(self, data: dict) -> dict:
124+
"""Copy-paste from AnswerCreator. Remove it after frontend migration"""
125+
text = data.get("text")
126+
content = data.get("content")
127+
if text is None or len(text) == 0: # validating json
128+
if not isinstance(content, dict) or not len(content.keys()):
129+
raise ValidationError("Please provide text or content field")
130+
131+
return data
132+
119133

120134
class AnswerCommentTreeSerializer(AnswerTreeSerializer):
121135
class Meta:

src/apps/homework/api/viewsets.py

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
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, PermissionDenied
7+
from rest_framework.exceptions import MethodNotAllowed
88
from rest_framework.generics import get_object_or_404
99
from rest_framework.permissions import IsAuthenticated
1010
from rest_framework.request import Request
@@ -23,7 +23,7 @@
2323
ReactionCreateSerializer,
2424
ReactionDetailedSerializer,
2525
)
26-
from apps.homework.models import Answer, Question
26+
from apps.homework.models import Answer
2727
from apps.homework.models.answer import AnswerQuerySet
2828
from apps.homework.models.reaction import Reaction
2929
from apps.homework.services import ReactionCreator
@@ -53,7 +53,7 @@ class AnswerViewSet(DisablePaginationWithQueryParamMixin, AppViewSet):
5353
queryset = Answer.objects.for_viewset()
5454
serializer_class = AnswerSerializer
5555
serializer_action_classes = {
56-
"partial_update": AnswerCreateSerializer,
56+
"partial_update": AnswerUpdateSerializer,
5757
"retrieve": AnswerTreeSerializer,
5858
}
5959

@@ -66,15 +66,15 @@ class AnswerViewSet(DisablePaginationWithQueryParamMixin, AppViewSet):
6666
@extend_schema(request=AnswerCreateSerializer, responses=AnswerTreeSerializer)
6767
def create(self, request: Request, *args: Any, **kwargs: dict[str, Any]) -> Response:
6868
"""Create an answer"""
69-
self._check_question_permissions(user=self.user, question_slug=request.data["question"])
7069

7170
answer = AnswerCreator(
72-
question_slug=request.data["question"],
71+
question_slug=request.data.get("question"), # type: ignore
7372
parent_slug=request.data.get("parent"),
74-
text=request.data["text"],
73+
text=request.data.get("text", ""),
74+
content=request.data.get("content", {}),
7575
)()
7676

77-
answer = self.get_queryset().get(pk=answer.pk) # augment answer with methods from .for_viewset() to display it properly
77+
answer = self.get_queryset().get(pk=answer.pk) # augment answer with annotations from .for_viewset() to display it properly
7878
Serializer = self.get_serializer_class(action="retrieve")
7979
return Response(
8080
Serializer(
@@ -128,11 +128,6 @@ def limit_queryset_for_list(self, queryset: AnswerQuerySet) -> AnswerQuerySet:
128128

129129
return queryset
130130

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-
136131
@property
137132
def user(self) -> User:
138133
return self.request.user # type: ignore
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Generated by Django 4.2.23 on 2025-08-22 14:26
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("homework", "0024_drop_question_courses_field"),
9+
]
10+
11+
operations = [
12+
migrations.AddField(
13+
model_name="answer",
14+
name="content",
15+
field=models.JSONField(blank=True, null=True, default=dict),
16+
),
17+
]

src/apps/homework/models/answer.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ class Answer(TestUtilsMixin, TreeNode):
6262
do_not_crosscheck = models.BooleanField(_("Exclude from cross-checking"), default=False, db_index=True)
6363

6464
text = models.TextField()
65+
content = models.JSONField(blank=True, null=True, default=dict)
6566

6667
class Meta:
6768
verbose_name = _("Homework answer")

src/apps/homework/services/answer_creator.py

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import contextlib
22
from dataclasses import dataclass
3+
from typing import Callable
34

45
from django.db import transaction
56
from django.utils import timezone
67
from django.utils.functional import cached_property
7-
from rest_framework.exceptions import NotAuthenticated
8+
from rest_framework.exceptions import NotAuthenticated, NotFound, ValidationError
89

910
from apps.homework.models import Answer, Question
1011
from apps.studying.models import Study
@@ -17,6 +18,7 @@
1718
@dataclass
1819
class AnswerCreator(BaseService):
1920
text: str
21+
content: dict
2022
question_slug: str
2123
parent_slug: str | None = None
2224

@@ -29,13 +31,21 @@ def act(self) -> Answer:
2931

3032
return instance
3133

34+
def get_validators(self) -> list[Callable]:
35+
return [
36+
self.validate_question_slug,
37+
self.validate_parent_slug,
38+
self.validate_json_or_text,
39+
]
40+
3241
def create(self) -> Answer:
3342
return Answer.objects.create(
3443
parent=self.parent,
3544
question=self.question,
3645
author=self.author,
3746
study=self.study,
3847
text=self.text,
48+
content=self.content,
3949
)
4050

4151
@cached_property
@@ -48,15 +58,23 @@ def author(self) -> User:
4858

4959
@cached_property
5060
def parent(self) -> Answer | None:
51-
if not is_valid_uuid(self.parent_slug):
61+
if self.parent_slug is None or len(self.parent_slug) == 0:
5262
return None
5363

5464
with contextlib.suppress(Answer.DoesNotExist):
5565
return Answer.objects.get(slug=self.parent_slug)
5666

5767
@cached_property
5868
def question(self) -> Question:
59-
return Question.objects.get(slug=self.question_slug)
69+
try:
70+
return Question.objects.for_user(
71+
self.author,
72+
).get(
73+
slug=self.question_slug,
74+
)
75+
76+
except Question.DoesNotExist:
77+
raise NotFound()
6078

6179
@cached_property
6280
def study(self) -> Study | None:
@@ -74,3 +92,24 @@ def is_answer_to_crosscheck(self, instance: Answer) -> bool:
7492

7593
def complete_crosscheck(self, instance: Answer) -> None:
7694
instance.parent.answercrosscheck_set.filter(checker=self.author).update(checked=timezone.now())
95+
96+
def validate_json_or_text(self) -> None:
97+
"""Remove it after frontend migration"""
98+
if self.text is None or len(self.text) == 0: # validating json
99+
if not isinstance(self.content, dict) or not len(self.content.keys()):
100+
raise ValidationError("Please provide text or content field")
101+
102+
def validate_question_slug(self) -> None:
103+
"""Validate only format, database validation is performed later"""
104+
if not is_valid_uuid(self.question_slug):
105+
raise ValidationError("Question should be a valid uuid")
106+
107+
def validate_parent_slug(self) -> None:
108+
if self.parent_slug is None or not len(self.parent_slug): # adding a root answer
109+
return
110+
111+
if not is_valid_uuid(self.parent_slug):
112+
raise ValidationError("Question should be a valid uuid")
113+
114+
if not Answer.objects.filter(slug=self.parent_slug).exists():
115+
raise ValidationError("Answer does not exist")

src/apps/homework/tests/homework/api/answers/list/tests_answer_list.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,7 @@ def test_ok(api, question, answer):
2222
assert got[0]["modified"] == "2022-10-09T10:30:12+12:00"
2323
assert got[0]["slug"] == str(answer.slug)
2424
assert got[0]["question"] == str(answer.question.slug)
25-
assert "<em>test</em>" in got[0]["text"]
26-
assert got[0]["src"] == "*test*"
25+
assert got[0]["content"]["type"] == "doc"
2726
assert got[0]["author"]["uuid"] == str(api.user.uuid)
2827
assert got[0]["author"]["first_name"] == api.user.first_name
2928
assert got[0]["author"]["last_name"] == api.user.last_name
@@ -33,6 +32,26 @@ def test_ok(api, question, answer):
3332
assert got[0]["reactions"] == []
3433

3534

35+
def test_text_content(api, question, answer):
36+
answer.update(content={}, text="*legacy*")
37+
38+
got = api.get(f"/api/v2/homework/answers/?question={question.slug}")["results"]
39+
40+
assert got[0]["content"] == {}
41+
assert "legacy" in got[0]["text"]
42+
assert "legacy" in got[0]["legacy_text"]
43+
assert "<em>" in got[0]["legacy_text"]
44+
45+
46+
def test_json_content(api, question, answer):
47+
answer.update(content={"type": "doc"}, text="")
48+
49+
got = api.get(f"/api/v2/homework/answers/?question={question.slug}")["results"]
50+
51+
assert got[0]["content"]["type"] == "doc"
52+
assert got[0]["text"] == ""
53+
54+
3655
def test_has_reaction_fields_if_there_is_reaction(api, question, reaction):
3756
got = api.get(f"/api/v2/homework/answers/?question={question.slug}")["results"]
3857

src/apps/homework/tests/homework/api/answers/retrieve/tests_answer_retrieve.py

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,28 @@ def test_ok(api, answer, question):
2525
assert got["descendants"] == []
2626
assert got["is_editable"] is True
2727
assert got["reactions"] == []
28-
assert "text" in got
29-
assert "src" in got
28+
assert got["content"]["type"] == "doc"
29+
30+
31+
def test_text_content(api, answer):
32+
answer.update(content={}, text="*legacy*")
33+
34+
got = api.get(f"/api/v2/homework/answers/{answer.slug}/")
35+
36+
assert got["content"] == {}
37+
assert "legacy" in got["text"]
38+
assert "legacy" in got["legacy_text"]
39+
assert "<em>" in got["legacy_text"]
40+
41+
42+
def test_json_content(api, answer):
43+
answer.update(content={"type": "doc"}, text="")
44+
45+
got = api.get(f"/api/v2/homework/answers/{answer.slug}/")
46+
47+
assert got["content"]["type"] == "doc"
48+
assert got["text"] == ""
49+
assert got["legacy_text"] == ""
3050

3151

3252
def test_has_descendants_is_true_if_answer_has_children(api, answer, another_answer, another_user):
@@ -93,15 +113,6 @@ def test_query_count_for_answer_without_descendants(api, answer, django_assert_n
93113
api.get(f"/api/v2/homework/answers/{answer.slug}/")
94114

95115

96-
def test_markdown(api, answer):
97-
answer.update(text="*should be rendered*")
98-
99-
got = api.get(f"/api/v2/homework/answers/{answer.slug}/")
100-
101-
assert got["text"].startswith("<p><em>should be rendered"), f'"{got["text"]}" should start with "<p><em>should be rendered"'
102-
assert got["src"] == "*should be rendered*"
103-
104-
105116
def test_non_root_answers_are_ok(api, answer, another_answer):
106117
answer.update(parent=another_answer)
107118

0 commit comments

Comments
 (0)