diff --git a/docs/operations/data_migration.md b/docs/operations/data_migration.md index cd748b22d..5914eb179 100644 --- a/docs/operations/data_migration.md +++ b/docs/operations/data_migration.md @@ -816,3 +816,25 @@ Example: `cf ssh getgov-za` | | Parameter | Description | |:-:|:-------------------------- |:-----------------------------------------------------------------------------------| | 1 | **federal_cio_csv_path** | Specifies where the federal CIO csv is | + +## Populate Domain Request Dates +This section outlines how to run the populate_domain_request_dates script + +### Running on sandboxes + +#### Step 1: Login to CloudFoundry +```cf login -a api.fr.cloud.gov --sso``` + +#### Step 2: SSH into your environment +```cf ssh getgov-{space}``` + +Example: `cf ssh getgov-za` + +#### Step 3: Create a shell instance +```/tmp/lifecycle/shell``` + +#### Step 4: Running the script +```./manage.py populate_domain_request_dates``` + +### Running locally +```docker-compose exec app ./manage.py populate_domain_request_dates``` diff --git a/src/registrar/admin.py b/src/registrar/admin.py index cda31ebf9..6b42cf96b 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -133,14 +133,6 @@ class MyUserAdminForm(UserChangeForm): widgets = { "groups": NoAutocompleteFilteredSelectMultiple("groups", False), "user_permissions": NoAutocompleteFilteredSelectMultiple("user_permissions", False), - "portfolio_roles": FilteredSelectMultipleArrayWidget( - "portfolio_roles", is_stacked=False, choices=UserPortfolioRoleChoices.choices - ), - "portfolio_additional_permissions": FilteredSelectMultipleArrayWidget( - "portfolio_additional_permissions", - is_stacked=False, - choices=UserPortfolioPermissionChoices.choices, - ), } def __init__(self, *args, **kwargs): @@ -172,6 +164,22 @@ class MyUserAdminForm(UserChangeForm): ) +class UserPortfolioPermissionsForm(forms.ModelForm): + class Meta: + model = models.UserPortfolioPermission + fields = "__all__" + widgets = { + "roles": FilteredSelectMultipleArrayWidget( + "roles", is_stacked=False, choices=UserPortfolioRoleChoices.choices + ), + "additional_permissions": FilteredSelectMultipleArrayWidget( + "additional_permissions", + is_stacked=False, + choices=UserPortfolioPermissionChoices.choices, + ), + } + + class PortfolioInvitationAdminForm(UserChangeForm): """This form utilizes the custom widget for its class's ManyToMany UIs.""" @@ -745,19 +753,12 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin): "is_superuser", "groups", "user_permissions", - "portfolio", - "portfolio_roles", - "portfolio_additional_permissions", ) }, ), ("Important dates", {"fields": ("last_login", "date_joined")}), ) - autocomplete_fields = [ - "portfolio", - ] - readonly_fields = ("verification_type",) analyst_fieldsets = ( @@ -777,9 +778,6 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin): "fields": ( "is_active", "groups", - "portfolio", - "portfolio_roles", - "portfolio_additional_permissions", ) }, ), @@ -834,9 +832,6 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin): "Important dates", "last_login", "date_joined", - "portfolio", - "portfolio_roles", - "portfolio_additional_permissions", ] # TODO: delete after we merge organization feature @@ -1245,6 +1240,26 @@ class UserDomainRoleResource(resources.ModelResource): model = models.UserDomainRole +class UserPortfolioPermissionAdmin(ListHeaderAdmin): + form = UserPortfolioPermissionsForm + + class Meta: + """Contains meta information about this class""" + + model = models.UserPortfolioPermission + fields = "__all__" + + _meta = Meta() + + # Columns + list_display = [ + "user", + "portfolio", + ] + + autocomplete_fields = ["user", "portfolio"] + + class UserDomainRoleAdmin(ListHeaderAdmin, ImportExportModelAdmin): """Custom user domain role admin class.""" @@ -1684,7 +1699,9 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): # Columns list_display = [ "requested_domain", - "submission_date", + "first_submitted_date", + "last_submitted_date", + "last_status_update", "status", "generic_org_type", "federal_type", @@ -1887,7 +1904,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): # Table ordering # NOTE: This impacts the select2 dropdowns (combobox) # Currentl, there's only one for requests on DomainInfo - ordering = ["-submission_date", "requested_domain__name"] + ordering = ["-last_submitted_date", "requested_domain__name"] change_form_template = "django/admin/domain_request_change_form.html" @@ -3009,6 +3026,7 @@ class PortfolioAdmin(ListHeaderAdmin): "domain_requests", "suborganizations", "portfolio_type", + "creator", ] def federal_type(self, obj: models.Portfolio): @@ -3299,6 +3317,7 @@ admin.site.register(models.Portfolio, PortfolioAdmin) admin.site.register(models.DomainGroup, DomainGroupAdmin) admin.site.register(models.Suborganization, SuborganizationAdmin) admin.site.register(models.SeniorOfficial, SeniorOfficialAdmin) +admin.site.register(models.UserPortfolioPermission, UserPortfolioPermissionAdmin) admin.site.register(models.AllowedEmail, AllowedEmailAdmin) # Register our custom waffle implementations diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index c05ef090c..24f020b75 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -908,10 +908,28 @@ function initializeWidgetOnList(list, parentId) { return; } + // Determine if any changes are necessary to the display of portfolio type or federal type + // based on changes to the Federal Agency + let federalPortfolioApi = document.getElementById("federal_and_portfolio_types_from_agency_json_url").value; + fetch(`${federalPortfolioApi}?organization_type=${organizationType.value}&agency_name=${selectedText}`) + .then(response => { + const statusCode = response.status; + return response.json().then(data => ({ statusCode, data })); + }) + .then(({ statusCode, data }) => { + if (data.error) { + console.error("Error in AJAX call: " + data.error); + return; + } + updateReadOnly(data.federal_type, '.field-federal_type'); + updateReadOnly(data.portfolio_type, '.field-portfolio_type'); + }) + .catch(error => console.error("Error fetching federal and portfolio types: ", error)); + // Hide the contactList initially. // If we can update the contact information, it'll be shown again. hideElement(contactList.parentElement); - + let seniorOfficialApi = document.getElementById("senior_official_from_agency_json_url").value; fetch(`${seniorOfficialApi}?agency_name=${selectedText}`) .then(response => { @@ -954,6 +972,7 @@ function initializeWidgetOnList(list, parentId) { } }) .catch(error => console.error("Error fetching senior official: ", error)); + } function handleStateTerritoryChange(stateTerritory, urbanizationField) { @@ -965,6 +984,26 @@ function initializeWidgetOnList(list, parentId) { } } + /** + * Utility that selects a div from the DOM using selectorString, + * and updates a div within that div which has class of 'readonly' + * so that the text of the div is updated to updateText + * @param {*} updateText + * @param {*} selectorString + */ + function updateReadOnly(updateText, selectorString) { + // find the div by selectorString + const selectedDiv = document.querySelector(selectorString); + if (selectedDiv) { + // find the nested div with class 'readonly' inside the selectorString div + const readonlyDiv = selectedDiv.querySelector('.readonly'); + if (readonlyDiv) { + // Update the text content of the readonly div + readonlyDiv.textContent = updateText !== null ? updateText : '-'; + } + } + } + function updateContactInfo(data) { if (!contactList) return; diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index f3b41eb51..70659b009 100644 --- a/src/registrar/assets/js/get-gov.js +++ b/src/registrar/assets/js/get-gov.js @@ -1599,7 +1599,7 @@ document.addEventListener('DOMContentLoaded', function() { const domainName = request.requested_domain ? request.requested_domain : `New domain request
(${utcDateString(request.created_at)})`; const actionUrl = request.action_url; const actionLabel = request.action_label; - const submissionDate = request.submission_date ? new Date(request.submission_date).toLocaleDateString('en-US', options) : `Not submitted`; + const submissionDate = request.last_submitted_date ? new Date(request.last_submitted_date).toLocaleDateString('en-US', options) : `Not submitted`; // Even if the request is not deletable, we may need this empty string for the td if the deletable column is displayed let modalTrigger = ''; @@ -1699,7 +1699,7 @@ document.addEventListener('DOMContentLoaded', function() { ${domainName} - + ${submissionDate} diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py index 413449896..19fa99809 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -24,7 +24,10 @@ from registrar.views.report_views import ( from registrar.views.domain_request import Step from registrar.views.domain_requests_json import get_domain_requests_json -from registrar.views.utility.api_views import get_senior_official_from_federal_agency_json +from registrar.views.utility.api_views import ( + get_senior_official_from_federal_agency_json, + get_federal_and_portfolio_types_from_federal_agency_json, +) from registrar.views.domains_json import get_domains_json from registrar.views.utility import always_404 from api.views import available, get_current_federal, get_current_full @@ -139,6 +142,11 @@ urlpatterns = [ get_senior_official_from_federal_agency_json, name="get-senior-official-from-federal-agency-json", ), + path( + "admin/api/get-federal-and-portfolio-types-from-federal-agency-json/", + get_federal_and_portfolio_types_from_federal_agency_json, + name="get-federal-and-portfolio-types-from-federal-agency-json", + ), path("admin/", admin.site.urls), path( "reports/export_data_type_user/", diff --git a/src/registrar/context_processors.py b/src/registrar/context_processors.py index ee5f8aee1..ea04dca80 100644 --- a/src/registrar/context_processors.py +++ b/src/registrar/context_processors.py @@ -61,27 +61,37 @@ 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 or not flag_is_active(request, "organization_feature"): + portfolio = request.session.get("portfolio") + if portfolio: return { - "has_base_portfolio_permission": False, - "has_domains_portfolio_permission": False, - "has_domain_requests_portfolio_permission": False, - "portfolio": None, - "has_organization_feature_flag": False, + "has_base_portfolio_permission": request.user.has_base_portfolio_permission(portfolio), + "has_domains_portfolio_permission": request.user.has_domains_portfolio_permission(portfolio), + "has_domain_requests_portfolio_permission": request.user.has_domain_requests_portfolio_permission( + portfolio + ), + "has_view_suborganization": request.user.has_view_suborganization(portfolio), + "has_edit_suborganization": request.user.has_edit_suborganization(portfolio), + "portfolio": portfolio, + "has_organization_feature_flag": True, } return { - "has_base_portfolio_permission": request.user.has_base_portfolio_permission(), - "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": True, + "has_base_portfolio_permission": False, + "has_domains_portfolio_permission": False, + "has_domain_requests_portfolio_permission": False, + "has_view_suborganization": False, + "has_edit_suborganization": False, + "portfolio": None, + "has_organization_feature_flag": False, } + except AttributeError: # Handles cases where request.user might not exist return { "has_base_portfolio_permission": False, "has_domains_portfolio_permission": False, "has_domain_requests_portfolio_permission": False, + "has_view_suborganization": False, + "has_edit_suborganization": False, "portfolio": None, "has_organization_feature_flag": False, } diff --git a/src/registrar/fixtures_domain_requests.py b/src/registrar/fixtures_domain_requests.py index 50f611474..a5ec3fc74 100644 --- a/src/registrar/fixtures_domain_requests.py +++ b/src/registrar/fixtures_domain_requests.py @@ -95,7 +95,7 @@ class DomainRequestFixture: # TODO for a future ticket: Allow for more than just "federal" here da.generic_org_type = app["generic_org_type"] if "generic_org_type" in app else "federal" - da.submission_date = fake.date() + da.last_submitted_date = fake.date() da.federal_type = ( app["federal_type"] if "federal_type" in app diff --git a/src/registrar/management/commands/populate_domain_request_dates.py b/src/registrar/management/commands/populate_domain_request_dates.py new file mode 100644 index 000000000..d975a035d --- /dev/null +++ b/src/registrar/management/commands/populate_domain_request_dates.py @@ -0,0 +1,45 @@ +import logging +from django.core.management import BaseCommand +from registrar.management.commands.utility.terminal_helper import PopulateScriptTemplate, TerminalColors +from registrar.models import DomainRequest +from auditlog.models import LogEntry + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand, PopulateScriptTemplate): + help = "Loops through each domain request object and populates the last_status_update and first_submitted_date" + + def handle(self, **kwargs): + """Loops through each DomainRequest object and populates + its last_status_update and first_submitted_date values""" + self.mass_update_records(DomainRequest, None, ["last_status_update", "first_submitted_date"]) + + def update_record(self, record: DomainRequest): + """Defines how we update the first_submitted_date and last_status_update fields""" + + # Retrieve and order audit log entries by timestamp in descending order + audit_log_entries = LogEntry.objects.filter(object_pk=record.pk).order_by("-timestamp") + # Loop through logs in descending order to find most recent status change + for log_entry in audit_log_entries: + if "status" in log_entry.changes_dict: + record.last_status_update = log_entry.timestamp.date() + break + + # Loop through logs in ascending order to find first submission + for log_entry in audit_log_entries.reverse(): + status = log_entry.changes_dict.get("status") + if status and status[1] == "submitted": + record.first_submitted_date = log_entry.timestamp.date() + break + + logger.info( + f"""{TerminalColors.OKCYAN}Updating {record} => + first submitted date: {record.first_submitted_date}, + last status update: {record.last_status_update}{TerminalColors.ENDC} + """ + ) + + def should_skip_record(self, record) -> bool: + # make sure the record had some kind of history + return not LogEntry.objects.filter(object_pk=record.pk).exists() diff --git a/src/registrar/management/commands/utility/terminal_helper.py b/src/registrar/management/commands/utility/terminal_helper.py index 2c69e1080..b9e11be5d 100644 --- a/src/registrar/management/commands/utility/terminal_helper.py +++ b/src/registrar/management/commands/utility/terminal_helper.py @@ -86,7 +86,7 @@ class PopulateScriptTemplate(ABC): You must define update_record before you can use this function. """ - records = object_class.objects.filter(**filter_conditions) + records = object_class.objects.filter(**filter_conditions) if filter_conditions else object_class.objects.all() readable_class_name = self.get_class_name(object_class) # Code execution will stop here if the user prompts "N" diff --git a/src/registrar/migrations/0119_remove_user_portfolio_and_more.py b/src/registrar/migrations/0119_remove_user_portfolio_and_more.py new file mode 100644 index 000000000..84ed45cd1 --- /dev/null +++ b/src/registrar/migrations/0119_remove_user_portfolio_and_more.py @@ -0,0 +1,97 @@ +# Generated by Django 4.2.10 on 2024-08-19 20:24 + +from django.conf import settings +import django.contrib.postgres.fields +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0118_alter_portfolio_options_alter_portfolio_creator_and_more"), + ] + + operations = [ + migrations.RemoveField( + model_name="user", + name="portfolio", + ), + migrations.RemoveField( + model_name="user", + name="portfolio_additional_permissions", + ), + migrations.RemoveField( + model_name="user", + name="portfolio_roles", + ), + migrations.CreateModel( + name="UserPortfolioPermission", + 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)), + ( + "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, + ), + ), + ( + "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"), + ("view_suborganization", "View suborganization"), + ("edit_suborganization", "Edit suborganization"), + ], + max_length=50, + ), + blank=True, + help_text="Select one or more additional permissions.", + null=True, + size=None, + ), + ), + ( + "portfolio", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="portfolio_users", + to="registrar.portfolio", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="portfolio_permissions", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "unique_together": {("user", "portfolio")}, + }, + ), + ] diff --git a/src/registrar/migrations/0120_add_domainrequest_submission_dates.py b/src/registrar/migrations/0120_add_domainrequest_submission_dates.py new file mode 100644 index 000000000..df409cf39 --- /dev/null +++ b/src/registrar/migrations/0120_add_domainrequest_submission_dates.py @@ -0,0 +1,47 @@ +# Generated by Django 4.2.10 on 2024-08-16 15:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0119_remove_user_portfolio_and_more"), + ] + + operations = [ + migrations.RenameField( + model_name="domainrequest", + old_name="submission_date", + new_name="last_submitted_date", + ), + migrations.AlterField( + model_name="domainrequest", + name="last_submitted_date", + field=models.DateField( + blank=True, default=None, help_text="Date last submitted", null=True, verbose_name="last submitted on" + ), + ), + migrations.AddField( + model_name="domainrequest", + name="first_submitted_date", + field=models.DateField( + blank=True, + default=None, + help_text="Date initially submitted", + null=True, + verbose_name="first submitted on", + ), + ), + migrations.AddField( + model_name="domainrequest", + name="last_status_update", + field=models.DateField( + blank=True, + default=None, + help_text="Date of the last status update", + null=True, + verbose_name="last updated on", + ), + ), + ] diff --git a/src/registrar/models/__init__.py b/src/registrar/models/__init__.py index f525e690e..a1738cc76 100644 --- a/src/registrar/models/__init__.py +++ b/src/registrar/models/__init__.py @@ -21,6 +21,7 @@ from .portfolio import Portfolio from .domain_group import DomainGroup from .suborganization import Suborganization from .senior_official import SeniorOfficial +from .user_portfolio_permission import UserPortfolioPermission from .allowed_email import AllowedEmail @@ -47,6 +48,7 @@ __all__ = [ "DomainGroup", "Suborganization", "SeniorOfficial", + "UserPortfolioPermission", "AllowedEmail", ] @@ -72,4 +74,5 @@ auditlog.register(Portfolio) auditlog.register(DomainGroup) auditlog.register(Suborganization) auditlog.register(SeniorOfficial) +auditlog.register(UserPortfolioPermission) auditlog.register(AllowedEmail) diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py index 02457a539..b80e063cd 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -563,15 +563,32 @@ class DomainRequest(TimeStampedModel): help_text="Acknowledged .gov acceptable use policy", ) - # submission date records when domain request is submitted - submission_date = models.DateField( + # Records when the domain request was first submitted + first_submitted_date = models.DateField( null=True, blank=True, default=None, - verbose_name="submitted at", - help_text="Date submitted", + verbose_name="first submitted on", + help_text="Date initially submitted", ) + # Records when domain request was last submitted + last_submitted_date = models.DateField( + null=True, + blank=True, + default=None, + verbose_name="last submitted on", + help_text="Date last submitted", + ) + + # Records when domain request status was last updated by an admin or analyst + last_status_update = models.DateField( + null=True, + blank=True, + default=None, + verbose_name="last updated on", + help_text="Date of the last status update", + ) notes = models.TextField( null=True, blank=True, @@ -627,6 +644,9 @@ class DomainRequest(TimeStampedModel): self.sync_organization_type() self.sync_yes_no_form_fields() + if self._cached_status != self.status: + self.last_status_update = timezone.now().date() + super().save(*args, **kwargs) # Handle the action needed email. @@ -809,8 +829,12 @@ class DomainRequest(TimeStampedModel): if not DraftDomain.string_could_be_domain(self.requested_domain.name): raise ValueError("Requested domain is not a valid domain name.") - # Update submission_date to today - self.submission_date = timezone.now().date() + # if the domain has not been submitted before this must be the first time + if not self.first_submitted_date: + self.first_submitted_date = timezone.now().date() + + # Update last_submitted_date to today + self.last_submitted_date = timezone.now().date() self.save() # Limit email notifications to transitions from Started and Withdrawn diff --git a/src/registrar/models/portfolio.py b/src/registrar/models/portfolio.py index 0f9904c31..fadcf8cac 100644 --- a/src/registrar/models/portfolio.py +++ b/src/registrar/models/portfolio.py @@ -131,9 +131,13 @@ class Portfolio(TimeStampedModel): Returns a combination of organization_type / federal_type, seperated by ' - '. If no federal_type is found, we just return the org type. """ - org_type_label = self.OrganizationChoices.get_org_label(self.organization_type) - agency_type_label = BranchChoices.get_branch_label(self.federal_type) - if self.organization_type == self.OrganizationChoices.FEDERAL and agency_type_label: + return self.get_portfolio_type(self.organization_type, self.federal_type) + + @classmethod + def get_portfolio_type(cls, organization_type, federal_type): + org_type_label = cls.OrganizationChoices.get_org_label(organization_type) + agency_type_label = BranchChoices.get_branch_label(federal_type) + if organization_type == cls.OrganizationChoices.FEDERAL and agency_type_label: return " - ".join([org_type_label, agency_type_label]) else: return org_type_label @@ -141,7 +145,11 @@ class Portfolio(TimeStampedModel): @property def federal_type(self): """Returns the federal_type value on the underlying federal_agency field""" - return self.federal_agency.federal_type if self.federal_agency else None + return self.get_federal_type(self.federal_agency) + + @classmethod + def get_federal_type(cls, federal_agency): + return federal_agency.federal_type if federal_agency else None # == Getters for domains == # def get_domains(self): diff --git a/src/registrar/models/portfolio_invitation.py b/src/registrar/models/portfolio_invitation.py index 2ad780429..46d7bf124 100644 --- a/src/registrar/models/portfolio_invitation.py +++ b/src/registrar/models/portfolio_invitation.py @@ -1,13 +1,11 @@ """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 registrar.models.user_portfolio_permission import UserPortfolioPermission from .utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices # type: ignore - from .utility.time_stamped_model import TimeStampedModel from django.contrib.postgres.fields import ArrayField @@ -87,9 +85,11 @@ class PortfolioInvitation(TimeStampedModel): 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 + user_portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create( + portfolio=self.portfolio, user=user + ) if self.portfolio_roles and len(self.portfolio_roles) > 0: - user.portfolio_roles = self.portfolio_roles + user_portfolio_permission.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() + user_portfolio_permission.additional_permissions = self.portfolio_additional_permissions + user_portfolio_permission.save() diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py index 81d3b9b61..a7ea1e14a 100644 --- a/src/registrar/models/user.py +++ b/src/registrar/models/user.py @@ -3,10 +3,9 @@ import logging from django.contrib.auth.models import AbstractUser from django.db import models from django.db.models import Q -from django.forms import ValidationError +from django.http import HttpRequest -from registrar.models.domain_information import DomainInformation -from registrar.models.user_domain_role import UserDomainRole +from registrar.models import DomainInformation, UserDomainRole from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from .domain_invitation import DomainInvitation @@ -15,7 +14,6 @@ from .transition_domain import TransitionDomain from .verified_by_staff import VerifiedByStaff from .domain import Domain from .domain_request import DomainRequest -from django.contrib.postgres.fields import ArrayField from waffle.decorators import flag_is_active from phonenumber_field.modelfields import PhoneNumberField # type: ignore @@ -112,34 +110,6 @@ class User(AbstractUser): related_name="users", ) - portfolio = models.ForeignKey( - "registrar.Portfolio", - null=True, - blank=True, - related_name="user", - on_delete=models.SET_NULL, - ) - - 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.", - ) - phone = PhoneNumberField( null=True, blank=True, @@ -230,68 +200,50 @@ class User(AbstractUser): def has_contact_info(self): return bool(self.title or self.email or self.phone) - def clean(self): - """Extends clean method to perform additional validation, which can raise errors in django admin.""" - super().clean() - - if self.portfolio is None and self._get_portfolio_permissions(): - raise ValidationError("When portfolio roles or additional permissions are assigned, portfolio is required.") - - if self.portfolio is not None and not self._get_portfolio_permissions(): - raise ValidationError("When portfolio is assigned, portfolio roles or additional permissions are required.") - - def _get_portfolio_permissions(self): - """ - Retrieve the permissions for the user's portfolio roles. - """ - portfolio_permissions = set() # Use a set to avoid duplicate permissions - - if self.portfolio_roles: - for role in self.portfolio_roles: - if role in self.PORTFOLIO_ROLE_PERMISSIONS: - portfolio_permissions.update(self.PORTFOLIO_ROLE_PERMISSIONS[role]) - if self.portfolio_additional_permissions: - portfolio_permissions.update(self.portfolio_additional_permissions) - return list(portfolio_permissions) # Convert back to list if necessary - - def _has_portfolio_permission(self, portfolio_permission): + def _has_portfolio_permission(self, portfolio, portfolio_permission): """The views should only call this function when testing for perms and not rely on roles.""" - if not self.portfolio: + if not portfolio: return False - portfolio_permissions = self._get_portfolio_permissions() + user_portfolio_perms = self.portfolio_permissions.filter(portfolio=portfolio, user=self).first() + if not user_portfolio_perms: + return False - return portfolio_permission in portfolio_permissions + return portfolio_permission in user_portfolio_perms._get_portfolio_permissions() - # 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(UserPortfolioPermissionChoices.VIEW_PORTFOLIO) + def has_base_portfolio_permission(self, portfolio): + return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_PORTFOLIO) - def has_edit_org_portfolio_permission(self): - return self._has_portfolio_permission(UserPortfolioPermissionChoices.EDIT_PORTFOLIO) + def has_edit_org_portfolio_permission(self, portfolio): + return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.EDIT_PORTFOLIO) - def has_domains_portfolio_permission(self): + def has_domains_portfolio_permission(self, portfolio): return self._has_portfolio_permission( - UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS - ) or self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS) + portfolio, UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS + ) or self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS) - def has_domain_requests_portfolio_permission(self): + def has_domain_requests_portfolio_permission(self, portfolio): return self._has_portfolio_permission( - UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS - ) or self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_CREATED_REQUESTS) + portfolio, UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS + ) or self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_CREATED_REQUESTS) - def has_view_all_domains_permission(self): + def has_view_all_domains_permission(self, portfolio): """Determines if the current user can view all available domains in a given portfolio""" - return self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS) + return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS) # Field specific permission checks - def has_view_suborganization(self): - return self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION) + def has_view_suborganization(self, portfolio): + return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION) - def has_edit_suborganization(self): - return self._has_portfolio_permission(UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION) + def has_edit_suborganization(self, portfolio): + return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION) + + def get_first_portfolio(self): + permission = self.portfolio_permissions.first() + if permission: + return permission.portfolio + return None @classmethod def needs_identity_verification(cls, email, uuid): @@ -406,7 +358,14 @@ class User(AbstractUser): for invitation in PortfolioInvitation.objects.filter( email__iexact=self.email, status=PortfolioInvitation.PortfolioInvitationStatus.INVITED ): - if self.portfolio is None: + # need to create a bogus request and assign user to it, in order to pass request + # to flag_is_active + request = HttpRequest() + request.user = self + only_single_portfolio = ( + not flag_is_active(request, "multiple_portfolios") and self.get_first_portfolio() is None + ) + if only_single_portfolio or flag_is_active(None, "multiple_portfolios"): try: invitation.retrieve() invitation.save() @@ -431,13 +390,17 @@ class User(AbstractUser): self.check_domain_invitations_on_login() self.check_portfolio_invitations_on_login() + # NOTE TO DAVE: I'd simply suggest that we move these functions outside of the user object, + # and move them to some sort of utility file. That way we aren't calling request inside here. def is_org_user(self, request): has_organization_feature_flag = flag_is_active(request, "organization_feature") - return has_organization_feature_flag and self.has_base_portfolio_permission() + portfolio = request.session.get("portfolio") + return has_organization_feature_flag and self.has_base_portfolio_permission(portfolio) def get_user_domain_ids(self, request): """Returns either the domains ids associated with this user on UserDomainRole or Portfolio""" - if self.is_org_user(request) and self.has_view_all_domains_permission(): - return DomainInformation.objects.filter(portfolio=self.portfolio).values_list("domain_id", flat=True) + portfolio = request.session.get("portfolio") + if self.is_org_user(request) and self.has_view_all_domains_permission(portfolio): + return DomainInformation.objects.filter(portfolio=portfolio).values_list("domain_id", flat=True) else: return UserDomainRole.objects.filter(user=self).values_list("domain_id", flat=True) diff --git a/src/registrar/models/user_portfolio_permission.py b/src/registrar/models/user_portfolio_permission.py new file mode 100644 index 000000000..bf1c3e566 --- /dev/null +++ b/src/registrar/models/user_portfolio_permission.py @@ -0,0 +1,119 @@ +from django.db import models +from django.forms import ValidationError +from django.http import HttpRequest +from waffle import flag_is_active +from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices +from .utility.time_stamped_model import TimeStampedModel +from django.contrib.postgres.fields import ArrayField + + +class UserPortfolioPermission(TimeStampedModel): + """This is a linking table that connects a user with a role on a portfolio.""" + + class Meta: + unique_together = ["user", "portfolio"] + + PORTFOLIO_ROLE_PERMISSIONS = { + UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [ + UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS, + UserPortfolioPermissionChoices.VIEW_MEMBER, + UserPortfolioPermissionChoices.EDIT_MEMBER, + UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, + UserPortfolioPermissionChoices.EDIT_REQUESTS, + UserPortfolioPermissionChoices.VIEW_PORTFOLIO, + UserPortfolioPermissionChoices.EDIT_PORTFOLIO, + # Domain: field specific permissions + UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION, + UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION, + ], + UserPortfolioRoleChoices.ORGANIZATION_ADMIN_READ_ONLY: [ + UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS, + UserPortfolioPermissionChoices.VIEW_MEMBER, + UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, + UserPortfolioPermissionChoices.VIEW_PORTFOLIO, + # Domain: field specific permissions + UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION, + ], + UserPortfolioRoleChoices.ORGANIZATION_MEMBER: [ + UserPortfolioPermissionChoices.VIEW_PORTFOLIO, + ], + } + + user = models.ForeignKey( + "registrar.User", + null=False, + # when a user is deleted, permissions are too + on_delete=models.CASCADE, + related_name="portfolio_permissions", + ) + + portfolio = models.ForeignKey( + "registrar.Portfolio", + null=False, + # when a portfolio is deleted, permissions are too + on_delete=models.CASCADE, + related_name="portfolio_users", + ) + + roles = ArrayField( + models.CharField( + max_length=50, + choices=UserPortfolioRoleChoices.choices, + ), + null=True, + blank=True, + help_text="Select one or more roles.", + ) + + additional_permissions = ArrayField( + models.CharField( + max_length=50, + choices=UserPortfolioPermissionChoices.choices, + ), + null=True, + blank=True, + help_text="Select one or more additional permissions.", + ) + + def __str__(self): + return f"User '{self.user}' on Portfolio '{self.portfolio}' " f"" if self.roles else "" + + def _get_portfolio_permissions(self): + """ + Retrieve the permissions for the user's portfolio roles. + """ + # Use a set to avoid duplicate permissions + portfolio_permissions = set() + + if self.roles: + for role in self.roles: + portfolio_permissions.update(self.PORTFOLIO_ROLE_PERMISSIONS.get(role, [])) + + if self.additional_permissions: + portfolio_permissions.update(self.additional_permissions) + + return list(portfolio_permissions) + + def clean(self): + """Extends clean method to perform additional validation, which can raise errors in django admin.""" + super().clean() + + # Check if a user is set without accessing the related object. + has_user = bool(self.user_id) + if self.pk is None and has_user: + # Have to create a bogus request to set the user and pass to flag_is_active + request = HttpRequest() + request.user = self.user + existing_permissions = UserPortfolioPermission.objects.filter(user=self.user) + if not flag_is_active(request, "multiple_portfolios") and existing_permissions.exists(): + raise ValidationError( + "Only one portfolio permission is allowed per user when multiple portfolios are disabled." + ) + + # Check if portfolio is set without accessing the related object. + has_portfolio = bool(self.portfolio_id) + if not has_portfolio and self._get_portfolio_permissions(): + raise ValidationError("When portfolio roles or additional permissions are assigned, portfolio is required.") + + if has_portfolio and not self._get_portfolio_permissions(): + raise ValidationError("When portfolio is assigned, portfolio roles or additional permissions are required.") diff --git a/src/registrar/registrar_middleware.py b/src/registrar/registrar_middleware.py index 3bcb1dc23..4b590db1e 100644 --- a/src/registrar/registrar_middleware.py +++ b/src/registrar/registrar_middleware.py @@ -125,8 +125,9 @@ class CheckUserProfileMiddleware: class CheckPortfolioMiddleware: """ - Checks if the current user has a portfolio - If they do, redirect them to the portfolio homepage when they navigate to home. + this middleware should serve two purposes: + 1 - set the portfolio in session if appropriate # views will need the session portfolio + 2 - if path is home and session portfolio is set, redirect based on permissions of user """ def __init__(self, get_response): @@ -140,15 +141,24 @@ class CheckPortfolioMiddleware: def process_view(self, request, view_func, view_args, view_kwargs): current_path = request.path - if current_path == self.home and request.user.is_authenticated and request.user.is_org_user(request): + if not request.user.is_authenticated: + return None - if request.user.has_base_portfolio_permission(): - portfolio = request.user.portfolio + # set the portfolio in the session if it is not set + if "portfolio" not in request.session or request.session["portfolio"] is None: + # if multiple portfolios are allowed for this user + if flag_is_active(request, "multiple_portfolios"): + # NOTE: we will want to change later to have a workflow for selecting + # portfolio and another for switching portfolio; for now, select first + request.session["portfolio"] = request.user.get_first_portfolio() + elif flag_is_active(request, "organization_feature"): + request.session["portfolio"] = request.user.get_first_portfolio() + else: + request.session["portfolio"] = None - # Add the portfolio to the request object - request.portfolio = portfolio - - if request.user.has_domains_portfolio_permission(): + if request.session["portfolio"] is not None and current_path == self.home: + if request.user.is_org_user(request): + if request.user.has_domains_portfolio_permission(request.session["portfolio"]): portfolio_redirect = reverse("domains") else: portfolio_redirect = reverse("no-portfolio-domains") diff --git a/src/registrar/templates/django/admin/portfolio_change_form.html b/src/registrar/templates/django/admin/portfolio_change_form.html index 9d59aae42..4eb941340 100644 --- a/src/registrar/templates/django/admin/portfolio_change_form.html +++ b/src/registrar/templates/django/admin/portfolio_change_form.html @@ -5,6 +5,8 @@ {% comment %} Stores the json endpoint in a url for easier access {% endcomment %} {% url 'get-senior-official-from-federal-agency-json' as url %} + {% url 'get-federal-and-portfolio-types-from-federal-agency-json' as url %} + {{ block.super }} {% endblock content %} diff --git a/src/registrar/templates/domain_detail.html b/src/registrar/templates/domain_detail.html index 19f196e40..d7bc277b3 100644 --- a/src/registrar/templates/domain_detail.html +++ b/src/registrar/templates/domain_detail.html @@ -72,9 +72,9 @@ {% include "includes/summary_item.html" with title='DNSSEC' value='Not Enabled' edit_link=url editable=is_editable %} {% endif %} - {% if portfolio and has_domains_portfolio_permission and request.user.has_view_suborganization %} + {% if portfolio and has_domains_portfolio_permission and has_view_suborganization %} {% url 'domain-suborganization' pk=domain.id as url %} - {% include "includes/summary_item.html" with title='Suborganization' value=domain.domain_info.sub_organization edit_link=url editable=is_editable|and:request.user.has_edit_suborganization %} + {% include "includes/summary_item.html" with title='Suborganization' value=domain.domain_info.sub_organization edit_link=url editable=is_editable|and:has_edit_suborganization %} {% else %} {% url 'domain-org-name-address' pk=domain.id as url %} {% include "includes/summary_item.html" with title='Organization name and mailing address' value=domain.domain_info address='true' edit_link=url editable=is_editable %} diff --git a/src/registrar/templates/domain_sidebar.html b/src/registrar/templates/domain_sidebar.html index f2e9f190c..24f92bf16 100644 --- a/src/registrar/templates/domain_sidebar.html +++ b/src/registrar/templates/domain_sidebar.html @@ -61,7 +61,7 @@ {% if portfolio %} {% comment %} Only show this menu option if the user has the perms to do so {% endcomment %} - {% if has_domains_portfolio_permission and request.user.has_view_suborganization %} + {% if has_domains_portfolio_permission and has_view_suborganization %} {% with url_name="domain-suborganization" %} {% include "includes/domain_sidenav_item.html" with item_text="Suborganization" %} {% endwith %} diff --git a/src/registrar/templates/domain_suborganization.html b/src/registrar/templates/domain_suborganization.html index 42bb766a3..ad96f1d65 100644 --- a/src/registrar/templates/domain_suborganization.html +++ b/src/registrar/templates/domain_suborganization.html @@ -15,7 +15,7 @@ If you believe there is an error please contact help@get.gov.

- {% if has_domains_portfolio_permission and request.user.has_edit_suborganization %} + {% if has_domains_portfolio_permission and has_edit_suborganization %}
{% csrf_token %} {% input_with_errors form.sub_organization %} diff --git a/src/registrar/templates/emails/action_needed_reasons/already_has_domains.txt b/src/registrar/templates/emails/action_needed_reasons/already_has_domains.txt index b1b3b0a1c..2e3012c91 100644 --- a/src/registrar/templates/emails/action_needed_reasons/already_has_domains.txt +++ b/src/registrar/templates/emails/action_needed_reasons/already_has_domains.txt @@ -4,7 +4,7 @@ Hi, {{ recipient.first_name }}. We've identified an action that you’ll need to complete before we continue reviewing your .gov domain request. DOMAIN REQUESTED: {{ domain_request.requested_domain.name }} -REQUEST RECEIVED ON: {{ domain_request.submission_date|date }} +REQUEST RECEIVED ON: {{ domain_request.last_submitted_date|date }} STATUS: Action needed ---------------------------------------------------------------- diff --git a/src/registrar/templates/emails/action_needed_reasons/bad_name.txt b/src/registrar/templates/emails/action_needed_reasons/bad_name.txt index 7d088aa4e..9481a1e63 100644 --- a/src/registrar/templates/emails/action_needed_reasons/bad_name.txt +++ b/src/registrar/templates/emails/action_needed_reasons/bad_name.txt @@ -4,7 +4,7 @@ Hi, {{ recipient.first_name }}. We've identified an action that you’ll need to complete before we continue reviewing your .gov domain request. DOMAIN REQUESTED: {{ domain_request.requested_domain.name }} -REQUEST RECEIVED ON: {{ domain_request.submission_date|date }} +REQUEST RECEIVED ON: {{ domain_request.last_submitted_date|date }} STATUS: Action needed ---------------------------------------------------------------- diff --git a/src/registrar/templates/emails/action_needed_reasons/eligibility_unclear.txt b/src/registrar/templates/emails/action_needed_reasons/eligibility_unclear.txt index d3a986183..705805998 100644 --- a/src/registrar/templates/emails/action_needed_reasons/eligibility_unclear.txt +++ b/src/registrar/templates/emails/action_needed_reasons/eligibility_unclear.txt @@ -4,7 +4,7 @@ Hi, {{ recipient.first_name }}. We've identified an action that you’ll need to complete before we continue reviewing your .gov domain request. DOMAIN REQUESTED: {{ domain_request.requested_domain.name }} -REQUEST RECEIVED ON: {{ domain_request.submission_date|date }} +REQUEST RECEIVED ON: {{ domain_request.last_submitted_date|date }} STATUS: Action needed ---------------------------------------------------------------- diff --git a/src/registrar/templates/emails/action_needed_reasons/questionable_senior_official.txt b/src/registrar/templates/emails/action_needed_reasons/questionable_senior_official.txt index e20e4cb60..5967d7089 100644 --- a/src/registrar/templates/emails/action_needed_reasons/questionable_senior_official.txt +++ b/src/registrar/templates/emails/action_needed_reasons/questionable_senior_official.txt @@ -4,7 +4,7 @@ Hi, {{ recipient.first_name }}. We've identified an action that you’ll need to complete before we continue reviewing your .gov domain request. DOMAIN REQUESTED: {{ domain_request.requested_domain.name }} -REQUEST RECEIVED ON: {{ domain_request.submission_date|date }} +REQUEST RECEIVED ON: {{ domain_request.last_submitted_date|date }} STATUS: Action needed ---------------------------------------------------------------- diff --git a/src/registrar/templates/emails/domain_request_withdrawn.txt b/src/registrar/templates/emails/domain_request_withdrawn.txt index 6efa92d64..0db00feea 100644 --- a/src/registrar/templates/emails/domain_request_withdrawn.txt +++ b/src/registrar/templates/emails/domain_request_withdrawn.txt @@ -4,7 +4,7 @@ Hi, {{ recipient.first_name }}. Your .gov domain request has been withdrawn and will not be reviewed by our team. DOMAIN REQUESTED: {{ domain_request.requested_domain.name }} -REQUEST RECEIVED ON: {{ domain_request.submission_date|date }} +REQUEST RECEIVED ON: {{ domain_request.last_submitted_date|date }} STATUS: Withdrawn ---------------------------------------------------------------- diff --git a/src/registrar/templates/emails/status_change_approved.txt b/src/registrar/templates/emails/status_change_approved.txt index 70f813599..66f8f8b6c 100644 --- a/src/registrar/templates/emails/status_change_approved.txt +++ b/src/registrar/templates/emails/status_change_approved.txt @@ -4,7 +4,7 @@ Hi, {{ recipient.first_name }}. Congratulations! Your .gov domain request has been approved. DOMAIN REQUESTED: {{ domain_request.requested_domain.name }} -REQUEST RECEIVED ON: {{ domain_request.submission_date|date }} +REQUEST RECEIVED ON: {{ domain_request.last_submitted_date|date }} STATUS: Approved You can manage your approved domain on the .gov registrar . diff --git a/src/registrar/templates/emails/status_change_rejected.txt b/src/registrar/templates/emails/status_change_rejected.txt index 2fcbb1d83..4e5250162 100644 --- a/src/registrar/templates/emails/status_change_rejected.txt +++ b/src/registrar/templates/emails/status_change_rejected.txt @@ -4,7 +4,7 @@ Hi, {{ recipient.first_name }}. Your .gov domain request has been rejected. DOMAIN REQUESTED: {{ domain_request.requested_domain.name }} -REQUEST RECEIVED ON: {{ domain_request.submission_date|date }} +REQUEST RECEIVED ON: {{ domain_request.last_submitted_date|date }} STATUS: Rejected ---------------------------------------------------------------- diff --git a/src/registrar/templates/emails/submission_confirmation.txt b/src/registrar/templates/emails/submission_confirmation.txt index 740e6f393..c8ff4c7eb 100644 --- a/src/registrar/templates/emails/submission_confirmation.txt +++ b/src/registrar/templates/emails/submission_confirmation.txt @@ -4,7 +4,7 @@ Hi, {{ recipient.first_name }}. We received your .gov domain request. DOMAIN REQUESTED: {{ domain_request.requested_domain.name }} -REQUEST RECEIVED ON: {{ domain_request.submission_date|date }} +REQUEST RECEIVED ON: {{ domain_request.last_submitted_date|date }} STATUS: Submitted ---------------------------------------------------------------- diff --git a/src/registrar/templates/includes/domain_requests_table.html b/src/registrar/templates/includes/domain_requests_table.html index 487c1cee5..bd909350c 100644 --- a/src/registrar/templates/includes/domain_requests_table.html +++ b/src/registrar/templates/includes/domain_requests_table.html @@ -45,7 +45,7 @@ Domain name - Date submitted + Date submitted Status Action diff --git a/src/registrar/templates/includes/domains_table.html b/src/registrar/templates/includes/domains_table.html index d3d7317f2..48de2d98c 100644 --- a/src/registrar/templates/includes/domains_table.html +++ b/src/registrar/templates/includes/domains_table.html @@ -157,7 +157,7 @@ Domain name Expires Status - {% if portfolio and request.user.has_view_suborganization %} + {% if portfolio and has_view_suborganization %} Suborganization {% endif %}