From a07fa5f8b1f9c7dbd13c0e280650c1d8a662e7f9 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Mon, 26 Oct 2026 22:58:49 +0300 Subject: [PATCH] fix(programs): public reader for published legacy projects --- partner_programs/published_projects_api.py | 58 ++++++++++ partner_programs/serializers/programs.py | 8 ++ .../tests/test_published_projects_api.py | 100 ++++++++++++++++++ partner_programs/urls.py | 2 + 4 files changed, 168 insertions(+) create mode 100644 partner_programs/published_projects_api.py create mode 100644 partner_programs/tests/test_published_projects_api.py diff --git a/partner_programs/published_projects_api.py b/partner_programs/published_projects_api.py new file mode 100644 index 00000000..976642b9 --- /dev/null +++ b/partner_programs/published_projects_api.py @@ -0,0 +1,58 @@ +"""Public legacy-project reader; manager lists and exports retain their permissions.""" + +from django.db.models import Q +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from rest_framework import generics +from rest_framework.permissions import AllowAny + +from partner_programs.models import PartnerProgram, PartnerProgramUserProfile +from partner_programs.pagination import PartnerProgramPagination +from projects.models import Project +from projects.serializers import ProjectListSerializer + + +def published_projects_available(program): + return bool( + not program.draft + and program.solution_storage_mode == "legacy_project" + and program.publish_projects_after_finish + and program.datetime_finished <= timezone.now() + ) + + +class PublishedProjectsPagination(PartnerProgramPagination): + max_limit = 100 + + +class PublishedProgramProjectsView(generics.ListAPIView): + permission_classes = [AllowAny] + serializer_class = ProjectListSerializer + pagination_class = PublishedProjectsPagination + + def get_queryset(self): + program = get_object_or_404( + PartnerProgram, + pk=self.kwargs["pk"], + draft=False, + solution_storage_mode="legacy_project", + publish_projects_after_finish=True, + datetime_finished__lte=timezone.now(), + ) + # The existing publisher can use either source. Never publish as a GET side effect. + projects = ( + Project.objects.filter( + Q(program_links__partner_program=program) + | Q( + id__in=PartnerProgramUserProfile.objects.filter( + partner_program=program + ).values("project_id") + ), + draft=False, + is_public=True, + ) + .distinct() + .order_by("pk") + ) + search = self.request.query_params.get("search", "").strip() + return projects.filter(name__icontains=search) if search else projects diff --git a/partner_programs/serializers/programs.py b/partner_programs/serializers/programs.py index 5d4f800f..b535a926 100644 --- a/partner_programs/serializers/programs.py +++ b/partner_programs/serializers/programs.py @@ -118,6 +118,12 @@ class PartnerProgramBaseSerializerMixin(serializers.ModelSerializer): pending_expert_invitation_id = serializers.SerializerMethodField() courses = serializers.SerializerMethodField() registration_managed = serializers.SerializerMethodField() + published_projects_available = serializers.SerializerMethodField() + + def get_published_projects_available(self, program): + from partner_programs.published_projects_api import published_projects_available + + return published_projects_available(program) def get_registration_managed(self, program): from partner_programs.services.native_registration import config_for @@ -252,6 +258,7 @@ class Meta: "legacy_team_max_size", "is_user_manager", "welcome_acknowledged_at", + "published_projects_available", "is_user_expert", "solution_storage_mode", "pending_expert_invitation_id", @@ -282,6 +289,7 @@ class Meta: "datetime_project_submission_ends", "datetime_evaluation_ends", "publish_projects_after_finish", + "published_projects_available", "legacy_team_min_size", "legacy_team_max_size", "is_user_manager", diff --git a/partner_programs/tests/test_published_projects_api.py b/partner_programs/tests/test_published_projects_api.py new file mode 100644 index 00000000..d968077c --- /dev/null +++ b/partner_programs/tests/test_published_projects_api.py @@ -0,0 +1,100 @@ +from django.test import TestCase +from django.utils import timezone +from rest_framework.test import APIClient + +from partner_programs.models import PartnerProgramProject +from partner_programs.tests.helpers import ( + create_partner_program, + create_project, + create_user, + create_program_member, +) + + +class PublishedProjectsTests(TestCase): + def setUp(self): + self.client = APIClient() + self.program = create_partner_program( + publish_projects_after_finish=True, + datetime_finished=timezone.now() - timezone.timedelta(days=1), + ) + self.user = create_user() + self.public = create_project(leader=self.user, is_public=True, name="Public work") + self.private = create_project(leader=self.user, is_public=False) + self.draft = create_project(leader=self.user, is_public=True, draft=True) + for project in (self.public, self.private, self.draft): + PartnerProgramProject.objects.create( + partner_program=self.program, project=project + ) + self.url = f"/programs/{self.program.pk}/published-projects/" + + def read(self, path=None, **kwargs): + return self.client.get(path or self.url, secure=True, **kwargs) + + def test_anonymous_reader_only_returns_published_non_draft_projects(self): + response = self.read() + self.assertEqual(response.status_code, 200) + self.assertEqual( + [row["id"] for row in response.data["results"]], [self.public.pk] + ) + self.assertNotIn("program_data", response.data["results"][0]) + self.assertNotIn("scores", response.data["results"][0]) + self.private.refresh_from_db() + self.assertFalse(self.private.is_public) # GET does not publish. + + def test_member_and_unregistered_detail_expose_server_capability(self): + for registered in (False, True): + with self.subTest(registered=registered): + if registered: + create_program_member(self.program, user=self.user) + self.client.force_authenticate(self.user) + response = self.read(f"/programs/{self.program.pk}/") + self.assertEqual(response.status_code, 200) + self.assertTrue(response.data["published_projects_available"]) + + def test_flag_future_draft_and_solution_mode_do_not_open_legacy_reader(self): + cases = [ + {"publish_projects_after_finish": False}, + {"datetime_finished": timezone.now() + timezone.timedelta(days=1)}, + {"draft": True}, + {"solution_storage_mode": "solution"}, + ] + for values in cases: + with self.subTest(values=values): + program = create_partner_program( + publish_projects_after_finish=True, + datetime_finished=timezone.now() - timezone.timedelta(days=1), + ) + for key, value in values.items(): + setattr(program, key, value) + program.save() + self.assertEqual( + self.read(f"/programs/{program.pk}/published-projects/").status_code, + 404, + ) + + def test_profile_source_search_pagination_and_duplicate_links(self): + create_program_member(self.program, user=self.user, project=self.public) + extra = create_project(is_public=True, name="Public second") + create_program_member(self.program, project=extra) + response = self.read(self.url + "?search=Public&limit=1&offset=1") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 2) + self.assertEqual([row["id"] for row in response.data["results"]], [extra.pk]) + + def test_private_manager_list_filters_and_exports_keep_permissions(self): + self.client.force_authenticate(self.user) + for suffix in ("projects/", "export-projects/"): + self.assertEqual( + self.read(f"/programs/{self.program.pk}/{suffix}").status_code, 403 + ) + self.assertEqual( + self.client.post( + f"/programs/{self.program.pk}/projects/filter/", + {}, + format="json", + secure=True, + ).status_code, + 403, + ) + self.assertEqual(self.client.post(self.url, {}, secure=True).status_code, 405) diff --git a/partner_programs/urls.py b/partner_programs/urls.py index 223673a2..b09e9f42 100644 --- a/partner_programs/urls.py +++ b/partner_programs/urls.py @@ -1,4 +1,5 @@ from django.urls import path +from partner_programs.published_projects_api import PublishedProgramProjectsView from partner_programs.solution_action_api import SolutionActionView from partner_programs.solution_case_action_api import SolutionCaseChangeView @@ -147,6 +148,7 @@ app_name = "partner_programs" urlpatterns = [ + path("/published-projects/", PublishedProgramProjectsView.as_view()), path("/published-solutions/", PublishedSolutionListView.as_view()), path( "/published-solutions//",