Merge branch 'staging' into revoke-portfolio-invite-success-message

This commit is contained in:
leigh-mil 2020-02-05 10:46:03 -05:00 committed by GitHub
commit e66605ff4a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 552 additions and 301 deletions

View File

@ -24,6 +24,7 @@ from .models import (
BillingProfileTenantAccessCSPResult,
BillingProfileVerificationCSPPayload,
BillingProfileVerificationCSPResult,
CostManagementQueryCSPResult,
KeyVaultCredentials,
ManagementGroupCSPResponse,
ProductPurchaseCSPPayload,
@ -32,6 +33,7 @@ from .models import (
ProductPurchaseVerificationCSPResult,
PrincipalAdminRoleCSPPayload,
PrincipalAdminRoleCSPResult,
ReportingCSPPayload,
TaskOrderBillingCreationCSPPayload,
TaskOrderBillingCreationCSPResult,
TaskOrderBillingVerificationCSPPayload,
@ -1070,3 +1072,41 @@ class AzureCloudProvider(CloudProviderInterface):
hashed = sha256_hex(tenant_id)
raw_creds = self.get_secret(hashed)
return KeyVaultCredentials(**json.loads(raw_creds))
def get_reporting_data(self, payload: ReportingCSPPayload):
"""
Queries the Cost Management API for an invoice section's raw reporting data
We query at the invoiceSection scope. The full scope path is passed in
with the payload at the `invoice_section_id` key.
"""
creds = self._source_tenant_creds(payload.tenant_id)
token = self._get_sp_token(
payload.tenant_id, creds.tenant_sp_client_id, creds.tenant_sp_key
)
if not token:
raise AuthenticationException("Could not retrieve tenant access token")
headers = {"Authorization": f"Bearer {token}"}
request_body = {
"type": "Usage",
"timeframe": "Custom",
"timePeriod": {"from": payload.from_date, "to": payload.to_date,},
"dataset": {
"granularity": "Daily",
"aggregation": {"totalCost": {"name": "PreTaxCost", "function": "Sum"}},
"grouping": [{"type": "Dimension", "name": "InvoiceId"}],
},
}
cost_mgmt_url = (
f"/providers/Microsoft.CostManagement/query?api-version=2019-11-01"
)
result = self.sdk.requests.post(
f"{self.sdk.cloud.endpoints.resource_manager}{payload.invoice_section_id}{cost_mgmt_url}",
json=request_body,
headers=headers,
)
if result.ok:
return CostManagementQueryCSPResult(**result.json())

View File

@ -25,12 +25,15 @@ from .models import (
BillingProfileTenantAccessCSPResult,
BillingProfileVerificationCSPPayload,
BillingProfileVerificationCSPResult,
CostManagementQueryCSPResult,
CostManagementQueryProperties,
ProductPurchaseCSPPayload,
ProductPurchaseCSPResult,
ProductPurchaseVerificationCSPPayload,
ProductPurchaseVerificationCSPResult,
PrincipalAdminRoleCSPPayload,
PrincipalAdminRoleCSPResult,
ReportingCSPPayload,
SubscriptionCreationCSPPayload,
SubscriptionCreationCSPResult,
SubscriptionVerificationCSPPayload,
@ -487,3 +490,25 @@ class MockCloudProvider(CloudProviderInterface):
def update_tenant_creds(self, tenant_id, secret):
return secret
def get_reporting_data(self, payload: ReportingCSPPayload):
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
object_id = str(uuid4())
properties = CostManagementQueryProperties(
**dict(
columns=[
{"name": "PreTaxCost", "type": "Number"},
{"name": "UsageDate", "type": "Number"},
{"name": "InvoiceId", "type": "String"},
{"name": "Currency", "type": "String"},
],
rows=[],
)
)
return CostManagementQueryCSPResult(
**dict(name=object_id, properties=properties,)
)

View File

@ -499,3 +499,34 @@ class UserCSPPayload(BaseCSPPayload):
class UserCSPResult(AliasModel):
id: str
class QueryColumn(AliasModel):
name: str
type: str
class CostManagementQueryProperties(AliasModel):
columns: List[QueryColumn]
rows: List[Optional[list]]
class CostManagementQueryCSPResult(AliasModel):
name: str
properties: CostManagementQueryProperties
class ReportingCSPPayload(BaseCSPPayload):
invoice_section_id: str
from_date: str
to_date: str
@root_validator(pre=True)
def extract_invoice_section(cls, values):
try:
values["invoice_section_id"] = values["billing_profile_properties"][
"invoice_sections"
][0]["invoice_section_id"]
return values
except (KeyError, IndexError):
raise ValueError("Invoice section ID not present in payload")

View File

@ -15,6 +15,8 @@ from atst.models import (
Permissions,
PortfolioRole,
PortfolioRoleStatus,
TaskOrder,
CLIN,
)
from .query import PortfoliosQuery, PortfolioStateMachinesQuery
@ -144,7 +146,7 @@ class Portfolios(object):
return db.session.query(Portfolio.id)
@classmethod
def get_portfolios_pending_provisioning(cls) -> List[UUID]:
def get_portfolios_pending_provisioning(cls, now) -> List[UUID]:
"""
Any portfolio with a corresponding State Machine that is either:
not started yet,
@ -153,22 +155,18 @@ class Portfolios(object):
"""
results = (
cls.base_provision_query()
db.session.query(Portfolio.id)
.join(PortfolioStateMachine)
.join(TaskOrder)
.join(CLIN)
.filter(Portfolio.deleted == False)
.filter(CLIN.start_date <= now)
.filter(CLIN.end_date > now)
.filter(
or_(
PortfolioStateMachine.state == FSMStates.UNSTARTED,
PortfolioStateMachine.state == FSMStates.FAILED,
PortfolioStateMachine.state == FSMStates.TENANT_FAILED,
PortfolioStateMachine.state.like("%CREATED"),
)
)
)
return [id_ for id_, in results]
# db.session.query(PortfolioStateMachine).\
# filter(
# or_(
# PortfolioStateMachine.state==FSMStates.UNSTARTED,
# PortfolioStateMachine.state==FSMStates.UNSTARTED,
# )
# ).all()

View File

@ -203,7 +203,7 @@ def dispatch_provision_portfolio(self):
"""
Iterate over portfolios with a corresponding State Machine that have not completed.
"""
for portfolio_id in Portfolios.get_portfolios_pending_provisioning():
for portfolio_id in Portfolios.get_portfolios_pending_provisioning(pendulum.now()):
provision_portfolio.delay(portfolio_id=portfolio_id)

View File

@ -175,11 +175,14 @@ class PortfolioStateMachine(
app.logger.info(exc.json())
print(exc.json())
app.logger.info(payload_data)
# TODO: Ensure that failing the stage does not preclude a Celery retry
self.fail_stage(stage)
# TODO: catch and handle general CSP exception here
except (ConnectionException, UnknownServerException) as exc:
app.logger.error(
f"CSP api call. Caught exception for {self.__repr__()}.", exc_info=1,
)
# TODO: Ensure that failing the stage does not preclude a Celery retry
self.fail_stage(stage)
self.finish_stage(stage)

View File

@ -1,30 +1,21 @@
import ExpandSidenavMixin from '../mixins/expand_sidenav'
import ToggleMixin from '../mixins/toggle'
const cookieName = 'expandSidenav'
export default {
name: 'sidenav-toggler',
mixins: [ToggleMixin],
mixins: [ExpandSidenavMixin, ToggleMixin],
props: {
defaultVisible: {
type: Boolean,
default: function() {
if (document.cookie.match(cookieName)) {
return !!document.cookie.match(cookieName + ' *= *true')
} else {
return true
}
},
},
mounted: function() {
this.$parent.$emit('sidenavToggle', this.isVisible)
},
methods: {
toggle: function(e) {
e.preventDefault()
this.isVisible = !this.isVisible
document.cookie = cookieName + '=' + this.isVisible + '; path=/'
document.cookie = this.cookieName + '=' + this.isVisible + '; path=/'
this.$parent.$emit('sidenavToggle', this.isVisible)
},
},
}

View File

@ -32,12 +32,14 @@ import ToForm from './components/forms/to_form'
import ClinFields from './components/clin_fields'
import PopDateRange from './components/pop_date_range'
import ToggleMenu from './components/toggle_menu'
import ExpandSidenav from './mixins/expand_sidenav'
Vue.config.productionTip = false
Vue.use(VTooltip)
Vue.mixin(Modal)
Vue.mixin(ExpandSidenav)
const app = new Vue({
el: '#app-root',
@ -67,6 +69,12 @@ const app = new Vue({
ToggleMenu,
},
data: function() {
return {
sidenavExpanded: this.defaultVisible,
}
},
mounted: function() {
this.$on('modalOpen', data => {
if (data['isOpen']) {
@ -105,6 +113,10 @@ const app = new Vue({
}
})
})
this.$on('sidenavToggle', data => {
this.sidenavExpanded = data
})
},
delimiters: ['!{', '}'],

View File

@ -0,0 +1,15 @@
export default {
props: {
cookieName: 'expandSidenav',
defaultVisible: {
type: Boolean,
default: function() {
if (document.cookie.match(this.cookieName)) {
return !!document.cookie.match(this.cookieName + ' *= *true')
} else {
return true
}
},
},
},
}

View File

@ -47,3 +47,4 @@
@import "sections/application_edit";
@import "sections/reports";
@import "sections/task_order";
@import "sections/ccpo";

View File

@ -11,6 +11,7 @@
.usa-alert {
padding-bottom: 2.4rem;
max-width: $max-panel-width;
}
@mixin alert {
@ -97,38 +98,3 @@
}
}
}
.alert {
@include alert;
@include alert-level("info");
&.alert--success {
@include alert-level("success");
.alert__actions {
.icon-link {
@include icon-link-color($color-green, $color-white);
}
}
}
&.alert--warning {
@include alert-level("warning");
.alert__actions {
.icon-link {
@include icon-link-color($color-gold-dark, $color-white);
}
}
}
&.alert--error {
@include alert-level("error");
.alert__actions {
.icon-link {
@include icon-link-color($color-red, $color-white);
}
}
}
}

View File

@ -1,8 +1,13 @@
.error-page {
max-width: 475px;
margin: auto;
max-width: $max-page-width;
.panel {
box-shadow: none;
background-color: unset;
border: none;
max-width: 475px;
margin: auto;
&__heading {
text-align: center;
padding: $gap 0;
@ -15,17 +20,6 @@
margin-bottom: $gap;
}
}
&__body {
padding: $gap * 2;
margin: 0;
hr {
width: 80%;
margin: auto;
margin-bottom: $gap * 3;
}
}
}
.icon {

View File

@ -12,7 +12,7 @@
flex-direction: row;
align-items: stretch;
justify-content: space-between;
max-width: 1190px;
max-width: $max-page-width;
a {
color: $color-white;

View File

@ -19,6 +19,7 @@ $sidenav-collapsed-width: 10rem;
$max-panel-width: 90rem;
$home-pg-icon-width: 6rem;
$large-spacing: 4rem;
$max-page-width: $max-panel-width + $sidenav-expanded-width + $large-spacing;
/*
* USWDS Variables

View File

@ -32,22 +32,35 @@
}
.action-group-footer {
@extend .action-group;
&:last-child {
margin-bottom: 0;
}
margin-top: 0;
margin-bottom: 0;
padding-top: $gap;
padding-bottom: $gap;
padding-right: $gap * 4;
position: fixed;
bottom: $footer-height;
left: 0;
background: white;
right: 0;
padding-right: $gap * 4;
border-top: 1px solid $color-gray-lighter;
width: 100%;
z-index: 1;
width: 100%;
&.action-group-footer--expand-offset {
padding-left: $sidenav-expanded-width;
}
&.action-group-footer--collapse-offset {
padding-left: $sidenav-collapsed-width;
}
.action-group-footer--container {
@extend .action-group;
margin-top: 0;
margin-bottom: 0;
margin-left: $large-spacing;
max-width: $max-panel-width;
&:last-child {
margin-bottom: 0;
}
}
}

View File

@ -0,0 +1,3 @@
.ccpo-panel-container {
max-width: $max-panel-width;
}

View File

@ -39,14 +39,18 @@
</div>
<span class="action-group-footer">
{% block next_button %}
{{ SaveButton(text=('portfolios.applications.new.step_1_button_text' | translate)) }}
{% endblock %}
<a href="{{ url_for('applications.portfolio_applications', portfolio_id=portfolio.id) }}">
Cancel
</a>
</span>
<div
class="action-group-footer"
v-bind:class="{'action-group-footer--expand-offset': this.$root.sidenavExpanded, 'action-group-footer--collapse-offset': !this.$root.sidenavExpanded}">
<div class="action-group-footer--container">
{% block next_button %}
{{ SaveButton(text=('portfolios.applications.new.step_1_button_text' | translate)) }}
{% endblock %}
<a href="{{ url_for('applications.portfolio_applications', portfolio_id=portfolio.id) }}">
Cancel
</a>
</div>
</div>
</form>
</base-form>

View File

@ -58,20 +58,24 @@
{{ Icon("plus") }}
</button>
</div>
</div>
</div>
</div>
<div
class="action-group-footer"
v-bind:class="{'action-group-footer--expand-offset': this.$root.sidenavExpanded, 'action-group-footer--collapse-offset': !this.$root.sidenavExpanded}">
<div class="action-group-footer--container">
{% block next_button %}
{{ SaveButton(text=('portfolios.applications.new.step_2_button_text' | translate)) }}
{% endblock %}
<a class="usa-button usa-button-secondary" href="{{ url_for('applications.view_new_application_step_1', application_id=application.id) }}">
Previous
</a>
<a href="{{ url_for('applications.portfolio_applications', portfolio_id=portfolio.id) }}">
Cancel
</a>
</div>
</div>
</div>
<span class="action-group-footer">
{% block next_button %}
{{ SaveButton(text=('portfolios.applications.new.step_2_button_text' | translate)) }}
{% endblock %}
<a class="usa-button usa-button-secondary" href="{{ url_for('applications.view_new_application_step_1', application_id=application.id) }}">
Previous
</a>
<a href="{{ url_for('applications.portfolio_applications', portfolio_id=portfolio.id) }}">
Cancel
</a>
</span>
</form>
</application-environments>

View File

@ -25,16 +25,20 @@
action_update="applications.update_new_application_step_3") }}
<span class="action-group-footer">
<a class="usa-button" href="{{ url_for('applications.settings', application_id=application_id) }}">
{{ "portfolios.applications.new.step_3_button_text" | translate }}
</a>
<a class="usa-button usa-button-secondary" href="{{ url_for('applications.view_new_application_step_2', application_id=application.id) }}">
{{ "common.previous" | translate }}
</a>
<a href="{{ url_for('applications.portfolio_applications', portfolio_id=portfolio.id) }}">
{{ "common.cancel" | translate }}
</a>
</span>
<div
class="action-group-footer"
v-bind:class="{'action-group-footer--expand-offset': this.$root.sidenavExpanded, 'action-group-footer--collapse-offset': !this.$root.sidenavExpanded}">
<div class="action-group-footer--container">
<a class="usa-button" href="{{ url_for('applications.settings', application_id=application_id) }}">
{{ "portfolios.applications.new.step_3_button_text" | translate }}
</a>
<a class="usa-button usa-button-secondary" href="{{ url_for('applications.view_new_application_step_2', application_id=application.id) }}">
{{ "common.previous" | translate }}
</a>
<a href="{{ url_for('applications.portfolio_applications', portfolio_id=portfolio.id) }}">
{{ "common.cancel" | translate }}
</a>
</div>
</div>
{% endblock %}

View File

@ -4,21 +4,23 @@
{% from "components/text_input.html" import TextInput %}
{% block content %}
<base-form inline-template>
<form id="add-ccpo-user-form" action="{{ url_for('ccpo.submit_new_user') }}" method="POST">
{{ form.csrf_token }}
<h1>{{ "ccpo.form.add_user_title" | translate }}</h1>
<div class='form-row'>
<div class='form-col form-col--two-thirds'>
{{ TextInput(form.dod_id, validation='dodId', optional=False) }}
</div>
<div class="form-col form-col--third">
<div class='action-group'>
{{ SaveButton(text="common.next"|translate, element="input", additional_classes="action-group__action", form="add-ccpo-user-form") }}
<a class='action-group__action icon-link icon-link--default' href="{{ url_for('ccpo.users') }}">{{ "common.cancel" | translate }}</a>
<div class="ccpo-panel-container">
<base-form inline-template>
<form id="add-ccpo-user-form" action="{{ url_for('ccpo.submit_new_user') }}" method="POST">
{{ form.csrf_token }}
<h1>{{ "ccpo.form.add_user_title" | translate }}</h1>
<div class='form-row'>
<div class='form-col form-col--two-thirds'>
{{ TextInput(form.dod_id, validation='dodId', optional=False) }}
</div>
<div class="form-col form-col--third">
<div class='action-group'>
{{ SaveButton(text="common.next"|translate, element="input", additional_classes="action-group__action", form="add-ccpo-user-form") }}
<a class='action-group__action icon-link icon-link--default' href="{{ url_for('ccpo.users') }}">{{ "common.cancel" | translate }}</a>
</div>
</div>
</div>
</div>
</form>
</base-form>
</form>
</base-form>
</div>
{% endblock %}

View File

@ -3,31 +3,33 @@
{% from "components/text_input.html" import TextInput %}
{% block content %}
{% if new_user %}
<h3>{{ 'ccpo.form.confirm_user_title' | translate }}</h3>
<form id="add-ccpo-user-form" action="{{ url_for('ccpo.confirm_new_user') }}" method="POST">
{{ form.csrf_token }}
<input type="hidden" name="dod_id" value="{{ form.dod_id.data }}">
<div>
<p>
{{ "ccpo.form.confirm_user_text" | translate }}
</p>
<p>
{{ new_user.full_name }}
</p>
<p>
{{ new_user.email }}
</p>
</div>
<div class='action-group'>
<input
type='submit'
class='action-group__action usa-button'
value='{{ "ccpo.form.confirm_button" | translate }}'>
<a class='action-group__action icon-link icon-link--default' href="{{ url_for('ccpo.users') }}">
{{ "common.cancel" | translate }}
</a>
</div>
</form>
{% endif %}
<div class="ccpo-panel-container">
{% if new_user %}
<h3>{{ 'ccpo.form.confirm_user_title' | translate }}</h3>
<form id="add-ccpo-user-form" action="{{ url_for('ccpo.confirm_new_user') }}" method="POST">
{{ form.csrf_token }}
<input type="hidden" name="dod_id" value="{{ form.dod_id.data }}">
<div>
<p>
{{ "ccpo.form.confirm_user_text" | translate }}
</p>
<p>
{{ new_user.full_name }}
</p>
<p>
{{ new_user.email }}
</p>
</div>
<div class='action-group'>
<input
type='submit'
class='action-group__action usa-button'
value='{{ "ccpo.form.confirm_button" | translate }}'>
<a class='action-group__action icon-link icon-link--default' href="{{ url_for('ccpo.users') }}">
{{ "common.cancel" | translate }}
</a>
</div>
</form>
{% endif %}
</div>
{% endblock %}

View File

@ -6,78 +6,80 @@
{% from "components/modal.html" import Modal %}
{% block content %}
<div class='col'>
<div class="h2">
{{ "ccpo.users_title" | translate }}
</div>
<div class="ccpo-panel-container">
<div class='col'>
<div class="h2">
{{ "ccpo.users_title" | translate }}
</div>
{% include "fragments/flash.html" %}
<table>
<thead>
<tr>
<th>{{ "common.name" | translate }}</th>
<th>{{ "common.email" | translate }}</th>
<th>{{ "common.dod_id" | translate }}</th>
{% if user_can(permissions.DELETE_CCPO_USER) %}
<th></th>
{% endif %}
</tr>
</thead>
<tbody>
{% for user, form in users_info %}
{% set modal_id = "disable_ccpo_user_{}".format(user.dod_id) %}
{% set disable_button_class = 'button-danger-outline' %}
{% if user == g.current_user %}
{% set disable_button_class = "usa-button-disabled" %}
{% endif %}
{% include "fragments/flash.html" %}
<table>
<thead>
<tr>
<td>{{ user.full_name }}</td>
<td>{{ user.email }}</td>
<td>{{ user.dod_id }}</td>
<th>{{ "common.name" | translate }}</th>
<th>{{ "common.email" | translate }}</th>
<th>{{ "common.dod_id" | translate }}</th>
{% if user_can(permissions.DELETE_CCPO_USER) %}
<td>
<a v-on:click="openModal('{{ modal_id }}')" class='usa-button {{ disable_button_class }}'>
{{ "common.disable" | translate }}
</a>
</td>
<th></th>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</thead>
<tbody>
{% for user, form in users_info %}
{% set modal_id = "disable_ccpo_user_{}".format(user.dod_id) %}
{% set disable_button_class = 'button-danger-outline' %}
{% if user == g.current_user %}
{% set disable_button_class = "usa-button-disabled" %}
{% endif %}
<tr>
<td>{{ user.full_name }}</td>
<td>{{ user.email }}</td>
<td>{{ user.dod_id }}</td>
{% if user_can(permissions.DELETE_CCPO_USER) %}
<td>
<a v-on:click="openModal('{{ modal_id }}')" class='usa-button {{ disable_button_class }}'>
{{ "common.disable" | translate }}
</a>
</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if user_can(permissions.CREATE_CCPO_USER) %}
<a class="icon-link" href="{{ url_for('ccpo.add_new_user')}}">
{{ "ccpo.add_user" | translate }} {{ Icon("plus") }}
</a>
{% endif %}
{% if user_can(permissions.DELETE_CCPO_USER) %}
{% for user, form in users_info %}
{% set modal_id = "disable_ccpo_user_{}".format(user.dod_id) %}
{% call Modal(name=modal_id) %}
<h1>Disable CCPO User</h1>
<hr>
{{
Alert(
title=("components.modal.destructive_title" | translate),
message=("ccpo.disable_user.alert_message" | translate({"user_name": user.full_name})),
level="warning"
)
}}
{{
DeleteConfirmation(
modal_id=modal_id,
delete_text='Remove Access',
delete_action=(url_for('ccpo.remove_access', user_id=user.id)),
form=form,
confirmation_text='remove'
)
}}
{% endcall %}
{% endfor %}
{% endif %}
</div>
{% if user_can(permissions.CREATE_CCPO_USER) %}
<a class="icon-link" href="{{ url_for('ccpo.add_new_user')}}">
{{ "ccpo.add_user" | translate }} {{ Icon("plus") }}
</a>
{% endif %}
{% if user_can(permissions.DELETE_CCPO_USER) %}
{% for user, form in users_info %}
{% set modal_id = "disable_ccpo_user_{}".format(user.dod_id) %}
{% call Modal(name=modal_id) %}
<h1>Disable CCPO User</h1>
<hr>
{{
Alert(
title=("components.modal.destructive_title" | translate),
message=("ccpo.disable_user.alert_message" | translate({"user_name": user.full_name})),
level="warning"
)
}}
{{
DeleteConfirmation(
modal_id=modal_id,
delete_text='Remove Access',
delete_action=(url_for('ccpo.remove_access', user_id=user.id)),
form=form,
confirmation_text='remove'
)
}}
{% endcall %}
{% endfor %}
{% endif %}
{% endblock %}

View File

@ -5,6 +5,7 @@
{% block content %}
<main class="usa-section usa-content error-page">
<div class="panel">
<div class="panel__heading">
{{ Icon('cloud', classes="icon--red icon--large")}}
<hr>
@ -17,6 +18,7 @@
{%- endif %}
</p>
</div>
</div>
</main>
{% endblock %}

View File

@ -10,29 +10,30 @@
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='img/favicon.ico') }} " />
</head>
<body class="{% if g.modalOpen %} modalOpen{% endif %}">
<div id='app-root'>
{% block template_vars %}{% endblock %}
{% block template_vars %}{% endblock %}
{% include 'components/usa_header.html' %}
{% include 'components/usa_header.html' %}
{% include 'navigation/topbar.html' %}
{% include 'navigation/topbar.html' %}
<div class='global-layout'>
<div class='global-layout'>
<div class='global-panel-container'>
{% block sidenav %}{% endblock %}
<div class='global-panel-container'>
{% block sidenav %}{% endblock %}
{% block content %}
these are not the droids you are looking for
{% endblock %}
{% block content %}
these are not the droids you are looking for
{% endblock %}
</div>
</div>
{% include 'footer.html' %}
{% block modal %}{% endblock %}
{% assets "js_all" %}
<script src="{{ ASSET_URL }}"></script>
{% endassets %}
</div>
{% include 'footer.html' %}
{% block modal %}{% endblock %}
{% assets "js_all" %}
<script src="{{ ASSET_URL }}"></script>
{% endassets %}
</body>
</html>

View File

@ -15,6 +15,7 @@
<p>{{ "portfolios.header" | translate }}</p>
<h1>{{ 'portfolios.new.title' | translate }}</h1>
</div>
</div>
{{ StickyCTA(text="portfolios.new.cta_step_1"|translate, context=("portfolios.new.sticky_header_context"|translate({"step": "1"}) )) }}
<base-form inline-template>
<div class="row">
@ -38,13 +39,18 @@
{{ "forms.portfolio.defense_component.help_text" | translate | safe }}
</div>
</div>
<div class='action-group-footer'>
{% block next_button %}
{{ SaveButton(text=('portfolios.new.save' | translate), form="portfolio-create", element="input") }}
{% endblock %}
<a class="usa-button usa-button-secondary" href="{{ url_for('atst.home') }}">
Cancel
</a>
<div
class='action-group-footer'
v-bind:class="{'action-group-footer--expand-offset': this.$root.sidenavExpanded, 'action-group-footer--collapse-offset': !this.$root.sidenavExpanded}">
<div class="action-group-footer--container">
{% block next_button %}
{{ SaveButton(text=('portfolios.new.save' | translate), form="portfolio-create", element="input") }}
{% endblock %}
<a class="usa-button usa-button-secondary" href="{{ url_for('atst.home') }}">
Cancel
</a>
</div>
</div>
</form>
</div>
</base-form>

View File

@ -31,7 +31,10 @@
<div class="task-order">
{% block to_builder_form_field %}{% endblock %}
</div>
<span class="action-group-footer">
<div
class="action-group-footer"
v-bind:class="{'action-group-footer--expand-offset': this.$root.sidenavExpanded, 'action-group-footer--collapse-offset': !this.$root.sidenavExpanded}">
<div class="action-group-footer--container">
{% block next_button %}
<input
type="submit"
@ -58,14 +61,13 @@
</a>
{%- endif %}
{% endif %}
<a
v-on:click="openModal('cancel')"
class="action-group__action icon-link">
{{ "common.cancel" | translate }}
</a>
</span>
</div>
</div>
</form>
</to-form>
{% endblock %}

View File

@ -5,6 +5,8 @@ from uuid import uuid4
import pytest
from tests.factories import ApplicationFactory, EnvironmentFactory
from tests.mock_azure import AUTH_CREDENTIALS, mock_azure
import pendulum
import pydantic
from atst.domain.csp.cloud import AzureCloudProvider
from atst.domain.csp.cloud.models import (
@ -20,10 +22,12 @@ from atst.domain.csp.cloud.models import (
BillingProfileTenantAccessCSPResult,
BillingProfileVerificationCSPPayload,
BillingProfileVerificationCSPResult,
CostManagementQueryCSPResult,
ProductPurchaseCSPPayload,
ProductPurchaseCSPResult,
ProductPurchaseVerificationCSPPayload,
ProductPurchaseVerificationCSPResult,
ReportingCSPPayload,
SubscriptionCreationCSPPayload,
SubscriptionCreationCSPResult,
SubscriptionVerificationCSPPayload,
@ -718,3 +722,77 @@ def test_create_subscription_verification(mock_azure: AzureCloudProvider):
payload
)
assert result.subscription_id == "60fbbb72-0516-4253-ab18-c92432ba3230"
def test_get_reporting_data(mock_azure: AzureCloudProvider):
mock_result = Mock()
mock_result.json.return_value = {
"eTag": None,
"id": "providers/Microsoft.Billing/billingAccounts/52865e4c-52e8-5a6c-da6b-c58f0814f06f:7ea5de9d-b8ce-4901-b1c5-d864320c7b03_2019-05-31/billingProfiles/XQDJ-6LB4-BG7-TGB/invoiceSections/P73M-XC7J-PJA-TGB/providers/Microsoft.CostManagement/query/e82d0cda-2ffb-4476-a98a-425c83c216f9",
"location": None,
"name": "e82d0cda-2ffb-4476-a98a-425c83c216f9",
"properties": {
"columns": [
{"name": "PreTaxCost", "type": "Number"},
{"name": "UsageDate", "type": "Number"},
{"name": "InvoiceId", "type": "String"},
{"name": "Currency", "type": "String"},
],
"nextLink": None,
"rows": [],
},
"sku": None,
"type": "Microsoft.CostManagement/query",
}
mock_result.ok = True
mock_azure.sdk.requests.post.return_value = mock_result
mock_azure = mock_get_secret(mock_azure)
# Subset of a profile's CSP data that we care about for reporting
csp_data = {
"tenant_id": "6d2d2d6c-a6d6-41e1-8bb1-73d11475f8f4",
"billing_profile_properties": {
"invoice_sections": [
{
"invoice_section_id": "providers/Microsoft.Billing/billingAccounts/52865e4c-52e8-5a6c-da6b-c58f0814f06f:7ea5de9d-b8ce-4901-b1c5-d864320c7b03_2019-05-31/billingProfiles/XQDJ-6LB4-BG7-TGB/invoiceSections/P73M-XC7J-PJA-TGB",
}
],
},
}
data: CostManagementQueryCSPResult = mock_azure.get_reporting_data(
ReportingCSPPayload(
from_date=pendulum.now().subtract(years=1).add(days=1).format("YYYY-MM-DD"),
to_date=pendulum.now().format("YYYY-MM-DD"),
**csp_data,
)
)
assert isinstance(data, CostManagementQueryCSPResult)
assert data.name == "e82d0cda-2ffb-4476-a98a-425c83c216f9"
assert len(data.properties.columns) == 4
def test_get_reporting_data_malformed_payload(mock_azure: AzureCloudProvider):
mock_result = Mock()
mock_result.ok = True
mock_azure.sdk.requests.post.return_value = mock_result
mock_azure = mock_get_secret(mock_azure)
# Malformed csp_data payloads that should throw pydantic validation errors
index_error = {
"tenant_id": "6d2d2d6c-a6d6-41e1-8bb1-73d11475f8f4",
"billing_profile_properties": {"invoice_sections": [],},
}
key_error = {
"tenant_id": "6d2d2d6c-a6d6-41e1-8bb1-73d11475f8f4",
"billing_profile_properties": {"invoice_sections": [{}],},
}
for malformed_payload in [key_error, index_error]:
with pytest.raises(pydantic.ValidationError):
assert mock_azure.get_reporting_data(
ReportingCSPPayload(
from_date="foo", to_date="bar", **malformed_payload,
)
)

View File

@ -1,5 +1,4 @@
import pytest
import pendulum
from uuid import uuid4
from atst.domain.environments import Environments
@ -14,6 +13,7 @@ from tests.factories import (
EnvironmentRoleFactory,
ApplicationRoleFactory,
)
from tests.utils import EnvQueryTest
def test_create_environments():
@ -119,40 +119,6 @@ def test_update_does_not_duplicate_names_within_application():
Environments.update(dupe_env, name)
class EnvQueryTest:
@property
def NOW(self):
return pendulum.now()
@property
def YESTERDAY(self):
return self.NOW.subtract(days=1)
@property
def TOMORROW(self):
return self.NOW.add(days=1)
def create_portfolio_with_clins(self, start_and_end_dates, env_data=None):
env_data = env_data or {}
return PortfolioFactory.create(
applications=[
{
"name": "Mos Eisley",
"description": "Where Han shot first",
"environments": [{"name": "thebar", **env_data}],
}
],
task_orders=[
{
"create_clins": [
{"start_date": start_date, "end_date": end_date}
for (start_date, end_date) in start_and_end_dates
]
}
],
)
class TestGetEnvironmentsPendingCreate(EnvQueryTest):
def test_with_expired_clins(self, session):
self.create_portfolio_with_clins([(self.YESTERDAY, self.YESTERDAY)])

View File

@ -26,6 +26,7 @@ from tests.factories import (
PortfolioStateMachineFactory,
get_all_portfolio_permission_sets,
)
from tests.utils import EnvQueryTest
@pytest.fixture(scope="function")
@ -263,10 +264,44 @@ def test_create_state_machine(portfolio):
assert fsm
def test_get_portfolios_pending_provisioning(session):
for x in range(5):
portfolio = PortfolioFactory.create()
sm = PortfolioStateMachineFactory.create(portfolio=portfolio)
if x == 2:
sm.state = FSMStates.COMPLETED
assert len(Portfolios.get_portfolios_pending_provisioning()) == 4
class TestGetPortfoliosPendingCreate(EnvQueryTest):
def test_finds_unstarted(self):
for x in range(5):
if x == 2:
state = "COMPLETED"
else:
state = "UNSTARTED"
self.create_portfolio_with_clins(
[(self.YESTERDAY, self.TOMORROW)], state_machine_status=state
)
assert len(Portfolios.get_portfolios_pending_provisioning(self.NOW)) == 4
def test_finds_created(self):
self.create_portfolio_with_clins(
[(self.YESTERDAY, self.TOMORROW)], state_machine_status="TENANT_CREATED"
)
assert len(Portfolios.get_portfolios_pending_provisioning(self.NOW)) == 1
def test_does_not_find_failed(self):
self.create_portfolio_with_clins(
[(self.YESTERDAY, self.TOMORROW)], state_machine_status="TENANT_FAILED"
)
assert len(Portfolios.get_portfolios_pending_provisioning(self.NOW)) == 0
def test_with_expired_clins(self):
self.create_portfolio_with_clins([(self.YESTERDAY, self.YESTERDAY)])
assert len(Portfolios.get_portfolios_pending_provisioning(self.NOW)) == 0
def test_with_active_clins(self):
portfolio = self.create_portfolio_with_clins([(self.YESTERDAY, self.TOMORROW)])
Portfolios.get_portfolios_pending_provisioning(self.NOW) == [portfolio.id]
def test_with_future_clins(self):
self.create_portfolio_with_clins([(self.TOMORROW, self.TOMORROW)])
assert len(Portfolios.get_portfolios_pending_provisioning(self.NOW)) == 0
def test_with_already_provisioned_env(self):
self.create_portfolio_with_clins(
[(self.YESTERDAY, self.TOMORROW)], env_data={"cloud_id": uuid4().hex}
)
assert len(Portfolios.get_portfolios_pending_provisioning(self.NOW)) == 0

View File

@ -286,9 +286,19 @@ def test_create_environment_no_dupes(session, celery_app, celery_worker):
assert environment.claimed_until == None
def test_dispatch_provision_portfolio(
csp, session, portfolio, celery_app, celery_worker, monkeypatch
):
def test_dispatch_provision_portfolio(csp, monkeypatch):
portfolio = PortfolioFactory.create(
task_orders=[
{
"create_clins": [
{
"start_date": pendulum.now().subtract(days=1),
"end_date": pendulum.now().add(days=1),
}
]
}
],
)
sm = PortfolioStateMachineFactory.create(portfolio=portfolio)
mock = Mock()
monkeypatch.setattr("atst.jobs.provision_portfolio", mock)

View File

@ -5,9 +5,12 @@ from unittest.mock import Mock
from OpenSSL import crypto
from cryptography.hazmat.backends import default_backend
from flask import template_rendered
import pendulum
from atst.utils.notification_sender import NotificationSender
import tests.factories as factories
@contextmanager
def captured_templates(app):
@ -62,3 +65,40 @@ def make_crl_list(x509_obj, x509_path):
issuer = x509_obj.issuer.public_bytes(default_backend())
filename = os.path.basename(x509_path)
return [(filename, issuer.hex())]
class EnvQueryTest:
@property
def NOW(self):
return pendulum.now()
@property
def YESTERDAY(self):
return self.NOW.subtract(days=1)
@property
def TOMORROW(self):
return self.NOW.add(days=1)
def create_portfolio_with_clins(
self, start_and_end_dates, env_data=None, state_machine_status=None
):
env_data = env_data or {}
return factories.PortfolioFactory.create(
state=state_machine_status,
applications=[
{
"name": "Mos Eisley",
"description": "Where Han shot first",
"environments": [{"name": "thebar", **env_data}],
}
],
task_orders=[
{
"create_clins": [
{"start_date": start_date, "end_date": end_date}
for (start_date, end_date) in start_and_end_dates
]
}
],
)