Merge branch 'main' into litterbox/2399-fill-senior-official

This commit is contained in:
zandercymatics 2024-08-06 13:53:44 -06:00
commit b61d92ce3b
No known key found for this signature in database
GPG key ID: FF4636ABEC9682B7
31 changed files with 663 additions and 130 deletions

View file

@ -74,20 +74,4 @@ jobs:
cf_org: cisa-dotgov cf_org: cisa-dotgov
cf_space: ${{ env.ENVIRONMENT }} cf_space: ${{ env.ENVIRONMENT }}
cf_manifest: ops/manifests/manifest-${{ env.ENVIRONMENT }}.yaml cf_manifest: ops/manifests/manifest-${{ env.ENVIRONMENT }}.yaml
comment:
runs-on: ubuntu-latest
needs: [deploy]
steps:
- uses: actions/github-script@v6
env:
ENVIRONMENT: ${{ github.event.inputs.environment }}
with:
github-token: ${{secrets.GITHUB_TOKEN}}
script: |
github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
body: '🥳 Successfully deployed to developer sandbox **[${{ env.ENVIRONMENT }}](https://getgov-${{ env.ENVIRONMENT }}.app.cloud.gov/)**.'
})

2
.gitignore vendored
View file

@ -7,8 +7,10 @@ docs/research/data/**
public/ public/
credentials* credentials*
src/certs/
*.pem *.pem
*.crt *.crt
*.cer
*.bk *.bk

View file

@ -357,4 +357,8 @@ Then, copy the variables under the section labled `s3`.
1. On the app, navigate to `\admin`. 1. On the app, navigate to `\admin`.
2. Under models, click `Waffle flags`. 2. Under models, click `Waffle flags`.
3. Click the `disable_email_sending` record. This should exist by default, if not - create one with that name. 3. Click the `disable_email_sending` record. This should exist by default, if not - create one with that name.
4. (Important) Set the field `everyone` to `Yes`. This field overrides all other settings 4. (Important) Set the field `everyone` to `Yes`. This field overrides all other settings
## Request Flow FSM Diagram
The [.gov Domain Request & Domain Status Digram](https://miro.com/app/board/uXjVMuqbLOk=/?moveToWidget=3458764594819017396&cot=14) visualizes the domain request flow and resulting domain objects.

View file

@ -45,6 +45,8 @@ When deploying to your personal sandbox, you should make sure all of the USWDS a
For ease of use, you can run the `deploy.sh <sandbox name>` script in the `/src` directory to build the assets and deploy to your sandbox. Similarly, you could run `build.sh <sandbox name>` script to just compile and collect the assets without deploying. For ease of use, you can run the `deploy.sh <sandbox name>` script in the `/src` directory to build the assets and deploy to your sandbox. Similarly, you could run `build.sh <sandbox name>` script to just compile and collect the assets without deploying.
You may also manually deploy to a sandbox using our [manual deploy workflow](https://github.com/cisagov/manage.get.gov/actions/workflows/deploy-manual.yaml) on GitHub Actions. Select Run workflow and enter the branch you want to deploy to your sandbox of choice.
Your sandbox space should've been setup as part of the onboarding process. If this was not the case, please have an admin follow the instructions below. Your sandbox space should've been setup as part of the onboarding process. If this was not the case, please have an admin follow the instructions below.
## Creating a sandbox or new environment ## Creating a sandbox or new environment

View file

@ -116,6 +116,10 @@ sed -i '' '/ - development/ {a\
- '"$1"' - '"$1"'
}' .github/workflows/migrate.yaml }' .github/workflows/migrate.yaml
sed -i '' '/ - backup/ {a\
- '"$1"'
}' .github/workflows/deploy-manual.yaml
sed -i '' '/${{startsWith(github.head_ref, / {a\ sed -i '' '/${{startsWith(github.head_ref, / {a\
|| startsWith(github.head_ref, '"'$1'"') || startsWith(github.head_ref, '"'$1'"')
}' .github/workflows/deploy-sandbox.yaml }' .github/workflows/deploy-sandbox.yaml

View file

@ -49,6 +49,7 @@ rm ops/manifests/manifest-$1.yaml
sed -i '' "/getgov-$1.app.cloud.gov/d" src/registrar/config/settings.py sed -i '' "/getgov-$1.app.cloud.gov/d" src/registrar/config/settings.py
sed -i '' "/- $1/d" .github/workflows/reset-db.yaml sed -i '' "/- $1/d" .github/workflows/reset-db.yaml
sed -i '' "/- $1/d" .github/workflows/migrate.yaml sed -i '' "/- $1/d" .github/workflows/migrate.yaml
sed -i '' "/- $1/d" .github/workflows/deploy-manual.yaml
echo "Cleaning up services, applications, and the Cloud.gov space for $1..." echo "Cleaning up services, applications, and the Cloud.gov space for $1..."
cf delete getgov-$1 cf delete getgov-$1

View file

@ -11,6 +11,7 @@ from django.shortcuts import redirect
from django_fsm import get_available_FIELD_transitions, FSMField from django_fsm import get_available_FIELD_transitions, FSMField
from registrar.models.domain_group import DomainGroup from registrar.models.domain_group import DomainGroup
from registrar.models.suborganization import Suborganization from registrar.models.suborganization import Suborganization
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from waffle.decorators import flag_is_active from waffle.decorators import flag_is_active
from django.contrib import admin, messages from django.contrib import admin, messages
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
@ -131,12 +132,12 @@ class MyUserAdminForm(UserChangeForm):
"groups": NoAutocompleteFilteredSelectMultiple("groups", False), "groups": NoAutocompleteFilteredSelectMultiple("groups", False),
"user_permissions": NoAutocompleteFilteredSelectMultiple("user_permissions", False), "user_permissions": NoAutocompleteFilteredSelectMultiple("user_permissions", False),
"portfolio_roles": FilteredSelectMultipleArrayWidget( "portfolio_roles": FilteredSelectMultipleArrayWidget(
"portfolio_roles", is_stacked=False, choices=User.UserPortfolioRoleChoices.choices "portfolio_roles", is_stacked=False, choices=UserPortfolioRoleChoices.choices
), ),
"portfolio_additional_permissions": FilteredSelectMultipleArrayWidget( "portfolio_additional_permissions": FilteredSelectMultipleArrayWidget(
"portfolio_additional_permissions", "portfolio_additional_permissions",
is_stacked=False, is_stacked=False,
choices=User.UserPortfolioPermissionChoices.choices, choices=UserPortfolioPermissionChoices.choices,
), ),
} }
@ -169,6 +170,24 @@ class MyUserAdminForm(UserChangeForm):
) )
class PortfolioInvitationAdminForm(UserChangeForm):
"""This form utilizes the custom widget for its class's ManyToMany UIs."""
class Meta:
model = models.PortfolioInvitation
fields = "__all__"
widgets = {
"portfolio_roles": FilteredSelectMultipleArrayWidget(
"portfolio_roles", is_stacked=False, choices=UserPortfolioRoleChoices.choices
),
"portfolio_additional_permissions": FilteredSelectMultipleArrayWidget(
"portfolio_additional_permissions",
is_stacked=False,
choices=UserPortfolioPermissionChoices.choices,
),
}
class DomainInformationAdminForm(forms.ModelForm): class DomainInformationAdminForm(forms.ModelForm):
"""This form utilizes the custom widget for its class's ManyToMany UIs.""" """This form utilizes the custom widget for its class's ManyToMany UIs."""
@ -1299,6 +1318,56 @@ class DomainInvitationAdmin(ListHeaderAdmin):
return super().changelist_view(request, extra_context=extra_context) return super().changelist_view(request, extra_context=extra_context)
class PortfolioInvitationAdmin(ListHeaderAdmin):
"""Custom portfolio invitation admin class."""
form = PortfolioInvitationAdminForm
class Meta:
model = models.PortfolioInvitation
fields = "__all__"
_meta = Meta()
# Columns
list_display = [
"email",
"portfolio",
"portfolio_roles",
"portfolio_additional_permissions",
"status",
]
# Search
search_fields = [
"email",
"portfolio__name",
]
# Filters
list_filter = ("status",)
search_help_text = "Search by email or portfolio."
# Mark the FSM field 'status' as readonly
# to allow admin users to create Domain Invitations
# without triggering the FSM Transition Not Allowed
# error.
readonly_fields = ["status"]
autocomplete_fields = ["portfolio"]
change_form_template = "django/admin/email_clipboard_change_form.html"
# Select portfolio invitations to change -> Portfolio invitations
def changelist_view(self, request, extra_context=None):
if extra_context is None:
extra_context = {}
extra_context["tabtitle"] = "Portfolio invitations"
# Get the filtered values
return super().changelist_view(request, extra_context=extra_context)
class DomainInformationResource(resources.ModelResource): class DomainInformationResource(resources.ModelResource):
"""defines how each field in the referenced model should be mapped to the corresponding fields in the """defines how each field in the referenced model should be mapped to the corresponding fields in the
import/export file""" import/export file"""
@ -2900,6 +2969,7 @@ admin.site.register(models.PublicContact, PublicContactAdmin)
admin.site.register(models.DomainRequest, DomainRequestAdmin) admin.site.register(models.DomainRequest, DomainRequestAdmin)
admin.site.register(models.TransitionDomain, TransitionDomainAdmin) admin.site.register(models.TransitionDomain, TransitionDomainAdmin)
admin.site.register(models.VerifiedByStaff, VerifiedByStaffAdmin) admin.site.register(models.VerifiedByStaff, VerifiedByStaffAdmin)
admin.site.register(models.PortfolioInvitation, PortfolioInvitationAdmin)
admin.site.register(models.Portfolio, PortfolioAdmin) admin.site.register(models.Portfolio, PortfolioAdmin)
admin.site.register(models.DomainGroup, DomainGroupAdmin) admin.site.register(models.DomainGroup, DomainGroupAdmin)
admin.site.register(models.Suborganization, SuborganizationAdmin) admin.site.register(models.Suborganization, SuborganizationAdmin)

View file

@ -82,3 +82,13 @@ legend.float-left-tablet + button.float-right-tablet {
color: var(--close-button-hover-bg); color: var(--close-button-hover-bg);
} }
} }
.read-only-label {
font-size: size('body', 'sm');
color: color('primary');
margin-bottom: units(0.5);
}
.read-only-value {
margin-top: units(0);
}

View file

@ -61,7 +61,7 @@ def add_has_profile_feature_flag_to_context(request):
def portfolio_permissions(request): def portfolio_permissions(request):
"""Make portfolio permissions for the request user available in global context""" """Make portfolio permissions for the request user available in global context"""
try: try:
if not request.user or not request.user.is_authenticated: if not request.user or not request.user.is_authenticated or not flag_is_active(request, "organization_feature"):
return { return {
"has_base_portfolio_permission": False, "has_base_portfolio_permission": False,
"has_domains_portfolio_permission": False, "has_domains_portfolio_permission": False,
@ -74,7 +74,7 @@ def portfolio_permissions(request):
"has_domains_portfolio_permission": request.user.has_domains_portfolio_permission(), "has_domains_portfolio_permission": request.user.has_domains_portfolio_permission(),
"has_domain_requests_portfolio_permission": request.user.has_domain_requests_portfolio_permission(), "has_domain_requests_portfolio_permission": request.user.has_domain_requests_portfolio_permission(),
"portfolio": request.user.portfolio, "portfolio": request.user.portfolio,
"has_organization_feature_flag": flag_is_active(request, "organization_feature"), "has_organization_feature_flag": True,
} }
except AttributeError: except AttributeError:
# Handles cases where request.user might not exist # Handles cases where request.user might not exist

View file

@ -0,0 +1,83 @@
# Generated by Django 4.2.10 on 2024-08-01 12:28
import django.contrib.postgres.fields
from django.db import migrations, models
import django.db.models.deletion
import django_fsm
class Migration(migrations.Migration):
dependencies = [
("registrar", "0114_alter_user_portfolio_additional_permissions"),
]
operations = [
migrations.CreateModel(
name="PortfolioInvitation",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("email", models.EmailField(max_length=254)),
(
"portfolio_roles",
django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(
choices=[
("organization_admin", "Admin"),
("organization_admin_read_only", "Admin read only"),
("organization_member", "Member"),
],
max_length=50,
),
blank=True,
help_text="Select one or more roles.",
null=True,
size=None,
),
),
(
"portfolio_additional_permissions",
django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(
choices=[
("view_all_domains", "View all domains and domain reports"),
("view_managed_domains", "View managed domains"),
("view_member", "View members"),
("edit_member", "Create and edit members"),
("view_all_requests", "View all requests"),
("view_created_requests", "View created requests"),
("edit_requests", "Create and edit requests"),
("view_portfolio", "View organization"),
("edit_portfolio", "Edit organization"),
],
max_length=50,
),
blank=True,
help_text="Select one or more additional permissions.",
null=True,
size=None,
),
),
(
"status",
django_fsm.FSMField(
choices=[("invited", "Invited"), ("retrieved", "Retrieved")],
default="invited",
max_length=50,
protected=True,
),
),
(
"portfolio",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, related_name="portfolios", to="registrar.portfolio"
),
),
],
options={
"indexes": [models.Index(fields=["status"], name="registrar_p_status_aa4218_idx")],
},
),
]

View file

@ -1,4 +1,4 @@
from auditlog.registry import auditlog # type: ignore from auditlog.registry import auditlog
from .contact import Contact from .contact import Contact
from .domain_request import DomainRequest from .domain_request import DomainRequest
from .domain_information import DomainInformation from .domain_information import DomainInformation
@ -16,6 +16,7 @@ from .website import Website
from .transition_domain import TransitionDomain from .transition_domain import TransitionDomain
from .verified_by_staff import VerifiedByStaff from .verified_by_staff import VerifiedByStaff
from .waffle_flag import WaffleFlag from .waffle_flag import WaffleFlag
from .portfolio_invitation import PortfolioInvitation
from .portfolio import Portfolio from .portfolio import Portfolio
from .domain_group import DomainGroup from .domain_group import DomainGroup
from .suborganization import Suborganization from .suborganization import Suborganization
@ -40,6 +41,7 @@ __all__ = [
"TransitionDomain", "TransitionDomain",
"VerifiedByStaff", "VerifiedByStaff",
"WaffleFlag", "WaffleFlag",
"PortfolioInvitation",
"Portfolio", "Portfolio",
"DomainGroup", "DomainGroup",
"Suborganization", "Suborganization",
@ -63,6 +65,7 @@ auditlog.register(Website)
auditlog.register(TransitionDomain) auditlog.register(TransitionDomain)
auditlog.register(VerifiedByStaff) auditlog.register(VerifiedByStaff)
auditlog.register(WaffleFlag) auditlog.register(WaffleFlag)
auditlog.register(PortfolioInvitation)
auditlog.register(Portfolio) auditlog.register(Portfolio)
auditlog.register(DomainGroup) auditlog.register(DomainGroup)
auditlog.register(Suborganization) auditlog.register(Suborganization)

View file

@ -0,0 +1,95 @@
"""People are invited by email to administer domains."""
import logging
from django.contrib.auth import get_user_model
from django.db import models
from django_fsm import FSMField, transition
from .utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices # type: ignore
from .utility.time_stamped_model import TimeStampedModel
from django.contrib.postgres.fields import ArrayField
logger = logging.getLogger(__name__)
class PortfolioInvitation(TimeStampedModel):
class Meta:
"""Contains meta information about this class"""
indexes = [
models.Index(fields=["status"]),
]
# Constants for status field
class PortfolioInvitationStatus(models.TextChoices):
INVITED = "invited", "Invited"
RETRIEVED = "retrieved", "Retrieved"
email = models.EmailField(
null=False,
blank=False,
)
portfolio = models.ForeignKey(
"registrar.Portfolio",
on_delete=models.CASCADE, # delete portfolio, then get rid of invitations
null=False,
related_name="portfolios",
)
portfolio_roles = ArrayField(
models.CharField(
max_length=50,
choices=UserPortfolioRoleChoices.choices,
),
null=True,
blank=True,
help_text="Select one or more roles.",
)
portfolio_additional_permissions = ArrayField(
models.CharField(
max_length=50,
choices=UserPortfolioPermissionChoices.choices,
),
null=True,
blank=True,
help_text="Select one or more additional permissions.",
)
status = FSMField(
choices=PortfolioInvitationStatus.choices,
default=PortfolioInvitationStatus.INVITED,
protected=True, # can't alter state except through transition methods!
)
def __str__(self):
return f"Invitation for {self.email} on {self.portfolio} is {self.status}"
@transition(field="status", source=PortfolioInvitationStatus.INVITED, target=PortfolioInvitationStatus.RETRIEVED)
def retrieve(self):
"""When an invitation is retrieved, create the corresponding permission.
Raises:
RuntimeError if no matching user can be found.
"""
# get a user with this email address
User = get_user_model()
try:
user = User.objects.get(email=self.email)
except User.DoesNotExist:
# should not happen because a matching user should exist before
# we retrieve this invitation
raise RuntimeError("Cannot find the user to retrieve this portfolio invitation.")
# and create a role for that user on this portfolio
user.portfolio = self.portfolio
if self.portfolio_roles and len(self.portfolio_roles) > 0:
user.portfolio_roles = self.portfolio_roles
if self.portfolio_additional_permissions and len(self.portfolio_additional_permissions) > 0:
user.portfolio_additional_permissions = self.portfolio_additional_permissions
user.save()

View file

@ -5,8 +5,10 @@ from django.db import models
from django.db.models import Q from django.db.models import Q
from registrar.models.user_domain_role import UserDomainRole from registrar.models.user_domain_role import UserDomainRole
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from .domain_invitation import DomainInvitation from .domain_invitation import DomainInvitation
from .portfolio_invitation import PortfolioInvitation
from .transition_domain import TransitionDomain from .transition_domain import TransitionDomain
from .verified_by_staff import VerifiedByStaff from .verified_by_staff import VerifiedByStaff
from .domain import Domain from .domain import Domain
@ -62,31 +64,6 @@ class User(AbstractUser):
# after they login. # after they login.
FIXTURE_USER = "fixture_user", "Created by fixtures" FIXTURE_USER = "fixture_user", "Created by fixtures"
class UserPortfolioRoleChoices(models.TextChoices):
"""
Roles make it easier for admins to look at
"""
ORGANIZATION_ADMIN = "organization_admin", "Admin"
ORGANIZATION_ADMIN_READ_ONLY = "organization_admin_read_only", "Admin read only"
ORGANIZATION_MEMBER = "organization_member", "Member"
class UserPortfolioPermissionChoices(models.TextChoices):
""" """
VIEW_ALL_DOMAINS = "view_all_domains", "View all domains and domain reports"
VIEW_MANAGED_DOMAINS = "view_managed_domains", "View managed domains"
VIEW_MEMBER = "view_member", "View members"
EDIT_MEMBER = "edit_member", "Create and edit members"
VIEW_ALL_REQUESTS = "view_all_requests", "View all requests"
VIEW_CREATED_REQUESTS = "view_created_requests", "View created requests"
EDIT_REQUESTS = "edit_requests", "Create and edit requests"
VIEW_PORTFOLIO = "view_portfolio", "View organization"
EDIT_PORTFOLIO = "edit_portfolio", "Edit organization"
PORTFOLIO_ROLE_PERMISSIONS = { PORTFOLIO_ROLE_PERMISSIONS = {
UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [ UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS, UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
@ -270,20 +247,23 @@ class User(AbstractUser):
return portfolio_permission in portfolio_permissions return portfolio_permission in portfolio_permissions
# the methods below are checks for individual portfolio permissions. they are defined here # the methods below are checks for individual portfolio permissions. They are defined here
# to make them easier to call elsewhere throughout the application # to make them easier to call elsewhere throughout the application
def has_base_portfolio_permission(self): def has_base_portfolio_permission(self):
return self._has_portfolio_permission(User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO) return self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_PORTFOLIO)
def has_edit_org_portfolio_permission(self):
return self._has_portfolio_permission(UserPortfolioPermissionChoices.EDIT_PORTFOLIO)
def has_domains_portfolio_permission(self): def has_domains_portfolio_permission(self):
return self._has_portfolio_permission( return self._has_portfolio_permission(
User.UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS
) or self._has_portfolio_permission(User.UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS) ) or self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS)
def has_domain_requests_portfolio_permission(self): def has_domain_requests_portfolio_permission(self):
return self._has_portfolio_permission( return self._has_portfolio_permission(
User.UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS
) or self._has_portfolio_permission(User.UserPortfolioPermissionChoices.VIEW_CREATED_REQUESTS) ) or self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_CREATED_REQUESTS)
@classmethod @classmethod
def needs_identity_verification(cls, email, uuid): def needs_identity_verification(cls, email, uuid):
@ -392,6 +372,24 @@ class User(AbstractUser):
new_domain_invitation = DomainInvitation(email=transition_domain_email.lower(), domain=new_domain) new_domain_invitation = DomainInvitation(email=transition_domain_email.lower(), domain=new_domain)
new_domain_invitation.save() new_domain_invitation.save()
def check_portfolio_invitations_on_login(self):
"""When a user first arrives on the site, we need to retrieve any portfolio
invitations that match their email address."""
for invitation in PortfolioInvitation.objects.filter(
email__iexact=self.email, status=PortfolioInvitation.PortfolioInvitationStatus.INVITED
):
if self.portfolio is None:
try:
invitation.retrieve()
invitation.save()
except RuntimeError:
# retrieving should not fail because of a missing user, but
# if it does fail, log the error so a new user can continue
# logging in
logger.warn("Failed to retrieve invitation %s", invitation, exc_info=True)
else:
logger.warn("User already has a portfolio, did not retrieve invitation %s", invitation, exc_info=True)
def on_each_login(self): def on_each_login(self):
"""Callback each time the user is authenticated. """Callback each time the user is authenticated.
@ -403,6 +401,7 @@ class User(AbstractUser):
""" """
self.check_domain_invitations_on_login() self.check_domain_invitations_on_login()
self.check_portfolio_invitations_on_login()
def is_org_user(self, request): def is_org_user(self, request):
has_organization_feature_flag = flag_is_active(request, "organization_feature") has_organization_feature_flag = flag_is_active(request, "organization_feature")

View file

@ -0,0 +1,28 @@
from django.db import models
class UserPortfolioRoleChoices(models.TextChoices):
"""
Roles make it easier for admins to look at
"""
ORGANIZATION_ADMIN = "organization_admin", "Admin"
ORGANIZATION_ADMIN_READ_ONLY = "organization_admin_read_only", "Admin read only"
ORGANIZATION_MEMBER = "organization_member", "Member"
class UserPortfolioPermissionChoices(models.TextChoices):
""" """
VIEW_ALL_DOMAINS = "view_all_domains", "View all domains and domain reports"
VIEW_MANAGED_DOMAINS = "view_managed_domains", "View managed domains"
VIEW_MEMBER = "view_member", "View members"
EDIT_MEMBER = "edit_member", "Create and edit members"
VIEW_ALL_REQUESTS = "view_all_requests", "View all requests"
VIEW_CREATED_REQUESTS = "view_created_requests", "View created requests"
EDIT_REQUESTS = "edit_requests", "Create and edit requests"
VIEW_PORTFOLIO = "view_portfolio", "View organization"
EDIT_PORTFOLIO = "edit_portfolio", "Edit organization"

View file

@ -30,6 +30,8 @@
{% include "django/admin/includes/descriptions/verified_by_staff_description.html" %} {% include "django/admin/includes/descriptions/verified_by_staff_description.html" %}
{% elif opts.model_name == 'website' %} {% elif opts.model_name == 'website' %}
{% include "django/admin/includes/descriptions/website_description.html" %} {% include "django/admin/includes/descriptions/website_description.html" %}
{% elif opts.model_name == 'portfolioinvitation' %}
{% include "django/admin/includes/descriptions/portfolio_invitation_description.html" %}
{% else %} {% else %}
<p>This table does not have a description yet.</p> <p>This table does not have a description yet.</p>
{% endif %} {% endif %}

View file

@ -0,0 +1,11 @@
<p>
Portfolio invitations contain all individuals who have been invited to become members of an organization.
Invitations are sent via email, and the recipient must log in to the registrar to officially
accept and become a member.
</p>
<p>
An “invited” status indicates that the recipient has not logged in to the registrar since the invitation was sent
or that the recipient has logged in but is already a member of an organization.
A “received” status indicates that the recipient has logged in.
</p>

View file

@ -6,8 +6,7 @@
<ul class="usa-list"> <ul class="usa-list">
<li>Be available </li> <li>Be available </li>
<li>Relate to your organizations name, location, and/or services </li> <li>Relate to your organizations name, location, and/or services </li>
<li>Be clear to the general public. Your domain name must not be easily confused <li>Be unlikely to mislead or confuse the general public (even if your domain is only intended for a specific audience) </li>
with other organizations.</li>
</ul> </ul>
</p> </p>

View file

@ -2,7 +2,7 @@
<section class="section--outlined domain-requests" id="domain-requests"> <section class="section--outlined domain-requests" id="domain-requests">
<div class="grid-row"> <div class="grid-row">
{% if portfolio is None %} {% if not has_domain_requests_portfolio_permission %}
<div class="mobile:grid-col-12 desktop:grid-col-6"> <div class="mobile:grid-col-12 desktop:grid-col-6">
<h2 id="domain-requests-header" class="flex-6">Domain requests</h2> <h2 id="domain-requests-header" class="flex-6">Domain requests</h2>
</div> </div>

View file

@ -1,15 +1,15 @@
{% load static %} {% load static %}
<section class="section--outlined domains{% if portfolio is not None %} margin-top-0{% endif %}" id="domains"> <section class="section--outlined domains{% if not has_domains_portfolio_permission %} margin-top-0{% endif %}" id="domains">
<div class="section--outlined__header margin-bottom-3 {% if portfolio is None %} section--outlined__header--no-portfolio justify-content-space-between{% else %} grid-row{% endif %}"> <div class="section--outlined__header margin-bottom-3 {% if not has_domains_portfolio_permission %} section--outlined__header--no-portfolio justify-content-space-between{% else %} grid-row{% endif %}">
{% if portfolio is None %} {% if not has_domains_portfolio_permission %}
<h2 id="domains-header" class="display-inline-block">Domains</h2> <h2 id="domains-header" class="display-inline-block">Domains</h2>
<span class="display-none" id="no-portfolio-js-flag"></span> <span class="display-none" id="no-portfolio-js-flag"></span>
{% else %} {% else %}
<!-- Embedding the portfolio value in a data attribute --> <!-- Embedding the portfolio value in a data attribute -->
<span id="portfolio-js-value" data-portfolio="{{ portfolio.id }}"></span> <span id="portfolio-js-value" data-portfolio="{{ portfolio.id }}"></span>
{% endif %} {% endif %}
<div class="section--outlined__search {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6{% endif %}"> <div class="section--outlined__search {% if has_domains_portfolio_permission %} mobile:grid-col-12 desktop:grid-col-6{% endif %}">
<section aria-label="Domains search component" class="margin-top-2"> <section aria-label="Domains search component" class="margin-top-2">
<form class="usa-search usa-search--small" method="POST" role="search"> <form class="usa-search usa-search--small" method="POST" role="search">
{% csrf_token %} {% csrf_token %}
@ -37,7 +37,7 @@
</form> </form>
</section> </section>
</div> </div>
<div class="section--outlined__utility-button mobile-lg:padding-right-105 {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6 desktop:padding-left-3{% endif %}"> <div class="section--outlined__utility-button mobile-lg:padding-right-105 {% if has_domains_portfolio_permission %} mobile:grid-col-12 desktop:grid-col-6 desktop:padding-left-3{% endif %}">
<section aria-label="Domains report component" class="mobile-lg:margin-top-205"> <section aria-label="Domains report component" class="mobile-lg:margin-top-205">
<a href="{% url 'export_data_type_user' %}" class="usa-button usa-button--unstyled" role="button"> <a href="{% url 'export_data_type_user' %}" class="usa-button usa-button--unstyled" role="button">
<svg class="usa-icon usa-icon--big margin-right-neg-4px" aria-hidden="true" focusable="false" role="img" width="24" height="24"> <svg class="usa-icon usa-icon--big margin-right-neg-4px" aria-hidden="true" focusable="false" role="img" width="24" height="24">
@ -47,7 +47,7 @@
</section> </section>
</div> </div>
</div> </div>
{% if portfolio %} {% if has_domains_portfolio_permission %}
<div class="display-flex flex-align-center"> <div class="display-flex flex-align-center">
<span class="margin-right-2 margin-top-neg-1 usa-prose text-base-darker">Filter by</span> <span class="margin-right-2 margin-top-neg-1 usa-prose text-base-darker">Filter by</span>
<div class="usa-accordion usa-accordion--select margin-right-2"> <div class="usa-accordion usa-accordion--select margin-right-2">
@ -150,7 +150,7 @@
<th data-sortable="name" scope="col" role="columnheader">Domain name</th> <th data-sortable="name" scope="col" role="columnheader">Domain name</th>
<th data-sortable="expiration_date" scope="col" role="columnheader">Expires</th> <th data-sortable="expiration_date" scope="col" role="columnheader">Expires</th>
<th data-sortable="state_display" scope="col" role="columnheader">Status</th> <th data-sortable="state_display" scope="col" role="columnheader">Status</th>
{% if portfolio %} {% if has_domains_portfolio_permission %}
<th data-sortable="suborganization" scope="col" role="columnheader">Suborganization</th> <th data-sortable="suborganization" scope="col" role="columnheader">Suborganization</th>
{% endif %} {% endif %}
<th <th

View file

@ -35,7 +35,7 @@
<input type="hidden" name="redirect" value="{{ form.initial.redirect }}"> <input type="hidden" name="redirect" value="{{ form.initial.redirect }}">
{% with show_edit_button=True show_readonly=True group_classes="usa-form-editable usa-form-editable--no-border padding-top-2" %} {% with toggleable_input=True toggleable_label=True group_classes="usa-form-editable usa-form-editable--no-border padding-top-2" %}
{% input_with_errors form.full_name %} {% input_with_errors form.full_name %}
{% endwith %} {% endwith %}
@ -53,12 +53,12 @@
{% endwith %} {% endwith %}
</div> </div>
{% with show_edit_button=True show_readonly=True group_classes="usa-form-editable padding-top-2" %} {% with toggleable_input=True toggleable_label=True group_classes="usa-form-editable padding-top-2" %}
{% input_with_errors form.title %} {% input_with_errors form.title %}
{% endwith %} {% endwith %}
{% public_site_url "help/account-management/#get-help-with-login.gov" as login_help_url %} {% public_site_url "help/account-management/#get-help-with-login.gov" as login_help_url %}
{% with show_readonly=True add_class="display-none" group_classes="usa-form-editable usa-form-editable padding-top-2 bold-usa-label" %} {% with toggleable_input=True add_class="display-none" group_classes="usa-form-editable usa-form-editable padding-top-2 bold-usa-label" %}
{% with link_href=login_help_url %} {% with link_href=login_help_url %}
{% with sublabel_text="We recommend using your work email for your .gov account. If the wrong email is displayed below, youll need to update your Login.gov account and log back in. Get help with your Login.gov account." %} {% with sublabel_text="We recommend using your work email for your .gov account. If the wrong email is displayed below, youll need to update your Login.gov account and log back in. Get help with your Login.gov account." %}
{% with link_text="Get help with your Login.gov account" target_blank=True do_not_show_max_chars=True %} {% with link_text="Get help with your Login.gov account" target_blank=True do_not_show_max_chars=True %}
@ -68,7 +68,7 @@
{% endwith %} {% endwith %}
{% endwith %} {% endwith %}
{% with show_edit_button=True show_readonly=True group_classes="usa-form-editable padding-top-2" %} {% with toggleable_input=True toggleable_label=True group_classes="usa-form-editable padding-top-2" %}
{% with add_class="usa-input--medium" %} {% with add_class="usa-input--medium" %}
{% input_with_errors form.phone %} {% input_with_errors form.phone %}
{% endwith %} {% endwith %}

View file

@ -0,0 +1,7 @@
{% comment %}
Template include for read-only form fields
{% endcomment %}
<h4 class="read-only-label">{{ field.label }}</h4>
<p class="read-only-value">{{ field.value }}</p>

View file

@ -27,8 +27,8 @@ error messages, if necessary.
{% endif %} {% endif %}
{% if not field.widget_type == "checkbox" %} {% if not field.widget_type == "checkbox" %}
{% if show_edit_button %} {% if toggleable_label %}
{% include "includes/label_with_edit_button.html" with bold_label=True %} {% include "includes/toggleable_label.html" with bold_label=True %}
{% else %} {% else %}
{% include "django/forms/label.html" %} {% include "django/forms/label.html" %}
{% endif %} {% endif %}
@ -63,8 +63,8 @@ error messages, if necessary.
<div class="display-flex flex-align-center"> <div class="display-flex flex-align-center">
{% endif %} {% endif %}
{% if show_readonly %} {% if toggleable_input %}
{% include "includes/readonly_input.html" %} {% include "includes/toggleable_input.html" %}
{% endif %} {% endif %}
{# this is the input field, itself #} {# this is the input field, itself #}

View file

@ -23,42 +23,53 @@
<p>The name of your federal agency will be publicly listed as the domain registrant.</p> <p>The name of your federal agency will be publicly listed as the domain registrant.</p>
<p> {% if has_edit_org_portfolio_permission %}
The federal agency for your organization cant be updated here.
To suggest an update, email <a href="mailto:help@get.gov" class="usa-link">help@get.gov</a>.
</p>
{% include "includes/form_errors.html" with form=form %}
{% include "includes/required_fields.html" %}
<form class="usa-form usa-form--large" method="post" novalidate id="form-container">
{% csrf_token %}
<p> <p>
<strong class="text-primary display-block margin-bottom-1">Federal agency</strong> The federal agency for your organization cant be updated here.
{{ portfolio }} To suggest an update, email <a href="mailto:help@get.gov" class="usa-link">help@get.gov</a>.
</p> </p>
{% input_with_errors form.address_line1 %} {% include "includes/form_errors.html" with form=form %}
{% include "includes/required_fields.html" %}
<form class="usa-form usa-form--large" method="post" novalidate>
{% csrf_token %}
<h4 class="read-only-label">Federal agency</h4>
<p class="read-only-value">
{{ portfolio.federal_agency }}
</p>
{% input_with_errors form.address_line1 %}
{% input_with_errors form.address_line2 %}
{% input_with_errors form.city %}
{% input_with_errors form.state_territory %}
{% with add_class="usa-input--small" %}
{% input_with_errors form.zipcode %}
{% endwith %}
<button type="submit" class="usa-button">
Save
</button>
</form>
{% else %}
<h4 class="read-only-label">Federal agency</h4>
<p class="read-only-value">
{{ portfolio.federal_agency }}
</p>
{% if form.address_line1.value is not None %}
{% include "includes/input_read_only.html" with field=form.address_line1 %}
{% endif %}
{% if form.address_line2.value is not None %}
{% include "includes/input_read_only.html" with field=form.address_line2 %}
{% endif %}
{% if form.city.value is not None %}
{% include "includes/input_read_only.html" with field=form.city %}
{% endif %}
{% if form.state_territory.value is not None %}
{% include "includes/input_read_only.html" with field=form.state_territory %}
{% endif %}
{% if form.zipcode.value is not None %}
{% include "includes/input_read_only.html" with field=form.zipcode %}
{% endif %}
{% endif %}
{% input_with_errors form.address_line2 %}
{% input_with_errors form.city %}
{% input_with_errors form.state_territory %}
{% with add_class="usa-input--small" %}
{% input_with_errors form.zipcode %}
{% endwith %}
<button
type="submit"
class="usa-button"
>
Save
</button>
</form>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -26,7 +26,7 @@ def input_with_errors(context, field=None): # noqa: C901
add_group_class: append to input element's surrounding tag's `class` attribute add_group_class: append to input element's surrounding tag's `class` attribute
attr_* - adds or replaces any single html attribute for the input attr_* - adds or replaces any single html attribute for the input
add_error_attr_* - like `attr_*` but only if field.errors is not empty add_error_attr_* - like `attr_*` but only if field.errors is not empty
show_edit_button: shows a simple edit button, and adds display-none to the input field. toggleable_input: shows a simple edit button, and adds display-none to the input field.
Example usage: Example usage:
``` ```
@ -92,7 +92,7 @@ def input_with_errors(context, field=None): # noqa: C901
elif key == "add_group_class": elif key == "add_group_class":
group_classes.append(value) group_classes.append(value)
elif key == "show_edit_button": elif key == "toggleable_input":
# Hide the primary input field. # Hide the primary input field.
# Used such that we can toggle it with JS # Used such that we can toggle it with JS
if "display-none" not in classes: if "display-none" not in classes:

View file

@ -13,6 +13,7 @@ from registrar.admin import (
ContactAdmin, ContactAdmin,
DomainInformationAdmin, DomainInformationAdmin,
MyHostAdmin, MyHostAdmin,
PortfolioInvitationAdmin,
UserDomainRoleAdmin, UserDomainRoleAdmin,
VerifiedByStaffAdmin, VerifiedByStaffAdmin,
FsmModelResource, FsmModelResource,
@ -38,6 +39,7 @@ from registrar.models import (
UserGroup, UserGroup,
TransitionDomain, TransitionDomain,
) )
from registrar.models.portfolio_invitation import PortfolioInvitation
from registrar.models.senior_official import SeniorOfficial from registrar.models.senior_official import SeniorOfficial
from registrar.models.user_domain_role import UserDomainRole from registrar.models.user_domain_role import UserDomainRole
from registrar.models.verified_by_staff import VerifiedByStaff from registrar.models.verified_by_staff import VerifiedByStaff
@ -177,6 +179,77 @@ class TestDomainInvitationAdmin(TestCase):
self.assertContains(response, retrieved_html, count=1) self.assertContains(response, retrieved_html, count=1)
class TestPortfolioInvitationAdmin(TestCase):
"""Tests for the PortfolioInvitationAdmin class as super user
Notes:
all tests share superuser; do not change this model in tests
tests have available superuser, client, and admin
"""
@classmethod
def setUpClass(cls):
cls.factory = RequestFactory()
cls.admin = ListHeaderAdmin(model=PortfolioInvitationAdmin, admin_site=AdminSite())
cls.superuser = create_superuser()
def setUp(self):
"""Create a client object"""
self.client = Client(HTTP_HOST="localhost:8080")
def tearDown(self):
"""Delete all DomainInvitation objects"""
PortfolioInvitation.objects.all().delete()
Contact.objects.all().delete()
@classmethod
def tearDownClass(self):
User.objects.all().delete()
@less_console_noise_decorator
def test_has_model_description(self):
"""Tests if this model has a model description on the table view"""
self.client.force_login(self.superuser)
response = self.client.get(
"/admin/registrar/portfolioinvitation/",
follow=True,
)
# Make sure that the page is loaded correctly
self.assertEqual(response.status_code, 200)
# Test for a description snippet
self.assertContains(
response,
"Portfolio invitations contain all individuals who have been invited to become members of an organization.",
)
self.assertContains(response, "Show more")
def test_get_filters(self):
"""Ensures that our filters are displaying correctly"""
with less_console_noise():
self.client.force_login(self.superuser)
response = self.client.get(
"/admin/registrar/portfolioinvitation/",
{},
follow=True,
)
# Assert that the filters are added
self.assertContains(response, "invited", count=4)
self.assertContains(response, "Invited", count=2)
self.assertContains(response, "retrieved", count=2)
self.assertContains(response, "Retrieved", count=2)
# Check for the HTML context specificially
invited_html = '<a href="?status__exact=invited">Invited</a>'
retrieved_html = '<a href="?status__exact=retrieved">Retrieved</a>'
self.assertContains(response, invited_html, count=1)
self.assertContains(response, retrieved_html, count=1)
class TestHostAdmin(TestCase): class TestHostAdmin(TestCase):
"""Tests for the HostAdmin class as super user """Tests for the HostAdmin class as super user

View file

@ -20,7 +20,9 @@ from registrar.models import (
import boto3_mocking import boto3_mocking
from registrar.models.portfolio import Portfolio from registrar.models.portfolio import Portfolio
from registrar.models.portfolio_invitation import PortfolioInvitation
from registrar.models.transition_domain import TransitionDomain from registrar.models.transition_domain import TransitionDomain
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from registrar.models.verified_by_staff import VerifiedByStaff # type: ignore from registrar.models.verified_by_staff import VerifiedByStaff # type: ignore
from registrar.utility.constants import BranchChoices from registrar.utility.constants import BranchChoices
@ -1071,8 +1073,8 @@ class TestDomainInformation(TestCase):
return {k: v for k, v in dict_obj.items() if k not in bad_fields} return {k: v for k, v in dict_obj.items() if k not in bad_fields}
class TestInvitations(TestCase): class TestDomainInvitations(TestCase):
"""Test the retrieval of invitations.""" """Test the retrieval of domain invitations."""
@less_console_noise_decorator @less_console_noise_decorator
def setUp(self): def setUp(self):
@ -1116,6 +1118,65 @@ class TestInvitations(TestCase):
self.assertTrue(UserDomainRole.objects.get(user=self.user, domain=self.domain)) self.assertTrue(UserDomainRole.objects.get(user=self.user, domain=self.domain))
class TestPortfolioInvitations(TestCase):
"""Test the retrieval of portfolio invitations."""
@less_console_noise_decorator
def setUp(self):
self.email = "mayor@igorville.gov"
self.email2 = "creator@igorville.gov"
self.user, _ = User.objects.get_or_create(email=self.email)
self.user2, _ = User.objects.get_or_create(email=self.email2, username="creator")
self.portfolio, _ = Portfolio.objects.get_or_create(creator=self.user2, organization_name="Hotel California")
self.portfolio_role_base = UserPortfolioRoleChoices.ORGANIZATION_MEMBER
self.portfolio_role_admin = UserPortfolioRoleChoices.ORGANIZATION_ADMIN
self.portfolio_permission_1 = UserPortfolioPermissionChoices.VIEW_CREATED_REQUESTS
self.portfolio_permission_2 = UserPortfolioPermissionChoices.EDIT_REQUESTS
self.invitation, _ = PortfolioInvitation.objects.get_or_create(
email=self.email,
portfolio=self.portfolio,
portfolio_roles=[self.portfolio_role_base, self.portfolio_role_admin],
portfolio_additional_permissions=[self.portfolio_permission_1, self.portfolio_permission_2],
)
def tearDown(self):
super().tearDown()
Portfolio.objects.all().delete()
PortfolioInvitation.objects.all().delete()
User.objects.all().delete()
@less_console_noise_decorator
def test_retrieval(self):
self.assertFalse(self.user.portfolio)
self.invitation.retrieve()
self.user.refresh_from_db()
self.assertEqual(self.user.portfolio.organization_name, "Hotel California")
self.assertEqual(self.user.portfolio_roles, [self.portfolio_role_base, self.portfolio_role_admin])
self.assertEqual(
self.user.portfolio_additional_permissions, [self.portfolio_permission_1, self.portfolio_permission_2]
)
self.assertEqual(self.invitation.status, PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED)
@less_console_noise_decorator
def test_retrieve_missing_user_error(self):
# get rid of matching users
User.objects.filter(email=self.email).delete()
with self.assertRaises(RuntimeError):
self.invitation.retrieve()
@less_console_noise_decorator
def test_retrieve_user_already_member_error(self):
self.assertFalse(self.user.portfolio)
portfolio2, _ = Portfolio.objects.get_or_create(creator=self.user2, organization_name="Tokyo Hotel")
self.user.portfolio = portfolio2
self.assertEqual(self.user.portfolio.organization_name, "Tokyo Hotel")
self.user.save()
self.user.check_portfolio_invitations_on_login()
self.user.refresh_from_db()
self.assertEqual(self.user.portfolio.organization_name, "Tokyo Hotel")
self.assertEqual(self.invitation.status, PortfolioInvitation.PortfolioInvitationStatus.INVITED)
class TestUser(TestCase): class TestUser(TestCase):
"""Test actions that occur on user login, """Test actions that occur on user login,
test class method that controls how users get validated.""" test class method that controls how users get validated."""
@ -1135,6 +1196,7 @@ class TestUser(TestCase):
DomainRequest.objects.all().delete() DomainRequest.objects.all().delete()
DraftDomain.objects.all().delete() DraftDomain.objects.all().delete()
TransitionDomain.objects.all().delete() TransitionDomain.objects.all().delete()
Portfolio.objects.all().delete()
User.objects.all().delete() User.objects.all().delete()
UserDomainRole.objects.all().delete() UserDomainRole.objects.all().delete()
@ -1297,7 +1359,7 @@ class TestUser(TestCase):
""" """
portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Hotel California") portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Hotel California")
self.user.portfolio_additional_permissions = [User.UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS] self.user.portfolio_additional_permissions = [UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS]
self.user.save() self.user.save()
self.user.refresh_from_db() self.user.refresh_from_db()
@ -1317,7 +1379,7 @@ class TestUser(TestCase):
self.assertTrue(user_can_view_all_domains) self.assertTrue(user_can_view_all_domains)
self.assertFalse(user_can_view_all_requests) self.assertFalse(user_can_view_all_requests)
self.user.portfolio_roles = [User.UserPortfolioRoleChoices.ORGANIZATION_ADMIN] self.user.portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
self.user.save() self.user.save()
self.user.refresh_from_db() self.user.refresh_from_db()

View file

@ -7,6 +7,7 @@ from django.contrib.auth import get_user_model
from api.tests.common import less_console_noise_decorator from api.tests.common import less_console_noise_decorator
from registrar.models.portfolio import Portfolio from registrar.models.portfolio import Portfolio
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
from .common import MockEppLib, MockSESClient, create_user # type: ignore from .common import MockEppLib, MockSESClient, create_user # type: ignore
from django_webtest import WebTest # type: ignore from django_webtest import WebTest # type: ignore
import boto3_mocking # type: ignore import boto3_mocking # type: ignore
@ -326,7 +327,7 @@ class TestDomainDetail(TestDomainOverview):
phone="8003111234", phone="8003111234",
title="test title", title="test title",
portfolio=portfolio, portfolio=portfolio,
portfolio_roles=[User.UserPortfolioRoleChoices.ORGANIZATION_ADMIN], portfolio_roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
) )
domain, _ = Domain.objects.get_or_create(name="bogusdomain.gov") domain, _ = Domain.objects.get_or_create(name="bogusdomain.gov")
DomainInformation.objects.get_or_create(creator=user, domain=domain, portfolio=portfolio) DomainInformation.objects.get_or_create(creator=user, domain=domain, portfolio=portfolio)

View file

@ -10,6 +10,7 @@ from registrar.models import (
UserDomainRole, UserDomainRole,
User, User,
) )
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from .common import create_test_user from .common import create_test_user
from waffle.testutils import override_flag from waffle.testutils import override_flag
@ -55,7 +56,7 @@ class TestPortfolio(WebTest):
def test_middleware_does_not_redirect_if_no_portfolio(self): def test_middleware_does_not_redirect_if_no_portfolio(self):
"""Test that user with no assigned portfolio is not redirected when attempting to access home""" """Test that user with no assigned portfolio is not redirected when attempting to access home"""
self.app.set_user(self.user.username) self.app.set_user(self.user.username)
self.user.portfolio_additional_permissions = [User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO] self.user.portfolio_additional_permissions = [UserPortfolioPermissionChoices.VIEW_PORTFOLIO]
self.user.save() self.user.save()
self.user.refresh_from_db() self.user.refresh_from_db()
with override_flag("organization_feature", active=True): with override_flag("organization_feature", active=True):
@ -67,10 +68,10 @@ class TestPortfolio(WebTest):
@less_console_noise_decorator @less_console_noise_decorator
def test_middleware_redirects_to_portfolio_organization_page(self): def test_middleware_redirects_to_portfolio_organization_page(self):
"""Test that user with VIEW_PORTFOLIO is redirected to portfolio organization page""" """Test that user with a portfolio and VIEW_PORTFOLIO is redirected to portfolio organization page"""
self.app.set_user(self.user.username) self.app.set_user(self.user.username)
self.user.portfolio = self.portfolio self.user.portfolio = self.portfolio
self.user.portfolio_additional_permissions = [User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO] self.user.portfolio_additional_permissions = [UserPortfolioPermissionChoices.VIEW_PORTFOLIO]
self.user.save() self.user.save()
self.user.refresh_from_db() self.user.refresh_from_db()
with override_flag("organization_feature", active=True): with override_flag("organization_feature", active=True):
@ -83,12 +84,13 @@ class TestPortfolio(WebTest):
@less_console_noise_decorator @less_console_noise_decorator
def test_middleware_redirects_to_portfolio_domains_page(self): def test_middleware_redirects_to_portfolio_domains_page(self):
"""Test that user with VIEW_PORTFOLIO and VIEW_ALL_DOMAINS is redirected to portfolio domains page""" """Test that user with a portfolio, VIEW_PORTFOLIO, VIEW_ALL_DOMAINS
is redirected to portfolio domains page"""
self.app.set_user(self.user.username) self.app.set_user(self.user.username)
self.user.portfolio = self.portfolio self.user.portfolio = self.portfolio
self.user.portfolio_additional_permissions = [ self.user.portfolio_additional_permissions = [
User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO, UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
User.UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS, UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
] ]
self.user.save() self.user.save()
self.user.refresh_from_db() self.user.refresh_from_db()
@ -143,15 +145,60 @@ class TestPortfolio(WebTest):
# Assert the response is a 403 Forbidden # Assert the response is a 403 Forbidden
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
@less_console_noise_decorator
def test_portfolio_organization_page_read_only(self):
"""Test that user with a portfolio can access the portfolio organization page, read only"""
self.app.set_user(self.user.username)
self.user.portfolio = self.portfolio
self.portfolio.city = "Los Angeles"
self.user.portfolio_additional_permissions = [UserPortfolioPermissionChoices.VIEW_PORTFOLIO]
self.portfolio.save()
self.user.save()
self.user.refresh_from_db()
with override_flag("organization_feature", active=True):
response = self.app.get(reverse("organization"))
# Assert the response is a 200
self.assertEqual(response.status_code, 200)
# The label for Federal agency will always be a h4
self.assertContains(response, '<h4 class="read-only-label">Federal agency</h4>')
# The read only label for city will be a h4
self.assertContains(response, '<h4 class="read-only-label">City</h4>')
self.assertNotContains(response, 'for="id_city"')
self.assertContains(response, '<p class="read-only-value">Los Angeles</p>')
@less_console_noise_decorator
def test_portfolio_organization_page_edit_access(self):
"""Test that user with a portfolio can access the portfolio organization page, read only"""
self.app.set_user(self.user.username)
self.user.portfolio = self.portfolio
self.user.portfolio_additional_permissions = [
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
UserPortfolioPermissionChoices.EDIT_PORTFOLIO,
]
self.portfolio.city = "Los Angeles"
self.portfolio.save()
self.user.save()
self.user.refresh_from_db()
with override_flag("organization_feature", active=True):
response = self.app.get(reverse("organization"))
# Assert the response is a 200
self.assertEqual(response.status_code, 200)
# The label for Federal agency will always be a h4
self.assertContains(response, '<h4 class="read-only-label">Federal agency</h4>')
# The read only label for city will be a h4
self.assertNotContains(response, '<h4 class="read-only-label">City</h4>')
self.assertNotContains(response, '<p class="read-only-value">Los Angeles</p>>')
self.assertContains(response, 'for="id_city"')
@less_console_noise_decorator @less_console_noise_decorator
def test_navigation_links_hidden_when_user_not_have_permission(self): def test_navigation_links_hidden_when_user_not_have_permission(self):
"""Test that navigation links are hidden when user does not have portfolio permissions""" """Test that navigation links are hidden when user does not have portfolio permissions"""
self.app.set_user(self.user.username) self.app.set_user(self.user.username)
self.user.portfolio = self.portfolio self.user.portfolio = self.portfolio
self.user.portfolio_additional_permissions = [ self.user.portfolio_additional_permissions = [
User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO, UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
User.UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS, UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
User.UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
] ]
self.user.save() self.user.save()
self.user.refresh_from_db() self.user.refresh_from_db()
@ -166,9 +213,9 @@ class TestPortfolio(WebTest):
self.assertContains(portfolio_page, reverse("domains")) self.assertContains(portfolio_page, reverse("domains"))
self.assertContains(portfolio_page, reverse("domain-requests")) self.assertContains(portfolio_page, reverse("domain-requests"))
# reducing portfolio permissions to just VIEW_PORTFOLIO, which should remove domains # removing non-basic portfolio perms, which should remove domains
# and domain requests from nav # and domain requests from nav
self.user.portfolio_additional_permissions = [User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO] self.user.portfolio_additional_permissions = [UserPortfolioPermissionChoices.VIEW_PORTFOLIO]
self.user.save() self.user.save()
self.user.refresh_from_db() self.user.refresh_from_db()
@ -180,17 +227,48 @@ class TestPortfolio(WebTest):
self.assertNotContains(portfolio_page, reverse("domains")) self.assertNotContains(portfolio_page, reverse("domains"))
self.assertNotContains(portfolio_page, reverse("domain-requests")) self.assertNotContains(portfolio_page, reverse("domain-requests"))
@less_console_noise_decorator
def test_navigation_links_hidden_when_user_not_have_role(self):
"""Test that admin / memmber roles are associated with the right access"""
self.app.set_user(self.user.username)
self.user.portfolio = self.portfolio
self.user.portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
self.user.save()
self.user.refresh_from_db()
with override_flag("organization_feature", active=True):
# This will redirect the user to the portfolio page.
# Follow implicity checks if our redirect is working.
portfolio_page = self.app.get(reverse("home")).follow()
# Assert that we're on the right page
self.assertContains(portfolio_page, self.portfolio.organization_name)
self.assertNotContains(portfolio_page, "<h1>Organization</h1>")
self.assertContains(portfolio_page, '<h1 id="domains-header">Domains</h1>')
self.assertContains(portfolio_page, reverse("domains"))
self.assertContains(portfolio_page, reverse("domain-requests"))
class TestPortfolioOrganization(TestPortfolio): # removing non-basic portfolio role, which should remove domains
# and domain requests from nav
self.user.portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_MEMBER]
self.user.save()
self.user.refresh_from_db()
portfolio_page = self.app.get(reverse("home")).follow()
self.assertContains(portfolio_page, self.portfolio.organization_name)
self.assertContains(portfolio_page, "<h1>Organization</h1>")
self.assertNotContains(portfolio_page, '<h1 id="domains-header">Domains</h1>')
self.assertNotContains(portfolio_page, reverse("domains"))
self.assertNotContains(portfolio_page, reverse("domain-requests"))
@less_console_noise_decorator
def test_portfolio_org_name(self): def test_portfolio_org_name(self):
"""Can load portfolio's org name page.""" """Can load portfolio's org name page."""
with override_flag("organization_feature", active=True): with override_flag("organization_feature", active=True):
self.app.set_user(self.user.username) self.app.set_user(self.user.username)
self.user.portfolio = self.portfolio self.user.portfolio = self.portfolio
self.user.portfolio_additional_permissions = [ self.user.portfolio_additional_permissions = [
User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO, UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
User.UserPortfolioPermissionChoices.EDIT_PORTFOLIO, UserPortfolioPermissionChoices.EDIT_PORTFOLIO,
] ]
self.user.save() self.user.save()
self.user.refresh_from_db() self.user.refresh_from_db()
@ -200,14 +278,15 @@ class TestPortfolioOrganization(TestPortfolio):
page, "The name of your federal agency will be publicly listed as the domain registrant." page, "The name of your federal agency will be publicly listed as the domain registrant."
) )
@less_console_noise_decorator
def test_domain_org_name_address_content(self): def test_domain_org_name_address_content(self):
"""Org name and address information appears on the page.""" """Org name and address information appears on the page."""
with override_flag("organization_feature", active=True): with override_flag("organization_feature", active=True):
self.app.set_user(self.user.username) self.app.set_user(self.user.username)
self.user.portfolio = self.portfolio self.user.portfolio = self.portfolio
self.user.portfolio_additional_permissions = [ self.user.portfolio_additional_permissions = [
User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO, UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
User.UserPortfolioPermissionChoices.EDIT_PORTFOLIO, UserPortfolioPermissionChoices.EDIT_PORTFOLIO,
] ]
self.user.save() self.user.save()
self.user.refresh_from_db() self.user.refresh_from_db()
@ -215,17 +294,19 @@ class TestPortfolioOrganization(TestPortfolio):
self.portfolio.organization_name = "Hotel California" self.portfolio.organization_name = "Hotel California"
self.portfolio.save() self.portfolio.save()
page = self.app.get(reverse("organization")) page = self.app.get(reverse("organization"))
# Once in the sidenav, once in the main nav, once in the form # Once in the sidenav, once in the main nav
self.assertContains(page, "Hotel California", count=3) self.assertContains(page, "Hotel California", count=2)
self.assertContains(page, "Non-Federal Agency")
@less_console_noise_decorator
def test_domain_org_name_address_form(self): def test_domain_org_name_address_form(self):
"""Submitting changes works on the org name address page.""" """Submitting changes works on the org name address page."""
with override_flag("organization_feature", active=True): with override_flag("organization_feature", active=True):
self.app.set_user(self.user.username) self.app.set_user(self.user.username)
self.user.portfolio = self.portfolio self.user.portfolio = self.portfolio
self.user.portfolio_additional_permissions = [ self.user.portfolio_additional_permissions = [
User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO, UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
User.UserPortfolioPermissionChoices.EDIT_PORTFOLIO, UserPortfolioPermissionChoices.EDIT_PORTFOLIO,
] ]
self.user.save() self.user.save()
self.user.refresh_from_db() self.user.refresh_from_db()

View file

@ -48,6 +48,7 @@ class PortfolioOrganizationView(PortfolioBasePermissionView, FormMixin):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
"""Add additional context data to the template.""" """Add additional context data to the template."""
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context["has_edit_org_portfolio_permission"] = self.request.user.has_edit_org_portfolio_permission()
return context return context
def get_object(self, queryset=None): def get_object(self, queryset=None):