diff --git a/invites/managers.py b/invites/managers.py index edb3552a..e71d0a6d 100644 --- a/invites/managers.py +++ b/invites/managers.py @@ -3,4 +3,4 @@ class InviteManager(Manager): def get_invite_for_list_view(self): - return self.get_queryset().select_related("project", "user") + return self.get_queryset().select_related("project", "project__leader", "user") diff --git a/invites/serializers.py b/invites/serializers.py index f9947a96..42a032e2 100644 --- a/invites/serializers.py +++ b/invites/serializers.py @@ -4,9 +4,22 @@ from invites.models import Invite from projects.models import Collaborator from projects.serializers import ProjectListSerializer +from users.models import CustomUser from users.serializers import UserDetailSerializer +class InviteSenderSerializer(serializers.ModelSerializer[CustomUser]): + class Meta: + model = CustomUser + fields = [ + "id", + "first_name", + "last_name", + "patronymic", + "avatar", + ] + + class InviteListSerializer(serializers.ModelSerializer[Invite]): class Meta: model = Invite @@ -67,6 +80,7 @@ def validate(self, attrs): class InviteDetailSerializer(serializers.ModelSerializer[Invite]): user = UserDetailSerializer(many=False, read_only=True) project = ProjectListSerializer(many=False, read_only=True) + sender = InviteSenderSerializer(source="project.leader", read_only=True) specialization = serializers.CharField( required=False, allow_null=True, allow_blank=True ) @@ -77,6 +91,7 @@ class Meta: "id", "project", "user", + "sender", "motivational_letter", "role", "specialization", diff --git a/invites/tests.py b/invites/tests.py index afbef07d..5f4b3d21 100644 --- a/invites/tests.py +++ b/invites/tests.py @@ -1,7 +1,11 @@ +from datetime import timedelta + from django.test import TestCase +from django.utils import timezone from rest_framework.test import APIRequestFactory, force_authenticate -from projects.models import Project +from partner_programs.models import PartnerProgram, PartnerProgramProject +from projects.models import Collaborator, Project from tests.constants import USER_CREATE_DATA from users.views import UserList @@ -112,6 +116,51 @@ def test_invites_creation_with_empty_data(self): self.assertEqual(response.status_code, 400) + def test_invites_creation_for_existing_collaborator(self): + sender = self._user_create("sender@example.com") + recipient = self._user_create("recipient@example.com") + project = self._project_create(sender) + Collaborator.objects.create(user=recipient, project=project, role="Developer") + + create_user = self.invite_create_data.copy() + create_user["user"] = recipient.id + create_user["project"] = project.id + request = self.factory.post("invites/", create_user, format="json") + force_authenticate(request, user=sender) + + response = self.invite_list_view(request) + + self.assertEqual(response.status_code, 400) + self.assertIn("user", response.data) + + def test_invites_creation_for_non_program_member(self): + sender = self._user_create("sender@example.com") + recipient = self._user_create("recipient@example.com") + project = self._project_create(sender) + now = timezone.now() + program = PartnerProgram.objects.create( + name="Test program", + tag="test", + city="Moscow", + datetime_registration_ends=now + timedelta(days=1), + datetime_started=now, + datetime_finished=now + timedelta(days=30), + ) + PartnerProgramProject.objects.create( + partner_program=program, project=project + ) + + create_user = self.invite_create_data.copy() + create_user["user"] = recipient.id + create_user["project"] = project.id + request = self.factory.post("invites/", create_user, format="json") + force_authenticate(request, user=sender) + + response = self.invite_list_view(request) + + self.assertEqual(response.status_code, 400) + self.assertIn("user", response.data) + def test_accept_invite_by_intended_user(self): sender = self._user_create("sender@example.com") recipient = self._user_create("recipient@example.com") diff --git a/partner_programs/services.py b/partner_programs/services.py index 9beb5e57..b75126b9 100644 --- a/partner_programs/services.py +++ b/partner_programs/services.py @@ -3,18 +3,48 @@ from openpyxl.cell.cell import ILLEGAL_CHARACTERS_RE from django.db.models import Prefetch +from django.utils import timezone from partner_programs.models import ( + PartnerProgram, PartnerProgramField, PartnerProgramFieldValue, PartnerProgramProject, PartnerProgramUserProfile, ) from project_rates.models import Criteria, ProjectScore +from projects.models import Project logger = logging.getLogger() +def publish_finished_program_projects(now=None) -> int: + if now is None: + now = timezone.now() + + program_ids = PartnerProgram.objects.filter( + publish_projects_after_finish=True, + datetime_finished__lte=now, + ).values_list("id", flat=True) + if not program_ids.exists(): + return 0 + + link_project_ids = PartnerProgramProject.objects.filter( + partner_program_id__in=program_ids + ).values_list("project_id", flat=True) + profile_project_ids = PartnerProgramUserProfile.objects.filter( + partner_program_id__in=program_ids, + project_id__isnull=False, + ).values_list("project_id", flat=True) + project_ids = link_project_ids.union(profile_project_ids) + + return Project.objects.filter( + id__in=project_ids, + is_public=False, + draft=False, + ).update(is_public=True) + + class ProjectScoreDataPreparer: """ Data preparer about project_rates by experts. diff --git a/partner_programs/tasks.py b/partner_programs/tasks.py new file mode 100644 index 00000000..39baf11c --- /dev/null +++ b/partner_programs/tasks.py @@ -0,0 +1,13 @@ +import logging + +from procollab.celery import app +from partner_programs.services import publish_finished_program_projects + +logger = logging.getLogger(__name__) + + +@app.task +def publish_finished_program_projects_task() -> int: + updated_count = publish_finished_program_projects() + logger.info("Published %s program projects after finish", updated_count) + return updated_count diff --git a/partner_programs/tests.py b/partner_programs/tests.py index d4804970..0fb33174 100644 --- a/partner_programs/tests.py +++ b/partner_programs/tests.py @@ -1,8 +1,16 @@ +from django.contrib.auth import get_user_model from django.test import TestCase from django.utils import timezone -from partner_programs.models import PartnerProgram, PartnerProgramField +from partner_programs.models import ( + PartnerProgram, + PartnerProgramField, + PartnerProgramProject, + PartnerProgramUserProfile, +) from partner_programs.serializers import PartnerProgramFieldValueUpdateSerializer +from partner_programs.services import publish_finished_program_projects +from projects.models import Project class PartnerProgramFieldValueUpdateSerializerInvalidTests(TestCase): @@ -118,6 +126,117 @@ def test_file_empty_required(self): self.assertIn("Файл обязателен для этого поля.", str(serializer.errors)) +class PublishFinishedProgramProjectsTests(TestCase): + def setUp(self): + self.now = timezone.now() + self.user = get_user_model().objects.create_user( + email="user@example.com", + password="pass", + first_name="User", + last_name="Test", + birthday="1990-01-01", + ) + + def create_program(self, **overrides): + defaults = { + "name": "Program", + "tag": "program_tag", + "description": "Program description", + "city": "Moscow", + "image_address": "https://example.com/image.png", + "cover_image_address": "https://example.com/cover.png", + "advertisement_image_address": "https://example.com/advertisement.png", + "presentation_address": "https://example.com/presentation.pdf", + "data_schema": {}, + "draft": False, + "projects_availability": "all_users", + "datetime_registration_ends": self.now - timezone.timedelta(days=5), + "datetime_started": self.now - timezone.timedelta(days=30), + "datetime_finished": self.now - timezone.timedelta(days=1), + } + defaults.update(overrides) + return PartnerProgram.objects.create(**defaults) + + def create_project(self, **overrides): + defaults = { + "leader": self.user, + "draft": False, + "is_public": False, + "name": "Project", + } + defaults.update(overrides) + return Project.objects.create(**defaults) + + def test_publish_updates_projects_from_both_sources(self): + program = self.create_program(publish_projects_after_finish=True) + + link_project = self.create_project(name="Linked Project") + PartnerProgramProject.objects.create( + partner_program=program, + project=link_project, + ) + + profile_project = self.create_project(name="Profile Project") + PartnerProgramUserProfile.objects.create( + user=self.user, + partner_program=program, + project=profile_project, + partner_program_data={}, + ) + + publish_finished_program_projects() + + link_project.refresh_from_db() + profile_project.refresh_from_db() + self.assertTrue(link_project.is_public) + self.assertTrue(profile_project.is_public) + + def test_publish_skips_draft_projects(self): + program = self.create_program(publish_projects_after_finish=True) + draft_project = self.create_project(draft=True, name="Draft Project") + PartnerProgramProject.objects.create( + partner_program=program, + project=draft_project, + ) + + publish_finished_program_projects() + + draft_project.refresh_from_db() + self.assertFalse(draft_project.is_public) + + def test_publish_skips_when_flag_false(self): + program = self.create_program(publish_projects_after_finish=False) + project = self.create_project(name="Private Project") + PartnerProgramProject.objects.create( + partner_program=program, + project=project, + ) + + publish_finished_program_projects() + + project.refresh_from_db() + self.assertFalse(project.is_public) + + def test_publish_after_flag_enabled_post_finish(self): + program = self.create_program(publish_projects_after_finish=False) + project = self.create_project(name="Delayed Project") + PartnerProgramProject.objects.create( + partner_program=program, + project=project, + ) + + publish_finished_program_projects() + project.refresh_from_db() + self.assertFalse(project.is_public) + + program.publish_projects_after_finish = True + program.save(update_fields=["publish_projects_after_finish"]) + + publish_finished_program_projects() + project.refresh_from_db() + self.assertTrue(project.is_public) + + class PartnerProgramFieldValueUpdateSerializerValidTests(TestCase): def setUp(self): now = timezone.now() diff --git a/procollab/celery.py b/procollab/celery.py index 882377a2..a2b9a531 100644 --- a/procollab/celery.py +++ b/procollab/celery.py @@ -17,7 +17,11 @@ "task": "vacancy.tasks.email_notificate_vacancy_outdated", # "schedule": crontab(minute=0, hour=0), "schedule": crontab(minute="*"), - } + }, + "publish_finished_program_projects": { + "task": "partner_programs.tasks.publish_finished_program_projects_task", + "schedule": crontab(minute=0, hour=6), + }, } if __name__ == "__main__": diff --git a/procollab/settings.py b/procollab/settings.py index a5020e37..5a0316b3 100644 --- a/procollab/settings.py +++ b/procollab/settings.py @@ -418,3 +418,4 @@ CELERY_ACCEPT_CONTENT = ["application/json"] CELERY_RESULT_SERIALIZER = "json" CELERY_TASK_SERIALIZER = "json" +CELERY_TIMEZONE = "Europe/Moscow" diff --git a/projects/admin.py b/projects/admin.py index 6f5e8e75..060be9ee 100644 --- a/projects/admin.py +++ b/projects/admin.py @@ -54,6 +54,7 @@ class ProjectAdmin(admin.ModelAdmin): "id", "name", "draft", + "is_public", "is_company", "trl", "target_audience", @@ -61,7 +62,7 @@ class ProjectAdmin(admin.ModelAdmin): ) list_display_links = ("id", "name") search_fields = ("name",) - list_filter = ("draft", "is_company", "trl") + list_filter = ("draft", "is_public", "is_company", "trl") fieldsets = ( ( @@ -74,6 +75,7 @@ class ProjectAdmin(admin.ModelAdmin): "industry", "region", "draft", + "is_public", "is_company", ) }, diff --git a/users/tests.py b/users/tests.py index 409fd63c..3fac35b5 100644 --- a/users/tests.py +++ b/users/tests.py @@ -2,8 +2,9 @@ from rest_framework.test import APIRequestFactory, force_authenticate from tests.constants import USER_CREATE_DATA +from projects.models import Collaborator, Project from users.models import CustomUser -from users.views import UserList, UserDetail +from users.views import UserLeaderProjectsList, UserList, UserDetail class UserTestCase(TestCase): @@ -11,6 +12,7 @@ def setUp(self): self.factory = APIRequestFactory() self.user_list_view = UserList.as_view() self.user_detail_view = UserDetail.as_view() + self.user_leader_projects_view = UserLeaderProjectsList.as_view() def test_user_creation(self): request = self.factory.post("auth/users/", USER_CREATE_DATA) @@ -58,3 +60,36 @@ def test_user_update(self): response = self.user_detail_view(request, pk=user.pk) self.assertEqual(response.status_code, 200) self.assertEqual(response.data["first_name"], "Сергей") + + def test_user_leader_projects_list(self): + leader = self._user_create("leader@example.com") + collaborator = self._user_create("collaborator@example.com") + + leader_project = Project.objects.create(name="Leader project", leader=leader) + second_leader_project = Project.objects.create( + name="Leader project 2", leader=leader, draft=False + ) + other_project = Project.objects.create(name="Other project", leader=collaborator) + Collaborator.objects.create(user=leader, project=other_project, role="Member") + + request = self.factory.get("users/projects/leader/") + force_authenticate(request, user=leader) + response = self.user_leader_projects_view(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 2) + returned_ids = {item["id"] for item in response.data["results"]} + self.assertSetEqual( + returned_ids, {leader_project.id, second_leader_project.id} + ) + + def _user_create(self, email): + tmp_create_data = USER_CREATE_DATA.copy() + tmp_create_data["email"] = email + request = self.factory.post("auth/users/", tmp_create_data) + response = self.user_list_view(request) + user_id = response.data["id"] + user = CustomUser.objects.get(id=user_id) + user.is_active = True + user.save() + return user diff --git a/users/urls.py b/users/urls.py index cf799bef..907a9c15 100644 --- a/users/urls.py +++ b/users/urls.py @@ -9,6 +9,7 @@ SpecialistsList, UserAdditionalRolesView, UserDetail, + UserLeaderProjectsList, UserProjectsList, UserList, UserTypesView, @@ -41,6 +42,7 @@ path("users/", UserList.as_view()), path('public-users/', PublicUserListView.as_view(), name='public-users'), path("users/projects/", UserProjectsList.as_view()), + path("users/projects/leader/", UserLeaderProjectsList.as_view()), path("users/liked/", LikedProjectList.as_view()), path("users/roles/", UserAdditionalRolesView.as_view()), path("users/types/", UserTypesView.as_view()), diff --git a/users/views.py b/users/views.py index e9223709..4a59332f 100644 --- a/users/views.py +++ b/users/views.py @@ -426,6 +426,28 @@ def get(self, request): ) +class UserLeaderProjectsList(GenericAPIView): + permission_classes = [IsAuthenticated] + pagination_class = ProjectsPagination + serializer_class = UserProjectListSerializer + + def get(self, request): + queryset = ( + Project.objects.filter(leader_id=self.request.user.id) + .prefetch_related("program_links__partner_program") + .distinct() + ) + + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + return Response( + {"detail": "Unable to return paginated list"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + class LogoutView(APIView): permission_classes = [IsAuthenticated]