Skip to content

Commit 1253798

Browse files
authored
Merge pull request #634 from PROCOLLAB-github/refactor/modules
Refactor/modules
2 parents 3951c30 + 4efbc7d commit 1253798

37 files changed

Lines changed: 1848 additions & 147 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ coverage.xml
5151
.hypothesis/
5252
.pytest_cache/
5353
.idea/
54+
.codex
5455

5556
# Translations
5657
*.mo

README.md

Lines changed: 22 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,70 +1,31 @@
1-
# Procollab backend service
1+
# Procollab Backend
22

3-
## Usage
3+
Backend API для продукта Procollab.
44

5-
### Clone project
5+
## Стек
66

7-
📌 `git clone https://github.com/procollab-github/api.git`
7+
- Python
8+
- Django
9+
- Django REST Framework
10+
- Channels
11+
- Celery
12+
- PostgreSQL
13+
- Redis
814

9-
### Create virtual environment
10-
11-
🔑 Copy `.env.example` to `.env` and change api settings
12-
13-
### Install dependencies
14-
15-
* 🐍 Install poetry with command `pip install poetry`
16-
* 📎 Install dependencies with command `poetry install`
17-
18-
### Accept migrations
19-
20-
🎓 Run `python manage.py migrate`
21-
22-
### Run project
23-
24-
🚀 Run project via `python manage.py runserver`
25-
## For developers
26-
27-
### Install pre-commit hooks
28-
29-
To install pre-commit simply run inside the shell:
15+
## Базовые команды
3016

3117
```bash
32-
pre-commit install
33-
```
34-
35-
To run it on all of your files, do
36-
37-
```bash
38-
pre-commit run --all-files
39-
```
40-
41-
## Troubleshooting
42-
43-
## Errors caused by weasyprint
44-
45-
### MacOS
46-
47-
Error:
48-
```
49-
OSError: cannot load library 'pango-1.0-0': dlopen(pango-1.0-0, 0x0002): tried: 'pango-1.0-0' (no such file), '/System/Volumes/Preboot/Cryptexes/OSpango-1.0-0' (no such file), '/Users/yakser/.pyenv/versions/3.11.9/lib/pango-1.0-0' (no such file), '/System/Volumes/Preboot/Cryptexes/OS/Users/yakser/.pyenv/versions/3.11.9/lib/pango-1.0-0' (no such file), '/opt/homebrew/lib/pango-1.0-0' (no such file), '/System/Volumes/Preboot/Cryptexes/OS/opt/homebrew/lib/pango-1.0-0' (no such file), '/usr/lib/pango-1.0-0' (no such file, not in dyld cache), 'pango-1.0-0' (no such file), '/usr/local/lib/pango-1.0-0' (no such file), '/usr/lib/pango-1.0-0' (no such file, not in dyld cache). Additionally, ctypes.util.find_library() did not manage to locate a library called 'pango-1.0-0'
50-
```
51-
52-
Fix:
53-
54-
```shell
55-
brew install weasyprint
56-
```
57-
58-
### Windows
59-
60-
Error:
18+
poetry install
19+
poetry run python manage.py migrate
20+
poetry run python manage.py runserver
21+
poetry run python manage.py test
6122
```
62-
OSError: cannot load library 'gobject-2.0-0': error 0x7e. Additionally, ctypes.util.find_library() did not manage to locate a library called 'gobject-2.0-0'
63-
```
64-
65-
Fix:
66-
67-
Go to [WeasyPrint docs](https://doc.courtbouillon.org/weasyprint/stable/first_steps.html#windows) step by step install dependencies. If the error persists, add the path to the windows environment variable: `C:\msys64\mingw64\bin`
6823

24+
## Документация
6925

70-
## [Docs](/docs/readme.md)
26+
- [Навигация по документации](docs/readme.md)
27+
- [Разработка](docs/development.md)
28+
- [Архитектура](docs/architecture.md)
29+
- [API](docs/api.md)
30+
- [Инфраструктура и деплой](docs/devops-state.md)
31+
- [Доменные модули](docs/modules/readme.md)

courses/admin_config/answers.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,25 @@
11
from django.contrib import admin
22

3-
from courses.models import UserTaskAnswer, UserTaskAnswerFile, UserTaskAnswerOption
3+
from courses.models import (
4+
CourseTaskCheckType,
5+
UserTaskAnswer,
6+
UserTaskAnswerFile,
7+
UserTaskAnswerOption,
8+
)
9+
from courses.services.progress import recalculate_user_progresses_for_lesson
410

511
from .inlines import UserTaskAnswerFileInline, UserTaskAnswerOptionInline
612

713

14+
REVIEW_PROGRESS_FIELDS = {
15+
"status",
16+
"is_correct",
17+
"review_comment",
18+
"reviewed_by",
19+
"reviewed_at",
20+
}
21+
22+
823
@admin.register(UserTaskAnswer)
924
class UserTaskAnswerAdmin(admin.ModelAdmin):
1025
list_display = (
@@ -44,6 +59,16 @@ class UserTaskAnswerAdmin(admin.ModelAdmin):
4459
)
4560
inlines = [UserTaskAnswerOptionInline, UserTaskAnswerFileInline]
4661

62+
def save_model(self, request, obj, form, change):
63+
super().save_model(request, obj, form, change)
64+
65+
changed_fields = set(getattr(form, "changed_data", []) or [])
66+
if (
67+
obj.task.check_type == CourseTaskCheckType.WITH_REVIEW
68+
and changed_fields & REVIEW_PROGRESS_FIELDS
69+
):
70+
recalculate_user_progresses_for_lesson(obj.user, obj.task.lesson)
71+
4772

4873
@admin.register(UserTaskAnswerOption)
4974
class UserTaskAnswerOptionAdmin(admin.ModelAdmin):

courses/admin_config/forms.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,8 @@ def clean(self):
117117
"image_upload",
118118
"В поле изображения можно загрузить только файл изображения.",
119119
)
120+
# TODO: убрать временные флаги, когда upload -> UserFile будет вынесен
121+
# в явный admin/service слой до запуска model validation.
120122
self.instance._has_pending_image_upload = bool(image_upload)
121123
self.instance._has_pending_attachment_upload = bool(attachment_upload)
122124
return cleaned_data

courses/api/response.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from rest_framework import serializers
2+
3+
4+
def serialize_response(
5+
serializer_class: type[serializers.Serializer],
6+
payload,
7+
*,
8+
many: bool = False,
9+
):
10+
serializer = serializer_class(data=payload, many=many)
11+
serializer.is_valid(raise_exception=True)
12+
return serializer.data

courses/api/views/course_read.py

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
CourseDetailSerializer,
66
CourseStructureSerializer,
77
)
8+
from courses.api.response import serialize_response
89
from courses.queries import (
910
build_course_detail_payload,
1011
build_course_list_payload,
@@ -17,29 +18,32 @@
1718
class CourseListAPIView(AuthenticatedCourseAPIView):
1819

1920
def get(self, request):
20-
serializer = CourseCardSerializer(
21-
data=build_course_list_payload(request.user),
22-
many=True,
21+
return Response(
22+
serialize_response(
23+
CourseCardSerializer,
24+
build_course_list_payload(request.user),
25+
many=True,
26+
)
2327
)
24-
serializer.is_valid(raise_exception=True)
25-
return Response(serializer.data)
2628

2729

2830
class CourseDetailAPIView(AuthenticatedCourseAPIView):
2931

3032
def get(self, request, pk: int):
31-
serializer = CourseDetailSerializer(
32-
data=build_course_detail_payload(request.user, pk)
33+
return Response(
34+
serialize_response(
35+
CourseDetailSerializer,
36+
build_course_detail_payload(request.user, pk),
37+
)
3338
)
34-
serializer.is_valid(raise_exception=True)
35-
return Response(serializer.data)
3639

3740

3841
class CourseStructureAPIView(AuthenticatedCourseAPIView):
3942

4043
def get(self, request, pk: int):
41-
serializer = CourseStructureSerializer(
42-
data=build_course_structure_payload(request.user, pk)
44+
return Response(
45+
serialize_response(
46+
CourseStructureSerializer,
47+
build_course_structure_payload(request.user, pk),
48+
)
4349
)
44-
serializer.is_valid(raise_exception=True)
45-
return Response(serializer.data)

courses/api/views/lesson_read.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from rest_framework.response import Response
22

3+
from courses.api.response import serialize_response
34
from courses.api.serializers import LessonDetailSerializer
45
from courses.queries import build_lesson_detail_payload
56

@@ -9,8 +10,9 @@
910
class LessonDetailAPIView(AuthenticatedCourseAPIView):
1011

1112
def get(self, request, pk: int):
12-
serializer = LessonDetailSerializer(
13-
data=build_lesson_detail_payload(request.user, pk)
13+
return Response(
14+
serialize_response(
15+
LessonDetailSerializer,
16+
build_lesson_detail_payload(request.user, pk),
17+
)
1418
)
15-
serializer.is_valid(raise_exception=True)
16-
return Response(serializer.data)

courses/services/answers.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,15 +61,15 @@ def _resolve_task_options(
6161
return options
6262

6363

64-
def _resolve_user_files(file_ids: list[str]) -> list[UserFile]:
64+
def _resolve_user_files(user, file_ids: list[str]) -> list[UserFile]:
6565
if not file_ids:
6666
return []
6767

6868
unique_ids = list(dict.fromkeys(file_ids))
6969
if len(unique_ids) != len(file_ids):
7070
raise ValidationError({"file_ids": "Переданы дублирующиеся файлы."})
7171

72-
files = list(UserFile.objects.filter(pk__in=unique_ids))
72+
files = list(UserFile.objects.filter(pk__in=unique_ids, user=user))
7373
files_by_id = {file.pk: file for file in files}
7474
missing_ids = [file_id for file_id in unique_ids if file_id not in files_by_id]
7575
if missing_ids:
@@ -305,11 +305,12 @@ def _validate_question_task(task: CourseTask) -> None:
305305

306306

307307
def _resolve_question_payload(
308+
user,
308309
task: CourseTask,
309310
payload: TaskAnswerSubmitPayload,
310311
) -> tuple[str, list[CourseTaskOption], list[UserFile]]:
311312
selected_options = _resolve_task_options(task, payload.option_ids)
312-
selected_files = _resolve_user_files(payload.file_ids)
313+
selected_files = _resolve_user_files(user, payload.file_ids)
313314
_validate_payload_by_answer_type(
314315
task,
315316
payload,
@@ -348,6 +349,7 @@ def _submit_question_answer(
348349
) -> SubmitAnswerResult:
349350
_validate_question_task(task)
350351
normalized_text, selected_options, selected_files = _resolve_question_payload(
352+
user,
351353
task,
352354
payload,
353355
)

courses/tests/helpers.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from dataclasses import dataclass
12
from datetime import date, timedelta
23
from uuid import uuid4
34

@@ -25,6 +26,14 @@
2526
from users.models import CustomUser
2627

2728

29+
@dataclass(frozen=True)
30+
class CourseTestContext:
31+
user: CustomUser
32+
course: Course
33+
module: CourseModule
34+
lesson: CourseLesson
35+
36+
2837
def unique_suffix() -> str:
2938
return uuid4().hex[:8]
3039

@@ -122,6 +131,25 @@ def create_lesson(
122131
)
123132

124133

134+
def create_course_context(
135+
*,
136+
user_prefix: str = "courses-test",
137+
course_title: str = "Course",
138+
module_title: str = "Module",
139+
lesson_title: str = "Lesson",
140+
) -> CourseTestContext:
141+
user = create_user(prefix=user_prefix)
142+
course = create_course(title=course_title)
143+
module = create_module(course, title=module_title)
144+
lesson = create_lesson(module, title=lesson_title)
145+
return CourseTestContext(
146+
user=user,
147+
course=course,
148+
module=module,
149+
lesson=lesson,
150+
)
151+
152+
125153
def create_informational_task(
126154
lesson: CourseLesson,
127155
*,

0 commit comments

Comments
 (0)