From 769867c6a9707105c94fee63da5030629e1acab9 Mon Sep 17 00:00:00 2001 From: George Drummond Date: Mon, 8 Apr 2019 13:53:17 -0400 Subject: [PATCH 1/6] Display app users view only table --- atst/models/application.py | 16 +++ atst/models/application_role.py | 6 ++ atst/routes/portfolios/applications.py | 44 ++++++++ styles/components/_accordion_table.scss | 25 +++++ templates/portfolios/applications/index.html | 4 +- templates/portfolios/applications/team.html | 104 +++++++++++++++++++ tests/routes/portfolios/test_applications.py | 36 +++++++ translations.yaml | 18 ++++ 8 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 templates/portfolios/applications/team.html diff --git a/atst/models/application.py b/atst/models/application.py index d06f20b7..8512c570 100644 --- a/atst/models/application.py +++ b/atst/models/application.py @@ -5,6 +5,13 @@ from atst.models import Base from atst.models.types import Id from atst.models import mixins +from atst.models.application_role import ( + ApplicationRole, + Status as ApplicationRoleStatuses, +) + +from atst.database import db + class Application( Base, mixins.TimestampsMixin, mixins.AuditableMixin, mixins.DeletableMixin @@ -28,6 +35,15 @@ class Application( def users(self): return set(role.user for role in self.roles) + @property + def members(self): + return ( + db.session.query(ApplicationRole) + .filter(ApplicationRole.application_id == self.id) + .filter(ApplicationRole.status != ApplicationRoleStatuses.DISABLED) + .all() + ) + @property def num_users(self): return len(self.users) diff --git a/atst/models/application_role.py b/atst/models/application_role.py index 9b8fe05d..3ae42845 100644 --- a/atst/models/application_role.py +++ b/atst/models/application_role.py @@ -4,6 +4,7 @@ from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import relationship from sqlalchemy.event import listen +from atst.utils import first_or_none from atst.models import Base, mixins from atst.models.mixins.auditable import record_permission_sets_updates from .types import Id @@ -59,6 +60,11 @@ class ApplicationRole( def history(self): return self.get_changes() + def has_permission_set(self, perm_set_name): + return first_or_none( + lambda prms: prms.name == perm_set_name, self.permission_sets + ) + Index( "application_role_user_application", diff --git a/atst/routes/portfolios/applications.py b/atst/routes/portfolios/applications.py index 1f91628c..d94cc063 100644 --- a/atst/routes/portfolios/applications.py +++ b/atst/routes/portfolios/applications.py @@ -9,6 +9,7 @@ from flask import ( from . import portfolios_bp from atst.domain.environment_roles import EnvironmentRoles +from atst.domain.environments import Environments from atst.domain.exceptions import UnauthorizedError from atst.domain.applications import Applications from atst.domain.portfolios import Portfolios @@ -16,6 +17,8 @@ from atst.forms.application import NewApplicationForm, ApplicationForm from atst.domain.authz.decorator import user_can_access_decorator as user_can from atst.models.permissions import Permissions from atst.utils.flash import formatted_flash as flash +from atst.domain.permission_sets import PermissionSets +from atst.utils.localization import translate @portfolios_bp.route("/portfolios//applications") @@ -139,3 +142,44 @@ def delete_application(portfolio_id, application_id): return redirect( url_for("portfolios.portfolio_applications", portfolio_id=portfolio_id) ) + + +def permission_str(member, edit_perm_set): + if member.has_permission_set(edit_perm_set): + return translate("portfolios.members.permissions.edit_access") + else: + return translate("portfolios.members.permissions.view_only") + + +@portfolios_bp.route("/portfolios//applications//team") +@user_can(Permissions.VIEW_APPLICATION, message="view portfolio applications") +def application_team(portfolio_id, application_id): + application = Applications.get(application_id) + portfolio = Portfolios.get(g.current_user, portfolio_id) + + environment_users = {} + for member in application.members: + user_id = member.user.id + environment_users[user_id] = { + "permissions": { + "delete_access": permission_str( + member, PermissionSets.DELETE_APPLICATION_ENVIRONMENTS + ), + "environment_management": permission_str( + member, PermissionSets.EDIT_APPLICATION_ENVIRONMENTS + ), + "team_management": permission_str( + member, PermissionSets.EDIT_APPLICATION_TEAM + ), + }, + "environments": Environments.for_user( + user=member.user, application=application + ), + } + + return render_template( + "portfolios/applications/team.html", + application=application, + portfolio=portfolio, + environment_users=environment_users, + ) diff --git a/styles/components/_accordion_table.scss b/styles/components/_accordion_table.scss index 715bc057..e2ba6a3b 100644 --- a/styles/components/_accordion_table.scss +++ b/styles/components/_accordion_table.scss @@ -117,3 +117,28 @@ } } } + +#portfolio-members { + .accordion-table { + .accordion-table__head { + font-size: $small-font-size; + padding-left: $gap*3; + } + + .accordion-table__item-content, .accordion-table__head { + display: flex; + + & > span { + width: 75%; + &.icon-link { + width: 25%; + } + } + } + + span { + display: flex; + width: 25%; + } + } +} diff --git a/templates/portfolios/applications/index.html b/templates/portfolios/applications/index.html index 0f609310..57535ad8 100644 --- a/templates/portfolios/applications/index.html +++ b/templates/portfolios/applications/index.html @@ -49,7 +49,9 @@
{% endif %} {% if user_can(permissions.VIEW_PORTFOLIO_USERS) %} - + {{ "portfolios.applications.team_text" | translate }} {{ application.num_users }} diff --git a/templates/portfolios/applications/team.html b/templates/portfolios/applications/team.html new file mode 100644 index 00000000..c0184c95 --- /dev/null +++ b/templates/portfolios/applications/team.html @@ -0,0 +1,104 @@ +{% extends "portfolios/applications/base.html" %} + +{% from "components/empty_state.html" import EmptyState %} +{% from "components/icon.html" import Icon %} +{% from "components/toggle_list.html" import ToggleList %} + +{% set secondary_breadcrumb = 'portfolios.applications.team_settings.title' | translate({ "application_name": application.name }) %} + +{% block application_content %} + {% if not application.members %} + {% set user_can_invite = user_can(permissions.CREATE_APPLICATION_MEMBER) %} + + {{ EmptyState( + ("portfolios.applications.team_settings.blank_slate.title" | translate), + action_label=("portfolios.applications.team_settings.blank_slate.action_label" | translate), + action_href='#' if user_can_invite else None, + sub_message=None if user_can_invite else ("portfolios.team_settings.blank_slate.sub_message" | translate), + icon='avatar' + ) }} + + {% else %} +
+ {{ 'portfolios.applications.team_settings.subheading' | translate }} +
+ +
+
+ {% if g.matchesPath("portfolio-members") %} + {% include "fragments/flash.html" %} + {% endif %} +
+
+
+
+
+ {{ "portfolios.applications.team_settings.section.title" | translate({ "application_name": application.name }) }} +
+
+ + {{ Icon('info') }} + {{ "portfolios.admin.settings_info" | translate }} + +
+
+ +
+
+ + + {{ "portfolios.applications.team_settings.user" | translate }} + + + {{ "portfolios.applications.team_settings.section.table.delete_access" | translate }} + + + {{ "portfolios.applications.team_settings.section.table.environment_management" | translate }} + + + {{ "portfolios.applications.team_settings.section.table.team_management" | translate }} + + +
+
    + {% for member in application.members %} + {% set user = member.user %} + {% set user_info = environment_users[user.id] %} + {% set user_permissions = user_info["permissions"] %} + + {% set user_row %} + {{ user.full_name }} + {{ user_permissions["delete_access"] }} + {{ user_permissions["environment_management"] }} + {{ user_permissions["team_management"] }} + {% endset %} + + {% call ToggleList( + item_name=user_row, + item_type=("portfolios.applications.team_settings.environments" | translate), + length=(user_info["environments"] | length) + ) + %} +
      + {% for environment in user_info["environments"] %} +
    • +
      + {{ environment.name }} +
      +
    • + {% endfor %} +
    + {% endcall %} + {% endfor %} +
+
+ + +
+
+
+ {% endif %} +{% endblock %} diff --git a/tests/routes/portfolios/test_applications.py b/tests/routes/portfolios/test_applications.py index 38595608..26f99d4a 100644 --- a/tests/routes/portfolios/test_applications.py +++ b/tests/routes/portfolios/test_applications.py @@ -347,4 +347,40 @@ def test_edit_application_scope(client, user_session): application_id=port1.applications[0].id, ) ) + + assert response.status_code == 404 + + +def test_application_team_with_permissions(client, user_session): + portfolio = PortfolioFactory.create() + application = ApplicationFactory.create(portfolio=portfolio) + + user_session(portfolio.owner) + + response = client.get( + url_for( + "portfolios.application_team", + portfolio_id=portfolio.id, + application_id=application.id, + ) + ) + + assert response.status_code == 200 + + +def test_application_team_without_permissions(client, user_session): + random_user = UserFactory.create() + portfolio = PortfolioFactory.create() + application = ApplicationFactory.create(portfolio=portfolio) + + user_session(random_user) + + response = client.get( + url_for( + "portfolios.application_team", + portfolio_id=portfolio.id, + application_id=application.id, + ) + ) + assert response.status_code == 404 diff --git a/translations.yaml b/translations.yaml index 3db18805..168f03d2 100644 --- a/translations.yaml +++ b/translations.yaml @@ -601,6 +601,24 @@ portfolios: environments_description: Each environment created within an application is logically separated from one another for easier management and security. update_button_text: Save create_button_text: Create + team_settings: + title: '{application_name} Team Settings' + subheading: Team Settings + user: User + environments: Environments + blank_slate: + action_label: Invite a new team member + sub_message: Please contact your JEDI Cloud portfolio administrator to invite new members. + title: There are currently no team members for this application. + section: + title: '{application_name} Team' + members_count: 'Members ({members_count})' + table: + delete_access: Delete Access + environment_management: Environment Management + environments: Environments + name: Name + team_management: Team Management team_management: title: '{application_name} Team Management' subheading: Team Management From e814f8904d81d8fb96a3ade3e5beadcfd6ddb6c1 Mon Sep 17 00:00:00 2001 From: George Drummond Date: Wed, 17 Apr 2019 13:37:28 -0400 Subject: [PATCH 2/6] Use correct scoping --- atst/routes/portfolios/applications.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/atst/routes/portfolios/applications.py b/atst/routes/portfolios/applications.py index d94cc063..836fec88 100644 --- a/atst/routes/portfolios/applications.py +++ b/atst/routes/portfolios/applications.py @@ -154,8 +154,10 @@ def permission_str(member, edit_perm_set): @portfolios_bp.route("/portfolios//applications//team") @user_can(Permissions.VIEW_APPLICATION, message="view portfolio applications") def application_team(portfolio_id, application_id): - application = Applications.get(application_id) portfolio = Portfolios.get(g.current_user, portfolio_id) + application = Applications.get( + resource_id=application_id, portfolio_id=portfolio_id + ) environment_users = {} for member in application.members: From 52669a0265216580d5fec7bd392e2734d55313f6 Mon Sep 17 00:00:00 2001 From: George Drummond Date: Wed, 17 Apr 2019 13:52:06 -0400 Subject: [PATCH 3/6] Use access specs --- tests/routes/portfolios/test_applications.py | 20 +------------------- tests/test_access.py | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/tests/routes/portfolios/test_applications.py b/tests/routes/portfolios/test_applications.py index 26f99d4a..9b3e84f5 100644 --- a/tests/routes/portfolios/test_applications.py +++ b/tests/routes/portfolios/test_applications.py @@ -351,7 +351,7 @@ def test_edit_application_scope(client, user_session): assert response.status_code == 404 -def test_application_team_with_permissions(client, user_session): +def test_application_team(client, user_session): portfolio = PortfolioFactory.create() application = ApplicationFactory.create(portfolio=portfolio) @@ -366,21 +366,3 @@ def test_application_team_with_permissions(client, user_session): ) assert response.status_code == 200 - - -def test_application_team_without_permissions(client, user_session): - random_user = UserFactory.create() - portfolio = PortfolioFactory.create() - application = ApplicationFactory.create(portfolio=portfolio) - - user_session(random_user) - - response = client.get( - url_for( - "portfolios.application_team", - portfolio_id=portfolio.id, - application_id=application.id, - ) - ) - - assert response.status_code == 404 diff --git a/tests/test_access.py b/tests/test_access.py index 0208887b..4c4c97f8 100644 --- a/tests/test_access.py +++ b/tests/test_access.py @@ -12,6 +12,7 @@ from atst.models.portfolio_role import Status as PortfolioRoleStatus from tests.factories import ( AttachmentFactory, + ApplicationFactory, ApplicationRoleFactory, InvitationFactory, PortfolioFactory, @@ -815,3 +816,21 @@ def test_task_orders_update_access(post_url_assert_status): post_url_assert_status(owner, url, 302) post_url_assert_status(ccpo, url, 302) post_url_assert_status(rando, url, 404) + + +def test_portfolio_application_team_access(get_url_assert_status): + ccpo = UserFactory.create_ccpo() + rando = UserFactory.create() + + portfolio = PortfolioFactory.create() + application = ApplicationFactory.create(portfolio=portfolio) + + url = url_for( + "portfolios.application_team", + portfolio_id=portfolio.id, + application_id=application.id, + ) + + get_url_assert_status(ccpo, url, 200) + get_url_assert_status(portfolio.owner, url, 200) + get_url_assert_status(rando, url, 404) From 20149268098051f62f402882e911f2b6475cb9da Mon Sep 17 00:00:00 2001 From: George Drummond Date: Wed, 17 Apr 2019 13:56:24 -0400 Subject: [PATCH 4/6] Don't need to pass portfolio to the template --- atst/routes/portfolios/applications.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/atst/routes/portfolios/applications.py b/atst/routes/portfolios/applications.py index 836fec88..429b0fe2 100644 --- a/atst/routes/portfolios/applications.py +++ b/atst/routes/portfolios/applications.py @@ -154,7 +154,6 @@ def permission_str(member, edit_perm_set): @portfolios_bp.route("/portfolios//applications//team") @user_can(Permissions.VIEW_APPLICATION, message="view portfolio applications") def application_team(portfolio_id, application_id): - portfolio = Portfolios.get(g.current_user, portfolio_id) application = Applications.get( resource_id=application_id, portfolio_id=portfolio_id ) @@ -182,6 +181,5 @@ def application_team(portfolio_id, application_id): return render_template( "portfolios/applications/team.html", application=application, - portfolio=portfolio, environment_users=environment_users, ) From e1cca58062dbbb0d6504842af162622a19f2ca2f Mon Sep 17 00:00:00 2001 From: George Drummond Date: Wed, 17 Apr 2019 14:02:23 -0400 Subject: [PATCH 5/6] Renamed section id --- styles/components/_accordion_table.scss | 2 +- templates/portfolios/applications/team.html | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/styles/components/_accordion_table.scss b/styles/components/_accordion_table.scss index e2ba6a3b..110c3999 100644 --- a/styles/components/_accordion_table.scss +++ b/styles/components/_accordion_table.scss @@ -118,7 +118,7 @@ } } -#portfolio-members { +#application-members { .accordion-table { .accordion-table__head { font-size: $small-font-size; diff --git a/templates/portfolios/applications/team.html b/templates/portfolios/applications/team.html index c0184c95..c12c5356 100644 --- a/templates/portfolios/applications/team.html +++ b/templates/portfolios/applications/team.html @@ -23,9 +23,9 @@ {{ 'portfolios.applications.team_settings.subheading' | translate }} -
+
- {% if g.matchesPath("portfolio-members") %} + {% if g.matchesPath("application-members") %} {% include "fragments/flash.html" %} {% endif %}
From e3cb30d35f9e99639d8f007fb674a99b7888b52f Mon Sep 17 00:00:00 2001 From: George Drummond Date: Wed, 17 Apr 2019 14:51:01 -0400 Subject: [PATCH 6/6] Make use of flexbox --- styles/components/_accordion_table.scss | 10 ++++++---- templates/components/toggle_list.html | 4 +++- templates/portfolios/applications/team.html | 1 + 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/styles/components/_accordion_table.scss b/styles/components/_accordion_table.scss index 110c3999..f11d767d 100644 --- a/styles/components/_accordion_table.scss +++ b/styles/components/_accordion_table.scss @@ -129,16 +129,18 @@ display: flex; & > span { - width: 75%; + flex-grow: 3; + display: flex; + flex-basis: 0; &.icon-link { - width: 25%; + flex-grow: 1; } } } span { - display: flex; - width: 25%; + flex-grow: 1; + flex-basis: 0; } } } diff --git a/templates/components/toggle_list.html b/templates/components/toggle_list.html index 8bce7dc1..af5f5db2 100644 --- a/templates/components/toggle_list.html +++ b/templates/components/toggle_list.html @@ -12,7 +12,9 @@