diff --git a/.secrets.baseline b/.secrets.baseline index bd9bdadc..5e2be19d 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -152,7 +152,7 @@ "hashed_secret": "e4f14805dfd1e6af030359090c535e149e6b4207", "is_secret": false, "is_verified": false, - "line_number": 665, + "line_number": 649, "type": "Hex High Entropy String" } ] diff --git a/Dockerfile b/Dockerfile index 1785b5d8..6f29d300 100644 --- a/Dockerfile +++ b/Dockerfile @@ -73,7 +73,8 @@ RUN apk update && \ postgresql-client \ postgresql-dev \ postgresql-libs \ - uwsgi-logfile + uwsgi-logfile \ + uwsgi-python3 COPY --from=builder /install/.venv/ ./.venv/ COPY --from=builder /install/alembic/ ./alembic/ diff --git a/atst/app.py b/atst/app.py index e9daabd6..d73188cf 100644 --- a/atst/app.py +++ b/atst/app.py @@ -159,6 +159,7 @@ def map_config(config): "ENV": config["default"]["ENVIRONMENT"], "BROKER_URL": config["default"]["REDIS_URI"], "DEBUG": config["default"].getboolean("DEBUG"), + "DEBUG_MAILER": config["default"].getboolean("DEBUG_MAILER"), "SQLALCHEMY_ECHO": config["default"].getboolean("SQLALCHEMY_ECHO"), "PORT": int(config["default"]["PORT"]), "SQLALCHEMY_DATABASE_URI": config["default"]["DATABASE_URI"], @@ -289,7 +290,7 @@ def make_crl_validator(app): def make_mailer(app): - if app.config["DEBUG"]: + if app.config["DEBUG"] or app.config["DEBUG_MAILER"]: mailer_connection = mailer.RedisConnection(app.redis) else: mailer_connection = mailer.SMTPConnection( diff --git a/atst/routes/portfolios/admin.py b/atst/routes/portfolios/admin.py index 699bdfab..4318a47c 100644 --- a/atst/routes/portfolios/admin.py +++ b/atst/routes/portfolios/admin.py @@ -19,9 +19,6 @@ from atst.domain.exceptions import UnauthorizedError def filter_perm_sets_data(member): perm_sets_data = { - "perms_portfolio_mgmt": bool( - member.has_permission_set(PermissionSets.EDIT_PORTFOLIO_ADMIN) - ), "perms_app_mgmt": bool( member.has_permission_set( PermissionSets.EDIT_PORTFOLIO_APPLICATION_MANAGEMENT @@ -33,24 +30,43 @@ def filter_perm_sets_data(member): "perms_reporting": bool( member.has_permission_set(PermissionSets.EDIT_PORTFOLIO_REPORTS) ), + "perms_portfolio_mgmt": bool( + member.has_permission_set(PermissionSets.EDIT_PORTFOLIO_ADMIN) + ), } return perm_sets_data -def filter_members_data(members_list, portfolio): +def filter_members_data(members_list): members_data = [] for member in members_list: - members_data.append( - { - "role_id": member.id, - "user_name": member.user_name, - "permission_sets": filter_perm_sets_data(member), - "status": member.display_status, - "ppoc": PermissionSets.PORTFOLIO_POC in member.permission_sets, - # add in stuff here for forms - } + permission_sets = filter_perm_sets_data(member) + ppoc = ( + PermissionSets.get(PermissionSets.PORTFOLIO_POC) in member.permission_sets ) + member_data = { + "role_id": member.id, + "user_name": member.user_name, + "permission_sets": filter_perm_sets_data(member), + "status": member.display_status, + "ppoc": ppoc, + "form": member_forms.PermissionsForm(permission_sets), + } + + if not ppoc: + member_data["update_invite_form"] = ( + member_forms.NewForm(user_data=member.latest_invitation) + if member.latest_invitation and member.latest_invitation.can_resend + else member_forms.NewForm() + ) + member_data["invite_token"] = ( + member.latest_invitation.token + if member.latest_invitation and member.latest_invitation.can_resend + else None + ) + + members_data.append(member_data) return sorted(members_data, key=lambda member: member["user_name"]) @@ -75,7 +91,7 @@ def render_admin_page(portfolio, form=None): "portfolios/admin.html", form=form, portfolio_form=portfolio_form, - members=filter_members_data(member_list, portfolio), + members=filter_members_data(member_list), new_manager_form=member_forms.NewForm(), assign_ppoc_form=assign_ppoc_form, portfolio=portfolio, @@ -93,26 +109,27 @@ def admin(portfolio_id): return render_admin_page(portfolio) -@portfolios_bp.route("/portfolios//update_ppoc", methods=["POST"]) -@user_can(Permissions.EDIT_PORTFOLIO_POC, message="update portfolio ppoc") -def update_ppoc(portfolio_id): - role_id = http_request.form.get("role_id") - - portfolio = Portfolios.get(g.current_user, portfolio_id) - new_ppoc_role = PortfolioRoles.get_by_id(role_id) - - PortfolioRoles.make_ppoc(portfolio_role=new_ppoc_role) - - flash("primary_point_of_contact_changed", ppoc_name=new_ppoc_role.full_name) - - return redirect( - url_for( - "portfolios.admin", - portfolio_id=portfolio.id, - fragment="primary-point-of-contact", - _anchor="primary-point-of-contact", - ) - ) +# Updating PPoC is a post-MVP feature +# @portfolios_bp.route("/portfolios//update_ppoc", methods=["POST"]) +# @user_can(Permissions.EDIT_PORTFOLIO_POC, message="update portfolio ppoc") +# def update_ppoc(portfolio_id): # pragma: no cover +# role_id = http_request.form.get("role_id") +# +# portfolio = Portfolios.get(g.current_user, portfolio_id) +# new_ppoc_role = PortfolioRoles.get_by_id(role_id) +# +# PortfolioRoles.make_ppoc(portfolio_role=new_ppoc_role) +# +# flash("primary_point_of_contact_changed", ppoc_name=new_ppoc_role.full_name) +# +# return redirect( +# url_for( +# "portfolios.admin", +# portfolio_id=portfolio.id, +# fragment="primary-point-of-contact", +# _anchor="primary-point-of-contact", +# ) +# ) @portfolios_bp.route("/portfolios//edit", methods=["POST"]) @@ -166,3 +183,30 @@ def remove_member(portfolio_id, portfolio_role_id): fragment="portfolio-members", ) ) + + +@portfolios_bp.route( + "/portfolios//members/", methods=["POST"] +) +@user_can(Permissions.EDIT_PORTFOLIO_USERS, message="update portfolio members") +def update_member(portfolio_id, portfolio_role_id): + form_data = http_request.form + form = member_forms.PermissionsForm(formdata=form_data) + portfolio_role = PortfolioRoles.get_by_id(portfolio_role_id) + portfolio = Portfolios.get(user=g.current_user, portfolio_id=portfolio_id) + + if form.validate() and portfolio.owner_role != portfolio_role: + PortfolioRoles.update(portfolio_role, form.data["permission_sets"]) + flash("update_portfolio_member", member_name=portfolio_role.full_name) + + return redirect( + url_for( + "portfolios.admin", + portfolio_id=portfolio_id, + _anchor="portfolio-members", + fragment="portfolio-members", + ) + ) + else: + flash("update_portfolio_member_error", member_name=portfolio_role.full_name) + return (render_admin_page(portfolio), 400) diff --git a/atst/routes/portfolios/invitations.py b/atst/routes/portfolios/invitations.py index 09a22d1f..7c48593f 100644 --- a/atst/routes/portfolios/invitations.py +++ b/atst/routes/portfolios/invitations.py @@ -54,13 +54,22 @@ def revoke_invitation(portfolio_id, portfolio_token): ) @user_can(Permissions.EDIT_PORTFOLIO_USERS, message="resend invitation") def resend_invitation(portfolio_id, portfolio_token): - invite = PortfolioInvitations.resend(g.current_user, portfolio_token) - send_portfolio_invitation( - invitee_email=invite.email, - inviter_name=g.current_user.full_name, - token=invite.token, - ) - flash("resend_portfolio_invitation", user_name=invite.user_name) + form = member_forms.NewForm(http_request.form) + + if form.validate(): + invite = PortfolioInvitations.resend( + g.current_user, portfolio_token, form.data["user_data"] + ) + send_portfolio_invitation( + invitee_email=invite.email, + inviter_name=g.current_user.full_name, + token=invite.token, + ) + flash("resend_portfolio_invitation", user_name=invite.user_name) + else: + user_name = f"{form['user_data']['first_name'].data} {form['user_data']['last_name'].data}" + flash("resend_portfolio_invitation_error", user_name=user_name) + return redirect( url_for( "portfolios.admin", diff --git a/atst/utils/flash.py b/atst/utils/flash.py index da2c9253..7a1cf4ce 100644 --- a/atst/utils/flash.py +++ b/atst/utils/flash.py @@ -128,6 +128,11 @@ MESSAGES = { "message": "flash.portfolio_invite.resent.message", "category": "success", }, + "resend_portfolio_invitation_error": { + "title": "flash.portfolio_invite.error.title", + "message": "flash.portfolio_invite.error.message", + "category": "error", + }, "revoked_portfolio_access": { "title": "flash.portfolio_member.revoked.title", "message": "flash.portfolio_member.revoked.message", @@ -153,6 +158,16 @@ MESSAGES = { "message": "flash.task_order.submitted.message", "category": "success", }, + "update_portfolio_member": { + "title": "flash.portfolio_member.update.title", + "message": "flash.portfolio_member.update.message", + "category": "success", + }, + "update_portfolio_member_error": { + "title": "flash.portfolio_member.update_error.title", + "message": "flash.portfolio_member.update_error.message", + "category": "error", + }, "updated_application_team_settings": { "title": "flash.success", "message": "flash.updated_application_team_settings", diff --git a/config/base.ini b/config/base.ini index 9233ef21..1df7f47a 100644 --- a/config/base.ini +++ b/config/base.ini @@ -15,6 +15,7 @@ CRL_FAIL_OPEN = false CRL_STORAGE_CONTAINER = crls CSP=mock DEBUG = true +DEBUG_MAILER = false DISABLE_CRL_CHECK = false ENVIRONMENT = dev LIMIT_CONCURRENT_SESSIONS = false diff --git a/deploy/azure/azure.yml b/deploy/azure/azure.yml index 1300ed34..f988d5fc 100644 --- a/deploy/azure/azure.yml +++ b/deploy/azure/azure.yml @@ -29,6 +29,13 @@ spec: containers: - name: atst image: $CONTAINER_IMAGE + env: + - name: UWSGI_PROCESSES + value: "2" + - name: UWSGI_THREADS + value: "2" + - name: UWSGI_ENABLE_THREADS + value: "1" envFrom: - configMapRef: name: atst-envvars @@ -50,11 +57,11 @@ spec: mountPath: "/config" resources: requests: - memory: 200Mi - cpu: 400m + memory: 400Mi + cpu: 940m limits: - memory: 200Mi - cpu: 400m + memory: 400Mi + cpu: 940m - name: nginx image: nginx:alpine ports: @@ -86,10 +93,10 @@ spec: resources: requests: memory: 20Mi - cpu: 10m + cpu: 25m limits: memory: 20Mi - cpu: 10m + cpu: 25m volumes: - name: nginx-client-ca-bundle configMap: @@ -309,6 +316,7 @@ metadata: namespace: atat spec: loadBalancerIP: 13.92.235.6 + externalTrafficPolicy: Local ports: - port: 80 targetPort: 8342 @@ -329,6 +337,7 @@ metadata: namespace: atat spec: loadBalancerIP: 23.100.24.41 + externalTrafficPolicy: Local ports: - port: 80 targetPort: 8343 diff --git a/deploy/azure/uwsgi-config.yml b/deploy/azure/uwsgi-config.yml index 553ea973..0c239637 100644 --- a/deploy/azure/uwsgi-config.yml +++ b/deploy/azure/uwsgi-config.yml @@ -10,6 +10,7 @@ data: callable = app module = app socket = /var/run/uwsgi/uwsgi.socket + plugins-dir = /usr/lib/uwsgi plugin = python3 plugin = logfile virtualenv = /opt/atat/atst/.venv diff --git a/deploy/overlays/staging/autoscaling.yml b/deploy/overlays/staging/autoscaling.yml new file mode 100644 index 00000000..b7500c09 --- /dev/null +++ b/deploy/overlays/staging/autoscaling.yml @@ -0,0 +1,16 @@ +--- +apiVersion: autoscaling/v2beta1 +kind: HorizontalPodAutoscaler +metadata: + name: atst +spec: + minReplicas: 1 + maxReplicas: 2 +--- +apiVersion: autoscaling/v2beta1 +kind: HorizontalPodAutoscaler +metadata: + name: atst-worker +spec: + minReplicas: 1 + maxReplicas: 2 diff --git a/deploy/overlays/staging/kustomization.yaml b/deploy/overlays/staging/kustomization.yaml index 24705531..c1ef8fd2 100644 --- a/deploy/overlays/staging/kustomization.yaml +++ b/deploy/overlays/staging/kustomization.yaml @@ -5,6 +5,7 @@ resources: - namespace.yml - reset-cron-job.yml patchesStrategicMerge: + - autoscaling.yml - ports.yml - envvars.yml - flex_vol.yml diff --git a/script/include/helper_functions.inc.sh b/script/include/helper_functions.inc.sh index 0df1323a..9f5de990 100644 --- a/script/include/helper_functions.inc.sh +++ b/script/include/helper_functions.inc.sh @@ -40,9 +40,7 @@ reset_db() { local database_name="${1}" # If the DB exists, drop it - set +e - dropdb "${database_name}" - set -e + dropdb --if-exists "${database_name}" # Create a fresh DB createdb "${database_name}" diff --git a/script/include/setup_functions.inc.sh b/script/include/setup_functions.inc.sh index 92c5dfa5..97ab9e4b 100644 --- a/script/include/setup_functions.inc.sh +++ b/script/include/setup_functions.inc.sh @@ -22,7 +22,7 @@ check_for_existing_virtual_environment() { local target_python_version_regex="^Python ${python_version}" # Check for existing venv, and if one exists, save the Python version string - existing_venv_version=$($(pipenv --py) --version) + existing_venv_version=$($(pipenv --py 2> /dev/null) --version 2> /dev/null) if [ "$?" = "0" ]; then # Existing venv; see if the Python version matches if [[ "${existing_venv_version}" =~ ${target_python_version_regex} ]]; then diff --git a/script/setup b/script/setup index 2e2dbcc6..a79c7be4 100755 --- a/script/setup +++ b/script/setup @@ -6,7 +6,7 @@ source "$(dirname "${0}")"/../script/include/global_header.inc.sh # create upload directory for app -mkdir uploads | true +mkdir -p uploads # Enable database resetting RESET_DB="true" diff --git a/styles/atat.scss b/styles/atat.scss index 0134dd89..72c7af40 100644 --- a/styles/atat.scss +++ b/styles/atat.scss @@ -39,6 +39,7 @@ @import "components/sticky_cta.scss"; @import "components/error_page.scss"; @import "components/member_form.scss"; +@import "components/toggle_menu.scss"; @import "sections/login"; @import "sections/home"; diff --git a/styles/components/_portfolio_layout.scss b/styles/components/_portfolio_layout.scss index fffc468f..81a15b69 100644 --- a/styles/components/_portfolio_layout.scss +++ b/styles/components/_portfolio_layout.scss @@ -130,10 +130,6 @@ &--th { width: 50%; } - - &--td { - position: relative; - } } .row { @@ -154,55 +150,6 @@ margin-right: $gap * 6; } } - - .app-member-menu { - position: absolute; - top: $gap; - right: $gap * 2; - - .accordion-table__item__toggler { - padding: $gap / 3; - border: 1px solid $color-gray-lighter; - border-radius: 3px; - cursor: pointer; - - &:hover, - &--active { - background-color: $color-aqua-lightest; - } - - .icon { - margin: $gap / 2; - } - } - - &__toggle { - position: absolute; - right: 0; - top: 30px; - background-color: $color-white; - border: 1px solid $color-gray-light; - z-index: 1; - margin-top: 0; - - a { - display: block; - padding: $gap; - border-bottom: 1px solid $color-gray-lighter; - text-decoration: none; - color: $color-black; - cursor: pointer; - - &:last-child { - border-bottom: 0; - } - - &:hover { - background-color: $color-aqua-lightest; - } - } - } - } } #add-new-env { diff --git a/styles/components/_toggle_menu.scss b/styles/components/_toggle_menu.scss new file mode 100644 index 00000000..b99bd75b --- /dev/null +++ b/styles/components/_toggle_menu.scss @@ -0,0 +1,58 @@ +.toggle-menu { + position: absolute; + top: $gap; + right: $gap * 2; + + &__container { + position: relative; + } + + .accordion-table__item__toggler { + padding: $gap / 3; + border: 1px solid $color-gray-lighter; + border-radius: 3px; + cursor: pointer; + + &:hover, + &--active { + background-color: $color-aqua-lightest; + } + + .icon { + margin: $gap / 2; + } + } + + &__toggle { + position: absolute; + right: 0; + top: 30px; + background-color: $color-white; + border: 1px solid $color-gray-light; + z-index: 1; + margin-top: 0; + + a { + display: block; + padding: $gap; + border-bottom: 1px solid $color-gray-lighter; + text-decoration: none; + color: $color-black; + cursor: pointer; + white-space: nowrap; + + &:last-child { + border-bottom: 0; + } + + &:hover { + background-color: $color-aqua-lightest; + } + + &.disabled { + color: $color-gray; + pointer-events: none; + } + } + } +} diff --git a/styles/components/_topbar.scss b/styles/components/_topbar.scss index 1f5db0ff..a64a1344 100644 --- a/styles/components/_topbar.scss +++ b/styles/components/_topbar.scss @@ -12,10 +12,13 @@ flex-direction: row; align-items: stretch; justify-content: space-between; + + a { + color: $color-white; + } } &__link { - color: $color-white !important; display: inline-flex; align-items: center; height: $topbar-height; @@ -23,20 +26,28 @@ text-decoration: none; &-label { - @include h5; - text-decoration: underline; - padding-left: $gap; + font-size: $h5-font-size; + font-weight: $font-semibold; text-decoration: none; } &-icon { - margin-left: $gap; - + margin: 0 $gap 0 0; @include icon-color($color-white); } + .icon--logout { + margin: 0 0 0 $gap; + } + &--home { - padding-left: $gap / 2; + padding: 0 ($gap * 2); + + .topbar__link-label { + font-size: $base-font-size; + font-weight: $font-bold; + text-transform: uppercase; + } } &:hover { diff --git a/templates/applications/fragments/members.html b/templates/applications/fragments/members.html index d6fb7290..9bd80dd0 100644 --- a/templates/applications/fragments/members.html +++ b/templates/applications/fragments/members.html @@ -5,6 +5,7 @@ {% from "components/modal.html" import Modal %} {% from "components/multi_step_modal_form.html" import MultiStepModalForm %} {% from "components/save_button.html" import SaveButton %} +{% from "components/toggle_menu.html" import ToggleMenu %} {% macro MemberManagementTemplate( application, @@ -38,16 +39,17 @@ {% call Modal(modal_name, classes="form-content--app-mem") %} {% endcall %} @@ -57,16 +59,17 @@ {% call Modal(resend_invite_modal, classes="form-content--app-mem") %}
{{ member.update_invite_form.csrf_token }} - {{ member_fields.InfoFields(member.update_invite_form) }} -
- {{ SaveButton(text="Resend Invite")}} - {{ "common.cancel" | translate }} -
+ {{ member_form.SubmitStep( + name=resend_invite_modal, + form=member_fields.InfoFields(member.update_invite_form), + submit_text="Resend Invite", + previous=False, + modal=resend_invite_modal, + ) }}
{% endcall %} @@ -119,7 +122,7 @@ {% endfor %} - + {% for env in member.environment_roles %}
@@ -131,32 +134,21 @@
{% endfor %} {% if user_can(permissions.EDIT_APPLICATION_MEMBER) -%} - -
- - {{ Icon('ellipsis')}} - - - {{ Icon('ellipsis')}} - - -
- - {{ "portfolios.applications.members.menu.edit" | translate }} - - {% if invite_pending or invite_expired -%} - {% set revoke_invite_modal = "revoke_invite_{}".format(member.role_id) %} - {% set resend_invite_modal = "resend_invite-{}".format(member.role_id) %} - - {{ "portfolios.applications.members.menu.resend" | translate }} - - {% if user_can(permissions.DELETE_APPLICATION_MEMBER) -%} - {{ 'invites.revoke' | translate }} - {%- endif %} - {%- endif %} -
-
-
+ {% call ToggleMenu() %} + + {{ "portfolios.applications.members.menu.edit" | translate }} + + {% if invite_pending or invite_expired -%} + {% set revoke_invite_modal = "revoke_invite_{}".format(member.role_id) %} + {% set resend_invite_modal = "resend_invite-{}".format(member.role_id) %} + + {{ "portfolios.applications.members.menu.resend" | translate }} + + {% if user_can(permissions.DELETE_APPLICATION_MEMBER) -%} + {{ 'invites.revoke' | translate }} + {%- endif %} + {%- endif %} + {% endcall %} {%- endif %} diff --git a/templates/components/alert.html b/templates/components/alert.html index 49f148b9..78ffe717 100644 --- a/templates/components/alert.html +++ b/templates/components/alert.html @@ -25,7 +25,7 @@
{% if vue_template %} -

+

{% elif title %}

{{ title | safe }}

{% endif %} diff --git a/templates/components/clin_dollar_amount.html b/templates/components/clin_dollar_amount.html index c39d3ef6..43973cc3 100644 --- a/templates/components/clin_dollar_amount.html +++ b/templates/components/clin_dollar_amount.html @@ -57,7 +57,7 @@ {{ "forms.task_order.clin_funding_errors.obligated_amount_error" | translate }}