Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#2737: Members csv report - [MEOWARD] #3083

Merged
merged 51 commits into from
Nov 26, 2024
Merged
Show file tree
Hide file tree
Changes from 36 commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
b0dc18c
base
zandercymatics Nov 12, 2024
2265b70
Refactor part 1
zandercymatics Nov 13, 2024
48ae7f4
Refactor part 2
zandercymatics Nov 13, 2024
1e43974
view
zandercymatics Nov 14, 2024
9ac8a3e
Add some data to report
zandercymatics Nov 14, 2024
10c0187
Add some additional data + migration
zandercymatics Nov 14, 2024
b7f3f08
Cleanup
zandercymatics Nov 14, 2024
174d217
add info about roles / perms
zandercymatics Nov 14, 2024
bf585be
add domain management portion
zandercymatics Nov 14, 2024
0ca0602
Update portfolio_invitation.py
zandercymatics Nov 14, 2024
78e0316
Delete 0137_userportfoliopermission_invitation.py
zandercymatics Nov 14, 2024
854df71
Merge branch 'main' into za/2737-members-csv-report
zandercymatics Nov 14, 2024
17acb66
readd migration
zandercymatics Nov 14, 2024
ae6ecab
Update csv_export.py
zandercymatics Nov 14, 2024
e915937
Update csv_export.py
zandercymatics Nov 15, 2024
9374d91
Fix failing tests (hopefully)
zandercymatics Nov 15, 2024
7075b72
lint + add invitation script
zandercymatics Nov 15, 2024
3b378d3
Cleanup
zandercymatics Nov 18, 2024
1c0f99e
Use audit log instead
zandercymatics Nov 18, 2024
5f5bc4f
lint
zandercymatics Nov 18, 2024
a236583
Minor cleanup (fix comments)
zandercymatics Nov 18, 2024
c98a96e
Test skeleton
zandercymatics Nov 19, 2024
f3e1826
Main unit test
zandercymatics Nov 19, 2024
121d218
lint
zandercymatics Nov 19, 2024
dc12efe
Update src/registrar/views/report_views.py
zandercymatics Nov 19, 2024
384a967
test to fix broken build
abroddrick Nov 19, 2024
b679108
removed test build line
abroddrick Nov 19, 2024
252fa2e
lint changes
zandercymatics Nov 19, 2024
43bc969
Merge branch 'main' into za/2737-members-csv-report
zandercymatics Nov 20, 2024
20c0813
Merge branch 'main' into za/2737-members-csv-report
zandercymatics Nov 20, 2024
908e06c
Merge conflict
zandercymatics Nov 20, 2024
b791daf
Guard report behind perms check
zandercymatics Nov 20, 2024
4ab526c
Lint!
zandercymatics Nov 20, 2024
981cdb3
Replace invitation date => joined date
zandercymatics Nov 20, 2024
3f8db08
Code cleanup
zandercymatics Nov 20, 2024
4eb5431
Update src/registrar/utility/csv_export.py
zandercymatics Nov 21, 2024
859e8b3
remove model annotations file
zandercymatics Nov 25, 2024
e813791
Update csv_export.py
zandercymatics Nov 25, 2024
e2bd982
Update portfolio_members_json.py
zandercymatics Nov 25, 2024
4013815
Update csv_export.py
zandercymatics Nov 25, 2024
958e010
Update csv_export.py
zandercymatics Nov 25, 2024
2b5a5fe
undo some past changes
zandercymatics Nov 25, 2024
d354f04
Update src/registrar/utility/csv_export.py
zandercymatics Nov 26, 2024
a7fd3b9
Merge branch 'main' into za/2737-members-csv-report
zandercymatics Nov 26, 2024
a77eb59
Merge branch 'za/2737-members-csv-report' of github.com:cisagov/manag…
zandercymatics Nov 26, 2024
2f93ad9
enums and the like
zandercymatics Nov 26, 2024
f05148f
Merge branch 'za/2737-take-two-members-csv-export' into za/2737-membe…
zandercymatics Nov 26, 2024
e91526e
cleanup
zandercymatics Nov 26, 2024
62f0205
Fix merge conflict + enum
zandercymatics Nov 26, 2024
c4a2748
more cleanup
zandercymatics Nov 26, 2024
d0f0d56
change enum
zandercymatics Nov 26, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/registrar/config/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
ExportDomainRequestDataFull,
ExportDataTypeUser,
ExportDataTypeRequests,
ExportMembersPortfolio,
)

# --jsons
Expand Down Expand Up @@ -239,6 +240,11 @@
name="get-rejection-email-for-user-json",
),
path("admin/", admin.site.urls),
path(
"reports/export_members_portfolio/",
ExportMembersPortfolio.as_view(),
name="export_members_portfolio",
),
path(
"reports/export_data_type_user/",
ExportDataTypeUser.as_view(),
Expand Down
30 changes: 30 additions & 0 deletions src/registrar/models/user_portfolio_permission.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,36 @@ def get_portfolio_permissions(cls, roles, 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):
return "Viewer Requester"
elif UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS in all_permissions:
return "Viewer"
else:
return "None"

@classmethod
def get_member_permission_display(cls, roles, additional_permissions):
"""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
# the way perms work for members is different than requests. We need to consolidate this.
if UserPortfolioPermissionChoices.EDIT_MEMBERS in all_permissions:
zandercymatics marked this conversation as resolved.
Show resolved Hide resolved
return "Manager"
elif UserPortfolioPermissionChoices.VIEW_MEMBERS in all_permissions:
return "Viewer"
else:
return "None"
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Q) Is this style of return readable? The else isn't needed here, but I was wondering if this makes it easier to parse

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks readable to me. Is there an instance where the PortfolioPermissions would be "None". I don't see it option listed, but its good to prevent errors?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comes from the AC: Whether the member can manage other members in the organization. Tentatively, I think the options as we currently understand would be : None, Viewer, Manager

We don't have a enum value of "None", its just a catch-all label for neither viewer nor manager


def clean(self):
"""Extends clean method to perform additional validation, which can raise errors in django admin."""
super().clean()
Expand Down
8 changes: 8 additions & 0 deletions src/registrar/models/utility/orm_helper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from django.db.models.expressions import Func


class ArrayRemoveNull(Func):
"""Custom Func to use array_remove to remove null values"""

function = "array_remove"
template = "%(function)s(%(expressions)s, NULL)"
11 changes: 10 additions & 1 deletion src/registrar/templates/includes/members_table.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<section class="section-outlined members margin-top-0 section-outlined--border-base-light" id="members">
<div class="section-outlined__header margin-bottom-3 grid-row">
<!-- ---------- SEARCH ---------- -->
<div class="section-outlined__search mobile:grid-col-12 desktop:grid-col-6">
<div class="section-outlined__search mobile:grid-col-12 desktop:grid-col-6 {% if is_widescreen_mode %} section-outlined__search--widescreen {% endif %}">
<section aria-label="Members search component" class="margin-top-2">
<form class="usa-search usa-search--small" method="POST" role="search">
{% csrf_token %}
Expand Down Expand Up @@ -36,6 +36,15 @@
</form>
</section>
</div>
<div class="section-outlined__utility-button mobile-lg:padding-right-105 {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6 desktop:padding-left-3{% endif %}">
<section aria-label="Domains report component" class="margin-top-205">
<a href="{% url 'export_members_portfolio' %}" class="usa-button usa-button--unstyled usa-button--with-icon usa-button--justify-right" role="button">
<svg class="usa-icon usa-icon--big" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
</svg>Export as CSV
</a>
</section>
</div>
</div>

<!-- ---------- MAIN TABLE ---------- -->
Expand Down
172 changes: 142 additions & 30 deletions src/registrar/tests/common.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import os
import logging

from contextlib import contextmanager
import random
from string import ascii_uppercase
Expand Down Expand Up @@ -29,6 +28,7 @@
FederalAgency,
UserPortfolioPermission,
Portfolio,
PortfolioInvitation,
)
from epplibwrapper import (
commands,
Expand All @@ -39,6 +39,7 @@
ErrorCode,
responses,
)
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from registrar.models.user_domain_role import UserDomainRole

from registrar.models.utility.contact_error import ContactError, ContactErrorCodes
Expand Down Expand Up @@ -196,6 +197,7 @@ def assert_table_sorted(self, o_index, sort_fields):

self.assertEqual(expected_sort_order, returned_sort_order)

@classmethod
def _mock_user_request_for_factory(self, request):
"""Adds sessionmiddleware when using factory to associate session information"""
middleware = SessionMiddleware(lambda req: req)
Expand Down Expand Up @@ -531,6 +533,8 @@ class MockDb(TestCase):
@classmethod
@less_console_noise_decorator
def sharedSetUp(cls):
cls.mock_client_class = MagicMock()
cls.mock_client = cls.mock_client_class.return_value
username = "test_user"
first_name = "First"
last_name = "Last"
Expand All @@ -540,16 +544,36 @@ def sharedSetUp(cls):
cls.user = get_user_model().objects.create(
username=username, first_name=first_name, last_name=last_name, email=email, title=title, phone=phone
)
cls.meoward_user = get_user_model().objects.create(
username="meoward_username", first_name="first_meoward", last_name="last_meoward", email="[email protected]"
)
cls.lebowski_user = get_user_model().objects.create(
username="big_lebowski", first_name="big", last_name="lebowski", email="[email protected]"
)
cls.tired_user = get_user_model().objects.create(
username="ministry_of_bedtime", first_name="tired", last_name="sleepy", email="[email protected]"
zandercymatics marked this conversation as resolved.
Show resolved Hide resolved
)
# Custom superuser and staff so that these do not conflict with what may be defined on what implements this.
cls.custom_superuser = create_superuser(
username="cold_superuser", first_name="cold", last_name="icy", email="[email protected]"
)
cls.custom_staffuser = create_user(
username="warm_staff", first_name="warm", last_name="cozy", email="[email protected]"
)

cls.federal_agency_1, _ = FederalAgency.objects.get_or_create(agency="World War I Centennial Commission")
cls.federal_agency_2, _ = FederalAgency.objects.get_or_create(agency="Armed Forces Retirement Home")

cls.portfolio_1, _ = Portfolio.objects.get_or_create(
creator=cls.custom_superuser, federal_agency=cls.federal_agency_1
)

current_date = get_time_aware_date(datetime(2024, 4, 2))
# Create start and end dates using timedelta

cls.end_date = current_date + timedelta(days=2)
cls.start_date = current_date - timedelta(days=2)

cls.federal_agency_1, _ = FederalAgency.objects.get_or_create(agency="World War I Centennial Commission")
cls.federal_agency_2, _ = FederalAgency.objects.get_or_create(agency="Armed Forces Retirement Home")

cls.domain_1, _ = Domain.objects.get_or_create(
name="cdomain1.gov", state=Domain.State.READY, first_ready=get_time_aware_date(datetime(2024, 4, 2))
)
Expand Down Expand Up @@ -596,9 +620,14 @@ def sharedSetUp(cls):
federal_agency=cls.federal_agency_1,
federal_type="executive",
is_election_board=False,
portfolio=cls.portfolio_1,
)
cls.domain_information_2, _ = DomainInformation.objects.get_or_create(
creator=cls.user, domain=cls.domain_2, generic_org_type="interstate", is_election_board=True
creator=cls.user,
domain=cls.domain_2,
generic_org_type="interstate",
is_election_board=True,
portfolio=cls.portfolio_1,
)
cls.domain_information_3, _ = DomainInformation.objects.get_or_create(
creator=cls.user,
Expand Down Expand Up @@ -671,14 +700,6 @@ def sharedSetUp(cls):
is_election_board=False,
)

cls.meoward_user = get_user_model().objects.create(
username="meoward_username", first_name="first_meoward", last_name="last_meoward", email="[email protected]"
)

cls.lebowski_user = get_user_model().objects.create(
username="big_lebowski", first_name="big", last_name="lebowski", email="[email protected]"
)

_, created = UserDomainRole.objects.get_or_create(
user=cls.meoward_user, domain=cls.domain_1, role=UserDomainRole.Roles.MANAGER
)
Expand Down Expand Up @@ -709,6 +730,12 @@ def sharedSetUp(cls):
status=DomainInvitation.DomainInvitationStatus.RETRIEVED,
)

_, created = DomainInvitation.objects.get_or_create(
email=cls.meoward_user.email,
domain=cls.domain_11,
status=DomainInvitation.DomainInvitationStatus.RETRIEVED,
)

_, created = DomainInvitation.objects.get_or_create(
email="[email protected]",
domain=cls.domain_1,
Expand All @@ -723,6 +750,85 @@ def sharedSetUp(cls):
email="[email protected]", domain=cls.domain_10, status=DomainInvitation.DomainInvitationStatus.INVITED
)

cls.portfolio_invitation_1, _ = PortfolioInvitation.objects.get_or_create(
email=cls.meoward_user.email,
portfolio=cls.portfolio_1,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
additional_permissions=[UserPortfolioPermissionChoices.EDIT_MEMBERS],
)

cls.portfolio_invitation_2, _ = PortfolioInvitation.objects.get_or_create(
email=cls.lebowski_user.email,
portfolio=cls.portfolio_1,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
additional_permissions=[UserPortfolioPermissionChoices.VIEW_MEMBERS],
)

cls.portfolio_invitation_3, _ = PortfolioInvitation.objects.get_or_create(
email=cls.tired_user.email,
portfolio=cls.portfolio_1,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
additional_permissions=[UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS],
)

cls.portfolio_invitation_4, _ = PortfolioInvitation.objects.get_or_create(
email=cls.custom_superuser.email,
portfolio=cls.portfolio_1,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_MEMBERS,
UserPortfolioPermissionChoices.EDIT_MEMBERS,
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
UserPortfolioPermissionChoices.EDIT_REQUESTS,
],
)

cls.portfolio_invitation_5, _ = PortfolioInvitation.objects.get_or_create(
email=cls.custom_staffuser.email,
portfolio=cls.portfolio_1,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
)

# Add some invitations that we never retireve
PortfolioInvitation.objects.get_or_create(
email="[email protected]",
portfolio=cls.portfolio_1,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
additional_permissions=[UserPortfolioPermissionChoices.EDIT_MEMBERS],
)

PortfolioInvitation.objects.get_or_create(
email="[email protected]",
portfolio=cls.portfolio_1,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
additional_permissions=[UserPortfolioPermissionChoices.VIEW_MEMBERS],
)

PortfolioInvitation.objects.get_or_create(
email="[email protected]",
portfolio=cls.portfolio_1,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
additional_permissions=[UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS],
)

PortfolioInvitation.objects.get_or_create(
email="[email protected]",
portfolio=cls.portfolio_1,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_MEMBERS,
UserPortfolioPermissionChoices.EDIT_MEMBERS,
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
UserPortfolioPermissionChoices.EDIT_REQUESTS,
],
)

PortfolioInvitation.objects.get_or_create(
email="[email protected]",
portfolio=cls.portfolio_1,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
)

with less_console_noise():
cls.domain_request_1 = completed_domain_request(
status=DomainRequest.DomainRequestStatus.STARTED,
Expand All @@ -731,10 +837,12 @@ def sharedSetUp(cls):
cls.domain_request_2 = completed_domain_request(
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
name="city2.gov",
portfolio=cls.portfolio_1,
)
cls.domain_request_3 = completed_domain_request(
status=DomainRequest.DomainRequestStatus.STARTED,
name="city3.gov",
portfolio=cls.portfolio_1,
)
cls.domain_request_4 = completed_domain_request(
status=DomainRequest.DomainRequestStatus.STARTED,
Expand All @@ -749,6 +857,7 @@ def sharedSetUp(cls):
cls.domain_request_6 = completed_domain_request(
status=DomainRequest.DomainRequestStatus.STARTED,
name="city6.gov",
portfolio=cls.portfolio_1,
)
cls.domain_request_3.submit()
cls.domain_request_4.submit()
Expand Down Expand Up @@ -797,6 +906,7 @@ def sharedTearDown(cls):
UserPortfolioPermission.objects.all().delete()
User.objects.all().delete()
DomainInvitation.objects.all().delete()
PortfolioInvitation.objects.all().delete()
cls.federal_agency_1.delete()
cls.federal_agency_2.delete()

Expand Down Expand Up @@ -837,17 +947,18 @@ def mock_user():
return mock_user


def create_superuser():
def create_superuser(**kwargs):
"""Creates a analyst user with is_staff=True and the group full_access_group"""
User = get_user_model()
p = "adminpass"
user = User.objects.create_user(
username="superuser",
email="[email protected]",
first_name="first",
last_name="last",
is_staff=True,
password=p,
phone="8003111234",
username=kwargs.get("username", "superuser"),
email=kwargs.get("email", "[email protected]"),
first_name=kwargs.get("first_name", "first"),
last_name=kwargs.get("last_name", "last"),
is_staff=kwargs.get("is_staff", True),
password=kwargs.get("password", p),
phone=kwargs.get("phone", "8003111234"),
)
# Retrieve the group or create it if it doesn't exist
group, _ = UserGroup.objects.get_or_create(name="full_access_group")
Expand All @@ -856,18 +967,19 @@ def create_superuser():
return user


def create_user():
def create_user(**kwargs):
"""Creates a analyst user with is_staff=True and the group cisa_analysts_group"""
User = get_user_model()
p = "userpass"
user = User.objects.create_user(
username="staffuser",
email="[email protected]",
first_name="first",
last_name="last",
is_staff=True,
title="title",
password=p,
phone="8003111234",
username=kwargs.get("username", "staffuser"),
email=kwargs.get("email", "[email protected]"),
first_name=kwargs.get("first_name", "first"),
last_name=kwargs.get("last_name", "last"),
is_staff=kwargs.get("is_staff", True),
title=kwargs.get("title", "title"),
password=kwargs.get("password", p),
phone=kwargs.get("phone", "8003111234"),
)
# Retrieve the group or create it if it doesn't exist
group, _ = UserGroup.objects.get_or_create(name="cisa_analysts_group")
Expand Down
Loading
Loading