Merge pull request #1218 from dod-ccpo/staging

Updates to master
This commit is contained in:
dandds 2019-12-02 09:11:10 -05:00 committed by GitHub
commit cfe4bcd319
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
69 changed files with 1414 additions and 1400 deletions

View File

@ -140,19 +140,20 @@ commands:
kubectl set image cronjobs.batch/crls crls=<< parameters.tag >> --namespace=<< parameters.namespace >>
jobs:
docker-build-staging:
docker-build:
docker:
- image: docker:18.06.0-ce-git
steps:
- docker-build:
cdn_url: https://atat-cdn-staging.azureedge.net/static/assets
docker-build-master:
docker:
- image: docker:18.06.0-ce-git
steps:
- docker-build:
cdn_url: https://atat-cdn.azureedge.net/static/assets
- checkout
- setup_remote_docker:
docker_layer_caching: true
version: 18.06.0-ce
- run:
name: Build image
command: |
docker build . --build-arg CSP=azure -f ./Dockerfile -t atat:builder --target builder
docker build . --build-arg CSP=azure -f ./Dockerfile -t atat:latest
- cache_docker_image
test:
docker:
@ -285,54 +286,29 @@ workflows:
version: 2
run-tests:
jobs:
- docker-build-staging:
filters:
branches:
ignore:
- staging
- master
- docker-build
- test:
requires:
- docker-build-staging
- docker-build
- integration-tests:
requires:
- docker-build-staging
build-staging:
jobs:
- docker-build-staging:
filters:
branches:
only:
- staging
- test:
requires:
- docker-build-staging
- integration-tests:
requires:
- docker-build-staging
- docker-build
- deploy-staging:
requires:
- test
- integration-tests
build-master:
jobs:
- docker-build-master:
filters:
branches:
only:
- master
- test:
requires:
- docker-build-master
- integration-tests:
requires:
- docker-build-master
- staging
- deploy-master:
requires:
- test
- integration-tests
filters:
branches:
only:
- master
test-crl-parser:
triggers:
@ -343,7 +319,7 @@ workflows:
only:
- staging
jobs:
- docker-build-staging
- docker-build
- test-crl-parser:
requires:
- docker-build-staging
- docker-build

View File

@ -3,7 +3,7 @@
"files": "^.secrets.baseline$|^.*pgsslrootcert.yml$",
"lines": null
},
"generated_at": "2019-11-19T18:28:27Z",
"generated_at": "2019-11-26T21:33:43Z",
"plugins_used": [
{
"base64_limit": 4.5,
@ -46,7 +46,7 @@
"hashed_secret": "81b127e2222d9bfc4609053faec85300f7525463",
"is_secret": false,
"is_verified": false,
"line_number": 244,
"line_number": 290,
"type": "Secret Keyword"
}
],

View File

@ -220,6 +220,44 @@ To generate coverage reports for the Javascript tests:
yarn test:coverage
## Configuration
- `ASSETS_URL`: URL to host which serves static assets (such as a CDN).
- `BLOB_STORAGE_URL`: URL to Azure blob storage container.
- `CAC_URL`: URL for the CAC authentication route.
- `CA_CHAIN`: Path to the CA chain file.
- `CDN_ORIGIN`: URL for the origin host for asset files.
- `CELERY_DEFAULT_QUEUE`: String specifying the name of the queue that background tasks will be added to.
- `CONTRACT_END_DATE`: String specifying the end date of the JEDI contract. Used for task order validation. Example: 2019-09-14
- `CONTRACT_START_DATE`: String specifying the start date of the JEDI contract. Used for task order validation. Example: 2019-09-14.
- `CRL_FAIL_OPEN`: Boolean specifying if expired CRLs should fail open, rather than closed.
- `CRL_STORAGE_CONTAINER`: Path to a directory where the CRL cache will be stored.
- `CSP`: String specifying the cloud service provider to use. Acceptable values: "azure", "mock", "mock-csp".
- `DEBUG`: Boolean. A truthy value enables Flask's debug mode. https://flask.palletsprojects.com/en/1.1.x/config/#DEBUG
- `DISABLE_CRL_CHECK`: Boolean specifying if CRL check should be bypassed. Useful for instances of the application container that are not serving HTTP requests, such as Celery workers.
- `ENVIRONMENT`: String specifying the current environment. Acceptable values: "dev", "prod".
- `LIMIT_CONCURRENT_SESSIONS`: Boolean specifying if users should be allowed only one active session at a time.
- `LOG_JSON`: Boolean specifying whether app should log in a json format.
- `PERMANENT_SESSION_LIFETIME`: Integer specifying how many seconds a user's session can stay valid for. https://flask.palletsprojects.com/en/1.1.x/config/#PERMANENT_SESSION_LIFETIME
- `PGDATABASE`: String specifying the name of the postgres database.
- `PGHOST`: String specifying the hostname of the postgres database.
- `PGPASSWORD`: String specifying the password of the postgres database.
- `PGPORT`: Integer specifying the port number of the postgres database.
- `PGSSLMODE`: String specifying the ssl mode to use when connecting to the postgres database. https://www.postgresql.org/docs/9.1/libpq-ssl.html
- `PGSSLROOTCERT`: Path to the root SSL certificate for the postgres database.
- `PGUSER`: String specifying the username to use when connecting to the postgres database.
- `PORT`: Integer specifying the port to bind to when running the flask server. Used only for local development.
- `REDIS_URI`: URI for the redis server.
- `SECRET_KEY`: String key which will be used to sign the session cookie. Should be a long string of random bytes. https://flask.palletsprojects.com/en/1.1.x/config/#SECRET_KEY
- `SERVER_NAME`: Hostname for ATAT. Only needs to be specified in contexts where the hostname cannot be inferred from the request, such as Celery workers. https://flask.palletsprojects.com/en/1.1.x/config/#SERVER_NAME
- `SESSION_COOKIE_NAME`: String value specifying the name to use for the session cookie. https://flask.palletsprojects.com/en/1.1.x/config/#SESSION_COOKIE_NAME
- `SESSION_TYPE`: String value specifying the cookie storage backend. https://pythonhosted.org/Flask-Session/
- `SESSION_USE_SIGNER`: Boolean value specifying if the cookie sid should be signed.
- `SQLALCHEMY_ECHO`: Boolean value specifying if SQLAlchemy should log queries to stdout.
- `STATIC_URL`: URL specifying where static assets are hosted.
- `USE_AUDIT_LOG`: Boolean value describing if ATAT should write to the audit log table in the database. Set to "false" by default for performance reasons.
- `WTF_CSRF_ENABLED`: Boolean value specifying if WTForms should protect against CSRF. Should be set to "true" unless running automated tests.
### UI Test Automation
AT-AT uses [Ghost Inpsector](https://app.ghostinspector.com/), a testing PaaS

View File

@ -160,7 +160,6 @@ def map_config(config):
"BROKER_URL": config["default"]["REDIS_URI"],
"DEBUG": config["default"].getboolean("DEBUG"),
"SQLALCHEMY_ECHO": config["default"].getboolean("SQLALCHEMY_ECHO"),
"CLASSIFIED": config["default"].getboolean("CLASSIFIED"),
"PORT": int(config["default"]["PORT"]),
"SQLALCHEMY_DATABASE_URI": config["default"]["DATABASE_URI"],
"SQLALCHEMY_TRACK_MODIFICATIONS": False,
@ -175,8 +174,6 @@ def map_config(config):
"PERMANENT_SESSION_LIFETIME": config.getint(
"default", "PERMANENT_SESSION_LIFETIME"
),
"RQ_REDIS_URL": config["default"]["REDIS_URI"],
"RQ_QUEUES": [config["default"]["RQ_QUEUES"]],
"DISABLE_CRL_CHECK": config.getboolean("default", "DISABLE_CRL_CHECK"),
"CRL_FAIL_OPEN": config.getboolean("default", "CRL_FAIL_OPEN"),
"LOG_JSON": config.getboolean("default", "LOG_JSON"),

View File

@ -1,6 +1,7 @@
from itertools import groupby
from collections import OrderedDict
import pendulum
from decimal import Decimal
from collections import OrderedDict
class ReportingInterface:
@ -35,14 +36,16 @@ def generate_sample_dates(_max=8):
current = pendulum.now()
sample_dates = []
for _i in range(_max):
current = current.subtract(months=1)
sample_dates.append(current.strftime("%m/%Y"))
current = current.subtract(months=1)
reversed(sample_dates)
return sample_dates
class MockReportingProvider(ReportingInterface):
MOCK_PERCENT_EXPENDED_FUNDS = 0.75
FIXTURE_MONTHS = generate_sample_dates()
MONTHLY_SPEND_BY_ENVIRONMENT = {
@ -163,25 +166,8 @@ class MockReportingProvider(ReportingInterface):
"FM_Prod": {FIXTURE_MONTHS[0]: 5686},
}
CUMULATIVE_BUDGET_A_WING = {
FIXTURE_MONTHS[7]: {"spend": 9857, "cumulative": 9857},
FIXTURE_MONTHS[6]: {"spend": 7881, "cumulative": 17738},
FIXTURE_MONTHS[5]: {"spend": 14010, "cumulative": 31748},
FIXTURE_MONTHS[4]: {"spend": 43510, "cumulative": 75259},
FIXTURE_MONTHS[3]: {"spend": 41725, "cumulative": 116_984},
FIXTURE_MONTHS[2]: {"spend": 41328, "cumulative": 158_312},
FIXTURE_MONTHS[1]: {"spend": 47491, "cumulative": 205_803},
FIXTURE_MONTHS[0]: {"spend": 36028, "cumulative": 241_831},
}
CUMULATIVE_BUDGET_B_WING = {
FIXTURE_MONTHS[1]: {"spend": 4838, "cumulative": 4838},
FIXTURE_MONTHS[0]: {"spend": 14500, "cumulative": 19338},
}
REPORT_FIXTURE_MAP = {
"A-Wing": {
"cumulative": CUMULATIVE_BUDGET_A_WING,
"applications": [
MockApplication("LC04", ["Integ", "PreProd", "Prod"]),
MockApplication("SF18", ["Integ", "PreProd", "Prod"]),
@ -202,7 +188,6 @@ class MockReportingProvider(ReportingInterface):
"budget": 500_000,
},
"B-Wing": {
"cumulative": CUMULATIVE_BUDGET_B_WING,
"applications": [
MockApplication("NP02", ["Integ", "PreProd", "Prod"]),
MockApplication("FM", ["Integ", "Prod"]),
@ -211,28 +196,6 @@ class MockReportingProvider(ReportingInterface):
},
}
def _sum_monthly_spend(self, data):
return sum(
[
spend
for application in data
for env in application.environments
for spend in self.MONTHLY_SPEND_BY_ENVIRONMENT[env.id].values()
]
)
def get_budget(self, portfolio):
if portfolio.name in self.REPORT_FIXTURE_MAP:
return self.REPORT_FIXTURE_MAP[portfolio.name]["budget"]
return 0
def get_total_spending(self, portfolio):
if portfolio.name in self.REPORT_FIXTURE_MAP:
return self._sum_monthly_spend(
self.REPORT_FIXTURE_MAP[portfolio.name]["applications"]
)
return 0
def _rollup_application_totals(self, data):
application_totals = {}
for application, environments in data.items():
@ -270,7 +233,14 @@ class MockReportingProvider(ReportingInterface):
{ "01/2018": 79.85, "02/2018": 86.54 }
"""
return self.MONTHLY_SPEND_BY_ENVIRONMENT.get(environment_id, {})
environment_monthly_totals = self.MONTHLY_SPEND_BY_ENVIRONMENT.get(
environment_id, {}
).copy()
environment_monthly_totals["total_spend_to_date"] = sum(
monthly_total for monthly_total in environment_monthly_totals.values()
)
return environment_monthly_totals
def monthly_totals(self, portfolio):
"""Return month totals rolled up by environment, application, and portfolio.
@ -309,19 +279,49 @@ class MockReportingProvider(ReportingInterface):
"portfolio": portfolio_totals,
}
def cumulative_budget(self, portfolio):
def get_obligated_funds_by_JEDI_clin(self, portfolio):
"""
Returns a dictionary of obligated funds and spending per JEDI CLIN
{
JEDI_CLIN: {
obligated_funds,
expended_funds
}
}
"""
if portfolio.name in self.REPORT_FIXTURE_MAP:
budget_months = self.REPORT_FIXTURE_MAP[portfolio.name]["cumulative"]
else:
budget_months = {}
return_dict = {}
for jedi_clin, clins in groupby(
portfolio.active_clins, lambda clin: clin.jedi_clin_type
):
obligated_funds = sum(clin.obligated_amount for clin in clins)
return_dict[jedi_clin.value] = {
"obligated_funds": obligated_funds,
"expended_funds": (
obligated_funds * Decimal(self.MOCK_PERCENT_EXPENDED_FUNDS)
),
}
return OrderedDict(
# 0 index for dict item, -1 for last digit of 4 digit CLIN, e.g. 0001
sorted(return_dict.items(), key=lambda clin: clin[0][-1])
)
return {}
end = pendulum.now()
start = end.subtract(months=12)
period = pendulum.period(start, end)
all_months = OrderedDict()
for t in period.range("months"):
month_str = "{month:02d}/{year}".format(month=t.month, year=t.year)
all_months[month_str] = budget_months.get(month_str, None)
return {"months": all_months}
def get_expired_task_orders(self, portfolio):
return [
{
"id": task_order.id,
"number": task_order.number,
"period_of_performance": {
"start_date": task_order.start_date,
"end_date": task_order.end_date,
},
"total_obligated_funds": task_order.total_obligated_funds,
"expended_funds": (
task_order.total_obligated_funds
* Decimal(self.MOCK_PERCENT_EXPENDED_FUNDS)
),
}
for task_order in portfolio.task_orders
if task_order.is_expired
]

View File

@ -2,16 +2,14 @@ from flask import current_app
class Reports:
@classmethod
def portfolio_totals(cls, portfolio):
budget = current_app.csp.reports.get_budget(portfolio)
spent = current_app.csp.reports.get_total_spending(portfolio)
return {"budget": budget, "spent": spent}
@classmethod
def monthly_totals(cls, portfolio):
return current_app.csp.reports.monthly_totals(portfolio)
@classmethod
def cumulative_budget(cls, portfolio):
return current_app.csp.reports.cumulative_budget(portfolio)
def expired_task_orders(cls, portfolio):
return current_app.csp.reports.get_expired_task_orders(portfolio)
@classmethod
def obligated_funds_by_JEDI_clin(cls, portfolio):
return current_app.csp.reports.get_obligated_funds_by_JEDI_clin(portfolio)

View File

@ -117,8 +117,8 @@ ENV_ROLES = [(role.value, role.value) for role in CSPRole] + [
]
JEDI_CLIN_TYPES = [
("JEDI_CLIN_1", translate("forms.task_order.clin_01_label")),
("JEDI_CLIN_2", translate("forms.task_order.clin_02_label")),
("JEDI_CLIN_3", translate("forms.task_order.clin_03_label")),
("JEDI_CLIN_4", translate("forms.task_order.clin_04_label")),
("JEDI_CLIN_1", translate("JEDICLINType.JEDI_CLIN_1")),
("JEDI_CLIN_2", translate("JEDICLINType.JEDI_CLIN_2")),
("JEDI_CLIN_3", translate("JEDICLINType.JEDI_CLIN_3")),
("JEDI_CLIN_4", translate("JEDICLINType.JEDI_CLIN_4")),
]

View File

@ -113,19 +113,17 @@ class ApplicationRole(
@property
def display_status(self):
if (
self.is_pending
and self.latest_invitation
and self.latest_invitation.is_pending
):
return "invite_pending"
elif (
self.is_pending
and self.latest_invitation
and self.latest_invitation.is_expired
):
return "invite_expired"
elif (
self.is_pending
and self.latest_invitation
and self.latest_invitation.is_pending
):
return "invite_pending"
elif self.is_active and any(
env_role.is_pending for env_role in self.environment_roles
):

View File

@ -1,6 +1,7 @@
from enum import Enum
from sqlalchemy import Column, Date, Enum as SQLAEnum, ForeignKey, Numeric, String
from sqlalchemy.orm import relationship
from datetime import date
from atst.models.base import Base
import atst.models.mixins as mixins
@ -61,3 +62,7 @@ class CLIN(Base, mixins.TimestampsMixin):
for c in self.__table__.columns
if c.name not in ["id"]
}
@property
def is_active(self):
return self.start_date <= date.today() <= self.end_date

View File

@ -65,6 +65,58 @@ class Portfolio(
def num_task_orders(self):
return len(self.task_orders)
@property
def active_clins(self):
return [
clin
for task_order in self.task_orders
for clin in task_order.clins
if clin.is_active
]
@property
def active_task_orders(self):
return [task_order for task_order in self.task_orders if task_order.is_active]
@property
def funding_duration(self):
"""
Return the earliest period of performance start date and latest period
of performance end date for all active task orders in a portfolio.
@return: (datetime.date or None, datetime.date or None)
"""
start_dates = (
task_order.start_date
for task_order in self.task_orders
if task_order.is_active
)
end_dates = (
task_order.end_date
for task_order in self.task_orders
if task_order.is_active
)
earliest_pop_start_date = min(start_dates, default=None)
latest_pop_end_date = max(end_dates, default=None)
return (earliest_pop_start_date, latest_pop_end_date)
@property
def days_to_funding_expiration(self):
"""
Returns the number of days between today and the lastest period performance
end date of all active Task Orders
"""
return max(
(
task_order.days_to_expiration
for task_order in self.task_orders
if task_order.is_active
),
default=0,
)
@property
def members(self):
return (

View File

@ -1,5 +1,6 @@
from celery import Celery
celery = Celery(__name__)

View File

@ -6,6 +6,7 @@ from atst.domain.environments import Environments
from atst.domain.applications import Applications
from atst.domain.application_roles import ApplicationRoles
from atst.domain.audit_log import AuditLog
from atst.domain.csp.cloud import GeneralCSPException
from atst.domain.common import Paginator
from atst.domain.environment_roles import EnvironmentRoles
from atst.domain.invitations import ApplicationInvitations
@ -221,14 +222,22 @@ def handle_update_member(application_id, application_role_id, form_data):
)
if form.validate():
ApplicationRoles.update_permission_sets(app_role, form.data["permission_sets"])
try:
ApplicationRoles.update_permission_sets(
app_role, form.data["permission_sets"]
)
for env_role in form.environment_roles:
environment = Environments.get(env_role.environment_id.data)
new_role = None if env_role.disabled.data else env_role.data["role"]
Environments.update_env_role(environment, app_role, new_role)
for env_role in form.environment_roles:
environment = Environments.get(env_role.environment_id.data)
new_role = None if env_role.disabled.data else env_role.data["role"]
Environments.update_env_role(environment, app_role, new_role)
flash("application_member_updated", user_name=app_role.user_name)
flash("application_member_updated", user_name=app_role.user_name)
except GeneralCSPException:
flash(
"application_member_update_error", user_name=app_role.user_name,
)
else:
pass
# TODO: flash error message

View File

@ -1,4 +1,4 @@
from datetime import date, timedelta
from datetime import date, datetime, timedelta
from flask import redirect, render_template, url_for, request as http_request, g
@ -46,34 +46,25 @@ def create_portfolio():
def reports(portfolio_id):
portfolio = Portfolios.get(g.current_user, portfolio_id)
today = date.today()
month = http_request.args.get("month", today.month)
year = http_request.args.get("year", today.year)
current_month = date(int(year), int(month), 15)
current_month = date(int(today.year), int(today.month), 15)
prev_month = current_month - timedelta(days=28)
two_months_ago = prev_month - timedelta(days=28)
task_order = next(
(task_order for task_order in portfolio.task_orders if task_order.is_active),
None,
# wrapped in str() because the sum of obligated funds returns a Decimal object
total_portfolio_value = str(
sum(
task_order.total_obligated_funds
for task_order in portfolio.active_task_orders
)
)
expiration_date = task_order and task_order.end_date
if expiration_date:
remaining_difference = expiration_date - today
remaining_days = remaining_difference.days
else:
remaining_days = None
return render_template(
"portfolios/reports/index.html",
cumulative_budget=Reports.cumulative_budget(portfolio),
portfolio_totals=Reports.portfolio_totals(portfolio),
portfolio=portfolio,
total_portfolio_value=total_portfolio_value,
current_obligated_funds=Reports.obligated_funds_by_JEDI_clin(portfolio),
expired_task_orders=Reports.expired_task_orders(portfolio),
monthly_totals=Reports.monthly_totals(portfolio),
task_order=task_order,
current_month=current_month,
prev_month=prev_month,
two_months_ago=two_months_ago,
expiration_date=expiration_date,
remaining_days=remaining_days,
now=datetime.now(), # mocked datetime of reporting data retrival
)

View File

@ -54,6 +54,11 @@ MESSAGES = {
"message_template": "You have successfully deleted {{ user_name }} from {{ application_name }}",
"category": "success",
},
"application_member_update_error": {
"title_template": "{{ user_name }} could not be updated",
"message_template": "An unexpected problem occurred with your request, please try again. If the problem persists, contact an administrator.",
"category": "error",
},
"application_member_updated": {
"title_template": "Team member updated",
"message_template": "You have successfully updated the permissions for {{ user_name }}",

View File

@ -10,6 +10,7 @@ class RequestContextFilter(logging.Filter):
if has_request_context():
if getattr(g, "current_user", None):
record.user_id = str(g.current_user.id)
record.dod_edipi = g.current_user.dod_id
if request.environ.get("HTTP_X_REQUEST_ID"):
record.request_id = request.environ.get("HTTP_X_REQUEST_ID")
@ -28,16 +29,28 @@ class JsonFormatter(logging.Formatter):
("version", lambda r: 1),
("request_id", lambda r: r.__dict__.get("request_id")),
("user_id", lambda r: r.__dict__.get("user_id")),
("dod_edipi", lambda r: r.__dict__.get("dod_edipi")),
("severity", lambda r: r.levelname),
("tags", lambda r: r.__dict__.get("tags")),
("message", lambda r: r.msg),
("audit_event", lambda r: r.__dict__.get("audit_event")),
]
def format(self, record):
message_dict = {"source": "atst"}
def __init__(self, *args, source="atst", **kwargs):
self.source = source
super().__init__(self)
def format(self, record, *args, **kwargs):
message_dict = {"source": self.source}
for field, func in self._DEFAULT_RECORD_FIELDS:
message_dict[field] = func(record)
result = func(record)
if result:
message_dict[field] = result
if record.args:
message_dict["message"] = record.msg % record.args
else:
message_dict["message"] = record.msg
if record.__dict__.get("exc_info") is not None:
message_dict["details"] = {

View File

@ -1,7 +1,19 @@
#!/usr/bin/env python
import logging
from atst.app import celery, make_app, make_config
from celery.signals import after_setup_task_logger
from atst.utils.logging import JsonFormatter
config = make_config()
app = make_app(config)
app.app_context().push()
@after_setup_task_logger.connect
def setup_task_logger(*args, **kwargs):
if app.config.get("LOG_JSON"):
logger = logging.getLogger()
for handler in logger.handlers:
handler.setFormatter(JsonFormatter(source="queue"))

View File

@ -5,7 +5,6 @@ CAC_URL = http://localhost:8000/login-redirect
CA_CHAIN = ssl/server-certs/ca-chain.pem
CDN_ORIGIN=http://localhost:8000
CELERY_DEFAULT_QUEUE=celery
CLASSIFIED = false
CONTRACT_END_DATE = 2022-09-14
CONTRACT_START_DATE = 2019-09-14
CRL_FAIL_OPEN = false
@ -17,8 +16,6 @@ ENVIRONMENT = dev
LIMIT_CONCURRENT_SESSIONS = false
LOG_JSON = false
PERMANENT_SESSION_LIFETIME = 1800
PE_NUMBER_CSV_URL = http://c95e1ebb198426ee57b8-174bb05a294821bedbf46b6384fe9b1f.r31.cf5.rackcdn.com/penumbers.csv
PGAPPNAME = atst
PGDATABASE = atat
PGHOST = localhost
PGPASSWORD = postgres
@ -28,7 +25,6 @@ PGSSLROOTCERT
PGUSER = postgres
PORT=8000
REDIS_URI = redis://localhost:6379
RQ_QUEUES = atat_%(ENVIRONMENT)s
SECRET_KEY = change_me_into_something_secret
SERVER_NAME
SESSION_COOKIE_NAME=atat

View File

@ -8,25 +8,28 @@ Applying the K8s config relies on a combination of kustomize and envsubst. Kusto
The production configuration (azure.atat.code.mil, currently) is reflected in the configuration found in the `deploy/azure` directory. Configuration for a staging environment relies on kustomize to overwrite the production config with values appropriate for that environment. You can find more information about using kustomize [here](https://kubernetes.io/docs/tasks/manage-kubernetes-objects/kustomization/). Kustomize does not manage templating, and certain values need to be templated. These include:
- CONTAINER_IMAGE: the ATAT container image to use
- PORT_PREFIX: "8" for production, "9" for staging
- MAIN_DOMAIN: the host domain for the environment
- AUTH_DOMAIN: the host domain for the authentication endpoint for the environment
- CONTAINER_IMAGE: The ATAT container image to use.
- PORT_PREFIX: "8" for production, "9" for staging.
- MAIN_DOMAIN: The host domain for the environment.
- AUTH_DOMAIN: The host domain for the authentication endpoint for the environment.
- KV_MI_ID: the fully qualified id (path) of the managed identity for the key vault (instructions on retrieving this are down in section on [Setting up FlexVol](#configuring-the-identity)). Example: /subscriptions/00000000-0000-0000-0000-000000000000/resourcegroups/RESOURCE_GROUP_NAME/providers/Microsoft.ManagedIdentity/userAssignedIdentities/MANAGED_IDENTITY_NAME
- KV_MI_CLIENT_ID: The client id of the managed identity for the key vault. This is a GUID.
We use envsubst to substitute values for these variables.
We use envsubst to substitute values for these variables. There is a wrapper script (script/k8s_config) that will output the compiled configuration, using a combination of kustomize and envsubst.
To apply config to the main environment, you should first do a diff to determine whether your new config introduces unexpected changes:
To apply config to the main environment, you should first do a diff to determine whether your new config introduces unexpected changes. These examples assume that all the relevant environment variables listed above have been set:
```
kubectl kustomize deploy/azure | CONTAINER_IMAGE=myregistry.io/atat-some-commit-sha PORT_PREFIX=8 MAIN_DOMAIN=azure.atat.code.mil AUTH_DOMAIN=auth-azure.atat.code.mil envsubst '$CONTAINER_IMAGE $PORT_PREFIX $MAIN_DOMAIN $AUTH_DOMAIN' | kubectl diff -f -
./script/k8s_config deploy/azure | kubectl diff -f -
```
Here, `kubectl kustomize` assembles the config and streams it to STDOUT. We specify environment variables for envsubst to use and pass the names of those env vars as a string argument to envsubst. This is important, because envsubst will override NGINX variables in the NGINX config if you don't limit its scope. Finally, we pipe the result from envsubst to `kubectl diff`, which reports a list of differences. Note that some values tracked by K8s internally might have changed, such as [`generation`](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.16/#objectmeta-v1-meta). This is fine and expected.
If you are satisfied with the output from the diff, you can apply the new config the same way:
```
kubectl kustomize deploy/azure | CONTAINER_IMAGE=myregistry.io/atat-some-commit-sha PORT_PREFIX=8 MAIN_DOMAIN=azure.atat.code.mil AUTH_DOMAIN=auth-azure.atat.code.mil envsubst '$CONTAINER_IMAGE $PORT_PREFIX $MAIN_DOMAIN $AUTH_DOMAIN' | kubectl apply -f -
./script/k8s_config deploy/azure | kubectl apply -f -
```
**Note:** Depending on how your `kubectl` config is set up, these commands may need to be adjusted. If you have configuration for multiple clusters, you may need to specify the `kubectl` context for each command with the `--context` flag (something like `kubectl --context=my-cluster [etc.]` or `kubectl --context=azure [etc.]`).
@ -165,3 +168,52 @@ Then:
```
kubectl -n atat create secret tls azure-atat-code-mil-tls --key="[path to the private key]" --cert="[path to the full chain]"
```
---
# Setting Up FlexVol for Secrets
## Preparing Azure Environment
A Key Vault will need to be created. Save it's full id (the full path) for use later.
## Preparing Cluster
The 2 following k8s configs will need to be applied to the cluster. They do not need to be namespaced, the latter will create a `kv` namespace for itself.
```
kubectl apply -f deploy/azure/keyvault/deployment-rbac.yaml
kubectl apply -f deploy/azure/keyvault/kv-flex-vol-installer.yaml
```
## Configuring The Identity
1. Creat the identity in a resource group that is able to manage the cluster (`RESOURCE_GROUP_NAME`). You'll also determine a name for the identity (`MANAGED_IDENTITY_NAME`) that you'll use to refer to the identity later:
`az identity create -g <RESOURCE_GROUP_NAME> -n <MANAGED_IDENTITY_NAME> -o json`
2. From the resulting JSON, we'll need to use: `id`, `clientId` and `principalId` for subsequent commands and configuration.
Example values:
```
id: "/subscriptions/00000000-0000-0000-0000-000000000000/resourcegroups/RESOURCE_GROUP_NAME/providers/Microsoft.ManagedIdentity/userAssignedIdentities/MANAGED_IDENTITY_NAME"
clientId: 00000000-0000-0000-0000-000000000000
principalId: 00000000-0000-0000-0000-000000000000
```
> You can recover these values later by running the following command. Verify you are looking at the correct identity by making sure the end of the first line (id) is the same as the name you provided above. If you want the full details of the identity, leave off the last section.
>
>`az identity list -g <RESOURCE_GROUP_NAME> | jq '.[] | select(.type == "Microsoft.ManagedIdentity/userAssignedIdentities") | .id, .clientId, principalId'`
3. Assign the new identity two roles: Managed Identity Operator (for itself) and Reader (of the vault). The `VAULT_ID` can be found in the azure portal.
```
az role assignment create --role "Managed Identity Operator" --assignee <principalId> --scope <id>
az role assignment create --role Reader --assignee <principalId> --scope <VAULT_ID>
```
4. Grant the identity get permissions for each of the types of values the vault can store, Keys, Secrets, and Certificates:
```
az keyvault set-policy -n <VAULT_NAME> --spn <clientId> --key-permissions get --secret-permissions get --certificate-permissions get
```
5. The file `deploy/azure/aadpodidentity.yml` is templated via Kustomize, so you'll need to include clientId (as `KV_MI_CLIENT_ID`) and id (as `KV_MI_ID`) of the managed identity as part of the call to Kustomize.

View File

@ -0,0 +1,19 @@
---
apiVersion: "aadpodidentity.k8s.io/v1"
kind: AzureIdentity
metadata:
name: atat-kv-identity
spec:
type: 0
ResourceID: $KV_MI_ID
ClientID: $KV_MI_CLIENT_ID
---
apiVersion: "aadpodidentity.k8s.io/v1"
kind: AzureIdentityBinding
metadata:
name: atat-key-vault-identity-binding
spec:
AzureIdentity: atat-kv-identity
Selector: atat-kv-id-binding
---

View File

@ -46,6 +46,9 @@ spec:
- name: pgsslrootcert
mountPath: "/opt/atat/atst/ssl/pgsslrootcert.crt"
subPath: pgsslrootcert.crt
- name: uwsgi-config
mountPath: "/opt/atat/atst/uwsgi.ini"
subPath: uwsgi.ini
- name: nginx
image: nginx:alpine
ports:
@ -124,6 +127,14 @@ spec:
configMap:
name: acme-challenges
defaultMode: 0666
- name: uwsgi-config
configMap:
name: uwsgi-config
defaultMode: 0666
items:
- key: uwsgi.ini
path: uwsgi.ini
mode: 0644
---
apiVersion: extensions/v1beta1
kind: Deployment

View File

@ -0,0 +1,259 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: aad-pod-id-nmi-service-account
namespace: default
---
apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:
name: azureassignedidentities.aadpodidentity.k8s.io
spec:
group: aadpodidentity.k8s.io
version: v1
names:
kind: AzureAssignedIdentity
plural: azureassignedidentities
scope: Namespaced
---
apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:
name: azureidentitybindings.aadpodidentity.k8s.io
spec:
group: aadpodidentity.k8s.io
version: v1
names:
kind: AzureIdentityBinding
plural: azureidentitybindings
scope: Namespaced
---
apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:
name: azureidentities.aadpodidentity.k8s.io
spec:
group: aadpodidentity.k8s.io
version: v1
names:
kind: AzureIdentity
singular: azureidentity
plural: azureidentities
scope: Namespaced
---
apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:
name: azurepodidentityexceptions.aadpodidentity.k8s.io
spec:
group: aadpodidentity.k8s.io
version: v1
names:
kind: AzurePodIdentityException
singular: azurepodidentityexception
plural: azurepodidentityexceptions
scope: Namespaced
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: aad-pod-id-nmi-role
rules:
- apiGroups: ["apiextensions.k8s.io"]
resources: ["customresourcedefinitions"]
verbs: ["get", "list"]
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list", "watch"]
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get"]
- apiGroups: ["aadpodidentity.k8s.io"]
resources:
["azureidentitybindings", "azureidentities", "azurepodidentityexceptions"]
verbs: ["get", "list", "watch"]
- apiGroups: ["aadpodidentity.k8s.io"]
resources: ["azureassignedidentities"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1beta1
kind: ClusterRoleBinding
metadata:
name: aad-pod-id-nmi-binding
labels:
k8s-app: aad-pod-id-nmi-binding
subjects:
- kind: ServiceAccount
name: aad-pod-id-nmi-service-account
namespace: default
roleRef:
kind: ClusterRole
name: aad-pod-id-nmi-role
apiGroup: rbac.authorization.k8s.io
---
apiVersion: apps/v1
kind: DaemonSet
metadata:
labels:
kubernetes.io/cluster-service: "true"
component: nmi
tier: node
k8s-app: aad-pod-id
name: nmi
namespace: default
spec:
updateStrategy:
type: RollingUpdate
selector:
matchLabels:
component: nmi
tier: node
template:
metadata:
labels:
component: nmi
tier: node
spec:
serviceAccountName: aad-pod-id-nmi-service-account
hostNetwork: true
volumes:
- hostPath:
path: /run/xtables.lock
type: FileOrCreate
name: iptableslock
containers:
- name: nmi
image: "mcr.microsoft.com/k8s/aad-pod-identity/nmi:1.5.3"
imagePullPolicy: Always
args:
- "--host-ip=$(HOST_IP)"
- "--node=$(NODE_NAME)"
env:
- name: HOST_IP
valueFrom:
fieldRef:
fieldPath: status.podIP
- name: NODE_NAME
valueFrom:
fieldRef:
fieldPath: spec.nodeName
resources:
limits:
cpu: 200m
memory: 512Mi
requests:
cpu: 100m
memory: 256Mi
securityContext:
privileged: true
capabilities:
add:
- NET_ADMIN
volumeMounts:
- mountPath: /run/xtables.lock
name: iptableslock
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
nodeSelector:
beta.kubernetes.io/os: linux
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: aad-pod-id-mic-service-account
namespace: default
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: aad-pod-id-mic-role
rules:
- apiGroups: ["apiextensions.k8s.io"]
resources: ["customresourcedefinitions"]
verbs: ["*"]
- apiGroups: [""]
resources: ["pods", "nodes"]
verbs: ["list", "watch"]
- apiGroups: [""]
resources: ["events"]
verbs: ["create", "patch"]
- apiGroups: [""]
resources: ["endpoints"]
verbs: ["create", "get", "update"]
- apiGroups: ["aadpodidentity.k8s.io"]
resources: ["azureidentitybindings", "azureidentities"]
verbs: ["get", "list", "watch", "post"]
- apiGroups: ["aadpodidentity.k8s.io"]
resources: ["azureassignedidentities"]
verbs: ["*"]
---
apiVersion: rbac.authorization.k8s.io/v1beta1
kind: ClusterRoleBinding
metadata:
name: aad-pod-id-mic-binding
labels:
k8s-app: aad-pod-id-mic-binding
subjects:
- kind: ServiceAccount
name: aad-pod-id-mic-service-account
namespace: default
roleRef:
kind: ClusterRole
name: aad-pod-id-mic-role
apiGroup: rbac.authorization.k8s.io
---
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
component: mic
k8s-app: aad-pod-id
name: mic
namespace: default
spec:
replicas: 2
selector:
matchLabels:
component: mic
app: mic
template:
metadata:
labels:
component: mic
app: mic
spec:
serviceAccountName: aad-pod-id-mic-service-account
containers:
- name: mic
image: "mcr.microsoft.com/k8s/aad-pod-identity/mic:1.5.3"
imagePullPolicy: Always
args:
- "--cloudconfig=/etc/kubernetes/azure.json"
- "--logtostderr"
resources:
limits:
cpu: 200m
memory: 1024Mi
requests:
cpu: 100m
memory: 256Mi
volumeMounts:
- name: k8s-azure-file
mountPath: /etc/kubernetes/azure.json
readOnly: true
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
volumes:
- name: k8s-azure-file
hostPath:
path: /etc/kubernetes/azure.json
nodeSelector:
beta.kubernetes.io/os: linux

View File

@ -0,0 +1,49 @@
apiVersion: v1
kind: Namespace
metadata:
name: kv
---
apiVersion: extensions/v1beta1
kind: DaemonSet
metadata:
labels:
app: keyvault-flexvolume
name: keyvault-flexvolume
namespace: kv
spec:
updateStrategy:
type: RollingUpdate
template:
metadata:
labels:
app: keyvault-flexvolume
spec:
tolerations:
containers:
- name: flexvol-driver-installer
image: "mcr.microsoft.com/k8s/flexvolume/keyvault-flexvolume:v0.0.15"
imagePullPolicy: Always
resources:
requests:
cpu: 50m
memory: 100Mi
limits:
cpu: 50m
memory: 100Mi
env:
# if you have used flex before on your cluster, use same directory
# set TARGET_DIR env var and mount the same directory to to the container
- name: TARGET_DIR
value: "/etc/kubernetes/volumeplugins"
volumeMounts:
- mountPath: "/etc/kubernetes/volumeplugins"
name: volplugins
volumes:
- hostPath:
# Modify this directory if your nodes are using a different one
# default is "/usr/libexec/kubernetes/kubelet-plugins/volume/exec"
# below is Azure default
path: "/etc/kubernetes/volumeplugins"
name: volplugins
nodeSelector:
beta.kubernetes.io/os: linux

View File

@ -1,7 +1,7 @@
namespace: atat
resources:
- azure.yml
- atst-configmap.yml
- uwsgi-config.yml
- atst-envvars-configmap.yml
- atst-nginx-configmap.yml
- atst-worker-envvars-configmap.yml
@ -10,3 +10,4 @@ resources:
- volume-claim.yml
- nginx-client-ca-bundle.yml
- acme-challenges.yml
- aadpodidentity.yml

View File

@ -2,10 +2,10 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: atst-config
name: uwsgi-config
namespace: atat
data:
uwsgi-config: |-
uwsgi.ini: |-
[uwsgi]
callable = app
module = app
@ -14,6 +14,7 @@ data:
plugin = logfile
virtualenv = /opt/atat/atst/.venv
chmod-socket = 666
chown-socket = atst:atat
; logger config

View File

@ -0,0 +1,14 @@
import ToggleMixin from '../mixins/toggle'
export default {
name: 'accordion',
mixins: [ToggleMixin],
props: {
defaultVisible: {
type: Boolean,
default: false,
},
},
}

View File

@ -1,189 +0,0 @@
import {
format,
isWithinRange,
addMonths,
isSameMonth,
getMonth,
} from 'date-fns'
import { abbreviateDollars, formatDollars } from '../../lib/dollars'
const TOP_OFFSET = 20
const BOTTOM_OFFSET = 70
const CHART_HEIGHT = 360
export default {
name: 'budget-chart',
props: {
currentMonth: String,
expirationDate: String,
months: Object,
budget: String,
},
data: function() {
const heightScale =
this.budget / (CHART_HEIGHT - TOP_OFFSET - BOTTOM_OFFSET)
return {
numMonths: 10,
focusedMonthPosition: 4,
height: CHART_HEIGHT,
heightScale,
budgetHeight: CHART_HEIGHT - BOTTOM_OFFSET - this.budget / heightScale,
baseHeight: CHART_HEIGHT - BOTTOM_OFFSET,
width: 0,
displayedMonths: [],
spendPath: '',
projectedPath: '',
displayBudget: formatDollars(parseFloat(this.budget)),
}
},
mounted: function() {
this._setDisplayedMonths()
this._setMetrics()
addEventListener('load', this._setMetrics)
addEventListener('resize', this._setMetrics)
},
methods: {
_setMetrics: function() {
this.width = this.$refs.panel.clientWidth
this.spendPath = ''
this.projectedPath = ''
let lastSpend = 0
let lastSpendPoint = ''
for (let i = 0; i < this.numMonths; i++) {
const {
metrics,
budget,
rollingAverage,
cumulativeTotal,
} = this.displayedMonths[i]
const blockWidth = this.width / this.numMonths
const blockX = blockWidth * i
const spend = budget && budget.spend ? budget.spend : rollingAverage
const barHeight = spend / this.heightScale
lastSpend = spend
const cumulativeY =
this.height - cumulativeTotal / this.heightScale - BOTTOM_OFFSET
const cumulativeX = blockX + blockWidth / 2
const cumulativePoint = `${cumulativeX} ${cumulativeY}`
this.displayedMonths[i].metrics = Object.assign(metrics, {
blockWidth,
blockX,
barHeight,
barWidth: 30,
barX: blockX + (blockWidth / 2 - 15),
barY: this.height - barHeight - BOTTOM_OFFSET,
cumulativeR: 2.5,
cumulativeY,
cumulativeX,
})
if (budget && budget.spend) {
this.spendPath += this.spendPath === '' ? 'M' : ' L'
this.spendPath += cumulativePoint
lastSpendPoint = cumulativePoint
} else if (lastSpendPoint !== '') {
this.projectedPath +=
this.projectedPath === '' ? `M${lastSpendPoint} L` : ' L'
this.projectedPath += cumulativePoint
}
}
},
_setDisplayedMonths: function() {
const [month, year] = this.currentMonth.split('/')
const [expYear, expMonth, expDate] = this.expirationDate.split('-') // assumes format 'YYYY-MM-DD'
const monthsRange = []
const monthsBack = this.focusedMonthPosition + 1
const monthsForward = this.numMonths - this.focusedMonthPosition - 1
// currently focused date
const current = new Date(year, month)
// starting date of the chart
const start = addMonths(current, -monthsBack)
// ending date of the chart
const end = addMonths(start, this.numMonths + 1)
// expiration date
const expires = new Date(expYear, expMonth - 1, expDate)
// is the expiration date within the displayed date range?
const expirationWithinRange = isWithinRange(expires, start, end)
let rollingAverage = 0
let cumulativeTotal = 0
for (let i = 0; i < this.numMonths; i++) {
const date = addMonths(start, i)
const dateMinusOne = addMonths(date, -1)
const dateMinusTwo = addMonths(date, -2)
const dateMinusThree = addMonths(date, -3)
const index = format(date, 'MM/YYYY')
const indexMinusOne = format(dateMinusOne, 'MM/YYYY')
const indexMinusTwo = format(dateMinusTwo, 'MM/YYYY')
const indexMinusThree = format(dateMinusThree, 'MM/YYYY')
const budget = this.months[index] || null
const spendAmount = budget ? budget.spend : rollingAverage
const spendMinusOne = this.months[indexMinusOne]
? this.months[indexMinusOne].spend
: rollingAverage
const spendMinusTwo = this.months[indexMinusTwo]
? this.months[indexMinusTwo].spend
: rollingAverage
const spendMinusThree = this.months[indexMinusThree]
? this.months[indexMinusThree].spend
: rollingAverage
const isExpirationMonth = isSameMonth(date, expires)
if (budget && budget.cumulative) {
cumulativeTotal = budget.cumulative
} else {
cumulativeTotal += spendAmount
}
rollingAverage =
(spendAmount + spendMinusOne + spendMinusTwo + spendMinusThree) / 4
monthsRange.push({
budget,
rollingAverage,
cumulativeTotal,
isExpirationMonth,
spendAmount: formatDollars(spendAmount),
abbreviatedSpend: abbreviateDollars(spendAmount),
cumulativeAmount: formatDollars(cumulativeTotal),
abbreviatedCumulative: abbreviateDollars(cumulativeTotal),
date: {
monthIndex: format(date, 'M'),
month: format(date, 'MMM'),
year: format(date, 'YYYY'),
},
showYear: isExpirationMonth || i === 0 || getMonth(date) === 0,
isHighlighted: this.currentMonth === index,
metrics: {
blockWidth: 0,
blockX: 0,
barHeight: 0,
barWidth: 0,
barX: 0,
barY: 0,
cumulativeY: 0,
cumulativeX: 0,
cumulativeR: 0,
},
})
}
this.displayedMonths = monthsRange
},
},
}

View File

@ -14,6 +14,7 @@ export default {
default: () => [],
},
initialOtherValue: String,
optional: Boolean,
},
data: function() {
@ -45,7 +46,7 @@ export default {
computed: {
valid: function() {
return this.showValid
return this.optional || this.showValid
},
},
}

View File

@ -10,10 +10,6 @@ export default {
default: () => [],
},
initialValue: String,
watch: {
type: Boolean,
default: false,
},
optional: Boolean,
nullOption: {
type: String,

View File

@ -6,11 +6,9 @@ export default {
props: {
applications: Object,
portfolio: Object,
environments: Object,
currentMonthIndex: String,
prevMonthIndex: String,
twoMonthsAgoIndex: String,
},
data: function() {
@ -40,9 +38,5 @@ export default {
formatDollars: function(value) {
return formatDollars(value, false)
},
round: function(value) {
return Math.round(value)
},
},
}

View File

@ -0,0 +1,25 @@
import ToggleMixin from '../mixins/toggle'
export default {
name: 'toggleMenu',
mixins: [ToggleMixin],
methods: {
toggle: function(e) {
if (this.$el.contains(e.target)) {
this.isVisible = !this.isVisible
} else {
this.isVisible = false
}
},
},
mounted: function() {
document.addEventListener('click', this.toggle)
},
beforeDestroy: function() {
document.removeEventListener('click', this.toggle)
},
}

View File

@ -17,7 +17,6 @@ import ApplicationEnvironments from './components/forms/new_application/environm
import MultiStepModalForm from './components/forms/multi_step_modal_form'
import uploadinput from './components/upload_input'
import Modal from './mixins/modal'
import BudgetChart from './components/charts/budget_chart'
import SpendTable from './components/tables/spend_table'
import LocalDatetime from './components/local_datetime'
import { isNotInVerticalViewport } from './lib/viewport'
@ -30,6 +29,8 @@ import SemiCollapsibleText from './components/semi_collapsible_text'
import ToForm from './components/forms/to_form'
import ClinFields from './components/clin_fields'
import PopDateRange from './components/pop_date_range'
import Accordion from './components/accordion'
import ToggleMenu from './components/toggle_menu'
Vue.config.productionTip = false
@ -40,6 +41,7 @@ Vue.mixin(Modal)
const app = new Vue({
el: '#app-root',
components: {
Accordion,
dodlogin,
toggler,
optionsinput,
@ -47,7 +49,6 @@ const app = new Vue({
textinput,
checkboxinput,
ApplicationEnvironments,
BudgetChart,
SpendTable,
LocalDatetime,
MultiStepModalForm,
@ -61,6 +62,7 @@ const app = new Vue({
ToForm,
ClinFields,
PopDateRange,
ToggleMenu,
},
mounted: function() {

View File

@ -27,10 +27,6 @@ export default {
paragraph: String,
noMaxWidth: String,
optional: Boolean,
watch: {
type: Boolean,
default: false,
},
},
data: function() {

35
script/add_ccpo_user.py Normal file
View File

@ -0,0 +1,35 @@
import os
import sys
parent_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
sys.path.append(parent_dir)
from atst.app import make_config, make_app
from atst.domain.exceptions import NotFoundError
from atst.domain.users import Users
def grant_ccpo_perms(dod_id):
try:
user = Users.get_by_dod_id(dod_id)
if user.permission_sets:
print("%s (DoD ID: %s) already CCPO user." % (user.full_name, user.dod_id))
else:
Users.give_ccpo_perms(user)
print(
"CCPO permissions successfully granted to %s (DoD ID: %s)."
% (user.full_name, user.dod_id)
)
except NotFoundError:
print("User not found.")
if __name__ == "__main__":
config = make_config({"DISABLE_CRL_CHECK": True, "DEBUG": False})
app = make_app(config)
with app.app_context():
dod_id = sys.argv[1]
grant_ccpo_perms(dod_id)

45
script/k8s_config Executable file
View File

@ -0,0 +1,45 @@
#!/bin/bash
# script/k8s_update: Compiles the Kubernetes configuration for a given
# directory. Expects that the SETTINGS listed are all set as environment
# variables.
set -e
# Expected settings. Script will error if these are not provided.
SETTINGS=(
CONTAINER_IMAGE
PORT_PREFIX
MAIN_DOMAIN
AUTH_DOMAIN
KV_MI_ID
KV_MI_CLIENT_ID
)
# Loop all expected settings. Track ones that are missing and build
# concatenated list for envsubst. If any are missing, exit.
MISSING_SETTINGS=()
CONCAT_SETTINGS=""
for envvar in "${SETTINGS[@]}"; do
CONCAT_SETTINGS="${CONCAT_SETTINGS} \$${envvar}"
if [ -z "${!envvar}" ]; then
MISSING_SETTINGS+=(${envvar})
fi
done
if [[ ${#MISSING_SETTINGS[@]} > 0 ]]; then
>&2 echo "The following variables need to be set:"
for missing in "${MISSING_SETTINGS[@]}"; do
>&2 echo $missing
done
exit 1
fi
# Check that a directory is provided as a command line argument.
if [ "$#" -ne 1 ] || ! [ -d $1 ]; then
>&2 echo "You must provide a Kubernetes configuration directory."
exit 1
fi
# Use Kustomize to compile Kubernetes configuration and pipe to envsubst to
# substitute the templated values.
kubectl kustomize $1 | envsubst "'${CONCAT_SETTINGS}'"

View File

@ -33,7 +33,6 @@
@import "components/progress_menu.scss";
@import "components/forms";
@import "components/selector";
@import "components/budget_chart";
@import "components/audit_log";
@import "components/usa_banner";
@import "components/dod_login_notice.scss";

View File

@ -1,169 +0,0 @@
.budget-chart {
svg {
display: block;
.filter__text-background {
feFlood {
flood-color: $color-white;
flood-opacity: 1;
}
&--highlighted {
feFlood {
flood-color: $color-aqua-lightest;
flood-opacity: 1;
}
}
}
a {
text-decoration: none;
&:focus {
outline: none;
stroke: $color-gray-light;
stroke-dasharray: 2px;
}
&:hover {
.filter__text-background {
feFlood {
flood-color: $color-aqua-lightest;
flood-opacity: 1;
}
}
}
}
}
&__header {
border-bottom: 1px solid $color-gray-light;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
&__legend {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
dl {
margin: 0 0 0 ($gap * 2);
> div {
margin: 0;
display: flex;
flex-direction: row-reverse;
align-items: center;
dt {
@include small-label;
}
}
}
&__dot {
width: $gap;
height: $gap;
border-radius: $gap / 2;
margin: 0 $gap;
&.accumulated {
background-color: $color-gold;
}
&.monthly {
background-color: $color-blue;
}
}
&__line {
height: 2px;
width: $gap * 3;
border-top-width: 2px;
border-top-style: dashed;
margin: $gap;
&.spend {
border-color: $color-blue;
}
&.accumulated {
border-color: $color-gold;
}
}
}
&__block {
fill: transparent;
cursor: pointer;
&--highlighted {
fill: rgba($color-aqua, 0.15);
}
&--is-expiration {
border-left: 2px dotted $color-gray;
}
&:hover {
fill: rgba($color-aqua, 0.15);
}
}
&__bar {
fill: $color-blue;
&--projected {
fill: transparent;
stroke-width: 2px;
stroke: $color-blue;
stroke-dasharray: 4px;
}
}
&__expiration-line {
stroke-width: 2px;
stroke: $color-gray-light;
stroke-dasharray: 4px;
}
&__cumulative {
&__dot {
fill: $color-gold;
}
}
&__projected-path {
stroke-width: 1px;
stroke: $color-gold;
stroke-dasharray: 4px;
fill: none;
}
&__spend-path {
stroke-width: 1px;
stroke: $color-gold;
fill: none;
}
&__budget-line {
stroke-width: 2px;
stroke: $color-gray-light;
stroke-dasharray: 4px;
}
&__label {
@include small-label;
fill: $color-gray;
pointer-events: none;
&--strong {
fill: $color-black;
}
}
}

View File

@ -253,6 +253,11 @@
border-radius: 3px;
cursor: pointer;
&:hover,
&--active {
background-color: $color-aqua-lightest;
}
.icon {
margin: $gap / 2;
}
@ -278,6 +283,10 @@
&:last-child {
border-bottom: 0;
}
&:hover {
background-color: $color-aqua-lightest;
}
}
}
}

View File

@ -102,3 +102,7 @@ dl {
font-weight: $font-bold;
color: $color-black;
}
@mixin small-copy {
font-size: $small-font-size;
}

View File

@ -1,264 +1,90 @@
.funding-summary-row {
@include media($medium-screen) {
@include grid-row;
flex-wrap: wrap;
.portfolio-reports {
.estimate-warning {
margin-top: $gap * 3;
margin-bottom: $gap * 3;
}
&__col {
hr {
margin: (2 * $gap) 0;
.reporting-section-header {
display: flex;
align-items: baseline;
&__header {
margin-right: $gap;
}
&__subheader {
@include small-copy;
}
}
.jedi-clin-funding {
padding-top: $gap * 3;
padding-bottom: $gap * 3;
&__clin-wrapper {
border-bottom: 1px solid $color-gray-light;
margin-bottom: $gap * 3;
padding-bottom: $gap * 3;
}
> div:nth-last-child(2) {
margin-bottom: 0;
}
@include media($medium-screen) {
@include grid-pad;
flex-grow: 1;
display: flex;
flex-direction: row;
flex-basis: 50%;
@include ie-only {
max-width: 50%;
}
&:first-child {
padding-left: 0;
}
&:last-child {
padding-right: 0;
}
&__header {
margin: 0;
}
align-items: stretch;
&__subheader {
@include small-copy;
margin: 0;
}
.panel {
padding: $gap * 2;
&__meter {
margin: 10px 0;
-moz-transform: scale(-1, 1);
-webkit-transform: scale(-1, 1);
-o-transform: scale(-1, 1);
-ms-transform: scale(-1, 1);
transform: scale(-1, 1);
width: 100%;
@include ie-only {
max-width: 100%;
}
.subheading {
@include h4;
margin: 0 $gap (2 * $gap) 0;
-ms-flex-negative: 1;
}
// Spending Summary
// ===============================
&.spend-summary {
&-values {
display: flex;
flex-direction: column;
justify-content: space-between;
.row {
justify-content: space-between;
@include ie-only {
max-width: 100%;
flex-wrap: wrap;
}
}
&__budget {
@include ie-only {
margin: $gap 0 0 0;
}
}
dl {
text-align: left;
margin: 0 0 ($gap / 2) 0;
@include ie-only {
text-align: left;
}
dt {
text-transform: uppercase;
color: $color-gray-light;
margin-right: $gap;
font-weight: bold;
font-size: $small-font-size;
}
}
meter {
width: 100%;
height: 3rem;
margin: ($gap * 2) 0 0;
}
&__spent {
margin: (2 * $gap) 0;
display: flex;
flex-direction: column;
justify-content: flex-end;
dt {
letter-spacing: 0.47px;
}
}
}
}
// Task Order Summary
// ===============================
&.to-summary {
.icon-link {
font-weight: $font-normal;
}
.subheading {
margin-bottom: 0;
}
.to-summary__heading {
@include h4;
margin: 0 $gap 0 0;
}
.to-summary__to-number {
margin: 0;
dd {
&::before {
content: "#";
color: $color-gray;
margin-right: $gap;
}
}
@include media($xlarge-screen) {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
.to-summary__to {
margin: 0 $gap 0 0;
}
.to-summary__expiration {
text-align: right;
flex-grow: 1;
dl {
margin: 0 0 0 $gap;
dd,
dt {
display: inline;
}
}
}
}
}
.to-summary__expiration {
dl {
text-align: right;
margin-top: -2 * $gap;
dd,
dt {
display: inline;
}
dt {
font-size: $small-font-size;
text-transform: uppercase;
font-weight: $font-bold;
color: $color-gray-light;
}
dd.ending-soon {
font-size: $h2-font-size;
white-space: nowrap;
.icon {
@include icon-size(28);
}
}
}
.icon-link {
margin: 0 (-$gap);
}
}
.to-summary__co {
margin: ($gap * 2) 0 0 0;
@include media($xlarge-screen) {
margin: 0;
}
}
&__meta {
&--remaining {
margin-left: auto;
text-align: right;
}
&-header {
@include small-copy;
margin-bottom: 0;
}
&-value {
margin-bottom: 0;
line-height: 1.2;
}
}
}
}
.spend-table__month-select {
margin: 0;
flex: 1;
}
table {
.spend-table__portfolio {
th,
td {
font-weight: bold;
.reporting-summary-item {
border-right: 1px solid $color-gray-light;
margin-right: $gap * 3;
padding-right: $gap * 3;
&:last-child {
border-right: none;
margin-right: 0;
padding-right: 0;
}
}
th,
td {
&.previous-month {
color: $color-gray;
&__header {
margin: 0;
&-icon {
margin: 0;
padding: 0;
}
}
&.meter-cell {
padding-left: 0;
position: relative;
min-width: 4rem;
@include media($medium-screen) {
min-width: 12rem;
}
meter {
width: 100%;
height: 3rem;
background: $color-white;
display: none;
@include media($medium-screen) {
display: block;
}
&::-webkit-meter-bar {
background: $color-white;
}
}
.spend-table__meter-value {
@include h5;
@include media($medium-screen) {
display: block;
color: $color-white;
background-color: rgba($color-blue, 0.65);
border-radius: $gap / 2;
position: absolute;
top: 2.3rem;
left: $gap / 2;
padding: 0 ($gap / 2);
}
}
&__value {
font-size: $lead-font-size;
}
}
}

View File

@ -66,8 +66,7 @@
<optionsinput inline-template
v-bind:initial-value="'{{ sub_form.role.data | string }}'"
v-bind:name="'{{ sub_form.name | string }}{% if member_role_id %}-{{ member_role_id }}{% endif %}'"
v-bind:optional="true"
v-bind:watch="true">
v-bind:optional="true">
<fieldset data-ally-disabled="true" v-on:change="onInput" class="usa-input__choices">
{{ sub_form.role(**{"v-model": "value", "id": "{}-{}".format(sub_form.role.name, member_role_id)}) }}
</fieldset>

View File

@ -94,61 +94,55 @@
<section class="member-list application-list">
<div class='responsive-table-wrapper'>
<toggler inline-template>
<table class="atat-table">
<thead>
<table class="atat-table">
<thead>
<tr>
<th>{{ "common.name" | translate }}</th>
<th>{{ "portfolios.applications.members.form.app_perms.title" | translate }}</th>
<th class="env_role--th">{{ 'portfolios.applications.members.form.env_access.table_header' | translate }}</th>
</tr>
</thead>
<tbody>
{% for member in members %}
{% set perms_modal = "edit_member-{}".format(loop.index) %}
{% set invite_pending = member.role_status == 'invite_pending' %}
{% set invite_expired = member.role_status == 'invite_expired' %}
<tr>
<th>{{ "common.name" | translate }}</th>
<th>{{ "portfolios.applications.members.form.app_perms.title" | translate }}</th>
<th class="env_role--th">{{ 'portfolios.applications.members.form.env_access.table_header' | translate }}</th>
</tr>
</thead>
<tbody>
{% for member in members %}
{% set perms_modal = "edit_member-{}".format(loop.index) %}
{% set invite_pending = member.role_status == 'invite_pending' %}
{% set invite_expired = member.role_status == 'invite_expired' %}
<tr>
<td>
<strong>{{ member.user_name }}</strong>
<br>
{{ Label(type=member.role_status, classes='label--below') }}
</td>
<td>
<strong>{{ member.user_name }}</strong>
<br>
{{ Label(type=member.role_status, classes='label--below') }}
</td>
<td>
{% for perm, value in member.permission_sets.items() %}
<div>
{{ ("portfolios.applications.members.{}.{}".format(perm, value)) | translate }}
</div>
{% endfor %}
</td>
<td class="env_role--td">
{% for env in member.environment_roles %}
<div class="row">
<span class="env-role__environment">
{{ env.environment_name }}
</span>
<span class="env-role__role">
: {{ env.role }}
</span>
</div>
{% endfor %}
{% if user_can(permissions.EDIT_APPLICATION_MEMBER) -%}
<td>
{% for perm, value in member.permission_sets.items() %}
<div>
{{ ("portfolios.applications.members.{}.{}".format(perm, value)) | translate }}
</div>
{% endfor %}
</td>
<td class="env_role--td">
{% for env in member.environment_roles %}
<div class="row">
<span class="env-role__environment">
{{ env.environment_name }}
</span>
<span class="env-role__role">
: {{ env.role }}
</span>
</div>
{% endfor %}
{% if user_can(permissions.EDIT_APPLICATION_MEMBER) -%}
<toggle-menu inline-template v-cloak>
<div class="app-member-menu">
{% set toggle_button -%}
<span v-if="isVisible" class="accordion-table__item__toggler accordion-table__item__toggler--active">
{{ Icon('ellipsis')}}
{%- endset %}
{% set section = "app_member_edit-{}".format(member.role_id) %}
</span>
<span v-else class="accordion-table__item__toggler">
{{ Icon('ellipsis')}}
</span>
{{
ToggleButton(
open_html=toggle_button,
close_html=toggle_button,
section_name=section,
active_style=True
)
}}
{% call ToggleSection(section_name=section, classes="app-member-menu__toggle") %}
<div v-show="isVisible" class="accordion-table__item-toggle-content app-member-menu__toggle">
<a v-on:click="openModal('{{ perms_modal }}')">
{{ "portfolios.applications.members.menu.edit" | translate }}
</a>
@ -162,15 +156,15 @@
<a v-on:click='openModal("{{ revoke_invite_modal }}")'>{{ 'invites.revoke' | translate }}</a>
{%- endif %}
{%- endif %}
{% endcall %}
</div>
</div>
{%- endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</toggler>
</toggle-menu>
{%- endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endif %}

View File

@ -0,0 +1,23 @@
{% macro Accordion(title, id, heading_level="h2") %}
<accordion inline-template>
<div>
<{{heading_level}}>
<button
v-on:click="toggle($event)"
class="usa-accordion-button"
aria-controls="{{ id }}"
v-bind:aria-expanded= "isVisible ? 'true' : 'false'"
>
{{ title }}
</button>
</{{heading_level}}>
<div
id="{{ id }}"
class="usa-accordion-content"
v-bind:aria-hidden="isVisible ? 'false' : 'true'"
>
{{ caller() }}
</div>
</div>
</accordion>
{% endmacro %}

View File

@ -1,6 +1,6 @@
{% from 'components/icon.html' import Icon %}
{% macro CLINDollarAmount(type, field=None, funding_validation=False) -%}
{% macro CLINDollarAmount(type, field=None, funding_validation=False) -%}
<div class="form-row">
<div class="form-col">
<clindollaramount
@ -21,16 +21,15 @@
:key="'clins-' + clinIndex + '-' + '{{ type }}' + '_amount'"
{% endif %}
validation="clinDollars"
:watch='true'>
validation="clinDollars">
<div v-bind:class="['usa-input usa-input--validation--dollars', { 'usa-input--error': showFundingError, 'usa-input--success': showFundingValid}]">
{% if field %}
<label for='{{ field.name }}'>
<label for='{{ field.name }}'>
{% else %}
<label :for='name'>
{% endif %}
{% if type=="obligated" %}
{% if type=="obligated" %}
<div class="usa-input__title">{{ 'task_orders.form.obligated_funds_label' | translate }}</div>
{% else %}
<div class="usa-input__title">{{ 'task_orders.form.total_funds_label' | translate }}</div>
@ -67,4 +66,4 @@
</clindollaramount>
</div>
</div>
{%- endmacro %}
{%- endmacro %}

View File

@ -41,10 +41,9 @@
<div class="form-row">
<div class="form-col">
{% if fields %}
{{ TextInput(fields.number, watch=True, optional=False) }}
{{ TextInput(fields.number, optional=False) }}
{% else %}
<textinput :name="'clins-' + clinIndex + '-number'" :watch='true'
inline-template>
<textinput :name="'clins-' + clinIndex + '-number'" inline-template>
<div v-bind:class="['usa-input usa-input--validation--' + validation, { 'usa-input--error': showError, 'usa-input--success': showValid, 'usa-input--validation--paragraph': paragraph, 'no-max-width': noMaxWidth }]">
<label :for="name">
<span v-show='showError'>{{ Icon('alert',classes="icon-validation") }}</span>
@ -82,9 +81,9 @@
<div class="form-row">
<div class="form-col">
{% if fields %}
{{ OptionsInput(fields.jedi_clin_type, watch=True, show_validation=False, optional=False) }}
{{ OptionsInput(fields.jedi_clin_type, show_validation=False, optional=False) }}
{% else %}
<optionsinput :name="'clins-' + clinIndex + '-jedi_clin_type'" :watch='true' :optional='false' inline-template>
<optionsinput :name="'clins-' + clinIndex + '-jedi_clin_type'" :optional='false' inline-template>
<div v-bind:class="['usa-input', { 'usa-input--error': showError, 'usa-input--success': showValid }]">
<fieldset data-ally-disabled="true" class="usa-input__choices" v-on:change="onInput">
<legend>
@ -93,10 +92,10 @@
</div>
</legend>
<select :id='name' :name='name'>
<option value="JEDI_CLIN_1">{{ "forms.task_order.clin_01_label" | translate }}</option>
<option value="JEDI_CLIN_2">{{ "forms.task_order.clin_02_label" | translate }}</option>
<option value="JEDI_CLIN_3">{{ "forms.task_order.clin_03_label" | translate }}</option>
<option value="JEDI_CLIN_4">{{ "forms.task_order.clin_04_label" | translate }}</option>
<option value="JEDI_CLIN_1">{{ "JEDICLINType.JEDI_CLIN_1" | translate }}</option>
<option value="JEDI_CLIN_2">{{ "JEDICLINType.JEDI_CLIN_2" | translate }}</option>
<option value="JEDI_CLIN_3">{{ "JEDICLINType.JEDI_CLIN_3" | translate }}</option>
<option value="JEDI_CLIN_4">{{ "JEDICLINType.JEDI_CLIN_4" | translate }}</option>
</select>
</fieldset>
</div>

View File

@ -11,6 +11,7 @@
{% if other_input_field and other_input_field.data and other_input_field.data != "None" %}
initial-other-value="{{ other_input_field.data }}"
{% endif %}
v-bind:optional={{ optional|lower }}
key='{{ field.name }}'>
<div
v-bind:class="['usa-input', { 'usa-input--error': showError, 'usa-input--success': showValid }]">

View File

@ -8,7 +8,6 @@
label=True,
show_validation=True,
disabled=False,
watch=False,
optional=True) -%}
<optionsinput
name='{{ field.name }}'
@ -16,7 +15,6 @@
{% if field.errors %}v-bind:initial-errors='{{ field.errors | list }}'{% endif %}
{% if field.data and field.data != "None" %}v-bind:initial-value="'{{ field.data }}'"{% endif %}
key='{{ field.name }}'
v-bind:watch='{{ watch | string | lower }}'
v-bind:optional={{ optional|lower }}
v-bind:null-option="'{{ field.default }}'"
>

View File

@ -17,7 +17,6 @@
optional=True,
showOptional=True,
showLabel=True,
watch=False,
show_validation=True) -%}
<textinput
@ -30,7 +29,6 @@
{% if field.errors %}v-bind:initial-errors='{{ field.errors | list }}'{% endif %}
v-bind:optional={{ optional|lower }}
key='{{ field.name }}'
:watch='{{ watch | string | lower }}'
inline-template>
<div

View File

@ -1,8 +1,8 @@
{% from "components/icon.html" import Icon %}
{% macro Tooltip(message,title='Help') -%}
{% macro Tooltip(message,title='Help', classes="") %}
<button type="button" tabindex="0" class="icon-tooltip" v-tooltip.top="{content: '{{message}}', container: false}">
<button type="button" tabindex="0" class="icon-tooltip {{classes}}" v-tooltip.top="{content: '{{message}}', container: false}">
{{ Icon('help') }}<span>{{ title }}</span>
</button>

View File

@ -0,0 +1,82 @@
{% from "components/empty_state.html" import EmptyState %}
{% from "components/icon.html" import Icon %}
<div>
<h2>Funds Expended per Application and Environment</h2>
{% set current_month_index = current_month.strftime('%m/%Y') %}
{% set prev_month_index = prev_month.strftime('%m/%Y') %}
{% if not portfolio.applications %}
{% set can_create_applications = user_can(permissions.CREATE_APPLICATION) %}
{% set message = ('portfolios.reports.empty_state.sub_message.can_create_applications' | translate)
if can_create_applications
else ('portfolios.reports.empty_state.sub_message.cannot_create_applications' | translate)
%}
{{ EmptyState(
('portfolios.reports.empty_state.message' | translate),
action_label= ('portfolios.reports.empty_state.action_label' | translate) if can_create_applications else None,
action_href=url_for('applications.create_new_application_step_1', portfolio_id=portfolio.id) if can_create_applications else None,
icon='chart',
sub_message=message,
add_perms=can_create_applications
) }}
{% else %}
<spend-table
v-bind:applications='{{ monthly_totals['applications'] | tojson }}'
v-bind:environments='{{ monthly_totals['environments'] | tojson }}'
current-month-index='{{ current_month_index }}'
prev-month-index='{{ prev_month_index }}'
inline-template>
<div class="responsive-table-wrapper">
<table class="atat-table">
<thead>
<tr>
<th>Applications and Environments</th>
<th class="table-cell--align-right">Current Month</th>
<th class="table-cell--align-right">Last Month</th>
<th class="table-cell--align-right">Total Spent</th>
</tr>
</thead>
<tbody>
<template v-for='(application, name) in applicationsState'>
<tr>
<td>
<button v-on:click='toggle($event, name)' class='icon-link icon-link--large'>
<span v-html='name'></span>
<template v-if='application.isVisible'>{{ Icon('caret_down') }}</template>
<template v-else>{{ Icon('caret_up') }}</template>
</button>
</td>
<td class="table-cell--align-right">
<span v-html='formatDollars(application[currentMonthIndex] || 0)'></span>
</td>
<td class="table-cell--align-right">
<span v-html='formatDollars(application[prevMonthIndex] || 0)'></span>
</td>
<td class="table-cell--align-right">
<span v-html='formatDollars(application["total_spend_to_date"])'></span>
</td>
</tr>
<tr v-for='(environment, envName) in environments[name]' v-show='application.isVisible'>
<td>
<span v-html='envName'></span>
</td>
<td class="table-cell--align-right">
<span v-html='formatDollars(environment[currentMonthIndex] || 0)'></span>
</td>
<td class="table-cell--align-right">
<span v-html='formatDollars(environment[prevMonthIndex] || 0)'></span>
</td>
<td class="table-cell--align-right">
<span v-html='formatDollars(environment["total_spend_to_date"])'></span>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</spend-table>
{% endif %}
</div>

View File

@ -0,0 +1,34 @@
{% from "components/accordion.html" import Accordion %}
<section>
<div class="usa-accordion">
{% call Accordion("Expired Task Orders", "expired_task_orders", "h3") %}
{% for task_order in expired_task_orders %}
<a href="{{ url_for("task_orders.review_task_order", task_order_id=task_order["id"]) }}">
Task Order {{ task_order["number"] }}
</a>
<div>
<p>Period of Performance</p>
<p>
{{ task_order["period_of_performance"].start_date | formattedDate(formatter="%B %d, %Y") }}
-
{{ task_order["period_of_performance"].end_date | formattedDate(formatter="%B %d, %Y") }}
</p>
</div>
<div>
<p>Total Obligated</p>
<p>{{ task_order["total_obligated_funds"] | dollars }}</p>
</div>
<div>
<p>Total Expended</p>
<p>{{ task_order["expended_funds"] | dollars }}</p>
</div>
<div>
<p>Total Unused</p>
<p>{{ (task_order["total_obligated_funds"] - task_order["expended_funds"]) | dollars }}</p>
</div>
{% endfor %}
{% endcall %}
</div>
</section>

View File

@ -1,463 +1,17 @@
{% extends "portfolios/base.html" %}
{% from "components/icon.html" import Icon %}
{% from "components/empty_state.html" import EmptyState %}
{% from "components/sticky_cta.html" import StickyCTA %}
{% block portfolio_content %}
<div class='portfolio-reports'>
<div v-cloak class='funding-summary-row'>
<div class='funding-summary-row__col'>
<div class='panel spend-summary'>
<h4 class='spend-summary__heading subheading'>Portfolio Total Spend</h4>
<div class='row'>
<dl class='spend-summary__budget col col--grow row'>
{% set budget = portfolio_totals['budget'] %}
{% set spent = portfolio_totals['spent'] %}
{% set remaining = budget - spent %}
<dl class='col col--grow'>
<dt>Budget</dt>
<dd>{{ budget | dollars }}</dd>
</dl>
<dl class='col col--grow'>
<dt>Remaining</dt>
<dd>{{ remaining | dollars }}</dd>
</dl>
</dl>
</div>
<hr></hr>
<div>
<meter value='{{ spent }}' min='0' {% if budget %}max='{{ budget }}' {% endif %}title='{{ spent | dollars }} Total spend to date'>
<div class='meter__fallback' style='width:{{ (spent / budget) * 100 if budget else 0 }}%;'></div>
</meter>
<dl class='spend-summary__spent'>
<dt>Total spending to date</dt>
<dd>{{ spent | dollars }}</dd>
</dl>
</div>
</div>
</div>
<div class='funding-summary-row__col'>
<div class='panel to-summary'>
<div class='to-summary__row'>
<div class='to-summary__to'>
<h2 class='to-summary__heading subheading'>Current Task Order</h2>
<dl class='to-summary__to-number'>
<dt class='usa-sr-only'>Task Order Number</dt>
<dd>{{ task_order.number }}</dd>
</dl>
</div>
<hr></hr>
<div class='to-summary__expiration'>
<div class='row'>
<h4 class='subheading'>Expiration Date</h4>
</div>
<div class='row'>
<div class='col col--grow'>
<div>
{% if expiration_date %}
<local-datetime
timestamp='{{ expiration_date }}'
format='MMMM D, YYYY'>
</local-datetime>
{% else %}
-
{% endif %}
</div>
<a href='{{ url_for("task_orders.review_task_order", task_order_id=task_order.id) }}' class='icon-link'>
{{ Icon('cog') }}
Manage Task Order
</a>
</div>
<div class='col col--grow'>
<dl>
<dt>Remaining Days</dt>
<dd class='{{ 'ending-soon' if remaining_days is not none }}'>
{% if remaining_days is not none %}
{{ Icon('arrow-down') }}
<span>{{ remaining_days }}</span>
{% else %}
-
{% endif %}
</dd>
</dl>
</div>
</div>
</div>
</div>
<hr></hr>
<dl class='to-summary__co'>
<dt class='subheading'>Contracting Officer</dt>
<dd class='row'>
<div class='col col--grow'>
{% if task_order.ko_first_name and task_order.ko_last_name %}
{{ task_order.ko_first_name }} {{ task_order.ko_last_name }}
{% endif %}
</div>
<div class='col'>
{% if task_order.ko_email %}
<a class='icon-link' href='mailto:{{ task_order.ko_email }}'>
{{ Icon('envelope') }}
{{ task_order.ko_email }}
</a>
{% endif %}
</div>
</dd>
</dl>
</div>
</div>
{{ StickyCTA("Reports") }}
<div class="portfolio-reports col col--grow">
<p class="row estimate-warning">{{ "portfolios.reports.estimate_warning" | translate }}</p>
{% include "portfolios/reports/portfolio_summary.html" %}
<hr>
{% include "portfolios/reports/obligated_funds.html" %}
{% include "portfolios/reports/expired_task_orders.html" %}
<hr>
{% include "portfolios/reports/application_and_env_spending.html" %}
</div>
{% set portfolio_totals = monthly_totals['portfolio'] %}
{% set current_month_index = current_month.strftime('%m/%Y') %}
{% set prev_month_index = prev_month.strftime('%m/%Y') %}
{% set two_months_ago_index = two_months_ago.strftime('%m/%Y') %}
{% set reports_url = url_for("portfolios.reports", portfolio_id=portfolio.id) %}
{% if not portfolio.applications %}
{% set can_create_applications = user_can(permissions.CREATE_APPLICATION) %}
{% set message = 'This portfolio has no cloud environments set up, so there is no spending data to report. Create an application with some cloud environments to get started.'
if can_create_applications
else 'This portfolio has no cloud environments set up, so there is no spending data to report. Contact the portfolio owner to set up some cloud environments.'
%}
{{ EmptyState(
'Nothing to report',
action_label='Add a new application' if can_create_applications else None,
action_href=url_for('applications.create_new_application_step_1', portfolio_id=portfolio.id) if can_create_applications else None,
icon='chart',
sub_message=message,
add_perms=can_create_applications
) }}
{% else %}
<budget-chart
v-cloak
budget={{ budget }}
current-month='{{ current_month_index }}'
expiration-date='{{ expiration_date }}'
v-bind:months='{{ cumulative_budget.months | tojson }}'
inline-template>
<div class='budget-chart panel' ref='panel'>
<header class='budget-chart__header panel__heading panel__heading--tight'>
<h4>Cumulative Budget</h4>
<div class='budget-chart__legend'>
<dl class='budget-chart__legend__spend'>
<div>
<dt>Monthly Spend</dt>
<dd class='budget-chart__legend__dot monthly'><span class='usa-sr-only'>Monthly spend visual key</span></dd>
</div>
<div>
<dt>Accumulated Spend</dt>
<dd class='budget-chart__legend__dot accumulated'><span class='usa-sr-only'>Accumulated spend visual key</span></dd>
</div>
</dl>
<dl class='budget-chart__legend__projected'>
<div>
<dt>Projected</dt>
<dd>
<div class='budget-chart__legend__line spend'><span class='usa-sr-only'>Projected monthly spend visual key</span></div>
<div class='budget-chart__legend__line accumulated'><span class='usa-sr-only'>Projected accumulated spend visual key</span></div>
</dd>
</div>
</dl>
</div>
</header>
<svg v-bind:height='height' v-bind:width='width'>
<defs>
<filter x="-0.04" y="0" width="1.08" height="1" class='filter__text-background' id="text-background">
<feFlood/>
<feComposite in="SourceGraphic"/>
</filter>
</defs>
{# spend/projected budget path lines #}
<path class='budget-chart__projected-path' v-bind:d='projectedPath'></path>
<path class='budget-chart__spend-path' v-bind:d='spendPath'></path>
{# max budget line #}
<line
class='budget-chart__budget-line'
x1='0'
v-bind:x2='width'
v-bind:y1='budgetHeight'
v-bind:y2='budgetHeight'></line>
<g v-for='month in displayedMonths' >
{# make this clickable to focus on that month #}
<a v-bind:href='"{{ reports_url }}?month=" + month.date.monthIndex + "&year=" + month.date.year'>
<defs>
<filter
x="-0.04"
y="0"
width="1.08"
height="1"
class='filter__text-background'
v-bind:class='{ "filter__text-background--highlighted": month.isHighlighted }'
v-bind:id="'text-background__' +month.date.month + month.date.year">
<feFlood/>
<feComposite in="SourceGraphic"/>
</filter>
</defs>
<title>
<span v-html='month.date.month + " " + month.date.year'></span>&nbsp;|&nbsp;<!--
--><template v-if='month.cumulativeTotal'><!--
--><template v-if='month.budget && month.budget.spend'>Spend:</template><!--
--><template v-else>Projected Spend:</template><!--
--><span v-html='month.spendAmount'></span><!--
-->&nbsp;|&nbsp;<!--
--><template v-if='month.budget'>Total:</template><!--
--><template v-else>Projected Total:</template><!--
--><span v-html='month.cumulativeAmount'></span><!--
--></template><!--
--><template v-else>No spend for this month</template>
</title>
{# container block #}
<rect
class='budget-chart__block'
v-bind:class='{ "budget-chart__block--highlighted": month.isHighlighted, "budget-chart__block-is-expiration": month.isExpirationMonth }'
v-bind:width='month.metrics.blockWidth'
v-bind:x='month.metrics.blockX'
v-bind:height='height'></rect>
{# budget bar #}
<rect
v-if='month.budget'
class='budget-chart__bar'
v-bind:class='{ "budget-chart__bar--projected": month.budget.projected }'
v-bind:width='month.metrics.barWidth'
v-bind:height='month.metrics.barHeight'
v-bind:x='month.metrics.barX'
v-bind:y='month.metrics.barY'></rect>
{# projected budget bar #}
<rect
v-if='!month.budget'
class='budget-chart__bar budget-chart__bar--projected'
v-bind:width='month.metrics.barWidth'
v-bind:height='month.metrics.barHeight'
v-bind:x='month.metrics.barX'
v-bind:y='month.metrics.barY'></rect>
{# task order expiration line #}
<line
v-if='month.isExpirationMonth'
class='budget-chart__expiration-line'
v-bind:x1='month.metrics.cumulativeX'
v-bind:x2='month.metrics.cumulativeX'
y1='0'
v-bind:y2='baseHeight'></line>
{# task order expiration label #}
<text
v-bind:filter="'url(#text-background__' + month.date.month + month.date.year + ')'"
v-if='month.isExpirationMonth'
text-anchor='middle'
v-bind:x='month.metrics.cumulativeX'
v-bind:y='budgetHeight + 20'
class='budget-chart__label'>T.O. Expires</text>
{# cumulative dot #}
<circle
v-if='month.cumulativeTotal'
class='budget-chart__cumulative__dot'
v-bind:r='month.metrics.cumulativeR'
v-bind:cx='month.metrics.cumulativeX'
v-bind:cy='month.metrics.cumulativeY'></circle>
{# abbreviated cumulative label #}
<text
v-bind:filter="'url(#text-background__' + month.date.month + month.date.year + ')'"
v-if='month.cumulativeTotal'
v-bind:x='month.metrics.cumulativeX'
v-bind:y='month.metrics.cumulativeY - 10'
text-anchor='middle'
class='budget-chart__label'
v-html='month.abbreviatedCumulative'></text>
{# abbreviated spend label #}
<text
v-bind:filter="'url(#text-background__' + month.date.month + month.date.year + ')'"
v-bind:x='month.metrics.cumulativeX'
v-bind:y='baseHeight + 20'
text-anchor='middle'
class='budget-chart__label'
v-html='"+" + month.abbreviatedSpend'></text>
{# month label #}
<text
v-bind:filter="'url(#text-background__' + month.date.month + month.date.year + ')'"
v-bind:x='month.metrics.cumulativeX'
v-bind:y='baseHeight + 40'
text-anchor='middle'
class='budget-chart__label budget-chart__label--strong'
v-html='month.date.month'></text>
{# year label #}
<text
v-bind:filter="'url(#text-background__' + month.date.month + month.date.year + ')'"
v-if='month.showYear'
v-bind:x='month.metrics.cumulativeX'
v-bind:y='baseHeight + 55'
text-anchor='middle'
class='budget-chart__label budget-chart__label--strong'
v-html='month.date.year'></text>
</g>
</a>
<text
x='20'
v-bind:y='budgetHeight + 20'
class='budget-chart__label'>Total Budget</text>
<text
x='20'
v-bind:y='budgetHeight + 40'
class='budget-chart__label'
v-html='displayBudget'></text>
</svg>
</div>
</budget-chart>
<div class='accordion-table responsive-table-wrapper'>
<div class='responsive-table-wrapper__header'>
<h2 class='responsive-table-wrapper__title'>Total spent per month</h2>
<select name='month' id='month' onchange='location = this.value' class='spend-table__month-select'>
{% for m in cumulative_budget["months"] %}
{% set month = m | dateFromString %}
<option
{% if month.month == current_month.month and month.year == current_month.year %}
selected='selected'
{% endif %}
value='{{ url_for("portfolios.reports",
portfolio_id=portfolio.id,
month=month.month,
year=month.year) }}'
>
{{ month.strftime('%B %Y') }}
</option>
{% endfor %}
{% if not cumulative_budget["months"] %}
<option>{{ current_month.strftime('%B %Y') }}</option>
{% endif %}
</select>
</div>
<spend-table
v-bind:applications='{{ monthly_totals['applications'] | tojson }}'
v-bind:portfolio='{{ portfolio_totals | tojson }}'
v-bind:environments='{{ monthly_totals['environments'] | tojson }}'
current-month-index='{{ current_month_index }}'
prev-month-index='{{ prev_month_index }}'
two-months-ago-index='{{ two_months_ago_index }}'
inline-template>
<table class="atat-table">
<thead>
<th scope='col'><span class='usa-sr-only'>Spending scope</span></th>
<th scope='col' class='table-cell--align-right previous-month'>{{ two_months_ago.strftime('%B %Y') }}</th>
<th scope='col' class='table-cell--align-right previous-month'>{{ prev_month.strftime('%B %Y') }}</th>
<th scope='col' class='table-cell--align-right current-month'>{{ current_month.strftime('%B %Y') }}</th>
<th class='current-month'></th>
</thead>
<tbody class='spend-table__portfolio'>
<tr>
<th scope='row'>Portfolio Total</th>
<td class='table-cell--align-right previous-month'>{{ portfolio_totals.get(two_months_ago_index, 0) | dollars }}</td>
<td class='table-cell--align-right previous-month'>{{ portfolio_totals.get(prev_month_index, 0) | dollars }}</td>
<td class='table-cell--align-right current-month'>{{ portfolio_totals.get(current_month_index, 0) | dollars }}</td>
<td class='table-cell--expand current-month meter-cell'>
<meter value='{{ portfolio_totals.get(current_month_index, 0) }}' min='0' max='{{ portfolio_totals.get(current_month_index, 0) }}'>
<div class='meter__fallback' style='width: 100%'></div>
</meter>
</td>
</tr>
</tbody>
<tbody v-for='(application, name) in applicationsState' class='accordion-table__items'>
<tr>
<th scope='rowgroup'>
<button v-on:click='toggle($event, name)' class='icon-link icon-link--large accordion-table__item__toggler'>
<template v-if='application.isVisible'>{{ Icon('caret_down') }}<div class='open-indicator'></div></template>
<template v-else>{{ Icon('caret_right') }}</template>
<span v-html='name'></span>
</button>
</th>
<td class='table-cell--align-right previous-month'>
<span v-html='formatDollars(application[twoMonthsAgoIndex] || 0)'></span>
</td>
<td class='table-cell--align-right previous-month'>
<span v-html='formatDollars(application[prevMonthIndex] || 0)'></span>
</td>
<td class='table-cell--align-right current-month'>
<span v-html='formatDollars(application[currentMonthIndex] || 0)'></span>
</td>
<td class='table-cell--expand current-month meter-cell'>
<span class='spend-table__meter-value'>
<span v-html='round( 100 * ((application[currentMonthIndex] || 0) / (portfolio[currentMonthIndex] || 1) )) + "%"'></span>
</span>
<meter v-bind:value='application[currentMonthIndex] || 0' min='0' v-bind:max='portfolio[currentMonthIndex] || 1'>
<div class='meter__fallback' v-bind:style='"width:" + round( 100 * ((application[currentMonthIndex] || 0) / (portfolio[currentMonthIndex] || 1) )) + "%;"'></div>
</meter>
</td>
</tr>
<tr v-for='(environment, envName) in environments[name]' v-show='application.isVisible' class='accordion-table__item__expanded'>
<th scope='rowgroup'>
<div class='icon-link accordion-table__item__expanded'>
<span v-html='envName'></span>
</div>
</th>
<td class='table-cell--align-right previous-month'>
<span v-html='formatDollars(environment[twoMonthsAgoIndex] || 0)'></span>
</td>
<td class='table-cell--align-right previous-month'>
<span v-html='formatDollars(environment[prevMonthIndex] || 0)'></span>
</td>
<td class='table-cell--align-right current-month'>
<span v-html='formatDollars(environment[currentMonthIndex] || 0)'></span>
</td>
<td class='table-cell--expand current-month'></td>
</tr>
</tbody>
</table>
</spend-table>
</div>
{% endif %}
</div>
{% endblock %}

View File

@ -0,0 +1,44 @@
{% from "components/icon.html" import Icon %}
<section>
<header class="reporting-section-header">
<h2 class="reporting-section-header__header">Current Obligated funds</h2>
<span class="reporting-section-header__subheader">As of {{ now | formattedDate(formatter="%B %d, %Y at %H:%M") }}</span>
</header>
<div class='panel'>
<div class='panel__content jedi-clin-funding'>
{% for JEDI_clin, funds in current_obligated_funds.items() %}
{% set remaining_funds = (funds["obligated_funds"] - funds["expended_funds"]) %}
<div class="jedi-clin-funding__clin-wrapper">
<h3 class="h5 jedi-clin-funding__header">
{{ "JEDICLINType.{}".format(JEDI_clin) | translate }}
</h3>
<p class="jedi-clin-funding__subheader">Total obligated amount: {{ funds["obligated_funds"] | dollars }}</p>
<meter class="jedi-clin-funding__meter" value='{{remaining_funds}}' min='0' max='{{ funds["obligated_funds"] }}' title='{{ JEDI_clin }}'>
<div class='jedi-clin-funding__meter-fallback' style='width:{{ (funds["expended_funds"] / funds["obligated_funds"]) * 100 }}%;'></div>
</meter>
<div class="jedi-clin-funding__meter-values">
<div class="jedi-clin-funding__meta">
<p class="jedi-clin-funding__meta-header">Funds expended:</p>
<p class="h3 jedi-clin-funding__meta-value">{{ funds["expended_funds"] | dollars }}</p>
</div>
<div class="jedi-clin-funding__meta jedi-clin-funding__meta--remaining">
<p class="jedi-clin-funding__meta-header">Remaining funds:</p>
<p class="h3 jedi-clin-funding__meta-value">{{ remaining_funds | dollars }}</p>
</div>
</div>
</div>
{% endfor %}
<div class="jedi-clin-funding__active-task-orders">
<h3 class="h4">
Active Task Orders
</h3>
{% for task_order in portfolio.active_task_orders %}
<a href="{{ url_for("task_orders.review_task_order", task_order_id=task_order.id) }}">
{{ task_order.number }} {{ Icon("caret_right", classes="icon--tiny icon--blue" ) }}
</a>
{% endfor %}
</div>
</div>
</div>
</section>

View File

@ -0,0 +1,36 @@
{% from "components/tooltip.html" import Tooltip %}
{% from "components/icon.html" import Icon %}
<section class="row">
<div class='col col--grow reporting-summary-item'>
<h5 class="reporting-summary-item__header">
<span class="reporting-summary-item__header-text">Total Portfolio Value</span>
{{Tooltip(("common.lorem" | translate), title="", classes="reporting-summary-item__header-icon")}}
</h5>
<p class="reporting-summary-item__value">{{ total_portfolio_value | dollars }}</p>
</div>
<div class='col col--grow reporting-summary-item'>
<h5 class="reporting-summary-item__header">
<span class="reporting-summary-item__header-text">Funding Duration</span>
{{Tooltip(("common.lorem" | translate), title="", classes="reporting-summary-item__header-icon")}}
</h5>
{% set earliest_pop_start_date, latest_pop_end_date = portfolio.funding_duration %}
{% if earliest_pop_start_date and latest_pop_end_date %}
<p class="reporting-summary-item__value" >
{{ earliest_pop_start_date | formattedDate(formatter="%B %d, %Y") }}
-
{{ latest_pop_end_date | formattedDate(formatter="%B %d, %Y") }}
</p>
{% else %}
<p class="reporting-summary-item__value"> - </p>
{% endif %}
</div>
<div class='col col--grow reporting-summary-item'>
<h5 class="reporting-summary-item__header">
<span class="reporting-summary-item__header-text">Days Remaining</span>
{{Tooltip(("common.lorem" | translate), title="", classes="reporting-summary-item__header-icon")}}
</h5>
<p class="reporting-summary-item__value">{{ portfolio.days_to_funding_expiration }} days</p>
</div>
</section>

View File

@ -1,27 +1,22 @@
from atst.domain.reports import Reports
from tests.factories import PortfolioFactory
from tests.factories import *
def test_portfolio_totals():
portfolio = PortfolioFactory.create()
report = Reports.portfolio_totals(portfolio)
assert report == {"budget": 0, "spent": 0}
# this is sketched in until we do real reporting
# this is sketched out until we do real reporting
def test_monthly_totals():
portfolio = PortfolioFactory.create()
monthly = Reports.monthly_totals(portfolio)
assert not monthly["environments"]
assert not monthly["applications"]
assert not monthly["portfolio"]
pass
# this is sketched in until we do real reporting
def test_cumulative_budget():
portfolio = PortfolioFactory.create()
months = Reports.cumulative_budget(portfolio)
# this is sketched out until we do real reporting
def test_current_obligated_funds():
pass
assert len(months["months"]) >= 12
# this is sketched out until we do real reporting
def test_expired_task_orders():
pass
# this is sketched out until we do real reporting
def test_obligated_funds_by_JEDI_clin():
pass

View File

@ -289,6 +289,7 @@ class TaskOrderFactory(Base):
)
number = factory.LazyFunction(random_task_order_number)
creator = factory.SubFactory(UserFactory)
signed_at = None
_pdf = factory.SubFactory(AttachmentFactory)
@classmethod

View File

@ -1,4 +1,12 @@
from tests.factories import ApplicationFactory, PortfolioFactory
from tests.factories import (
ApplicationFactory,
PortfolioFactory,
TaskOrderFactory,
CLINFactory,
random_future_date,
random_past_date,
)
import datetime
def test_portfolio_applications_excludes_deleted():
@ -7,3 +15,73 @@ def test_portfolio_applications_excludes_deleted():
ApplicationFactory.create(portfolio=portfolio, deleted=True)
assert len(portfolio.applications) == 1
assert portfolio.applications[0].id == app.id
def test_funding_duration(session):
# portfolio with active task orders
portfolio = PortfolioFactory()
funding_start_date = random_past_date()
funding_end_date = random_future_date(year_min=2)
TaskOrderFactory.create(
signed_at=random_past_date(),
portfolio=portfolio,
create_clins=[
{
"start_date": funding_start_date,
"end_date": random_future_date(year_max=1),
}
],
)
TaskOrderFactory.create(
portfolio=portfolio,
signed_at=random_past_date(),
create_clins=[
{"start_date": datetime.datetime.now(), "end_date": funding_end_date,}
],
)
assert portfolio.funding_duration == (funding_start_date, funding_end_date)
# empty portfolio
empty_portfolio = PortfolioFactory()
assert empty_portfolio.funding_duration == (None, None)
def test_days_remaining(session):
# portfolio with task orders
funding_end_date = random_future_date(year_min=2)
portfolio = PortfolioFactory()
TaskOrderFactory.create(
portfolio=portfolio,
signed_at=random_past_date(),
create_clins=[{"end_date": funding_end_date}],
)
assert (
portfolio.days_to_funding_expiration
== (funding_end_date - datetime.date.today()).days
)
# empty portfolio
empty_portfolio = PortfolioFactory()
assert empty_portfolio.days_to_funding_expiration == 0
def test_active_task_orders(session):
portfolio = PortfolioFactory()
TaskOrderFactory.create(
portfolio=portfolio,
signed_at=random_past_date(),
create_clins=[
{
"start_date": datetime.date(2019, 1, 1),
"end_date": datetime.date(2019, 10, 31),
}
],
)
TaskOrderFactory.create(
portfolio=portfolio, signed_at=random_past_date(), clins=[CLINFactory.create()]
)
assert len(portfolio.active_task_orders) == 1

View File

@ -110,7 +110,6 @@ def test_portfolio_reports_with_mock_portfolio(client, user_session):
response = client.get(url_for("portfolios.reports", portfolio_id=portfolio.id))
assert response.status_code == 200
assert portfolio.name in response.data.decode()
assert "$251,626.00 Total spend to date" in response.data.decode()
def test_delete_portfolio_success(client, user_session):

View File

@ -69,10 +69,12 @@ def test_request_context_filter(logger, log_stream_content, request_ctx, monkeyp
user = Mock(spec=["id"])
user.id = user_uuid
user.dod_id = "5678901234"
monkeypatch.setattr("atst.utils.logging.g", Mock(current_user=user))
request_ctx.request.environ["HTTP_X_REQUEST_ID"] = request_uuid
logger.info("this user is doing something")
log = json.loads(log_stream_content())
assert log["user_id"] == str(user_uuid)
assert log["dod_edipi"] == str(user.dod_id)
assert log["request_id"] == request_uuid

View File

@ -182,10 +182,6 @@ forms:
none: Not planning to migrate any applications
not_sure: Not sure
on_premise: 'Yes, migrating from an on-premise data center'
clin_01_label: 'IaaS/PaaS (IDIQ CLIN 0001)'
clin_02_label: 'IDIQ CLIN 0002'
clin_03_label: 'IDIQ CLIN 0003'
clin_04_label: 'IDIQ CLIN 0004'
complexity:
conus: CONUS access
data_analytics: Data analytics
@ -449,6 +445,15 @@ portfolios:
name: Name
portfolio_mgmt: Portfolio management
reporting: Reporting
reports:
estimate_warning: Reports displayed in JEDI are estimates and not a system of record.
empty_state:
message: Nothing to report.
sub_message:
can_create_applications: This portfolio has no cloud environments set up, so there is no spending data to report. Create an application with some cloud environments to get started.
cannot_create_applications: This portfolio has no cloud environments set up, so there is no spending data to report. Contact the portfolio owner to set up some cloud environments.
action_label: 'Add a new application'
task_orders:
review:
pdf_title: Approved Task Order
@ -511,10 +516,10 @@ task_orders:
sign:
digital_signature_description: I acknowledge that the uploaded task order contains the required KO signature.
JEDICLINType:
JEDI_CLIN_1: 'Unclassified IaaS and PaaS (IDIQ CLIN 0001)'
JEDI_CLIN_2: 'Classified IaaS and PaaS (IDIQ CLIN 0002)'
JEDI_CLIN_3: 'Unclassified Cloud Support Package (IDIQ CLIN 0003)'
JEDI_CLIN_4: 'Classified Cloud Support Package (IDIQ CLIN 0004)'
JEDI_CLIN_1: 'IDIQ CLIN 0001 Unclassified IaaS/PaaS'
JEDI_CLIN_2: 'IDIQ CLIN 0002 Classified IaaS/PaaS'
JEDI_CLIN_3: 'IDIQ CLIN 0003 Unclassified Cloud Support Package'
JEDI_CLIN_4: 'IDIQ CLIN 0004 Classified Cloud Support Package'
testing:
example_string: Hello World
example_with_variables: 'Hello, {name}!'

View File

@ -74,15 +74,16 @@
<td></td>
<td></td>
</tr>
<!--Imported from: AT-AT CI - Login Brandon-->
<!--Imported from: AT-AT CI - Login Brandon
11/19/19 17:10 Temporarily making optional until Leigh's PR#1197 merges-->
<tr>
<td>waitForElementPresent</td>
<td>css=.col > .usa-alert.usa-alert-info:nth-of-type(3) > .usa-alert-body > h3.usa-alert-heading</td>
<td>css=.col > .usa-alert.usa-alert-info:nth-of-type(2) > .usa-alert-body > h3.usa-alert-heading</td>
<td></td>
</tr>
<tr>
<td>assertText</td>
<td>css=.col > .usa-alert.usa-alert-info:nth-of-type(3) > .usa-alert-body > h3.usa-alert-heading</td>
<td>verifyText</td>
<td>css=.col > .usa-alert.usa-alert-info:nth-of-type(2) > .usa-alert-body > h3.usa-alert-heading</td>
<td>*Logged out*</td>
</tr>
<tr>

View File

@ -78,15 +78,16 @@ Imported from: AT-AT CI - Login Brandon-->
<td></td>
</tr>
<!--Imported from: AT-AT CI - New App Step 3
Imported from: AT-AT CI - Login Brandon-->
Imported from: AT-AT CI - Login Brandon
11/19/19 17:10 Temporarily making optional until Leigh's PR#1197 merges-->
<tr>
<td>waitForElementPresent</td>
<td>css=.col > .usa-alert.usa-alert-info:nth-of-type(3) > .usa-alert-body > h3.usa-alert-heading</td>
<td>css=.col > .usa-alert.usa-alert-info:nth-of-type(2) > .usa-alert-body > h3.usa-alert-heading</td>
<td></td>
</tr>
<tr>
<td>assertText</td>
<td>css=.col > .usa-alert.usa-alert-info:nth-of-type(3) > .usa-alert-body > h3.usa-alert-heading</td>
<td>verifyText</td>
<td>css=.col > .usa-alert.usa-alert-info:nth-of-type(2) > .usa-alert-body > h3.usa-alert-heading</td>
<td>*Logged out*</td>
</tr>
<tr>

View File

@ -82,15 +82,16 @@ Imported from: AT-AT CI - Login Brandon-->
</tr>
<!--Imported from: AT-AT CI - Application Settings
Imported from: AT-AT CI - New App Step 3
Imported from: AT-AT CI - Login Brandon-->
Imported from: AT-AT CI - Login Brandon
11/19/19 17:10 Temporarily making optional until Leigh's PR#1197 merges-->
<tr>
<td>waitForElementPresent</td>
<td>css=.col > .usa-alert.usa-alert-info:nth-of-type(3) > .usa-alert-body > h3.usa-alert-heading</td>
<td>css=.col > .usa-alert.usa-alert-info:nth-of-type(2) > .usa-alert-body > h3.usa-alert-heading</td>
<td></td>
</tr>
<tr>
<td>assertText</td>
<td>css=.col > .usa-alert.usa-alert-info:nth-of-type(3) > .usa-alert-body > h3.usa-alert-heading</td>
<td>verifyText</td>
<td>css=.col > .usa-alert.usa-alert-info:nth-of-type(2) > .usa-alert-body > h3.usa-alert-heading</td>
<td>*Logged out*</td>
</tr>
<tr>
@ -1431,6 +1432,36 @@ Imported from: AT-AT CI - New App Step 3-->
<td>css=.usa-alert.usa-alert-success > .usa-alert-body > .usa-alert-text</td>
<td>*You have successfully updated the permissions for Brandon Buchannan*</td>
</tr>
<tr>
<td>waitForPageToLoad</td>
<td></td>
<td></td>
</tr>
<tr>
<td>waitForElementPresent</td>
<td>css=table.atat-table > tbody > tr:nth-of-type(1) > td:nth-of-type(2) > div:nth-of-type(1)</td>
<td></td>
</tr>
<tr>
<td>assertText</td>
<td>css=table.atat-table > tbody > tr:nth-of-type(1) > td:nth-of-type(2) > div:nth-of-type(1)</td>
<td>*View Team*</td>
</tr>
<tr>
<td>waitForPageToLoad</td>
<td></td>
<td></td>
</tr>
<tr>
<td>waitForElementPresent</td>
<td>css=table.atat-table > tbody > tr:nth-of-type(1) > td:nth-of-type(2) > div:nth-of-type(2)</td>
<td></td>
</tr>
<tr>
<td>assertText</td>
<td>css=table.atat-table > tbody > tr:nth-of-type(1) > td:nth-of-type(2) > div:nth-of-type(2)</td>
<td>*View Environments*</td>
</tr>
</tbody>
</table>
</body>

View File

@ -78,15 +78,16 @@ Imported from: AT-AT CI - Login Brandon-->
<td></td>
</tr>
<!--Imported from: AT-AT CI - New Portfolio Member
Imported from: AT-AT CI - Login Brandon-->
Imported from: AT-AT CI - Login Brandon
11/19/19 17:10 Temporarily making optional until Leigh's PR#1197 merges-->
<tr>
<td>waitForElementPresent</td>
<td>css=.col > .usa-alert.usa-alert-info:nth-of-type(3) > .usa-alert-body > h3.usa-alert-heading</td>
<td>css=.col > .usa-alert.usa-alert-info:nth-of-type(2) > .usa-alert-body > h3.usa-alert-heading</td>
<td></td>
</tr>
<tr>
<td>assertText</td>
<td>css=.col > .usa-alert.usa-alert-info:nth-of-type(3) > .usa-alert-body > h3.usa-alert-heading</td>
<td>verifyText</td>
<td>css=.col > .usa-alert.usa-alert-info:nth-of-type(2) > .usa-alert-body > h3.usa-alert-heading</td>
<td>*Logged out*</td>
</tr>
<tr>
@ -874,6 +875,22 @@ Imported from: AT-AT CI - Portfolio Settings-->
<td></td>
<td></td>
</tr>
<!--Imported from: AT-AT CI - New Portfolio Member-->
<tr>
<td>waitForElementPresent</td>
<td>css=.usa-alert-body > p:nth-of-type(2)</td>
<td></td>
</tr>
<tr>
<td>assertText</td>
<td>css=.usa-alert-body > p:nth-of-type(2)</td>
<td>*You have successfully invited Brandon Buchannan to the portfolio.*</td>
</tr>
<tr>
<td>waitForPageToLoad</td>
<td></td>
<td></td>
</tr>
<tr>
<td>waitForElementPresent</td>
<td>css=select[name="members_permissions-1-perms_app_mgmt"]</td>
@ -959,6 +976,21 @@ Imported from: AT-AT CI - Portfolio Settings-->
<td>css=.usa-alert.usa-alert-success > .usa-alert-body > h3.usa-alert-heading</td>
<td>*Success!*</td>
</tr>
<tr>
<td>waitForPageToLoad</td>
<td></td>
<td></td>
</tr>
<tr>
<td>waitForElementPresent</td>
<td>css=.usa-alert-body > p:nth-of-type(2)</td>
<td></td>
</tr>
<tr>
<td>assertText</td>
<td>css=.usa-alert-body > p:nth-of-type(2)</td>
<td>*You have successfully updated access permissions for members of Tatooine Energy Maintenance Systems.*</td>
</tr>
</tbody>
</table>
</body>

View File

@ -71,14 +71,15 @@
<td></td>
<td></td>
</tr>
<!--11/19/19 17:10 Temporarily making optional until Leigh's PR#1197 merges-->
<tr>
<td>waitForElementPresent</td>
<td>css=.col > .usa-alert.usa-alert-info:nth-of-type(3) > .usa-alert-body > h3.usa-alert-heading</td>
<td>css=.col > .usa-alert.usa-alert-info:nth-of-type(2) > .usa-alert-body > h3.usa-alert-heading</td>
<td></td>
</tr>
<tr>
<td>assertText</td>
<td>css=.col > .usa-alert.usa-alert-info:nth-of-type(3) > .usa-alert-body > h3.usa-alert-heading</td>
<td>verifyText</td>
<td>css=.col > .usa-alert.usa-alert-info:nth-of-type(2) > .usa-alert-body > h3.usa-alert-heading</td>
<td>*Logged out*</td>
</tr>
</tbody>

View File

@ -74,15 +74,16 @@
<td></td>
<td></td>
</tr>
<!--Imported from: AT-AT CI - Login Brandon-->
<!--Imported from: AT-AT CI - Login Brandon
11/19/19 17:10 Temporarily making optional until Leigh's PR#1197 merges-->
<tr>
<td>waitForElementPresent</td>
<td>css=.col > .usa-alert.usa-alert-info:nth-of-type(3) > .usa-alert-body > h3.usa-alert-heading</td>
<td>css=.col > .usa-alert.usa-alert-info:nth-of-type(2) > .usa-alert-body > h3.usa-alert-heading</td>
<td></td>
</tr>
<tr>
<td>assertText</td>
<td>css=.col > .usa-alert.usa-alert-info:nth-of-type(3) > .usa-alert-body > h3.usa-alert-heading</td>
<td>verifyText</td>
<td>css=.col > .usa-alert.usa-alert-info:nth-of-type(2) > .usa-alert-body > h3.usa-alert-heading</td>
<td>*Logged out*</td>
</tr>
<tr>

View File

@ -74,15 +74,16 @@
<td></td>
<td></td>
</tr>
<!--Imported from: AT-AT CI - Login Brandon-->
<!--Imported from: AT-AT CI - Login Brandon
11/19/19 17:10 Temporarily making optional until Leigh's PR#1197 merges-->
<tr>
<td>waitForElementPresent</td>
<td>css=.col > .usa-alert.usa-alert-info:nth-of-type(3) > .usa-alert-body > h3.usa-alert-heading</td>
<td>css=.col > .usa-alert.usa-alert-info:nth-of-type(2) > .usa-alert-body > h3.usa-alert-heading</td>
<td></td>
</tr>
<tr>
<td>assertText</td>
<td>css=.col > .usa-alert.usa-alert-info:nth-of-type(3) > .usa-alert-body > h3.usa-alert-heading</td>
<td>verifyText</td>
<td>css=.col > .usa-alert.usa-alert-info:nth-of-type(2) > .usa-alert-body > h3.usa-alert-heading</td>
<td>*Logged out*</td>
</tr>
<tr>
@ -819,6 +820,21 @@ Imported from: AT-AT CI - New Portfolio-->
<td>css=table.atat-table > tbody > tr:nth-of-type(2) > td.name</td>
<td></td>
</tr>
<tr>
<td>waitForPageToLoad</td>
<td></td>
<td></td>
</tr>
<tr>
<td>waitForElementPresent</td>
<td>css=.usa-alert-body > p:nth-of-type(2)</td>
<td></td>
</tr>
<tr>
<td>assertText</td>
<td>css=.usa-alert-body > p:nth-of-type(2)</td>
<td>*You have successfully invited Brandon Buchannan to the portfolio.*</td>
</tr>
</tbody>
</table>
</body>

View File

@ -78,15 +78,16 @@ Imported from: AT-AT CI - Login Brandon-->
<td></td>
</tr>
<!--Imported from: AT-AT CI - New Portfolio Member
Imported from: AT-AT CI - Login Brandon-->
Imported from: AT-AT CI - Login Brandon
11/19/19 17:10 Temporarily making optional until Leigh's PR#1197 merges-->
<tr>
<td>waitForElementPresent</td>
<td>css=.col > .usa-alert.usa-alert-info:nth-of-type(3) > .usa-alert-body > h3.usa-alert-heading</td>
<td>css=.col > .usa-alert.usa-alert-info:nth-of-type(2) > .usa-alert-body > h3.usa-alert-heading</td>
<td></td>
</tr>
<tr>
<td>assertText</td>
<td>css=.col > .usa-alert.usa-alert-info:nth-of-type(3) > .usa-alert-body > h3.usa-alert-heading</td>
<td>verifyText</td>
<td>css=.col > .usa-alert.usa-alert-info:nth-of-type(2) > .usa-alert-body > h3.usa-alert-heading</td>
<td>*Logged out*</td>
</tr>
<tr>
@ -874,6 +875,22 @@ Imported from: AT-AT CI - Portfolio Settings-->
<td></td>
<td></td>
</tr>
<!--Imported from: AT-AT CI - New Portfolio Member-->
<tr>
<td>waitForElementPresent</td>
<td>css=.usa-alert-body > p:nth-of-type(2)</td>
<td></td>
</tr>
<tr>
<td>assertText</td>
<td>css=.usa-alert-body > p:nth-of-type(2)</td>
<td>*You have successfully invited Brandon Buchannan to the portfolio.*</td>
</tr>
<tr>
<td>waitForPageToLoad</td>
<td></td>
<td></td>
</tr>
<tr>
<td>waitForElementPresent</td>
<td>css=a.usa-button.button-danger-outline</td>

View File

@ -82,15 +82,16 @@ Imported from: AT-AT CI - Login Brandon-->
</tr>
<!--Imported from: AT-AT CI - Application Settings
Imported from: AT-AT CI - New App Step 3
Imported from: AT-AT CI - Login Brandon-->
Imported from: AT-AT CI - Login Brandon
11/19/19 17:10 Temporarily making optional until Leigh's PR#1197 merges-->
<tr>
<td>waitForElementPresent</td>
<td>css=.col > .usa-alert.usa-alert-info:nth-of-type(3) > .usa-alert-body > h3.usa-alert-heading</td>
<td>css=.col > .usa-alert.usa-alert-info:nth-of-type(2) > .usa-alert-body > h3.usa-alert-heading</td>
<td></td>
</tr>
<tr>
<td>assertText</td>
<td>css=.col > .usa-alert.usa-alert-info:nth-of-type(3) > .usa-alert-body > h3.usa-alert-heading</td>
<td>verifyText</td>
<td>css=.col > .usa-alert.usa-alert-info:nth-of-type(2) > .usa-alert-body > h3.usa-alert-heading</td>
<td>*Logged out*</td>
</tr>
<tr>

View File

@ -82,15 +82,16 @@ Imported from: AT-AT CI - Login Brandon-->
</tr>
<!--Imported from: AT-AT CI - Application Settings
Imported from: AT-AT CI - New App Step 3
Imported from: AT-AT CI - Login Brandon-->
Imported from: AT-AT CI - Login Brandon
11/19/19 17:10 Temporarily making optional until Leigh's PR#1197 merges-->
<tr>
<td>waitForElementPresent</td>
<td>css=.col > .usa-alert.usa-alert-info:nth-of-type(3) > .usa-alert-body > h3.usa-alert-heading</td>
<td>css=.col > .usa-alert.usa-alert-info:nth-of-type(2) > .usa-alert-body > h3.usa-alert-heading</td>
<td></td>
</tr>
<tr>
<td>assertText</td>
<td>css=.col > .usa-alert.usa-alert-info:nth-of-type(3) > .usa-alert-body > h3.usa-alert-heading</td>
<td>verifyText</td>
<td>css=.col > .usa-alert.usa-alert-info:nth-of-type(2) > .usa-alert-body > h3.usa-alert-heading</td>
<td>*Logged out*</td>
</tr>
<tr>