Skip to content

Commit 2c6e31a

Browse files
authored
Merge pull request #649 from PROCOLLAB-github/dev
Dev
2 parents f86461a + 944eaa1 commit 2c6e31a

184 files changed

Lines changed: 13698 additions & 3213 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/dev-ci.yml

Lines changed: 44 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,23 @@ jobs:
5353
docker compose -f docker-compose.dev-ci.yml build web &&
5454
docker compose -f docker-compose.dev-ci.yml run --rm web python manage.py migrate &&
5555
docker compose -f docker-compose.dev-ci.yml up -d --force-recreate &&
56+
expected_image="procollab-dev-api:${IMAGE_TAG}" &&
57+
for service in web celerys; do
58+
container="$(docker compose -f docker-compose.dev-ci.yml ps -q "$service")"
59+
if [ -z "$container" ]; then
60+
echo "Service ${service} has no running container" >&2
61+
docker compose -f docker-compose.dev-ci.yml ps >&2 || true
62+
exit 1
63+
fi
64+
65+
actual_image="$(docker inspect -f '{{.Config.Image}}' "$container")"
66+
echo "Service ${service}: container=${container} image=${actual_image}"
67+
if [ "$actual_image" != "$expected_image" ]; then
68+
echo "Service ${service} uses unexpected image: ${actual_image}, expected ${expected_image}" >&2
69+
docker compose -f docker-compose.dev-ci.yml ps >&2 || true
70+
exit 1
71+
fi
72+
done &&
5673
5774
install -d /etc/nginx/procollab/includes &&
5875
install -m 644 deploy/nginx/host/includes/proxy_app.inc /etc/nginx/procollab/includes/proxy_app.inc &&
@@ -83,28 +100,37 @@ jobs:
83100
exit 1
84101
fi &&
85102
86-
celery_status="" &&
87-
celery_ping="" &&
88-
for attempt in $(seq 1 24); do
89-
celery_status="$(docker inspect -f '{{.State.Status}}' api_celery 2>/dev/null || true)" &&
103+
docker compose -f docker-compose.dev-ci.yml ps
104+
105+
celery_status=""
106+
celery_ping=""
107+
celery_container=""
108+
for attempt in $(seq 1 12); do
109+
celery_container="$(docker compose -f docker-compose.dev-ci.yml ps -q celerys 2>/dev/null || true)"
110+
if [ -n "$celery_container" ]; then
111+
celery_status="$(docker inspect -f '{{.State.Status}}' "$celery_container" 2>/dev/null || true)"
112+
else
113+
celery_status="missing"
114+
fi
115+
116+
echo "Celery check attempt ${attempt}: container=${celery_container:-missing} status=${celery_status}"
90117
if [ "$celery_status" = "running" ]; then
91-
celery_ping="$(docker compose -f docker-compose.dev-ci.yml exec -T celerys sh -lc 'celery -A procollab inspect ping --timeout=10' 2>&1 || true)" &&
92-
printf '%s\n' "$celery_ping" &&
118+
celery_ping="$(docker compose -f docker-compose.dev-ci.yml exec -T celerys sh -lc 'celery -A procollab inspect ping --timeout=15' 2>&1 || true)"
119+
printf '%s\n' "$celery_ping"
93120
if printf '%s\n' "$celery_ping" | grep -q 'pong'; then
94-
echo "Celery check passed on attempt ${attempt}" &&
121+
echo "Celery check passed on attempt ${attempt}"
95122
break
96123
fi
97-
fi &&
124+
fi
98125
99126
sleep 5
100-
done &&
101-
102-
if [ "$celery_status" != "running" ]; then
103-
echo "Celery container is not running: ${celery_status}" >&2 &&
104-
exit 1
105-
fi &&
106-
107-
printf '%s\n' "$celery_ping" | grep -q 'pong' || {
108-
echo "Celery ping failed" >&2
127+
done
128+
129+
if [ "$celery_status" != "running" ] || ! printf '%s\n' "$celery_ping" | grep -q 'pong'; then
130+
echo "Celery check failed: status=${celery_status}" >&2
131+
docker compose -f docker-compose.dev-ci.yml ps >&2 || true
132+
docker compose -f docker-compose.dev-ci.yml logs --tail=200 celerys >&2 || true
133+
docker compose -f docker-compose.dev-ci.yml logs --tail=100 redis >&2 || true
134+
docker compose -f docker-compose.dev-ci.yml logs --tail=100 web >&2 || true
109135
exit 1
110-
}
136+
fi

.github/workflows/release-ci.yml

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,24 @@ jobs:
184184
docker compose -f docker-compose.prod-ci.yml -p prod pull web celerys
185185
186186
docker compose -f docker-compose.prod-ci.yml -p prod run --rm web python manage.py migrate
187-
docker compose -f docker-compose.prod-ci.yml -p prod up -d
187+
docker compose -f docker-compose.prod-ci.yml -p prod up -d --force-recreate
188+
expected_image="ghcr.io/procollab-github/api:${IMAGE_TAG}"
189+
for service in web celerys; do
190+
container="$(docker compose -f docker-compose.prod-ci.yml -p prod ps -q "$service")"
191+
if [ -z "$container" ]; then
192+
echo "Service ${service} has no running container" >&2
193+
docker compose -f docker-compose.prod-ci.yml -p prod ps >&2 || true
194+
exit 1
195+
fi
196+
197+
actual_image="$(docker inspect -f '{{.Config.Image}}' "$container")"
198+
echo "Service ${service}: container=${container} image=${actual_image}"
199+
if [ "$actual_image" != "$expected_image" ]; then
200+
echo "Service ${service} uses unexpected image: ${actual_image}, expected ${expected_image}" >&2
201+
docker compose -f docker-compose.prod-ci.yml -p prod ps >&2 || true
202+
exit 1
203+
fi
204+
done
188205
if [ "$(id -u)" -eq 0 ]; then
189206
nginx -t
190207
systemctl reload nginx
@@ -210,12 +227,22 @@ jobs:
210227
exit 1
211228
fi
212229
230+
docker compose -f docker-compose.prod-ci.yml -p prod ps
231+
213232
celery_status=""
214233
celery_ping=""
215-
for attempt in $(seq 1 24); do
216-
celery_status="$(docker inspect -f '{{.State.Status}}' api_celery 2>/dev/null || true)"
234+
celery_container=""
235+
for attempt in $(seq 1 12); do
236+
celery_container="$(docker compose -f docker-compose.prod-ci.yml -p prod ps -q celerys 2>/dev/null || true)"
237+
if [ -n "$celery_container" ]; then
238+
celery_status="$(docker inspect -f '{{.State.Status}}' "$celery_container" 2>/dev/null || true)"
239+
else
240+
celery_status="missing"
241+
fi
242+
243+
echo "Celery check attempt ${attempt}: container=${celery_container:-missing} status=${celery_status}"
217244
if [ "$celery_status" = "running" ]; then
218-
celery_ping="$(docker compose -f docker-compose.prod-ci.yml -p prod exec -T celerys sh -lc 'celery -A procollab inspect ping --timeout=10' 2>&1 || true)"
245+
celery_ping="$(docker compose -f docker-compose.prod-ci.yml -p prod exec -T celerys sh -lc 'celery -A procollab inspect ping --timeout=15' 2>&1 || true)"
219246
printf '%s\n' "$celery_ping"
220247
if printf '%s\n' "$celery_ping" | grep -q 'pong'; then
221248
echo "Celery check passed on attempt ${attempt}"
@@ -226,12 +253,11 @@ jobs:
226253
sleep 5
227254
done
228255
229-
if [ "$celery_status" != "running" ]; then
230-
echo "Celery container is not running: ${celery_status}" >&2
256+
if [ "$celery_status" != "running" ] || ! printf '%s\n' "$celery_ping" | grep -q 'pong'; then
257+
echo "Celery check failed: status=${celery_status}" >&2
258+
docker compose -f docker-compose.prod-ci.yml -p prod ps >&2 || true
259+
docker compose -f docker-compose.prod-ci.yml -p prod logs --tail=200 celerys >&2 || true
260+
docker compose -f docker-compose.prod-ci.yml -p prod logs --tail=100 redis >&2 || true
261+
docker compose -f docker-compose.prod-ci.yml -p prod logs --tail=100 web >&2 || true
231262
exit 1
232263
fi
233-
234-
printf '%s\n' "$celery_ping" | grep -q 'pong' || {
235-
echo "Celery ping failed" >&2
236-
exit 1
237-
}

.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)

0 commit comments

Comments
 (0)