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",
]
readonly_fields = ["invitation"]
autocomplete_fields = ["user", "portfolio"]
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."

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.time_stamped_model import TimeStampedModel
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__)
@ -65,6 +67,20 @@ class PortfolioInvitation(TimeStampedModel):
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):
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
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:
user_portfolio_permission.roles = self.roles

View file

@ -65,6 +65,16 @@ class UserPortfolioPermission(TimeStampedModel):
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):
readable_roles = []
if self.roles:

View file

@ -26,7 +26,7 @@ from registrar.utility.constants import BranchChoices
from registrar.utility.enums import DefaultEmail
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__)
@ -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()
class BaseExport(BaseModelDict):
class BaseExport(BaseModelAnnotation):
"""
A generic class for exporting data which returns a csv file for the given model.
Base class in an inheritance tree of 3.
@ -87,13 +87,13 @@ class BaseExport(BaseModelDict):
"""
writer = csv.writer(csv_file)
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
cls.write_csv_before(writer, **export_kwargs)
# 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
@ -146,7 +146,7 @@ class MemberExport(BaseExport):
return None
@classmethod
def get_models_dict(cls, request=None):
def get_model_dict(cls, request=None):
portfolio = request.session.get("portfolio")
if not portfolio:
return {}
@ -164,10 +164,13 @@ class MemberExport(BaseExport):
"member_display",
"domain_info",
"source",
"invitation_date",
"invited_by",
]
permissions = UserPortfolioPermissionModelDict.get_annotated_queryset(portfolio, csv_report=True).values(*shared_columns)
invitations = PortfolioInvitationModelDict.get_annotated_queryset(portfolio, csv_report=True).values(*shared_columns)
return convert_queryset_to_dict(permissions.union(invitations), is_model=False)
permissions = UserPortfolioPermissionModelAnnotation.get_annotated_queryset(portfolio, csv_report=True).values(*shared_columns)
invitations = PortfolioInvitationModelAnnotation.get_annotated_queryset(portfolio, csv_report=True).values(*shared_columns)
queryset_dict = convert_queryset_to_dict(permissions.union(invitations), is_model=False)
return queryset_dict
@classmethod
def get_columns(cls):
@ -178,6 +181,7 @@ class MemberExport(BaseExport):
"Email",
"Organization admin",
"Invited by",
"Invitation date",
"Last active",
"Domain requests",
"Member management",
@ -195,19 +199,26 @@ class MemberExport(BaseExport):
"""
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 = {
"Email": model.get("email_display"),
"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"),
"Domain requests": "TODO",
"Member management": "TODO",
"Domain management": "TODO",
"Number of domains": "TODO",
# Quote enclose the domain list
# Note: this will only enclose when more than two items exist
"Domains": domains,
"Number of domains": len(user_managed_domains),
# TODO - this doesn't quote enclose with one record
"Domains": managed_domains_as_csv,
}
# "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 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.orm_helper import ArrayRemove
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
@abstractmethod
@ -166,14 +195,16 @@ class BaseModelDict(ABC):
return queryset
@classmethod
def get_models_dict(cls, **kwargs):
request = kwargs.get("request")
print(f"get_models_dict => request is: {request}")
def get_model_dict(cls, **kwargs):
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
def model(cls):
# Return the model class that this export handles
@ -217,7 +248,7 @@ class UserPortfolioPermissionModelDict(BaseModelDict):
domain_query = F("user__permissions__domain__name")
last_active_query = Func(
F("user__last_login"),
Value("FMMonth DD, YYYY"),
Value("YYYY-MM-DD"),
function="to_char",
output_field=TextField()
)
@ -263,6 +294,30 @@ class UserPortfolioPermissionModelDict(BaseModelDict):
& Q(user__permissions__domain__domain_info__portfolio=portfolio),
),
"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
@ -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
def model(cls):
# Return the model class that this export handles
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
def get_filter_conditions(cls, portfolio):
"""
@ -322,7 +389,7 @@ class PortfolioInvitationModelDict(BaseModelDict):
else:
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(
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
@ -342,6 +409,31 @@ class PortfolioInvitationModelDict(BaseModelDict):
)
),
"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

View file

@ -9,7 +9,7 @@ from django.views import View
from registrar.models import UserPortfolioPermission
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
@ -55,7 +55,7 @@ class PortfolioMembersJson(PortfolioMembersPermission, View):
def initial_permissions_search(self, portfolio):
"""Perform initial search for permissions before applying any filters."""
queryset = UserPortfolioPermissionModelDict.get_annotated_queryset(portfolio)
queryset = UserPortfolioPermissionModelAnnotation.get_annotated_queryset(portfolio)
return queryset.values(
"id",
"first_name",
@ -72,7 +72,7 @@ class PortfolioMembersJson(PortfolioMembersPermission, View):
def initial_invitations_search(self, portfolio):
"""Perform initial invitations search and get related DomainInvitation data based on the email."""
# 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(
"id",
"first_name",