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()