Coverage for baibu/users/views.py: 97%

94 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2026-10-06 13:07 +0000

1import logging 

2 

3from django.conf import settings 

4from django.contrib import messages 

5from django.contrib.auth import logout 

6from django.contrib.auth.decorators import login_required 

7from django.contrib.auth.mixins import LoginRequiredMixin 

8from django.contrib.messages.views import SuccessMessageMixin 

9from django.core.cache import cache 

10from django.http import HttpRequest 

11from django.http import HttpResponse 

12from django.shortcuts import redirect 

13from django.shortcuts import render 

14from django.urls import reverse 

15from django.utils.crypto import salted_hmac 

16from django.utils.http import url_has_allowed_host_and_scheme 

17from django.utils.translation import gettext_lazy as _ 

18from django.views.decorators.cache import cache_control 

19from django.views.decorators.http import require_http_methods 

20from django.views.generic import UpdateView 

21 

22from .account_deletion_requests import capture_account_deletion_request 

23from .consent import consent_state 

24from .consent import record_consent 

25from .forms import AccountDeletionRequestForm 

26from .forms import ConsentForm 

27from .forms import UserAccountForm 

28from .middleware import missing_profile_fields 

29from .models import AccountDeletionRequest 

30from .models import User 

31 

32logger = logging.getLogger(__name__) 

33 

34 

35class UserAccountView(LoginRequiredMixin, SuccessMessageMixin, UpdateView): 

36 model = User 

37 form_class = UserAccountForm 

38 template_name = "users/account.html" 

39 success_message = _("Your profile was updated.") 

40 

41 def get_object(self, queryset=None) -> User: 

42 return self.request.user 

43 

44 def get_success_url(self) -> str: 

45 return reverse("users:account") 

46 

47 def get_context_data(self, **kwargs): 

48 context = super().get_context_data(**kwargs) 

49 context["consent_state"] = consent_state(user=self.request.user) 

50 return context 

51 

52 

53account_view = cache_control(private=True, no_store=True)(UserAccountView.as_view()) 

54 

55 

56class CompleteProfileView(UserAccountView): 

57 template_name = "users/complete_profile.html" 

58 success_message = _("Thanks, your profile is complete.") 

59 

60 def get_form(self, form_class=None): 

61 form = super().get_form(form_class) 

62 for field in missing_profile_fields(self.request.user): 

63 form.fields[field].required = True 

64 return form 

65 

66 def get_success_url(self) -> str: 

67 next_url = self.request.GET.get("next", "") 

68 if next_url and url_has_allowed_host_and_scheme(next_url, allowed_hosts={self.request.get_host()}): 

69 return next_url 

70 return reverse("users:account") 

71 

72 

73complete_profile_view = cache_control(private=True, no_store=True)(CompleteProfileView.as_view()) 

74 

75 

76@login_required 

77@cache_control(private=True, no_store=True) 

78@require_http_methods(["GET", "POST"]) 

79def consent_view(request: HttpRequest) -> HttpResponse: 

80 state = consent_state(user=request.user) 

81 form = ConsentForm(request.POST or None, initial={"tier": state.tier if state.latest_record else None}) 

82 if request.method == "POST" and form.is_valid(): 

83 new_tier = form.cleaned_data["tier"] 

84 if state.latest_record is None or new_tier != state.tier: 

85 record_consent( 

86 user=request.user, 

87 tier=new_tier, 

88 source="account_settings", 

89 metadata={ 

90 "locale": getattr(request, "LANGUAGE_CODE", settings.LANGUAGE_CODE), 

91 "consent_text_version": settings.CONSENT_TEXT_VERSION, 

92 }, 

93 ) 

94 messages.success(request, _("Your choice was saved.")) 

95 return redirect("users:consent") 

96 history = request.user.consent_records.filter(scope=settings.CONSENT_DEFAULT_SCOPE)[:20] 

97 return render(request, "users/consent.html", {"form": form, "consent_state": state, "history": history}) 

98 

99 

100@login_required 

101@cache_control(private=True, no_store=True) 

102@require_http_methods(["GET", "POST"]) 

103def account_deletion_view(request: HttpRequest) -> HttpResponse: 

104 form = AccountDeletionRequestForm(request.POST or None) 

105 if request.method == "POST" and form.is_valid(): 

106 if _deletion_rate_limited(request): 

107 form.add_error(None, _("Too many requests. Please wait before trying again.")) 

108 else: 

109 deletion_request, _created = capture_account_deletion_request( 

110 source=AccountDeletionRequest.Source.IN_APP, 

111 user=request.user, 

112 requester_name=request.user.name, 

113 requester_email=request.user.email or "", 

114 request_details=form.cleaned_data["request_details"], 

115 metadata={"locale": getattr(request, "LANGUAGE_CODE", settings.LANGUAGE_CODE)}, 

116 ) 

117 # The account is now inactive, so end the session. 

118 logout(request) 

119 return render(request, "users/account_deletion_done.html", {"receipt_id": deletion_request.pk}) 

120 return render(request, "users/account_deletion.html", {"form": form}) 

121 

122 

123def _deletion_rate_limited(request: HttpRequest) -> bool: 

124 digest = salted_hmac("account-deletion-rate", f"user:{request.user.pk}").hexdigest() 

125 key = f"account-deletion-rate:{digest}" 

126 try: 

127 if cache.add(key, 1, timeout=settings.ACCOUNT_DELETION_REQUEST_RATE_WINDOW_SECONDS): 

128 return False 

129 count = cache.incr(key) 

130 except Exception as exc: # noqa: BLE001 

131 logger.warning("Account deletion rate limiter unavailable; error_type=%s", type(exc).__name__) 

132 return False 

133 return count > settings.ACCOUNT_DELETION_REQUEST_RATE_LIMIT