diff --git a/src/registrar/management/commands/populate_user_portfolio_permission_invitation.py b/src/registrar/management/commands/populate_user_portfolio_permission_invitation.py new file mode 100644 index 000000000..6e72792ac --- /dev/null +++ b/src/registrar/management/commands/populate_user_portfolio_permission_invitation.py @@ -0,0 +1,27 @@ +import logging +from django.core.management import BaseCommand +from registrar.management.commands.utility.terminal_helper import PopulateScriptTemplate, TerminalColors, TerminalHelper +from registrar.models import UserPortfolioPermission, PortfolioInvitation +from auditlog.models import LogEntry + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand, PopulateScriptTemplate): + help = "Loops through each UserPortfolioPermission object and populates the invitation field" + + def handle(self, **kwargs): + """Loops through each DomainRequest object and populates + its last_status_update and first_submitted_date values""" + self.existing_invitations = PortfolioInvitation.objects.filter(portfolio__isnull=False, email__isnull=False).select_related('portfolio') + filter_condition = {"invitation__isnull": True, "portfolio__isnull": False, "user__email__isnull": False} + self.mass_update_records(UserPortfolioPermission, filter_condition, fields_to_update=["invitation"]) + + def update_record(self, record: UserPortfolioPermission): + """Associate the invitation to the right object""" + record.invitation = self.existing_invitations.filter(email=record.user.email, portfolio=record.portfolio).first() + TerminalHelper.colorful_logger("INFO", "OKCYAN", f"{TerminalColors.OKCYAN}Adding invitation to {record}") + + def should_skip_record(self, record) -> bool: + """There is nothing to add if no invitation exists""" + return not record or not self.existing_invitations.filter(email=record.user.email, portfolio=record.portfolio).exists() diff --git a/src/registrar/models/user_portfolio_permission.py b/src/registrar/models/user_portfolio_permission.py index 5a26f350e..424f09a17 100644 --- a/src/registrar/models/user_portfolio_permission.py +++ b/src/registrar/models/user_portfolio_permission.py @@ -115,16 +115,19 @@ class UserPortfolioPermission(TimeStampedModel): if additional_permissions: portfolio_permissions.update(additional_permissions) return list(portfolio_permissions) - + @classmethod def get_domain_request_permission_display(cls, roles, additional_permissions): """Class method to return a readable string for domain request permissions""" # Tracks if they can view, create requests, or not do anything all_permissions = UserPortfolioPermission.get_portfolio_permissions(roles, additional_permissions) - all_domain_perms = [UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, UserPortfolioPermissionChoices.EDIT_REQUESTS] - if (all(perm in all_permissions for perm in all_domain_perms)): + all_domain_perms = [ + UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, + UserPortfolioPermissionChoices.EDIT_REQUESTS, + ] + if all(perm in all_permissions for perm in all_domain_perms): return "Viewer Requester" - elif (UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS in all_permissions): + elif UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS in all_permissions: return "Viewer" else: return "None" @@ -134,11 +137,11 @@ class UserPortfolioPermission(TimeStampedModel): """Class method to return a readable string for member permissions""" # Tracks if they can view, create requests, or not do anything all_permissions = UserPortfolioPermission.get_portfolio_permissions(roles, additional_permissions) - # Note for reviewers: the reason why this isn't checking on "all" is because + # Note for reviewers: the reason why this isn't checking on "all" is because # the way perms work for members is different than requests. We need to consolidate this. - if (UserPortfolioPermissionChoices.EDIT_MEMBERS in all_permissions): + if UserPortfolioPermissionChoices.EDIT_MEMBERS in all_permissions: return "Manager" - elif (UserPortfolioPermissionChoices.VIEW_MEMBERS in all_permissions): + elif UserPortfolioPermissionChoices.VIEW_MEMBERS in all_permissions: return "Viewer" else: return "None" diff --git a/src/registrar/models/utility/orm_helper.py b/src/registrar/models/utility/orm_helper.py index 24f7982e7..4f4665216 100644 --- a/src/registrar/models/utility/orm_helper.py +++ b/src/registrar/models/utility/orm_helper.py @@ -1,6 +1,8 @@ from django.db.models.expressions import Func + class ArrayRemove(Func): """Custom Func to use array_remove to remove null values""" + function = "array_remove" - template = "%(function)s(%(expressions)s, NULL)" \ No newline at end of file + template = "%(function)s(%(expressions)s, NULL)" diff --git a/src/registrar/templates/includes/members_table.html b/src/registrar/templates/includes/members_table.html index ae430501d..09cebda2e 100644 --- a/src/registrar/templates/includes/members_table.html +++ b/src/registrar/templates/includes/members_table.html @@ -36,7 +36,7 @@ - {% comment %} {% if user_domain_count and user_domain_count > 0 %} {% endcomment %} + {% if member_count and member_count > 0 %}
@@ -46,7 +46,7 @@
- {% comment %} {% endif %} {% endcomment %} + {% endif %} diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py index cf9cd5a2b..76a84094c 100644 --- a/src/registrar/utility/csv_export.py +++ b/src/registrar/utility/csv_export.py @@ -11,7 +11,21 @@ from registrar.models import ( PublicContact, UserDomainRole, ) -from django.db.models import Case, CharField, Count, DateField, F, ManyToManyField, Q, QuerySet, Value, When, TextField, OuterRef, Subquery +from django.db.models import ( + Case, + CharField, + Count, + DateField, + F, + ManyToManyField, + Q, + QuerySet, + Value, + When, + TextField, + OuterRef, + Subquery, +) from django.db.models.functions import Cast from django.utils import timezone from django.db.models.functions import Concat, Coalesce @@ -25,7 +39,11 @@ from registrar.utility.constants import BranchChoices from registrar.utility.enums import DefaultEmail from django.contrib.postgres.aggregates import ArrayAgg -from registrar.utility.model_annotations import BaseModelAnnotation, PortfolioInvitationModelAnnotation, UserPortfolioPermissionModelAnnotation +from registrar.utility.model_annotations import ( + BaseModelAnnotation, + PortfolioInvitationModelAnnotation, + UserPortfolioPermissionModelAnnotation, +) logger = logging.getLogger(__name__) @@ -134,6 +152,7 @@ class BaseExport(BaseModelAnnotation): """ pass + class MemberExport(BaseExport): @classmethod @@ -143,7 +162,7 @@ class MemberExport(BaseExport): This is a special edge case, but the base report requires this to be defined. """ return None - + @classmethod def get_model_annotation_dict(cls, request=None, **kwargs): portfolio = request.session.get("portfolio") @@ -166,8 +185,12 @@ class MemberExport(BaseExport): "invitation_date", "invited_by", ] - permissions = UserPortfolioPermissionModelAnnotation.get_annotated_queryset(portfolio, csv_report=True).values(*shared_columns) - invitations = PortfolioInvitationModelAnnotation.get_annotated_queryset(portfolio, csv_report=True).values(*shared_columns) + 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 @@ -199,7 +222,9 @@ class MemberExport(BaseExport): roles = model.get("roles") additional_permissions = model.get("additional_permissions_display") is_admin = UserPortfolioRoleChoices.ORGANIZATION_ADMIN in (roles or []) - domain_request_display = UserPortfolioPermission.get_domain_request_permission_display(roles, additional_permissions) + domain_request_display = UserPortfolioPermission.get_domain_request_permission_display( + roles, additional_permissions + ) member_perm_display = UserPortfolioPermission.get_member_permission_display(roles, additional_permissions) user_managed_domains = model.get("domain_info", []) managed_domains_as_csv = ",".join(user_managed_domains) @@ -231,6 +256,7 @@ class MemberExport(BaseExport): row = [FIELDS.get(column, "") for column in columns] return row + class DomainExport(BaseExport): """ A collection of functions which return csv files regarding Domains. Although class is @@ -1521,4 +1547,3 @@ class DomainRequestDataFull(DomainRequestExport): distinct=True, ) return query - diff --git a/src/registrar/utility/model_annotations.py b/src/registrar/utility/model_annotations.py index 38f00b072..dc6e6ea87 100644 --- a/src/registrar/utility/model_annotations.py +++ b/src/registrar/utility/model_annotations.py @@ -20,12 +20,26 @@ Example: 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 ( DomainInvitation, PortfolioInvitation, ) -from django.db.models import CharField, F, ManyToManyField, Q, QuerySet, Value, TextField, OuterRef, Subquery, Func, Case, When +from django.db.models import ( + CharField, + F, + ManyToManyField, + Q, + QuerySet, + Value, + TextField, + OuterRef, + Subquery, + Func, + Case, + When, +) from django.db.models.functions import Concat, Coalesce, Cast from registrar.models.user_portfolio_permission import UserPortfolioPermission from registrar.models.utility.generic_helper import convert_queryset_to_dict @@ -39,9 +53,9 @@ 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. + common queryset building logic. Intended ensure consistent data presentation across both table UI components and CSV exports. """ @@ -118,7 +132,7 @@ class BaseModelAnnotation(ABC): Get a list of fields from related tables. """ return [] - + @classmethod def annotate_and_retrieve_fields( cls, initial_queryset, annotated_fields, related_table_fields=None, include_many_to_many=False, **kwargs @@ -174,8 +188,7 @@ class BaseModelAnnotation(ABC): model_queryset = ( cls.model() - .objects - .select_related(*select_related) + .objects.select_related(*select_related) .prefetch_related(*prefetch_related) .filter(filter_conditions) .exclude(exclusions) @@ -183,9 +196,7 @@ class BaseModelAnnotation(ABC): .order_by(*sort_fields) .distinct() ) - return cls.annotate_and_retrieve_fields( - model_queryset, annotated_fields, related_table_fields, **kwargs - ) + return cls.annotate_and_retrieve_fields(model_queryset, annotated_fields, related_table_fields, **kwargs) @classmethod def update_queryset(cls, queryset, **kwargs): @@ -193,7 +204,7 @@ class BaseModelAnnotation(ABC): Returns an updated queryset. Override in subclass to update queryset. """ return queryset - + @classmethod def get_model_annotation_dict(cls, **kwargs): return convert_queryset_to_dict(cls.get_annotated_queryset(**kwargs), is_model=False) @@ -205,6 +216,7 @@ class UserPortfolioPermissionModelAnnotation(BaseModelAnnotation): 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 @@ -247,10 +259,7 @@ class UserPortfolioPermissionModelAnnotation(BaseModelAnnotation): if csv_report: domain_query = F("user__permissions__domain__name") last_active_query = Func( - F("user__last_login"), - Value("YYYY-MM-DD"), - function="to_char", - output_field=TextField() + F("user__last_login"), Value("YYYY-MM-DD"), function="to_char", output_field=TextField() ) else: domain_query = Concat( @@ -272,10 +281,7 @@ class UserPortfolioPermissionModelAnnotation(BaseModelAnnotation): ), "additional_permissions_display": F("additional_permissions"), "member_display": Case( - When( - Q(user__email__isnull=False) & ~Q(user__email=""), - then=F("user__email") - ), + When(Q(user__email__isnull=False) & ~Q(user__email=""), then=F("user__email")), When( Q(user__first_name__isnull=False) | Q(user__last_name__isnull=False), then=Concat( @@ -290,17 +296,12 @@ class UserPortfolioPermissionModelAnnotation(BaseModelAnnotation): "domain_info": ArrayAgg( domain_query, distinct=True, - filter=Q(user__permissions__domain__isnull=False) + filter=Q(user__permissions__domain__isnull=False) & 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() - ), + Func(F("invitation__created_at"), Value("YYYY-MM-DD"), function="to_char", output_field=TextField()), Value("Invalid date"), output_field=TextField(), ), @@ -311,12 +312,16 @@ class UserPortfolioPermissionModelAnnotation(BaseModelAnnotation): 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] + 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() + output_field=CharField(), ), } @@ -394,12 +399,7 @@ class PortfolioInvitationModelAnnotation(BaseModelAnnotation): ), "source": Value("invitation", output_field=CharField()), "invitation_date": Coalesce( - Func( - F("created_at"), - Value("YYYY-MM-DD"), - function="to_char", - output_field=TextField() - ), + Func(F("created_at"), Value("YYYY-MM-DD"), function="to_char", output_field=TextField()), Value("Invalid date"), output_field=TextField(), ), @@ -412,11 +412,13 @@ class PortfolioInvitationModelAnnotation(BaseModelAnnotation): 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] + action_flag=ADDITION, + ) + .order_by("action_time") + .values("user__email")[:1] ), Value("Unknown"), - output_field=CharField() + output_field=CharField(), ), } diff --git a/src/registrar/views/portfolio_members_json.py b/src/registrar/views/portfolio_members_json.py index 2fe0492d6..ebe537247 100644 --- a/src/registrar/views/portfolio_members_json.py +++ b/src/registrar/views/portfolio_members_json.py @@ -9,7 +9,10 @@ from django.views import View from registrar.models import UserPortfolioPermission from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices -from registrar.utility.model_annotations import PortfolioInvitationModelAnnotation, UserPortfolioPermissionModelAnnotation +from registrar.utility.model_annotations import ( + PortfolioInvitationModelAnnotation, + UserPortfolioPermissionModelAnnotation, +) from registrar.views.utility.mixins import PortfolioMembersPermission diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py index 7839d209e..2ad36b71e 100644 --- a/src/registrar/views/portfolios.py +++ b/src/registrar/views/portfolios.py @@ -386,6 +386,21 @@ class PortfolioMembersView(PortfolioMembersPermissionView, View): def get(self, request): """Add additional context data to the template.""" return render(request, "portfolio_members.html") + + def get_context_data(self, **kwargs): + """Add additional context data to the template.""" + + context = super().get_context_data(**kwargs) + portfolio = self.request.session.get("portfolio") + user_count = portfolio.portfolio_users.count() + invitation_count = PortfolioInvitation.objects.filter( + portfolio=portfolio + ).count() + context["member_count"] = user_count + invitation_count + + # check if any portfolio invitations exist 4 portfolio + # check if any userportfolioroles exist 4 portfolio + return context class NewMemberView(PortfolioMembersPermissionView, FormMixin):