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