mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-05-16 17:47:02 +02:00
Merge branch 'main' into litterbox/2399-fill-senior-official
This commit is contained in:
commit
b61d92ce3b
31 changed files with 663 additions and 130 deletions
|
@ -74,20 +74,4 @@ jobs:
|
||||||
cf_org: cisa-dotgov
|
cf_org: cisa-dotgov
|
||||||
cf_space: ${{ env.ENVIRONMENT }}
|
cf_space: ${{ env.ENVIRONMENT }}
|
||||||
cf_manifest: ops/manifests/manifest-${{ env.ENVIRONMENT }}.yaml
|
cf_manifest: ops/manifests/manifest-${{ env.ENVIRONMENT }}.yaml
|
||||||
comment:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [deploy]
|
|
||||||
steps:
|
|
||||||
- uses: actions/github-script@v6
|
|
||||||
env:
|
|
||||||
ENVIRONMENT: ${{ github.event.inputs.environment }}
|
|
||||||
with:
|
|
||||||
github-token: ${{secrets.GITHUB_TOKEN}}
|
|
||||||
script: |
|
|
||||||
github.rest.issues.createComment({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
body: '🥳 Successfully deployed to developer sandbox **[${{ env.ENVIRONMENT }}](https://getgov-${{ env.ENVIRONMENT }}.app.cloud.gov/)**.'
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -7,8 +7,10 @@ docs/research/data/**
|
||||||
public/
|
public/
|
||||||
credentials*
|
credentials*
|
||||||
|
|
||||||
|
src/certs/
|
||||||
*.pem
|
*.pem
|
||||||
*.crt
|
*.crt
|
||||||
|
*.cer
|
||||||
|
|
||||||
*.bk
|
*.bk
|
||||||
|
|
||||||
|
|
|
@ -357,4 +357,8 @@ Then, copy the variables under the section labled `s3`.
|
||||||
1. On the app, navigate to `\admin`.
|
1. On the app, navigate to `\admin`.
|
||||||
2. Under models, click `Waffle flags`.
|
2. Under models, click `Waffle flags`.
|
||||||
3. Click the `disable_email_sending` record. This should exist by default, if not - create one with that name.
|
3. Click the `disable_email_sending` record. This should exist by default, if not - create one with that name.
|
||||||
4. (Important) Set the field `everyone` to `Yes`. This field overrides all other settings
|
4. (Important) Set the field `everyone` to `Yes`. This field overrides all other settings
|
||||||
|
|
||||||
|
## Request Flow FSM Diagram
|
||||||
|
|
||||||
|
The [.gov Domain Request & Domain Status Digram](https://miro.com/app/board/uXjVMuqbLOk=/?moveToWidget=3458764594819017396&cot=14) visualizes the domain request flow and resulting domain objects.
|
||||||
|
|
|
@ -45,6 +45,8 @@ When deploying to your personal sandbox, you should make sure all of the USWDS a
|
||||||
|
|
||||||
For ease of use, you can run the `deploy.sh <sandbox name>` script in the `/src` directory to build the assets and deploy to your sandbox. Similarly, you could run `build.sh <sandbox name>` script to just compile and collect the assets without deploying.
|
For ease of use, you can run the `deploy.sh <sandbox name>` script in the `/src` directory to build the assets and deploy to your sandbox. Similarly, you could run `build.sh <sandbox name>` script to just compile and collect the assets without deploying.
|
||||||
|
|
||||||
|
You may also manually deploy to a sandbox using our [manual deploy workflow](https://github.com/cisagov/manage.get.gov/actions/workflows/deploy-manual.yaml) on GitHub Actions. Select Run workflow and enter the branch you want to deploy to your sandbox of choice.
|
||||||
|
|
||||||
Your sandbox space should've been setup as part of the onboarding process. If this was not the case, please have an admin follow the instructions below.
|
Your sandbox space should've been setup as part of the onboarding process. If this was not the case, please have an admin follow the instructions below.
|
||||||
|
|
||||||
## Creating a sandbox or new environment
|
## Creating a sandbox or new environment
|
||||||
|
|
|
@ -116,6 +116,10 @@ sed -i '' '/ - development/ {a\
|
||||||
- '"$1"'
|
- '"$1"'
|
||||||
}' .github/workflows/migrate.yaml
|
}' .github/workflows/migrate.yaml
|
||||||
|
|
||||||
|
sed -i '' '/ - backup/ {a\
|
||||||
|
- '"$1"'
|
||||||
|
}' .github/workflows/deploy-manual.yaml
|
||||||
|
|
||||||
sed -i '' '/${{startsWith(github.head_ref, / {a\
|
sed -i '' '/${{startsWith(github.head_ref, / {a\
|
||||||
|| startsWith(github.head_ref, '"'$1'"')
|
|| startsWith(github.head_ref, '"'$1'"')
|
||||||
}' .github/workflows/deploy-sandbox.yaml
|
}' .github/workflows/deploy-sandbox.yaml
|
||||||
|
|
|
@ -49,6 +49,7 @@ rm ops/manifests/manifest-$1.yaml
|
||||||
sed -i '' "/getgov-$1.app.cloud.gov/d" src/registrar/config/settings.py
|
sed -i '' "/getgov-$1.app.cloud.gov/d" src/registrar/config/settings.py
|
||||||
sed -i '' "/- $1/d" .github/workflows/reset-db.yaml
|
sed -i '' "/- $1/d" .github/workflows/reset-db.yaml
|
||||||
sed -i '' "/- $1/d" .github/workflows/migrate.yaml
|
sed -i '' "/- $1/d" .github/workflows/migrate.yaml
|
||||||
|
sed -i '' "/- $1/d" .github/workflows/deploy-manual.yaml
|
||||||
|
|
||||||
echo "Cleaning up services, applications, and the Cloud.gov space for $1..."
|
echo "Cleaning up services, applications, and the Cloud.gov space for $1..."
|
||||||
cf delete getgov-$1
|
cf delete getgov-$1
|
||||||
|
|
|
@ -11,6 +11,7 @@ from django.shortcuts import redirect
|
||||||
from django_fsm import get_available_FIELD_transitions, FSMField
|
from django_fsm import get_available_FIELD_transitions, FSMField
|
||||||
from registrar.models.domain_group import DomainGroup
|
from registrar.models.domain_group import DomainGroup
|
||||||
from registrar.models.suborganization import Suborganization
|
from registrar.models.suborganization import Suborganization
|
||||||
|
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||||
from waffle.decorators import flag_is_active
|
from waffle.decorators import flag_is_active
|
||||||
from django.contrib import admin, messages
|
from django.contrib import admin, messages
|
||||||
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
|
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
|
||||||
|
@ -131,12 +132,12 @@ class MyUserAdminForm(UserChangeForm):
|
||||||
"groups": NoAutocompleteFilteredSelectMultiple("groups", False),
|
"groups": NoAutocompleteFilteredSelectMultiple("groups", False),
|
||||||
"user_permissions": NoAutocompleteFilteredSelectMultiple("user_permissions", False),
|
"user_permissions": NoAutocompleteFilteredSelectMultiple("user_permissions", False),
|
||||||
"portfolio_roles": FilteredSelectMultipleArrayWidget(
|
"portfolio_roles": FilteredSelectMultipleArrayWidget(
|
||||||
"portfolio_roles", is_stacked=False, choices=User.UserPortfolioRoleChoices.choices
|
"portfolio_roles", is_stacked=False, choices=UserPortfolioRoleChoices.choices
|
||||||
),
|
),
|
||||||
"portfolio_additional_permissions": FilteredSelectMultipleArrayWidget(
|
"portfolio_additional_permissions": FilteredSelectMultipleArrayWidget(
|
||||||
"portfolio_additional_permissions",
|
"portfolio_additional_permissions",
|
||||||
is_stacked=False,
|
is_stacked=False,
|
||||||
choices=User.UserPortfolioPermissionChoices.choices,
|
choices=UserPortfolioPermissionChoices.choices,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -169,6 +170,24 @@ class MyUserAdminForm(UserChangeForm):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PortfolioInvitationAdminForm(UserChangeForm):
|
||||||
|
"""This form utilizes the custom widget for its class's ManyToMany UIs."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.PortfolioInvitation
|
||||||
|
fields = "__all__"
|
||||||
|
widgets = {
|
||||||
|
"portfolio_roles": FilteredSelectMultipleArrayWidget(
|
||||||
|
"portfolio_roles", is_stacked=False, choices=UserPortfolioRoleChoices.choices
|
||||||
|
),
|
||||||
|
"portfolio_additional_permissions": FilteredSelectMultipleArrayWidget(
|
||||||
|
"portfolio_additional_permissions",
|
||||||
|
is_stacked=False,
|
||||||
|
choices=UserPortfolioPermissionChoices.choices,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class DomainInformationAdminForm(forms.ModelForm):
|
class DomainInformationAdminForm(forms.ModelForm):
|
||||||
"""This form utilizes the custom widget for its class's ManyToMany UIs."""
|
"""This form utilizes the custom widget for its class's ManyToMany UIs."""
|
||||||
|
|
||||||
|
@ -1299,6 +1318,56 @@ class DomainInvitationAdmin(ListHeaderAdmin):
|
||||||
return super().changelist_view(request, extra_context=extra_context)
|
return super().changelist_view(request, extra_context=extra_context)
|
||||||
|
|
||||||
|
|
||||||
|
class PortfolioInvitationAdmin(ListHeaderAdmin):
|
||||||
|
"""Custom portfolio invitation admin class."""
|
||||||
|
|
||||||
|
form = PortfolioInvitationAdminForm
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.PortfolioInvitation
|
||||||
|
fields = "__all__"
|
||||||
|
|
||||||
|
_meta = Meta()
|
||||||
|
|
||||||
|
# Columns
|
||||||
|
list_display = [
|
||||||
|
"email",
|
||||||
|
"portfolio",
|
||||||
|
"portfolio_roles",
|
||||||
|
"portfolio_additional_permissions",
|
||||||
|
"status",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Search
|
||||||
|
search_fields = [
|
||||||
|
"email",
|
||||||
|
"portfolio__name",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Filters
|
||||||
|
list_filter = ("status",)
|
||||||
|
|
||||||
|
search_help_text = "Search by email or portfolio."
|
||||||
|
|
||||||
|
# Mark the FSM field 'status' as readonly
|
||||||
|
# to allow admin users to create Domain Invitations
|
||||||
|
# without triggering the FSM Transition Not Allowed
|
||||||
|
# error.
|
||||||
|
readonly_fields = ["status"]
|
||||||
|
|
||||||
|
autocomplete_fields = ["portfolio"]
|
||||||
|
|
||||||
|
change_form_template = "django/admin/email_clipboard_change_form.html"
|
||||||
|
|
||||||
|
# Select portfolio invitations to change -> Portfolio invitations
|
||||||
|
def changelist_view(self, request, extra_context=None):
|
||||||
|
if extra_context is None:
|
||||||
|
extra_context = {}
|
||||||
|
extra_context["tabtitle"] = "Portfolio invitations"
|
||||||
|
# Get the filtered values
|
||||||
|
return super().changelist_view(request, extra_context=extra_context)
|
||||||
|
|
||||||
|
|
||||||
class DomainInformationResource(resources.ModelResource):
|
class DomainInformationResource(resources.ModelResource):
|
||||||
"""defines how each field in the referenced model should be mapped to the corresponding fields in the
|
"""defines how each field in the referenced model should be mapped to the corresponding fields in the
|
||||||
import/export file"""
|
import/export file"""
|
||||||
|
@ -2900,6 +2969,7 @@ admin.site.register(models.PublicContact, PublicContactAdmin)
|
||||||
admin.site.register(models.DomainRequest, DomainRequestAdmin)
|
admin.site.register(models.DomainRequest, DomainRequestAdmin)
|
||||||
admin.site.register(models.TransitionDomain, TransitionDomainAdmin)
|
admin.site.register(models.TransitionDomain, TransitionDomainAdmin)
|
||||||
admin.site.register(models.VerifiedByStaff, VerifiedByStaffAdmin)
|
admin.site.register(models.VerifiedByStaff, VerifiedByStaffAdmin)
|
||||||
|
admin.site.register(models.PortfolioInvitation, PortfolioInvitationAdmin)
|
||||||
admin.site.register(models.Portfolio, PortfolioAdmin)
|
admin.site.register(models.Portfolio, PortfolioAdmin)
|
||||||
admin.site.register(models.DomainGroup, DomainGroupAdmin)
|
admin.site.register(models.DomainGroup, DomainGroupAdmin)
|
||||||
admin.site.register(models.Suborganization, SuborganizationAdmin)
|
admin.site.register(models.Suborganization, SuborganizationAdmin)
|
||||||
|
|
|
@ -82,3 +82,13 @@ legend.float-left-tablet + button.float-right-tablet {
|
||||||
color: var(--close-button-hover-bg);
|
color: var(--close-button-hover-bg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.read-only-label {
|
||||||
|
font-size: size('body', 'sm');
|
||||||
|
color: color('primary');
|
||||||
|
margin-bottom: units(0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-only-value {
|
||||||
|
margin-top: units(0);
|
||||||
|
}
|
||||||
|
|
|
@ -61,7 +61,7 @@ def add_has_profile_feature_flag_to_context(request):
|
||||||
def portfolio_permissions(request):
|
def portfolio_permissions(request):
|
||||||
"""Make portfolio permissions for the request user available in global context"""
|
"""Make portfolio permissions for the request user available in global context"""
|
||||||
try:
|
try:
|
||||||
if not request.user or not request.user.is_authenticated:
|
if not request.user or not request.user.is_authenticated or not flag_is_active(request, "organization_feature"):
|
||||||
return {
|
return {
|
||||||
"has_base_portfolio_permission": False,
|
"has_base_portfolio_permission": False,
|
||||||
"has_domains_portfolio_permission": False,
|
"has_domains_portfolio_permission": False,
|
||||||
|
@ -74,7 +74,7 @@ def portfolio_permissions(request):
|
||||||
"has_domains_portfolio_permission": request.user.has_domains_portfolio_permission(),
|
"has_domains_portfolio_permission": request.user.has_domains_portfolio_permission(),
|
||||||
"has_domain_requests_portfolio_permission": request.user.has_domain_requests_portfolio_permission(),
|
"has_domain_requests_portfolio_permission": request.user.has_domain_requests_portfolio_permission(),
|
||||||
"portfolio": request.user.portfolio,
|
"portfolio": request.user.portfolio,
|
||||||
"has_organization_feature_flag": flag_is_active(request, "organization_feature"),
|
"has_organization_feature_flag": True,
|
||||||
}
|
}
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
# Handles cases where request.user might not exist
|
# Handles cases where request.user might not exist
|
||||||
|
|
83
src/registrar/migrations/0115_portfolioinvitation.py
Normal file
83
src/registrar/migrations/0115_portfolioinvitation.py
Normal 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")],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
|
@ -1,4 +1,4 @@
|
||||||
from auditlog.registry import auditlog # type: ignore
|
from auditlog.registry import auditlog
|
||||||
from .contact import Contact
|
from .contact import Contact
|
||||||
from .domain_request import DomainRequest
|
from .domain_request import DomainRequest
|
||||||
from .domain_information import DomainInformation
|
from .domain_information import DomainInformation
|
||||||
|
@ -16,6 +16,7 @@ from .website import Website
|
||||||
from .transition_domain import TransitionDomain
|
from .transition_domain import TransitionDomain
|
||||||
from .verified_by_staff import VerifiedByStaff
|
from .verified_by_staff import VerifiedByStaff
|
||||||
from .waffle_flag import WaffleFlag
|
from .waffle_flag import WaffleFlag
|
||||||
|
from .portfolio_invitation import PortfolioInvitation
|
||||||
from .portfolio import Portfolio
|
from .portfolio import Portfolio
|
||||||
from .domain_group import DomainGroup
|
from .domain_group import DomainGroup
|
||||||
from .suborganization import Suborganization
|
from .suborganization import Suborganization
|
||||||
|
@ -40,6 +41,7 @@ __all__ = [
|
||||||
"TransitionDomain",
|
"TransitionDomain",
|
||||||
"VerifiedByStaff",
|
"VerifiedByStaff",
|
||||||
"WaffleFlag",
|
"WaffleFlag",
|
||||||
|
"PortfolioInvitation",
|
||||||
"Portfolio",
|
"Portfolio",
|
||||||
"DomainGroup",
|
"DomainGroup",
|
||||||
"Suborganization",
|
"Suborganization",
|
||||||
|
@ -63,6 +65,7 @@ auditlog.register(Website)
|
||||||
auditlog.register(TransitionDomain)
|
auditlog.register(TransitionDomain)
|
||||||
auditlog.register(VerifiedByStaff)
|
auditlog.register(VerifiedByStaff)
|
||||||
auditlog.register(WaffleFlag)
|
auditlog.register(WaffleFlag)
|
||||||
|
auditlog.register(PortfolioInvitation)
|
||||||
auditlog.register(Portfolio)
|
auditlog.register(Portfolio)
|
||||||
auditlog.register(DomainGroup)
|
auditlog.register(DomainGroup)
|
||||||
auditlog.register(Suborganization)
|
auditlog.register(Suborganization)
|
||||||
|
|
95
src/registrar/models/portfolio_invitation.py
Normal file
95
src/registrar/models/portfolio_invitation.py
Normal 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()
|
|
@ -5,8 +5,10 @@ from django.db import models
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
|
||||||
from registrar.models.user_domain_role import UserDomainRole
|
from registrar.models.user_domain_role import UserDomainRole
|
||||||
|
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||||
|
|
||||||
from .domain_invitation import DomainInvitation
|
from .domain_invitation import DomainInvitation
|
||||||
|
from .portfolio_invitation import PortfolioInvitation
|
||||||
from .transition_domain import TransitionDomain
|
from .transition_domain import TransitionDomain
|
||||||
from .verified_by_staff import VerifiedByStaff
|
from .verified_by_staff import VerifiedByStaff
|
||||||
from .domain import Domain
|
from .domain import Domain
|
||||||
|
@ -62,31 +64,6 @@ class User(AbstractUser):
|
||||||
# after they login.
|
# after they login.
|
||||||
FIXTURE_USER = "fixture_user", "Created by fixtures"
|
FIXTURE_USER = "fixture_user", "Created by fixtures"
|
||||||
|
|
||||||
class UserPortfolioRoleChoices(models.TextChoices):
|
|
||||||
"""
|
|
||||||
Roles make it easier for admins to look at
|
|
||||||
"""
|
|
||||||
|
|
||||||
ORGANIZATION_ADMIN = "organization_admin", "Admin"
|
|
||||||
ORGANIZATION_ADMIN_READ_ONLY = "organization_admin_read_only", "Admin read only"
|
|
||||||
ORGANIZATION_MEMBER = "organization_member", "Member"
|
|
||||||
|
|
||||||
class UserPortfolioPermissionChoices(models.TextChoices):
|
|
||||||
""" """
|
|
||||||
|
|
||||||
VIEW_ALL_DOMAINS = "view_all_domains", "View all domains and domain reports"
|
|
||||||
VIEW_MANAGED_DOMAINS = "view_managed_domains", "View managed domains"
|
|
||||||
|
|
||||||
VIEW_MEMBER = "view_member", "View members"
|
|
||||||
EDIT_MEMBER = "edit_member", "Create and edit members"
|
|
||||||
|
|
||||||
VIEW_ALL_REQUESTS = "view_all_requests", "View all requests"
|
|
||||||
VIEW_CREATED_REQUESTS = "view_created_requests", "View created requests"
|
|
||||||
EDIT_REQUESTS = "edit_requests", "Create and edit requests"
|
|
||||||
|
|
||||||
VIEW_PORTFOLIO = "view_portfolio", "View organization"
|
|
||||||
EDIT_PORTFOLIO = "edit_portfolio", "Edit organization"
|
|
||||||
|
|
||||||
PORTFOLIO_ROLE_PERMISSIONS = {
|
PORTFOLIO_ROLE_PERMISSIONS = {
|
||||||
UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [
|
UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [
|
||||||
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
|
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
|
||||||
|
@ -270,20 +247,23 @@ class User(AbstractUser):
|
||||||
|
|
||||||
return portfolio_permission in portfolio_permissions
|
return portfolio_permission in portfolio_permissions
|
||||||
|
|
||||||
# the methods below are checks for individual portfolio permissions. they are defined here
|
# the methods below are checks for individual portfolio permissions. They are defined here
|
||||||
# to make them easier to call elsewhere throughout the application
|
# to make them easier to call elsewhere throughout the application
|
||||||
def has_base_portfolio_permission(self):
|
def has_base_portfolio_permission(self):
|
||||||
return self._has_portfolio_permission(User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO)
|
return self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_PORTFOLIO)
|
||||||
|
|
||||||
|
def has_edit_org_portfolio_permission(self):
|
||||||
|
return self._has_portfolio_permission(UserPortfolioPermissionChoices.EDIT_PORTFOLIO)
|
||||||
|
|
||||||
def has_domains_portfolio_permission(self):
|
def has_domains_portfolio_permission(self):
|
||||||
return self._has_portfolio_permission(
|
return self._has_portfolio_permission(
|
||||||
User.UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS
|
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS
|
||||||
) or self._has_portfolio_permission(User.UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS)
|
) or self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS)
|
||||||
|
|
||||||
def has_domain_requests_portfolio_permission(self):
|
def has_domain_requests_portfolio_permission(self):
|
||||||
return self._has_portfolio_permission(
|
return self._has_portfolio_permission(
|
||||||
User.UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS
|
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS
|
||||||
) or self._has_portfolio_permission(User.UserPortfolioPermissionChoices.VIEW_CREATED_REQUESTS)
|
) or self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_CREATED_REQUESTS)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def needs_identity_verification(cls, email, uuid):
|
def needs_identity_verification(cls, email, uuid):
|
||||||
|
@ -392,6 +372,24 @@ class User(AbstractUser):
|
||||||
new_domain_invitation = DomainInvitation(email=transition_domain_email.lower(), domain=new_domain)
|
new_domain_invitation = DomainInvitation(email=transition_domain_email.lower(), domain=new_domain)
|
||||||
new_domain_invitation.save()
|
new_domain_invitation.save()
|
||||||
|
|
||||||
|
def check_portfolio_invitations_on_login(self):
|
||||||
|
"""When a user first arrives on the site, we need to retrieve any portfolio
|
||||||
|
invitations that match their email address."""
|
||||||
|
for invitation in PortfolioInvitation.objects.filter(
|
||||||
|
email__iexact=self.email, status=PortfolioInvitation.PortfolioInvitationStatus.INVITED
|
||||||
|
):
|
||||||
|
if self.portfolio is None:
|
||||||
|
try:
|
||||||
|
invitation.retrieve()
|
||||||
|
invitation.save()
|
||||||
|
except RuntimeError:
|
||||||
|
# retrieving should not fail because of a missing user, but
|
||||||
|
# if it does fail, log the error so a new user can continue
|
||||||
|
# logging in
|
||||||
|
logger.warn("Failed to retrieve invitation %s", invitation, exc_info=True)
|
||||||
|
else:
|
||||||
|
logger.warn("User already has a portfolio, did not retrieve invitation %s", invitation, exc_info=True)
|
||||||
|
|
||||||
def on_each_login(self):
|
def on_each_login(self):
|
||||||
"""Callback each time the user is authenticated.
|
"""Callback each time the user is authenticated.
|
||||||
|
|
||||||
|
@ -403,6 +401,7 @@ class User(AbstractUser):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
self.check_domain_invitations_on_login()
|
self.check_domain_invitations_on_login()
|
||||||
|
self.check_portfolio_invitations_on_login()
|
||||||
|
|
||||||
def is_org_user(self, request):
|
def is_org_user(self, request):
|
||||||
has_organization_feature_flag = flag_is_active(request, "organization_feature")
|
has_organization_feature_flag = flag_is_active(request, "organization_feature")
|
||||||
|
|
28
src/registrar/models/utility/portfolio_helper.py
Normal file
28
src/registrar/models/utility/portfolio_helper.py
Normal 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"
|
|
@ -30,6 +30,8 @@
|
||||||
{% include "django/admin/includes/descriptions/verified_by_staff_description.html" %}
|
{% include "django/admin/includes/descriptions/verified_by_staff_description.html" %}
|
||||||
{% elif opts.model_name == 'website' %}
|
{% elif opts.model_name == 'website' %}
|
||||||
{% include "django/admin/includes/descriptions/website_description.html" %}
|
{% include "django/admin/includes/descriptions/website_description.html" %}
|
||||||
|
{% elif opts.model_name == 'portfolioinvitation' %}
|
||||||
|
{% include "django/admin/includes/descriptions/portfolio_invitation_description.html" %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<p>This table does not have a description yet.</p>
|
<p>This table does not have a description yet.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
@ -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>
|
|
@ -6,8 +6,7 @@
|
||||||
<ul class="usa-list">
|
<ul class="usa-list">
|
||||||
<li>Be available </li>
|
<li>Be available </li>
|
||||||
<li>Relate to your organization’s name, location, and/or services </li>
|
<li>Relate to your organization’s name, location, and/or services </li>
|
||||||
<li>Be clear to the general public. Your domain name must not be easily confused
|
<li>Be unlikely to mislead or confuse the general public (even if your domain is only intended for a specific audience) </li>
|
||||||
with other organizations.</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
<section class="section--outlined domain-requests" id="domain-requests">
|
<section class="section--outlined domain-requests" id="domain-requests">
|
||||||
<div class="grid-row">
|
<div class="grid-row">
|
||||||
{% if portfolio is None %}
|
{% if not has_domain_requests_portfolio_permission %}
|
||||||
<div class="mobile:grid-col-12 desktop:grid-col-6">
|
<div class="mobile:grid-col-12 desktop:grid-col-6">
|
||||||
<h2 id="domain-requests-header" class="flex-6">Domain requests</h2>
|
<h2 id="domain-requests-header" class="flex-6">Domain requests</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,15 +1,15 @@
|
||||||
{% load static %}
|
{% load static %}
|
||||||
|
|
||||||
<section class="section--outlined domains{% if portfolio is not None %} margin-top-0{% endif %}" id="domains">
|
<section class="section--outlined domains{% if not has_domains_portfolio_permission %} margin-top-0{% endif %}" id="domains">
|
||||||
<div class="section--outlined__header margin-bottom-3 {% if portfolio is None %} section--outlined__header--no-portfolio justify-content-space-between{% else %} grid-row{% endif %}">
|
<div class="section--outlined__header margin-bottom-3 {% if not has_domains_portfolio_permission %} section--outlined__header--no-portfolio justify-content-space-between{% else %} grid-row{% endif %}">
|
||||||
{% if portfolio is None %}
|
{% if not has_domains_portfolio_permission %}
|
||||||
<h2 id="domains-header" class="display-inline-block">Domains</h2>
|
<h2 id="domains-header" class="display-inline-block">Domains</h2>
|
||||||
<span class="display-none" id="no-portfolio-js-flag"></span>
|
<span class="display-none" id="no-portfolio-js-flag"></span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<!-- Embedding the portfolio value in a data attribute -->
|
<!-- Embedding the portfolio value in a data attribute -->
|
||||||
<span id="portfolio-js-value" data-portfolio="{{ portfolio.id }}"></span>
|
<span id="portfolio-js-value" data-portfolio="{{ portfolio.id }}"></span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="section--outlined__search {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6{% endif %}">
|
<div class="section--outlined__search {% if has_domains_portfolio_permission %} mobile:grid-col-12 desktop:grid-col-6{% endif %}">
|
||||||
<section aria-label="Domains search component" class="margin-top-2">
|
<section aria-label="Domains search component" class="margin-top-2">
|
||||||
<form class="usa-search usa-search--small" method="POST" role="search">
|
<form class="usa-search usa-search--small" method="POST" role="search">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
@ -37,7 +37,7 @@
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<div class="section--outlined__utility-button mobile-lg:padding-right-105 {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6 desktop:padding-left-3{% endif %}">
|
<div class="section--outlined__utility-button mobile-lg:padding-right-105 {% if has_domains_portfolio_permission %} mobile:grid-col-12 desktop:grid-col-6 desktop:padding-left-3{% endif %}">
|
||||||
<section aria-label="Domains report component" class="mobile-lg:margin-top-205">
|
<section aria-label="Domains report component" class="mobile-lg:margin-top-205">
|
||||||
<a href="{% url 'export_data_type_user' %}" class="usa-button usa-button--unstyled" role="button">
|
<a href="{% url 'export_data_type_user' %}" class="usa-button usa-button--unstyled" role="button">
|
||||||
<svg class="usa-icon usa-icon--big margin-right-neg-4px" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
<svg class="usa-icon usa-icon--big margin-right-neg-4px" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||||
|
@ -47,7 +47,7 @@
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if portfolio %}
|
{% if has_domains_portfolio_permission %}
|
||||||
<div class="display-flex flex-align-center">
|
<div class="display-flex flex-align-center">
|
||||||
<span class="margin-right-2 margin-top-neg-1 usa-prose text-base-darker">Filter by</span>
|
<span class="margin-right-2 margin-top-neg-1 usa-prose text-base-darker">Filter by</span>
|
||||||
<div class="usa-accordion usa-accordion--select margin-right-2">
|
<div class="usa-accordion usa-accordion--select margin-right-2">
|
||||||
|
@ -150,7 +150,7 @@
|
||||||
<th data-sortable="name" scope="col" role="columnheader">Domain name</th>
|
<th data-sortable="name" scope="col" role="columnheader">Domain name</th>
|
||||||
<th data-sortable="expiration_date" scope="col" role="columnheader">Expires</th>
|
<th data-sortable="expiration_date" scope="col" role="columnheader">Expires</th>
|
||||||
<th data-sortable="state_display" scope="col" role="columnheader">Status</th>
|
<th data-sortable="state_display" scope="col" role="columnheader">Status</th>
|
||||||
{% if portfolio %}
|
{% if has_domains_portfolio_permission %}
|
||||||
<th data-sortable="suborganization" scope="col" role="columnheader">Suborganization</th>
|
<th data-sortable="suborganization" scope="col" role="columnheader">Suborganization</th>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<th
|
<th
|
||||||
|
|
|
@ -35,7 +35,7 @@
|
||||||
|
|
||||||
<input type="hidden" name="redirect" value="{{ form.initial.redirect }}">
|
<input type="hidden" name="redirect" value="{{ form.initial.redirect }}">
|
||||||
|
|
||||||
{% with show_edit_button=True show_readonly=True group_classes="usa-form-editable usa-form-editable--no-border padding-top-2" %}
|
{% with toggleable_input=True toggleable_label=True group_classes="usa-form-editable usa-form-editable--no-border padding-top-2" %}
|
||||||
{% input_with_errors form.full_name %}
|
{% input_with_errors form.full_name %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|
||||||
|
@ -53,12 +53,12 @@
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% with show_edit_button=True show_readonly=True group_classes="usa-form-editable padding-top-2" %}
|
{% with toggleable_input=True toggleable_label=True group_classes="usa-form-editable padding-top-2" %}
|
||||||
{% input_with_errors form.title %}
|
{% input_with_errors form.title %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|
||||||
{% public_site_url "help/account-management/#get-help-with-login.gov" as login_help_url %}
|
{% public_site_url "help/account-management/#get-help-with-login.gov" as login_help_url %}
|
||||||
{% with show_readonly=True add_class="display-none" group_classes="usa-form-editable usa-form-editable padding-top-2 bold-usa-label" %}
|
{% with toggleable_input=True add_class="display-none" group_classes="usa-form-editable usa-form-editable padding-top-2 bold-usa-label" %}
|
||||||
{% with link_href=login_help_url %}
|
{% with link_href=login_help_url %}
|
||||||
{% with sublabel_text="We recommend using your work email for your .gov account. If the wrong email is displayed below, you’ll need to update your Login.gov account and log back in. Get help with your Login.gov account." %}
|
{% with sublabel_text="We recommend using your work email for your .gov account. If the wrong email is displayed below, you’ll need to update your Login.gov account and log back in. Get help with your Login.gov account." %}
|
||||||
{% with link_text="Get help with your Login.gov account" target_blank=True do_not_show_max_chars=True %}
|
{% with link_text="Get help with your Login.gov account" target_blank=True do_not_show_max_chars=True %}
|
||||||
|
@ -68,7 +68,7 @@
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|
||||||
{% with show_edit_button=True show_readonly=True group_classes="usa-form-editable padding-top-2" %}
|
{% with toggleable_input=True toggleable_label=True group_classes="usa-form-editable padding-top-2" %}
|
||||||
{% with add_class="usa-input--medium" %}
|
{% with add_class="usa-input--medium" %}
|
||||||
{% input_with_errors form.phone %}
|
{% input_with_errors form.phone %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|
7
src/registrar/templates/includes/input_read_only.html
Normal file
7
src/registrar/templates/includes/input_read_only.html
Normal 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>
|
|
@ -27,8 +27,8 @@ error messages, if necessary.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if not field.widget_type == "checkbox" %}
|
{% if not field.widget_type == "checkbox" %}
|
||||||
{% if show_edit_button %}
|
{% if toggleable_label %}
|
||||||
{% include "includes/label_with_edit_button.html" with bold_label=True %}
|
{% include "includes/toggleable_label.html" with bold_label=True %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% include "django/forms/label.html" %}
|
{% include "django/forms/label.html" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -63,8 +63,8 @@ error messages, if necessary.
|
||||||
<div class="display-flex flex-align-center">
|
<div class="display-flex flex-align-center">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if show_readonly %}
|
{% if toggleable_input %}
|
||||||
{% include "includes/readonly_input.html" %}
|
{% include "includes/toggleable_input.html" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# this is the input field, itself #}
|
{# this is the input field, itself #}
|
||||||
|
|
|
@ -23,42 +23,53 @@
|
||||||
|
|
||||||
<p>The name of your federal agency will be publicly listed as the domain registrant.</p>
|
<p>The name of your federal agency will be publicly listed as the domain registrant.</p>
|
||||||
|
|
||||||
<p>
|
{% if has_edit_org_portfolio_permission %}
|
||||||
The federal agency for your organization can’t be updated here.
|
|
||||||
To suggest an update, email <a href="mailto:help@get.gov" class="usa-link">help@get.gov</a>.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{% include "includes/form_errors.html" with form=form %}
|
|
||||||
|
|
||||||
{% include "includes/required_fields.html" %}
|
|
||||||
|
|
||||||
<form class="usa-form usa-form--large" method="post" novalidate id="form-container">
|
|
||||||
{% csrf_token %}
|
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<strong class="text-primary display-block margin-bottom-1">Federal agency</strong>
|
The federal agency for your organization can’t be updated here.
|
||||||
{{ portfolio }}
|
To suggest an update, email <a href="mailto:help@get.gov" class="usa-link">help@get.gov</a>.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{% input_with_errors form.address_line1 %}
|
{% include "includes/form_errors.html" with form=form %}
|
||||||
|
{% include "includes/required_fields.html" %}
|
||||||
|
<form class="usa-form usa-form--large" method="post" novalidate>
|
||||||
|
{% csrf_token %}
|
||||||
|
<h4 class="read-only-label">Federal agency</h4>
|
||||||
|
<p class="read-only-value">
|
||||||
|
{{ portfolio.federal_agency }}
|
||||||
|
</p>
|
||||||
|
{% input_with_errors form.address_line1 %}
|
||||||
|
{% input_with_errors form.address_line2 %}
|
||||||
|
{% input_with_errors form.city %}
|
||||||
|
{% input_with_errors form.state_territory %}
|
||||||
|
{% with add_class="usa-input--small" %}
|
||||||
|
{% input_with_errors form.zipcode %}
|
||||||
|
{% endwith %}
|
||||||
|
<button type="submit" class="usa-button">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<h4 class="read-only-label">Federal agency</h4>
|
||||||
|
<p class="read-only-value">
|
||||||
|
{{ portfolio.federal_agency }}
|
||||||
|
</p>
|
||||||
|
{% if form.address_line1.value is not None %}
|
||||||
|
{% include "includes/input_read_only.html" with field=form.address_line1 %}
|
||||||
|
{% endif %}
|
||||||
|
{% if form.address_line2.value is not None %}
|
||||||
|
{% include "includes/input_read_only.html" with field=form.address_line2 %}
|
||||||
|
{% endif %}
|
||||||
|
{% if form.city.value is not None %}
|
||||||
|
{% include "includes/input_read_only.html" with field=form.city %}
|
||||||
|
{% endif %}
|
||||||
|
{% if form.state_territory.value is not None %}
|
||||||
|
{% include "includes/input_read_only.html" with field=form.state_territory %}
|
||||||
|
{% endif %}
|
||||||
|
{% if form.zipcode.value is not None %}
|
||||||
|
{% include "includes/input_read_only.html" with field=form.zipcode %}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% input_with_errors form.address_line2 %}
|
|
||||||
|
|
||||||
{% input_with_errors form.city %}
|
|
||||||
|
|
||||||
{% input_with_errors form.state_territory %}
|
|
||||||
|
|
||||||
{% with add_class="usa-input--small" %}
|
|
||||||
{% input_with_errors form.zipcode %}
|
|
||||||
{% endwith %}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="usa-button"
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -26,7 +26,7 @@ def input_with_errors(context, field=None): # noqa: C901
|
||||||
add_group_class: append to input element's surrounding tag's `class` attribute
|
add_group_class: append to input element's surrounding tag's `class` attribute
|
||||||
attr_* - adds or replaces any single html attribute for the input
|
attr_* - adds or replaces any single html attribute for the input
|
||||||
add_error_attr_* - like `attr_*` but only if field.errors is not empty
|
add_error_attr_* - like `attr_*` but only if field.errors is not empty
|
||||||
show_edit_button: shows a simple edit button, and adds display-none to the input field.
|
toggleable_input: shows a simple edit button, and adds display-none to the input field.
|
||||||
|
|
||||||
Example usage:
|
Example usage:
|
||||||
```
|
```
|
||||||
|
@ -92,7 +92,7 @@ def input_with_errors(context, field=None): # noqa: C901
|
||||||
elif key == "add_group_class":
|
elif key == "add_group_class":
|
||||||
group_classes.append(value)
|
group_classes.append(value)
|
||||||
|
|
||||||
elif key == "show_edit_button":
|
elif key == "toggleable_input":
|
||||||
# Hide the primary input field.
|
# Hide the primary input field.
|
||||||
# Used such that we can toggle it with JS
|
# Used such that we can toggle it with JS
|
||||||
if "display-none" not in classes:
|
if "display-none" not in classes:
|
||||||
|
|
|
@ -13,6 +13,7 @@ from registrar.admin import (
|
||||||
ContactAdmin,
|
ContactAdmin,
|
||||||
DomainInformationAdmin,
|
DomainInformationAdmin,
|
||||||
MyHostAdmin,
|
MyHostAdmin,
|
||||||
|
PortfolioInvitationAdmin,
|
||||||
UserDomainRoleAdmin,
|
UserDomainRoleAdmin,
|
||||||
VerifiedByStaffAdmin,
|
VerifiedByStaffAdmin,
|
||||||
FsmModelResource,
|
FsmModelResource,
|
||||||
|
@ -38,6 +39,7 @@ from registrar.models import (
|
||||||
UserGroup,
|
UserGroup,
|
||||||
TransitionDomain,
|
TransitionDomain,
|
||||||
)
|
)
|
||||||
|
from registrar.models.portfolio_invitation import PortfolioInvitation
|
||||||
from registrar.models.senior_official import SeniorOfficial
|
from registrar.models.senior_official import SeniorOfficial
|
||||||
from registrar.models.user_domain_role import UserDomainRole
|
from registrar.models.user_domain_role import UserDomainRole
|
||||||
from registrar.models.verified_by_staff import VerifiedByStaff
|
from registrar.models.verified_by_staff import VerifiedByStaff
|
||||||
|
@ -177,6 +179,77 @@ class TestDomainInvitationAdmin(TestCase):
|
||||||
self.assertContains(response, retrieved_html, count=1)
|
self.assertContains(response, retrieved_html, count=1)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPortfolioInvitationAdmin(TestCase):
|
||||||
|
"""Tests for the PortfolioInvitationAdmin class as super user
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
all tests share superuser; do not change this model in tests
|
||||||
|
tests have available superuser, client, and admin
|
||||||
|
"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.factory = RequestFactory()
|
||||||
|
cls.admin = ListHeaderAdmin(model=PortfolioInvitationAdmin, admin_site=AdminSite())
|
||||||
|
cls.superuser = create_superuser()
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Create a client object"""
|
||||||
|
self.client = Client(HTTP_HOST="localhost:8080")
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
"""Delete all DomainInvitation objects"""
|
||||||
|
PortfolioInvitation.objects.all().delete()
|
||||||
|
Contact.objects.all().delete()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(self):
|
||||||
|
User.objects.all().delete()
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
def test_has_model_description(self):
|
||||||
|
"""Tests if this model has a model description on the table view"""
|
||||||
|
self.client.force_login(self.superuser)
|
||||||
|
response = self.client.get(
|
||||||
|
"/admin/registrar/portfolioinvitation/",
|
||||||
|
follow=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Make sure that the page is loaded correctly
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
# Test for a description snippet
|
||||||
|
self.assertContains(
|
||||||
|
response,
|
||||||
|
"Portfolio invitations contain all individuals who have been invited to become members of an organization.",
|
||||||
|
)
|
||||||
|
self.assertContains(response, "Show more")
|
||||||
|
|
||||||
|
def test_get_filters(self):
|
||||||
|
"""Ensures that our filters are displaying correctly"""
|
||||||
|
with less_console_noise():
|
||||||
|
self.client.force_login(self.superuser)
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
"/admin/registrar/portfolioinvitation/",
|
||||||
|
{},
|
||||||
|
follow=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert that the filters are added
|
||||||
|
self.assertContains(response, "invited", count=4)
|
||||||
|
self.assertContains(response, "Invited", count=2)
|
||||||
|
self.assertContains(response, "retrieved", count=2)
|
||||||
|
self.assertContains(response, "Retrieved", count=2)
|
||||||
|
|
||||||
|
# Check for the HTML context specificially
|
||||||
|
invited_html = '<a href="?status__exact=invited">Invited</a>'
|
||||||
|
retrieved_html = '<a href="?status__exact=retrieved">Retrieved</a>'
|
||||||
|
|
||||||
|
self.assertContains(response, invited_html, count=1)
|
||||||
|
self.assertContains(response, retrieved_html, count=1)
|
||||||
|
|
||||||
|
|
||||||
class TestHostAdmin(TestCase):
|
class TestHostAdmin(TestCase):
|
||||||
"""Tests for the HostAdmin class as super user
|
"""Tests for the HostAdmin class as super user
|
||||||
|
|
||||||
|
|
|
@ -20,7 +20,9 @@ from registrar.models import (
|
||||||
|
|
||||||
import boto3_mocking
|
import boto3_mocking
|
||||||
from registrar.models.portfolio import Portfolio
|
from registrar.models.portfolio import Portfolio
|
||||||
|
from registrar.models.portfolio_invitation import PortfolioInvitation
|
||||||
from registrar.models.transition_domain import TransitionDomain
|
from registrar.models.transition_domain import TransitionDomain
|
||||||
|
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||||
from registrar.models.verified_by_staff import VerifiedByStaff # type: ignore
|
from registrar.models.verified_by_staff import VerifiedByStaff # type: ignore
|
||||||
from registrar.utility.constants import BranchChoices
|
from registrar.utility.constants import BranchChoices
|
||||||
|
|
||||||
|
@ -1071,8 +1073,8 @@ class TestDomainInformation(TestCase):
|
||||||
return {k: v for k, v in dict_obj.items() if k not in bad_fields}
|
return {k: v for k, v in dict_obj.items() if k not in bad_fields}
|
||||||
|
|
||||||
|
|
||||||
class TestInvitations(TestCase):
|
class TestDomainInvitations(TestCase):
|
||||||
"""Test the retrieval of invitations."""
|
"""Test the retrieval of domain invitations."""
|
||||||
|
|
||||||
@less_console_noise_decorator
|
@less_console_noise_decorator
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
@ -1116,6 +1118,65 @@ class TestInvitations(TestCase):
|
||||||
self.assertTrue(UserDomainRole.objects.get(user=self.user, domain=self.domain))
|
self.assertTrue(UserDomainRole.objects.get(user=self.user, domain=self.domain))
|
||||||
|
|
||||||
|
|
||||||
|
class TestPortfolioInvitations(TestCase):
|
||||||
|
"""Test the retrieval of portfolio invitations."""
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
def setUp(self):
|
||||||
|
self.email = "mayor@igorville.gov"
|
||||||
|
self.email2 = "creator@igorville.gov"
|
||||||
|
self.user, _ = User.objects.get_or_create(email=self.email)
|
||||||
|
self.user2, _ = User.objects.get_or_create(email=self.email2, username="creator")
|
||||||
|
self.portfolio, _ = Portfolio.objects.get_or_create(creator=self.user2, organization_name="Hotel California")
|
||||||
|
self.portfolio_role_base = UserPortfolioRoleChoices.ORGANIZATION_MEMBER
|
||||||
|
self.portfolio_role_admin = UserPortfolioRoleChoices.ORGANIZATION_ADMIN
|
||||||
|
self.portfolio_permission_1 = UserPortfolioPermissionChoices.VIEW_CREATED_REQUESTS
|
||||||
|
self.portfolio_permission_2 = UserPortfolioPermissionChoices.EDIT_REQUESTS
|
||||||
|
self.invitation, _ = PortfolioInvitation.objects.get_or_create(
|
||||||
|
email=self.email,
|
||||||
|
portfolio=self.portfolio,
|
||||||
|
portfolio_roles=[self.portfolio_role_base, self.portfolio_role_admin],
|
||||||
|
portfolio_additional_permissions=[self.portfolio_permission_1, self.portfolio_permission_2],
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
super().tearDown()
|
||||||
|
Portfolio.objects.all().delete()
|
||||||
|
PortfolioInvitation.objects.all().delete()
|
||||||
|
User.objects.all().delete()
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
def test_retrieval(self):
|
||||||
|
self.assertFalse(self.user.portfolio)
|
||||||
|
self.invitation.retrieve()
|
||||||
|
self.user.refresh_from_db()
|
||||||
|
self.assertEqual(self.user.portfolio.organization_name, "Hotel California")
|
||||||
|
self.assertEqual(self.user.portfolio_roles, [self.portfolio_role_base, self.portfolio_role_admin])
|
||||||
|
self.assertEqual(
|
||||||
|
self.user.portfolio_additional_permissions, [self.portfolio_permission_1, self.portfolio_permission_2]
|
||||||
|
)
|
||||||
|
self.assertEqual(self.invitation.status, PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED)
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
def test_retrieve_missing_user_error(self):
|
||||||
|
# get rid of matching users
|
||||||
|
User.objects.filter(email=self.email).delete()
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
self.invitation.retrieve()
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
def test_retrieve_user_already_member_error(self):
|
||||||
|
self.assertFalse(self.user.portfolio)
|
||||||
|
portfolio2, _ = Portfolio.objects.get_or_create(creator=self.user2, organization_name="Tokyo Hotel")
|
||||||
|
self.user.portfolio = portfolio2
|
||||||
|
self.assertEqual(self.user.portfolio.organization_name, "Tokyo Hotel")
|
||||||
|
self.user.save()
|
||||||
|
self.user.check_portfolio_invitations_on_login()
|
||||||
|
self.user.refresh_from_db()
|
||||||
|
self.assertEqual(self.user.portfolio.organization_name, "Tokyo Hotel")
|
||||||
|
self.assertEqual(self.invitation.status, PortfolioInvitation.PortfolioInvitationStatus.INVITED)
|
||||||
|
|
||||||
|
|
||||||
class TestUser(TestCase):
|
class TestUser(TestCase):
|
||||||
"""Test actions that occur on user login,
|
"""Test actions that occur on user login,
|
||||||
test class method that controls how users get validated."""
|
test class method that controls how users get validated."""
|
||||||
|
@ -1135,6 +1196,7 @@ class TestUser(TestCase):
|
||||||
DomainRequest.objects.all().delete()
|
DomainRequest.objects.all().delete()
|
||||||
DraftDomain.objects.all().delete()
|
DraftDomain.objects.all().delete()
|
||||||
TransitionDomain.objects.all().delete()
|
TransitionDomain.objects.all().delete()
|
||||||
|
Portfolio.objects.all().delete()
|
||||||
User.objects.all().delete()
|
User.objects.all().delete()
|
||||||
UserDomainRole.objects.all().delete()
|
UserDomainRole.objects.all().delete()
|
||||||
|
|
||||||
|
@ -1297,7 +1359,7 @@ class TestUser(TestCase):
|
||||||
"""
|
"""
|
||||||
portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Hotel California")
|
portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Hotel California")
|
||||||
|
|
||||||
self.user.portfolio_additional_permissions = [User.UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS]
|
self.user.portfolio_additional_permissions = [UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS]
|
||||||
self.user.save()
|
self.user.save()
|
||||||
self.user.refresh_from_db()
|
self.user.refresh_from_db()
|
||||||
|
|
||||||
|
@ -1317,7 +1379,7 @@ class TestUser(TestCase):
|
||||||
self.assertTrue(user_can_view_all_domains)
|
self.assertTrue(user_can_view_all_domains)
|
||||||
self.assertFalse(user_can_view_all_requests)
|
self.assertFalse(user_can_view_all_requests)
|
||||||
|
|
||||||
self.user.portfolio_roles = [User.UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
self.user.portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||||
self.user.save()
|
self.user.save()
|
||||||
self.user.refresh_from_db()
|
self.user.refresh_from_db()
|
||||||
|
|
||||||
|
|
|
@ -7,6 +7,7 @@ from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
from api.tests.common import less_console_noise_decorator
|
from api.tests.common import less_console_noise_decorator
|
||||||
from registrar.models.portfolio import Portfolio
|
from registrar.models.portfolio import Portfolio
|
||||||
|
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
|
||||||
from .common import MockEppLib, MockSESClient, create_user # type: ignore
|
from .common import MockEppLib, MockSESClient, create_user # type: ignore
|
||||||
from django_webtest import WebTest # type: ignore
|
from django_webtest import WebTest # type: ignore
|
||||||
import boto3_mocking # type: ignore
|
import boto3_mocking # type: ignore
|
||||||
|
@ -326,7 +327,7 @@ class TestDomainDetail(TestDomainOverview):
|
||||||
phone="8003111234",
|
phone="8003111234",
|
||||||
title="test title",
|
title="test title",
|
||||||
portfolio=portfolio,
|
portfolio=portfolio,
|
||||||
portfolio_roles=[User.UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
portfolio_roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||||
)
|
)
|
||||||
domain, _ = Domain.objects.get_or_create(name="bogusdomain.gov")
|
domain, _ = Domain.objects.get_or_create(name="bogusdomain.gov")
|
||||||
DomainInformation.objects.get_or_create(creator=user, domain=domain, portfolio=portfolio)
|
DomainInformation.objects.get_or_create(creator=user, domain=domain, portfolio=portfolio)
|
||||||
|
|
|
@ -10,6 +10,7 @@ from registrar.models import (
|
||||||
UserDomainRole,
|
UserDomainRole,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
|
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||||
from .common import create_test_user
|
from .common import create_test_user
|
||||||
from waffle.testutils import override_flag
|
from waffle.testutils import override_flag
|
||||||
|
|
||||||
|
@ -55,7 +56,7 @@ class TestPortfolio(WebTest):
|
||||||
def test_middleware_does_not_redirect_if_no_portfolio(self):
|
def test_middleware_does_not_redirect_if_no_portfolio(self):
|
||||||
"""Test that user with no assigned portfolio is not redirected when attempting to access home"""
|
"""Test that user with no assigned portfolio is not redirected when attempting to access home"""
|
||||||
self.app.set_user(self.user.username)
|
self.app.set_user(self.user.username)
|
||||||
self.user.portfolio_additional_permissions = [User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO]
|
self.user.portfolio_additional_permissions = [UserPortfolioPermissionChoices.VIEW_PORTFOLIO]
|
||||||
self.user.save()
|
self.user.save()
|
||||||
self.user.refresh_from_db()
|
self.user.refresh_from_db()
|
||||||
with override_flag("organization_feature", active=True):
|
with override_flag("organization_feature", active=True):
|
||||||
|
@ -67,10 +68,10 @@ class TestPortfolio(WebTest):
|
||||||
|
|
||||||
@less_console_noise_decorator
|
@less_console_noise_decorator
|
||||||
def test_middleware_redirects_to_portfolio_organization_page(self):
|
def test_middleware_redirects_to_portfolio_organization_page(self):
|
||||||
"""Test that user with VIEW_PORTFOLIO is redirected to portfolio organization page"""
|
"""Test that user with a portfolio and VIEW_PORTFOLIO is redirected to portfolio organization page"""
|
||||||
self.app.set_user(self.user.username)
|
self.app.set_user(self.user.username)
|
||||||
self.user.portfolio = self.portfolio
|
self.user.portfolio = self.portfolio
|
||||||
self.user.portfolio_additional_permissions = [User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO]
|
self.user.portfolio_additional_permissions = [UserPortfolioPermissionChoices.VIEW_PORTFOLIO]
|
||||||
self.user.save()
|
self.user.save()
|
||||||
self.user.refresh_from_db()
|
self.user.refresh_from_db()
|
||||||
with override_flag("organization_feature", active=True):
|
with override_flag("organization_feature", active=True):
|
||||||
|
@ -83,12 +84,13 @@ class TestPortfolio(WebTest):
|
||||||
|
|
||||||
@less_console_noise_decorator
|
@less_console_noise_decorator
|
||||||
def test_middleware_redirects_to_portfolio_domains_page(self):
|
def test_middleware_redirects_to_portfolio_domains_page(self):
|
||||||
"""Test that user with VIEW_PORTFOLIO and VIEW_ALL_DOMAINS is redirected to portfolio domains page"""
|
"""Test that user with a portfolio, VIEW_PORTFOLIO, VIEW_ALL_DOMAINS
|
||||||
|
is redirected to portfolio domains page"""
|
||||||
self.app.set_user(self.user.username)
|
self.app.set_user(self.user.username)
|
||||||
self.user.portfolio = self.portfolio
|
self.user.portfolio = self.portfolio
|
||||||
self.user.portfolio_additional_permissions = [
|
self.user.portfolio_additional_permissions = [
|
||||||
User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
|
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
|
||||||
User.UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
|
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
|
||||||
]
|
]
|
||||||
self.user.save()
|
self.user.save()
|
||||||
self.user.refresh_from_db()
|
self.user.refresh_from_db()
|
||||||
|
@ -143,15 +145,60 @@ class TestPortfolio(WebTest):
|
||||||
# Assert the response is a 403 Forbidden
|
# Assert the response is a 403 Forbidden
|
||||||
self.assertEqual(response.status_code, 403)
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
def test_portfolio_organization_page_read_only(self):
|
||||||
|
"""Test that user with a portfolio can access the portfolio organization page, read only"""
|
||||||
|
self.app.set_user(self.user.username)
|
||||||
|
self.user.portfolio = self.portfolio
|
||||||
|
self.portfolio.city = "Los Angeles"
|
||||||
|
self.user.portfolio_additional_permissions = [UserPortfolioPermissionChoices.VIEW_PORTFOLIO]
|
||||||
|
self.portfolio.save()
|
||||||
|
self.user.save()
|
||||||
|
self.user.refresh_from_db()
|
||||||
|
with override_flag("organization_feature", active=True):
|
||||||
|
response = self.app.get(reverse("organization"))
|
||||||
|
# Assert the response is a 200
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
# The label for Federal agency will always be a h4
|
||||||
|
self.assertContains(response, '<h4 class="read-only-label">Federal agency</h4>')
|
||||||
|
# The read only label for city will be a h4
|
||||||
|
self.assertContains(response, '<h4 class="read-only-label">City</h4>')
|
||||||
|
self.assertNotContains(response, 'for="id_city"')
|
||||||
|
self.assertContains(response, '<p class="read-only-value">Los Angeles</p>')
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
def test_portfolio_organization_page_edit_access(self):
|
||||||
|
"""Test that user with a portfolio can access the portfolio organization page, read only"""
|
||||||
|
self.app.set_user(self.user.username)
|
||||||
|
self.user.portfolio = self.portfolio
|
||||||
|
self.user.portfolio_additional_permissions = [
|
||||||
|
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
|
||||||
|
UserPortfolioPermissionChoices.EDIT_PORTFOLIO,
|
||||||
|
]
|
||||||
|
self.portfolio.city = "Los Angeles"
|
||||||
|
self.portfolio.save()
|
||||||
|
self.user.save()
|
||||||
|
self.user.refresh_from_db()
|
||||||
|
with override_flag("organization_feature", active=True):
|
||||||
|
response = self.app.get(reverse("organization"))
|
||||||
|
# Assert the response is a 200
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
# The label for Federal agency will always be a h4
|
||||||
|
self.assertContains(response, '<h4 class="read-only-label">Federal agency</h4>')
|
||||||
|
# The read only label for city will be a h4
|
||||||
|
self.assertNotContains(response, '<h4 class="read-only-label">City</h4>')
|
||||||
|
self.assertNotContains(response, '<p class="read-only-value">Los Angeles</p>>')
|
||||||
|
self.assertContains(response, 'for="id_city"')
|
||||||
|
|
||||||
@less_console_noise_decorator
|
@less_console_noise_decorator
|
||||||
def test_navigation_links_hidden_when_user_not_have_permission(self):
|
def test_navigation_links_hidden_when_user_not_have_permission(self):
|
||||||
"""Test that navigation links are hidden when user does not have portfolio permissions"""
|
"""Test that navigation links are hidden when user does not have portfolio permissions"""
|
||||||
self.app.set_user(self.user.username)
|
self.app.set_user(self.user.username)
|
||||||
self.user.portfolio = self.portfolio
|
self.user.portfolio = self.portfolio
|
||||||
self.user.portfolio_additional_permissions = [
|
self.user.portfolio_additional_permissions = [
|
||||||
User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
|
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
|
||||||
User.UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
|
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
|
||||||
User.UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
|
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
|
||||||
]
|
]
|
||||||
self.user.save()
|
self.user.save()
|
||||||
self.user.refresh_from_db()
|
self.user.refresh_from_db()
|
||||||
|
@ -166,9 +213,9 @@ class TestPortfolio(WebTest):
|
||||||
self.assertContains(portfolio_page, reverse("domains"))
|
self.assertContains(portfolio_page, reverse("domains"))
|
||||||
self.assertContains(portfolio_page, reverse("domain-requests"))
|
self.assertContains(portfolio_page, reverse("domain-requests"))
|
||||||
|
|
||||||
# reducing portfolio permissions to just VIEW_PORTFOLIO, which should remove domains
|
# removing non-basic portfolio perms, which should remove domains
|
||||||
# and domain requests from nav
|
# and domain requests from nav
|
||||||
self.user.portfolio_additional_permissions = [User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO]
|
self.user.portfolio_additional_permissions = [UserPortfolioPermissionChoices.VIEW_PORTFOLIO]
|
||||||
self.user.save()
|
self.user.save()
|
||||||
self.user.refresh_from_db()
|
self.user.refresh_from_db()
|
||||||
|
|
||||||
|
@ -180,17 +227,48 @@ class TestPortfolio(WebTest):
|
||||||
self.assertNotContains(portfolio_page, reverse("domains"))
|
self.assertNotContains(portfolio_page, reverse("domains"))
|
||||||
self.assertNotContains(portfolio_page, reverse("domain-requests"))
|
self.assertNotContains(portfolio_page, reverse("domain-requests"))
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
def test_navigation_links_hidden_when_user_not_have_role(self):
|
||||||
|
"""Test that admin / memmber roles are associated with the right access"""
|
||||||
|
self.app.set_user(self.user.username)
|
||||||
|
self.user.portfolio = self.portfolio
|
||||||
|
self.user.portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||||
|
self.user.save()
|
||||||
|
self.user.refresh_from_db()
|
||||||
|
with override_flag("organization_feature", active=True):
|
||||||
|
# This will redirect the user to the portfolio page.
|
||||||
|
# Follow implicity checks if our redirect is working.
|
||||||
|
portfolio_page = self.app.get(reverse("home")).follow()
|
||||||
|
# Assert that we're on the right page
|
||||||
|
self.assertContains(portfolio_page, self.portfolio.organization_name)
|
||||||
|
self.assertNotContains(portfolio_page, "<h1>Organization</h1>")
|
||||||
|
self.assertContains(portfolio_page, '<h1 id="domains-header">Domains</h1>')
|
||||||
|
self.assertContains(portfolio_page, reverse("domains"))
|
||||||
|
self.assertContains(portfolio_page, reverse("domain-requests"))
|
||||||
|
|
||||||
class TestPortfolioOrganization(TestPortfolio):
|
# removing non-basic portfolio role, which should remove domains
|
||||||
|
# and domain requests from nav
|
||||||
|
self.user.portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_MEMBER]
|
||||||
|
self.user.save()
|
||||||
|
self.user.refresh_from_db()
|
||||||
|
|
||||||
|
portfolio_page = self.app.get(reverse("home")).follow()
|
||||||
|
|
||||||
|
self.assertContains(portfolio_page, self.portfolio.organization_name)
|
||||||
|
self.assertContains(portfolio_page, "<h1>Organization</h1>")
|
||||||
|
self.assertNotContains(portfolio_page, '<h1 id="domains-header">Domains</h1>')
|
||||||
|
self.assertNotContains(portfolio_page, reverse("domains"))
|
||||||
|
self.assertNotContains(portfolio_page, reverse("domain-requests"))
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
def test_portfolio_org_name(self):
|
def test_portfolio_org_name(self):
|
||||||
"""Can load portfolio's org name page."""
|
"""Can load portfolio's org name page."""
|
||||||
with override_flag("organization_feature", active=True):
|
with override_flag("organization_feature", active=True):
|
||||||
self.app.set_user(self.user.username)
|
self.app.set_user(self.user.username)
|
||||||
self.user.portfolio = self.portfolio
|
self.user.portfolio = self.portfolio
|
||||||
self.user.portfolio_additional_permissions = [
|
self.user.portfolio_additional_permissions = [
|
||||||
User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
|
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
|
||||||
User.UserPortfolioPermissionChoices.EDIT_PORTFOLIO,
|
UserPortfolioPermissionChoices.EDIT_PORTFOLIO,
|
||||||
]
|
]
|
||||||
self.user.save()
|
self.user.save()
|
||||||
self.user.refresh_from_db()
|
self.user.refresh_from_db()
|
||||||
|
@ -200,14 +278,15 @@ class TestPortfolioOrganization(TestPortfolio):
|
||||||
page, "The name of your federal agency will be publicly listed as the domain registrant."
|
page, "The name of your federal agency will be publicly listed as the domain registrant."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
def test_domain_org_name_address_content(self):
|
def test_domain_org_name_address_content(self):
|
||||||
"""Org name and address information appears on the page."""
|
"""Org name and address information appears on the page."""
|
||||||
with override_flag("organization_feature", active=True):
|
with override_flag("organization_feature", active=True):
|
||||||
self.app.set_user(self.user.username)
|
self.app.set_user(self.user.username)
|
||||||
self.user.portfolio = self.portfolio
|
self.user.portfolio = self.portfolio
|
||||||
self.user.portfolio_additional_permissions = [
|
self.user.portfolio_additional_permissions = [
|
||||||
User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
|
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
|
||||||
User.UserPortfolioPermissionChoices.EDIT_PORTFOLIO,
|
UserPortfolioPermissionChoices.EDIT_PORTFOLIO,
|
||||||
]
|
]
|
||||||
self.user.save()
|
self.user.save()
|
||||||
self.user.refresh_from_db()
|
self.user.refresh_from_db()
|
||||||
|
@ -215,17 +294,19 @@ class TestPortfolioOrganization(TestPortfolio):
|
||||||
self.portfolio.organization_name = "Hotel California"
|
self.portfolio.organization_name = "Hotel California"
|
||||||
self.portfolio.save()
|
self.portfolio.save()
|
||||||
page = self.app.get(reverse("organization"))
|
page = self.app.get(reverse("organization"))
|
||||||
# Once in the sidenav, once in the main nav, once in the form
|
# Once in the sidenav, once in the main nav
|
||||||
self.assertContains(page, "Hotel California", count=3)
|
self.assertContains(page, "Hotel California", count=2)
|
||||||
|
self.assertContains(page, "Non-Federal Agency")
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
def test_domain_org_name_address_form(self):
|
def test_domain_org_name_address_form(self):
|
||||||
"""Submitting changes works on the org name address page."""
|
"""Submitting changes works on the org name address page."""
|
||||||
with override_flag("organization_feature", active=True):
|
with override_flag("organization_feature", active=True):
|
||||||
self.app.set_user(self.user.username)
|
self.app.set_user(self.user.username)
|
||||||
self.user.portfolio = self.portfolio
|
self.user.portfolio = self.portfolio
|
||||||
self.user.portfolio_additional_permissions = [
|
self.user.portfolio_additional_permissions = [
|
||||||
User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
|
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
|
||||||
User.UserPortfolioPermissionChoices.EDIT_PORTFOLIO,
|
UserPortfolioPermissionChoices.EDIT_PORTFOLIO,
|
||||||
]
|
]
|
||||||
self.user.save()
|
self.user.save()
|
||||||
self.user.refresh_from_db()
|
self.user.refresh_from_db()
|
||||||
|
|
|
@ -48,6 +48,7 @@ class PortfolioOrganizationView(PortfolioBasePermissionView, FormMixin):
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
"""Add additional context data to the template."""
|
"""Add additional context data to the template."""
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
|
context["has_edit_org_portfolio_permission"] = self.request.user.has_edit_org_portfolio_permission()
|
||||||
return context
|
return context
|
||||||
|
|
||||||
def get_object(self, queryset=None):
|
def get_object(self, queryset=None):
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue