diff --git a/news/permissions.py b/news/permissions.py index 34107135..a1906a24 100644 --- a/news/permissions.py +++ b/news/permissions.py @@ -1,6 +1,6 @@ from django.contrib.auth import get_user_model +from django.shortcuts import get_object_or_404 from rest_framework import permissions -from rest_framework.exceptions import NotFound from rest_framework.permissions import SAFE_METHODS from partner_programs.models import PartnerProgram @@ -10,47 +10,37 @@ class IsNewsCreatorOrReadOnly(permissions.BasePermission): - def has_object_permission(self, request, view, obj): - """ - read/update/delete permission - currently can only be updated/deleted in admin panel - """ + def has_permission(self, request, view): if request.method in SAFE_METHODS: return True - if ( - isinstance(obj.content_object, Project) - and obj.content_object.leader == request.user - ): - return True - if isinstance(obj.content_object, User) and obj.content_object == request.user: - return True - if isinstance(obj.content_object, PartnerProgram): - # TODO: implement - pass + + if view.kwargs.get("project_pk"): + project = get_object_or_404(Project, pk=view.kwargs["project_pk"]) + return request.user == project.leader + + if view.kwargs.get("user_pk"): + user = get_object_or_404(User, pk=view.kwargs["user_pk"]) + return request.user == user + + if view.kwargs.get("partnerprogram_pk"): + program = get_object_or_404( + PartnerProgram, pk=view.kwargs["partnerprogram_pk"] + ) + return program.is_manager(request.user) + return False - def has_permission(self, request, view): - """ - Creation permission - Currently can only be created via admin panel - """ + def has_object_permission(self, request, view, obj): if request.method in SAFE_METHODS: return True - if view.kwargs.get("project_pk"): - try: - project = Project.objects.get(pk=view.kwargs["project_pk"]) - if request.method in SAFE_METHODS or (request.user == project.leader): - return True - except Project.DoesNotExist: - raise NotFound + if isinstance(obj.content_object, Project): + return obj.content_object.leader == request.user - if view.kwargs.get("user_pk"): - try: - user = User.objects.get(pk=view.kwargs["user_pk"]) - if request.method in SAFE_METHODS or (request.user == user): - return True - except User.DoesNotExist: - raise NotFound + if isinstance(obj.content_object, User): + return obj.content_object == request.user + + if isinstance(obj.content_object, PartnerProgram): + return obj.content_object.is_manager(request.user) return False diff --git a/news/views.py b/news/views.py index f29e1599..a93eabc8 100644 --- a/news/views.py +++ b/news/views.py @@ -2,20 +2,21 @@ from django.shortcuts import get_object_or_404 from rest_framework import generics, status from rest_framework.permissions import IsAuthenticated -from rest_framework.response import Response from rest_framework.request import Request +from rest_framework.response import Response -from core.serializers import SetViewedSerializer, SetLikedSerializer +from core.serializers import SetLikedSerializer, SetViewedSerializer from core.services import add_view, set_like from news.mixins import NewsQuerysetMixin from news.models import News from news.pagination import NewsPagination from news.permissions import IsNewsCreatorOrReadOnly from news.serializers import ( - NewsListSerializer, NewsDetailSerializer, NewsListCreateSerializer, + NewsListSerializer, ) +from partner_programs.models import PartnerProgram from projects.models import Project User = get_user_model() @@ -44,7 +45,12 @@ def post(self, request: Request, *args, **kwargs) -> Response: NewsDetailSerializer(news).data, status=status.HTTP_201_CREATED ) - # creating partner program news, not implemented yet, return 400 + if kwargs.get("partnerprogram_pk"): + program = get_object_or_404(PartnerProgram, pk=kwargs["partnerprogram_pk"]) + news = News.objects.add_news(program, **data) + return Response( + NewsDetailSerializer(news).data, status=status.HTTP_201_CREATED + ) return Response(status=status.HTTP_400_BAD_REQUEST) def get(self, request: Request, *args, **kwargs) -> Response: diff --git a/partner_programs/admin.py b/partner_programs/admin.py index 4b7465f7..af2ca596 100644 --- a/partner_programs/admin.py +++ b/partner_programs/admin.py @@ -1,22 +1,35 @@ -import tablib import re import urllib.parse + +import tablib from django.contrib import admin from django.db.models import QuerySet -from django.http import HttpResponse, HttpRequest +from django.http import HttpRequest, HttpResponse from django.urls import path from django.utils import timezone -from mailing.views import MailingTemplateRender from core.utils import XlsxFileToExport -from partner_programs.models import PartnerProgram, PartnerProgramUserProfile +from mailing.views import MailingTemplateRender +from partner_programs.models import ( + PartnerProgram, + PartnerProgramMaterial, + PartnerProgramUserProfile, +) +from partner_programs.services import ProjectScoreDataPreparer from project_rates.models import Criteria, ProjectScore from projects.models import Project -from partner_programs.services import ProjectScoreDataPreparer + + +class PartnerProgramMaterialInline(admin.StackedInline): + model = PartnerProgramMaterial + extra = 1 + fields = ("title", "url", "file") + readonly_fields = ("datetime_created", "datetime_updated") @admin.register(PartnerProgram) class PartnerProgramAdmin(admin.ModelAdmin): + inlines = [PartnerProgramMaterialInline] list_display = ("id", "name", "tag", "city", "datetime_created") list_display_links = ( "id", @@ -32,7 +45,7 @@ class PartnerProgramAdmin(admin.ModelAdmin): ) list_filter = ("city",) - filter_horizontal = ("users",) + filter_horizontal = ("users", "managers") date_hierarchy = "datetime_started" def get_queryset(self, request: HttpRequest) -> QuerySet[PartnerProgram]: @@ -54,7 +67,9 @@ def change_view(self, request, object_id, form_url="", extra_context=None): "partner_programs/admin/program_manager_change_form.html" ) else: - self.change_form_template = "partner_programs/admin/programs_change_form.html" + self.change_form_template = ( + "partner_programs/admin/programs_change_form.html" + ) return super().change_view(request, object_id, form_url, extra_context) @@ -145,7 +160,7 @@ def get_export_file(self, partner_program: PartnerProgram): binary_data = response_data.export("xlsx") file_name = ( - f'{partner_program.name} {timezone.now().strftime("%d-%m-%Y %H:%M:%S")}' + f"{partner_program.name} {timezone.now().strftime('%d-%m-%Y %H:%M:%S')}" ) response = HttpResponse( content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", @@ -155,7 +170,9 @@ def get_export_file(self, partner_program: PartnerProgram): return response def get_export_rates_view(self, request, object_id): - rates_data_to_write: list[dict] = self._get_prepared_rates_data_for_export(object_id) + rates_data_to_write: list[dict] = self._get_prepared_rates_data_for_export( + object_id + ) xlsx_file_writer = XlsxFileToExport() xlsx_file_writer.write_data_to_xlsx(rates_data_to_write) @@ -163,14 +180,16 @@ def get_export_rates_view(self, request, object_id): xlsx_file_writer.delete_self_xlsx_file_from_local_machine() encoded_file_name: str = urllib.parse.quote( - f'{PartnerProgram.objects.get(pk=object_id).name}_оценки {timezone.now().strftime("%d-%m-%Y %H:%M:%S")}' + f"{PartnerProgram.objects.get(pk=object_id).name}_оценки {timezone.now().strftime('%d-%m-%Y %H:%M:%S')}" f".xlsx" ) response = HttpResponse( binary_data_to_export, content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ) - response["Content-Disposition"] = f'attachment; filename*=UTF-8\'\'{encoded_file_name}' + response["Content-Disposition"] = ( + f"attachment; filename*=UTF-8''{encoded_file_name}" + ) return response def _get_prepared_rates_data_for_export(self, program_id: int) -> list[dict]: @@ -179,19 +198,22 @@ def _get_prepared_rates_data_for_export(self, program_id: int) -> list[dict]: Columns example: ФИО|Email|Регион_РФ|Учебное_заведение|Название_учебного_заведения|Класс_курс|Фамилия эксперта|**criteria """ - criterias = Criteria.objects.filter(partner_program__id=program_id).select_related("partner_program") + criterias = Criteria.objects.filter( + partner_program__id=program_id + ).select_related("partner_program") scores = ( - ProjectScore.objects - .filter(criteria__in=criterias) + ProjectScore.objects.filter(criteria__in=criterias) .select_related("user", "criteria", "project") .order_by("project", "criteria") ) - user_programm_profiles = ( - PartnerProgramUserProfile.objects - .filter(partner_program__id=program_id) - .select_related("user") + user_programm_profiles = PartnerProgramUserProfile.objects.filter( + partner_program__id=program_id + ).select_related("user") + projects = ( + Project.objects.filter(scores__in=scores) + .select_related("leader") + .distinct() ) - projects = Project.objects.filter(scores__in=scores).select_related("leader").distinct() # To reduce the number of DB requests. user_profiles_dict: dict[int, PartnerProgramUserProfile] = { @@ -203,7 +225,9 @@ def _get_prepared_rates_data_for_export(self, program_id: int) -> list[dict]: prepared_projects_rates_data: list[dict] = [] for project in projects: - project_data_preparer = ProjectScoreDataPreparer(user_profiles_dict, scores_dict, project.id, program_id) + project_data_preparer = ProjectScoreDataPreparer( + user_profiles_dict, scores_dict, project.id, program_id + ) full_project_rates_data: dict = { **project_data_preparer.get_project_user_info(), **project_data_preparer.get_project_expert_info(), @@ -242,3 +266,23 @@ def get_form(self, request, obj=None, **kwargs): form = super().get_form(request, obj, **kwargs) form.base_fields["project"].required = False return form + + +@admin.register(PartnerProgramMaterial) +class PartnerProgramMaterialAdmin(admin.ModelAdmin): + list_display = ("title", "program", "short_url", "has_file", "datetime_created") + list_filter = ("program",) + search_fields = ("title", "program__name") + + readonly_fields = ("datetime_created", "datetime_updated") + + def short_url(self, obj): + return obj.url[:60] if obj.url else "—" + + short_url.short_description = "Ссылка" + + def has_file(self, obj): + return bool(obj.file) + + has_file.boolean = True + has_file.short_description = "Файл" diff --git a/partner_programs/migrations/0007_partnerprogrammaterial.py b/partner_programs/migrations/0007_partnerprogrammaterial.py new file mode 100644 index 00000000..d2649d0d --- /dev/null +++ b/partner_programs/migrations/0007_partnerprogrammaterial.py @@ -0,0 +1,72 @@ +# Generated by Django 4.2.11 on 2025-07-21 09:55 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("files", "0007_auto_20230929_1727"), + ("partner_programs", "0006_partnerprogram_projects_availability"), + ] + + operations = [ + migrations.CreateModel( + name="PartnerProgramMaterial", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "title", + models.CharField( + help_text="Например, 'Кейс Сбера'", + max_length=255, + verbose_name="Название материала", + ), + ), + ( + "url", + models.URLField( + blank=True, + help_text="Укажите ссылку вручную или прикрепите файл", + null=True, + verbose_name="Ссылка на материал", + ), + ), + ("datetime_created", models.DateTimeField(auto_now_add=True)), + ("datetime_updated", models.DateTimeField(auto_now=True)), + ( + "file", + models.ForeignKey( + blank=True, + help_text="Если указан файл, ссылка берётся из него", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="files.userfile", + verbose_name="Файл", + ), + ), + ( + "program", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="materials", + to="partner_programs.partnerprogram", + verbose_name="Программа", + ), + ), + ], + options={ + "verbose_name": "Материал программы", + "verbose_name_plural": "Материалы программ", + }, + ), + ] diff --git a/partner_programs/migrations/0008_partnerprogram_managers_and_more.py b/partner_programs/migrations/0008_partnerprogram_managers_and_more.py new file mode 100644 index 00000000..1be902a9 --- /dev/null +++ b/partner_programs/migrations/0008_partnerprogram_managers_and_more.py @@ -0,0 +1,34 @@ +# Generated by Django 4.2.11 on 2025-07-22 08:44 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("partner_programs", "0007_partnerprogrammaterial"), + ] + + operations = [ + migrations.AddField( + model_name="partnerprogram", + name="managers", + field=models.ManyToManyField( + blank=True, + help_text="Пользователи, имеющие право создавать и редактировать новости", + related_name="managed_partner_programs", + to=settings.AUTH_USER_MODEL, + verbose_name="Менеджеры программы", + ), + ), + migrations.AlterField( + model_name="partnerprogrammaterial", + name="title", + field=models.CharField( + help_text="Укажите текст для гиперссылки", + max_length=255, + verbose_name="Название материала", + ), + ), + ] diff --git a/partner_programs/models.py b/partner_programs/models.py index cd7d3550..cceef657 100644 --- a/partner_programs/models.py +++ b/partner_programs/models.py @@ -1,6 +1,8 @@ from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError from django.db import models +from files.models import UserFile from partner_programs.constants import get_default_data_schema from projects.models import Project @@ -26,6 +28,7 @@ class PartnerProgram(models.Model): datetime_created: A DateTimeField indicating date of creation. datetime_updated: A DateTimeField indicating date of update. """ + PROJECTS_AVAILABILITY_CHOISES = [ ("all_users", "Всем пользователям"), ("experts_only", "Только экспертам"), @@ -77,6 +80,13 @@ class PartnerProgram(models.Model): verbose_name="Участники программы", through="PartnerProgramUserProfile", ) + managers = models.ManyToManyField( + User, + related_name="managed_partner_programs", + blank=True, + verbose_name="Менеджеры программы", + help_text="Пользователи, имеющие право создавать и редактировать новости", + ) draft = models.BooleanField(blank=False, default=True) projects_availability = models.CharField( choices=PROJECTS_AVAILABILITY_CHOISES, @@ -96,7 +106,17 @@ class PartnerProgram(models.Model): datetime_created = models.DateTimeField( verbose_name="Дата создания", auto_now_add=True ) - datetime_updated = models.DateTimeField(verbose_name="Дата изменения", auto_now=True) + datetime_updated = models.DateTimeField( + verbose_name="Дата изменения", auto_now=True + ) + + def is_manager(self, user: User) -> bool: + """ + Возвращает True, если пользователь — менеджер этой программы. + """ + if not user or not user.is_authenticated: + return False + return self.managers.filter(pk=user.pk).exists() class Meta: verbose_name = "Программа" @@ -148,3 +168,62 @@ class Meta: def __str__(self): return f"PartnerProgramUserProfile<{self.pk}> - {self.user} {self.project} {self.partner_program}" + + +class PartnerProgramMaterial(models.Model): + """ + Материал для программы: прямая ссылка или ссылка на прикреплённый файл. + """ + + program = models.ForeignKey( + PartnerProgram, + on_delete=models.CASCADE, + related_name="materials", + verbose_name="Программа", + ) + + title = models.CharField( + max_length=255, + verbose_name="Название материала", + help_text="Укажите текст для гиперссылки", + ) + + url = models.URLField( + blank=True, + null=True, + verbose_name="Ссылка на материал", + help_text="Укажите ссылку вручную или прикрепите файл", + ) + + file = models.ForeignKey( + UserFile, + on_delete=models.SET_NULL, + blank=True, + null=True, + verbose_name="Файл", + help_text="Если указан файл, ссылка берётся из него", + ) + + datetime_created = models.DateTimeField(auto_now_add=True) + datetime_updated = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "Материал программы" + verbose_name_plural = "Материалы программ" + + def clean(self): + if self.file and not self.url: + self.url = self.file.link + + if not self.file and not self.url: + raise ValidationError("Необходимо указать либо файл, либо ссылку.") + + if self.file and self.url and self.url != self.file.link: + raise ValidationError("Укажите либо файл, либо ссылку, но не оба сразу.") + + def save(self, *args, **kwargs): + self.full_clean() + super().save(*args, **kwargs) + + def __str__(self): + return f"{self.title} для программы {self.program.name}" diff --git a/partner_programs/serializers.py b/partner_programs/serializers.py index a623f4dd..3535af24 100644 --- a/partner_programs/serializers.py +++ b/partner_programs/serializers.py @@ -2,7 +2,7 @@ from rest_framework import serializers from core.services import get_likes_count, get_links, get_views_count, is_fan -from partner_programs.models import PartnerProgram +from partner_programs.models import PartnerProgram, PartnerProgramMaterial User = get_user_model() @@ -49,11 +49,35 @@ class Meta: ) -class PartnerProgramForMemberSerializer(serializers.ModelSerializer): +class PartnerProgramBaseSerializerMixin(serializers.ModelSerializer): + """ + Базовый миксин для сериализаторов PartnerProgram, + включает общие поля: materials и is_user_manager. + """ + + materials = serializers.SerializerMethodField() + is_user_manager = serializers.SerializerMethodField() + + def get_materials(self, program: PartnerProgram): + materials = program.materials.all() + return PartnerProgramMaterialSerializer(materials, many=True).data + + def get_is_user_manager(self, program: PartnerProgram) -> bool: + user = self.context.get("user") + return bool(user and program.is_manager(user)) + + class Meta: + abstract = True + + +class PartnerProgramForMemberSerializer(PartnerProgramBaseSerializerMixin): """Serializer for PartnerProgram model for member of this program""" views_count = serializers.SerializerMethodField(method_name="count_views") links = serializers.SerializerMethodField(method_name="get_links") + is_user_manager = serializers.SerializerMethodField( + method_name="get_is_user_manager" + ) def count_views(self, program): return get_views_count(program) @@ -79,15 +103,17 @@ class Meta: "description", "city", "links", + "materials", "image_address", "cover_image_address", "presentation_address", "views_count", "datetime_registration_ends", + "is_user_manager", ) -class PartnerProgramForUnregisteredUserSerializer(serializers.ModelSerializer): +class PartnerProgramForUnregisteredUserSerializer(PartnerProgramBaseSerializerMixin): """Serializer for PartnerProgram model for unregistered users in the program""" class Meta: @@ -97,11 +123,13 @@ class Meta: "name", "tag", "city", + "materials", "image_address", "cover_image_address", "advertisement_image_address", "presentation_address", "datetime_registration_ends", + "is_user_manager", ) @@ -138,3 +166,9 @@ class Meta: "name", "tag", ] + + +class PartnerProgramMaterialSerializer(serializers.ModelSerializer): + class Meta: + model = PartnerProgramMaterial + fields = ("title", "url") diff --git a/partner_programs/views.py b/partner_programs/views.py index 9db6f67d..654d4568 100644 --- a/partner_programs/views.py +++ b/partner_programs/views.py @@ -35,27 +35,25 @@ class PartnerProgramList(generics.ListCreateAPIView): class PartnerProgramDetail(generics.RetrieveAPIView): - queryset = PartnerProgram.objects.all() + queryset = PartnerProgram.objects.prefetch_related("materials", "managers").all() permission_classes = [permissions.IsAuthenticatedOrReadOnly] - serializer_class = PartnerProgramForUnregisteredUserSerializer def get(self, request, *args, **kwargs): - try: - program = self.get_object() - is_user_member = program.users.filter(pk=request.user.pk).exists() - serializer_class = ( - PartnerProgramForMemberSerializer - if is_user_member - else PartnerProgramForUnregisteredUserSerializer - ) - data = serializer_class(program).data - data["is_user_member"] = is_user_member - if request.user.is_authenticated: - add_view(program, request.user) - - return Response(data=data, status=status.HTTP_200_OK) - except PartnerProgram.DoesNotExist: - return Response(status=status.HTTP_404_NOT_FOUND) + program = self.get_object() + is_user_member = program.users.filter(pk=request.user.pk).exists() + serializer_class = ( + PartnerProgramForMemberSerializer + if is_user_member + else PartnerProgramForUnregisteredUserSerializer + ) + serializer = serializer_class( + program, context={"request": request, "user": request.user} + ) + data = serializer.data + data["is_user_member"] = is_user_member + if request.user.is_authenticated: + add_view(program, request.user) + return Response(data, status=status.HTTP_200_OK) class PartnerProgramCreateUserAndRegister(generics.GenericAPIView):