diff --git a/partner_programs/serializers/programs.py b/partner_programs/serializers/programs.py index 54bed685..80f02375 100644 --- a/partner_programs/serializers/programs.py +++ b/partner_programs/serializers/programs.py @@ -2,7 +2,8 @@ from rest_framework import serializers from core.services import get_likes_count, get_links, get_views_count, is_fan -from .fields import PartnerProgramFieldValueUpdateSerializer +from courses.models import CourseContentStatus +from courses.services.access import resolve_course_availability from partner_programs.models import ( PartnerProgram, PartnerProgramField, @@ -12,6 +13,8 @@ from projects.models import Project from projects.validators import validate_project +from .fields import PartnerProgramFieldValueUpdateSerializer + User = get_user_model() @@ -90,6 +93,7 @@ class PartnerProgramBaseSerializerMixin(serializers.ModelSerializer): materials = serializers.SerializerMethodField() is_user_manager = serializers.SerializerMethodField() + courses = serializers.SerializerMethodField() def get_materials(self, program: PartnerProgram): materials = program.materials.all() @@ -99,6 +103,36 @@ def get_is_user_manager(self, program: PartnerProgram) -> bool: user = self.context.get("user") return bool(user and program.is_manager(user)) + def get_courses(self, program: PartnerProgram) -> list[dict]: + user = self.context.get("user") + prefetched_courses = ( + getattr(program, "_prefetched_objects_cache", {}).get("courses") + if hasattr(program, "_prefetched_objects_cache") + else None + ) + if prefetched_courses is None: + related_courses = program.courses.exclude( + status=CourseContentStatus.DRAFT + ).order_by("id") + else: + related_courses = sorted( + ( + course + for course in prefetched_courses + if course.status != CourseContentStatus.DRAFT + ), + key=lambda course: course.id, + ) + + return [ + { + "id": course.id, + "title": course.title, + "is_available": resolve_course_availability(course, user).is_available, + } + for course in related_courses + ] + class Meta: abstract = True @@ -145,6 +179,7 @@ class Meta: "datetime_evaluation_ends", "publish_projects_after_finish", "is_user_manager", + "courses", ) @@ -169,6 +204,7 @@ class Meta: "datetime_evaluation_ends", "publish_projects_after_finish", "is_user_manager", + "courses", ) diff --git a/partner_programs/tests.py b/partner_programs/tests.py index eb1ea43e..95cab2e5 100644 --- a/partner_programs/tests.py +++ b/partner_programs/tests.py @@ -3,6 +3,7 @@ from django.utils import timezone from rest_framework.test import APIRequestFactory, force_authenticate +from courses.models import Course, CourseAccessType, CourseContentStatus from partner_programs.models import ( PartnerProgram, PartnerProgramField, @@ -11,7 +12,7 @@ ) from partner_programs.serializers import PartnerProgramFieldValueUpdateSerializer from partner_programs.services import publish_finished_program_projects -from partner_programs.views import PartnerProgramProjectSubmitView +from partner_programs.views import PartnerProgramDetail, PartnerProgramProjectSubmitView from projects.models import Project @@ -412,3 +413,138 @@ def test_file_valid_url(self): data = {"field_id": field.id, "value_text": "https://example.com/file.pdf"} serializer = PartnerProgramFieldValueUpdateSerializer(data=data) self.assertTrue(serializer.is_valid()) + + +class PartnerProgramDetailCoursesTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.view = PartnerProgramDetail.as_view() + self.now = timezone.now() + + def create_program(self, **overrides): + defaults = { + "name": "Program with courses", + "tag": "program_with_courses", + "description": "Program description", + "city": "Moscow", + "data_schema": {}, + "draft": False, + "projects_availability": "all_users", + "datetime_registration_ends": self.now + timezone.timedelta(days=10), + "datetime_started": self.now - timezone.timedelta(days=1), + "datetime_finished": self.now + timezone.timedelta(days=30), + } + defaults.update(overrides) + return PartnerProgram.objects.create(**defaults) + + def create_user(self, email: str): + return get_user_model().objects.create_user( + email=email, + password="pass", + first_name="Test", + last_name="User", + birthday="1990-01-01", + ) + + def create_course(self, program: PartnerProgram, **overrides): + defaults = { + "title": "Program course", + "partner_program": program, + "access_type": CourseAccessType.ALL_USERS, + "status": CourseContentStatus.PUBLISHED, + } + defaults.update(overrides) + return Course.objects.create(**defaults) + + def test_detail_includes_related_courses_with_availability_for_member(self): + program = self.create_program() + member = self.create_user("member-program@example.com") + PartnerProgramUserProfile.objects.create( + user=member, + partner_program=program, + project=None, + partner_program_data={}, + ) + all_users_course = self.create_course( + program, + title="Open course", + access_type=CourseAccessType.ALL_USERS, + ) + member_course = self.create_course( + program, + title="Members course", + access_type=CourseAccessType.PROGRAM_MEMBERS, + ) + self.create_course( + program, + title="Draft course", + access_type=CourseAccessType.ALL_USERS, + status=CourseContentStatus.DRAFT, + ) + + request = self.factory.get(f"/programs/{program.id}/") + force_authenticate(request, user=member) + response = self.view(request, pk=program.id) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.data["courses"], + [ + { + "id": all_users_course.id, + "title": "Open course", + "is_available": True, + }, + { + "id": member_course.id, + "title": "Members course", + "is_available": True, + }, + ], + ) + + def test_detail_includes_empty_courses_list_when_program_has_no_related_courses(self): + program = self.create_program() + user = self.create_user("plain-user@example.com") + + request = self.factory.get(f"/programs/{program.id}/") + force_authenticate(request, user=user) + response = self.view(request, pk=program.id) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["courses"], []) + + def test_detail_marks_program_only_courses_as_unavailable_for_non_member(self): + program = self.create_program() + outsider = self.create_user("outsider-program@example.com") + open_course = self.create_course( + program, + title="Open course", + access_type=CourseAccessType.ALL_USERS, + ) + member_course = self.create_course( + program, + title="Members course", + access_type=CourseAccessType.PROGRAM_MEMBERS, + ) + + request = self.factory.get(f"/programs/{program.id}/") + force_authenticate(request, user=outsider) + response = self.view(request, pk=program.id) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.data["courses"], + [ + { + "id": open_course.id, + "title": "Open course", + "is_available": True, + }, + { + "id": member_course.id, + "title": "Members course", + "is_available": False, + }, + ], + ) diff --git a/partner_programs/views.py b/partner_programs/views.py index e4c53cb0..02e38f4d 100644 --- a/partner_programs/views.py +++ b/partner_programs/views.py @@ -98,7 +98,11 @@ def get_queryset(self): class PartnerProgramDetail(generics.RetrieveAPIView): - queryset = PartnerProgram.objects.prefetch_related("materials", "managers").all() + queryset = PartnerProgram.objects.prefetch_related( + "materials", + "managers", + "courses", + ).all() permission_classes = [permissions.IsAuthenticatedOrReadOnly] serializer_class = PartnerProgramForUnregisteredUserSerializer