diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py index bd691cedb..af086c8eb 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -272,7 +272,7 @@ urlpatterns = [ ), path( "domain-request/viewonly/", - views.PortfolioDomainRequestStatusViewOnly.as_view(), + views.DomainRequestStatusViewOnly.as_view(), name="domain-request-status-viewonly", ), path( diff --git a/src/registrar/decorators.py b/src/registrar/decorators.py index d607935a2..4b7a230c7 100644 --- a/src/registrar/decorators.py +++ b/src/registrar/decorators.py @@ -18,13 +18,13 @@ IS_FULL_ACCESS = "is_full_access" IS_DOMAIN_MANAGER = "is_domain_manager" IS_DOMAIN_REQUEST_CREATOR = "is_domain_request_creator" IS_STAFF_MANAGING_DOMAIN = "is_staff_managing_domain" +HAS_DOMAIN_REQUESTS_VIEW_ALL = "has_domain_requests_view_all" IS_PORTFOLIO_MEMBER = "is_portfolio_member" IS_PORTFOLIO_MEMBER_AND_DOMAIN_MANAGER = "is_portfolio_member_and_domain_manager" IS_DOMAIN_MANAGER_AND_NOT_PORTFOLIO_MEMBER = "is_domain_manager_and_not_portfolio_member" HAS_PORTFOLIO_DOMAINS_ANY_PERM = "has_portfolio_domains_any_perm" HAS_PORTFOLIO_DOMAINS_VIEW_ALL = "has_portfolio_domains_view_all" HAS_PORTFOLIO_DOMAIN_REQUESTS_ANY_PERM = "has_portfolio_domain_requests_any_perm" -HAS_PORTFOLIO_DOMAIN_REQUESTS_VIEW_ALL = "has_portfolio_domain_requests_view_all" HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT = "has_portfolio_domain_requests_edit" HAS_PORTFOLIO_MEMBERS_ANY_PERM = "has_portfolio_members_any_perm" HAS_PORTFOLIO_MEMBERS_EDIT = "has_portfolio_members_edit" @@ -152,18 +152,16 @@ def _user_has_permission(user, request, rules, **kwargs): lambda: _is_domain_request_creator(user, kwargs.get("domain_request_pk")) and not _is_portfolio_member(request), ), + ( + HAS_DOMAIN_REQUESTS_VIEW_ALL, + lambda: _can_view_all_domain_requests(user, request, kwargs.get("domain_request_pk")), + ), ( HAS_PORTFOLIO_DOMAIN_REQUESTS_ANY_PERM, lambda: user.is_org_user(request) and user.has_any_requests_portfolio_permission(portfolio) and _domain_request_exists_under_portfolio(portfolio, kwargs.get("domain_request_pk")), ), - ( - HAS_PORTFOLIO_DOMAIN_REQUESTS_VIEW_ALL, - lambda: user.is_org_user(request) - and user.has_view_all_domain_requests_portfolio_permission(portfolio) - and _domain_request_exists_under_portfolio(portfolio, kwargs.get("domain_request_pk")), - ), ( HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, lambda: _has_portfolio_domain_requests_edit(user, request, kwargs.get("domain_request_pk")) @@ -397,3 +395,48 @@ def _is_staff_managing_domain(request, **kwargs): # the user is permissioned, # and it is in a valid status return True + + +def _can_view_all_domain_requests(user, request, domain_request_pk): + """ + Determines if the user has view-all permission for domain requests. + This permission allows users to view domain request details without editing. + Handles both portfolio and non-portfolio domain requests. + """ + if not domain_request_pk: + return False + + portfolio = request.session.get("portfolio") + # Portfolio-based access + if user.is_org_user(request) and portfolio: + has_perm = user.has_view_all_domain_requests_portfolio_permission(portfolio) + exists = _domain_request_exists_under_portfolio(portfolio, domain_request_pk) + return has_perm and exists + + # Check non-portfolio permissions + try: + domain_request = DomainRequest.objects.get(pk=domain_request_pk) + except DomainRequest.DoesNotExist: + return False + + can_view = _has_legacy_domain_request_view_access(user, domain_request) + return can_view + + +def _has_legacy_domain_request_view_access(user, domain_request): + """ + All of the ways a user can view a non-portfolio aka only applies to legacy mode domain request: + Has the analyst_access_permission or + Is the creator of the request or + Has the full_access_permission + """ + if user.has_perm("registrar.analyst_access_permission"): + return True + + if domain_request.creator == user: + return True + + if user.has_perm("registrar.full_access_permission"): + return True + + return False diff --git a/src/registrar/permissions.py b/src/registrar/permissions.py index 892b201a2..657d7051b 100644 --- a/src/registrar/permissions.py +++ b/src/registrar/permissions.py @@ -4,6 +4,7 @@ Centralized permissions management for the registrar. from django.urls import URLResolver, get_resolver, URLPattern from registrar.decorators import ( + HAS_DOMAIN_REQUESTS_VIEW_ALL, HAS_PORTFOLIO_DOMAIN_REQUESTS_ANY_PERM, IS_STAFF, IS_DOMAIN_MANAGER, @@ -18,7 +19,6 @@ from registrar.decorators import ( HAS_PORTFOLIO_DOMAINS_ANY_PERM, HAS_PORTFOLIO_DOMAINS_VIEW_ALL, HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, - HAS_PORTFOLIO_DOMAIN_REQUESTS_VIEW_ALL, HAS_PORTFOLIO_MEMBERS_EDIT, HAS_PORTFOLIO_MEMBERS_ANY_PERM, HAS_PORTFOLIO_MEMBERS_VIEW, @@ -67,7 +67,7 @@ URL_PERMISSIONS = { "organization-senior-official": [IS_PORTFOLIO_MEMBER], # Domain requests "domain-request-status": [HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, IS_DOMAIN_REQUEST_CREATOR], - "domain-request-status-viewonly": [HAS_PORTFOLIO_DOMAIN_REQUESTS_VIEW_ALL], + "domain-request-status-viewonly": [HAS_DOMAIN_REQUESTS_VIEW_ALL], "domain-request-withdraw-confirmation": [HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, IS_DOMAIN_REQUEST_CREATOR], "domain-request-withdrawn": [HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, IS_DOMAIN_REQUEST_CREATOR], "domain-request-delete": [HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, IS_DOMAIN_REQUEST_CREATOR], diff --git a/src/registrar/tests/test_views_requests_json.py b/src/registrar/tests/test_views_requests_json.py index 8f50e16bb..343d191b3 100644 --- a/src/registrar/tests/test_views_requests_json.py +++ b/src/registrar/tests/test_views_requests_json.py @@ -152,6 +152,21 @@ class GetRequestsJsonTest(TestWithUser, WebTest): def test_get_domain_requests_json_authenticated(self): """Test that domain requests are returned properly for an authenticated user.""" + deletable_statuses = [ + DomainRequest.DomainRequestStatus.STARTED, + DomainRequest.DomainRequestStatus.WITHDRAWN, + ] + + editable_statuses = [ + DomainRequest.DomainRequestStatus.STARTED, + DomainRequest.DomainRequestStatus.ACTION_NEEDED, + DomainRequest.DomainRequestStatus.WITHDRAWN, + ] + + view_only_statuses = [ + DomainRequest.DomainRequestStatus.REJECTED, + ] + response = self.app.get(reverse("get_domain_requests_json")) self.assertEqual(response.status_code, 200) data = response.json @@ -191,49 +206,31 @@ class GetRequestsJsonTest(TestWithUser, WebTest): self.assertEqual(self.domain_requests[i].id, ids[i]) # Check is_deletable - is_deletable_expected = self.domain_requests[i].status in [ - DomainRequest.DomainRequestStatus.STARTED, - DomainRequest.DomainRequestStatus.WITHDRAWN, - ] + is_deletable_expected = self.domain_requests[i].status in deletable_statuses self.assertEqual(is_deletable_expected, is_deletables[i]) # Check action_url - action_url_expected = ( - reverse("edit-domain-request", kwargs={"domain_request_pk": self.domain_requests[i].id}) - if self.domain_requests[i].status - in [ - DomainRequest.DomainRequestStatus.STARTED, - DomainRequest.DomainRequestStatus.ACTION_NEEDED, - DomainRequest.DomainRequestStatus.WITHDRAWN, - ] - else reverse("domain-request-status", kwargs={"domain_request_pk": self.domain_requests[i].id}) - ) + if self.domain_requests[i].status in view_only_statuses: + action_url_expected = reverse( + "domain-request-status-viewonly", kwargs={"domain_request_pk": self.domain_requests[i].id} + ) + action_label_expected = "View" + svg_icon_expected = "visibility" + elif self.domain_requests[i].status in editable_statuses: + action_url_expected = reverse( + "edit-domain-request", kwargs={"domain_request_pk": self.domain_requests[i].id} + ) + action_label_expected = "Edit" + svg_icon_expected = "edit" + else: + action_url_expected = reverse( + "domain-request-status", kwargs={"domain_request_pk": self.domain_requests[i].id} + ) + action_label_expected = "Manage" + svg_icon_expected = "settings" + self.assertEqual(action_url_expected, action_urls[i]) - - # Check action_label - action_label_expected = ( - "Edit" - if self.domain_requests[i].status - in [ - DomainRequest.DomainRequestStatus.STARTED, - DomainRequest.DomainRequestStatus.ACTION_NEEDED, - DomainRequest.DomainRequestStatus.WITHDRAWN, - ] - else "Manage" - ) self.assertEqual(action_label_expected, action_labels[i]) - - # Check svg_icon - svg_icon_expected = ( - "edit" - if self.domain_requests[i].status - in [ - DomainRequest.DomainRequestStatus.STARTED, - DomainRequest.DomainRequestStatus.ACTION_NEEDED, - DomainRequest.DomainRequestStatus.WITHDRAWN, - ] - else "settings" - ) self.assertEqual(svg_icon_expected, svg_icons[i]) def test_get_domain_requests_json_search(self): diff --git a/src/registrar/views/domain_request.py b/src/registrar/views/domain_request.py index 6b4eb440d..3585e17fc 100644 --- a/src/registrar/views/domain_request.py +++ b/src/registrar/views/domain_request.py @@ -10,8 +10,8 @@ from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ from django.views.generic import DeleteView, DetailView, TemplateView from registrar.decorators import ( + HAS_DOMAIN_REQUESTS_VIEW_ALL, HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, - HAS_PORTFOLIO_DOMAIN_REQUESTS_VIEW_ALL, IS_DOMAIN_REQUEST_CREATOR, grant_access, ) @@ -1194,9 +1194,20 @@ class DomainRequestDeleteView(PermissionRequiredMixin, DeleteView): return duplicates -# region Portfolio views -@grant_access(HAS_PORTFOLIO_DOMAIN_REQUESTS_VIEW_ALL) -class PortfolioDomainRequestStatusViewOnly(DetailView): +@grant_access(HAS_DOMAIN_REQUESTS_VIEW_ALL) +class DomainRequestStatusViewOnly(DetailView): + """ + View-only access for domain requests both on enterprise-mode portfolios and legacy mode. + + This view provides read-only access to domain request details for users who have + view permissions but not edit permissions. + + Access is granted via HAS_DOMAIN_REQUESTS_VIEW_ALL which handles: + - Portfolio members with view-all domain requests permission + - Non-portfolio users who are creators of the domain request + - Analysts with appropriate permissions + """ + template_name = "portfolio_domain_request_status_viewonly.html" model = DomainRequest pk_url_kwarg = "domain_request_pk" @@ -1204,16 +1215,35 @@ class PortfolioDomainRequestStatusViewOnly(DetailView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - # Create a temp wizard object to grab the step list - wizard = PortfolioDomainRequestWizard() - wizard.request = self.request - context["Step"] = PortfolioDomainRequestStep.__members__ - context["steps"] = request_step_list(wizard, PortfolioDomainRequestStep) - context["form_titles"] = wizard.titles - context["requires_feb_questions"] = self.object.is_feb() and flag_is_active_for_user( + domain_request = self.object + + # Determine if this is a portfolio request or if user is org user + is_portfolio = domain_request.portfolio is not None or self.request.user.is_org_user(self.request) + + if is_portfolio: + # Create a temp wizard object to grab the step list + wizard = PortfolioDomainRequestWizard() + wizard.request = self.request + context["Step"] = PortfolioDomainRequestStep.__members__ + context["steps"] = request_step_list(wizard, PortfolioDomainRequestStep) + context["form_titles"] = wizard.titles + else: + # For non-portfolio requests + wizard = DomainRequestWizard() + wizard.request = self.request + context["Step"] = Step.__members__ + context["steps"] = request_step_list(wizard, Step) + context["form_titles"] = wizard.titles + + # Common context + context["requires_feb_questions"] = domain_request.is_feb() and flag_is_active_for_user( self.request.user, "organization_feature" ) - context["purpose_label"] = DomainRequest.FEBPurposeChoices.get_purpose_label(self.object.feb_purpose_choice) + context["purpose_label"] = DomainRequest.FEBPurposeChoices.get_purpose_label(domain_request.feb_purpose_choice) + context["view_only_mode"] = True + context["is_portfolio"] = is_portfolio + context["portfolio"] = self.request.session.get("portfolio") + return context diff --git a/src/registrar/views/domain_requests_json.py b/src/registrar/views/domain_requests_json.py index 0533af66b..b13afd2e5 100644 --- a/src/registrar/views/domain_requests_json.py +++ b/src/registrar/views/domain_requests_json.py @@ -132,9 +132,19 @@ def _serialize_domain_request(request, domain_request, user): DomainRequest.DomainRequestStatus.WITHDRAWN, ] + # Statuses that should only allow viewing (not managing) + view_only_statuses = [ + DomainRequest.DomainRequestStatus.REJECTED, + ] + # No portfolio action_label if domain_request.creator == user: - action_label = "Edit" if domain_request.status in editable_statuses else "Manage" + if domain_request.status in editable_statuses: + action_label = "Edit" + elif domain_request.status in view_only_statuses: + action_label = "View" + else: + action_label = "Manage" else: action_label = "View" @@ -148,7 +158,12 @@ def _serialize_domain_request(request, domain_request, user): domain_request.status in deletable_statuses and user.has_edit_request_portfolio_permission(portfolio) ) and domain_request.creator == user if user.has_edit_request_portfolio_permission(portfolio) and domain_request.creator == user: - action_label = "Edit" if domain_request.status in editable_statuses else "Manage" + if domain_request.status in editable_statuses: + action_label = "Edit" + elif domain_request.status in view_only_statuses: + action_label = "View" + else: + action_label = "Manage" else: action_label = "View"