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
4 changes: 4 additions & 0 deletions docs/modules/partner-programs.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Назначение

Тег программы необязателен. При создании через фронт общий сервис сохраняет
пустую строку, если тег не задан; Django-админка также допускает пустой тег.
Он не входит в условия первой публикации. Существующие теги не изменяются.

Модуль `partner_programs` отвечает за партнерские программы: регистрацию
участников, привязку проектов к программе, дополнительные поля заявки,
сдачу проектов на проверку, фильтрацию и выгрузки проектов программы.
Expand Down
15 changes: 15 additions & 0 deletions docs/modules/users.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,21 @@ Users отвечает за учетные записи и профиль пол

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

### Право создания программ в админке

В карточке пользователя, в блоке «Права доступа», галочка «Может создавать
программы» выдает или снимает личное разрешение
`partner_programs.add_partnerprogram`. Это то же разрешение, которое проверяет
фронт через `GET /programs/capabilities/`; отдельного флага в модели нет.
Остальные личные разрешения и доступ к админке галочка не изменяет.

Если право получено через группу или статус суперпользователя, галочка отмечена
и недоступна для изменения, рядом указана причина. Сохранение такой карточки
не создает дополнительного личного разрешения. Для отзыва группового права
нужно изменить группы пользователя. Существующая личная выдача сохраняется.

### Основные файлы

- `users/models.py` - модель пользователя, роли, достижения, ссылки,
образование, опыт, языки и подтверждения навыков.
- `users/views.py` - HTTP endpoints и orchestration logic для профиля,
Expand Down
15 changes: 15 additions & 0 deletions partner_programs/migrations/0042_partnerprogram_tag_optional.py
Original file line number Diff line number Diff line change
@@ -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="Тег"),
),
]
1 change: 1 addition & 0 deletions partner_programs/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ class PartnerProgram(models.Model):
)
tag = models.TextField(
verbose_name="Тег",
blank=True,
)
description = models.TextField(
null=True,
Expand Down
4 changes: 3 additions & 1 deletion partner_programs/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)


Expand Down
52 changes: 52 additions & 0 deletions partner_programs/tests/test_optional_tag.py
Original file line number Diff line number Diff line change
@@ -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"])
4 changes: 4 additions & 0 deletions users/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -75,6 +76,8 @@ def make_active(modeladmin, request, queryset):

@admin.register(CustomUser)
class CustomUserAdmin(admin.ModelAdmin):
form = CustomUserAdminForm

fieldsets = (
(
"Аккаунт",
Expand Down Expand Up @@ -129,6 +132,7 @@ class CustomUserAdmin(admin.ModelAdmin):
"fields": (
"is_staff",
"is_superuser",
"can_create_programs",
"groups",
"user_permissions",
)
Expand Down
89 changes: 89 additions & 0 deletions users/admin_forms.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading