mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-08-22 01:01:08 +02:00
Add some additional data + migration
This commit is contained in:
parent
9ac8a3e8bc
commit
10c01870d7
7 changed files with 184 additions and 28 deletions
|
@ -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."
|
||||
|
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
|
@ -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",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue