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_space: ${{ env.ENVIRONMENT }}
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/
credentials*
src/certs/
*.pem
*.crt
*.cer
*.bk

View file

@ -357,4 +357,8 @@ Then, copy the variables under the section labled `s3`.
1. On the app, navigate to `\admin`.
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.
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.
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.
## Creating a sandbox or new environment

View file

@ -116,6 +116,10 @@ sed -i '' '/ - development/ {a\
- '"$1"'
}' .github/workflows/migrate.yaml
sed -i '' '/ - backup/ {a\
- '"$1"'
}' .github/workflows/deploy-manual.yaml
sed -i '' '/${{startsWith(github.head_ref, / {a\
|| startsWith(github.head_ref, '"'$1'"')
}' .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 '' "/- $1/d" .github/workflows/reset-db.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..."
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 registrar.models.domain_group import DomainGroup
from registrar.models.suborganization import Suborganization
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from waffle.decorators import flag_is_active
from django.contrib import admin, messages
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
@ -131,12 +132,12 @@ class MyUserAdminForm(UserChangeForm):
"groups": NoAutocompleteFilteredSelectMultiple("groups", False),
"user_permissions": NoAutocompleteFilteredSelectMultiple("user_permissions", False),
"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",
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):
"""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)
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):
"""defines how each field in the referenced model should be mapped to the corresponding fields in the
import/export file"""
@ -2900,6 +2969,7 @@ admin.site.register(models.PublicContact, PublicContactAdmin)
admin.site.register(models.DomainRequest, DomainRequestAdmin)
admin.site.register(models.TransitionDomain, TransitionDomainAdmin)
admin.site.register(models.VerifiedByStaff, VerifiedByStaffAdmin)
admin.site.register(models.PortfolioInvitation, PortfolioInvitationAdmin)
admin.site.register(models.Portfolio, PortfolioAdmin)
admin.site.register(models.DomainGroup, DomainGroupAdmin)
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);
}
}
.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):
"""Make portfolio permissions for the request user available in global context"""
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 {
"has_base_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_domain_requests_portfolio_permission": request.user.has_domain_requests_portfolio_permission(),
"portfolio": request.user.portfolio,
"has_organization_feature_flag": flag_is_active(request, "organization_feature"),
"has_organization_feature_flag": True,
}
except AttributeError:
# 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 .domain_request import DomainRequest
from .domain_information import DomainInformation
@ -16,6 +16,7 @@ from .website import Website
from .transition_domain import TransitionDomain
from .verified_by_staff import VerifiedByStaff
from .waffle_flag import WaffleFlag
from .portfolio_invitation import PortfolioInvitation
from .portfolio import Portfolio
from .domain_group import DomainGroup
from .suborganization import Suborganization
@ -40,6 +41,7 @@ __all__ = [
"TransitionDomain",
"VerifiedByStaff",
"WaffleFlag",
"PortfolioInvitation",
"Portfolio",
"DomainGroup",
"Suborganization",
@ -63,6 +65,7 @@ auditlog.register(Website)
auditlog.register(TransitionDomain)
auditlog.register(VerifiedByStaff)
auditlog.register(WaffleFlag)
auditlog.register(PortfolioInvitation)
auditlog.register(Portfolio)
auditlog.register(DomainGroup)
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 registrar.models.user_domain_role import UserDomainRole
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from .domain_invitation import DomainInvitation
from .portfolio_invitation import PortfolioInvitation
from .transition_domain import TransitionDomain
from .verified_by_staff import VerifiedByStaff
from .domain import Domain
@ -62,31 +64,6 @@ class User(AbstractUser):
# after they login.
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 = {
UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
@ -270,20 +247,23 @@ class User(AbstractUser):
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
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):
return self._has_portfolio_permission(
User.UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS
) or self._has_portfolio_permission(User.UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS)
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS
) or self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS)
def has_domain_requests_portfolio_permission(self):
return self._has_portfolio_permission(
User.UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS
) or self._has_portfolio_permission(User.UserPortfolioPermissionChoices.VIEW_CREATED_REQUESTS)
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS
) or self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_CREATED_REQUESTS)
@classmethod
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.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):
"""Callback each time the user is authenticated.
@ -403,6 +401,7 @@ class User(AbstractUser):
"""
self.check_domain_invitations_on_login()
self.check_portfolio_invitations_on_login()
def is_org_user(self, request):
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" %}
{% elif opts.model_name == 'website' %}
{% include "django/admin/includes/descriptions/website_description.html" %}
{% elif opts.model_name == 'portfolioinvitation' %}
{% include "django/admin/includes/descriptions/portfolio_invitation_description.html" %}
{% else %}
<p>This table does not have a description yet.</p>
{% 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">
<li>Be available </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
with other organizations.</li>
<li>Be unlikely to mislead or confuse the general public (even if your domain is only intended for a specific audience) </li>
</ul>
</p>

View file

@ -2,7 +2,7 @@
<section class="section--outlined domain-requests" id="domain-requests">
<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">
<h2 id="domain-requests-header" class="flex-6">Domain requests</h2>
</div>

View file

@ -1,15 +1,15 @@
{% load static %}
<section class="section--outlined domains{% if portfolio is not None %} 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 %}">
{% if portfolio is None %}
<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 not has_domains_portfolio_permission %} section--outlined__header--no-portfolio justify-content-space-between{% else %} grid-row{% endif %}">
{% if not has_domains_portfolio_permission %}
<h2 id="domains-header" class="display-inline-block">Domains</h2>
<span class="display-none" id="no-portfolio-js-flag"></span>
{% else %}
<!-- Embedding the portfolio value in a data attribute -->
<span id="portfolio-js-value" data-portfolio="{{ portfolio.id }}"></span>
{% 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">
<form class="usa-search usa-search--small" method="POST" role="search">
{% csrf_token %}
@ -37,7 +37,7 @@
</form>
</section>
</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">
<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">
@ -47,7 +47,7 @@
</section>
</div>
</div>
{% if portfolio %}
{% if has_domains_portfolio_permission %}
<div class="display-flex flex-align-center">
<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">
@ -150,7 +150,7 @@
<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="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>
{% endif %}
<th

View file

@ -35,7 +35,7 @@
<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 %}
{% endwith %}
@ -53,12 +53,12 @@
{% endwith %}
</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 %}
{% endwith %}
{% 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 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 %}
@ -68,7 +68,7 @@
{% 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" %}
{% input_with_errors form.phone %}
{% 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 %}
{% if not field.widget_type == "checkbox" %}
{% if show_edit_button %}
{% include "includes/label_with_edit_button.html" with bold_label=True %}
{% if toggleable_label %}
{% include "includes/toggleable_label.html" with bold_label=True %}
{% else %}
{% include "django/forms/label.html" %}
{% endif %}
@ -63,8 +63,8 @@ error messages, if necessary.
<div class="display-flex flex-align-center">
{% endif %}
{% if show_readonly %}
{% include "includes/readonly_input.html" %}
{% if toggleable_input %}
{% include "includes/toggleable_input.html" %}
{% endif %}
{# 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 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 %}
{% if has_edit_org_portfolio_permission %}
<p>
<strong class="text-primary display-block margin-bottom-1">Federal agency</strong>
{{ portfolio }}
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>
{% 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>
{% 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
attr_* - adds or replaces any single html attribute for the input
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:
```
@ -92,7 +92,7 @@ def input_with_errors(context, field=None): # noqa: C901
elif key == "add_group_class":
group_classes.append(value)
elif key == "show_edit_button":
elif key == "toggleable_input":
# Hide the primary input field.
# Used such that we can toggle it with JS
if "display-none" not in classes:

View file

@ -13,6 +13,7 @@ from registrar.admin import (
ContactAdmin,
DomainInformationAdmin,
MyHostAdmin,
PortfolioInvitationAdmin,
UserDomainRoleAdmin,
VerifiedByStaffAdmin,
FsmModelResource,
@ -38,6 +39,7 @@ from registrar.models import (
UserGroup,
TransitionDomain,
)
from registrar.models.portfolio_invitation import PortfolioInvitation
from registrar.models.senior_official import SeniorOfficial
from registrar.models.user_domain_role import UserDomainRole
from registrar.models.verified_by_staff import VerifiedByStaff
@ -177,6 +179,77 @@ class TestDomainInvitationAdmin(TestCase):
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):
"""Tests for the HostAdmin class as super user

View file

@ -20,7 +20,9 @@ from registrar.models import (
import boto3_mocking
from registrar.models.portfolio import Portfolio
from registrar.models.portfolio_invitation import PortfolioInvitation
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.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}
class TestInvitations(TestCase):
"""Test the retrieval of invitations."""
class TestDomainInvitations(TestCase):
"""Test the retrieval of domain invitations."""
@less_console_noise_decorator
def setUp(self):
@ -1116,6 +1118,65 @@ class TestInvitations(TestCase):
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):
"""Test actions that occur on user login,
test class method that controls how users get validated."""
@ -1135,6 +1196,7 @@ class TestUser(TestCase):
DomainRequest.objects.all().delete()
DraftDomain.objects.all().delete()
TransitionDomain.objects.all().delete()
Portfolio.objects.all().delete()
User.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")
self.user.portfolio_additional_permissions = [User.UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS]
self.user.portfolio_additional_permissions = [UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS]
self.user.save()
self.user.refresh_from_db()
@ -1317,7 +1379,7 @@ class TestUser(TestCase):
self.assertTrue(user_can_view_all_domains)
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.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 registrar.models.portfolio import Portfolio
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
from .common import MockEppLib, MockSESClient, create_user # type: ignore
from django_webtest import WebTest # type: ignore
import boto3_mocking # type: ignore
@ -326,7 +327,7 @@ class TestDomainDetail(TestDomainOverview):
phone="8003111234",
title="test title",
portfolio=portfolio,
portfolio_roles=[User.UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
portfolio_roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
)
domain, _ = Domain.objects.get_or_create(name="bogusdomain.gov")
DomainInformation.objects.get_or_create(creator=user, domain=domain, portfolio=portfolio)

View file

@ -10,6 +10,7 @@ from registrar.models import (
UserDomainRole,
User,
)
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from .common import create_test_user
from waffle.testutils import override_flag
@ -55,7 +56,7 @@ class TestPortfolio(WebTest):
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"""
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.refresh_from_db()
with override_flag("organization_feature", active=True):
@ -67,10 +68,10 @@ class TestPortfolio(WebTest):
@less_console_noise_decorator
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.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.refresh_from_db()
with override_flag("organization_feature", active=True):
@ -83,12 +84,13 @@ class TestPortfolio(WebTest):
@less_console_noise_decorator
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.user.portfolio = self.portfolio
self.user.portfolio_additional_permissions = [
User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
User.UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
]
self.user.save()
self.user.refresh_from_db()
@ -143,15 +145,60 @@ class TestPortfolio(WebTest):
# Assert the response is a 403 Forbidden
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
def test_navigation_links_hidden_when_user_not_have_permission(self):
"""Test that navigation links are hidden when user does not have portfolio permissions"""
self.app.set_user(self.user.username)
self.user.portfolio = self.portfolio
self.user.portfolio_additional_permissions = [
User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
User.UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
User.UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
]
self.user.save()
self.user.refresh_from_db()
@ -166,9 +213,9 @@ class TestPortfolio(WebTest):
self.assertContains(portfolio_page, reverse("domains"))
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
self.user.portfolio_additional_permissions = [User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO]
self.user.portfolio_additional_permissions = [UserPortfolioPermissionChoices.VIEW_PORTFOLIO]
self.user.save()
self.user.refresh_from_db()
@ -180,17 +227,48 @@ class TestPortfolio(WebTest):
self.assertNotContains(portfolio_page, reverse("domains"))
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):
"""Can load portfolio's org name page."""
with override_flag("organization_feature", active=True):
self.app.set_user(self.user.username)
self.user.portfolio = self.portfolio
self.user.portfolio_additional_permissions = [
User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
User.UserPortfolioPermissionChoices.EDIT_PORTFOLIO,
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
UserPortfolioPermissionChoices.EDIT_PORTFOLIO,
]
self.user.save()
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."
)
@less_console_noise_decorator
def test_domain_org_name_address_content(self):
"""Org name and address information appears on the page."""
with override_flag("organization_feature", active=True):
self.app.set_user(self.user.username)
self.user.portfolio = self.portfolio
self.user.portfolio_additional_permissions = [
User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
User.UserPortfolioPermissionChoices.EDIT_PORTFOLIO,
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
UserPortfolioPermissionChoices.EDIT_PORTFOLIO,
]
self.user.save()
self.user.refresh_from_db()
@ -215,17 +294,19 @@ class TestPortfolioOrganization(TestPortfolio):
self.portfolio.organization_name = "Hotel California"
self.portfolio.save()
page = self.app.get(reverse("organization"))
# Once in the sidenav, once in the main nav, once in the form
self.assertContains(page, "Hotel California", count=3)
# Once in the sidenav, once in the main nav
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):
"""Submitting changes works on the org name address page."""
with override_flag("organization_feature", active=True):
self.app.set_user(self.user.username)
self.user.portfolio = self.portfolio
self.user.portfolio_additional_permissions = [
User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
User.UserPortfolioPermissionChoices.EDIT_PORTFOLIO,
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
UserPortfolioPermissionChoices.EDIT_PORTFOLIO,
]
self.user.save()
self.user.refresh_from_db()

View file

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