Skip to content

Commit 6fa9d26

Browse files
Christinarlongc298lee
authored andcommitted
ref(users): move users related web resources to users directory (#76981)
Issue ref(#73856 )
1 parent db27be2 commit 6fa9d26

17 files changed

+195
-177
lines changed

pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -375,7 +375,6 @@ module = [
375375
"sentry.utils.committers",
376376
"sentry.utils.services",
377377
"sentry.web.forms.accounts",
378-
"sentry.web.frontend.account_identity",
379378
"sentry.web.frontend.auth_close",
380379
"sentry.web.frontend.auth_login",
381380
"sentry.web.frontend.auth_logout",

src/sentry/middleware/auth.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ def process_exception(
8686
self, request: HttpRequest, exception: Exception
8787
) -> HttpResponseBase | None:
8888
if isinstance(exception, AuthUserPasswordExpired):
89-
from sentry.web.frontend.accounts import expired
89+
from sentry.users.web.accounts import expired
9090

9191
return expired(request, exception.user)
9292
else:

src/sentry/users/api/endpoints/user_details.py

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
import logging
2-
import zoneinfo
3-
from datetime import datetime
42
from typing import Any
53

64
from django.conf import settings
@@ -31,27 +29,13 @@
3129
from sentry.users.models.user_option import UserOption
3230
from sentry.users.models.useremail import UserEmail
3331
from sentry.users.services.user.serial import serialize_generic_user
34-
from sentry.utils.dates import AVAILABLE_TIMEZONES
32+
from sentry.utils.dates import get_timezone_choices
3533

3634
audit_logger = logging.getLogger("sentry.audit.user")
3735
delete_logger = logging.getLogger("sentry.deletions.api")
3836

3937

40-
def _get_timezone_choices() -> list[tuple[str, str]]:
41-
build_results = []
42-
for tz in AVAILABLE_TIMEZONES:
43-
now = datetime.now(zoneinfo.ZoneInfo(tz))
44-
offset = now.strftime("%z")
45-
build_results.append((int(offset), tz, f"(UTC{offset}) {tz}"))
46-
build_results.sort()
47-
48-
results: list[tuple[str, str]] = []
49-
for item in build_results:
50-
results.append(item[1:])
51-
return results
52-
53-
54-
TIMEZONE_CHOICES = _get_timezone_choices()
38+
TIMEZONE_CHOICES = get_timezone_choices()
5539

5640

5741
class UserOptionsSerializer(serializers.Serializer[UserOption]):

src/sentry/users/web/__init__.py

Whitespace-only changes.

src/sentry/web/frontend/account_identity.py renamed to src/sentry/users/web/account_identity.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1+
from django.http.request import HttpRequest
12
from django.http.response import HttpResponseBase
23
from django.urls import reverse
34
from django.utils.decorators import method_decorator
45
from django.views.decorators.cache import never_cache
5-
from rest_framework.request import Request
66

77
from sentry.identity.pipeline import IdentityProviderPipeline
8+
from sentry.models.organization import Organization
89
from sentry.users.models.identity import IdentityProvider
910
from sentry.web.frontend.base import ControlSiloOrganizationView, control_silo_view
1011
from sentry.web.helpers import render_to_response
@@ -13,7 +14,9 @@
1314
@control_silo_view
1415
class AccountIdentityAssociateView(ControlSiloOrganizationView):
1516
@method_decorator(never_cache)
16-
def handle(self, request: Request, organization, provider_key, external_id) -> HttpResponseBase:
17+
def handle(
18+
self, request: HttpRequest, organization: Organization, provider_key: str, external_id: str
19+
) -> HttpResponseBase:
1720
try:
1821
provider_model = IdentityProvider.objects.get(
1922
type=provider_key, external_id=external_id

src/sentry/web/frontend/accounts.py renamed to src/sentry/users/web/accounts.py

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from django.contrib.auth import authenticate
66
from django.contrib.auth import login as login_user
77
from django.db import router, transaction
8-
from django.http import HttpResponse, HttpResponseRedirect
8+
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
99
from django.urls import reverse
1010
from django.utils.translation import gettext as _
1111
from django.views.decorators.http import require_http_methods
@@ -21,9 +21,13 @@
2121
from sentry.users.models.useremail import UserEmail
2222
from sentry.users.services.lost_password_hash import lost_password_hash_service
2323
from sentry.users.services.user.service import user_service
24+
from sentry.users.web.accounts_form import (
25+
ChangePasswordRecoverForm,
26+
RecoverPasswordForm,
27+
RelocationForm,
28+
)
2429
from sentry.utils import auth
2530
from sentry.web.decorators import login_required, set_referrer_policy
26-
from sentry.web.forms.accounts import ChangePasswordRecoverForm, RecoverPasswordForm, RelocationForm
2731
from sentry.web.helpers import render_to_response
2832

2933
logger = logging.getLogger("sentry.accounts")
@@ -38,19 +42,19 @@ class InvalidRequest(Exception):
3842
pass
3943

4044

41-
def get_template(mode, name):
45+
def get_template(mode: str, name: str) -> str:
4246
return f"sentry/account/{mode}/{name}.html"
4347

4448

4549
@login_required
4650
@control_silo_function
47-
def login_redirect(request):
51+
def login_redirect(request: HttpRequest) -> HttpResponseRedirect:
4852
login_url = auth.get_login_redirect(request)
4953
return HttpResponseRedirect(login_url)
5054

5155

5256
@control_silo_function
53-
def expired(request, user):
57+
def expired(request: HttpRequest, user: User) -> HttpResponse:
5458
hash = lost_password_hash_service.get_or_create(user_id=user.id).hash
5559
LostPasswordHash.send_recover_password_email(user, hash, request.META["REMOTE_ADDR"])
5660

@@ -59,7 +63,7 @@ def expired(request, user):
5963

6064

6165
@control_silo_function
62-
def recover(request):
66+
def recover(request: HttpRequest) -> HttpResponse:
6367
from sentry import ratelimits as ratelimiter
6468

6569
extra = {
@@ -112,7 +116,7 @@ def recover(request):
112116

113117
@set_referrer_policy("strict-origin-when-cross-origin")
114118
@control_silo_function
115-
def relocate_reclaim(request, user_id):
119+
def relocate_reclaim(request: HttpRequest, user_id: int) -> HttpResponse:
116120
"""
117121
Ask to receive a new "claim this user" email.
118122
"""
@@ -176,7 +180,9 @@ def relocate_reclaim(request, user_id):
176180

177181
@set_referrer_policy("strict-origin-when-cross-origin")
178182
@control_silo_function
179-
def recover_confirm(request, user_id, hash, mode="recover"):
183+
def recover_confirm(
184+
request: HttpRequest, user_id: int, hash: str, mode: str = "recover"
185+
) -> HttpResponse:
180186
from sentry import ratelimits as ratelimiter
181187

182188
try:
@@ -285,7 +291,7 @@ def recover_confirm(request, user_id, hash, mode="recover"):
285291
@login_required
286292
@require_http_methods(["POST"])
287293
@control_silo_function
288-
def start_confirm_email(request):
294+
def start_confirm_email(request: HttpRequest) -> HttpResponse:
289295
from sentry import ratelimits as ratelimiter
290296

291297
if ratelimiter.backend.is_limited(
@@ -299,6 +305,9 @@ def start_confirm_email(request):
299305
status=429,
300306
)
301307

308+
assert isinstance(
309+
request.user, User
310+
), "User must have an associated email to send confirm emails to"
302311
if "primary-email" in request.POST:
303312
email = request.POST.get("email")
304313
try:
@@ -334,7 +343,7 @@ def start_confirm_email(request):
334343
@set_referrer_policy("strict-origin-when-cross-origin")
335344
@login_required
336345
@control_silo_function
337-
def confirm_email(request, user_id, hash):
346+
def confirm_email(request: HttpRequest, user_id: int, hash: str) -> HttpResponseRedirect:
338347
msg = _("Thanks for confirming your email")
339348
level = messages.SUCCESS
340349
try:

src/sentry/users/web/accounts_form.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
from __future__ import annotations
2+
3+
import re
4+
from typing import Any
5+
6+
from django import forms
7+
from django.conf import settings
8+
from django.utils.translation import gettext_lazy as _
9+
10+
from sentry.auth import password_validation
11+
from sentry.users.models.user import User
12+
from sentry.utils.auth import find_users
13+
from sentry.utils.dates import get_timezone_choices
14+
from sentry.web.forms.fields import AllowedEmailField
15+
16+
TIMEZONE_CHOICES = get_timezone_choices()
17+
18+
19+
class RecoverPasswordForm(forms.Form):
20+
user = forms.CharField(
21+
label=_("Account"),
22+
max_length=128,
23+
widget=forms.TextInput(attrs={"placeholder": _("username or email")}),
24+
)
25+
26+
def clean_user(self) -> User | None:
27+
value = (self.cleaned_data.get("user") or "").strip()
28+
if not value:
29+
return None
30+
users = find_users(value, with_valid_password=False)
31+
if not users:
32+
return None
33+
34+
# If we find more than one user, we likely matched on email address.
35+
# We silently bail here as we emailing the 'wrong' person isn't great.
36+
# They will have to retry with their username which is guaranteed
37+
# to be unique
38+
if len(users) > 1:
39+
return None
40+
41+
users = [u for u in users if not u.is_managed]
42+
if not users:
43+
raise forms.ValidationError(
44+
_(
45+
"The account you are trying to recover is managed and does not support password recovery."
46+
)
47+
)
48+
return users[0]
49+
50+
51+
class ChangePasswordRecoverForm(forms.Form):
52+
password = forms.CharField(widget=forms.PasswordInput())
53+
54+
def __init__(self, *args: Any, **kwargs: Any) -> None:
55+
self.user = kwargs.pop("user", None)
56+
super().__init__(*args, **kwargs)
57+
58+
def clean_password(self) -> str:
59+
password = self.cleaned_data["password"]
60+
password_validation.validate_password(password, user=self.user)
61+
return password
62+
63+
64+
class EmailForm(forms.Form):
65+
alt_email = AllowedEmailField(
66+
label=_("New Email"),
67+
required=False,
68+
help_text="Designate an alternative email for this account",
69+
)
70+
71+
password = forms.CharField(
72+
label=_("Current password"),
73+
widget=forms.PasswordInput(),
74+
help_text=_("You will need to enter your current account password to make changes."),
75+
required=True,
76+
)
77+
78+
def __init__(self, user: User, *args: Any, **kwargs: Any) -> None:
79+
self.user = user
80+
super().__init__(*args, **kwargs)
81+
82+
needs_password = user.has_usable_password()
83+
84+
if not needs_password:
85+
del self.fields["password"]
86+
87+
def clean_password(self) -> str:
88+
value = self.cleaned_data.get("password")
89+
if value and not self.user.check_password(value):
90+
raise forms.ValidationError(_("The password you entered is not correct."))
91+
elif not value:
92+
raise forms.ValidationError(
93+
_("You must confirm your current password to make changes.")
94+
)
95+
return value
96+
97+
98+
class RelocationForm(forms.Form):
99+
username = forms.CharField(max_length=128, required=False, widget=forms.TextInput())
100+
password = forms.CharField(widget=forms.PasswordInput())
101+
tos_check = forms.BooleanField(
102+
label=_(
103+
f"I agree to the <a href={settings.TERMS_URL}>Terms of Service</a> and <a href={settings.PRIVACY_URL}>Privacy Policy</a>"
104+
),
105+
widget=forms.CheckboxInput(),
106+
required=False,
107+
initial=False,
108+
)
109+
110+
def __init__(self, *args: Any, **kwargs: Any) -> None:
111+
self.user = kwargs.pop("user", None)
112+
super().__init__(*args, **kwargs)
113+
self.fields["username"].widget.attrs.update(placeholder=self.user.username)
114+
115+
def clean_username(self) -> str | None:
116+
value = self.cleaned_data.get("username") or self.user.username
117+
value = re.sub(r"[ \n\t\r\0]*", "", value)
118+
if not value:
119+
return None
120+
if User.objects.filter(username__iexact=value).exclude(id=self.user.id).exists():
121+
raise forms.ValidationError(_("An account is already registered with that username."))
122+
return value.lower()
123+
124+
def clean_password(self) -> str:
125+
password = self.cleaned_data["password"]
126+
password_validation.validate_password(password, user=self.user)
127+
return password
128+
129+
def clean_tos_check(self) -> None:
130+
value = self.cleaned_data.get("tos_check")
131+
if not value:
132+
raise forms.ValidationError(
133+
_("You must agree to the Terms of Service and Privacy Policy before proceeding.")
134+
)
135+
return None

src/sentry/utils/dates.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,3 +184,17 @@ def outside_retention_with_modified_start(
184184
start = max(start, now - timedelta(days=retention))
185185

186186
return start > end, start
187+
188+
189+
def get_timezone_choices() -> list[tuple[str, str]]:
190+
build_results = []
191+
for tz in AVAILABLE_TIMEZONES:
192+
now = datetime.now(zoneinfo.ZoneInfo(tz))
193+
offset = now.strftime("%z")
194+
build_results.append((int(offset), tz, f"(UTC{offset}) {tz}"))
195+
build_results.sort()
196+
197+
results: list[tuple[str, str]] = []
198+
for item in build_results:
199+
results.append(item[1:])
200+
return results

0 commit comments

Comments
 (0)