Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ coverage.xml
.hypothesis/
.pytest_cache/
.idea/
.codex

# Translations
*.mo
Expand Down
83 changes: 22 additions & 61 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,70 +1,31 @@
# Procollab backend service
# Procollab Backend

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

### Clone project
## Стек

📌 `git clone https://github.com/procollab-github/api.git`
- Python
- Django
- Django REST Framework
- Channels
- Celery
- PostgreSQL
- Redis

### Create virtual environment

🔑 Copy `.env.example` to `.env` and change api settings

### Install dependencies

* 🐍 Install poetry with command `pip install poetry`
* 📎 Install dependencies with command `poetry install`

### Accept migrations

🎓 Run `python manage.py migrate`

### Run project

🚀 Run project via `python manage.py runserver`
## For developers

### Install pre-commit hooks

To install pre-commit simply run inside the shell:
## Базовые команды

```bash
pre-commit install
```

To run it on all of your files, do

```bash
pre-commit run --all-files
```

## Troubleshooting

## Errors caused by weasyprint

### MacOS

Error:
```
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'
```

Fix:

```shell
brew install weasyprint
```

### Windows

Error:
poetry install
poetry run python manage.py migrate
poetry run python manage.py runserver
poetry run python manage.py test
```
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'
```

Fix:

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`

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

## [Docs](/docs/readme.md)
- [Навигация по документации](docs/readme.md)
- [Разработка](docs/development.md)
- [Архитектура](docs/architecture.md)
- [API](docs/api.md)
- [Инфраструктура и деплой](docs/devops-state.md)
- [Доменные модули](docs/modules/readme.md)
27 changes: 26 additions & 1 deletion courses/admin_config/answers.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,25 @@
from django.contrib import admin

from courses.models import UserTaskAnswer, UserTaskAnswerFile, UserTaskAnswerOption
from courses.models import (
CourseTaskCheckType,
UserTaskAnswer,
UserTaskAnswerFile,
UserTaskAnswerOption,
)
from courses.services.progress import recalculate_user_progresses_for_lesson

from .inlines import UserTaskAnswerFileInline, UserTaskAnswerOptionInline


REVIEW_PROGRESS_FIELDS = {
"status",
"is_correct",
"review_comment",
"reviewed_by",
"reviewed_at",
}


@admin.register(UserTaskAnswer)
class UserTaskAnswerAdmin(admin.ModelAdmin):
list_display = (
Expand Down Expand Up @@ -44,6 +59,16 @@ class UserTaskAnswerAdmin(admin.ModelAdmin):
)
inlines = [UserTaskAnswerOptionInline, UserTaskAnswerFileInline]

def save_model(self, request, obj, form, change):
super().save_model(request, obj, form, change)

changed_fields = set(getattr(form, "changed_data", []) or [])
if (
obj.task.check_type == CourseTaskCheckType.WITH_REVIEW
and changed_fields & REVIEW_PROGRESS_FIELDS
):
recalculate_user_progresses_for_lesson(obj.user, obj.task.lesson)


@admin.register(UserTaskAnswerOption)
class UserTaskAnswerOptionAdmin(admin.ModelAdmin):
Expand Down
2 changes: 2 additions & 0 deletions courses/admin_config/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ def clean(self):
"image_upload",
"В поле изображения можно загрузить только файл изображения.",
)
# TODO: убрать временные флаги, когда upload -> UserFile будет вынесен
# в явный admin/service слой до запуска model validation.
self.instance._has_pending_image_upload = bool(image_upload)
self.instance._has_pending_attachment_upload = bool(attachment_upload)
return cleaned_data
12 changes: 12 additions & 0 deletions courses/api/response.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from rest_framework import serializers


def serialize_response(
serializer_class: type[serializers.Serializer],
payload,
*,
many: bool = False,
):
serializer = serializer_class(data=payload, many=many)
serializer.is_valid(raise_exception=True)
return serializer.data
30 changes: 17 additions & 13 deletions courses/api/views/course_read.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
CourseDetailSerializer,
CourseStructureSerializer,
)
from courses.api.response import serialize_response
from courses.queries import (
build_course_detail_payload,
build_course_list_payload,
Expand All @@ -17,29 +18,32 @@
class CourseListAPIView(AuthenticatedCourseAPIView):

def get(self, request):
serializer = CourseCardSerializer(
data=build_course_list_payload(request.user),
many=True,
return Response(
serialize_response(
CourseCardSerializer,
build_course_list_payload(request.user),
many=True,
)
)
serializer.is_valid(raise_exception=True)
return Response(serializer.data)


class CourseDetailAPIView(AuthenticatedCourseAPIView):

def get(self, request, pk: int):
serializer = CourseDetailSerializer(
data=build_course_detail_payload(request.user, pk)
return Response(
serialize_response(
CourseDetailSerializer,
build_course_detail_payload(request.user, pk),
)
)
serializer.is_valid(raise_exception=True)
return Response(serializer.data)


class CourseStructureAPIView(AuthenticatedCourseAPIView):

def get(self, request, pk: int):
serializer = CourseStructureSerializer(
data=build_course_structure_payload(request.user, pk)
return Response(
serialize_response(
CourseStructureSerializer,
build_course_structure_payload(request.user, pk),
)
)
serializer.is_valid(raise_exception=True)
return Response(serializer.data)
10 changes: 6 additions & 4 deletions courses/api/views/lesson_read.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from rest_framework.response import Response

from courses.api.response import serialize_response
from courses.api.serializers import LessonDetailSerializer
from courses.queries import build_lesson_detail_payload

Expand All @@ -9,8 +10,9 @@
class LessonDetailAPIView(AuthenticatedCourseAPIView):

def get(self, request, pk: int):
serializer = LessonDetailSerializer(
data=build_lesson_detail_payload(request.user, pk)
return Response(
serialize_response(
LessonDetailSerializer,
build_lesson_detail_payload(request.user, pk),
)
)
serializer.is_valid(raise_exception=True)
return Response(serializer.data)
8 changes: 5 additions & 3 deletions courses/services/answers.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,15 +61,15 @@ def _resolve_task_options(
return options


def _resolve_user_files(file_ids: list[str]) -> list[UserFile]:
def _resolve_user_files(user, file_ids: list[str]) -> list[UserFile]:
if not file_ids:
return []

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

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


def _resolve_question_payload(
user,
task: CourseTask,
payload: TaskAnswerSubmitPayload,
) -> tuple[str, list[CourseTaskOption], list[UserFile]]:
selected_options = _resolve_task_options(task, payload.option_ids)
selected_files = _resolve_user_files(payload.file_ids)
selected_files = _resolve_user_files(user, payload.file_ids)
_validate_payload_by_answer_type(
task,
payload,
Expand Down Expand Up @@ -348,6 +349,7 @@ def _submit_question_answer(
) -> SubmitAnswerResult:
_validate_question_task(task)
normalized_text, selected_options, selected_files = _resolve_question_payload(
user,
task,
payload,
)
Expand Down
43 changes: 43 additions & 0 deletions courses/tests/test_answers.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
from types import SimpleNamespace

from django.contrib import admin
from django.test import TestCase
from django.test import RequestFactory
from django.utils import timezone

from courses.admin_config.answers import UserTaskAnswerAdmin
from courses.models import UserTaskAnswer, UserTaskAnswerStatus
from courses.services.answers import TaskAnswerSubmitPayload, submit_user_task_answer

Expand Down Expand Up @@ -68,3 +74,40 @@ def test_submit_text_question_with_review_blocks_continue(self):
self.assertIsNone(answer.is_correct)
self.assertFalse(result.can_continue)
self.assertIsNone(result.next_task_id)

def test_admin_review_recalculates_progress_after_accept(self):
reviewer = create_user(prefix="reviewer")
question_task = create_text_question_task(
self.lesson,
order=1,
check_type="with_review",
)
submit_user_task_answer(
self.user,
question_task,
TaskAnswerSubmitPayload(answer_text="ok"),
)
answer = UserTaskAnswer.objects.get(user=self.user, task=question_task)
request = RequestFactory().post("/")
request.user = reviewer
form = SimpleNamespace(
changed_data=["status", "is_correct", "reviewed_by", "reviewed_at"]
)

answer.status = UserTaskAnswerStatus.ACCEPTED
answer.is_correct = True
answer.reviewed_by = reviewer
answer.reviewed_at = timezone.now()
UserTaskAnswerAdmin(UserTaskAnswer, admin.site).save_model(
request,
answer,
form,
change=True,
)

lesson_progress = self.lesson.user_progresses.get(user=self.user)
module_progress = self.module.user_progresses.get(user=self.user)
course_progress = self.course.user_progresses.get(user=self.user)
self.assertEqual(lesson_progress.percent, 100)
self.assertEqual(module_progress.percent, 100)
self.assertEqual(course_progress.percent, 100)
16 changes: 16 additions & 0 deletions courses/tests/test_api_extended.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,16 @@ def test_files_and_text_and_files_flow_validates_payload_and_completes_course(se
"vnd.openxmlformats-officedocument.presentationml.presentation"
),
)
other_user = create_user(prefix="other-file-owner")
other_user_file = create_user_file(
other_user,
name="foreign-model",
extension="xlsx",
mime_type=(
"application/"
"vnd.openxmlformats-officedocument.spreadsheetml.sheet"
),
)

files_task = create_files_question_task(
lesson,
Expand All @@ -194,6 +204,11 @@ def test_files_and_text_and_files_flow_validates_payload_and_completes_course(se
{"file_ids": ["https://cdn.example.com/missing/file.pdf"]},
format="json",
)
foreign_file_response = self.client.post(
f"/courses/tasks/{files_task.id}/answer/",
{"file_ids": [other_user_file.pk]},
format="json",
)
files_response = self.client.post(
f"/courses/tasks/{files_task.id}/answer/",
{"file_ids": [answer_file_1.pk]},
Expand All @@ -216,6 +231,7 @@ def test_files_and_text_and_files_flow_validates_payload_and_completes_course(se
course_detail = self.client.get(f"/courses/{course.id}/").json()

self.assertEqual(invalid_file_response.status_code, 400)
self.assertEqual(foreign_file_response.status_code, 400)
self.assertEqual(files_response.status_code, 200)
self.assertTrue(files_response.json()["can_continue"])
self.assertEqual(invalid_text_and_files_response.status_code, 400)
Expand Down
Loading
Loading