diff --git a/.github/workflows/deploy-branch-to-sandbox.yaml b/.github/workflows/deploy-manual.yaml
similarity index 79%
rename from .github/workflows/deploy-branch-to-sandbox.yaml
rename to .github/workflows/deploy-manual.yaml
index 652aec207..e0bbee436 100644
--- a/.github/workflows/deploy-branch-to-sandbox.yaml
+++ b/.github/workflows/deploy-manual.yaml
@@ -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/)**.'
- })
-
diff --git a/.gitignore b/.gitignore
index ddd75475d..f2d82f599 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,8 +7,10 @@ docs/research/data/**
public/
credentials*
+src/certs/
*.pem
*.crt
+*.cer
*.bk
diff --git a/docs/developer/README.md b/docs/developer/README.md
index f63f01938..358df649c 100644
--- a/docs/developer/README.md
+++ b/docs/developer/README.md
@@ -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
\ No newline at end of file
+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.
diff --git a/docs/operations/README.md b/docs/operations/README.md
index 9aaee4c86..cc73d82cb 100644
--- a/docs/operations/README.md
+++ b/docs/operations/README.md
@@ -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 ` script in the `/src` directory to build the assets and deploy to your sandbox. Similarly, you could run `build.sh ` 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
diff --git a/ops/scripts/create_dev_sandbox.sh b/ops/scripts/create_dev_sandbox.sh
index 676fcf7ae..6cbad9c4f 100755
--- a/ops/scripts/create_dev_sandbox.sh
+++ b/ops/scripts/create_dev_sandbox.sh
@@ -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
diff --git a/ops/scripts/destroy_dev_sandbox.sh b/ops/scripts/destroy_dev_sandbox.sh
index 9e233b2f1..c8a00937f 100755
--- a/ops/scripts/destroy_dev_sandbox.sh
+++ b/ops/scripts/destroy_dev_sandbox.sh
@@ -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
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index 43be92ec2..c87556a7a 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -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)
diff --git a/src/registrar/assets/sass/_theme/_forms.scss b/src/registrar/assets/sass/_theme/_forms.scss
index c025bdb29..0aedfcdba 100644
--- a/src/registrar/assets/sass/_theme/_forms.scss
+++ b/src/registrar/assets/sass/_theme/_forms.scss
@@ -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);
+}
diff --git a/src/registrar/context_processors.py b/src/registrar/context_processors.py
index 861a4e701..ee5f8aee1 100644
--- a/src/registrar/context_processors.py
+++ b/src/registrar/context_processors.py
@@ -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
diff --git a/src/registrar/migrations/0115_portfolioinvitation.py b/src/registrar/migrations/0115_portfolioinvitation.py
new file mode 100644
index 000000000..82a171f10
--- /dev/null
+++ b/src/registrar/migrations/0115_portfolioinvitation.py
@@ -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")],
+ },
+ ),
+ ]
diff --git a/src/registrar/models/__init__.py b/src/registrar/models/__init__.py
index a68633aff..1e0aad0b1 100644
--- a/src/registrar/models/__init__.py
+++ b/src/registrar/models/__init__.py
@@ -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)
diff --git a/src/registrar/models/portfolio_invitation.py b/src/registrar/models/portfolio_invitation.py
new file mode 100644
index 000000000..2ad780429
--- /dev/null
+++ b/src/registrar/models/portfolio_invitation.py
@@ -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()
diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py
index b1c9473db..bd2af40b7 100644
--- a/src/registrar/models/user.py
+++ b/src/registrar/models/user.py
@@ -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")
diff --git a/src/registrar/models/utility/portfolio_helper.py b/src/registrar/models/utility/portfolio_helper.py
new file mode 100644
index 000000000..70977f312
--- /dev/null
+++ b/src/registrar/models/utility/portfolio_helper.py
@@ -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"
diff --git a/src/registrar/templates/admin/model_descriptions.html b/src/registrar/templates/admin/model_descriptions.html
index c075e03a5..4b61e21bd 100644
--- a/src/registrar/templates/admin/model_descriptions.html
+++ b/src/registrar/templates/admin/model_descriptions.html
@@ -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 %}
This table does not have a description yet.
{% endif %}
diff --git a/src/registrar/templates/django/admin/includes/descriptions/portfolio_invitation_description.html b/src/registrar/templates/django/admin/includes/descriptions/portfolio_invitation_description.html
new file mode 100644
index 000000000..51515bcb2
--- /dev/null
+++ b/src/registrar/templates/django/admin/includes/descriptions/portfolio_invitation_description.html
@@ -0,0 +1,11 @@
+
+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.
+
+
+
+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.
+
diff --git a/src/registrar/templates/domain_request_dotgov_domain.html b/src/registrar/templates/domain_request_dotgov_domain.html
index 60220e014..5864cad29 100644
--- a/src/registrar/templates/domain_request_dotgov_domain.html
+++ b/src/registrar/templates/domain_request_dotgov_domain.html
@@ -6,8 +6,7 @@
- Be available
- Relate to your organization’s name, location, and/or services
- - Be clear to the general public. Your domain name must not be easily confused
- with other organizations.
+ - Be unlikely to mislead or confuse the general public (even if your domain is only intended for a specific audience)
diff --git a/src/registrar/templates/includes/domain_requests_table.html b/src/registrar/templates/includes/domain_requests_table.html
index ad91699ef..30c206741 100644
--- a/src/registrar/templates/includes/domain_requests_table.html
+++ b/src/registrar/templates/includes/domain_requests_table.html
@@ -2,7 +2,7 @@
- {% if portfolio is None %}
+ {% if not has_domain_requests_portfolio_permission %}
Domain requests
diff --git a/src/registrar/templates/includes/domains_table.html b/src/registrar/templates/includes/domains_table.html
index 348ab21d7..64eddec41 100644
--- a/src/registrar/templates/includes/domains_table.html
+++ b/src/registrar/templates/includes/domains_table.html
@@ -1,15 +1,15 @@
{% load static %}
-