Skip to content

Commit

Permalink
Merge pull request #2526 from cisagov/bob/2367-portfolio-invitation
Browse files Browse the repository at this point in the history
Issue #2367: Portfolio invitation backend - MIGRATION - [BOB]
  • Loading branch information
rachidatecs authored Aug 2, 2024
2 parents 5bbd533 + 70d30f9 commit 33234ac
Show file tree
Hide file tree
Showing 11 changed files with 478 additions and 54 deletions.
74 changes: 72 additions & 2 deletions src/registrar/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from django_fsm import get_available_FIELD_transitions, FSMField
from registrar.models.domain_group import DomainGroup
from registrar.models.suborganization import Suborganization
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from waffle.decorators import flag_is_active
from django.contrib import admin, messages
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
Expand Down Expand Up @@ -131,12 +132,12 @@ class Meta:
"groups": NoAutocompleteFilteredSelectMultiple("groups", False),
"user_permissions": NoAutocompleteFilteredSelectMultiple("user_permissions", False),
"portfolio_roles": FilteredSelectMultipleArrayWidget(
"portfolio_roles", is_stacked=False, choices=User.UserPortfolioRoleChoices.choices
"portfolio_roles", is_stacked=False, choices=UserPortfolioRoleChoices.choices
),
"portfolio_additional_permissions": FilteredSelectMultipleArrayWidget(
"portfolio_additional_permissions",
is_stacked=False,
choices=User.UserPortfolioPermissionChoices.choices,
choices=UserPortfolioPermissionChoices.choices,
),
}

Expand Down Expand Up @@ -169,6 +170,24 @@ def _override_base_help_texts(self):
)


class PortfolioInvitationAdminForm(UserChangeForm):
"""This form utilizes the custom widget for its class's ManyToMany UIs."""

class Meta:
model = models.PortfolioInvitation
fields = "__all__"
widgets = {
"portfolio_roles": FilteredSelectMultipleArrayWidget(
"portfolio_roles", is_stacked=False, choices=UserPortfolioRoleChoices.choices
),
"portfolio_additional_permissions": FilteredSelectMultipleArrayWidget(
"portfolio_additional_permissions",
is_stacked=False,
choices=UserPortfolioPermissionChoices.choices,
),
}


class DomainInformationAdminForm(forms.ModelForm):
"""This form utilizes the custom widget for its class's ManyToMany UIs."""

Expand Down Expand Up @@ -1299,6 +1318,56 @@ def changelist_view(self, request, extra_context=None):
return super().changelist_view(request, extra_context=extra_context)


class PortfolioInvitationAdmin(ListHeaderAdmin):
"""Custom portfolio invitation admin class."""

form = PortfolioInvitationAdminForm

class Meta:
model = models.PortfolioInvitation
fields = "__all__"

_meta = Meta()

# Columns
list_display = [
"email",
"portfolio",
"portfolio_roles",
"portfolio_additional_permissions",
"status",
]

# Search
search_fields = [
"email",
"portfolio__name",
]

# Filters
list_filter = ("status",)

search_help_text = "Search by email or portfolio."

# Mark the FSM field 'status' as readonly
# to allow admin users to create Domain Invitations
# without triggering the FSM Transition Not Allowed
# error.
readonly_fields = ["status"]

autocomplete_fields = ["portfolio"]

change_form_template = "django/admin/email_clipboard_change_form.html"

# Select portfolio invitations to change -> Portfolio invitations
def changelist_view(self, request, extra_context=None):
if extra_context is None:
extra_context = {}
extra_context["tabtitle"] = "Portfolio invitations"
# Get the filtered values
return super().changelist_view(request, extra_context=extra_context)


class DomainInformationResource(resources.ModelResource):
"""defines how each field in the referenced model should be mapped to the corresponding fields in the
import/export file"""
Expand Down Expand Up @@ -2900,6 +2969,7 @@ class SuborganizationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
admin.site.register(models.DomainRequest, DomainRequestAdmin)
admin.site.register(models.TransitionDomain, TransitionDomainAdmin)
admin.site.register(models.VerifiedByStaff, VerifiedByStaffAdmin)
admin.site.register(models.PortfolioInvitation, PortfolioInvitationAdmin)
admin.site.register(models.Portfolio, PortfolioAdmin)
admin.site.register(models.DomainGroup, DomainGroupAdmin)
admin.site.register(models.Suborganization, SuborganizationAdmin)
Expand Down
83 changes: 83 additions & 0 deletions src/registrar/migrations/0115_portfolioinvitation.py
Original file line number Diff line number Diff line change
@@ -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")],
},
),
]
5 changes: 4 additions & 1 deletion src/registrar/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from auditlog.registry import auditlog # type: ignore
from auditlog.registry import auditlog
from .contact import Contact
from .domain_request import DomainRequest
from .domain_information import DomainInformation
Expand All @@ -16,6 +16,7 @@
from .transition_domain import TransitionDomain
from .verified_by_staff import VerifiedByStaff
from .waffle_flag import WaffleFlag
from .portfolio_invitation import PortfolioInvitation
from .portfolio import Portfolio
from .domain_group import DomainGroup
from .suborganization import Suborganization
Expand All @@ -40,6 +41,7 @@
"TransitionDomain",
"VerifiedByStaff",
"WaffleFlag",
"PortfolioInvitation",
"Portfolio",
"DomainGroup",
"Suborganization",
Expand All @@ -63,6 +65,7 @@
auditlog.register(TransitionDomain)
auditlog.register(VerifiedByStaff)
auditlog.register(WaffleFlag)
auditlog.register(PortfolioInvitation)
auditlog.register(Portfolio)
auditlog.register(DomainGroup)
auditlog.register(Suborganization)
Expand Down
95 changes: 95 additions & 0 deletions src/registrar/models/portfolio_invitation.py
Original file line number Diff line number Diff line change
@@ -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()
Loading

0 comments on commit 33234ac

Please sign in to comment.