Skip to content

Commit 9fea4e5

Browse files
authored
Update PROJECTS_ENABLED to use middleware for url blocking (#4526)
This PR introduces a runtime approach to controlling access to the Projects feature based on the `PROJECTS_ENABLED` setting. Rather than conditionally registering URL patterns, a new middleware is implemented to handle access control to project-related URLs at request time. This change improves the reliability of the feature toggle system and avoids potential issues with module loading order and settings configuration. ## 💡 Implementation Details The middleware works by resolving the current URL path and checking if it belongs to a "projects" namespace. When the `PROJECTS_ENABLED` setting is `False` and a user attempts to access a projects-related URL, they'll receive a 404 error instead of seeing the projects functionality. This approach improves upon the previous implementation by: 1. Separating the concerns of URL registration and feature availability 2. Avoiding potential race conditions or initialization issues 3. Providing a more consistent user experience when the feature is disabled ## 👀 Areas for Review Attention - Middleware ordering in the settings file - is this the optimal position? - Performance impact of URL resolution for every request - Handling of nested namespaces containing "projects" Closes #4514
1 parent fdafcb4 commit 9fea4e5

4 files changed

Lines changed: 149 additions & 6 deletions

File tree

hypha/apply/funds/urls.py

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
from django.conf import settings
21
from django.urls import include, path
32
from django.views.generic import RedirectView
43

@@ -280,9 +279,5 @@
280279
urlpatterns = [
281280
path("submissions/", include(submission_urls)),
282281
path("rounds/", include(rounds_urls)),
282+
path("projects/", include(projects_urls)),
283283
]
284-
285-
if settings.PROJECTS_ENABLED:
286-
urlpatterns += [
287-
path("projects/", include(projects_urls)),
288-
]

hypha/apply/projects/middleware.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from django.conf import settings
2+
from django.http import Http404
3+
from django.urls import Resolver404, resolve
4+
5+
6+
class ProjectsEnabledMiddleware:
7+
"""
8+
Middleware to control access to project urls based on PROJECTS_ENABLED setting.
9+
10+
This makes the decision at runtime rather than at URL pattern registration time,
11+
avoiding potential issues with module loading order and settings configuration.
12+
"""
13+
14+
def __init__(self, get_response):
15+
self.get_response = get_response
16+
17+
def __call__(self, request):
18+
# Skip processing if PROJECTS_ENABLED is true
19+
if settings.PROJECTS_ENABLED:
20+
return self.get_response(request)
21+
22+
# Check if the current URL is in the projects namespace
23+
try:
24+
resolver_match = resolve(request.path)
25+
if "projects" in getattr(resolver_match, "namespaces", []):
26+
raise Http404("Projects functionality is disabled")
27+
except Resolver404:
28+
# Not a URL we can resolve, let Django handle it
29+
pass
30+
31+
# Continue processing the request
32+
return self.get_response(request)
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
from unittest.mock import Mock, patch
2+
3+
import pytest
4+
from django.http import Http404
5+
from django.urls import Resolver404
6+
7+
from hypha.apply.projects.middleware import ProjectsEnabledMiddleware
8+
9+
10+
@pytest.fixture
11+
def middleware():
12+
get_response_mock = Mock()
13+
return ProjectsEnabledMiddleware(get_response_mock), get_response_mock
14+
15+
16+
@pytest.mark.parametrize(
17+
"projects_enabled,namespaces,path,should_raise_404",
18+
[
19+
# When projects are enabled, project URLs are accessible
20+
(True, ["projects"], "/projects/some/path/", False),
21+
# When projects are disabled, project URLs raise Http404
22+
(False, ["projects"], "/projects/some/path/", True),
23+
# When projects are disabled, non-project URLs are still accessible
24+
(False, ["submissions"], "/submissions/path/", False),
25+
# Nested projects namespaces are properly handled
26+
(False, ["funds", "projects"], "/funds/projects/some/path/", True),
27+
# Projects in nested namespaces are accessible when enabled
28+
(True, ["funds", "projects"], "/funds/projects/some/path/", False),
29+
],
30+
)
31+
def test_projects_middleware_access_control(
32+
middleware, rf, settings, projects_enabled, namespaces, path, should_raise_404
33+
):
34+
"""
35+
Test that middleware controls access to project URLs based on settings.PROJECTS_ENABLED
36+
"""
37+
# Setup
38+
settings.PROJECTS_ENABLED = projects_enabled
39+
middleware_instance, get_response_mock = middleware
40+
request = rf.get(path)
41+
42+
with patch("hypha.apply.projects.middleware.resolve") as mock_resolve:
43+
resolver_match = Mock(namespaces=namespaces, url_name="project-detail")
44+
mock_resolve.return_value = resolver_match
45+
46+
# Execute
47+
if should_raise_404:
48+
with pytest.raises(Http404) as excinfo:
49+
middleware_instance(request)
50+
assert str(excinfo.value) == "Projects functionality is disabled"
51+
get_response_mock.assert_not_called()
52+
else:
53+
response = middleware_instance(request)
54+
get_response_mock.assert_called_once_with(request)
55+
assert response == get_response_mock.return_value
56+
57+
# Verify resolve was called only if projects are disabled
58+
if projects_enabled:
59+
mock_resolve.assert_not_called()
60+
else:
61+
mock_resolve.assert_called_once_with(request.path)
62+
63+
64+
def test_resolver404_passes_through(middleware, rf, settings):
65+
"""
66+
Test that Resolver404 exceptions are caught and handled properly
67+
(letting Django handle them as it normally would).
68+
"""
69+
middleware_instance, get_response_mock = middleware
70+
request = rf.get("/nonexistent/path/")
71+
72+
settings.PROJECTS_ENABLED = False
73+
74+
with patch("hypha.apply.projects.middleware.resolve") as mock_resolve:
75+
mock_resolve.side_effect = Resolver404()
76+
77+
response = middleware_instance(request)
78+
79+
mock_resolve.assert_called_once_with(request.path)
80+
get_response_mock.assert_called_once_with(request)
81+
assert response == get_response_mock.return_value
82+
83+
84+
@pytest.mark.parametrize(
85+
"namespaces_value",
86+
[
87+
# Empty namespaces list
88+
[],
89+
# No namespaces attribute
90+
None,
91+
],
92+
)
93+
def test_non_project_routes_allowed(middleware, rf, settings, namespaces_value):
94+
"""
95+
Test that URLs with no namespaces or no namespaces attribute are allowed through.
96+
"""
97+
settings.PROJECTS_ENABLED = False # Even when projects are disabled
98+
middleware_instance, get_response_mock = middleware
99+
request = rf.get("/some/path/")
100+
101+
with patch("hypha.apply.projects.middleware.resolve") as mock_resolve:
102+
if namespaces_value is None:
103+
# Create a mock without namespaces attribute
104+
resolver_match = Mock(spec=["url_name"])
105+
else:
106+
resolver_match = Mock()
107+
resolver_match.namespaces = namespaces_value
108+
109+
mock_resolve.return_value = resolver_match
110+
111+
response = middleware_instance(request)
112+
113+
mock_resolve.assert_called_once_with(request.path)
114+
get_response_mock.assert_called_once_with(request)
115+
assert response == get_response_mock.return_value

hypha/settings/django.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@
9595
"hypha.apply.middleware.HandleProtectionErrorMiddleware",
9696
"django_htmx.middleware.HtmxMiddleware",
9797
"hypha.core.middleware.htmx.HtmxMessageMiddleware",
98+
"hypha.apply.projects.middleware.ProjectsEnabledMiddleware",
9899
]
99100

100101
# Logging

0 commit comments

Comments
 (0)