diff --git a/settings/common.py b/settings/common.py index e9715241..c32f5cdb 100644 --- a/settings/common.py +++ b/settings/common.py @@ -403,6 +403,11 @@ REST_FRAMEWORK = { "DATETIME_FORMAT": "%Y-%m-%dT%H:%M:%S%z" } +# Extra expose header related to Taiga APP (see taiga.base.middleware.cors=) +APP_EXTRA_EXPOSE_HEADERS = [ + "taiga-info-total-opened-milestones", + "taiga-info-total-closed-milestones" +] DEFAULT_PROJECT_TEMPLATE = "scrum" PUBLIC_REGISTER_ENABLED = False diff --git a/taiga/base/middleware/cors.py b/taiga/base/middleware/cors.py index 3bc8bfac..3d6aed10 100644 --- a/taiga/base/middleware/cors.py +++ b/taiga/base/middleware/cors.py @@ -15,6 +15,7 @@ # along with this program. If not, see . from django import http +from django.conf import settings COORS_ALLOWED_ORIGINS = "*" @@ -28,7 +29,7 @@ COORS_EXPOSE_HEADERS = ["x-pagination-count", "x-paginated", "x-paginated-by", "x-pagination-current", "x-pagination-next", "x-pagination-prev", "x-site-host", "x-site-register"] -TAIGA_EXPOSE_HEADERS = ["taiga-info-has-closed-milestones"] +COORS_EXTRA_EXPOSE_HEADERS = getattr(settings, "APP_EXTRA_EXPOSE_HEADERS", []) class CoorsMiddleware(object): @@ -36,7 +37,7 @@ class CoorsMiddleware(object): response["Access-Control-Allow-Origin"] = COORS_ALLOWED_ORIGINS response["Access-Control-Allow-Methods"] = ",".join(COORS_ALLOWED_METHODS) response["Access-Control-Allow-Headers"] = ",".join(COORS_ALLOWED_HEADERS) - response["Access-Control-Expose-Headers"] = ",".join(COORS_EXPOSE_HEADERS + TAIGA_EXPOSE_HEADERS) + response["Access-Control-Expose-Headers"] = ",".join(COORS_EXPOSE_HEADERS + COORS_EXTRA_EXPOSE_HEADERS) response["Access-Control-Max-Age"] = "3600" if COORS_ALLOWED_CREDENTIALS: diff --git a/taiga/base/utils/db.py b/taiga/base/utils/db.py index a9663751..40afea81 100644 --- a/taiga/base/utils/db.py +++ b/taiga/base/utils/db.py @@ -16,10 +16,28 @@ from django.contrib.contenttypes.models import ContentType from django.db import transaction +from django.shortcuts import _get_queryset from . import functions +def get_object_or_none(klass, *args, **kwargs): + """ + Uses get() to return an object, or None if the object does not exist. + + klass may be a Model, Manager, or QuerySet object. All other passed + arguments and keyword arguments are used in the get() query. + + Note: Like with get(), an MultipleObjectsReturned will be raised if more + than one object is found. + """ + queryset = _get_queryset(klass) + try: + return queryset.get(*args, **kwargs) + except queryset.model.DoesNotExist: + return None + + def get_typename_for_model_class(model:object, for_concrete_model=True) -> str: """ Get typename for model instance. diff --git a/taiga/projects/milestones/api.py b/taiga/projects/milestones/api.py index 3194dc39..e2e62363 100644 --- a/taiga/projects/milestones/api.py +++ b/taiga/projects/milestones/api.py @@ -14,16 +14,18 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +from django.apps import apps + from taiga.base import filters from taiga.base import response from taiga.base.decorators import detail_route from taiga.base.api import ModelCrudViewSet, ModelListViewSet from taiga.base.api.utils import get_object_or_404 +from taiga.base.utils.db import get_object_or_none from taiga.projects.notifications.mixins import WatchedResourceMixin, WatchersViewSetMixin from taiga.projects.history.mixins import HistoryResourceMixin - from . import serializers from . import models from . import permissions @@ -38,6 +40,26 @@ class MilestoneViewSet(HistoryResourceMixin, WatchedResourceMixin, ModelCrudView filter_fields = ("project", "closed") queryset = models.Milestone.objects.all() + def list(self, request, *args, **kwargs): + res = super().list(request, *args, **kwargs) + self._add_taiga_info_headers() + return res + + def _add_taiga_info_headers(self): + try: + project_id = int(self.request.QUERY_PARAMS.get("project", None)) + project_model = apps.get_model("projects", "Project") + project = get_object_or_none(project_model, id=project_id) + except TypeError: + project = None + + if project: + opened_milestones = project.milestones.filter(closed=False).count() + closed_milestones = project.milestones.filter(closed=True).count() + + self.headers["Taiga-Info-Total-Opened-Milestones"] = opened_milestones + self.headers["Taiga-Info-Total-Closed-Milestones"] = closed_milestones + def get_queryset(self): qs = super().get_queryset() qs = self.attach_watchers_attrs_to_queryset(qs) diff --git a/taiga/projects/userstories/api.py b/taiga/projects/userstories/api.py index a3737488..9869159a 100644 --- a/taiga/projects/userstories/api.py +++ b/taiga/projects/userstories/api.py @@ -228,13 +228,6 @@ class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixi field=order_field) services.snapshot_userstories_in_bulk(data["bulk_stories"], request.user) - if order_field in ["sprint_order", "backlog_order"]: - # NOTE: This is useful according to issue #2851 to update sprints column in - # the browser client when move USs from the backlog to an sprint, from - # an sprint to the backlog or between sprints. - has_closed_milestones = project.milestones.filter(closed=True).exists() - self.headers["Taiga-Info-Has-Closed-Milestones"] = has_closed_milestones - return response.NoContent() @list_route(methods=["POST"]) diff --git a/tests/integration/test_milestones.py b/tests/integration/test_milestones.py index 1b3410a1..932da487 100644 --- a/tests/integration/test_milestones.py +++ b/tests/integration/test_milestones.py @@ -47,3 +47,36 @@ def test_update_milestone_with_userstories_list(client): client.login(user) response = client.json.patch(url, json.dumps(form_data)) assert response.status_code == 200 + + +def test_list_milestones_taiga_info_headers(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + role = f.RoleFactory.create(project=project) + f.MembershipFactory.create(project=project, user=user, role=role, is_owner=True) + + f.MilestoneFactory.create(project=project, owner=user, closed=True) + f.MilestoneFactory.create(project=project, owner=user, closed=True) + f.MilestoneFactory.create(project=project, owner=user, closed=True) + f.MilestoneFactory.create(project=project, owner=user, closed=False) + f.MilestoneFactory.create(owner=user, closed=False) + + url = reverse("milestones-list") + + client.login(project.owner) + response1 = client.json.get(url) + response2 = client.json.get(url, {"project": project.id}) + + assert response1.status_code == 200 + assert "taiga-info-total-closed-milestones" in response1["access-control-expose-headers"] + assert "taiga-info-total-opened-milestones" in response1["access-control-expose-headers"] + assert response1.has_header("Taiga-Info-Total-Closed-Milestones") == False + assert response1.has_header("Taiga-Info-Total-Opened-Milestones") == False + + assert response2.status_code == 200 + assert "taiga-info-total-closed-milestones" in response2["access-control-expose-headers"] + assert "taiga-info-total-opened-milestones" in response2["access-control-expose-headers"] + assert response2.has_header("Taiga-Info-Total-Closed-Milestones") == True + assert response2.has_header("Taiga-Info-Total-Opened-Milestones") == True + assert response2["taiga-info-total-closed-milestones"] == "3" + assert response2["taiga-info-total-opened-milestones"] == "1" diff --git a/tests/integration/test_userstories.py b/tests/integration/test_userstories.py index 1eaea1b1..33b18a8d 100644 --- a/tests/integration/test_userstories.py +++ b/tests/integration/test_userstories.py @@ -134,51 +134,6 @@ def test_api_update_orders_in_bulk(client): assert response2.status_code == 204, response2.data assert response3.status_code == 204, response3.data -def test_api_update_orders_in_bulk_to_test_extra_headers(client): - project = f.create_project() - f.MembershipFactory.create(project=project, user=project.owner, is_owner=True) - us1 = f.create_userstory(project=project) - us2 = f.create_userstory(project=project) - - url1 = reverse("userstories-bulk-update-backlog-order") - url2 = reverse("userstories-bulk-update-kanban-order") - url3 = reverse("userstories-bulk-update-sprint-order") - - data = { - "project_id": project.id, - "bulk_stories": [{"us_id": us1.id, "order": 1}, - {"us_id": us2.id, "order": 2}] - } - - client.login(project.owner) - - response1 = client.json.post(url1, json.dumps(data)) - response2 = client.json.post(url2, json.dumps(data)) - response3 = client.json.post(url3, json.dumps(data)) - assert response1.status_code == 204 - assert response1.has_header("Taiga-Info-Has-Closed-Milestones") == True - assert response1["taiga-info-has-closed-milestones"] == "False" - assert response2.status_code == 204 - assert response2.has_header("Taiga-Info-Has-Closed-Milestones") == False - assert response3.status_code == 204 - assert response3.has_header("Taiga-Info-Has-Closed-Milestones") == True - assert response3["taiga-info-has-closed-milestones"] == "False" - - us1.milestone.closed = True - us1.milestone.save() - - response1 = client.json.post(url1, json.dumps(data)) - response2 = client.json.post(url2, json.dumps(data)) - response3 = client.json.post(url3, json.dumps(data)) - assert response1.status_code == 204 - assert response1.has_header("Taiga-Info-Has-Closed-Milestones") == True - assert response3["taiga-info-has-closed-milestones"] == "True" - assert response2.status_code == 204 - assert response2.has_header("Taiga-Info-Has-Closed-Milestones") == False - assert response3.status_code == 204 - assert response3.has_header("Taiga-Info-Has-Closed-Milestones") == True - assert response3["taiga-info-has-closed-milestones"] == "True" - def test_update_userstory_points(client): user1 = f.UserFactory.create()