diff --git a/docs/modules/partner-programs.md b/docs/modules/partner-programs.md index fb0738fc..973e2528 100644 --- a/docs/modules/partner-programs.md +++ b/docs/modules/partner-programs.md @@ -2,6 +2,10 @@ ## Назначение +Тег программы необязателен. При создании через фронт общий сервис сохраняет +пустую строку, если тег не задан; Django-админка также допускает пустой тег. +Он не входит в условия первой публикации. Существующие теги не изменяются. + Модуль `partner_programs` отвечает за партнерские программы: регистрацию участников, привязку проектов к программе, дополнительные поля заявки, сдачу проектов на проверку, фильтрацию и выгрузки проектов программы. diff --git a/docs/modules/users.md b/docs/modules/users.md index 89a2a4f1..b82d101c 100644 --- a/docs/modules/users.md +++ b/docs/modules/users.md @@ -47,6 +47,21 @@ Users отвечает за учетные записи и профиль пол ## Архитектура +### Право создания программ в админке + +В карточке пользователя, в блоке «Права доступа», галочка «Может создавать +программы» выдает или снимает личное разрешение +`partner_programs.add_partnerprogram`. Это то же разрешение, которое проверяет +фронт через `GET /programs/capabilities/`; отдельного флага в модели нет. +Остальные личные разрешения и доступ к админке галочка не изменяет. + +Если право получено через группу или статус суперпользователя, галочка отмечена +и недоступна для изменения, рядом указана причина. Сохранение такой карточки +не создает дополнительного личного разрешения. Для отзыва группового права +нужно изменить группы пользователя. Существующая личная выдача сохраняется. + +### Основные файлы + - `users/models.py` - модель пользователя, роли, достижения, ссылки, образование, опыт, языки и подтверждения навыков. - `users/views.py` - HTTP endpoints и orchestration logic для профиля, diff --git a/partner_programs/migrations/0042_partnerprogram_tag_optional.py b/partner_programs/migrations/0042_partnerprogram_tag_optional.py new file mode 100644 index 00000000..cfd79d59 --- /dev/null +++ b/partner_programs/migrations/0042_partnerprogram_tag_optional.py @@ -0,0 +1,15 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("partner_programs", "0041_registration_admin_russian"), + ] + + operations = [ + migrations.AlterField( + model_name="partnerprogram", + name="tag", + field=models.TextField(blank=True, verbose_name="Тег"), + ), + ] diff --git a/partner_programs/models.py b/partner_programs/models.py index 8419f4c0..d055a1fd 100644 --- a/partner_programs/models.py +++ b/partner_programs/models.py @@ -107,6 +107,7 @@ class PartnerProgram(models.Model): ) tag = models.TextField( verbose_name="Тег", + blank=True, ) description = models.TextField( null=True, diff --git a/partner_programs/permissions.py b/partner_programs/permissions.py index b28b38a5..46005509 100644 --- a/partner_programs/permissions.py +++ b/partner_programs/permissions.py @@ -5,12 +5,14 @@ from partner_programs.models import PartnerProgram +PROGRAM_CREATION_PERMISSION = "partner_programs.add_partnerprogram" + def can_create_program(user) -> bool: return bool( user and user.is_authenticated - and user.has_perm("partner_programs.add_partnerprogram") + and user.has_perm(PROGRAM_CREATION_PERMISSION) ) diff --git a/partner_programs/tests/test_optional_tag.py b/partner_programs/tests/test_optional_tag.py new file mode 100644 index 00000000..58ad87bf --- /dev/null +++ b/partner_programs/tests/test_optional_tag.py @@ -0,0 +1,52 @@ +"""Пустой тег допустим в модели, админке и настройках организатора.""" + +from django.test import TestCase, override_settings +from django.urls import reverse +from rest_framework.test import APIClient + +from partner_programs.models import PartnerProgram +from partner_programs.tests.helpers import create_user + + +@override_settings(STATICFILES_STORAGE="django.contrib.staticfiles.storage.StaticFilesStorage") +class OptionalProgramTagTests(TestCase): + def setUp(self): + self.admin = create_user(is_staff=True, is_superuser=True, is_active=True) + self.client.force_login(self.admin) + self.url = reverse("admin:partner_programs_partnerprogram_add") + + def payload(self): + page = self.client.get(self.url) + self.assertFalse(page.context["adminform"].form.fields["tag"].required) + data = { + "name": "Программа без тега", "tag": "", "description": "Описание", "city": "Москва", + "projects_availability": "all_users", "data_schema": '{"extra": {"name": "Поле"}}', + "draft": "on", "_save": "Сохранить", + } + for inline in page.context["inline_admin_formsets"]: + prefix = inline.formset.prefix + data.update({f"{prefix}-TOTAL_FORMS": 0, f"{prefix}-INITIAL_FORMS": 0}) + return data + + def test_create_in_admin_without_tag_and_edit_through_frontend_settings(self): + response = self.client.post(self.url, self.payload()) + errors = response.context["adminform"].form.errors if response.status_code == 200 else None + self.assertEqual(response.status_code, 302, errors) + program = PartnerProgram.objects.get(name="Программа без тега") + self.assertEqual(program.tag, "") + program.full_clean() + self.assertTrue(program.managers.filter(pk=self.admin.pk).exists()) + api = APIClient() + api.force_authenticate(self.admin) + saved = api.patch( + f"/programs/{program.pk}/management/settings/basic/", + {"tag": "", "expected_updated_at": program.datetime_updated.isoformat()}, format="json", + ) + self.assertEqual(saved.status_code, 200, saved.data) + + def test_existing_nonempty_tag_remains_unchanged(self): + data = self.payload() + data["tag"] = "существующий-тег" + self.assertEqual(self.client.post(self.url, data).status_code, 302) + program = PartnerProgram.objects.get(name=data["name"]) + self.assertEqual(program.tag, data["tag"]) diff --git a/users/admin.py b/users/admin.py index 4b79a6fb..ccfae127 100644 --- a/users/admin.py +++ b/users/admin.py @@ -14,6 +14,7 @@ from core.utils import XlsxFileToExport, build_xlsx_download_response from mailing.views import MailingTemplateRender from users.models import UserAchievementFile +from users.admin_forms import CustomUserAdminForm from users.services.users_activity import UserActivityDataPreparer from .helpers import force_verify_user, send_verification_completed_email @@ -75,6 +76,8 @@ def make_active(modeladmin, request, queryset): @admin.register(CustomUser) class CustomUserAdmin(admin.ModelAdmin): + form = CustomUserAdminForm + fieldsets = ( ( "Аккаунт", @@ -129,6 +132,7 @@ class CustomUserAdmin(admin.ModelAdmin): "fields": ( "is_staff", "is_superuser", + "can_create_programs", "groups", "user_permissions", ) diff --git a/users/admin_forms.py b/users/admin_forms.py new file mode 100644 index 00000000..4a72305c --- /dev/null +++ b/users/admin_forms.py @@ -0,0 +1,89 @@ +"""Настройки доступа пользователя в стандартной карточке администратора.""" + +from django import forms +from django.contrib.auth.models import Permission + +from partner_programs.permissions import PROGRAM_CREATION_PERMISSION +from users.models import CustomUser + + +class CustomUserAdminForm(forms.ModelForm): + can_create_programs = forms.BooleanField( + label="Может создавать программы", + required=False, + help_text="Разрешает создавать новые программы на платформе.", + ) + + class Meta: + model = CustomUser + fields = "__all__" + labels = { + "is_staff": "Доступ к админке", + "is_superuser": "Все права (суперпользователь)", + "groups": "Группы доступа", + "user_permissions": "Другие разрешения", + } + help_texts = { + "is_staff": "Разрешает вход в административную панель.", + "is_superuser": "Предоставляет все разрешения, включая создание программ.", + "groups": "Пользователь получает разрешения выбранных групп.", + "user_permissions": "Дополнительные личные разрешения пользователя.", + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + app_label, codename = PROGRAM_CREATION_PERMISSION.split(".") + self.creation_permission = Permission.objects.get( + content_type__app_label=app_label, codename=codename + ) + permission_id = self.creation_permission.pk + direct = False + inherited = False + if self.instance.pk: + direct = self.instance.user_permissions.filter(pk=permission_id).exists() + inherited = self.instance.groups.filter(permissions=permission_id).exists() + self.direct_creation_permission = direct + self.inherited_creation_permission = self.instance.is_superuser or inherited + checkbox = self.fields["can_create_programs"] + self.initial["can_create_programs"] = direct or self.inherited_creation_permission + if self.inherited_creation_permission: + checkbox.disabled = True + checkbox.help_text = ( + "Создание программ доступно благодаря статусу суперпользователя." + if self.instance.is_superuser + else "Право выдано через группу доступа. Чтобы его отозвать, измените группы пользователя." + ) + if "user_permissions" in self.fields: + # У одного разрешения один элемент управления; остальные права сохраняются штатно. + self.fields["user_permissions"].queryset = self.fields[ + "user_permissions" + ].queryset.exclude(pk=permission_id) + self.initial["user_permissions"] = ( + list( + self.instance.user_permissions.exclude(pk=permission_id).values_list( + "pk", flat=True + ) + ) + if self.instance.pk + else [] + ) + + def clean(self): + data = super().clean() + if "user_permissions" in data: + permissions = list(data["user_permissions"]) + # Группа или суперпользователь не должны превращаться в скрытую личную выдачу. + grant = ( + self.direct_creation_permission + if self.inherited_creation_permission + else data.get("can_create_programs", False) + ) + if grant: + permissions.append(self.creation_permission) + data["user_permissions"] = permissions + return data + + def _save_m2m(self): + super()._save_m2m() + for cache in ("_perm_cache", "_user_perm_cache", "_group_perm_cache"): + self.instance.__dict__.pop(cache, None) diff --git a/users/tests/test_program_creation_permission_admin.py b/users/tests/test_program_creation_permission_admin.py new file mode 100644 index 00000000..a31af625 --- /dev/null +++ b/users/tests/test_program_creation_permission_admin.py @@ -0,0 +1,177 @@ +"""Выдача отдельного права из карточки пользователя и его использование на фронте.""" + +from datetime import date, datetime + +from django import forms +from django.contrib.auth.models import Group, Permission +from django.test import TestCase, override_settings +from django.urls import reverse +from rest_framework.test import APIClient + +from users.models import CustomUser +from users.tests.helpers import build_specialization, build_superuser, build_user + + +@override_settings(STATICFILES_STORAGE="django.contrib.staticfiles.storage.StaticFilesStorage") +class ProgramCreationPermissionAdminTests(TestCase): + def setUp(self): + self.admin = build_superuser() + self.user = build_user(v2_speciality=build_specialization()) + self.permission = Permission.objects.get( + content_type__app_label="partner_programs", codename="add_partnerprogram" + ) + self.other = Permission.objects.get( + content_type__app_label="auth", codename="view_permission" + ) + self.user.user_permissions.add(self.other) + self.client.force_login(self.admin) + self.url = reverse("admin:users_customuser_change", args=[self.user.pk]) + + def page_and_payload(self): + page = self.client.get(self.url) + self.assertEqual(page.status_code, 200) + form = page.context["adminform"].form + data = {"_save": "Сохранить"} + for name, field in form.fields.items(): + value = form[name].value() + if isinstance(field.widget, forms.MultiWidget): + for index, part in enumerate(field.widget.decompress(value)): + data[f"{name}_{index}"] = str(part) if part is not None else "" + elif isinstance(field, forms.BooleanField): + if value: + data[name] = "on" + elif isinstance(field, forms.ModelMultipleChoiceField): + data[name] = ",".join(str(item) for item in (value or [])) + elif isinstance(value, datetime): + data[name] = value.isoformat() + elif isinstance(value, date): + data[name] = value.isoformat() + else: + data[name] = value if value is not None else "" + for inline in page.context["inline_admin_formsets"]: + prefix = inline.formset.prefix + data.update({f"{prefix}-TOTAL_FORMS": 0, f"{prefix}-INITIAL_FORMS": 0}) + return page, data + + def save(self, **changes): + _, data = self.page_and_payload() + data.update(changes) + response = self.client.post(self.url, data) + errors = response.context["adminform"].form.errors if response.status_code == 200 else None + self.assertEqual(response.status_code, 302, errors) + self.user = CustomUser.objects.get(pk=self.user.pk) + + def capability(self): + api = APIClient() + api.force_authenticate(CustomUser.objects.get(pk=self.user.pk)) + response = api.get("/programs/capabilities/") + self.assertEqual(response.status_code, 200) + return response.data["can_create_program"] + + def test_checkbox_and_access_labels_are_russian_and_permission_is_not_duplicated(self): + self.user.user_permissions.add(self.permission) + page, _ = self.page_and_payload() + form = page.context["adminform"].form + for text in ( + "Может создавать программы", "Доступ к админке", "Все права (суперпользователь)", + "Группы доступа", "Другие разрешения", + ): + self.assertContains(page, text) + self.assertTrue(form["can_create_programs"].value()) + self.assertFalse(form.fields["user_permissions"].queryset.filter(pk=self.permission.pk).exists()) + self.assertNotIn(self.permission.pk, form["user_permissions"].value()) + + def test_grant_and_revoke_change_frontend_capability_without_other_privileges(self): + self.assertFalse(self.capability()) + self.save(can_create_programs="on") + self.assertTrue(self.capability()) + self.assertFalse(self.user.is_staff) + self.assertFalse(self.user.is_superuser) + self.assertEqual(set(self.user.user_permissions.all()), {self.other, self.permission}) + self.save(can_create_programs="") + self.assertFalse(self.capability()) + self.assertEqual(set(self.user.user_permissions.all()), {self.other}) + + def test_unrelated_save_preserves_existing_direct_permission(self): + self.user.user_permissions.add(self.permission) + self.save(about_me="Описание пользователя") + self.assertTrue(self.capability()) + self.assertEqual(set(self.user.user_permissions.all()), {self.other, self.permission}) + + def test_invalid_user_form_does_not_grant_permission(self): + _, data = self.page_and_payload() + data.update(can_create_programs="on", email="неверный адрес") + self.assertEqual(self.client.post(self.url, data).status_code, 200) + self.assertFalse(self.capability()) + self.assertEqual(set(self.user.user_permissions.all()), {self.other}) + + def test_new_user_can_receive_permission_from_checkbox(self): + self.url = reverse("admin:users_customuser_add") + _, data = self.page_and_payload() + data.update( + email="new-creator@example.com", first_name="Анна", last_name="Иванова", + password="local-test-password", birthday="2000-01-01", user_type=CustomUser.MEMBER, + v2_speciality=self.user.v2_speciality_id, can_create_programs="on", + ) + response = self.client.post(self.url, data) + errors = response.context["adminform"].form.errors if response.status_code == 200 else None + self.assertEqual(response.status_code, 302, errors) + created = CustomUser.objects.get(email=data["email"]) + self.assertEqual(set(created.user_permissions.all()), {self.permission}) + self.assertFalse(created.is_staff) + self.assertFalse(created.is_superuser) + + def test_group_permission_is_explained_and_does_not_become_a_direct_grant(self): + group = Group.objects.create(name="Создатели программ") + group.permissions.add(self.permission) + self.user.groups.add(group) + page, data = self.page_and_payload() + checkbox = page.context["adminform"].form.fields["can_create_programs"] + self.assertTrue(checkbox.disabled) + self.assertContains(page, "Право выдано через группу доступа") + data["can_create_programs"] = "" + self.assertEqual(self.client.post(self.url, data).status_code, 302) + self.assertTrue(self.capability()) + self.assertEqual(set(self.user.user_permissions.all()), {self.other}) + self.save(groups="") + self.assertFalse(self.capability()) + + def test_group_does_not_remove_an_existing_direct_grant(self): + group = Group.objects.create(name="Создатели программ") + group.permissions.add(self.permission) + self.user.groups.add(group) + self.user.user_permissions.add(self.permission) + self.save(about_me="Описание") + self.assertEqual(set(self.user.user_permissions.all()), {self.other, self.permission}) + + def test_new_group_grant_keeps_permission_in_the_group(self): + group = Group.objects.create(name="Создатели программ") + group.permissions.add(self.permission) + self.save(groups=str(group.pk), can_create_programs="") + self.assertTrue(self.capability()) + self.assertEqual(set(self.user.user_permissions.all()), {self.other}) + + def test_superuser_permission_is_explained_without_creating_a_direct_grant(self): + self.user.is_superuser = True + self.user.save(update_fields=["is_superuser"]) + page, _ = self.page_and_payload() + self.assertTrue(page.context["adminform"].form.fields["can_create_programs"].disabled) + self.assertContains(page, "Создание программ доступно благодаря статусу суперпользователя") + self.save(about_me="Описание") + self.assertEqual(set(self.user.user_permissions.all()), {self.other}) + self.save(is_superuser="") + self.assertFalse(self.capability()) + + def test_permission_cannot_be_edited_through_both_controls(self): + _, data = self.page_and_payload() + data["user_permissions"] = f"{self.other.pk},{self.permission.pk}" + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, 200) + self.assertIn("user_permissions", response.context["adminform"].form.errors) + self.assertFalse(self.capability()) + + def test_staff_without_user_change_permission_cannot_grant(self): + staff = build_user(email="staff@example.com", is_staff=True) + self.client.force_login(staff) + self.assertEqual(self.client.post(self.url, {"can_create_programs": "on"}).status_code, 403) + self.assertFalse(self.capability())