resolve conflict with staging
This commit is contained in:
commit
38e92e427b
2
Pipfile
2
Pipfile
@ -21,7 +21,7 @@ flask-wtf = "*"
|
||||
pyopenssl = "*"
|
||||
requests = "*"
|
||||
lockfile = "*"
|
||||
werkzeug = "*"
|
||||
werkzeug = "==0.16.1"
|
||||
PyYAML = "*"
|
||||
azure-storage = "*"
|
||||
azure-storage-common = "*"
|
||||
|
20
Pipfile.lock
generated
20
Pipfile.lock
generated
@ -1,7 +1,7 @@
|
||||
{
|
||||
"_meta": {
|
||||
"hash": {
|
||||
"sha256": "faa5dab7bc6d13d39c0ef80f015f34a7fce2d66bec273ff38b7bd9ba232a3502"
|
||||
"sha256": "44296f145fcb42cff5fadf14a706ec9598f4436ccbdf05e1d69fcd8316c89e8d"
|
||||
},
|
||||
"pipfile-spec": 6,
|
||||
"requires": {
|
||||
@ -164,10 +164,10 @@
|
||||
},
|
||||
"billiard": {
|
||||
"hashes": [
|
||||
"sha256:01afcb4e7c4fd6480940cfbd4d9edc19d7a7509d6ada533984d0d0f49901ec82",
|
||||
"sha256:b8809c74f648dfe69b973c8e660bcec00603758c9db8ba89d7719f88d5f01f26"
|
||||
"sha256:26fd494dc3251f8ce1f5559744f18aeed427fdaf29a75d7baae26752a5d3816f",
|
||||
"sha256:f4e09366653aa3cb3ae8ed16423f9ba1665ff426f087bcdbbed86bf3664fe02c"
|
||||
],
|
||||
"version": "==3.6.1.0"
|
||||
"version": "==3.6.2.0"
|
||||
},
|
||||
"celery": {
|
||||
"hashes": [
|
||||
@ -582,11 +582,11 @@
|
||||
},
|
||||
"redis": {
|
||||
"hashes": [
|
||||
"sha256:7595976eb0b4e1fc3ad5478f1fd44215a814ee184a7820de92726f559bdff9cd",
|
||||
"sha256:e933bdb504c69cbd5bdf4e2bb819a99644a36731cef4c59aa637cebfd5ddd4f9"
|
||||
"sha256:0dcfb335921b88a850d461dc255ff4708294943322bd55de6cfd68972490ca1f",
|
||||
"sha256:b205cffd05ebfd0a468db74f0eedbff8df1a7bfc47521516ade4692991bb0833"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==3.4.0"
|
||||
"version": "==3.4.1"
|
||||
},
|
||||
"requests": {
|
||||
"hashes": [
|
||||
@ -908,11 +908,11 @@
|
||||
},
|
||||
"ipython": {
|
||||
"hashes": [
|
||||
"sha256:0f4bcf18293fb666df8511feec0403bdb7e061a5842ea6e88a3177b0ceb34ead",
|
||||
"sha256:387686dd7fc9caf29d2fddcf3116c4b07a11d9025701d220c589a430b0171d8a"
|
||||
"sha256:d9459e7237e2e5858738ff9c3e26504b79899b58a6d49e574d352493d80684c6",
|
||||
"sha256:f6689108b1734501d3b59c84427259fd5ac5141afe2e846cfa8598eb811886c9"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==7.11.1"
|
||||
"version": "==7.12.0"
|
||||
},
|
||||
"ipython-genutils": {
|
||||
"hashes": [
|
||||
|
@ -0,0 +1,29 @@
|
||||
"""add application_role.cloud_id
|
||||
|
||||
Revision ID: 17da2a475429
|
||||
Revises: 50979d8ef680
|
||||
Create Date: 2020-02-01 10:43:03.073539
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '17da2a475429' # pragma: allowlist secret
|
||||
down_revision = '50979d8ef680' # pragma: allowlist secret
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('application_roles', sa.Column('cloud_id', sa.String(), nullable=True))
|
||||
op.add_column('application_roles', sa.Column('claimed_until', sa.TIMESTAMP(timezone=True), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('application_roles', 'cloud_id')
|
||||
op.drop_column('application_roles', 'claimed_until')
|
||||
# ### end Alembic commands ###
|
@ -1,8 +1,12 @@
|
||||
from itertools import groupby
|
||||
from typing import List
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.orm.exc import NoResultFound
|
||||
|
||||
from atst.database import db
|
||||
from atst.domain.environment_roles import EnvironmentRoles
|
||||
from atst.models import ApplicationRole, ApplicationRoleStatus
|
||||
from atst.models import Application, ApplicationRole, ApplicationRoleStatus, Portfolio
|
||||
from .permission_sets import PermissionSets
|
||||
from .exceptions import NotFoundError
|
||||
|
||||
@ -61,6 +65,15 @@ class ApplicationRoles(object):
|
||||
except NoResultFound:
|
||||
raise NotFoundError("application_role")
|
||||
|
||||
@classmethod
|
||||
def get_many(cls, ids):
|
||||
return (
|
||||
db.session.query(ApplicationRole)
|
||||
.filter(ApplicationRole.id.in_(ids))
|
||||
.filter(ApplicationRole.status != ApplicationRoleStatus.DISABLED)
|
||||
.all()
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def update_permission_sets(cls, application_role, new_perm_sets_names):
|
||||
application_role.permission_sets = ApplicationRoles._permission_sets_for_names(
|
||||
@ -92,3 +105,29 @@ class ApplicationRoles(object):
|
||||
|
||||
db.session.add(application_role)
|
||||
db.session.commit()
|
||||
|
||||
@classmethod
|
||||
def get_pending_creation(cls) -> List[List[UUID]]:
|
||||
"""
|
||||
Returns a list of lists of ApplicationRole IDs. The IDs
|
||||
should be grouped by user and portfolio.
|
||||
"""
|
||||
results = (
|
||||
db.session.query(ApplicationRole.id, ApplicationRole.user_id, Portfolio.id)
|
||||
.join(Application, Application.id == ApplicationRole.application_id)
|
||||
.join(Portfolio, Portfolio.id == Application.portfolio_id)
|
||||
.filter(Application.cloud_id.isnot(None))
|
||||
.filter(ApplicationRole.deleted == False)
|
||||
.filter(ApplicationRole.cloud_id.is_(None))
|
||||
.filter(ApplicationRole.user_id.isnot(None))
|
||||
.filter(ApplicationRole.status == ApplicationRoleStatus.ACTIVE)
|
||||
).all()
|
||||
|
||||
groups = []
|
||||
keyfunc = lambda pair: (pair[1], pair[2])
|
||||
sorted_results = sorted(results, key=keyfunc)
|
||||
for _, g in groupby(sorted_results, keyfunc):
|
||||
group = [pair[0] for pair in list(g)]
|
||||
groups.append(group)
|
||||
|
||||
return groups
|
||||
|
@ -6,7 +6,7 @@ from uuid import uuid4
|
||||
from atst.utils import sha256_hex
|
||||
|
||||
from .cloud_provider_interface import CloudProviderInterface
|
||||
from .exceptions import AuthenticationException
|
||||
from .exceptions import AuthenticationException, UserProvisioningException
|
||||
from .models import (
|
||||
SubscriptionCreationCSPPayload,
|
||||
SubscriptionCreationCSPResult,
|
||||
@ -24,6 +24,7 @@ from .models import (
|
||||
BillingProfileTenantAccessCSPResult,
|
||||
BillingProfileVerificationCSPPayload,
|
||||
BillingProfileVerificationCSPResult,
|
||||
CostManagementQueryCSPResult,
|
||||
KeyVaultCredentials,
|
||||
ManagementGroupCSPPayload,
|
||||
ManagementGroupCSPResponse,
|
||||
@ -35,6 +36,7 @@ from .models import (
|
||||
ProductPurchaseVerificationCSPResult,
|
||||
PrincipalAdminRoleCSPPayload,
|
||||
PrincipalAdminRoleCSPResult,
|
||||
ReportingCSPPayload,
|
||||
TaskOrderBillingCreationCSPPayload,
|
||||
TaskOrderBillingCreationCSPResult,
|
||||
TaskOrderBillingVerificationCSPPayload,
|
||||
@ -51,6 +53,8 @@ from .models import (
|
||||
TenantPrincipalCSPResult,
|
||||
TenantPrincipalOwnershipCSPPayload,
|
||||
TenantPrincipalOwnershipCSPResult,
|
||||
UserCSPPayload,
|
||||
UserCSPResult,
|
||||
)
|
||||
from .policy import AzurePolicyManager
|
||||
|
||||
@ -196,9 +200,9 @@ class AzureCloudProvider(CloudProviderInterface):
|
||||
creds = self._source_creds(payload.tenant_id)
|
||||
credentials = self._get_credential_obj(
|
||||
{
|
||||
"client_id": creds.root_sp_client_id,
|
||||
"secret_key": creds.root_sp_key,
|
||||
"tenant_id": creds.root_tenant_id,
|
||||
"client_id": creds.tenant_sp_client_id,
|
||||
"secret_key": creds.tenant_sp_key,
|
||||
"tenant_id": creds.tenant_id,
|
||||
},
|
||||
resource=self.sdk.cloud.endpoints.resource_manager,
|
||||
)
|
||||
@ -352,7 +356,9 @@ class AzureCloudProvider(CloudProviderInterface):
|
||||
tenant_admin_password=payload.password,
|
||||
),
|
||||
)
|
||||
return self._ok(TenantCSPResult(**result_dict))
|
||||
return self._ok(
|
||||
TenantCSPResult(domain_name=payload.domain_name, **result_dict)
|
||||
)
|
||||
else:
|
||||
return self._error(result.json())
|
||||
|
||||
@ -892,6 +898,80 @@ class AzureCloudProvider(CloudProviderInterface):
|
||||
|
||||
return service_principal
|
||||
|
||||
def create_user(self, payload: UserCSPPayload) -> UserCSPResult:
|
||||
"""Create a user in an Azure Active Directory instance.
|
||||
Unlike most of the methods on this interface, this requires
|
||||
two API calls: one POST to create the user and one PATCH to
|
||||
set the alternate email address. The email address cannot
|
||||
be set on the first API call. The email address is
|
||||
necessary so that users can do Self-Service Password
|
||||
Recovery.
|
||||
|
||||
Arguments:
|
||||
payload {UserCSPPayload} -- a payload object with the
|
||||
data necessary for both calls
|
||||
|
||||
Returns:
|
||||
UserCSPResult -- a result object containing the AAD ID.
|
||||
"""
|
||||
graph_token = self._get_tenant_principal_token(
|
||||
payload.tenant_id, resource=self.graph_resource
|
||||
)
|
||||
if graph_token is None:
|
||||
raise AuthenticationException(
|
||||
"Could not resolve graph token for tenant admin"
|
||||
)
|
||||
|
||||
result = self._create_active_directory_user(graph_token, payload)
|
||||
self._update_active_directory_user_email(graph_token, result.id, payload)
|
||||
|
||||
return result
|
||||
|
||||
def _create_active_directory_user(self, graph_token, payload: UserCSPPayload):
|
||||
request_body = {
|
||||
"accountEnabled": True,
|
||||
"displayName": payload.display_name,
|
||||
"mailNickname": payload.mail_nickname,
|
||||
"userPrincipalName": payload.user_principal_name,
|
||||
"passwordProfile": {
|
||||
"forceChangePasswordNextSignIn": True,
|
||||
"password": payload.password,
|
||||
},
|
||||
}
|
||||
|
||||
auth_header = {
|
||||
"Authorization": f"Bearer {graph_token}",
|
||||
}
|
||||
|
||||
url = f"{self.graph_resource}v1.0/users"
|
||||
|
||||
response = self.sdk.requests.post(url, headers=auth_header, json=request_body)
|
||||
|
||||
if response.ok:
|
||||
return UserCSPResult(**response.json())
|
||||
else:
|
||||
raise UserProvisioningException(f"Failed to create user: {response.json()}")
|
||||
|
||||
def _update_active_directory_user_email(
|
||||
self, graph_token, user_id, payload: UserCSPPayload
|
||||
):
|
||||
request_body = {"otherMails": [payload.email]}
|
||||
|
||||
auth_header = {
|
||||
"Authorization": f"Bearer {graph_token}",
|
||||
}
|
||||
|
||||
url = f"{self.graph_resource}v1.0/users/{user_id}"
|
||||
|
||||
response = self.sdk.requests.patch(url, headers=auth_header, json=request_body)
|
||||
|
||||
if response.ok:
|
||||
return True
|
||||
else:
|
||||
raise UserProvisioningException(
|
||||
f"Failed update user email: {response.json()}"
|
||||
)
|
||||
|
||||
def _extract_subscription_id(self, subscription_url):
|
||||
sub_id_match = SUBSCRIPTION_ID_REGEX.match(subscription_url)
|
||||
|
||||
@ -913,14 +993,15 @@ class AzureCloudProvider(CloudProviderInterface):
|
||||
creds.root_tenant_id, creds.root_sp_client_id, creds.root_sp_key
|
||||
)
|
||||
|
||||
def _get_sp_token(self, tenant_id, client_id, secret_key):
|
||||
def _get_sp_token(self, tenant_id, client_id, secret_key, resource=None):
|
||||
context = self.sdk.adal.AuthenticationContext(
|
||||
f"{self.sdk.cloud.endpoints.active_directory}/{tenant_id}"
|
||||
)
|
||||
|
||||
resource = resource or self.sdk.cloud.endpoints.resource_manager
|
||||
# TODO: handle failure states here
|
||||
token_response = context.acquire_token_with_client_credentials(
|
||||
self.sdk.cloud.endpoints.resource_manager, client_id, secret_key
|
||||
resource, client_id, secret_key
|
||||
)
|
||||
|
||||
return token_response.get("accessToken", None)
|
||||
@ -981,10 +1062,13 @@ class AzureCloudProvider(CloudProviderInterface):
|
||||
"tenant_id": self.tenant_id,
|
||||
}
|
||||
|
||||
def _get_tenant_principal_token(self, tenant_id):
|
||||
def _get_tenant_principal_token(self, tenant_id, resource=None):
|
||||
creds = self._source_creds(tenant_id)
|
||||
return self._get_sp_token(
|
||||
creds.tenant_id, creds.tenant_sp_client_id, creds.tenant_sp_key
|
||||
creds.tenant_id,
|
||||
creds.tenant_sp_client_id,
|
||||
creds.tenant_sp_key,
|
||||
resource=resource,
|
||||
)
|
||||
|
||||
def _get_elevated_management_token(self, tenant_id):
|
||||
@ -1030,3 +1114,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())
|
||||
|
@ -88,17 +88,6 @@ class UserProvisioningException(GeneralCSPException):
|
||||
"""Failed to provision a user
|
||||
"""
|
||||
|
||||
def __init__(self, env_identifier, user_identifier, reason):
|
||||
self.env_identifier = env_identifier
|
||||
self.user_identifier = user_identifier
|
||||
self.reason = reason
|
||||
|
||||
@property
|
||||
def message(self):
|
||||
return "Failed to create user {} for environment {}: {}".format(
|
||||
self.user_identifier, self.env_identifier, self.reason
|
||||
)
|
||||
|
||||
|
||||
class UserRemovalException(GeneralCSPException):
|
||||
"""Failed to remove a user
|
||||
|
@ -29,12 +29,15 @@ from .models import (
|
||||
ManagementGroupCSPResponse,
|
||||
ManagementGroupGetCSPPayload,
|
||||
ManagementGroupGetCSPResponse,
|
||||
CostManagementQueryCSPResult,
|
||||
CostManagementQueryProperties,
|
||||
ProductPurchaseCSPPayload,
|
||||
ProductPurchaseCSPResult,
|
||||
ProductPurchaseVerificationCSPPayload,
|
||||
ProductPurchaseVerificationCSPResult,
|
||||
PrincipalAdminRoleCSPPayload,
|
||||
PrincipalAdminRoleCSPResult,
|
||||
ReportingCSPPayload,
|
||||
SubscriptionCreationCSPPayload,
|
||||
SubscriptionCreationCSPResult,
|
||||
SubscriptionVerificationCSPPayload,
|
||||
@ -55,6 +58,8 @@ from .models import (
|
||||
TenantPrincipalCSPResult,
|
||||
TenantPrincipalOwnershipCSPPayload,
|
||||
TenantPrincipalOwnershipCSPResult,
|
||||
UserCSPPayload,
|
||||
UserCSPResult,
|
||||
)
|
||||
|
||||
|
||||
@ -179,6 +184,7 @@ class MockCloudProvider(CloudProviderInterface):
|
||||
"tenant_id": "",
|
||||
"user_id": "",
|
||||
"user_object_id": "",
|
||||
"domain_name": "",
|
||||
"tenant_admin_username": "test",
|
||||
"tenant_admin_password": "test",
|
||||
}
|
||||
@ -501,8 +507,35 @@ class MockCloudProvider(CloudProviderInterface):
|
||||
id=f"{AZURE_MGMNT_PATH}{payload.management_group_name}"
|
||||
)
|
||||
|
||||
def create_user(self, payload: UserCSPPayload):
|
||||
self._maybe_raise(self.UNAUTHORIZED_RATE, GeneralCSPException)
|
||||
|
||||
return UserCSPResult(id=str(uuid4()))
|
||||
|
||||
def get_credentials(self, scope="portfolio", tenant_id=None):
|
||||
return self.root_creds()
|
||||
|
||||
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,)
|
||||
)
|
||||
|
@ -1,6 +1,7 @@
|
||||
from secrets import token_urlsafe
|
||||
from typing import Dict, List, Optional
|
||||
import re
|
||||
from uuid import uuid4
|
||||
import re
|
||||
|
||||
from pydantic import BaseModel, validator, root_validator
|
||||
|
||||
@ -39,6 +40,7 @@ class TenantCSPResult(AliasModel):
|
||||
user_id: str
|
||||
tenant_id: str
|
||||
user_object_id: str
|
||||
domain_name: str
|
||||
|
||||
tenant_admin_username: Optional[str]
|
||||
tenant_admin_password: Optional[str]
|
||||
@ -484,3 +486,57 @@ class ProductPurchaseVerificationCSPPayload(BaseCSPPayload):
|
||||
|
||||
class ProductPurchaseVerificationCSPResult(AliasModel):
|
||||
premium_purchase_date: str
|
||||
|
||||
|
||||
class UserCSPPayload(BaseCSPPayload):
|
||||
display_name: str
|
||||
tenant_host_name: str
|
||||
email: str
|
||||
password: Optional[str]
|
||||
|
||||
@property
|
||||
def user_principal_name(self):
|
||||
return f"{self.mail_nickname}@{self.tenant_host_name}.onmicrosoft.com"
|
||||
|
||||
@property
|
||||
def mail_nickname(self):
|
||||
return self.display_name.replace(" ", ".").lower()
|
||||
|
||||
@validator("password", pre=True, always=True)
|
||||
def supply_password_default(cls, password):
|
||||
return password or token_urlsafe(16)
|
||||
|
||||
|
||||
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")
|
||||
|
@ -79,7 +79,7 @@ class AzureFileService(FileService):
|
||||
sas_token = bbs.generate_blob_shared_access_signature(
|
||||
self.container_name,
|
||||
object_name,
|
||||
permission=self.BlobPermissions.READ,
|
||||
permission=self.BlobSasPermissions(read=True),
|
||||
expiry=datetime.utcnow() + self.timeout,
|
||||
content_disposition=f"attachment; filename={filename}",
|
||||
protocol="https",
|
||||
|
@ -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()
|
||||
|
81
atst/jobs.py
81
atst/jobs.py
@ -3,16 +3,16 @@ import pendulum
|
||||
|
||||
from atst.database import db
|
||||
from atst.queue import celery
|
||||
from atst.models import EnvironmentRole, JobFailure
|
||||
from atst.models import JobFailure
|
||||
from atst.domain.csp.cloud.exceptions import GeneralCSPException
|
||||
from atst.domain.csp.cloud import CloudProviderInterface
|
||||
from atst.domain.applications import Applications
|
||||
from atst.domain.environments import Environments
|
||||
from atst.domain.portfolios import Portfolios
|
||||
from atst.domain.environment_roles import EnvironmentRoles
|
||||
from atst.models.utils import claim_for_update
|
||||
from atst.domain.application_roles import ApplicationRoles
|
||||
from atst.models.utils import claim_for_update, claim_many_for_update
|
||||
from atst.utils.localization import translate
|
||||
from atst.domain.csp.cloud.models import ApplicationCSPPayload
|
||||
from atst.domain.csp.cloud.models import ApplicationCSPPayload, UserCSPPayload
|
||||
|
||||
|
||||
class RecordFailure(celery.Task):
|
||||
@ -75,6 +75,34 @@ def do_create_application(csp: CloudProviderInterface, application_id=None):
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def do_create_user(csp: CloudProviderInterface, application_role_ids=None):
|
||||
if not application_role_ids:
|
||||
return
|
||||
|
||||
app_roles = ApplicationRoles.get_many(application_role_ids)
|
||||
|
||||
with claim_many_for_update(app_roles) as app_roles:
|
||||
|
||||
if any([ar.cloud_id for ar in app_roles]):
|
||||
return
|
||||
|
||||
csp_details = app_roles[0].application.portfolio.csp_data
|
||||
user = app_roles[0].user
|
||||
|
||||
payload = UserCSPPayload(
|
||||
tenant_id=csp_details.get("tenant_id"),
|
||||
tenant_host_name=csp_details.get("domain_name"),
|
||||
display_name=user.full_name,
|
||||
email=user.email,
|
||||
)
|
||||
result = csp.create_user(payload)
|
||||
for app_role in app_roles:
|
||||
app_role.cloud_id = result.id
|
||||
db.session.add(app_role)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def do_create_environment(csp: CloudProviderInterface, environment_id=None):
|
||||
environment = Environments.get(environment_id)
|
||||
|
||||
@ -128,21 +156,6 @@ def render_email(template_path, context):
|
||||
return app.jinja_env.get_template(template_path).render(context)
|
||||
|
||||
|
||||
def do_provision_user(csp: CloudProviderInterface, environment_role_id=None):
|
||||
environment_role = EnvironmentRoles.get_by_id(environment_role_id)
|
||||
|
||||
with claim_for_update(environment_role) as environment_role:
|
||||
credentials = environment_role.environment.csp_credentials
|
||||
|
||||
csp_user_id = csp.create_or_update_user(
|
||||
credentials, environment_role, environment_role.role
|
||||
)
|
||||
environment_role.csp_user_id = csp_user_id
|
||||
environment_role.status = EnvironmentRole.Status.COMPLETED
|
||||
db.session.add(environment_role)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def do_work(fn, task, csp, **kwargs):
|
||||
try:
|
||||
fn(csp, **kwargs)
|
||||
@ -166,6 +179,13 @@ def create_application(self, application_id=None):
|
||||
do_work(do_create_application, self, app.csp.cloud, application_id=application_id)
|
||||
|
||||
|
||||
@celery.task(bind=True, base=RecordFailure)
|
||||
def create_user(self, application_role_ids=None):
|
||||
do_work(
|
||||
do_create_user, self, app.csp.cloud, application_role_ids=application_role_ids
|
||||
)
|
||||
|
||||
|
||||
@celery.task(bind=True, base=RecordFailure)
|
||||
def create_environment(self, environment_id=None):
|
||||
do_work(do_create_environment, self, app.csp.cloud, environment_id=environment_id)
|
||||
@ -178,19 +198,12 @@ def create_atat_admin_user(self, environment_id=None):
|
||||
)
|
||||
|
||||
|
||||
@celery.task(bind=True)
|
||||
def provision_user(self, environment_role_id=None):
|
||||
do_work(
|
||||
do_provision_user, self, app.csp.cloud, environment_role_id=environment_role_id
|
||||
)
|
||||
|
||||
|
||||
@celery.task(bind=True)
|
||||
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)
|
||||
|
||||
|
||||
@ -200,6 +213,12 @@ def dispatch_create_application(self):
|
||||
create_application.delay(application_id=application_id)
|
||||
|
||||
|
||||
@celery.task(bind=True)
|
||||
def dispatch_create_user(self):
|
||||
for application_role_ids in ApplicationRoles.get_pending_creation():
|
||||
create_user.delay(application_role_ids=application_role_ids)
|
||||
|
||||
|
||||
@celery.task(bind=True)
|
||||
def dispatch_create_environment(self):
|
||||
for environment_id in Environments.get_environments_pending_creation(
|
||||
@ -214,11 +233,3 @@ def dispatch_create_atat_admin_user(self):
|
||||
pendulum.now()
|
||||
):
|
||||
create_atat_admin_user.delay(environment_id=environment_id)
|
||||
|
||||
|
||||
@celery.task(bind=True)
|
||||
def dispatch_provision_user(self):
|
||||
for (
|
||||
environment_role_id
|
||||
) in EnvironmentRoles.get_environment_roles_pending_creation():
|
||||
provision_user.delay(environment_role_id=environment_role_id)
|
||||
|
@ -1,4 +1,4 @@
|
||||
from sqlalchemy import and_, Column, ForeignKey, String, UniqueConstraint, TIMESTAMP
|
||||
from sqlalchemy import and_, Column, ForeignKey, String, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship, synonym
|
||||
|
||||
from atst.models.base import Base
|
||||
@ -9,7 +9,11 @@ from atst.models.types import Id
|
||||
|
||||
|
||||
class Application(
|
||||
Base, mixins.TimestampsMixin, mixins.AuditableMixin, mixins.DeletableMixin
|
||||
Base,
|
||||
mixins.TimestampsMixin,
|
||||
mixins.AuditableMixin,
|
||||
mixins.DeletableMixin,
|
||||
mixins.ClaimableMixin,
|
||||
):
|
||||
__tablename__ = "applications"
|
||||
|
||||
@ -41,7 +45,6 @@ class Application(
|
||||
)
|
||||
|
||||
cloud_id = Column(String)
|
||||
claimed_until = Column(TIMESTAMP(timezone=True))
|
||||
|
||||
@property
|
||||
def users(self):
|
||||
|
@ -1,5 +1,5 @@
|
||||
from enum import Enum
|
||||
from sqlalchemy import Index, ForeignKey, Column, Enum as SQLAEnum, Table
|
||||
from sqlalchemy import Index, ForeignKey, Column, Enum as SQLAEnum, Table, String
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.event import listen
|
||||
@ -33,6 +33,7 @@ class ApplicationRole(
|
||||
mixins.AuditableMixin,
|
||||
mixins.PermissionsMixin,
|
||||
mixins.DeletableMixin,
|
||||
mixins.ClaimableMixin,
|
||||
):
|
||||
__tablename__ = "application_roles"
|
||||
|
||||
@ -59,6 +60,8 @@ class ApplicationRole(
|
||||
primaryjoin="and_(EnvironmentRole.application_role_id == ApplicationRole.id, EnvironmentRole.deleted == False)",
|
||||
)
|
||||
|
||||
cloud_id = Column(String)
|
||||
|
||||
@property
|
||||
def latest_invitation(self):
|
||||
if self.invitations:
|
||||
|
@ -1,4 +1,4 @@
|
||||
from sqlalchemy import Column, ForeignKey, String, TIMESTAMP, UniqueConstraint
|
||||
from sqlalchemy import Column, ForeignKey, String, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from enum import Enum
|
||||
@ -9,7 +9,11 @@ import atst.models.types as types
|
||||
|
||||
|
||||
class Environment(
|
||||
Base, mixins.TimestampsMixin, mixins.AuditableMixin, mixins.DeletableMixin
|
||||
Base,
|
||||
mixins.TimestampsMixin,
|
||||
mixins.AuditableMixin,
|
||||
mixins.DeletableMixin,
|
||||
mixins.ClaimableMixin,
|
||||
):
|
||||
__tablename__ = "environments"
|
||||
|
||||
@ -28,8 +32,6 @@ class Environment(
|
||||
cloud_id = Column(String)
|
||||
root_user_info = Column(JSONB(none_as_null=True))
|
||||
|
||||
claimed_until = Column(TIMESTAMP(timezone=True))
|
||||
|
||||
roles = relationship(
|
||||
"EnvironmentRole",
|
||||
back_populates="environment",
|
||||
|
@ -1,5 +1,5 @@
|
||||
from enum import Enum
|
||||
from sqlalchemy import Index, ForeignKey, Column, String, TIMESTAMP, Enum as SQLAEnum
|
||||
from sqlalchemy import Index, ForeignKey, Column, String, Enum as SQLAEnum
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
@ -15,7 +15,11 @@ class CSPRole(Enum):
|
||||
|
||||
|
||||
class EnvironmentRole(
|
||||
Base, mixins.TimestampsMixin, mixins.AuditableMixin, mixins.DeletableMixin
|
||||
Base,
|
||||
mixins.TimestampsMixin,
|
||||
mixins.AuditableMixin,
|
||||
mixins.DeletableMixin,
|
||||
mixins.ClaimableMixin,
|
||||
):
|
||||
__tablename__ = "environment_roles"
|
||||
|
||||
@ -33,7 +37,6 @@ class EnvironmentRole(
|
||||
application_role = relationship("ApplicationRole")
|
||||
|
||||
csp_user_id = Column(String())
|
||||
claimed_until = Column(TIMESTAMP(timezone=True))
|
||||
|
||||
class Status(Enum):
|
||||
PENDING = "pending"
|
||||
|
@ -4,3 +4,4 @@ from .permissions import PermissionsMixin
|
||||
from .deletable import DeletableMixin
|
||||
from .invites import InvitesMixin
|
||||
from .state_machines import FSMMixin
|
||||
from .claimable import ClaimableMixin
|
||||
|
5
atst/models/mixins/claimable.py
Normal file
5
atst/models/mixins/claimable.py
Normal file
@ -0,0 +1,5 @@
|
||||
from sqlalchemy import Column, TIMESTAMP
|
||||
|
||||
|
||||
class ClaimableMixin(object):
|
||||
claimed_until = Column(TIMESTAMP(timezone=True))
|
@ -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)
|
||||
|
@ -1,3 +1,5 @@
|
||||
from typing import List
|
||||
|
||||
from sqlalchemy import func, sql, Interval, and_, or_
|
||||
from contextlib import contextmanager
|
||||
|
||||
@ -28,7 +30,7 @@ def claim_for_update(resource, minutes=30):
|
||||
.filter(
|
||||
and_(
|
||||
Model.id == resource.id,
|
||||
or_(Model.claimed_until == None, Model.claimed_until <= func.now()),
|
||||
or_(Model.claimed_until.is_(None), Model.claimed_until <= func.now()),
|
||||
)
|
||||
)
|
||||
.update({"claimed_until": claim_until}, synchronize_session="fetch")
|
||||
@ -48,3 +50,51 @@ def claim_for_update(resource, minutes=30):
|
||||
Model.claimed_until != None
|
||||
).update({"claimed_until": None}, synchronize_session="fetch")
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def claim_many_for_update(resources: List, minutes=30):
|
||||
"""
|
||||
Claim a mutually exclusive expiring hold on a group of resources.
|
||||
Uses the database as a central source of time in case the server clocks have drifted.
|
||||
|
||||
Args:
|
||||
resources: A list of SQLAlchemy model instances with a `claimed_until` attribute.
|
||||
minutes: The maximum amount of time, in minutes, to hold the claim.
|
||||
"""
|
||||
Model = resources[0].__class__
|
||||
|
||||
claim_until = func.now() + func.cast(
|
||||
sql.functions.concat(minutes, " MINUTES"), Interval
|
||||
)
|
||||
|
||||
ids = tuple(r.id for r in resources)
|
||||
|
||||
# Optimistically query for and update the resources in question. If they're
|
||||
# already claimed, `rows_updated` will be 0 and we can give up.
|
||||
rows_updated = (
|
||||
db.session.query(Model)
|
||||
.filter(
|
||||
and_(
|
||||
Model.id.in_(ids),
|
||||
or_(Model.claimed_until.is_(None), Model.claimed_until <= func.now()),
|
||||
)
|
||||
)
|
||||
.update({"claimed_until": claim_until}, synchronize_session="fetch")
|
||||
)
|
||||
if rows_updated < 1:
|
||||
# TODO: Generalize this exception class so it can take multiple resources
|
||||
raise ClaimFailedException(resources[0])
|
||||
|
||||
# Fetch the claimed resources
|
||||
claimed = db.session.query(Model).filter(Model.id.in_(ids)).all()
|
||||
|
||||
try:
|
||||
# Give the resource to the caller.
|
||||
yield claimed
|
||||
finally:
|
||||
# Release the claim.
|
||||
db.session.query(Model).filter(Model.id.in_(ids)).filter(
|
||||
Model.claimed_until != None
|
||||
).update({"claimed_until": None}, synchronize_session="fetch")
|
||||
db.session.commit()
|
||||
|
@ -23,8 +23,8 @@ def update_celery(celery, app):
|
||||
"task": "atst.jobs.dispatch_create_atat_admin_user",
|
||||
"schedule": 60,
|
||||
},
|
||||
"beat-dispatch_provision_user": {
|
||||
"task": "atst.jobs.dispatch_provision_user",
|
||||
"beat-dispatch_create_user": {
|
||||
"task": "atst.jobs.dispatch_create_user",
|
||||
"schedule": 60,
|
||||
},
|
||||
}
|
||||
|
@ -467,9 +467,10 @@ def revoke_invite(application_id, application_role_id):
|
||||
if invite.is_pending:
|
||||
ApplicationInvitations.revoke(invite.token)
|
||||
flash(
|
||||
"application_invite_revoked",
|
||||
"invite_revoked",
|
||||
resource="Application",
|
||||
user_name=app_role.user_name,
|
||||
application_name=g.application.name,
|
||||
resource_name=g.application.name,
|
||||
)
|
||||
else:
|
||||
flash(
|
||||
|
@ -37,8 +37,14 @@ def accept_invitation(portfolio_token):
|
||||
)
|
||||
@user_can(Permissions.EDIT_PORTFOLIO_USERS, message="revoke invitation")
|
||||
def revoke_invitation(portfolio_id, portfolio_token):
|
||||
PortfolioInvitations.revoke(portfolio_token)
|
||||
invite = PortfolioInvitations.revoke(portfolio_token)
|
||||
|
||||
flash(
|
||||
"invite_revoked",
|
||||
resource="Portfolio",
|
||||
user_name=invite.user_name,
|
||||
resource_name=g.portfolio.name,
|
||||
)
|
||||
return redirect(
|
||||
url_for(
|
||||
"portfolios.admin",
|
||||
|
@ -33,11 +33,6 @@ MESSAGES = {
|
||||
"message": "flash.application_invite.resent.message",
|
||||
"category": "success",
|
||||
},
|
||||
"application_invite_revoked": {
|
||||
"title": "flash.application_invite.revoked.title",
|
||||
"message": "flash.application_invite.revoked.message",
|
||||
"category": "success",
|
||||
},
|
||||
"application_member_removed": {
|
||||
"title": "flash.application_member.removed.title",
|
||||
"message": "flash.application_member.removed.message",
|
||||
@ -103,6 +98,11 @@ MESSAGES = {
|
||||
"message": None,
|
||||
"category": "warning",
|
||||
},
|
||||
"invite_revoked": {
|
||||
"title": "flash.invite_revoked.title",
|
||||
"message": "flash.invite_revoked.message",
|
||||
"category": "success",
|
||||
},
|
||||
"logged_out": {
|
||||
"title": "flash.logged_out.title",
|
||||
"message": "flash.logged_out.message",
|
||||
|
@ -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)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
@ -2,6 +2,13 @@ import { buildUploader } from '../lib/upload'
|
||||
import { emitFieldChange } from '../lib/emitters'
|
||||
import inputValidations from '../lib/input_validations'
|
||||
|
||||
function uploadResponseOkay(response) {
|
||||
// check BlobUploadCommonResponse: https://docs.microsoft.com/en-us/javascript/api/@azure/storage-blob/blobuploadcommonresponse?view=azure-node-latest
|
||||
// The upload operation is a PUT that should return a 201
|
||||
// https://docs.microsoft.com/en-us/rest/api/storageservices/put-blob#status-code
|
||||
return response._response.status === 201
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'uploadinput',
|
||||
|
||||
@ -21,7 +28,7 @@ export default {
|
||||
type: String,
|
||||
},
|
||||
sizeLimit: {
|
||||
type: String,
|
||||
type: Number,
|
||||
},
|
||||
},
|
||||
|
||||
@ -34,7 +41,7 @@ export default {
|
||||
sizeError: false,
|
||||
filenameError: false,
|
||||
downloadLink: '',
|
||||
fileSizeLimit: parseInt(this.sizeLimit),
|
||||
fileSizeLimit: this.sizeLimit,
|
||||
}
|
||||
},
|
||||
|
||||
@ -63,7 +70,7 @@ export default {
|
||||
|
||||
const uploader = await this.getUploader()
|
||||
const response = await uploader.upload(file)
|
||||
if (response.ok) {
|
||||
if (uploadResponseOkay(response)) {
|
||||
this.attachment = e.target.value
|
||||
this.$refs.attachmentFilename.value = file.name
|
||||
this.$refs.attachmentObjectName.value = response.objectName
|
||||
@ -73,7 +80,7 @@ export default {
|
||||
|
||||
this.downloadLink = await this.getDownloadLink(
|
||||
file.name,
|
||||
response.objectName
|
||||
uploader.objectName
|
||||
)
|
||||
} else {
|
||||
emitFieldChange(this)
|
||||
|
12
js/index.js
12
js/index.js
@ -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: ['!{', '}'],
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import Azure from 'azure-storage'
|
||||
import { BlobServiceClient } from '@azure/storage-blob'
|
||||
import 'whatwg-fetch'
|
||||
|
||||
class AzureUploader {
|
||||
@ -10,46 +10,22 @@ class AzureUploader {
|
||||
}
|
||||
|
||||
async upload(file) {
|
||||
const blobService = Azure.createBlobServiceWithSas(
|
||||
`https://${this.accountName}.blob.core.windows.net`,
|
||||
this.sasToken
|
||||
const blobServiceClient = new BlobServiceClient(
|
||||
`https://${this.accountName}.blob.core.windows.net?${this.sasToken}`
|
||||
)
|
||||
const fileReader = new FileReader()
|
||||
const containerClient = blobServiceClient.getContainerClient(
|
||||
this.containerName
|
||||
)
|
||||
const blobClient = containerClient.getBlockBlobClient(this.objectName)
|
||||
const options = {
|
||||
contentSettings: {
|
||||
contentType: 'application/pdf',
|
||||
blobHTTPHeaders: {
|
||||
blobContentType: 'application/pdf',
|
||||
},
|
||||
metadata: {
|
||||
filename: file.name,
|
||||
},
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
fileReader.addEventListener('load', f => {
|
||||
blobService.createBlockBlobFromText(
|
||||
this.containerName,
|
||||
`${this.objectName}`,
|
||||
f.target.result,
|
||||
options,
|
||||
(err, result) => {
|
||||
if (err) {
|
||||
resolve({ ok: false })
|
||||
} else {
|
||||
resolve({ ok: true, objectName: this.objectName })
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
fileReader.readAsText(file)
|
||||
})
|
||||
}
|
||||
|
||||
downloadUrl(objectName) {
|
||||
const blobService = Azure.createBlobServiceWithSas(
|
||||
`https://${this.accountName}.blob.core.windows.net`,
|
||||
this.sasToken
|
||||
)
|
||||
return blobService.getUrl(this.containerName, objectName, this.sasToken)
|
||||
return blobClient.uploadBrowserData(file, options)
|
||||
}
|
||||
}
|
||||
|
||||
@ -60,7 +36,8 @@ export class MockUploader {
|
||||
}
|
||||
|
||||
async upload(file, objectName) {
|
||||
return Promise.resolve({ ok: true, objectName: this.objectName })
|
||||
// mock BlobUploadCommonResponse structure: https://docs.microsoft.com/en-us/javascript/api/@azure/storage-blob/blobuploadcommonresponse?view=azure-node-latest
|
||||
return Promise.resolve({ _response: { status: 201 } })
|
||||
}
|
||||
}
|
||||
|
||||
|
15
js/mixins/expand_sidenav.js
Normal file
15
js/mixins/expand_sidenav.js
Normal 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
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
@ -14,9 +14,9 @@
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@azure/storage-blob": "^12.0.2",
|
||||
"ally.js": "^1.4.1",
|
||||
"autoprefixer": "^9.1.3",
|
||||
"azure-storage": "^2.10.3",
|
||||
"babel-polyfill": "^6.26.0",
|
||||
"date-fns": "^1.29.0",
|
||||
"ramda": "^0.25.0",
|
||||
|
@ -47,3 +47,4 @@
|
||||
@import "sections/application_edit";
|
||||
@import "sections/reports";
|
||||
@import "sections/task_order";
|
||||
@import "sections/ccpo";
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
3
styles/sections/_ccpo.scss
Normal file
3
styles/sections/_ccpo.scss
Normal file
@ -0,0 +1,3 @@
|
||||
.ccpo-panel-container {
|
||||
max-width: $max-panel-width;
|
||||
}
|
@ -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>
|
||||
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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 %}
|
||||
|
@ -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 %}
|
||||
|
@ -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 %}
|
||||
|
@ -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 %}
|
||||
|
@ -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 %}
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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 %}
|
||||
|
@ -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 (
|
||||
@ -24,10 +26,12 @@ from atst.domain.csp.cloud.models import (
|
||||
ManagementGroupCSPResponse,
|
||||
ManagementGroupGetCSPPayload,
|
||||
ManagementGroupGetCSPResponse,
|
||||
CostManagementQueryCSPResult,
|
||||
ProductPurchaseCSPPayload,
|
||||
ProductPurchaseCSPResult,
|
||||
ProductPurchaseVerificationCSPPayload,
|
||||
ProductPurchaseVerificationCSPResult,
|
||||
ReportingCSPPayload,
|
||||
SubscriptionCreationCSPPayload,
|
||||
SubscriptionCreationCSPResult,
|
||||
SubscriptionVerificationCSPPayload,
|
||||
@ -765,3 +769,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,
|
||||
)
|
||||
)
|
||||
|
@ -7,6 +7,7 @@ from atst.domain.csp.cloud.models import (
|
||||
KeyVaultCredentials,
|
||||
ManagementGroupCSPPayload,
|
||||
ManagementGroupCSPResponse,
|
||||
UserCSPPayload,
|
||||
)
|
||||
|
||||
|
||||
@ -97,3 +98,26 @@ def test_KeyVaultCredentials_enforce_root_creds():
|
||||
assert KeyVaultCredentials(
|
||||
root_tenant_id="an id", root_sp_client_id="C3PO", root_sp_key="beep boop"
|
||||
)
|
||||
|
||||
|
||||
user_payload = {
|
||||
"tenant_id": "123",
|
||||
"display_name": "Han Solo",
|
||||
"tenant_host_name": "rebelalliance",
|
||||
"email": "han@moseisley.cantina",
|
||||
}
|
||||
|
||||
|
||||
def test_UserCSPPayload_mail_nickname():
|
||||
payload = UserCSPPayload(**user_payload)
|
||||
assert payload.mail_nickname == f"han.solo"
|
||||
|
||||
|
||||
def test_UserCSPPayload_user_principal_name():
|
||||
payload = UserCSPPayload(**user_payload)
|
||||
assert payload.user_principal_name == f"han.solo@rebelalliance.onmicrosoft.com"
|
||||
|
||||
|
||||
def test_UserCSPPayload_password():
|
||||
payload = UserCSPPayload(**user_payload)
|
||||
assert payload.password
|
||||
|
@ -86,3 +86,79 @@ def test_disable(session):
|
||||
session.refresh(environment_role)
|
||||
assert member_role.status == ApplicationRoleStatus.DISABLED
|
||||
assert environment_role.deleted
|
||||
|
||||
|
||||
def test_get_pending_creation():
|
||||
|
||||
# ready Applications belonging to the same Portfolio
|
||||
portfolio_one = PortfolioFactory.create()
|
||||
ready_app = ApplicationFactory.create(cloud_id="123", portfolio=portfolio_one)
|
||||
ready_app2 = ApplicationFactory.create(cloud_id="321", portfolio=portfolio_one)
|
||||
|
||||
# ready Application belonging to a new Portfolio
|
||||
ready_app3 = ApplicationFactory.create(cloud_id="567")
|
||||
unready_app = ApplicationFactory.create()
|
||||
|
||||
# two distinct Users
|
||||
user_one = UserFactory.create()
|
||||
user_two = UserFactory.create()
|
||||
|
||||
# Two ApplicationRoles belonging to the same User and
|
||||
# different Applications. These should sort together because
|
||||
# they are all under the same portfolio (portfolio_one).
|
||||
role_one = ApplicationRoleFactory.create(
|
||||
user=user_one, application=ready_app, status=ApplicationRoleStatus.ACTIVE
|
||||
)
|
||||
role_two = ApplicationRoleFactory.create(
|
||||
user=user_one, application=ready_app2, status=ApplicationRoleStatus.ACTIVE
|
||||
)
|
||||
|
||||
# An ApplicationRole belonging to a different User. This will
|
||||
# be included but sort separately because it belongs to a
|
||||
# different user.
|
||||
role_three = ApplicationRoleFactory.create(
|
||||
user=user_two, application=ready_app, status=ApplicationRoleStatus.ACTIVE
|
||||
)
|
||||
|
||||
# An ApplicationRole belonging to one of the existing users
|
||||
# but under a different portfolio. It will sort separately.
|
||||
role_four = ApplicationRoleFactory.create(
|
||||
user=user_one, application=ready_app3, status=ApplicationRoleStatus.ACTIVE
|
||||
)
|
||||
|
||||
# This ApplicationRole will not be in the results because its
|
||||
# application is not ready (implicitly, its cloud_id is not
|
||||
# set.)
|
||||
ApplicationRoleFactory.create(
|
||||
user=UserFactory.create(),
|
||||
application=unready_app,
|
||||
status=ApplicationRoleStatus.ACTIVE,
|
||||
)
|
||||
|
||||
# This ApplicationRole will not be in the results because it
|
||||
# does not have a user associated.
|
||||
ApplicationRoleFactory.create(
|
||||
user=None, application=ready_app, status=ApplicationRoleStatus.ACTIVE,
|
||||
)
|
||||
|
||||
# This ApplicationRole will not be in the results because its
|
||||
# status is not ACTIVE.
|
||||
ApplicationRoleFactory.create(
|
||||
user=UserFactory.create(),
|
||||
application=unready_app,
|
||||
status=ApplicationRoleStatus.DISABLED,
|
||||
)
|
||||
|
||||
app_ids = ApplicationRoles.get_pending_creation()
|
||||
expected_ids = [[role_one.id, role_two.id], [role_three.id], [role_four.id]]
|
||||
# Sort them to produce the same order.
|
||||
assert sorted(app_ids) == sorted(expected_ids)
|
||||
|
||||
|
||||
def test_get_many():
|
||||
ar1 = ApplicationRoleFactory.create()
|
||||
ar2 = ApplicationRoleFactory.create()
|
||||
ApplicationRoleFactory.create()
|
||||
|
||||
result = ApplicationRoles.get_many([ar1.id, ar2.id])
|
||||
assert result == [ar1, ar2]
|
||||
|
@ -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)])
|
||||
|
@ -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
|
||||
|
104
tests/models/test_utils.py
Normal file
104
tests/models/test_utils.py
Normal file
@ -0,0 +1,104 @@
|
||||
from threading import Thread
|
||||
|
||||
from atst.domain.exceptions import ClaimFailedException
|
||||
from atst.models.utils import claim_for_update, claim_many_for_update
|
||||
|
||||
from tests.factories import EnvironmentFactory
|
||||
|
||||
|
||||
def test_claim_for_update(session):
|
||||
environment = EnvironmentFactory.create()
|
||||
|
||||
satisfied_claims = []
|
||||
exceptions = []
|
||||
|
||||
# Two threads race to do work on environment and check out the lock
|
||||
class FirstThread(Thread):
|
||||
def run(self):
|
||||
try:
|
||||
with claim_for_update(environment) as env:
|
||||
assert env.claimed_until
|
||||
satisfied_claims.append("FirstThread")
|
||||
except ClaimFailedException:
|
||||
exceptions.append("FirstThread")
|
||||
|
||||
class SecondThread(Thread):
|
||||
def run(self):
|
||||
try:
|
||||
with claim_for_update(environment) as env:
|
||||
assert env.claimed_until
|
||||
satisfied_claims.append("SecondThread")
|
||||
except ClaimFailedException:
|
||||
exceptions.append("SecondThread")
|
||||
|
||||
t1 = FirstThread()
|
||||
t2 = SecondThread()
|
||||
t1.start()
|
||||
t2.start()
|
||||
t1.join()
|
||||
t2.join()
|
||||
|
||||
session.refresh(environment)
|
||||
|
||||
assert len(satisfied_claims) == 1
|
||||
assert len(exceptions) == 1
|
||||
|
||||
if satisfied_claims == ["FirstThread"]:
|
||||
assert exceptions == ["SecondThread"]
|
||||
else:
|
||||
assert satisfied_claims == ["SecondThread"]
|
||||
assert exceptions == ["FirstThread"]
|
||||
|
||||
# The claim is released
|
||||
assert environment.claimed_until is None
|
||||
|
||||
|
||||
def test_claim_many_for_update(session):
|
||||
environments = [
|
||||
EnvironmentFactory.create(),
|
||||
EnvironmentFactory.create(),
|
||||
]
|
||||
|
||||
satisfied_claims = []
|
||||
exceptions = []
|
||||
|
||||
# Two threads race to do work on environment and check out the lock
|
||||
class FirstThread(Thread):
|
||||
def run(self):
|
||||
try:
|
||||
with claim_many_for_update(environments) as envs:
|
||||
assert all([e.claimed_until for e in envs])
|
||||
satisfied_claims.append("FirstThread")
|
||||
except ClaimFailedException:
|
||||
exceptions.append("FirstThread")
|
||||
|
||||
class SecondThread(Thread):
|
||||
def run(self):
|
||||
try:
|
||||
with claim_many_for_update(environments) as envs:
|
||||
assert all([e.claimed_until for e in envs])
|
||||
satisfied_claims.append("SecondThread")
|
||||
except ClaimFailedException:
|
||||
exceptions.append("SecondThread")
|
||||
|
||||
t1 = FirstThread()
|
||||
t2 = SecondThread()
|
||||
t1.start()
|
||||
t2.start()
|
||||
t1.join()
|
||||
t2.join()
|
||||
|
||||
for env in environments:
|
||||
session.refresh(env)
|
||||
|
||||
assert len(satisfied_claims) == 1
|
||||
assert len(exceptions) == 1
|
||||
|
||||
if satisfied_claims == ["FirstThread"]:
|
||||
assert exceptions == ["SecondThread"]
|
||||
else:
|
||||
assert satisfied_claims == ["SecondThread"]
|
||||
assert exceptions == ["FirstThread"]
|
||||
|
||||
# The claim is released
|
||||
# assert environment.claimed_until is None
|
@ -2,27 +2,25 @@ import pendulum
|
||||
import pytest
|
||||
from uuid import uuid4
|
||||
from unittest.mock import Mock
|
||||
from threading import Thread
|
||||
|
||||
from atst.domain.csp.cloud import MockCloudProvider
|
||||
from atst.domain.portfolios import Portfolios
|
||||
from atst.models import ApplicationRoleStatus
|
||||
|
||||
from atst.jobs import (
|
||||
RecordFailure,
|
||||
dispatch_create_environment,
|
||||
dispatch_create_application,
|
||||
dispatch_create_user,
|
||||
dispatch_create_atat_admin_user,
|
||||
dispatch_provision_portfolio,
|
||||
dispatch_provision_user,
|
||||
create_environment,
|
||||
do_provision_user,
|
||||
do_create_user,
|
||||
do_provision_portfolio,
|
||||
do_create_environment,
|
||||
do_create_application,
|
||||
do_create_atat_admin_user,
|
||||
)
|
||||
from atst.models.utils import claim_for_update
|
||||
from atst.domain.exceptions import ClaimFailedException
|
||||
from tests.factories import (
|
||||
EnvironmentFactory,
|
||||
EnvironmentRoleFactory,
|
||||
@ -30,6 +28,7 @@ from tests.factories import (
|
||||
PortfolioStateMachineFactory,
|
||||
ApplicationFactory,
|
||||
ApplicationRoleFactory,
|
||||
UserFactory,
|
||||
)
|
||||
from atst.models import CSPRole, EnvironmentRole, ApplicationRoleStatus, JobFailure
|
||||
|
||||
@ -126,6 +125,30 @@ def test_create_application_job_is_idempotent(csp):
|
||||
csp.create_application.assert_not_called()
|
||||
|
||||
|
||||
def test_create_user_job(session, csp):
|
||||
portfolio = PortfolioFactory.create(
|
||||
csp_data={
|
||||
"tenant_id": str(uuid4()),
|
||||
"domain_name": "rebelalliance.onmicrosoft.com",
|
||||
}
|
||||
)
|
||||
application = ApplicationFactory.create(portfolio=portfolio, cloud_id="321")
|
||||
user = UserFactory.create(
|
||||
first_name="Han", last_name="Solo", email="han@example.com"
|
||||
)
|
||||
app_role = ApplicationRoleFactory.create(
|
||||
application=application,
|
||||
user=user,
|
||||
status=ApplicationRoleStatus.ACTIVE,
|
||||
cloud_id=None,
|
||||
)
|
||||
|
||||
do_create_user(csp, [app_role.id])
|
||||
session.refresh(app_role)
|
||||
|
||||
assert app_role.cloud_id
|
||||
|
||||
|
||||
def test_create_atat_admin_user(csp, session):
|
||||
environment = EnvironmentFactory.create(cloud_id="something")
|
||||
do_create_atat_admin_user(csp, environment.id)
|
||||
@ -181,6 +204,29 @@ def test_dispatch_create_application(monkeypatch):
|
||||
mock.delay.assert_called_once_with(application_id=app.id)
|
||||
|
||||
|
||||
def test_dispatch_create_user(monkeypatch):
|
||||
application = ApplicationFactory.create(cloud_id="123")
|
||||
user = UserFactory.create(
|
||||
first_name="Han", last_name="Solo", email="han@example.com"
|
||||
)
|
||||
app_role = ApplicationRoleFactory.create(
|
||||
application=application,
|
||||
user=user,
|
||||
status=ApplicationRoleStatus.ACTIVE,
|
||||
cloud_id=None,
|
||||
)
|
||||
|
||||
mock = Mock()
|
||||
monkeypatch.setattr("atst.jobs.create_user", mock)
|
||||
|
||||
# When dispatch_create_user is called
|
||||
dispatch_create_user.run()
|
||||
|
||||
# It should cause the create_user task to be called once
|
||||
# with the application id
|
||||
mock.delay.assert_called_once_with(application_role_ids=[app_role.id])
|
||||
|
||||
|
||||
def test_dispatch_create_atat_admin_user(session, monkeypatch):
|
||||
portfolio = PortfolioFactory.create(
|
||||
applications=[
|
||||
@ -240,11 +286,8 @@ def test_create_environment_no_dupes(session, celery_app, celery_worker):
|
||||
assert environment.claimed_until == None
|
||||
|
||||
|
||||
def test_claim_for_update(session):
|
||||
def test_dispatch_provision_portfolio(csp, monkeypatch):
|
||||
portfolio = PortfolioFactory.create(
|
||||
applications=[
|
||||
{"environments": [{"cloud_id": uuid4().hex, "root_user_info": {}}]}
|
||||
],
|
||||
task_orders=[
|
||||
{
|
||||
"create_clins": [
|
||||
@ -256,115 +299,6 @@ def test_claim_for_update(session):
|
||||
}
|
||||
],
|
||||
)
|
||||
environment = portfolio.applications[0].environments[0]
|
||||
|
||||
satisfied_claims = []
|
||||
exceptions = []
|
||||
|
||||
# Two threads race to do work on environment and check out the lock
|
||||
class FirstThread(Thread):
|
||||
def run(self):
|
||||
try:
|
||||
with claim_for_update(environment):
|
||||
satisfied_claims.append("FirstThread")
|
||||
except ClaimFailedException:
|
||||
exceptions.append("FirstThread")
|
||||
|
||||
class SecondThread(Thread):
|
||||
def run(self):
|
||||
try:
|
||||
with claim_for_update(environment):
|
||||
satisfied_claims.append("SecondThread")
|
||||
except ClaimFailedException:
|
||||
exceptions.append("SecondThread")
|
||||
|
||||
t1 = FirstThread()
|
||||
t2 = SecondThread()
|
||||
t1.start()
|
||||
t2.start()
|
||||
t1.join()
|
||||
t2.join()
|
||||
|
||||
session.refresh(environment)
|
||||
|
||||
assert len(satisfied_claims) == 1
|
||||
assert len(exceptions) == 1
|
||||
|
||||
if satisfied_claims == ["FirstThread"]:
|
||||
assert exceptions == ["SecondThread"]
|
||||
else:
|
||||
assert satisfied_claims == ["SecondThread"]
|
||||
assert exceptions == ["FirstThread"]
|
||||
|
||||
# The claim is released
|
||||
assert environment.claimed_until is None
|
||||
|
||||
|
||||
def test_dispatch_provision_user(csp, session, celery_app, celery_worker, monkeypatch):
|
||||
|
||||
# Given that I have four environment roles:
|
||||
# (A) one of which has a completed status
|
||||
# (B) one of which has an environment that has not been provisioned
|
||||
# (C) one of which is pending, has a provisioned environment but an inactive application role
|
||||
# (D) one of which is pending, has a provisioned environment and has an active application role
|
||||
provisioned_environment = EnvironmentFactory.create(
|
||||
cloud_id="cloud_id", root_user_info={}
|
||||
)
|
||||
unprovisioned_environment = EnvironmentFactory.create()
|
||||
_er_a = EnvironmentRoleFactory.create(
|
||||
environment=provisioned_environment, status=EnvironmentRole.Status.COMPLETED
|
||||
)
|
||||
_er_b = EnvironmentRoleFactory.create(
|
||||
environment=unprovisioned_environment, status=EnvironmentRole.Status.PENDING
|
||||
)
|
||||
_er_c = EnvironmentRoleFactory.create(
|
||||
environment=unprovisioned_environment,
|
||||
status=EnvironmentRole.Status.PENDING,
|
||||
application_role=ApplicationRoleFactory(status=ApplicationRoleStatus.PENDING),
|
||||
)
|
||||
er_d = EnvironmentRoleFactory.create(
|
||||
environment=provisioned_environment,
|
||||
status=EnvironmentRole.Status.PENDING,
|
||||
application_role=ApplicationRoleFactory(status=ApplicationRoleStatus.ACTIVE),
|
||||
)
|
||||
|
||||
mock = Mock()
|
||||
monkeypatch.setattr("atst.jobs.provision_user", mock)
|
||||
|
||||
# When I dispatch the user provisioning task
|
||||
dispatch_provision_user.run()
|
||||
|
||||
# I expect it to dispatch only one call, to EnvironmentRole D
|
||||
mock.delay.assert_called_once_with(environment_role_id=er_d.id)
|
||||
|
||||
|
||||
def test_do_provision_user(csp, session):
|
||||
# Given that I have an EnvironmentRole with a provisioned environment
|
||||
credentials = MockCloudProvider(())._auth_credentials
|
||||
provisioned_environment = EnvironmentFactory.create(
|
||||
cloud_id="cloud_id", root_user_info={"credentials": credentials}
|
||||
)
|
||||
environment_role = EnvironmentRoleFactory.create(
|
||||
environment=provisioned_environment,
|
||||
status=EnvironmentRole.Status.PENDING,
|
||||
role="ADMIN",
|
||||
)
|
||||
|
||||
# When I call the user provisoning task
|
||||
do_provision_user(csp=csp, environment_role_id=environment_role.id)
|
||||
|
||||
session.refresh(environment_role)
|
||||
# I expect that the CSP create_or_update_user method will be called
|
||||
csp.create_or_update_user.assert_called_once_with(
|
||||
credentials, environment_role, CSPRole.ADMIN
|
||||
)
|
||||
# I expect that the EnvironmentRole now has a csp_user_id
|
||||
assert environment_role.csp_user_id
|
||||
|
||||
|
||||
def test_dispatch_provision_portfolio(
|
||||
csp, session, portfolio, celery_app, celery_worker, monkeypatch
|
||||
):
|
||||
sm = PortfolioStateMachineFactory.create(portfolio=portfolio)
|
||||
mock = Mock()
|
||||
monkeypatch.setattr("atst.jobs.provision_portfolio", mock)
|
||||
|
@ -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
|
||||
]
|
||||
}
|
||||
],
|
||||
)
|
||||
|
@ -128,9 +128,6 @@ flash:
|
||||
message: There was an error processing the invitation for {user_name} from {application_name}
|
||||
resent:
|
||||
message: "{email} has been sent an invitation to access this Application"
|
||||
revoked:
|
||||
title: Application invitation revoked
|
||||
message: You have successfully revoked the invite for {user_name} from {application_name}
|
||||
application_member:
|
||||
removed:
|
||||
title: Team member removed from application
|
||||
@ -166,6 +163,9 @@ flash:
|
||||
errors:
|
||||
title: There were some errors
|
||||
message: Please see below.
|
||||
invite_revoked:
|
||||
title: "{resource} invitation revoked"
|
||||
message: "You have successfully revoked the invite for {user_name} from {resource_name}"
|
||||
login_required_message: After you log in, you will be redirected to your destination page.
|
||||
login_required_title: Log in required
|
||||
logged_out:
|
||||
|
246
yarn.lock
246
yarn.lock
@ -2,6 +2,97 @@
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
"@azure/abort-controller@^1.0.0":
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@azure/abort-controller/-/abort-controller-1.0.1.tgz#8510935b25ac051e58920300e9d7b511ca6e656a"
|
||||
integrity sha512-wP2Jw6uPp8DEDy0n4KNidvwzDjyVV2xnycEIq7nPzj1rHyb/r+t3OPeNT1INZePP2wy5ZqlwyuyOMTi0ePyY1A==
|
||||
dependencies:
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@azure/core-asynciterator-polyfill@^1.0.0":
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@azure/core-asynciterator-polyfill/-/core-asynciterator-polyfill-1.0.0.tgz#dcccebb88406e5c76e0e1d52e8cc4c43a68b3ee7"
|
||||
integrity sha512-kmv8CGrPfN9SwMwrkiBK9VTQYxdFQEGe0BmQk+M8io56P9KNzpAxcWE/1fxJj7uouwN4kXF0BHW8DNlgx+wtCg==
|
||||
|
||||
"@azure/core-auth@^1.0.0":
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@azure/core-auth/-/core-auth-1.0.2.tgz#7377c0cacf0e3c988ce321295bf5d2c174e0e288"
|
||||
integrity sha512-zhPJObdrhz2ymIqGL1x8i3meEuaLz0UPjH9mOq9RGOlJB2Pb6K6xPtkHbRsfElgoO9USR4hH2XU5pLa4/JHHIw==
|
||||
dependencies:
|
||||
"@azure/abort-controller" "^1.0.0"
|
||||
"@azure/core-tracing" "1.0.0-preview.7"
|
||||
"@opentelemetry/types" "^0.2.0"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@azure/core-http@^1.0.0":
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@azure/core-http/-/core-http-1.0.3.tgz#faea8da9187278b7109c2af74d6f96e0c806e636"
|
||||
integrity sha512-hmsalo2i1noF5LMwNBNymJnf210ha7Rh6x+BQBBcb+wUZI5hVGRbaRgHzqpJiH8FmfJrDuKZI+S7i2rILUBJTg==
|
||||
dependencies:
|
||||
"@azure/abort-controller" "^1.0.0"
|
||||
"@azure/core-auth" "^1.0.0"
|
||||
"@azure/core-tracing" "1.0.0-preview.7"
|
||||
"@azure/logger" "^1.0.0"
|
||||
"@opentelemetry/types" "^0.2.0"
|
||||
"@types/node-fetch" "^2.5.0"
|
||||
"@types/tunnel" "^0.0.1"
|
||||
form-data "^3.0.0"
|
||||
node-fetch "^2.6.0"
|
||||
process "^0.11.10"
|
||||
tough-cookie "^3.0.1"
|
||||
tslib "^1.10.0"
|
||||
tunnel "^0.0.6"
|
||||
uuid "^3.3.2"
|
||||
xml2js "^0.4.19"
|
||||
|
||||
"@azure/core-lro@^1.0.0":
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@azure/core-lro/-/core-lro-1.0.0.tgz#9837398e03aa04b5b0b09158f4338861348dcce4"
|
||||
integrity sha512-l4abIb8S9qmlv3bJkonLvgGSVQcSXq5jByA7Z28GRGJaQN/mSFal9YQOuLvVag+JXQJsoftuxJFrZiggF2TwOg==
|
||||
dependencies:
|
||||
"@azure/abort-controller" "^1.0.0"
|
||||
"@azure/core-http" "^1.0.0"
|
||||
events "^3.0.0"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@azure/core-paging@^1.0.0":
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@azure/core-paging/-/core-paging-1.0.0.tgz#3aa3855582154260326feea97f9f8322cbfe56d9"
|
||||
integrity sha512-CzaT7LwxU97PZ+/Pn7uAbNGXY2mJ/3b56kmLsZzbR9stfrNfzlILxR94WHG/D1jZEQOk4lUNiaqJ2zP7nSGJhA==
|
||||
dependencies:
|
||||
"@azure/core-asynciterator-polyfill" "^1.0.0"
|
||||
|
||||
"@azure/core-tracing@1.0.0-preview.7":
|
||||
version "1.0.0-preview.7"
|
||||
resolved "https://registry.yarnpkg.com/@azure/core-tracing/-/core-tracing-1.0.0-preview.7.tgz#e9ee9c88f0dcf50d8e5b468fc827203165ecbc3f"
|
||||
integrity sha512-pkFCw6OiJrpR+aH1VQe6DYm3fK2KWCC5Jf3m/Pv1RxF08M1Xm08RCyQ5Qe0YyW5L16yYT2nnV48krVhYZ6SGFA==
|
||||
dependencies:
|
||||
"@opencensus/web-types" "0.0.7"
|
||||
"@opentelemetry/types" "^0.2.0"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@azure/logger@^1.0.0":
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@azure/logger/-/logger-1.0.0.tgz#48b371dfb34288c8797e5c104f6c4fb45bf1772c"
|
||||
integrity sha512-g2qLDgvmhyIxR3JVS8N67CyIOeFRKQlX/llxYJQr1OSGQqM3HTpVP8MjmjcEKbL/OIt2N9C9UFaNQuKOw1laOA==
|
||||
dependencies:
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@azure/storage-blob@^12.0.2":
|
||||
version "12.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@azure/storage-blob/-/storage-blob-12.0.2.tgz#6bdaf1171007972051024f3ca852e06a82d7f1a0"
|
||||
integrity sha512-URELuzMzSDKUVRLt+bXIh/PZe54qWiLxDQ443rcvfpRAPP3QBLnDFw6hFacSXeU/0bOIRcbzAeKtKOMhoE7lSw==
|
||||
dependencies:
|
||||
"@azure/abort-controller" "^1.0.0"
|
||||
"@azure/core-http" "^1.0.0"
|
||||
"@azure/core-lro" "^1.0.0"
|
||||
"@azure/core-paging" "^1.0.0"
|
||||
"@azure/core-tracing" "1.0.0-preview.7"
|
||||
"@azure/logger" "^1.0.0"
|
||||
"@opentelemetry/types" "^0.2.0"
|
||||
events "^3.0.0"
|
||||
tslib "^1.10.0"
|
||||
|
||||
"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.5.5":
|
||||
version "7.5.5"
|
||||
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.5.5.tgz#bc0782f6d69f7b7d49531219699b988f669a8f9d"
|
||||
@ -720,6 +811,16 @@
|
||||
resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-1.1.3.tgz#2b5a3ab3f918cca48a8c754c08168e3f03eba61b"
|
||||
integrity sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw==
|
||||
|
||||
"@opencensus/web-types@0.0.7":
|
||||
version "0.0.7"
|
||||
resolved "https://registry.yarnpkg.com/@opencensus/web-types/-/web-types-0.0.7.tgz#4426de1fe5aa8f624db395d2152b902874f0570a"
|
||||
integrity sha512-xB+w7ZDAu3YBzqH44rCmG9/RlrOmFuDPt/bpf17eJr8eZSrLt7nc7LnWdxM9Mmoj/YKMHpxRg28txu3TcpiL+g==
|
||||
|
||||
"@opentelemetry/types@^0.2.0":
|
||||
version "0.2.0"
|
||||
resolved "https://registry.yarnpkg.com/@opentelemetry/types/-/types-0.2.0.tgz#2a0afd40fa7026e39ea56a454642bda72b172f80"
|
||||
integrity sha512-GtwNB6BNDdsIPAYEdpp3JnOGO/3AJxjPvny53s3HERBdXSJTGQw8IRhiaTEX0b3w9P8+FwFZde4k+qkjn67aVw==
|
||||
|
||||
"@parcel/fs@^1.11.0":
|
||||
version "1.11.0"
|
||||
resolved "https://registry.yarnpkg.com/@parcel/fs/-/fs-1.11.0.tgz#fb8a2be038c454ad46a50dc0554c1805f13535cd"
|
||||
@ -761,6 +862,18 @@
|
||||
"@parcel/utils" "^1.11.0"
|
||||
physical-cpu-count "^2.0.0"
|
||||
|
||||
"@types/node-fetch@^2.5.0":
|
||||
version "2.5.4"
|
||||
resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.5.4.tgz#5245b6d8841fc3a6208b82291119bc11c4e0ce44"
|
||||
integrity sha512-Oz6id++2qAOFuOlE1j0ouk1dzl3mmI1+qINPNBhi9nt/gVOz0G+13Ao6qjhdF0Ys+eOkhu6JnFmt38bR3H0POQ==
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/node@*":
|
||||
version "13.7.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-13.7.0.tgz#b417deda18cf8400f278733499ad5547ed1abec4"
|
||||
integrity sha512-GnZbirvmqZUzMgkFn70c74OQpTTUcCzlhQliTzYjQMqg+hVKcDnxdL19Ne3UdYzdMA/+W3eb646FWn/ZaT1NfQ==
|
||||
|
||||
"@types/node@^10.11.7":
|
||||
version "10.14.8"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-10.14.8.tgz#fe444203ecef1162348cd6deb76c62477b2cc6e9"
|
||||
@ -780,6 +893,13 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/semver/-/semver-5.5.0.tgz#146c2a29ee7d3bae4bf2fcb274636e264c813c45"
|
||||
integrity sha512-41qEJgBH/TWgo5NFSvBCJ1qkoi3Q6ONSF2avrHq1LVEZfYpdHmj0y9SuTK+u9ZhG1sYQKBL1AWXKyLWP4RaUoQ==
|
||||
|
||||
"@types/tunnel@^0.0.1":
|
||||
version "0.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/tunnel/-/tunnel-0.0.1.tgz#0d72774768b73df26f25df9184273a42da72b19c"
|
||||
integrity sha512-AOqu6bQu5MSWwYvehMXLukFHnupHrpZ8nvgae5Ggie9UwzDR1CCwoXgSSWNZJuyOlCdfdsWMA5F2LlmvyoTv8A==
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@vue/test-utils@^1.0.0-beta.25":
|
||||
version "1.0.0-beta.25"
|
||||
resolved "https://registry.yarnpkg.com/@vue/test-utils/-/test-utils-1.0.0-beta.25.tgz#4703076de3076bac42cdd242cd53e6fb8752ed8c"
|
||||
@ -1112,23 +1232,6 @@ aws4@^1.8.0:
|
||||
resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.9.0.tgz#24390e6ad61386b0a747265754d2a17219de862c"
|
||||
integrity sha512-Uvq6hVe90D0B2WEnUqtdgY1bATGz3mw33nH9Y+dmA+w5DHvUmBgkr5rM/KCHpCsiFNRUfokW/szpPPgMK2hm4A==
|
||||
|
||||
azure-storage@^2.10.3:
|
||||
version "2.10.3"
|
||||
resolved "https://registry.yarnpkg.com/azure-storage/-/azure-storage-2.10.3.tgz#c5966bf929d87587d78f6847040ea9a4b1d4a50a"
|
||||
integrity sha512-IGLs5Xj6kO8Ii90KerQrrwuJKexLgSwYC4oLWmc11mzKe7Jt2E5IVg+ZQ8K53YWZACtVTMBNO3iGuA+4ipjJxQ==
|
||||
dependencies:
|
||||
browserify-mime "~1.2.9"
|
||||
extend "^3.0.2"
|
||||
json-edm-parser "0.1.2"
|
||||
md5.js "1.3.4"
|
||||
readable-stream "~2.0.0"
|
||||
request "^2.86.0"
|
||||
underscore "~1.8.3"
|
||||
uuid "^3.0.0"
|
||||
validator "~9.4.1"
|
||||
xml2js "0.2.8"
|
||||
xmlbuilder "^9.0.7"
|
||||
|
||||
babel-code-frame@^6.26.0:
|
||||
version "6.26.0"
|
||||
resolved "https://registry.yarnpkg.com/babel-code-frame/-/babel-code-frame-6.26.0.tgz#63fd43f7dc1e3bb7ce35947db8fe369a3f58c74b"
|
||||
@ -1814,11 +1917,6 @@ browserify-des@^1.0.0:
|
||||
inherits "^2.0.1"
|
||||
safe-buffer "^5.1.2"
|
||||
|
||||
browserify-mime@~1.2.9:
|
||||
version "1.2.9"
|
||||
resolved "https://registry.yarnpkg.com/browserify-mime/-/browserify-mime-1.2.9.tgz#aeb1af28de6c0d7a6a2ce40adb68ff18422af31f"
|
||||
integrity sha1-rrGvKN5sDXpqLOQK22j/GEIq8x8=
|
||||
|
||||
browserify-rsa@^4.0.0:
|
||||
version "4.0.1"
|
||||
resolved "https://registry.yarnpkg.com/browserify-rsa/-/browserify-rsa-4.0.1.tgz#21e0abfaf6f2029cf2fafb133567a701d4135524"
|
||||
@ -2272,7 +2370,7 @@ combine-source-map@^0.8.0, combine-source-map@~0.8.0:
|
||||
lodash.memoize "~3.0.3"
|
||||
source-map "~0.5.3"
|
||||
|
||||
combined-stream@^1.0.6, combined-stream@~1.0.6:
|
||||
combined-stream@^1.0.6, combined-stream@^1.0.8, combined-stream@~1.0.6:
|
||||
version "1.0.8"
|
||||
resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f"
|
||||
integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==
|
||||
@ -3219,7 +3317,7 @@ extend-shallow@^3.0.0, extend-shallow@^3.0.2:
|
||||
assign-symbols "^1.0.0"
|
||||
is-extendable "^1.0.1"
|
||||
|
||||
extend@^3.0.2, extend@~3.0.2:
|
||||
extend@~3.0.2:
|
||||
version "3.0.2"
|
||||
resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"
|
||||
|
||||
@ -3391,6 +3489,15 @@ forever-agent@~0.6.1:
|
||||
resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91"
|
||||
integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=
|
||||
|
||||
form-data@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/form-data/-/form-data-3.0.0.tgz#31b7e39c85f1355b7139ee0c647cf0de7f83c682"
|
||||
integrity sha512-CKMFDglpbMi6PyN+brwB9Q/GOw0eAnsrEZDgcsH5Krhz5Od/haKHAX0NmQfha2zPPz0JpWzA7GJHGSnvCRLWsg==
|
||||
dependencies:
|
||||
asynckit "^0.4.0"
|
||||
combined-stream "^1.0.8"
|
||||
mime-types "^2.1.12"
|
||||
|
||||
form-data@~2.3.2:
|
||||
version "2.3.3"
|
||||
resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6"
|
||||
@ -3976,6 +4083,11 @@ invert-kv@^2.0.0:
|
||||
resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-2.0.0.tgz#7393f5afa59ec9ff5f67a27620d11c226e3eec02"
|
||||
integrity sha512-wPVv/y/QQ/Uiirj/vh3oP+1Ww+AWehmi1g5fFWGPF6IpCBCDVrhgHRMvrLfdYcwDh3QJbGXDW4JAuzxElLSqKA==
|
||||
|
||||
ip-regex@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-2.1.0.tgz#fa78bf5d2e6913c911ce9f819ee5146bb6d844e9"
|
||||
integrity sha1-+ni/XS5pE8kRzp+BnuUUa7bYROk=
|
||||
|
||||
is-absolute-url@^2.0.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/is-absolute-url/-/is-absolute-url-2.1.0.tgz#50530dfb84fcc9aa7dbe7852e83a37b93b9f2aa6"
|
||||
@ -4822,13 +4934,6 @@ jsesc@~0.5.0:
|
||||
resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d"
|
||||
integrity sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0=
|
||||
|
||||
json-edm-parser@0.1.2:
|
||||
version "0.1.2"
|
||||
resolved "https://registry.yarnpkg.com/json-edm-parser/-/json-edm-parser-0.1.2.tgz#1e60b0fef1bc0af67bc0d146dfdde5486cd615b4"
|
||||
integrity sha1-HmCw/vG8CvZ7wNFG393lSGzWFbQ=
|
||||
dependencies:
|
||||
jsonparse "~1.2.0"
|
||||
|
||||
json-parse-better-errors@^1.0.1:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9"
|
||||
@ -4882,11 +4987,6 @@ jsonparse@^1.2.0:
|
||||
version "1.3.1"
|
||||
resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280"
|
||||
|
||||
jsonparse@~1.2.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.2.0.tgz#5c0c5685107160e72fe7489bddea0b44c2bc67bd"
|
||||
integrity sha1-XAxWhRBxYOcv50ib3eoLRMK8Z70=
|
||||
|
||||
jsprim@^1.2.2:
|
||||
version "1.4.1"
|
||||
resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2"
|
||||
@ -5112,14 +5212,6 @@ math-random@^1.0.1:
|
||||
resolved "https://registry.yarnpkg.com/math-random/-/math-random-1.0.4.tgz#5dd6943c938548267016d4e34f057583080c514c"
|
||||
integrity sha512-rUxjysqif/BZQH2yhd5Aaq7vXMSx9NdEsQcyA07uEzIvxgI7zIr33gGsh+RU0/XjmQpCW7RsVof1vlkvQVCK5A==
|
||||
|
||||
md5.js@1.3.4:
|
||||
version "1.3.4"
|
||||
resolved "https://registry.yarnpkg.com/md5.js/-/md5.js-1.3.4.tgz#e9bdbde94a20a5ac18b04340fc5764d5b09d901d"
|
||||
integrity sha1-6b296UogpawYsENA/Fdk1bCdkB0=
|
||||
dependencies:
|
||||
hash-base "^3.0.0"
|
||||
inherits "^2.0.1"
|
||||
|
||||
md5.js@^1.3.4:
|
||||
version "1.3.5"
|
||||
resolved "https://registry.yarnpkg.com/md5.js/-/md5.js-1.3.5.tgz#b5d07b8e3216e3e27cd728d72f70d1e6a342005f"
|
||||
@ -5411,6 +5503,11 @@ node-addon-api@^1.7.1:
|
||||
resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-1.7.1.tgz#cf813cd69bb8d9100f6bdca6755fc268f54ac492"
|
||||
integrity sha512-2+DuKodWvwRTrCfKOeR24KIc5unKjOh8mz17NCzVnHWfjAdDqbfbjqh7gUT+BkXBRQM52+xCHciKWonJ3CbJMQ==
|
||||
|
||||
node-fetch@^2.6.0:
|
||||
version "2.6.0"
|
||||
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.0.tgz#e633456386d4aa55863f676a7ab0daa8fdecb0fd"
|
||||
integrity sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==
|
||||
|
||||
node-forge@^0.7.1:
|
||||
version "0.7.6"
|
||||
resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.7.6.tgz#fdf3b418aee1f94f0ef642cd63486c77ca9724ac"
|
||||
@ -6931,7 +7028,7 @@ request-promise-native@^1.0.5:
|
||||
stealthy-require "^1.1.1"
|
||||
tough-cookie "^2.3.3"
|
||||
|
||||
request@^2.86.0, request@^2.87.0, request@^2.88.0:
|
||||
request@^2.87.0, request@^2.88.0:
|
||||
version "2.88.0"
|
||||
resolved "https://registry.yarnpkg.com/request/-/request-2.88.0.tgz#9c2fca4f7d35b592efe57c7f0a55e81052124fef"
|
||||
integrity sha512-NAqBSrijGLZdM0WZNsInLJpkJokL72XYjUpnB0iwsRgxh7dB6COrHnTBNwN0E+lHDAJzu7kLAkDeY08z2/A0hg==
|
||||
@ -7116,12 +7213,7 @@ sass-graph@^2.2.4:
|
||||
scss-tokenizer "^0.2.3"
|
||||
yargs "^7.0.0"
|
||||
|
||||
sax@0.5.x:
|
||||
version "0.5.8"
|
||||
resolved "https://registry.yarnpkg.com/sax/-/sax-0.5.8.tgz#d472db228eb331c2506b0e8c15524adb939d12c1"
|
||||
integrity sha1-1HLbIo6zMcJQaw6MFVJK25OdEsE=
|
||||
|
||||
sax@^1.2.4, sax@~1.2.4:
|
||||
sax@>=0.6.0, sax@^1.2.4, sax@~1.2.4:
|
||||
version "1.2.4"
|
||||
resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
|
||||
integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==
|
||||
@ -7885,6 +7977,15 @@ tough-cookie@^2.3.3, tough-cookie@^2.3.4, tough-cookie@^2.5.0:
|
||||
psl "^1.1.28"
|
||||
punycode "^2.1.1"
|
||||
|
||||
tough-cookie@^3.0.1:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-3.0.1.tgz#9df4f57e739c26930a018184887f4adb7dca73b2"
|
||||
integrity sha512-yQyJ0u4pZsv9D4clxO69OEjLWYw+jbgspjTue4lTQZLfV0c5l1VmK2y1JK8E9ahdpltPOaAThPcp5nKPUgSnsg==
|
||||
dependencies:
|
||||
ip-regex "^2.1.0"
|
||||
psl "^1.1.28"
|
||||
punycode "^2.1.1"
|
||||
|
||||
tough-cookie@~2.4.3:
|
||||
version "2.4.3"
|
||||
resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.4.3.tgz#53f36da3f47783b0925afa06ff9f3b165280f781"
|
||||
@ -7917,6 +8018,11 @@ trim-right@^1.0.1:
|
||||
dependencies:
|
||||
glob "^7.1.2"
|
||||
|
||||
tslib@^1.10.0, tslib@^1.9.3:
|
||||
version "1.10.0"
|
||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a"
|
||||
integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==
|
||||
|
||||
tty-browserify@0.0.0:
|
||||
version "0.0.0"
|
||||
resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.0.tgz#a157ba402da24e9bf957f9aa69d524eed42901a6"
|
||||
@ -7933,6 +8039,11 @@ tunnel-agent@^0.6.0:
|
||||
dependencies:
|
||||
safe-buffer "^5.0.1"
|
||||
|
||||
tunnel@^0.0.6:
|
||||
version "0.0.6"
|
||||
resolved "https://registry.yarnpkg.com/tunnel/-/tunnel-0.0.6.tgz#72f1314b34a5b192db012324df2cc587ca47f92c"
|
||||
integrity sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==
|
||||
|
||||
tweetnacl@^0.14.3, tweetnacl@~0.14.0:
|
||||
version "0.14.5"
|
||||
resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"
|
||||
@ -7990,11 +8101,6 @@ undeclared-identifiers@^1.1.2:
|
||||
simple-concat "^1.0.0"
|
||||
xtend "^4.0.1"
|
||||
|
||||
underscore@~1.8.3:
|
||||
version "1.8.3"
|
||||
resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.8.3.tgz#4f3fb53b106e6097fcf9cb4109f2a5e9bdfa5022"
|
||||
integrity sha1-Tz+1OxBuYJf8+ctBCfKl6b36UCI=
|
||||
|
||||
unicode-canonical-property-names-ecmascript@^1.0.4:
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz#2619800c4c825800efdd8343af7dd9933cbe2818"
|
||||
@ -8138,10 +8244,6 @@ util@~0.10.1:
|
||||
dependencies:
|
||||
inherits "2.0.3"
|
||||
|
||||
uuid@^3.0.0:
|
||||
version "3.3.2"
|
||||
resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131"
|
||||
|
||||
uuid@^3.3.2:
|
||||
version "3.3.3"
|
||||
resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.3.tgz#4568f0216e78760ee1dbf3a4d2cf53e224112866"
|
||||
@ -8169,11 +8271,6 @@ validate-npm-package-license@^3.0.1:
|
||||
spdx-correct "^3.0.0"
|
||||
spdx-expression-parse "^3.0.0"
|
||||
|
||||
validator@~9.4.1:
|
||||
version "9.4.1"
|
||||
resolved "https://registry.yarnpkg.com/validator/-/validator-9.4.1.tgz#abf466d398b561cd243050112c6ff1de6cc12663"
|
||||
integrity sha512-YV5KjzvRmSyJ1ee/Dm5UED0G+1L4GZnLN3w6/T+zZm8scVua4sOhYKWTUrKa0H/tMiJyO9QLHMPN+9mB/aMunA==
|
||||
|
||||
vendors@^1.0.0:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/vendors/-/vendors-1.0.3.tgz#a6467781abd366217c050f8202e7e50cc9eef8c0"
|
||||
@ -8386,17 +8483,18 @@ xml-name-validator@^3.0.0:
|
||||
resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a"
|
||||
integrity sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==
|
||||
|
||||
xml2js@0.2.8:
|
||||
version "0.2.8"
|
||||
resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.2.8.tgz#9b81690931631ff09d1957549faf54f4f980b3c2"
|
||||
integrity sha1-m4FpCTFjH/CdGVdUn69U9PmAs8I=
|
||||
xml2js@^0.4.19:
|
||||
version "0.4.23"
|
||||
resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.4.23.tgz#a0c69516752421eb2ac758ee4d4ccf58843eac66"
|
||||
integrity sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==
|
||||
dependencies:
|
||||
sax "0.5.x"
|
||||
sax ">=0.6.0"
|
||||
xmlbuilder "~11.0.0"
|
||||
|
||||
xmlbuilder@^9.0.7:
|
||||
version "9.0.7"
|
||||
resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-9.0.7.tgz#132ee63d2ec5565c557e20f4c22df9aca686b10d"
|
||||
integrity sha1-Ey7mPS7FVlxVfiD0wi35rKaGsQ0=
|
||||
xmlbuilder@~11.0.0:
|
||||
version "11.0.1"
|
||||
resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-11.0.1.tgz#be9bae1c8a046e76b31127726347d0ad7002beb3"
|
||||
integrity sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==
|
||||
|
||||
xmlchars@^2.1.1:
|
||||
version "2.2.0"
|
||||
|
Loading…
x
Reference in New Issue
Block a user