Skip to content
Merged
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
12 changes: 9 additions & 3 deletions core/log/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from django.conf import settings
from loguru import logger

from core.log.utils import InterceptHandler
from core.log.utils import InterceptHandler, redact_expert_invitation_token


def _add_logger_handler(path: str, level: str) -> None:
Expand Down Expand Up @@ -32,8 +32,14 @@ def __init__(self, get_response):

def __call__(self, request):
response = self.get_response(request)
logger.info(f"{request.method} {request.get_full_path()}")
logger.info(
redact_expert_invitation_token(f"{request.method} {request.get_full_path()}")
)
return response

def process_exception(self, request, exception):
logger.warning(f"{exception} http_path={request.get_full_path()}")
logger.warning(
redact_expert_invitation_token(
f"{exception} http_path={request.get_full_path()}"
)
)
11 changes: 10 additions & 1 deletion core/log/utils.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import logging
import sys
import re
from loguru import logger


def redact_expert_invitation_token(message):
return re.sub(
r"(/evaluation/invitations/)[A-Za-z0-9_-]{20,100}", r"\1[redacted]", message
)


class InterceptHandler(logging.Handler):
def emit(self, record):
try:
Expand All @@ -15,4 +22,6 @@ def emit(self, record):
frame = frame.f_back
depth += 1

logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
logger.opt(depth=depth, exception=record.exc_info).log(
level, redact_expert_invitation_token(record.getMessage())
)
2 changes: 1 addition & 1 deletion deploy/nginx/host/dev/dev.procollab.ru
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ server {

client_max_body_size 100M;

location ~ ^/(admin|api-auth|files|industries|news|projects|vacancies|core|invites|auth|chats|events|programs|courses|rate-project|feed|api|anymail|ws)(/|$) {
location ~ ^/(admin|api-auth|files|industries|news|projects|vacancies|core|invites|auth|chats|events|programs|courses|rate-project|evaluation|feed|api|anymail|ws)(/|$) {
include /etc/nginx/procollab/includes/proxy_app.inc;
}

Expand Down
3 changes: 3 additions & 0 deletions docker-compose.dev-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ services:
- .env
environment:
HOST: 0.0.0.0
EXPERT_WORKSPACE_FRONTEND_URL: https://dev.procollab.ru
ports:
- "127.0.0.1:8000:8000"

Expand All @@ -31,6 +32,8 @@ services:
image: ${DEPLOY_IMAGE:-procollab-dev-api:dev}
env_file:
- .env
environment:
EXPERT_WORKSPACE_FRONTEND_URL: https://dev.procollab.ru
command: bash ./scripts/celery.sh
depends_on:
- redis
Expand Down
107 changes: 107 additions & 0 deletions docs/expert-workspace.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# Эксперты программы и личное оценивание

Организатор приглашает экспертов по email и назначает им сданные проекты. После
явного принятия приглашения эксперт видит программу и свои назначения в кабинете.
Основание доступа — `Expert.programs`, независимо от основной роли пользователя.
Организатор не получает право оценивать автоматически.

## Контракт

Все organizer endpoints ниже требуют менеджера **конкретной** программы:

| Метод и путь относительно `/programs/{id}/management/` | Назначение |
| --- | --- |
| GET `experts/` | Состав, приглашения, нагрузка, N, нехватка и revision |
| POST `experts/` | Пригласить `{email}` |
| POST `expert-invitations/{id}/resend/` | Новая ссылка, прежняя недействительна |
| POST `expert-invitations/{id}/revoke/` | Отозвать непринятое приглашение |
| GET `experts/{expert_id}/assignments/` | Проекты с assigned/protected/own_team |
| PUT `experts/{expert_id}/assignments/` | Заменить набор `{revision, project_ids}` |
| POST `expert-allocation/` | Предпросмотр `{expert_ids, project_ids?}` |
| POST `expert-allocation/apply/` | Применить подписанный `{plan_token}` |
| POST `evaluators/` | Предпросмотр `{required_evaluators}` |
| PUT `evaluators/` | Подтвердить `{required_evaluators, revision}` |

`GET /evaluation/programs/` возвращает только программы пользователя, его счётчики
assigned/completed/remaining и действующие приглашения на его email.
`POST /evaluation/own-invitations/{id}/accept/` явно принимает своё приглашение.

`GET /evaluation/invitations/{token}/` возвращает контекст ссылки, фиксированный
email, account_exists, login_email и matches_account. Записей при GET нет.
`POST .../{token}/accept/` требует входа в активный аккаунт с этим email.
`POST .../{token}/register/` принимает first_name, last_name, password,
repeat_password, accept_policy, accept_age и атомарно создаёт аккаунт, членство
и acceptance; возвращает access/refresh/program_id. Email задаётся приглашением.

Ошибки: 400 — невалидный ввод, 403 — нет прав/другая почта, 404 — нет объекта,
409 — дубликат, устаревший revision/plan или защищённая оценка, 410 — ссылка
истекла/отозвана. Повторное acceptance тем же аккаунтом безопасно.

## Правила

- Pending не является экспертом и не участвует в распределении.
- Новые назначения — только принявшим приглашение активным экспертам, на сданные
проекты своей программы; собственная команда исключена.
- Любая сохранённая оценка защищает назначение от удаления, включая старые
неполные наборы. Участие в команде, назначения и оценки входят в revision.
- Автораспределение заполняет дефицит до N, сохраняя существующие назначения,
и балансирует незавершённую нагрузку. Предпросмотр ничего не записывает.
Нехватка кандидатов блокирует применение; план действует 15 минут.
- Все новые записи организатора и сохранение оценок используют одну блокировку
строки программы. Конкурирующая запись отклоняется целиком с 409.
- N — минимум полных оценок, а не максимум. Изменять его можно в любое время;
готовность пересчитывается, оценки и лишние назначения сохраняются.
- Управляемая форма оценки сохраняется целиком: целые баллы и Да/нет обязательны,
комментарий необязателен. 0 и False — допустимые ответы. Дедлайн, назначение,
сданное решение и запрет оценки своей команды проверяются сервером.
- Первая сохранённая оценка продолжает блокировать критерии.

## Совместимость и rollout

Добавлены `PartnerProgram.required_evaluators`, `ExpertInvitation` и nullable
`CustomUser.birthday`: migrations partner_programs.0022, project_rates.0004,
users.0065. Обычная регистрация продолжает требовать дату рождения; обычный
профиль не может удалить её. Только новый приглашённый эксперт создаётся без даты,
без фиктивного значения, повторного подтверждения email и обязательного onboarding.

Legacy rating endpoints/payload и open mode на backend сохраняются. Явное
приглашение, ручное назначение, применение распределения или подтверждение N
включает управляемый режим программы; назначения за историческими оценками
восстанавливаются без изменения самих оценок. `max_project_rates` остаётся
legacy максимумом. Новый Angular запрашивает `assigned_only=1`: он показывает
свои назначения, поэтому организатору нужно распределить проекты существующим
экспертам перед использованием нового кабинета.

Сначала backend/migrations, затем Angular. DEV использует существующее отдельное
migration connection из Deploy Dev; runtime остаётся под application role.
Откат UI возможен независимо; откат nullable birthday после создания приглашённых
аккаунтов требует отдельной обработки данных, обратную миграцию вслепую не запускать.

## Письма

Ссылка имеет случайный bearer token, в БД хранится SHA-256 digest. Срок — 7 дней;
повторная отправка заменяет token. `EXPERT_WORKSPACE_FRONTEND_URL` задаёт разрешённый
frontend origin (DEV: `https://dev.procollab.ru`) для Web и Celery. Нельзя передавать
произвольный return URL. `queued/sent/failed` описывает передачу почтовому backend,
а не подтверждение доставки в inbox. Используется существующий Celery/mail provider.

В `/auth/reset_password/` можно передать `expert_invitation_token`. Для действующей
ссылки, соответствующей email, reset email ведёт на `/expert-password-reset/` настроенного frontend с контекстом
приглашения. После смены пароля — вход и отдельное acceptance. Общий reset не менялся.
Private ответы не кешируются; invitation token скрывается application HTTP logger.

На DEV Nginx направляет `/evaluation/` в API. `/expert-invitation/` и
`/expert-password-reset/` остаются SPA routes; отдельный reset route обходит
server-side `/auth/`, который на совмещённом DEV-host принадлежит API.

## Проверяемые требования

AC1 invite/resend/revoke, scope, duplicate и доставка; AC2 signup без даты с сохранением
обычной регистрации; AC3 login, wrong account, expiry/replay/reset и гонки; AC4 manual
scope/своя команда/защищённые оценки; AC5 preview/balance/stale apply; AC6 N и полная
оценка; AC7 личные программы и роли; AC8 центрированные адаптивные состояния на UI;
AC9 права при прямом запросе, миграции и регресс.

Backend coverage: `project_rates.tests.test_workspace` и
`project_rates.tests.test_workspace_concurrency` (гонки требуют PostgreSQL).
UI/Figma и результаты сборки описаны в связанном Angular PR.
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 4.2.11 on 2026-10-18 00:28

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('partner_programs', '0021_evaluation_criteria_lock'),
]

operations = [
migrations.AddField(
model_name='partnerprogram',
name='required_evaluators',
field=models.PositiveIntegerField(blank=True, help_text='Минимум полных оценок. NULL сохраняет прежний режим оценивания.', null=True, verbose_name='Обязательное число оценщиков на проект'),
),
]
6 changes: 6 additions & 0 deletions partner_programs/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,12 @@ class PartnerProgram(models.Model):
"Если включено, проекты для оценки доступны только назначенным экспертам"
),
)
required_evaluators = models.PositiveIntegerField(
null=True,
blank=True,
verbose_name="Обязательное число оценщиков на проект",
help_text="Минимум полных оценок. NULL сохраняет прежний режим оценивания.",
)
evaluation_criteria_locked_at = models.DateTimeField(null=True, editable=False)
evaluation_criteria_version = models.PositiveIntegerField(default=0, editable=False)
data_schema = models.JSONField(
Expand Down
30 changes: 30 additions & 0 deletions partner_programs/serializers/programs.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,12 +96,38 @@ class PartnerProgramBaseSerializerMixin(serializers.ModelSerializer):

materials = serializers.SerializerMethodField()
is_user_manager = serializers.SerializerMethodField()
is_user_expert = serializers.SerializerMethodField()
pending_expert_invitation_id = serializers.SerializerMethodField()
courses = serializers.SerializerMethodField()

def get_materials(self, program: PartnerProgram):
materials = program.materials.all()
return PartnerProgramMaterialSerializer(materials, many=True).data

def get_is_user_expert(self, program):
user = self.context.get("user") or getattr(
self.context.get("request"), "user", None
)
return bool(
user and user.is_authenticated and program.experts.filter(user=user).exists()
)

def get_pending_expert_invitation_id(self, program):
from django.utils import timezone

user = self.context.get("user") or getattr(
self.context.get("request"), "user", None
)
if not user or not user.is_authenticated or not user.is_active:
return None
return (
program.expert_invitations.filter(
email__iexact=user.email, status="pending", expires_at__gt=timezone.now()
)
.values_list("pk", flat=True)
.first()
)

def get_is_user_manager(self, program: PartnerProgram) -> bool:
user = self.context.get("user")
return bool(user and program.is_manager(user))
Expand Down Expand Up @@ -193,6 +219,8 @@ class Meta:
"legacy_team_max_size",
"is_user_manager",
"welcome_acknowledged_at",
"is_user_expert",
"pending_expert_invitation_id",
"courses",
)

Expand Down Expand Up @@ -222,6 +250,8 @@ class Meta:
"legacy_team_min_size",
"legacy_team_max_size",
"is_user_manager",
"is_user_expert",
"pending_expert_invitation_id",
"courses",
)

Expand Down
4 changes: 3 additions & 1 deletion partner_programs/services/analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,9 @@ def _solution_rows(program, *, rows=None):
then=Value("awaiting_evaluation"),
),
When(
assignments_completed__lt=F("assignments_total"),
assignments_completed__lt=(
program.required_evaluators or F("assignments_total")
),
then=Value("partially_evaluated"),
),
default=Value("evaluated"),
Expand Down
21 changes: 21 additions & 0 deletions partner_programs/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,30 @@
ManagementMaterialDetailView,
)

from project_rates.workspace_views import (
ExpertsView,
InvitationActionView,
ExpertAssignmentsView,
AllocationView,
AllocationApplyView,
EvaluatorsView,
)

app_name = "partner_programs"

urlpatterns = [
path("<int:pk>/management/experts/", ExpertsView.as_view()),
path(
"<int:pk>/management/expert-invitations/<int:invitation_id>/<str:action>/",
InvitationActionView.as_view(),
),
path(
"<int:pk>/management/experts/<int:expert_id>/assignments/",
ExpertAssignmentsView.as_view(),
),
path("<int:pk>/management/expert-allocation/", AllocationView.as_view()),
path("<int:pk>/management/expert-allocation/apply/", AllocationApplyView.as_view()),
path("<int:pk>/management/evaluators/", EvaluatorsView.as_view()),
path(
"<int:pk>/management/criteria/",
ManagementCriteriaView.as_view(),
Expand Down
3 changes: 3 additions & 0 deletions procollab/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
SECRET_KEY = config("DJANGO_SECRET_KEY", default="django-default-secret-key", cast=str)

DEBUG = config("DEBUG", default=False, cast=bool)
EXPERT_WORKSPACE_FRONTEND_URL = config(
"EXPERT_WORKSPACE_FRONTEND_URL", default="https://app.procollab.ru"
)

AUTOPOSTING_ON = config("AUTOPOSTING_ON", default=False, cast=bool)

Expand Down
1 change: 1 addition & 0 deletions procollab/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
path("programs/", include("partner_programs.urls", namespace="partner_programs")),
path("courses/", include("courses.urls", namespace="courses")),
path("rate-project/", include(("project_rates.urls", "rate_projects"))),
path("evaluation/", include("project_rates.workspace_urls")),
path("feed/", include("feed.urls", namespace="feed")),
path("api/token/", TokenObtainPairView.as_view(), name="token_obtain_pair"),
path("api/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
Expand Down
40 changes: 40 additions & 0 deletions project_rates/migrations/0004_expertinvitation_and_more.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Generated by Django 4.2.11 on 2026-10-18 00:28

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('partner_programs', '0022_partnerprogram_required_evaluators'),
('project_rates', '0003_projectexpertassignment'),
]

operations = [
migrations.CreateModel(
name='ExpertInvitation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('email', models.EmailField(max_length=254)),
('token_digest', models.CharField(max_length=64, unique=True)),
('status', models.CharField(choices=[('pending', 'pending'), ('accepted', 'accepted'), ('revoked', 'revoked')], default='pending', max_length=12)),
('delivery_status', models.CharField(choices=[('queued', 'queued'), ('sent', 'sent'), ('failed', 'failed')], default='queued', max_length=12)),
('expires_at', models.DateTimeField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('accepted_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='accepted_expert_invitations', to=settings.AUTH_USER_MODEL)),
('invited_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sent_expert_invitations', to=settings.AUTH_USER_MODEL)),
('partner_program', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='expert_invitations', to='partner_programs.partnerprogram')),
],
options={
'indexes': [models.Index(fields=['email', 'status'], name='project_rat_email_f32dd6_idx')],
},
),
migrations.AddConstraint(
model_name='expertinvitation',
constraint=models.UniqueConstraint(condition=models.Q(('status', 'pending')), fields=('partner_program', 'email'), name='unique_pending_expert_program_email'),
),
]
Loading
Loading