#3538: #3857: For Rejected status domain request changed Action from Manage to View - [litterbox] (#3853)

* 3538 Changed Action URL to View for Rejected Status domains. Added workaround for viewonly view.

* Bump setuptools from 77.0.3 to 78.1.1 in /src (#3802)

Bumps [setuptools](https://github.com/pypa/setuptools) from 77.0.3 to 78.1.1.
- [Release notes](https://github.com/pypa/setuptools/releases)
- [Changelog](https://github.com/pypa/setuptools/blob/main/NEWS.rst)
- [Commits](https://github.com/pypa/setuptools/compare/v77.0.3...v78.1.1)

---
updated-dependencies:
- dependency-name: setuptools
  dependency-version: 78.1.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: CuriousX <nicolle.leclair@gmail.com>

* 3806: Developer Onboarding adding Abe Alam to fixtures_users.py (#3828)

* adding Abe to fixtures_users.py
* Updated Admin Account to ECS Email
* Updated username and email for Analyst to ECS Alias email

* Bump undici from 6.21.1 to 6.21.3 in /src (#3787)

Bumps [undici](https://github.com/nodejs/undici) from 6.21.1 to 6.21.3.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v6.21.1...v6.21.3)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 6.21.3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: CuriousX <nicolle.leclair@gmail.com>

* #3455: Unlock current websites step even if there are none [litterbox] (#3840)

* Update to unlock with prior step for non-org request

* Update org model unlocking for current sites

* Update non-org unlocking

* Added a temp url fix for view only. Added rejected to the test views request.

* Undid previous temp fix as it should work for others. The 403 was caused by my local setup lack of org and portfolio for the rejected domain used for testing.

* updated for lint and test

* Refactored test for domain_requests

* Lint fixes for test.

* 3857 Fix for bug around view only permissions.

* fix linting

* Update src/registrar/views/domain_request.py

Co-authored-by: Erin Song <121973038+erinysong@users.noreply.github.com>

* PR cleanup

* Returning false if pk is missing and moved to the top of the function. Removed debug logs.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: CuriousX <nicolle.leclair@gmail.com>
Co-authored-by: Abe Alam <143724440+abe-alam-ecs@users.noreply.github.com>
Co-authored-by: Kim Allen <kim@truss.works>
Co-authored-by: Erin Song <121973038+erinysong@users.noreply.github.com>
This commit is contained in:
Daisy Guti 2025-07-02 09:31:50 -07:00 committed by GitHub
parent b250375de0
commit 10ba59317c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 147 additions and 62 deletions

View file

@ -272,7 +272,7 @@ urlpatterns = [
),
path(
"domain-request/viewonly/<int:domain_request_pk>",
views.PortfolioDomainRequestStatusViewOnly.as_view(),
views.DomainRequestStatusViewOnly.as_view(),
name="domain-request-status-viewonly",
),
path(

View file

@ -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

View file

@ -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],

View file

@ -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):

View file

@ -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

View file

@ -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"