diff --git a/apps/commons/views.py b/apps/commons/views.py index 71937a9b..3031ad7c 100644 --- a/apps/commons/views.py +++ b/apps/commons/views.py @@ -10,7 +10,7 @@ from apps.accounts.permissions import ProjectNestedPermission from apps.organizations.models import Organization from apps.organizations.utils import get_below_hierarchy_codes -from apps.projects.models import Project +from apps.projects.models import Project, ProjectTab from .mixins import HasMultipleIDs @@ -185,6 +185,19 @@ def get_permissions(self): return [ProjectNestedPermission(), *super().get_permissions()] +class NestedProjectTabViewMixins: + tab: ProjectTab + project: Project + + def initial(self, request, *args, **kwargs): + + self.tab = get_object_or_404( + self.project.modules_by_user(request.user).tabs(), pk=kwargs["tab_id"] + ) + + super().initial(request, *args, **kwargs) + + class NestedPeopleGroupViewMixins: def initial(self, request, *args, **kwargs): self.people_group = get_object_or_404( diff --git a/apps/modules/__init__.py b/apps/modules/__init__.py index 38b9d7e4..a8a2f3f7 100644 --- a/apps/modules/__init__.py +++ b/apps/modules/__init__.py @@ -1,4 +1,5 @@ from .group import PeopleGroupModules from .project import ProjectModules +from .tab import TabModules -__all__ = ["PeopleGroupModules", "ProjectModules"] +__all__ = ["PeopleGroupModules", "ProjectModules", "TabModules"] diff --git a/apps/modules/project.py b/apps/modules/project.py index 9c7e8aa5..9192495f 100644 --- a/apps/modules/project.py +++ b/apps/modules/project.py @@ -12,6 +12,7 @@ Location, Project, ProjectMessage, + ProjectTab, ) @@ -114,3 +115,6 @@ def reviews(self) -> QuerySet[Review]: def messages(self) -> QuerySet[ProjectMessage]: return self.instance.messages.all() + + def tabs(self) -> QuerySet[ProjectTab]: + return self.instance.additional_tabs.all() diff --git a/apps/modules/tab.py b/apps/modules/tab.py new file mode 100644 index 00000000..e9ca289f --- /dev/null +++ b/apps/modules/tab.py @@ -0,0 +1,12 @@ +from django.db.models import QuerySet + +from apps.modules.base import AbstractModules, register_module +from apps.projects.models import ProjectTab, ProjectTabItem + + +@register_module(ProjectTab) +class TabModules(AbstractModules): + instance: ProjectTab + + def items(self) -> QuerySet[ProjectTabItem]: + return self.instance.items.all() diff --git a/apps/projects/filters.py b/apps/projects/filters.py index 8a1f5ef0..5e81d166 100644 --- a/apps/projects/filters.py +++ b/apps/projects/filters.py @@ -10,7 +10,7 @@ ) from apps.organizations.utils import get_below_hierarchy_codes -from .models import Location, Project +from .models import Location, Project, ProjectTab, ProjectTabItem class ProjectFilterMixin(filters.FilterSet): @@ -118,3 +118,28 @@ class ProjectGroupsFilter(filters.FilterSet): class Meta: model = PeopleGroup fields = ("role",) + + +class ProjectTabFilter(filters.FilterSet): + type = filters.CharFilter() # noqa: A003 + show_preview = filters.BooleanFilter() + + class Meta: + model = ProjectTab + fields = ("type", "show_preview") + + +class ProjectTabItemFilter(filters.FilterSet): + from_date = filters.CharFilter(method="range_filter_from", label="form_date") + to_date = filters.CharFilter(method="range_filter_to", label="to_date") + + class Meta: + model = ProjectTabItem + fields = ("from_date", "to_date") + + def range_filter_from(self, queryset, name, value): + return queryset.filter(created_at__gte=value) + + def range_filter_to(self, queryset, name, value): + # same above but with start_date + return queryset.filter(created_at__lte=value) diff --git a/apps/projects/migrations/0004_projecttab_show_preview_alter_projecttab_description_and_more.py b/apps/projects/migrations/0004_projecttab_show_preview_alter_projecttab_description_and_more.py new file mode 100644 index 00000000..097e84cc --- /dev/null +++ b/apps/projects/migrations/0004_projecttab_show_preview_alter_projecttab_description_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 6.0.4 on 2026-06-15 13:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("projects", "0003_alter_location_type"), + ] + + operations = [ + migrations.AddField( + model_name="projecttab", + name="show_preview", + field=models.BooleanField(default=True), + ), + migrations.AlterField( + model_name="projecttab", + name="description", + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name="projecttab", + name="icon", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name="projecttabitem", + name="content", + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/apps/projects/models.py b/apps/projects/models.py index d3ca08c5..8a19a1d1 100644 --- a/apps/projects/models.py +++ b/apps/projects/models.py @@ -1005,7 +1005,9 @@ def is_owned_by(self, user: "ProjectUser") -> bool: return self.author == user -class ProjectTab(HasAutoTranslatedFields, ProjectRelated, models.Model): +class ProjectTab( + HasRelatedModules, HasAutoTranslatedFields, ProjectRelated, models.Model +): """A tab in the project page. Attributes @@ -1037,9 +1039,10 @@ class TabType(models.TextChoices): max_length=32, choices=TabType.choices, default=TabType.TEXT ) title = models.CharField(max_length=255) - description = models.TextField(blank=True) - icon = models.CharField(max_length=255, blank=True) + description = models.TextField(blank=True, null=True) + icon = models.CharField(max_length=255, blank=True, null=True) images = models.ManyToManyField("files.Image", related_name="project_tabs") + show_preview = models.BooleanField(default=True) def get_related_project(self) -> Project: """Return the projects related to this model.""" @@ -1072,7 +1075,7 @@ class ProjectTabItem(HasAutoTranslatedFields, ProjectRelated, models.Model): "projects.ProjectTab", on_delete=models.CASCADE, related_name="items" ) title = models.CharField(max_length=255) - content = models.TextField(blank=True) + content = models.TextField(blank=True, null=True) images = models.ManyToManyField("files.Image", related_name="project_tab_items") created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) diff --git a/apps/projects/serializers.py b/apps/projects/serializers.py index e9177de5..2a0ed290 100644 --- a/apps/projects/serializers.py +++ b/apps/projects/serializers.py @@ -900,6 +900,7 @@ def get_string_images_kwargs( @auto_translated class ProjectTabSerializer( + ModulesSerializers, StringsImagesSerializer, serializers.ModelSerializer, ): @@ -914,13 +915,14 @@ class ProjectTabSerializer( class Meta: model = ProjectTab - read_only_fields = ["id"] + read_only_fields = ["id", "modules"] fields = read_only_fields + [ "type", "title", "description", "icon", "images", + "show_preview", ] def validate_type(self, value: str): diff --git a/apps/projects/tests/views/test_project_tab.py b/apps/projects/tests/views/test_project_tab.py index aecceebd..b7e6711b 100644 --- a/apps/projects/tests/views/test_project_tab.py +++ b/apps/projects/tests/views/test_project_tab.py @@ -109,13 +109,16 @@ def test_retrieve_project_tab(self, role, retrieved_tabs): user = self.get_parameterized_test_user(role, instances=[project]) self.client.force_authenticate(user) response = self.client.get(reverse("ProjectTab-list", args=(project.id,))) - self.assertEqual(response.status_code, status.HTTP_200_OK) - content = response.json()["results"] if publication_status in retrieved_tabs: + self.assertEqual(response.status_code, status.HTTP_200_OK) + content = response.json()["results"] self.assertEqual(len(content), 1) self.assertEqual(content[0]["id"], tab.id) else: - self.assertEqual(len(content), 0) + self.assertIn( + response.status_code, + (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN), + ) class UpdateProjectTabTestCase(JwtAPITestCase): diff --git a/apps/projects/tests/views/test_project_tab_image.py b/apps/projects/tests/views/test_project_tab_image.py index 4b6a9c36..2fe00061 100644 --- a/apps/projects/tests/views/test_project_tab_image.py +++ b/apps/projects/tests/views/test_project_tab_image.py @@ -50,7 +50,6 @@ def setUpTestData(cls): (TestRoles.ANONYMOUS, ("public",)), (TestRoles.DEFAULT, ("public",)), (TestRoles.SUPERADMIN, ("public", "org", "private")), - (TestRoles.OWNER, ("public", "org", "private")), (TestRoles.ORG_ADMIN, ("public", "org", "private")), (TestRoles.ORG_FACILITATOR, ("public", "org", "private")), (TestRoles.ORG_USER, ("public", "org")), @@ -73,7 +72,10 @@ def test_retrieve_project_tab_image(self, role, retrieved_images): if publication_status in retrieved_images: self.assertEqual(response.status_code, status.HTTP_302_FOUND) else: - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertIn( + response.status_code, + (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN), + ) class CreateProjectTabImageTestCase(JwtAPITestCase): diff --git a/apps/projects/tests/views/test_project_tab_item.py b/apps/projects/tests/views/test_project_tab_item.py index d8ce8dcf..4d25244d 100644 --- a/apps/projects/tests/views/test_project_tab_item.py +++ b/apps/projects/tests/views/test_project_tab_item.py @@ -112,16 +112,19 @@ def test_retrieve_project_tab_items(self, role, retrieved_items): response = self.client.get( reverse("ProjectTabItem-list", args=(project.id, tab.id)) ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - content = response.json()["results"] if publication_status in retrieved_items: + self.assertEqual(response.status_code, status.HTTP_200_OK) + content = response.json()["results"] self.assertEqual(len(content), 2) self.assertSetEqual( {item["id"] for item in content}, {item.id for item in item} ) self.assertGreater(content[0]["created_at"], content[1]["created_at"]) else: - self.assertEqual(len(content), 0) + self.assertIn( + response.status_code, + (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN), + ) class UpdateProjectTabItemTestCase(JwtAPITestCase): diff --git a/apps/projects/tests/views/test_project_tab_item_image.py b/apps/projects/tests/views/test_project_tab_item_image.py index 5f0c47ff..dfb7f0bd 100644 --- a/apps/projects/tests/views/test_project_tab_item_image.py +++ b/apps/projects/tests/views/test_project_tab_item_image.py @@ -59,7 +59,6 @@ def setUpTestData(cls): (TestRoles.ANONYMOUS, ("public",)), (TestRoles.DEFAULT, ("public",)), (TestRoles.SUPERADMIN, ("public", "org", "private")), - (TestRoles.OWNER, ("public", "org", "private")), (TestRoles.ORG_ADMIN, ("public", "org", "private")), (TestRoles.ORG_FACILITATOR, ("public", "org", "private")), (TestRoles.ORG_USER, ("public", "org")), @@ -85,7 +84,10 @@ def test_retrieve_tab_item_image(self, role, retrieved_images): if publication_status in retrieved_images: self.assertEqual(response.status_code, status.HTTP_302_FOUND) else: - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertIn( + response.status_code, + (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN), + ) class CreateProjectTabItemImageTestCase(JwtAPITestCase): diff --git a/apps/projects/views.py b/apps/projects/views.py index c0d273f4..36c38edd 100644 --- a/apps/projects/views.py +++ b/apps/projects/views.py @@ -5,7 +5,7 @@ from django.core.cache import cache from django.db import transaction from django.db.models import QuerySet -from django.shortcuts import get_object_or_404, redirect +from django.shortcuts import redirect from django.utils.decorators import method_decorator from django_filters.rest_framework import DjangoFilterBackend from drf_spectacular.utils import OpenApiParameter, extend_schema @@ -27,6 +27,7 @@ from apps.commons.utils import map_action_to_permission from apps.commons.views import ( MultipleIDViewsetMixin, + NestedProjectTabViewMixins, NestedProjectViewMixins, QuerySerializersMixin, ) @@ -49,7 +50,13 @@ OrganizationsParameterMissing, ) -from .filters import ProjectFilter, ProjectGroupsFilter, ProjectMembersFilter +from .filters import ( + ProjectFilter, + ProjectGroupsFilter, + ProjectMembersFilter, + ProjectTabFilter, + ProjectTabItemFilter, +) from .models import ( BlogEntry, LinkedProject, @@ -780,11 +787,12 @@ def add_image_to_model(self, image, *args, **kwargs): return f"/v1/project/{self.project.id}/project-message-image/{image.id}" -class ProjectTabViewset(MultipleIDViewsetMixin, viewsets.ModelViewSet): +class ProjectTabViewset(NestedProjectViewMixins, viewsets.ModelViewSet): """Project tabs.""" serializer_class = ProjectTabSerializer filter_backends = [DjangoFilterBackend] + filterset_class = ProjectTabFilter lookup_field = "id" lookup_value_regex = "[^/]+" permission_classes = [ @@ -795,23 +803,15 @@ class ProjectTabViewset(MultipleIDViewsetMixin, viewsets.ModelViewSet): | HasOrganizationPermission("change_project") | HasProjectPermission("change_project"), ] - multiple_lookup_fields = [(Project, "project_id")] def get_queryset(self) -> QuerySet[ProjectTab]: - if "project_id" in self.kwargs: - return self.request.user.get_project_related_queryset( - ProjectTab.objects.filter(project=self.kwargs["project_id"]) - ) - return ProjectTab.objects.none() + return self.project.modules_by_user(self.request.user).tabs() def perform_create(self, serializer: ProjectTabSerializer): - project_id = self.kwargs.get("project_id") - if project_id: - project = get_object_or_404(Project, id=project_id) - serializer.save(project=project) + serializer.save(project=self.project) -class ProjectTabImagesView(MultipleIDViewsetMixin, ImageStorageView): +class ProjectTabImagesView(NestedProjectViewMixins, ImageStorageView): permission_classes = [ IsAuthenticatedOrReadOnly, ProjectIsNotLocked, @@ -821,19 +821,15 @@ class ProjectTabImagesView(MultipleIDViewsetMixin, ImageStorageView): | HasOrganizationPermission("change_project") | HasProjectPermission("change_project"), ] - multiple_lookup_fields = [(Project, "project_id")] def get_queryset(self): - if "project_id" in self.kwargs: - qs = self.request.user.get_project_related_queryset( - Image.objects.filter(project_tabs__project=self.kwargs["project_id"]), - project_related_name="project_tabs__project", - ) - # Retrieve images before tab is posted - if self.request.user.is_authenticated: - qs = qs | Image.objects.filter(owner=self.request.user) - return qs.distinct() - return Image.objects.none() + tabs_qs = self.project.modules_by_user(self.request.user).tabs() + + qs = Image.objects.filter(project_tabs__in=tabs_qs) + # Retrieve images before tab is posted + if self.request.user.is_authenticated: + qs = qs | Image.objects.filter(owner=self.request.user) + return qs.distinct() @staticmethod def upload_to(instance, filename) -> str: @@ -844,23 +840,25 @@ def retrieve(self, request, *args, **kwargs): return redirect(image.file.url) def add_image_to_model(self, image, *args, **kwargs): - if "project_id" in self.kwargs: - if "tab_id" in self.request.query_params: - project_tab = ProjectTab.objects.get( - project_id=self.kwargs["project_id"], - id=self.request.query_params["tab_id"], - ) - project_tab.images.add(image) - project_tab.save() - return f"/v1/project/{self.kwargs['project_id']}/tab-image/{image.id}" - return None + if "tab_id" in self.request.query_params: + project_tab = ProjectTab.objects.get( + project=self.project, + id=self.request.query_params["tab_id"], + ) + project_tab.images.add(image) + project_tab.save() + return f"/v1/project/{self.project.id}/tab-image/{image.id}" -class ProjectTabItemViewset(MultipleIDViewsetMixin, viewsets.ModelViewSet): +class ProjectTabItemViewset( + NestedProjectViewMixins, NestedProjectTabViewMixins, viewsets.ModelViewSet +): """Project tabs.""" serializer_class = ProjectTabItemSerializer - filter_backends = [DjangoFilterBackend] + filter_backends = [DjangoFilterBackend, OrderingFilter] + filterset_class = ProjectTabItemFilter + ordering_fields = ("created_at", "updated_at") lookup_field = "id" lookup_value_regex = "[^/]+" permission_classes = [ @@ -871,28 +869,17 @@ class ProjectTabItemViewset(MultipleIDViewsetMixin, viewsets.ModelViewSet): | HasOrganizationPermission("change_project") | HasProjectPermission("change_project"), ] - multiple_lookup_fields = [(Project, "project_id")] def get_queryset(self) -> QuerySet[ProjectTabItem]: - if "project_id" in self.kwargs and "tab_id" in self.kwargs: - return self.request.user.get_project_related_queryset( - ProjectTabItem.objects.filter( - tab__project=self.kwargs["project_id"], - tab=self.kwargs["tab_id"], - ), - project_related_name="tab__project", - ) - return ProjectTabItem.objects.none() + return self.tab.items.all() def perform_create(self, serializer: ProjectTabItemSerializer): - project_id = self.kwargs.get("project_id") - tab_id = self.kwargs.get("tab_id") - if project_id and tab_id: - tab = get_object_or_404(ProjectTab, id=tab_id, project_id=project_id) - serializer.save(tab=tab) + serializer.save(tab=self.tab) -class ProjectTabItemImagesView(MultipleIDViewsetMixin, ImageStorageView): +class ProjectTabItemImagesView( + NestedProjectViewMixins, NestedProjectTabViewMixins, ImageStorageView +): permission_classes = [ IsAuthenticatedOrReadOnly, ProjectIsNotLocked,