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
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
# Возможности организатора

Все действия организатора, доступные на фронте, должны дублироваться в Django admin.
Новые и изменяемые действия реализуйте в обоих интерфейсах через общую бизнес-логику
с одинаковыми проверками доступа и сохранности данных. Переход на фронт не заменяет
отсутствующее действие в админке. Все тексты интерфейса организатора и админки
должны быть на русском языке.

# Тексты интерфейса PROCOLLAB

Во всех новых и изменяемых текстах интерфейса используйте Е/е вместо U+0401/U+0451.
Expand Down
27 changes: 27 additions & 0 deletions assets/admin/partner_programs/admin_registration.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
.registration-admin { max-width: 1000px; }
.registration-admin .module { margin-bottom: 24px; padding-bottom: 16px; }
.registration-admin .module > p,
.registration-admin .module > form,
.registration-admin .module > label { margin: 12px 16px; }
.registration-admin .registration-status { padding: 0 16px 16px; }
.registration-admin .registration-status h2 { margin: 0 -16px 16px; }
.registration-admin input[type="url"] { width: min(95%, 660px); }
.registration-field { border: 1px solid var(--hairline-color); border-radius: 4px; margin: 16px; padding: 12px; }
.registration-field legend { padding: 0 8px; font-weight: 600; }
.registration-admin .form-row label { display: block; float: none; width: auto; margin-bottom: 6px; }
.registration-admin .form-row input[type="checkbox"] + label { display: inline; }
.registration-admin textarea,
.registration-admin input[type="text"] { width: min(95%, 660px); box-sizing: border-box; }
.registration-admin button.button { padding: 10px 15px; border: 0; cursor: pointer; margin: 4px; }
.registration-admin button.default { background: var(--default-button-bg); }
.registration-admin .errorlist { margin: 12px 0; }
.registration-preview-body { padding: 16px; max-width: 760px; border: 1px solid var(--hairline-color); }
.registration-preview-body.mobile { max-width: 360px; }
.registration-preview-body label { display: block; margin-bottom: 8px; }
.registration-preview-body input,
.registration-preview-body select,
.registration-preview-body textarea { max-width: 100%; }
@media (max-width: 600px) {
.registration-field { margin: 10px 0; padding: 8px; }
.registration-admin .submit-row { display: flex; flex-wrap: wrap; gap: 8px; }
}
77 changes: 77 additions & 0 deletions assets/admin/partner_programs/admin_registration.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"use strict";
document.addEventListener("DOMContentLoaded", () => {
const editor = document.getElementById("registration-editor");
let dirty = false;
editor?.addEventListener("input", () => { dirty = true; });
editor?.addEventListener("change", () => { dirty = true; });
editor?.addEventListener("submit", () => { dirty = false; });
window.addEventListener("beforeunload", event => {
if (dirty) {
event.preventDefault();
event.returnValue = "";
}
});
const add = document.getElementById("add-registration-field");
const total = document.getElementById("id_fields-TOTAL_FORMS");
function updateField(row, changed) {
const type = row.querySelector('[name$="-type"]');
const source = row.querySelector('[name$="-source"]');
const list = ["select", "multiselect"].includes(type.value);
if (changed === type && !list) {
source.value = "own";
row.querySelector('[name$="-allow_other"]').checked = false;
}
if ((changed === type && !list) || (changed === source && source.value !== "own")) {
row.querySelector('[name$="-options"]').value = "";
row.querySelector('[name$="-options_file"]').value = "";
}
for (const name of ["source", "options", "options_file", "allow_other"]) {
const ownOnly = ["options", "options_file"].includes(name);
row.querySelector(`[name$="-${name}"]`).closest(".form-row").hidden =
!list || (ownOnly && source.value !== "own");
}
}
document.querySelectorAll(".registration-field").forEach(row => updateField(row));
editor?.addEventListener("change", event => {
const row = event.target.closest(".registration-field");
if (row) updateField(row, event.target);
});
add?.addEventListener("click", event => {
event.preventDefault();
const count = Number(total.value);
if (count >= 100) {
alert("В анкете может быть не более 100 полей.");
return;
}
const template = document.getElementById("registration-empty-field");
const wrapper = document.createElement("div");
wrapper.innerHTML = template.innerHTML.replaceAll("__prefix__", String(count));
const row = wrapper.firstElementChild;
row.querySelector('[name$="-ORDER"]').value = count + 1;
document.querySelector("[data-empty-fields]")?.remove();
document.getElementById("registration-fields").append(row);
updateField(row);
total.value = count + 1;
dirty = true;
row.querySelector('[name$="-label"]').focus();
});
document.querySelector("[data-copy-link]")?.addEventListener("click", async () => {
const link = document.getElementById("registration-link");
const status = document.querySelector("[data-copy-status]");
try {
await navigator.clipboard.writeText(link.value);
status.textContent = "Ссылка скопирована.";
} catch {
link.focus();
link.select();
status.textContent = "Скопируйте выделенную ссылку.";
}
});
document.querySelector("[data-mobile-preview]")?.addEventListener("change", event => {
document.querySelector(".registration-preview-body").classList.toggle("mobile", event.target.checked);
});
const preview = document.getElementById("registration-preview");
if (preview) {
preview.focus();
}
});
18 changes: 18 additions & 0 deletions core/admin_language.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"""Русский интерфейс админки независимо от языка публичного API."""

from django.urls import reverse
from django.utils import translation


class AdminRussianLanguageMiddleware:
def __init__(self, get_response):
self.get_response = get_response

def __call__(self, request):
if not request.path_info.startswith(reverse("admin:index")):
return self.get_response(request)
with translation.override("ru"):
request.LANGUAGE_CODE = "ru"
response = self.get_response(request)
response.setdefault("Content-Language", "ru")
return response
31 changes: 31 additions & 0 deletions docs/modules/partner-programs.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,37 @@
- публикация проектов после окончания программы;
- новости программы через общий модуль `news`.

## Регистрация через админку

Откройте **Партнерские программы → Программы → нужная программа → Регистрация**.
Создатель новой программы в админке автоматически добавляется в ее менеджеры.
Для существующих программ менеджеров можно назначить в карточке программы.

В редакторе доступны те же настройки анкеты, что на фронте: встроенная или внешняя
регистрация, шесть типов вопросов, обязательность, подсказки, порядок, удаление,
свой список вариантов, справочники регионов и вузов, вариант «Другое», импорт
XLSX/CSV и предпросмотр на ширине компьютера или телефона. Редактировать JSON
для настройки анкеты не требуется.

**Сохранить черновик** сохраняет изменения без замены действующей формы.
**Опубликовать форму** открывает новую версию участникам и показывает ссылку.
У программы в черновике кнопка **Опубликовать программу и форму** проверяет
описание и сроки и публикует их вместе. При ошибке изменения не сохраняются
частично. Для поля с ответами требуется подтверждение архивирования; ответы
остаются в карточках участников и выгрузках.

Для внешнего сайта можно отправить запрос на подключение прямо здесь.
Администратор с правом изменения регистраций открывает **Настроить подключение
внешней формы**, указывает адрес и статус **Подключена**, после чего внешний
способ регистрации можно опубликовать. Сама ссылка не заменяет настройку интеграции.

Редактор находится по адресу `/admin/partner_programs/partnerprogram/<id>/registration/`
и использует права изменения программы и ограничения списка программ в админке.
Сохранение, публикация, проверка версии и импорт используют общие сервисы с API;
устаревшая страница не перезаписывает изменения другого организатора.
Стандартные элементы и сообщения Django admin отображаются на русском языке;
язык публичного API от этого не меняется.

## Архитектура

- `partner_programs/models.py` - модели программ, участников, материалов,
Expand Down
46 changes: 41 additions & 5 deletions partner_programs/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import tablib
from django import forms
from django.contrib import admin
from django.db import transaction
from . import solution_admin # noqa: F401
from projects.team_admin import TeamValidationAdminMixin
from django.db.models import QuerySet
Expand All @@ -27,6 +28,7 @@
ProgramFieldValueInlineFormSet,
)
from partner_programs.services.case_fields import case_field_has_values
from partner_programs.admin_registration import ProgramRegistrationAdminMixin


class PartnerProgramMaterialInline(admin.StackedInline):
Expand All @@ -44,7 +46,7 @@ class PartnerProgramFieldInline(admin.TabularInline):


@admin.register(PartnerProgram)
class PartnerProgramAdmin(admin.ModelAdmin):
class PartnerProgramAdmin(ProgramRegistrationAdminMixin, admin.ModelAdmin):
class PartnerProgramAdminForm(forms.ModelForm):
class Meta:
model = PartnerProgram
Expand Down Expand Up @@ -123,12 +125,23 @@ def get_queryset(self, request: HttpRequest) -> QuerySet[PartnerProgram]:
if "Руководитель программы" in request.user.groups.all().values_list(
"name", flat=True
):
user_programs: list[int] = request.user.expert.programs.values_list(
"id", flat=True
)
qs = qs.filter(id__in=user_programs)
expert = getattr(request.user, "expert", None)
user_programs = expert.programs.values_list("id", flat=True) if expert else []
from django.db.models import Q

qs = qs.filter(Q(id__in=user_programs) | Q(managers=request.user)).distinct()
return qs

def save_model(self, request, obj, form, change):
if not change:
obj.created_by = request.user
super().save_model(request, obj, form, change)

def save_related(self, request, form, formsets, change):
super().save_related(request, form, formsets, change)
if not change:
form.instance.managers.add(request.user)

def change_view(self, request, object_id, form_url="", extra_context=None):
if "Руководитель программы" in request.user.groups.all().values_list(
"name", flat=True
Expand Down Expand Up @@ -368,6 +381,7 @@ def get_deleted_objects(self, objs, request):

@admin.register(ProgramRegistration)
class ProgramRegistrationAdmin(admin.ModelAdmin):
change_form_template = "partner_programs/admin/registration_connection.html"
list_display = ("program", "external_status", "requested_at", "published_at")
list_filter = ("external_status",)
search_fields = (
Expand All @@ -389,6 +403,28 @@ class ProgramRegistrationAdmin(admin.ModelAdmin):
"connection_contact",
)

def get_queryset(self, request):
programs = self.admin_site._registry[PartnerProgram].get_queryset(request)
return super().get_queryset(request).filter(program__in=programs)

def has_change_permission(self, request, obj=None):
return super().has_change_permission(request, obj) and (
obj is None
or self.admin_site._registry[PartnerProgram].has_change_permission(
request, obj.program
)
)

@transaction.atomic
def save_model(self, request, obj, form, change):
# Подключение формы участвует в той же блокировке и версии, что редакторы анкеты.
PartnerProgram.objects.select_for_update().get(pk=obj.program_id)
current = ProgramRegistration.objects.select_for_update().get(pk=obj.pk)
for name in self.readonly_fields:
setattr(obj, name, getattr(current, name))
obj.revision = current.revision + 1
super().save_model(request, obj, form, change)

def has_add_permission(self, request):
return False

Expand Down
Loading
Loading