diff --git a/.github/workflows/deploy-manual.yaml b/.github/workflows/deploy-manual.yaml index ba85342b0..a85cc7565 100644 --- a/.github/workflows/deploy-manual.yaml +++ b/.github/workflows/deploy-manual.yaml @@ -14,6 +14,7 @@ on: options: - ab - backup + - el - cb - dk - es diff --git a/.github/workflows/deploy-sandbox.yaml b/.github/workflows/deploy-sandbox.yaml index fe0a19089..e9eb06627 100644 --- a/.github/workflows/deploy-sandbox.yaml +++ b/.github/workflows/deploy-sandbox.yaml @@ -30,6 +30,7 @@ jobs: || startsWith(github.head_ref, 'ag/') || startsWith(github.head_ref, 'ms/') || startsWith(github.head_ref, 'ad/') + || startsWith(github.head_ref, 'el/') outputs: environment: ${{ steps.var.outputs.environment}} runs-on: "ubuntu-latest" diff --git a/.github/workflows/migrate.yaml b/.github/workflows/migrate.yaml index 70ff8ee95..1853b3c4f 100644 --- a/.github/workflows/migrate.yaml +++ b/.github/workflows/migrate.yaml @@ -16,6 +16,7 @@ on: - stable - staging - development + - el - ad - ms - ag diff --git a/.github/workflows/reset-db.yaml b/.github/workflows/reset-db.yaml index b6fa0fec5..111555b3c 100644 --- a/.github/workflows/reset-db.yaml +++ b/.github/workflows/reset-db.yaml @@ -16,6 +16,7 @@ on: options: - staging - development + - el - ad - ms - ag diff --git a/docs/developer/README.md b/docs/developer/README.md index 9ddb35352..0fa9d9a8c 100644 --- a/docs/developer/README.md +++ b/docs/developer/README.md @@ -173,7 +173,7 @@ You can change the logging verbosity, if needed. Do a web search for "django log ## Mock data -[load.py](../../src/registrar/management/commands/load.py) called from docker-compose (locally) and reset-db.yml (upper) loads the fixtures from [fixtures_user.py](../../src/registrar/fixtures_users.py) and [fixtures_domain_requests.py](../../src/registrar/fixtures_domain_requests.py), giving you some test data to play with while developing. +[load.py](../../src/registrar/management/commands/load.py) called from docker-compose (locally) and reset-db.yml (upper) loads the fixtures from [fixtures_user.py](../../src/registrar/fixtures/fixtures_users.py) and the rest of the data-loading fixtures in that fixtures folder, giving you some test data to play with while developing. See the [database-access README](./database-access.md) for information on how to pull data to update these fixtures. diff --git a/docs/operations/data_migration.md b/docs/operations/data_migration.md index 5e1aa688a..a234d882b 100644 --- a/docs/operations/data_migration.md +++ b/docs/operations/data_migration.md @@ -754,7 +754,7 @@ Example: `cf ssh getgov-za` | 1 | **emailTo** | Specifies where the email will be emailed. Defaults to help@get.gov on production. | ## Populate federal agency initials and FCEB -This script adds to the "is_fceb" and "initials" fields on the FederalAgency model. This script expects a CSV of federal CIOs to pull from, which can be sourced from [here](https://docs.google.com/spreadsheets/d/14oXHFpKyUXS5_mDWARPusghGdHCrP67jCleOknaSx38/edit?gid=479328070#gid=479328070). +This script adds to the "is_fceb" and "acronym" fields on the FederalAgency model. This script expects a CSV of federal CIOs to pull from, which can be sourced from [here](https://docs.google.com/spreadsheets/d/14oXHFpKyUXS5_mDWARPusghGdHCrP67jCleOknaSx38/edit?gid=479328070#gid=479328070). ### Running on sandboxes diff --git a/docs/operations/import_export.md b/docs/operations/import_export.md index b7fd50d52..224fe9a5f 100644 --- a/docs/operations/import_export.md +++ b/docs/operations/import_export.md @@ -9,17 +9,16 @@ Simple scripts are provided as detailed below. ### Export To export from the source environment, run the following command from src directory: -manage.py export_tables Connect to the source sandbox and run the command: -cf ssh {source-app} -/tmp/lifecycle/shell -./manage.py export_tables +`cf ssh {source-app}` +`/tmp/lifecycle/shell` +`./manage.py export_tables` example exporting from getgov-stable: -cf ssh getgov-stable -/tmp/lifecycle/shell -./manage.py export_tables +`cf ssh getgov-stable` +`/tmp/lifecycle/shell` +`./manage.py export_tables` This exports a file, exported_tables.zip, to the tmp directory @@ -42,14 +41,16 @@ After exporting the file from the target environment, scp the exported_tables.zi file from the target environment to local. Run the below commands from local. Get passcode by running: -cf ssh-code +`cf ssh-code` scp file from source app to local file: -scp -P 2222 -o User=cf:$(cf curl /v3/apps/$(cf app {source-app} --guid)/processes | jq -r '.resources[] | select(.type=="web") | .guid')/0 ssh.fr.cloud.gov:app/tmp/exported_tables.zip {local_file_path} +`scp -P 2222 -o User=cf:$(cf curl /v3/apps/$(cf app {source-app} --guid)/processes | jq -r '.resources[] | select(.type=="web") | .guid')/0 ssh.fr.cloud.gov:app/tmp/exported_tables.zip {local_file_path}` when prompted, supply the passcode retrieved in the 'cf ssh-code' command example copying from stable to local cwd: -scp -P 2222 -o User=cf:$(cf curl /v3/apps/$(cf app getgov-stable --guid)/processes | jq -r '.resources[] | select(.type=="web") | .guid')/0 ssh.fr.cloud.gov:app/tmp/exported_tables.zip . +`scp -P 2222 -o User=cf:$(cf curl /v3/apps/$(cf app getgov-stable --guid)/processes | jq -r '.resources[] | select(.type=="web") | .guid')/0 ssh.fr.cloud.gov:app/tmp/exported_tables.zip .` + +`scp -P 2222 -o User=cf:$(cf curl /v3/apps/$(cf app getgov-stable --guid)/processes | jq -r '.resources[] | select(.type=="web") | .guid')/0 ssh.fr.cloud.gov:app/tmp/exported_tables.zip .` ### Import @@ -63,14 +64,14 @@ that there are no database conflicts on import. In order to delete all rows from the appropriate tables, run the following command: -cf ssh {target-app} -/tmp/lifecycle/shell -./manage.py clean_tables +`cf ssh {target-app}` +`/tmp/lifecycle/shell` +`./manage.py clean_tables` example cleaning getgov-backup: -cf ssh getgov-backup -/tmp/lifecycle/backup -./manage.py clean_tables +`cf ssh getgov-backup` +`/tmp/lifecycle/shell` +`./manage.py clean_tables` For reference, this deletes all rows from the following tables: @@ -96,28 +97,30 @@ with --skipEppSave option set to False. If you set to False, it will attempt to records to the registry on load. If this is unset, or set to True, it will load the database and not attempt to update the registry on load. +Please note that there is currently a bug (missing batch importing, see #2862) so this may not work +smoothly right now currently. + To scp the exported_tables.zip file from local to the sandbox, run the following: Get passcode by running: -cf ssh-code +`cf ssh-code` scp file from local to target app: -scp -P 2222 -o User=cf:$(cf curl /v3/apps/$(cf app {target-app} --guid)/processes | jq -r '.resources[] | select(.type=="web") | .guid')/0 {local_file_path} ssh.fr.cloud.gov:app/tmp/exported_tables.zip +`scp -P 2222 -o User=cf:$(cf curl /v3/apps/$(cf app {target-app} --guid)/processes | jq -r '.resources[] | select(.type=="web") | .guid')/0 {local_file_path} ssh.fr.cloud.gov:app/tmp/exported_tables.zip` when prompted, supply the passcode retrieved in the 'cf ssh-code' command example copy of local file in tmp to getgov-backup: -scp -P 2222 -o User=cf:$(cf curl /v3/apps/$(cf app getgov-backup --guid)/processes | jq -r '.resources[] | select(.type=="web") | .guid')/0 tmp/exported_tables.zip ssh.fr.cloud.gov:app/tmp/exported_tables.zip - +`scp -P 2222 -o User=cf:$(cf curl /v3/apps/$(cf app getgov-backup --guid)/processes | jq -r '.resources[] | select(.type=="web") | .guid')/0 exported_tables.zip ssh.fr.cloud.gov:app/tmp/exported_tables.zip` Then connect to a shell in the target environment, and run the following import command: -cf ssh {target-app} -/tmp/lifecycle/shell -./manage.py import_tables +`cf ssh {target-app}` +`/tmp/lifecycle/shell` +`./manage.py import_tables` example cleaning getgov-backup: -cf ssh getgov-backup -/tmp/lifecycle/backup -./manage.py import_tables --no-skipEppSave +`cf ssh getgov-backup` +`/tmp/lifecycle/shell` +`./manage.py import_tables --no-skipEppSave` For reference, this imports tables in the following order: diff --git a/ops/manifests/manifest-el.yaml b/ops/manifests/manifest-el.yaml new file mode 100644 index 000000000..4c7d4d4e4 --- /dev/null +++ b/ops/manifests/manifest-el.yaml @@ -0,0 +1,32 @@ +--- +applications: +- name: getgov-el + buildpacks: + - python_buildpack + path: ../../src + instances: 1 + memory: 512M + stack: cflinuxfs4 + timeout: 180 + command: ./run.sh + health-check-type: http + health-check-http-endpoint: /health + health-check-invocation-timeout: 40 + env: + # Send stdout and stderr straight to the terminal without buffering + PYTHONUNBUFFERED: yup + # Tell Django where to find its configuration + DJANGO_SETTINGS_MODULE: registrar.config.settings + # Tell Django where it is being hosted + DJANGO_BASE_URL: https://getgov-el.app.cloud.gov + # Tell Django how much stuff to log + DJANGO_LOG_LEVEL: INFO + # default public site location + GETGOV_PUBLIC_SITE_URL: https://get.gov + # Flag to disable/enable features in prod environments + IS_PRODUCTION: False + routes: + - route: getgov-el.app.cloud.gov + services: + - getgov-credentials + - getgov-el-database diff --git a/src/registrar/admin.py b/src/registrar/admin.py index efb832f6d..0f9714113 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -9,9 +9,7 @@ from registrar.utility.admin_helpers import get_action_needed_reason_default_ema from django.conf import settings from django.shortcuts import redirect from django_fsm import get_available_FIELD_transitions, FSMField -from registrar.models.domain_information import DomainInformation -from registrar.models.domain_invitation import DomainInvitation -from registrar.models.user_portfolio_permission import UserPortfolioPermission +from registrar.models import DomainInformation, Portfolio, UserPortfolioPermission, DomainInvitation from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from waffle.decorators import flag_is_active from django.contrib import admin, messages @@ -23,6 +21,11 @@ from epplibwrapper.errors import ErrorCode, RegistryError from registrar.models.user_domain_role import UserDomainRole from waffle.admin import FlagAdmin from waffle.models import Sample, Switch +from registrar.utility.admin_helpers import ( + get_all_action_needed_reason_emails, + get_action_needed_reason_default_email, + get_field_links_as_list, +) from registrar.models import Contact, Domain, DomainRequest, DraftDomain, User, Website, SeniorOfficial from registrar.utility.constants import BranchChoices from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes @@ -758,9 +761,10 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin): }, ), ("Important dates", {"fields": ("last_login", "date_joined")}), + ("Associated portfolios", {"fields": ("portfolios",)}), ) - readonly_fields = ("verification_type",) + readonly_fields = ("verification_type", "portfolios") analyst_fieldsets = ( ( @@ -783,6 +787,7 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin): }, ), ("Important dates", {"fields": ("last_login", "date_joined")}), + ("Associated portfolios", {"fields": ("portfolios",)}), ) # TODO: delete after we merge organization feature @@ -862,6 +867,14 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin): ordering = ["first_name", "last_name", "email"] search_help_text = "Search by first name, last name, or email." + def portfolios(self, obj: models.User): + """Returns a list of links for each related suborg""" + portfolio_ids = obj.get_portfolios().values_list("portfolio", flat=True) + queryset = models.Portfolio.objects.filter(id__in=portfolio_ids) + return get_field_links_as_list(queryset, "portfolio", msg_for_none="No portfolios.") + + portfolios.short_description = "Portfolios" # type: ignore + def get_search_results(self, request, queryset, search_term): """ Override for get_search_results. This affects any upstream model using autocomplete_fields, @@ -1258,9 +1271,18 @@ class UserPortfolioPermissionAdmin(ListHeaderAdmin): list_display = [ "user", "portfolio", + "get_roles", ] 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." + + def get_roles(self, obj): + readable_roles = obj.get_readable_roles() + return ", ".join(readable_roles) + + get_roles.short_description = "Roles" # type: ignore class UserDomainRoleAdmin(ListHeaderAdmin, ImportExportModelAdmin): @@ -1544,33 +1566,6 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin): change_form_template = "django/admin/domain_information_change_form.html" - superuser_only_fields = [ - "portfolio", - "sub_organization", - ] - - # DEVELOPER's NOTE: - # Normally, to exclude a field from an Admin form, we could simply utilize - # Django's "exclude" feature. However, it causes a "missing key" error if we - # go that route for this particular form. The error gets thrown by our - # custom fieldset.html code and is due to the fact that "exclude" removes - # fields from base_fields but not fieldsets. Rather than reworking our - # custom frontend, it seems more straightforward (and easier to read) to simply - # modify the fieldsets list so that it excludes any fields we want to remove - # based on permissions (eg. superuser_only_fields) or other conditions. - def get_fieldsets(self, request, obj=None): - fieldsets = self.fieldsets - - # Create a modified version of fieldsets to exclude certain fields - if not request.user.has_perm("registrar.full_access_permission"): - modified_fieldsets = [] - for name, data in fieldsets: - fields = data.get("fields", []) - fields = [field for field in fields if field not in DomainInformationAdmin.superuser_only_fields] - modified_fieldsets.append((name, {**data, "fields": fields})) - return modified_fieldsets - return fieldsets - def get_readonly_fields(self, request, obj=None): """Set the read-only state on form elements. We have 1 conditions that determine which fields are read-only: @@ -1867,33 +1862,6 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): ] filter_horizontal = ("current_websites", "alternative_domains", "other_contacts") - superuser_only_fields = [ - "portfolio", - "sub_organization", - ] - - # DEVELOPER's NOTE: - # Normally, to exclude a field from an Admin form, we could simply utilize - # Django's "exclude" feature. However, it causes a "missing key" error if we - # go that route for this particular form. The error gets thrown by our - # custom fieldset.html code and is due to the fact that "exclude" removes - # fields from base_fields but not fieldsets. Rather than reworking our - # custom frontend, it seems more straightforward (and easier to read) to simply - # modify the fieldsets list so that it excludes any fields we want to remove - # based on permissions (eg. superuser_only_fields) or other conditions. - def get_fieldsets(self, request, obj=None): - fieldsets = super().get_fieldsets(request, obj) - - # Create a modified version of fieldsets to exclude certain fields - if not request.user.has_perm("registrar.full_access_permission"): - modified_fieldsets = [] - for name, data in fieldsets: - fields = data.get("fields", []) - fields = tuple(field for field in fields if field not in self.superuser_only_fields) - modified_fieldsets.append((name, {**data, "fields": fields})) - return modified_fieldsets - return fieldsets - # Table ordering # NOTE: This impacts the select2 dropdowns (combobox) # Currentl, there's only one for requests on DomainInfo @@ -3003,39 +2971,59 @@ class VerifiedByStaffAdmin(ListHeaderAdmin): class PortfolioAdmin(ListHeaderAdmin): + + class Meta: + """Contains meta information about this class""" + + model = models.Portfolio + fields = "__all__" + + _meta = Meta() + change_form_template = "django/admin/portfolio_change_form.html" fieldsets = [ - # created_on is the created_at field, and portfolio_type is f"{organization_type} - {federal_type}" - (None, {"fields": ["portfolio_type", "organization_name", "creator", "created_on", "notes"]}), - ("Portfolio members", {"fields": ["display_admins", "display_members"]}), - ("Portfolio domains", {"fields": ["domains", "domain_requests"]}), + # created_on is the created_at field + (None, {"fields": ["creator", "created_on", "notes"]}), ("Type of organization", {"fields": ["organization_type", "federal_type"]}), ( "Organization name and mailing address", { "fields": [ + "organization_name", "federal_agency", + ] + }, + ), + ( + "Show details", + { + "classes": ["collapse--dgfieldset"], + "description": "Extends organization name and mailing address", + "fields": [ "state_territory", "address_line1", "address_line2", "city", "zipcode", "urbanization", - ] + ], }, ), + ("Portfolio members", {"fields": ["display_admins", "display_members"]}), + ("Domains and requests", {"fields": ["domains", "domain_requests"]}), ("Suborganizations", {"fields": ["suborganizations"]}), ("Senior official", {"fields": ["senior_official"]}), ] # This is the fieldset display when adding a new model add_fieldsets = [ - (None, {"fields": ["organization_name", "creator", "notes"]}), + (None, {"fields": ["creator", "notes"]}), ("Type of organization", {"fields": ["organization_type"]}), ( "Organization name and mailing address", { "fields": [ + "organization_name", "federal_agency", "state_territory", "address_line1", @@ -3049,7 +3037,7 @@ class PortfolioAdmin(ListHeaderAdmin): ("Senior official", {"fields": ["senior_official"]}), ] - list_display = ("organization_name", "federal_agency", "creator") + list_display = ("organization_name", "organization_type", "federal_type", "creator") search_fields = ["organization_name"] search_help_text = "Search by organization name." readonly_fields = [ @@ -3062,23 +3050,35 @@ class PortfolioAdmin(ListHeaderAdmin): "domains", "domain_requests", "suborganizations", - "portfolio_type", "display_admins", "display_members", "creator", + # As of now this means that only federal agency can update this, but this will change. + "senior_official", + ] + + analyst_readonly_fields = [ + "organization_name", ] def get_admin_users(self, obj): # Filter UserPortfolioPermission objects related to the portfolio - admin_permissions = UserPortfolioPermission.objects.filter( - portfolio=obj, roles__contains=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] - ) + admin_permissions = self.get_user_portfolio_permission_admins(obj) # Get the user objects associated with these permissions admin_users = User.objects.filter(portfolio_permissions__in=admin_permissions) return admin_users + def get_user_portfolio_permission_admins(self, obj): + """Returns each admin on UserPortfolioPermission for a given portfolio.""" + if obj: + return obj.portfolio_users.filter( + portfolio=obj, roles__contains=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + ) + else: + return [] + def get_non_admin_users(self, obj): # Filter UserPortfolioPermission objects related to the portfolio that do NOT have the "Admin" role non_admin_permissions = UserPortfolioPermission.objects.filter(portfolio=obj).exclude( @@ -3090,82 +3090,12 @@ class PortfolioAdmin(ListHeaderAdmin): return non_admin_users - def display_admins(self, obj): - """Get joined users who are Admin, unpack and return an HTML block. - - 'DJA readonly can't handle querysets, so we need to unpack and return html here. - Alternatively, we could return querysets in context but that would limit where this - data would display in a custom change form without extensive template customization. - - Will be used in the field_readonly block""" - admins = self.get_admin_users(obj) - if not admins: - return format_html("
No admins found.
") - - admin_details = "" - for portfolio_admin in admins: - change_url = reverse("admin:registrar_user_change", args=[portfolio_admin.pk]) - admin_details += "" - admin_details += f'{escape(portfolio_admin)}Name | Title | Phone | Roles | |
---|---|---|---|---|
{escape(full_name)} | " - member_details += f"{escape(member.title)} | " - member_details += f"{escape(member.email)} | " - member_details += f"{escape(member.phone)} | " - member_details += "" - for role in member.portfolio_role_summary(obj): - member_details += f"{escape(role)} " - member_details += " |
Name | +Title | +Phone | +||
---|---|---|---|---|
{{ admin.user.get_formatted_name}} | +{{ admin.user.title }} | ++ {% if admin.user.email %} + {{ admin.user.email }} + {% else %} + None + {% endif %} + | +{{ admin.user.phone }} | ++ {% if admin.user.email %} + + + {% endif %} + | +
Name | +Status | +|
---|---|---|
{{ domain_request }} | + {% if domain_request.get_status_display %} +{{ domain_request.get_status_display }} | + {% else %} +None | + {% endif %} +
Name | +State | +|
---|---|---|
{{ domain }} | + {% if domain and domain.get_state_display %} +{{ domain.get_state_display }} | + {% else %} +None | + {% endif %} +
No roles found.
+ {% endif %} +No additional permissions found.
+ {% endif %} +Name | +Title | +Phone | +Roles | +||
---|---|---|---|---|---|
{{ member.user.get_formatted_name}} | +{{ member.user.title }} | ++ {% if member.user.email %} + {{ member.user.email }} + {% else %} + None + {% endif %} + | +{{ member.user.phone }} | ++ {% for role in member.user|portfolio_role_summary:original %} + {{ role }} + {% endfor %} + | ++ {% if member.user.email %} + + + {% endif %} + | +
Member | +Last Active | ++ Action + | +
---|
You don't have any members.
+No results found
+