Skip to content

Commit d70f62a

Browse files
authored
Replace slugged user profile URLs with own-profile views (#3172)
* rm slugged urls * show only users profile * no username needed * rm url for lookup * no slug * rm unused queryset searchable * mythos-red-team: Drop username from membership delete URL * mythos-red-team: Remove unused profile privacy settings from form * mythos-red-team: Look up the edited profile by pk
1 parent 4cd81c3 commit d70f62a

12 files changed

Lines changed: 65 additions & 158 deletions

File tree

‎apps/users/forms.py‎

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,7 @@ class Meta:
1919
"last_name",
2020
"email",
2121
"bio",
22-
"search_visibility",
23-
"email_privacy",
24-
"public_profile",
2522
]
26-
widgets = {
27-
"search_visibility": forms.RadioSelect,
28-
"email_privacy": forms.RadioSelect,
29-
}
3023

3124
def clean_username(self):
3225
"""Validate that the username is unique (case-insensitive)."""

‎apps/users/managers.py‎

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,12 @@
55

66

77
class UserQuerySet(QuerySet):
8-
"""QuerySet with convenience filters for active and searchable users."""
8+
"""QuerySet with convenience filters for active users."""
99

1010
def active(self):
1111
"""Filter to active users only."""
1212
return self.filter(is_active=True)
1313

14-
def searchable(self):
15-
"""Filter to users who have opted into public search visibility."""
16-
return self.active().filter(
17-
public_profile=True,
18-
search_visibility__exact=self.model.SEARCH_PUBLIC,
19-
)
20-
2114

2215
class UserManager(DjangoUserManager.from_queryset(UserQuerySet)):
2316
"""Custom user manager with UserQuerySet methods available on the manager."""

‎apps/users/models.py‎

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
from django.conf import settings
66
from django.contrib.auth.models import AbstractUser
77
from django.db import models
8-
from django.urls import reverse
98
from django.utils import timezone
109
from markupfield.fields import MarkupField
1110
from tastypie.models import create_api_key
@@ -51,10 +50,6 @@ class User(AbstractUser):
5150

5251
objects = CustomUserManager()
5352

54-
def get_absolute_url(self):
55-
"""Return the URL for the user's profile page."""
56-
return reverse("users:user_detail", kwargs={"slug": self.username})
57-
5853
@property
5954
def has_membership(self):
6055
"""Return True if the user has an associated PSF membership."""

‎apps/users/templates/users/membership_form.html‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ <h1 class="default-title">Register to become a PSF Basic Member</h1>
101101
</form>
102102

103103
{% if request.user.has_membership %}
104-
<form action="{% url 'users:user_membership_delete' user.username %}"
104+
<form action="{% url 'users:user_membership_delete' %}"
105105
method="post"
106106
class="deletion-form"
107107
onsubmit="return confirm('Are you sure?');">

‎apps/users/templates/users/user_form.html‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ <h1 class="default-title">Edit information for: <strong>{% firstof user.get_full
4242
</p>
4343
</form>
4444

45-
<form action="{% url 'users:user_delete' user.username %}"
45+
<form action="{% url 'users:user_delete' %}"
4646
method="post"
4747
class="deletion-form"
4848
onsubmit="return confirm('Are you sure? There is no way to get back your account.');">

‎apps/users/tests/test_forms.py‎

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -122,8 +122,6 @@ def test_unique_email(self):
122122
{
123123
"username": "stanne",
124124
"email": "test42@example.com",
125-
"search_visibility": 0,
126-
"email_privacy": 0,
127125
},
128126
instance=User.objects.get(username="stanne"),
129127
)
@@ -138,8 +136,6 @@ def test_case_insensitive_unique_username(self):
138136
{
139137
"username": "Test42",
140138
"email": "mikael@darktranquillity.com",
141-
"search_visibility": 0,
142-
"email_privacy": 0,
143139
},
144140
instance=User.objects.get(username="stanne"),
145141
)

‎apps/users/tests/test_views.py‎

Lines changed: 34 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,11 @@ def setUp(self):
2121
username="username",
2222
password="password",
2323
email="niklas@sundin.se",
24-
search_visibility=User.SEARCH_PUBLIC,
2524
membership=None,
2625
)
2726
self.user2 = UserFactory(
2827
username="spameggs",
2928
password="password",
30-
search_visibility=User.SEARCH_PRIVATE,
31-
email_privacy=User.EMAIL_PRIVATE,
32-
public_profile=False,
3329
)
3430

3531
def assertUserCreated(self, data=None, template_name="account/verification_sent.html"): # noqa: N802 - unittest assertion naming convention
@@ -136,90 +132,37 @@ def test_user_update_redirect(self):
136132
response = self.client.get(url)
137133
self.assertEqual(response.status_code, 200)
138134

139-
# should return 200 if the user does want to see their user profile
135+
# should redirect to the profile page after saving
140136
post_data = {
141137
"username": "username",
142-
"search_visibility": 0,
143-
"email_privacy": 1,
144-
"public_profile": False,
145138
"email": "niklas@sundin.se",
146139
settings.HONEYPOT_FIELD_NAME: settings.HONEYPOT_VALUE,
147140
}
148141
response = self.client.post(url, post_data)
149-
profile_url = reverse("users:user_detail", kwargs={"slug": "username"})
142+
profile_url = reverse("users:user_detail")
150143
self.assertRedirects(response, profile_url)
151144

152-
# should return 404 for another user
153-
another_user_url = reverse("users:user_detail", kwargs={"slug": "spameggs"})
154-
response = self.client.get(another_user_url)
155-
self.assertEqual(response.status_code, 404)
156-
157-
# should return 404 if the user is not logged-in
145+
# should redirect to login if the user is not logged-in
158146
self.client.logout()
159147
response = self.client.get(profile_url)
160-
self.assertEqual(response.status_code, 404)
148+
self.assertRedirects(response, "{}?next={}".format(reverse("account_login"), profile_url))
161149

162150
def test_user_detail(self):
163-
# Ensure detail page is viewable without login, but that edit URLs
164-
# do not appear
165-
detail_url = reverse("users:user_detail", kwargs={"slug": self.user.username})
151+
# Ensure the detail page shows the logged-in user's own profile
152+
detail_url = reverse("users:user_detail")
166153
edit_url = reverse("users:user_profile_edit")
154+
self.client.login(username=self.user2.username, password="password")
167155
response = self.client.get(detail_url)
168-
self.assertTrue(self.user.is_active)
169-
self.assertNotContains(response, edit_url)
170-
171-
# Ensure edit url is available to logged in users
172-
self.client.login(username="username", password="password")
173-
response = self.client.get(detail_url)
156+
self.assertEqual(response.context["object"], self.user2)
174157
self.assertContains(response, edit_url)
175158

176-
# Ensure inactive accounts shouldn't be shown to users.
177-
user = User.objects.create_user(
178-
username="foobar",
179-
password="baz",
180-
email="paradiselost@example.com",
181-
)
182-
user.is_active = False
183-
user.save()
184-
self.assertFalse(user.is_active)
185-
detail_url = reverse("users:user_detail", kwargs={"slug": user.username})
186-
response = self.client.get(detail_url)
187-
self.assertEqual(response.status_code, 404)
188-
189-
def test_special_usernames(self):
190-
# Ensure usernames in the forms of:
191-
# first.last
192-
# user@host.com
193-
# are allowed to view their profile pages since we allow them in
194-
# the username field
195-
u1 = User.objects.create_user(
196-
username="user.name",
197-
password="password",
198-
)
199-
detail_url = reverse("users:user_detail", kwargs={"slug": u1.username})
200-
edit_url = reverse("users:user_profile_edit")
201-
202-
self.client.login(username=u1.username, password="password")
203-
response = self.client.get(detail_url)
204-
self.assertEqual(response.status_code, 200)
205-
206-
response = self.client.get(edit_url)
207-
self.assertEqual(response.status_code, 200)
208-
209-
u2 = User.objects.create_user(
210-
username="user@example.com",
211-
password="password",
212-
)
213-
214-
detail_url = reverse("users:user_detail", kwargs={"slug": u2.username})
215-
edit_url = reverse("users:user_profile_edit")
216-
217-
self.client.login(username=u2.username, password="password")
218-
response = self.client.get(detail_url)
219-
self.assertEqual(response.status_code, 200)
220-
221-
response = self.client.get(edit_url)
222-
self.assertEqual(response.status_code, 200)
159+
def test_user_detail_not_addressable_by_username(self):
160+
# Profiles used to live at /users/<username>/; existing and unknown
161+
# usernames must be indistinguishable to prevent user enumeration.
162+
existing = self.client.get(f"/users/{self.user.username}/")
163+
unknown = self.client.get("/users/thisusernamedoesntexist/")
164+
self.assertEqual(existing.status_code, 404)
165+
self.assertEqual(unknown.status_code, 404)
223166

224167
def test_user_new_account(self):
225168
self.assertUserCreated(
@@ -298,62 +241,61 @@ def test_is_active_login(self):
298241
self.assertRedirects(response, "{}?next={}".format(reverse("account_login"), url))
299242

300243
def test_user_delete_needs_to_be_logged_in(self):
301-
url = reverse("users:user_delete", kwargs={"slug": self.user.username})
244+
url = reverse("users:user_delete")
302245
response = self.client.delete(url)
303246
self.assertRedirects(response, "{}?next={}".format(reverse("account_login"), url))
304247

305248
def test_user_delete_invalid_request_method(self):
306-
url = reverse("users:user_delete", kwargs={"slug": self.user.username})
249+
url = reverse("users:user_delete")
307250
self.client.login(username=self.user.username, password="password")
308251
response = self.client.get(url)
309252
self.assertEqual(response.status_code, 405)
310253

311-
def test_user_delete_different_user(self):
312-
url = reverse("users:user_delete", kwargs={"slug": self.user.username})
313-
self.client.login(username=self.user2.username, password="password")
314-
response = self.client.delete(url)
315-
self.assertEqual(response.status_code, 403)
316-
317254
def test_user_delete(self):
318-
url = reverse("users:user_delete", kwargs={"slug": self.user.username})
255+
url = reverse("users:user_delete")
319256
self.client.login(username=self.user.username, password="password")
320257
response = self.client.delete(url)
321258
self.assertRedirects(response, reverse("home"))
322259
self.assertRaises(User.DoesNotExist, User.objects.get, username=self.user.username)
323260
self.assertRaises(Membership.DoesNotExist, Membership.objects.get, creator=self.user)
261+
self.assertTrue(User.objects.filter(username=self.user2.username).exists())
324262

325263
def test_membership_delete_needs_to_be_logged_in(self):
326-
url = reverse("users:user_membership_delete", kwargs={"slug": self.user2.username})
264+
url = reverse("users:user_membership_delete")
327265
response = self.client.delete(url)
328266
self.assertRedirects(response, "{}?next={}".format(reverse("account_login"), url))
329267

330268
def test_membership_delete_invalid_request_method(self):
331-
url = reverse("users:user_membership_delete", kwargs={"slug": self.user2.username})
269+
url = reverse("users:user_membership_delete")
332270
self.client.login(username=self.user2.username, password="password")
333271
response = self.client.get(url)
334272
self.assertEqual(response.status_code, 405)
335273

336-
def test_membership_delete_different_user_membership(self):
337-
user = UserFactory()
338-
self.assertTrue(user.has_membership)
339-
url = reverse("users:user_membership_delete", kwargs={"slug": user.username})
274+
def test_membership_delete_not_addressable_by_username(self):
275+
# Membership deletion used to live at /users/membership/delete/<username>/
276+
# and answered 403 for members and 404 for everyone else.
277+
member = UserFactory()
278+
self.assertTrue(member.has_membership)
340279
self.client.login(username=self.user2.username, password="password")
341-
response = self.client.delete(url)
342-
self.assertEqual(response.status_code, 403)
280+
existing = self.client.delete(f"/users/membership/delete/{member.username}/")
281+
unknown = self.client.delete("/users/membership/delete/thisusernamedoesntexist/")
282+
self.assertEqual(existing.status_code, 404)
283+
self.assertEqual(unknown.status_code, 404)
284+
self.assertTrue(Membership.objects.filter(creator=member).exists())
343285

344286
def test_membership_does_not_exist(self):
345287
self.assertFalse(self.user.has_membership)
346-
url = reverse("users:user_membership_delete", kwargs={"slug": self.user.username})
288+
url = reverse("users:user_membership_delete")
347289
self.client.login(username=self.user.username, password="password")
348290
response = self.client.delete(url)
349291
self.assertEqual(response.status_code, 404)
350292

351293
def test_membership_delete(self):
352294
self.assertTrue(self.user2.has_membership)
353-
url = reverse("users:user_membership_delete", kwargs={"slug": self.user2.username})
295+
url = reverse("users:user_membership_delete")
354296
self.client.login(username=self.user2.username, password="password")
355297
response = self.client.delete(url)
356-
self.assertRedirects(response, reverse("users:user_detail", kwargs={"slug": self.user2.username}))
298+
self.assertRedirects(response, reverse("users:user_detail"))
357299
# TODO: We can't use 'self.user2.refresh_from_db()' because
358300
# of https://code.djangoproject.com/ticket/27846.
359301
with self.assertRaises(Membership.DoesNotExist):

‎apps/users/urls.py‎

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,17 @@
11
"""URL configuration for user profiles, memberships, and sponsorship management."""
22

3-
from django.urls import path, re_path
3+
from django.urls import path
44

55
from apps.users import views
66

77
app_name = "users"
88
urlpatterns = [
99
path("edit/", views.UserUpdate.as_view(), name="user_profile_edit"),
10+
path("profile/detail/", views.UserDetail.as_view(), name="user_detail"),
11+
path("profile/delete/", views.UserDeleteView.as_view(), name="user_delete"),
1012
path("membership/", views.MembershipCreate.as_view(), name="user_membership_create"),
1113
path("membership/edit/", views.MembershipUpdate.as_view(), name="user_membership_edit"),
12-
re_path(
13-
r"^membership/delete/(?P<slug>[-a-zA-Z0-9_\@\.+]+)/$",
14-
views.MembershipDeleteView.as_view(),
15-
name="user_membership_delete",
16-
),
14+
path("membership/delete/", views.MembershipDeleteView.as_view(), name="user_membership_delete"),
1715
path("membership/thanks/", views.MembershipThanks.as_view(), name="user_membership_thanks"),
1816
path("membership/affirm/", views.MembershipVoteAffirm.as_view(), name="membership_affirm_vote"),
1917
path("membership/affirm/done/", views.MembershipVoteAffirmDone.as_view(), name="membership_affirm_vote_done"),
@@ -44,6 +42,4 @@
4442
views.SponsorshipDetailView.as_view(),
4543
name="sponsorship_application_detail",
4644
),
47-
re_path(r"^(?P<slug>[-a-zA-Z0-9_\@\.+]+)/delete/$", views.UserDeleteView.as_view(), name="user_delete"),
48-
re_path(r"^(?P<slug>[-a-zA-Z0-9_\@\.+]+)/$", views.UserDetail.as_view(), name="user_detail"),
4945
]

0 commit comments

Comments
 (0)