Merge pull request #748 from dod-ccpo/display-app-users

Display app users
This commit is contained in:
George Drummond 2019-04-17 15:28:28 -04:00 committed by GitHub
commit 882998e1d7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 259 additions and 2 deletions

View File

@ -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)

View File

@ -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",

View File

@ -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/<portfolio_id>/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/<portfolio_id>/applications/<application_id>/team")
@user_can(Permissions.VIEW_APPLICATION, message="view portfolio applications")
def application_team(portfolio_id, application_id):
application = Applications.get(
resource_id=application_id, portfolio_id=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,
environment_users=environment_users,
)

View File

@ -117,3 +117,30 @@
}
}
}
#application-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 {
flex-grow: 3;
display: flex;
flex-basis: 0;
&.icon-link {
flex-grow: 1;
}
}
}
span {
flex-grow: 1;
flex-basis: 0;
}
}
}

View File

@ -12,7 +12,9 @@
<li is="toggler" class="accordion-table__item">
<template slot-scope="props">
<div class="accordion-table__item-content">
<span v-on:click="props.toggle">{{ item_name }}</span>
<span v-on:click="props.toggle">
{{ item_name }}
</span>
<template v-if="props.isVisible">
{{

View File

@ -49,7 +49,9 @@
<div class='separator'></div>
{% endif %}
{% if user_can(permissions.VIEW_PORTFOLIO_USERS) %}
<a class='icon-link'>
<a
href="{{ url_for('portfolios.application_team', portfolio_id=portfolio.id, application_id=application.id) }}"
class='icon-link'>
<span>{{ "portfolios.applications.team_text" | translate }}</span>
<span class='counter'>{{ application.num_users }}</span>
</a>

View File

@ -0,0 +1,105 @@
{% 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 %}
<div class='subheading'>
{{ 'portfolios.applications.team_settings.subheading' | translate }}
</div>
<section class="member-list" id="application-members">
<div class='responsive-table-wrapper panel'>
{% if g.matchesPath("application-members") %}
{% include "fragments/flash.html" %}
{% endif %}
<form>
<header>
<div class="responsive-table-wrapper__header">
<div class="responsive-table-wrapper__title">
<div class="h3">
{{ "portfolios.applications.team_settings.section.title" | translate({ "application_name": application.name }) }}
</div>
</div>
<a class='icon-link'>
{{ Icon('info') }}
{{ "portfolios.admin.settings_info" | translate }}
</a>
</div>
</header>
<div class="accordion-table accordion-table-list">
<div class="accordion-table__head">
<span>
<span>
{{ "portfolios.applications.team_settings.user" | translate }}
</span>
<span>
{{ "portfolios.applications.team_settings.section.table.delete_access" | translate }}
</span>
<span>
{{ "portfolios.applications.team_settings.section.table.environment_management" | translate }}
</span>
<span>
{{ "portfolios.applications.team_settings.section.table.team_management" | translate }}
</span>
</span>
<span class="icon-link" />
</div>
<ul class="accordion-table__items">
{% 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 %}
<span>{{ user.full_name }}</span>
<span>{{ user_permissions["delete_access"] }}</span>
<span>{{ user_permissions["environment_management"] }}</span>
<span>{{ user_permissions["team_management"] }}</span>
{% endset %}
{% call ToggleList(
item_name=user_row,
item_type=("portfolios.applications.team_settings.environments" | translate),
length=(user_info["environments"] | length)
)
%}
<ul>
{% for environment in user_info["environments"] %}
<li class="accordion-table__item__expanded">
<div class="accordion-table__item-content">
{{ environment.name }}
</div>
</li>
{% endfor %}
</ul>
{% endcall %}
{% endfor %}
</ul>
</div>
<div class="members-table-footer">
<div class="action-group save">
</div>
</div>
</form>
</div>
</section>
{% endif %}
{% endblock %}

View File

@ -347,4 +347,22 @@ def test_edit_application_scope(client, user_session):
application_id=port1.applications[0].id,
)
)
assert response.status_code == 404
def test_application_team(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

View File

@ -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)

View File

@ -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