Skip to content

Commit 8bbb70a

Browse files
authored
fix(projects): use normalized name lookup for project URLs (#1247)
Implement PyPI-style PEP 426 name normalization for project lookups: - Use func.normalize_pep426_name() to query projects case-insensitively - Redirect non-canonical URLs to canonical names with 301 redirects - Update template to use project.name instead of normalized_name This fixes issues where projects with capitalized names (e.g., Watson) could not be accessed via lowercase URLs (/projects/watson). Now: - /projects/watson -> 301 redirect to /projects/Watson - /projects/Watson -> 200 OK (canonical URL) - /projects/some_project -> 301 redirect to /projects/some-project
1 parent 9fc2ebe commit 8bbb70a

File tree

3 files changed

+131
-2
lines changed

3 files changed

+131
-2
lines changed

jazzband/projects/views.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,16 @@ def index():
9595

9696
class ProjectMixin:
9797
def project_query(self, name):
98-
return Project.query.filter(Project.is_active.is_(True), Project.name == name)
98+
"""
99+
Query for a project by normalized name (case-insensitive).
100+
101+
Uses the same PEP 426 normalization as PyPI: lowercase and
102+
collapse runs of dots, underscores, and hyphens to a single hyphen.
103+
"""
104+
return Project.query.filter(
105+
Project.is_active.is_(True),
106+
Project.normalized_name == func.normalize_pep426_name(name),
107+
)
99108

100109
def project_name(self, *args, **kwargs):
101110
name = kwargs.get("name")
@@ -109,6 +118,15 @@ def redirect_to_project(self):
109118
def dispatch_request(self, *args, **kwargs):
110119
name = self.project_name(*args, **kwargs)
111120
self.project = self.project_query(name).first_or_404()
121+
122+
# Redirect to canonical URL if the name doesn't match exactly
123+
# (e.g., /projects/watson -> /projects/Watson)
124+
if name != self.project.name:
125+
return redirect(
126+
url_for(request.endpoint, name=self.project.name),
127+
code=301,
128+
)
129+
112130
return super().dispatch_request(*args, **kwargs)
113131

114132

jazzband/templates/projects/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ <h2>Projects</h2>
4949
<tbody>
5050
{% for project in projects %}
5151
<tr>
52-
<td><a href="{{ url_for('projects.detail', name=project.normalized_name) }}">{{ project.name }}</a></td>
52+
<td><a href="{{ url_for('projects.detail', name=project.name) }}">{{ project.name }}</a></td>
5353
<td class="numbers"></td>
5454
<td class="numbers">{{ project.membership_count or 0 }}</td>
5555
<td class="numbers">{{ project.uploads_count or 0 }}</td>
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"""Function-based tests for project name normalization and canonical URL redirects.
2+
3+
Converted from class-based tests to follow `AGENTS.md` testing conventions.
4+
"""
5+
6+
import pytest
7+
from werkzeug.exceptions import NotFound
8+
9+
from jazzband.projects.views import ProjectMixin
10+
11+
12+
def test_project_query_uses_normalized_name(app, mocker):
13+
"""ProjectMixin.project_query should use normalized name for lookup."""
14+
with app.app_context():
15+
mock_query = mocker.MagicMock()
16+
mock_query.filter.return_value = mock_query
17+
18+
mocker.patch("jazzband.projects.views.Project.query", mock_query)
19+
20+
mixin = ProjectMixin()
21+
mixin.project_query("Watson")
22+
23+
assert mock_query.filter.called
24+
25+
26+
def test_project_name_returns_name_from_kwargs(app):
27+
"""ProjectMixin.project_name should return name from kwargs."""
28+
with app.app_context():
29+
mixin = ProjectMixin()
30+
assert mixin.project_name(name="test-project") == "test-project"
31+
32+
33+
def test_project_name_aborts_if_no_name(app):
34+
"""ProjectMixin.project_name should abort with NotFound if no name provided."""
35+
with app.app_context():
36+
mixin = ProjectMixin()
37+
with pytest.raises(NotFound):
38+
mixin.project_name()
39+
40+
41+
def test_lowercase_url_finds_capitalized_project(app, mocker):
42+
"""/projects/watson should redirect to canonical /projects/Watson."""
43+
with app.test_client() as client:
44+
mock_project = mocker.MagicMock()
45+
mock_project.name = "Watson"
46+
mock_project.is_active = True
47+
48+
mock_query = mocker.MagicMock()
49+
mock_query.filter.return_value.first_or_404.return_value = mock_project
50+
51+
mocker.patch("jazzband.projects.views.Project.query", mock_query)
52+
53+
response = client.get("/projects/watson", follow_redirects=False)
54+
55+
assert response.status_code == 301
56+
assert "/projects/Watson" in response.headers["Location"]
57+
58+
59+
def test_canonical_url_does_not_redirect(app, mocker):
60+
"""Canonical URL should not redirect."""
61+
with app.test_client() as client:
62+
mock_project = mocker.MagicMock()
63+
mock_project.name = "Watson"
64+
mock_project.is_active = True
65+
mock_project.uploads = mocker.MagicMock()
66+
mock_project.uploads.order_by.return_value = []
67+
68+
mock_query = mocker.MagicMock()
69+
mock_query.filter.return_value.first_or_404.return_value = mock_project
70+
71+
mocker.patch("jazzband.projects.views.Project.query", mock_query)
72+
73+
response = client.get("/projects/Watson", follow_redirects=False)
74+
75+
assert response.status_code != 301
76+
77+
78+
def test_uppercase_url_redirects_to_canonical(app, mocker):
79+
"""/projects/WATSON should redirect to canonical /projects/Watson."""
80+
with app.test_client() as client:
81+
mock_project = mocker.MagicMock()
82+
mock_project.name = "Watson"
83+
mock_project.is_active = True
84+
85+
mock_query = mocker.MagicMock()
86+
mock_query.filter.return_value.first_or_404.return_value = mock_project
87+
88+
mocker.patch("jazzband.projects.views.Project.query", mock_query)
89+
90+
response = client.get("/projects/WATSON", follow_redirects=False)
91+
92+
assert response.status_code == 301
93+
assert "/projects/Watson" in response.headers["Location"]
94+
95+
96+
def test_hyphen_underscore_normalization(app, mocker):
97+
"""/projects/some_project should redirect to canonical /projects/some-project."""
98+
with app.test_client() as client:
99+
mock_project = mocker.MagicMock()
100+
mock_project.name = "some-project"
101+
mock_project.is_active = True
102+
103+
mock_query = mocker.MagicMock()
104+
mock_query.filter.return_value.first_or_404.return_value = mock_project
105+
106+
mocker.patch("jazzband.projects.views.Project.query", mock_query)
107+
108+
response = client.get("/projects/some_project", follow_redirects=False)
109+
110+
assert response.status_code == 301
111+
assert "/projects/some-project" in response.headers["Location"]

0 commit comments

Comments
 (0)