Add some additional data + migration

This commit is contained in:
zandercymatics 2024-11-14 12:53:13 -07:00
parent 9ac8a3e8bc
commit 10c01870d7
No known key found for this signature in database
GPG key ID: FF4636ABEC9682B7
7 changed files with 184 additions and 28 deletions

View file

@ -1275,6 +1275,8 @@ class UserPortfolioPermissionAdmin(ListHeaderAdmin):
"get_roles", "get_roles",
] ]
readonly_fields = ["invitation"]
autocomplete_fields = ["user", "portfolio"] autocomplete_fields = ["user", "portfolio"]
search_fields = ["user__first_name", "user__last_name", "user__email", "portfolio__organization_name"] search_fields = ["user__first_name", "user__last_name", "user__email", "portfolio__organization_name"]
search_help_text = "Search by first name, last name, email, or portfolio." search_help_text = "Search by first name, last name, email, or portfolio."

View file

@ -0,0 +1,25 @@
# Generated by Django 4.2.10 on 2024-11-14 17:54
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("registrar", "0136_domainrequest_requested_suborganization_and_more"),
]
operations = [
migrations.AddField(
model_name="userportfoliopermission",
name="invitation",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="created_user_portfolio_permission",
to="registrar.portfolioinvitation",
),
),
]

View file

@ -9,6 +9,8 @@ from registrar.models.user_portfolio_permission import UserPortfolioPermission
from .utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices # type: ignore from .utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices # type: ignore
from .utility.time_stamped_model import TimeStampedModel from .utility.time_stamped_model import TimeStampedModel
from django.contrib.postgres.fields import ArrayField from django.contrib.postgres.fields import ArrayField
from django.contrib.admin.models import LogEntry, ADDITION
from django.contrib.contenttypes.models import ContentType
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -65,6 +67,20 @@ class PortfolioInvitation(TimeStampedModel):
protected=True, # can't alter state except through transition methods! protected=True, # can't alter state except through transition methods!
) )
# TODO - replace this with a "creator" field on portfolio invitation. This should be another ticket.
@property
def creator(self):
"""Get the user who created this invitation from the audit log"""
content_type = ContentType.objects.get_for_model(self)
log_entry = LogEntry.objects.filter(
content_type=content_type,
object_id=self.pk,
action_flag=ADDITION
).order_by("action_time").first()
return log_entry.user if log_entry else None
def __str__(self): def __str__(self):
return f"Invitation for {self.email} on {self.portfolio} is {self.status}" return f"Invitation for {self.email} on {self.portfolio} is {self.status}"
@ -101,7 +117,7 @@ class PortfolioInvitation(TimeStampedModel):
# and create a role for that user on this portfolio # and create a role for that user on this portfolio
user_portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create( user_portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
portfolio=self.portfolio, user=user portfolio=self.portfolio, user=user, invitation=self
) )
if self.roles and len(self.roles) > 0: if self.roles and len(self.roles) > 0:
user_portfolio_permission.roles = self.roles user_portfolio_permission.roles = self.roles

View file

@ -65,6 +65,16 @@ class UserPortfolioPermission(TimeStampedModel):
help_text="Select one or more additional permissions.", help_text="Select one or more additional permissions.",
) )
# TODO - this needs a small script to update existing values
invitation = models.ForeignKey(
"registrar.PortfolioInvitation",
null=True,
blank=True,
# We don't want to accidentally delete invitations
on_delete=models.PROTECT,
related_name="created_user_portfolio_permission",
)
def __str__(self): def __str__(self):
readable_roles = [] readable_roles = []
if self.roles: if self.roles:

View file

@ -26,7 +26,7 @@ from registrar.utility.constants import BranchChoices
from registrar.utility.enums import DefaultEmail from registrar.utility.enums import DefaultEmail
from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.aggregates import ArrayAgg
from registrar.utility.model_dicts import BaseModelDict, PortfolioInvitationModelDict, UserPortfolioPermissionModelDict from registrar.utility.model_annotations import BaseModelAnnotation, PortfolioInvitationModelAnnotation, UserPortfolioPermissionModelAnnotation
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -58,7 +58,7 @@ def format_end_date(end_date):
return timezone.make_aware(datetime.strptime(end_date, "%Y-%m-%d")) if end_date else get_default_end_date() return timezone.make_aware(datetime.strptime(end_date, "%Y-%m-%d")) if end_date else get_default_end_date()
class BaseExport(BaseModelDict): class BaseExport(BaseModelAnnotation):
""" """
A generic class for exporting data which returns a csv file for the given model. A generic class for exporting data which returns a csv file for the given model.
Base class in an inheritance tree of 3. Base class in an inheritance tree of 3.
@ -87,13 +87,13 @@ class BaseExport(BaseModelDict):
""" """
writer = csv.writer(csv_file) writer = csv.writer(csv_file)
columns = cls.get_columns() columns = cls.get_columns()
models_dict = cls.get_models_dict(request=request) model_dict = cls.get_model_dict(request=request)
# Write to csv file before the write_csv # Write to csv file before the write_csv
cls.write_csv_before(writer, **export_kwargs) cls.write_csv_before(writer, **export_kwargs)
# Write the csv file # Write the csv file
rows = cls.write_csv(writer, columns, models_dict) rows = cls.write_csv(writer, columns, model_dict)
# Return rows that for easier parsing and testing # Return rows that for easier parsing and testing
return rows return rows
@ -146,7 +146,7 @@ class MemberExport(BaseExport):
return None return None
@classmethod @classmethod
def get_models_dict(cls, request=None): def get_model_dict(cls, request=None):
portfolio = request.session.get("portfolio") portfolio = request.session.get("portfolio")
if not portfolio: if not portfolio:
return {} return {}
@ -164,10 +164,13 @@ class MemberExport(BaseExport):
"member_display", "member_display",
"domain_info", "domain_info",
"source", "source",
"invitation_date",
"invited_by",
] ]
permissions = UserPortfolioPermissionModelDict.get_annotated_queryset(portfolio, csv_report=True).values(*shared_columns) permissions = UserPortfolioPermissionModelAnnotation.get_annotated_queryset(portfolio, csv_report=True).values(*shared_columns)
invitations = PortfolioInvitationModelDict.get_annotated_queryset(portfolio, csv_report=True).values(*shared_columns) invitations = PortfolioInvitationModelAnnotation.get_annotated_queryset(portfolio, csv_report=True).values(*shared_columns)
return convert_queryset_to_dict(permissions.union(invitations), is_model=False) queryset_dict = convert_queryset_to_dict(permissions.union(invitations), is_model=False)
return queryset_dict
@classmethod @classmethod
def get_columns(cls): def get_columns(cls):
@ -178,6 +181,7 @@ class MemberExport(BaseExport):
"Email", "Email",
"Organization admin", "Organization admin",
"Invited by", "Invited by",
"Invitation date",
"Last active", "Last active",
"Domain requests", "Domain requests",
"Member management", "Member management",
@ -195,19 +199,26 @@ class MemberExport(BaseExport):
""" """
is_admin = UserPortfolioRoleChoices.ORGANIZATION_ADMIN in (model.get("roles") or []) is_admin = UserPortfolioRoleChoices.ORGANIZATION_ADMIN in (model.get("roles") or [])
domains = ",".join(model.get("domain_info")) if model.get("domain_info") else "" # Tracks if they can view, create requests, or not do anything
x = model.get("roles")
print(f"what are the roles? {x}")
domain_request_user_permission = None
user_managed_domains = model.get("domain_info", [])
managed_domains_as_csv = ",".join(user_managed_domains)
# Whether they can make domain requests. Tentatively, I think the options as we currently understand would be: None, Viewer, Viewer Requester
FIELDS = { FIELDS = {
"Email": model.get("email_display"), "Email": model.get("email_display"),
"Organization admin": is_admin, "Organization admin": is_admin,
"Invited by": model.get("source"), "Invited by": model.get("invited_by"),
"Invitation date": model.get("invitation_date"),
"Last active": model.get("last_active"), "Last active": model.get("last_active"),
"Domain requests": "TODO", "Domain requests": "TODO",
"Member management": "TODO", "Member management": "TODO",
"Domain management": "TODO", "Domain management": "TODO",
"Number of domains": "TODO", "Number of domains": len(user_managed_domains),
# Quote enclose the domain list # TODO - this doesn't quote enclose with one record
# Note: this will only enclose when more than two items exist "Domains": managed_domains_as_csv,
"Domains": domains,
} }
# "id", # "id",

View file

@ -1,5 +1,24 @@
""" """
TODO: explanation here Model annotation classes. Intended to return django querysets with computed fields for api endpoints and our csv reports.
Created to manage the complexity of the MembersTable and Members CSV report, as they require complex but common annotations.
These classes provide consistent, reusable query transformations that:
1. Add computed fields via annotations
2. Handle related model data
3. Format fields for display
4. Standardize field names across different contexts
Used by both API endpoints (e.g. portfolio members JSON) and data exports (e.g. CSV reports).
Example:
# For a JSON table endpoint
permissions = UserPortfolioPermissionAnnotation.get_annotated_queryset(portfolio)
# Returns queryset with standardized fields for member tables
# For a CSV export
permissions = UserPortfolioPermissionAnnotation.get_annotated_queryset(portfolio, csv_report=True)
# Returns same fields but formatted for CSV export
""" """
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from registrar.models import ( from registrar.models import (
@ -12,9 +31,19 @@ from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.models.utility.generic_helper import convert_queryset_to_dict from registrar.models.utility.generic_helper import convert_queryset_to_dict
from registrar.models.utility.orm_helper import ArrayRemove from registrar.models.utility.orm_helper import ArrayRemove
from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.admin.models import LogEntry, ADDITION
from django.contrib.contenttypes.models import ContentType
class BaseModelDict(ABC): class BaseModelAnnotation(ABC):
"""
Abstract base class that standardizes how models are annotated for csv exports or complex annotation queries.
For example, the Members table / csv export.
Subclasses define model-specific annotations, filters, and field formatting while inheriting
common queryset building logic.
Intended ensure consistent data presentation across both table UI components and CSV exports.
"""
@classmethod @classmethod
@abstractmethod @abstractmethod
@ -166,14 +195,16 @@ class BaseModelDict(ABC):
return queryset return queryset
@classmethod @classmethod
def get_models_dict(cls, **kwargs): def get_model_dict(cls, **kwargs):
request = kwargs.get("request")
print(f"get_models_dict => request is: {request}")
return convert_queryset_to_dict(cls.get_annotated_queryset(**kwargs), is_model=False) return convert_queryset_to_dict(cls.get_annotated_queryset(**kwargs), is_model=False)
class UserPortfolioPermissionModelDict(BaseModelDict): class UserPortfolioPermissionModelAnnotation(BaseModelAnnotation):
"""
Annotates UserPortfolioPermission querysets with computed fields for member tables.
Handles formatting of user details, permissions, and related domain information
for both UI display and CSV export.
"""
@classmethod @classmethod
def model(cls): def model(cls):
# Return the model class that this export handles # Return the model class that this export handles
@ -217,7 +248,7 @@ class UserPortfolioPermissionModelDict(BaseModelDict):
domain_query = F("user__permissions__domain__name") domain_query = F("user__permissions__domain__name")
last_active_query = Func( last_active_query = Func(
F("user__last_login"), F("user__last_login"),
Value("FMMonth DD, YYYY"), Value("YYYY-MM-DD"),
function="to_char", function="to_char",
output_field=TextField() output_field=TextField()
) )
@ -263,6 +294,30 @@ class UserPortfolioPermissionModelDict(BaseModelDict):
& Q(user__permissions__domain__domain_info__portfolio=portfolio), & Q(user__permissions__domain__domain_info__portfolio=portfolio),
), ),
"source": Value("permission", output_field=CharField()), "source": Value("permission", output_field=CharField()),
"invitation_date": Coalesce(
Func(
F("invitation__created_at"),
Value("YYYY-MM-DD"),
function="to_char",
output_field=TextField()
),
Value("Invalid date"),
output_field=TextField(),
),
# TODO - replace this with a "creator" field on portfolio invitation. This should be another ticket.
# Grab the invitation creator from the audit log. This will need to be replaced with a creator field.
# When that happens, just replace this with F("invitation__creator")
"invited_by": Coalesce(
Subquery(
LogEntry.objects.filter(
content_type=ContentType.objects.get_for_model(PortfolioInvitation),
object_id=Cast(OuterRef("invitation__id"), output_field=TextField()), # Look up the invitation's ID
action_flag=ADDITION
).order_by("action_time").values("user__email")[:1]
),
Value("Unknown"),
output_field=CharField()
),
} }
@classmethod @classmethod
@ -287,13 +342,25 @@ class UserPortfolioPermissionModelDict(BaseModelDict):
) )
class PortfolioInvitationModelDict(BaseModelDict): class PortfolioInvitationModelAnnotation(BaseModelAnnotation):
"""
Annotates PortfolioInvitation querysets with computed fields for the member table.
Handles formatting of user details, permissions, and related domain information
for both UI display and CSV export.
"""
@classmethod @classmethod
def model(cls): def model(cls):
# Return the model class that this export handles # Return the model class that this export handles
return PortfolioInvitation return PortfolioInvitation
@classmethod
def get_exclusions(cls):
"""
Get a Q object of exclusion conditions to pass to .exclude() when building queryset.
"""
return Q(status=PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED)
@classmethod @classmethod
def get_filter_conditions(cls, portfolio): def get_filter_conditions(cls, portfolio):
""" """
@ -322,7 +389,7 @@ class PortfolioInvitationModelDict(BaseModelDict):
else: else:
domain_query = Concat(F("domain__id"), Value(":"), F("domain__name"), output_field=CharField()) domain_query = Concat(F("domain__id"), Value(":"), F("domain__name"), output_field=CharField())
# Get all existing domain invitations and search on that # Get all existing domain invitations and search on that for domains the user exists on
domain_invitations = DomainInvitation.objects.filter( domain_invitations = DomainInvitation.objects.filter(
email=OuterRef("email"), # Check if email matches the OuterRef("email") email=OuterRef("email"), # Check if email matches the OuterRef("email")
domain__domain_info__portfolio=portfolio, # Check if the domain's portfolio matches the given portfolio domain__domain_info__portfolio=portfolio, # Check if the domain's portfolio matches the given portfolio
@ -342,6 +409,31 @@ class PortfolioInvitationModelDict(BaseModelDict):
) )
), ),
"source": Value("invitation", output_field=CharField()), "source": Value("invitation", output_field=CharField()),
"invitation_date": Coalesce(
Func(
F("created_at"),
Value("YYYY-MM-DD"),
function="to_char",
output_field=TextField()
),
Value("Invalid date"),
output_field=TextField(),
),
# TODO - replace this with a "creator" field on portfolio invitation. This should be another ticket.
# Grab the invitation creator from the audit log. This will need to be replaced with a creator field.
# When that happens, just replace this with F("invitation__creator")
"invited_by": Coalesce(
Subquery(
LogEntry.objects.filter(
content_type=ContentType.objects.get_for_model(PortfolioInvitation),
# Look up the invitation's ID. LogEntry expects a string as this it is stored as json.
object_id=Cast(OuterRef("id"), output_field=TextField()),
action_flag=ADDITION
).order_by("action_time").values("user__email")[:1]
),
Value("Unknown"),
output_field=CharField()
),
} }
@classmethod @classmethod

View file

@ -9,7 +9,7 @@ from django.views import View
from registrar.models import UserPortfolioPermission from registrar.models import UserPortfolioPermission
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from registrar.utility.model_dicts import PortfolioInvitationModelDict, UserPortfolioPermissionModelDict from registrar.utility.model_annotations import PortfolioInvitationModelAnnotation, UserPortfolioPermissionModelAnnotation
from registrar.views.utility.mixins import PortfolioMembersPermission from registrar.views.utility.mixins import PortfolioMembersPermission
@ -55,7 +55,7 @@ class PortfolioMembersJson(PortfolioMembersPermission, View):
def initial_permissions_search(self, portfolio): def initial_permissions_search(self, portfolio):
"""Perform initial search for permissions before applying any filters.""" """Perform initial search for permissions before applying any filters."""
queryset = UserPortfolioPermissionModelDict.get_annotated_queryset(portfolio) queryset = UserPortfolioPermissionModelAnnotation.get_annotated_queryset(portfolio)
return queryset.values( return queryset.values(
"id", "id",
"first_name", "first_name",
@ -72,7 +72,7 @@ class PortfolioMembersJson(PortfolioMembersPermission, View):
def initial_invitations_search(self, portfolio): def initial_invitations_search(self, portfolio):
"""Perform initial invitations search and get related DomainInvitation data based on the email.""" """Perform initial invitations search and get related DomainInvitation data based on the email."""
# Get DomainInvitation query for matching email and for the portfolio # Get DomainInvitation query for matching email and for the portfolio
queryset = PortfolioInvitationModelDict.get_annotated_queryset(portfolio) queryset = PortfolioInvitationModelAnnotation.get_annotated_queryset(portfolio)
return queryset.values( return queryset.values(
"id", "id",
"first_name", "first_name",