Merge branch 'master' of github-DDS:dod-ccpo/atst into delete-user-update
This commit is contained in:
commit
aecb4fc583
28
alembic/versions/49e12ae7c9ca_add_last_login_to_user.py
Normal file
28
alembic/versions/49e12ae7c9ca_add_last_login_to_user.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
"""add last login to user
|
||||||
|
|
||||||
|
Revision ID: 49e12ae7c9ca
|
||||||
|
Revises: fc08d99bb7f7
|
||||||
|
Create Date: 2019-03-28 15:46:58.226281
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '49e12ae7c9ca'
|
||||||
|
down_revision = 'fc08d99bb7f7'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('users', sa.Column('last_login', sa.TIMESTAMP(timezone=True), nullable=True))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column('users', 'last_login')
|
||||||
|
# ### end Alembic commands ###
|
25
atst/app.py
25
atst/app.py
@ -29,11 +29,16 @@ from atst.utils.form_cache import FormCache
|
|||||||
from atst.utils.json import CustomJSONEncoder
|
from atst.utils.json import CustomJSONEncoder
|
||||||
from atst.queue import queue
|
from atst.queue import queue
|
||||||
|
|
||||||
|
from logging.config import dictConfig
|
||||||
|
from atst.utils.logging import JsonFormatter, RequestContextFilter
|
||||||
|
|
||||||
|
|
||||||
ENV = os.getenv("FLASK_ENV", "dev")
|
ENV = os.getenv("FLASK_ENV", "dev")
|
||||||
|
|
||||||
|
|
||||||
def make_app(config):
|
def make_app(config):
|
||||||
|
if ENV == "prod" or config.get("LOG_JSON"):
|
||||||
|
apply_json_logger()
|
||||||
|
|
||||||
parent_dir = Path().parent
|
parent_dir = Path().parent
|
||||||
|
|
||||||
@ -143,6 +148,7 @@ def map_config(config):
|
|||||||
"RQ_QUEUES": [config["default"]["RQ_QUEUES"]],
|
"RQ_QUEUES": [config["default"]["RQ_QUEUES"]],
|
||||||
"DISABLE_CRL_CHECK": config.getboolean("default", "DISABLE_CRL_CHECK"),
|
"DISABLE_CRL_CHECK": config.getboolean("default", "DISABLE_CRL_CHECK"),
|
||||||
"CRL_FAIL_OPEN": config.getboolean("default", "CRL_FAIL_OPEN"),
|
"CRL_FAIL_OPEN": config.getboolean("default", "CRL_FAIL_OPEN"),
|
||||||
|
"LOG_JSON": config.getboolean("default", "LOG_JSON"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -228,3 +234,22 @@ def make_mailer(app):
|
|||||||
)
|
)
|
||||||
sender = app.config.get("MAIL_SENDER")
|
sender = app.config.get("MAIL_SENDER")
|
||||||
app.mailer = mailer.Mailer(mailer_connection, sender)
|
app.mailer = mailer.Mailer(mailer_connection, sender)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_json_logger():
|
||||||
|
dictConfig(
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"formatters": {"default": {"()": lambda *a, **k: JsonFormatter()}},
|
||||||
|
"filters": {"requests": {"()": lambda *a, **k: RequestContextFilter()}},
|
||||||
|
"handlers": {
|
||||||
|
"wsgi": {
|
||||||
|
"class": "logging.StreamHandler",
|
||||||
|
"stream": "ext://flask.logging.wsgi_errors_stream",
|
||||||
|
"formatter": "default",
|
||||||
|
"filters": ["requests"],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {"level": "INFO", "handlers": ["wsgi"]},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@ -22,6 +22,8 @@ def apply_authentication(app):
|
|||||||
user = get_current_user()
|
user = get_current_user()
|
||||||
if user:
|
if user:
|
||||||
g.current_user = user
|
g.current_user = user
|
||||||
|
g.last_login = get_last_login()
|
||||||
|
|
||||||
if should_redirect_to_user_profile(request, user):
|
if should_redirect_to_user_profile(request, user):
|
||||||
return redirect(url_for("users.user", next=request.path))
|
return redirect(url_for("users.user", next=request.path))
|
||||||
elif not _unprotected_route(request):
|
elif not _unprotected_route(request):
|
||||||
@ -50,9 +52,14 @@ def get_current_user():
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_last_login():
|
||||||
|
return session.get("user_id") and session.get("last_login")
|
||||||
|
|
||||||
|
|
||||||
def logout():
|
def logout():
|
||||||
if session.get("user_id"): # pragma: no branch
|
if session.get("user_id"): # pragma: no branch
|
||||||
del session["user_id"]
|
del session["user_id"]
|
||||||
|
del session["last_login"]
|
||||||
|
|
||||||
|
|
||||||
def _unprotected_route(request):
|
def _unprotected_route(request):
|
||||||
|
@ -2,6 +2,7 @@ import sys
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import logging
|
||||||
from flask import current_app as app
|
from flask import current_app as app
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from OpenSSL import crypto, SSL
|
from OpenSSL import crypto, SSL
|
||||||
@ -30,9 +31,9 @@ class CRLInterface:
|
|||||||
def __init__(self, *args, logger=None, **kwargs):
|
def __init__(self, *args, logger=None, **kwargs):
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
|
|
||||||
def _log_info(self, message):
|
def _log(self, message, level=logging.INFO):
|
||||||
if self.logger:
|
if self.logger:
|
||||||
self.logger.info(message)
|
self.logger.log(level, message, extras={"tags": ["authorization", "crl"]})
|
||||||
|
|
||||||
def crl_check(self, cert):
|
def crl_check(self, cert):
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
@ -50,7 +51,7 @@ class NoOpCRLCache(CRLInterface):
|
|||||||
|
|
||||||
def crl_check(self, cert):
|
def crl_check(self, cert):
|
||||||
cn = self._get_cn(cert)
|
cn = self._get_cn(cert)
|
||||||
self._log_info(
|
self._log(
|
||||||
"Did not perform CRL validation for certificate with Common Name '{}'".format(
|
"Did not perform CRL validation for certificate with Common Name '{}'".format(
|
||||||
cn
|
cn
|
||||||
)
|
)
|
||||||
@ -111,11 +112,14 @@ class CRLCache(CRLInterface):
|
|||||||
try:
|
try:
|
||||||
return crypto.load_crl(crypto.FILETYPE_ASN1, crl_file.read())
|
return crypto.load_crl(crypto.FILETYPE_ASN1, crl_file.read())
|
||||||
except crypto.Error:
|
except crypto.Error:
|
||||||
self._log_info("Could not load CRL at location {}".format(crl_location))
|
self._log(
|
||||||
|
"Could not load CRL at location {}".format(crl_location),
|
||||||
|
level=logging.WARNING,
|
||||||
|
)
|
||||||
|
|
||||||
def _build_store(self, issuer):
|
def _build_store(self, issuer):
|
||||||
store = self.store_class()
|
store = self.store_class()
|
||||||
self._log_info("STORE ID: {}. Building store.".format(id(store)))
|
self._log("STORE ID: {}. Building store.".format(id(store)))
|
||||||
store.set_flags(crypto.X509StoreFlags.CRL_CHECK)
|
store.set_flags(crypto.X509StoreFlags.CRL_CHECK)
|
||||||
crl_info = self.crl_cache.get(issuer.der(), {})
|
crl_info = self.crl_cache.get(issuer.der(), {})
|
||||||
issuer_name = get_common_name(issuer)
|
issuer_name = get_common_name(issuer)
|
||||||
@ -132,7 +136,7 @@ class CRLCache(CRLInterface):
|
|||||||
crl = self._load_crl(crl_info["location"])
|
crl = self._load_crl(crl_info["location"])
|
||||||
store.add_crl(crl)
|
store.add_crl(crl)
|
||||||
|
|
||||||
self._log_info(
|
self._log(
|
||||||
"STORE ID: {}. Adding CRL with issuer Common Name {}".format(
|
"STORE ID: {}. Adding CRL with issuer Common Name {}".format(
|
||||||
id(store), issuer_name
|
id(store), issuer_name
|
||||||
)
|
)
|
||||||
@ -158,7 +162,7 @@ class CRLCache(CRLInterface):
|
|||||||
def _add_certificate_chain_to_store(self, store, issuer):
|
def _add_certificate_chain_to_store(self, store, issuer):
|
||||||
ca = self.certificate_authorities.get(issuer.der())
|
ca = self.certificate_authorities.get(issuer.der())
|
||||||
store.add_cert(ca)
|
store.add_cert(ca)
|
||||||
self._log_info(
|
self._log(
|
||||||
"STORE ID: {}. Adding CA with subject {}".format(
|
"STORE ID: {}. Adding CA with subject {}".format(
|
||||||
id(store), ca.get_subject()
|
id(store), ca.get_subject()
|
||||||
)
|
)
|
||||||
@ -182,10 +186,11 @@ class CRLCache(CRLInterface):
|
|||||||
except crypto.X509StoreContextError as err:
|
except crypto.X509StoreContextError as err:
|
||||||
if err.args[0][0] == CRL_EXPIRED_ERROR_CODE:
|
if err.args[0][0] == CRL_EXPIRED_ERROR_CODE:
|
||||||
if app.config.get("CRL_FAIL_OPEN"):
|
if app.config.get("CRL_FAIL_OPEN"):
|
||||||
self._log_info(
|
self._log(
|
||||||
"Encountered expired CRL for certificate with CN {} and issuer CN {}, failing open.".format(
|
"Encountered expired CRL for certificate with CN {} and issuer CN {}, failing open.".format(
|
||||||
parsed.get_subject().CN, parsed.get_issuer().CN
|
parsed.get_subject().CN, parsed.get_issuer().CN
|
||||||
)
|
),
|
||||||
|
level=logging.WARNING,
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
from atst.utils import first_or_none
|
from atst.utils import first_or_none
|
||||||
from atst.models.permissions import Permissions
|
from atst.models.permissions import Permissions
|
||||||
from atst.domain.exceptions import UnauthorizedError
|
from atst.domain.exceptions import UnauthorizedError
|
||||||
|
from atst.models.portfolio_role import Status as PortfolioRoleStatus
|
||||||
|
|
||||||
|
|
||||||
class Authorization(object):
|
class Authorization(object):
|
||||||
@ -9,7 +10,7 @@ class Authorization(object):
|
|||||||
port_role = first_or_none(
|
port_role = first_or_none(
|
||||||
lambda pr: pr.portfolio == portfolio, user.portfolio_roles
|
lambda pr: pr.portfolio == portfolio, user.portfolio_roles
|
||||||
)
|
)
|
||||||
if port_role:
|
if port_role and port_role.status is not PortfolioRoleStatus.DISABLED:
|
||||||
return permission in port_role.permissions
|
return permission in port_role.permissions
|
||||||
else:
|
else:
|
||||||
return False
|
return False
|
||||||
|
@ -45,17 +45,19 @@ def user_can_access_decorator(permission, message=None, override=None):
|
|||||||
try:
|
try:
|
||||||
check_access(permission, message, override, *args, **kwargs)
|
check_access(permission, message, override, *args, **kwargs)
|
||||||
app.logger.info(
|
app.logger.info(
|
||||||
"[access] User {} accessed {} {}".format(
|
"User {} accessed {} {}".format(
|
||||||
g.current_user.id, request.method, request.path
|
g.current_user.id, request.method, request.path
|
||||||
)
|
),
|
||||||
|
extra={"tags": ["access", "success"]},
|
||||||
)
|
)
|
||||||
|
|
||||||
return f(*args, **kwargs)
|
return f(*args, **kwargs)
|
||||||
except UnauthorizedError as err:
|
except UnauthorizedError as err:
|
||||||
app.logger.warning(
|
app.logger.warning(
|
||||||
"[access] User {} denied access {} {}".format(
|
"User {} denied access {} {}".format(
|
||||||
g.current_user.id, request.method, request.path
|
g.current_user.id, request.method, request.path
|
||||||
)
|
),
|
||||||
|
extra={"tags": ["access", "failure"]},
|
||||||
)
|
)
|
||||||
|
|
||||||
raise (err)
|
raise (err)
|
||||||
|
@ -121,6 +121,15 @@ class PortfolioRoles(object):
|
|||||||
)
|
)
|
||||||
return PermissionSets.get_many(perms_set_names)
|
return PermissionSets.get_many(perms_set_names)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def disable(cls, portfolio_role):
|
||||||
|
portfolio_role.status = PortfolioRoleStatus.DISABLED
|
||||||
|
|
||||||
|
db.session.add(portfolio_role)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return portfolio_role
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def update(cls, portfolio_role, set_names):
|
def update(cls, portfolio_role, set_names):
|
||||||
new_permission_sets = PortfolioRoles._permission_sets_for_names(set_names)
|
new_permission_sets = PortfolioRoles._permission_sets_for_names(set_names)
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
from sqlalchemy.orm.exc import NoResultFound
|
from sqlalchemy.orm.exc import NoResultFound
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from atst.database import db
|
from atst.database import db
|
||||||
from atst.models import User
|
from atst.models import User
|
||||||
@ -82,6 +83,12 @@ class Users(object):
|
|||||||
|
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def update_last_login(cls, user):
|
||||||
|
user.last_login = datetime.now()
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def finalize(cls, user):
|
def finalize(cls, user):
|
||||||
user.provisional = False
|
user.provisional = False
|
||||||
|
@ -29,16 +29,14 @@ USER_FIELDS = {
|
|||||||
translate("forms.edit_user.service_branch_label"), choices=SERVICE_BRANCHES
|
translate("forms.edit_user.service_branch_label"), choices=SERVICE_BRANCHES
|
||||||
),
|
),
|
||||||
"citizenship": RadioField(
|
"citizenship": RadioField(
|
||||||
description=translate("forms.edit_user.citizenship_description"),
|
|
||||||
choices=[
|
choices=[
|
||||||
("United States", "United States"),
|
("United States", "United States"),
|
||||||
("Foreign National", "Foreign National"),
|
("Foreign National", "Foreign National"),
|
||||||
("Other", "Other"),
|
("Other", "Other"),
|
||||||
],
|
]
|
||||||
),
|
),
|
||||||
"designation": RadioField(
|
"designation": RadioField(
|
||||||
translate("forms.edit_user.designation_label"),
|
translate("forms.edit_user.designation_label"),
|
||||||
description=translate("forms.edit_user.designation_description"),
|
|
||||||
choices=[
|
choices=[
|
||||||
("military", "Military"),
|
("military", "Military"),
|
||||||
("civilian", "Civilian"),
|
("civilian", "Civilian"),
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
from wtforms.fields import StringField, FormField, FieldList
|
|
||||||
from wtforms.fields.html5 import EmailField, TelField
|
from wtforms.fields.html5 import EmailField, TelField
|
||||||
from wtforms.validators import Required, Email, Length, Optional
|
from wtforms.validators import Required, Email, Length, Optional
|
||||||
|
from wtforms.fields import StringField, FormField, FieldList, HiddenField
|
||||||
|
|
||||||
from atst.domain.permission_sets import PermissionSets
|
from atst.domain.permission_sets import PermissionSets
|
||||||
from .forms import BaseForm
|
from .forms import BaseForm
|
||||||
@ -11,32 +11,33 @@ from atst.utils.localization import translate
|
|||||||
|
|
||||||
class PermissionsForm(BaseForm):
|
class PermissionsForm(BaseForm):
|
||||||
member = StringField()
|
member = StringField()
|
||||||
|
user_id = HiddenField()
|
||||||
perms_app_mgmt = SelectField(
|
perms_app_mgmt = SelectField(
|
||||||
None,
|
None,
|
||||||
choices=[
|
choices=[
|
||||||
(PermissionSets.VIEW_PORTFOLIO_APPLICATION_MANAGEMENT, "View Only"),
|
(PermissionSets.VIEW_PORTFOLIO_APPLICATION_MANAGEMENT, "View only"),
|
||||||
(PermissionSets.EDIT_PORTFOLIO_APPLICATION_MANAGEMENT, "Edit Access"),
|
(PermissionSets.EDIT_PORTFOLIO_APPLICATION_MANAGEMENT, "Edit access"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
perms_funding = SelectField(
|
perms_funding = SelectField(
|
||||||
None,
|
None,
|
||||||
choices=[
|
choices=[
|
||||||
(PermissionSets.VIEW_PORTFOLIO_FUNDING, "View Only"),
|
(PermissionSets.VIEW_PORTFOLIO_FUNDING, "View only"),
|
||||||
(PermissionSets.EDIT_PORTFOLIO_FUNDING, "Edit Access"),
|
(PermissionSets.EDIT_PORTFOLIO_FUNDING, "Edit access"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
perms_reporting = SelectField(
|
perms_reporting = SelectField(
|
||||||
None,
|
None,
|
||||||
choices=[
|
choices=[
|
||||||
(PermissionSets.VIEW_PORTFOLIO_REPORTS, "View Only"),
|
(PermissionSets.VIEW_PORTFOLIO_REPORTS, "View only"),
|
||||||
(PermissionSets.EDIT_PORTFOLIO_REPORTS, "Edit Access"),
|
(PermissionSets.EDIT_PORTFOLIO_REPORTS, "Edit access"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
perms_portfolio_mgmt = SelectField(
|
perms_portfolio_mgmt = SelectField(
|
||||||
None,
|
None,
|
||||||
choices=[
|
choices=[
|
||||||
(PermissionSets.VIEW_PORTFOLIO_ADMIN, "View Only"),
|
(PermissionSets.VIEW_PORTFOLIO_ADMIN, "View only"),
|
||||||
(PermissionSets.EDIT_PORTFOLIO_ADMIN, "Edit Access"),
|
(PermissionSets.EDIT_PORTFOLIO_ADMIN, "Edit access"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
from sqlalchemy import String, ForeignKey, Column, Date, Boolean, Table
|
from sqlalchemy import String, ForeignKey, Column, Date, Boolean, Table, TIMESTAMP
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
|
||||||
@ -36,6 +36,7 @@ class User(
|
|||||||
citizenship = Column(String)
|
citizenship = Column(String)
|
||||||
designation = Column(String)
|
designation = Column(String)
|
||||||
date_latest_training = Column(Date)
|
date_latest_training = Column(Date)
|
||||||
|
last_login = Column(TIMESTAMP(timezone=True), nullable=True)
|
||||||
|
|
||||||
provisional = Column(Boolean)
|
provisional = Column(Boolean)
|
||||||
|
|
||||||
|
@ -122,6 +122,12 @@ def redirect_after_login_url():
|
|||||||
return url_for("atst.home")
|
return url_for("atst.home")
|
||||||
|
|
||||||
|
|
||||||
|
def current_user_setup(user):
|
||||||
|
session["user_id"] = user.id
|
||||||
|
session["last_login"] = user.last_login
|
||||||
|
Users.update_last_login(user)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/login-redirect")
|
@bp.route("/login-redirect")
|
||||||
def login_redirect():
|
def login_redirect():
|
||||||
auth_context = _make_authentication_context()
|
auth_context = _make_authentication_context()
|
||||||
@ -131,8 +137,7 @@ def login_redirect():
|
|||||||
if user.provisional:
|
if user.provisional:
|
||||||
Users.finalize(user)
|
Users.finalize(user)
|
||||||
|
|
||||||
session["user_id"] = user.id
|
current_user_setup(user)
|
||||||
|
|
||||||
return redirect(redirect_after_login_url())
|
return redirect(redirect_after_login_url())
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
from flask import (
|
from flask import (
|
||||||
Blueprint,
|
Blueprint,
|
||||||
request,
|
request,
|
||||||
session,
|
|
||||||
redirect,
|
redirect,
|
||||||
render_template,
|
render_template,
|
||||||
url_for,
|
url_for,
|
||||||
@ -9,7 +8,7 @@ from flask import (
|
|||||||
)
|
)
|
||||||
import pendulum
|
import pendulum
|
||||||
|
|
||||||
from . import redirect_after_login_url
|
from . import redirect_after_login_url, current_user_setup
|
||||||
from atst.domain.users import Users
|
from atst.domain.users import Users
|
||||||
from atst.domain.permission_sets import PermissionSets
|
from atst.domain.permission_sets import PermissionSets
|
||||||
from atst.queue import queue
|
from atst.queue import queue
|
||||||
@ -124,8 +123,7 @@ def login_dev():
|
|||||||
user_data,
|
user_data,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
session["user_id"] = user.id
|
current_user_setup(user)
|
||||||
|
|
||||||
return redirect(redirect_after_login_url())
|
return redirect(redirect_after_login_url())
|
||||||
|
|
||||||
|
|
||||||
|
@ -15,7 +15,7 @@ from atst.utils.flash import formatted_flash as flash
|
|||||||
|
|
||||||
def log_error(e):
|
def log_error(e):
|
||||||
error_message = e.message if hasattr(e, "message") else str(e)
|
error_message = e.message if hasattr(e, "message") else str(e)
|
||||||
current_app.logger.error(error_message)
|
current_app.logger.exception(error_message)
|
||||||
|
|
||||||
|
|
||||||
def handle_error(e, message="Not Found", code=404):
|
def handle_error(e, message="Not Found", code=404):
|
||||||
|
@ -5,6 +5,7 @@ from flask import render_template, request as http_request, g, redirect, url_for
|
|||||||
from . import portfolios_bp
|
from . import portfolios_bp
|
||||||
from atst.domain.reports import Reports
|
from atst.domain.reports import Reports
|
||||||
from atst.domain.portfolios import Portfolios
|
from atst.domain.portfolios import Portfolios
|
||||||
|
from atst.domain.portfolio_roles import PortfolioRoles
|
||||||
from atst.domain.audit_log import AuditLog
|
from atst.domain.audit_log import AuditLog
|
||||||
from atst.domain.common import Paginator
|
from atst.domain.common import Paginator
|
||||||
from atst.forms.portfolio import PortfolioForm
|
from atst.forms.portfolio import PortfolioForm
|
||||||
@ -12,6 +13,8 @@ import atst.forms.portfolio_member as member_forms
|
|||||||
from atst.models.permissions import Permissions
|
from atst.models.permissions import Permissions
|
||||||
from atst.domain.permission_sets import PermissionSets
|
from atst.domain.permission_sets import PermissionSets
|
||||||
from atst.domain.authz.decorator import user_can_access_decorator as user_can
|
from atst.domain.authz.decorator import user_can_access_decorator as user_can
|
||||||
|
from atst.utils.flash import formatted_flash as flash
|
||||||
|
from atst.domain.exceptions import UnauthorizedError
|
||||||
|
|
||||||
|
|
||||||
@portfolios_bp.route("/portfolios")
|
@portfolios_bp.route("/portfolios")
|
||||||
@ -34,6 +37,7 @@ def permission_str(member, edit_perm_set, view_perm_set):
|
|||||||
def serialize_member_form_data(member):
|
def serialize_member_form_data(member):
|
||||||
return {
|
return {
|
||||||
"member": member.user.full_name,
|
"member": member.user.full_name,
|
||||||
|
"user_id": member.user_id,
|
||||||
"perms_app_mgmt": permission_str(
|
"perms_app_mgmt": permission_str(
|
||||||
member,
|
member,
|
||||||
PermissionSets.EDIT_PORTFOLIO_APPLICATION_MANAGEMENT,
|
PermissionSets.EDIT_PORTFOLIO_APPLICATION_MANAGEMENT,
|
||||||
@ -86,6 +90,33 @@ def portfolio_admin(portfolio_id):
|
|||||||
return render_admin_page(portfolio)
|
return render_admin_page(portfolio)
|
||||||
|
|
||||||
|
|
||||||
|
@portfolios_bp.route("/portfolios/<portfolio_id>/admin", methods=["POST"])
|
||||||
|
@user_can(Permissions.EDIT_PORTFOLIO_USERS, message="view portfolio admin page")
|
||||||
|
def edit_portfolio_members(portfolio_id):
|
||||||
|
portfolio = Portfolios.get_for_update(portfolio_id)
|
||||||
|
member_perms_form = member_forms.MembersPermissionsForm(http_request.form)
|
||||||
|
|
||||||
|
if member_perms_form.validate():
|
||||||
|
for subform in member_perms_form.members_permissions:
|
||||||
|
new_perm_set = subform.data["permission_sets"]
|
||||||
|
user_id = subform.user_id.data
|
||||||
|
portfolio_role = PortfolioRoles.get(portfolio.id, user_id)
|
||||||
|
PortfolioRoles.update(portfolio_role, new_perm_set)
|
||||||
|
|
||||||
|
flash("update_portfolio_members", portfolio=portfolio)
|
||||||
|
|
||||||
|
return redirect(
|
||||||
|
url_for(
|
||||||
|
"portfolios.portfolio_admin",
|
||||||
|
portfolio_id=portfolio.id,
|
||||||
|
fragment="portfolio-members",
|
||||||
|
_anchor="portfolio-members",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return render_admin_page(portfolio)
|
||||||
|
|
||||||
|
|
||||||
@portfolios_bp.route("/portfolios/<portfolio_id>/edit", methods=["POST"])
|
@portfolios_bp.route("/portfolios/<portfolio_id>/edit", methods=["POST"])
|
||||||
@user_can(Permissions.EDIT_PORTFOLIO_NAME, message="edit portfolio")
|
@user_can(Permissions.EDIT_PORTFOLIO_NAME, message="edit portfolio")
|
||||||
def edit_portfolio(portfolio_id):
|
def edit_portfolio(portfolio_id):
|
||||||
@ -143,3 +174,28 @@ def portfolio_reports(portfolio_id):
|
|||||||
expiration_date=expiration_date,
|
expiration_date=expiration_date,
|
||||||
remaining_days=remaining_days,
|
remaining_days=remaining_days,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@portfolios_bp.route(
|
||||||
|
"/portfolios/<portfolio_id>/members/<user_id>/delete", methods=["POST"]
|
||||||
|
)
|
||||||
|
@user_can(Permissions.EDIT_PORTFOLIO_USERS, message="update portfolio members")
|
||||||
|
def remove_member(portfolio_id, user_id):
|
||||||
|
if str(g.current_user.id) == user_id:
|
||||||
|
raise UnauthorizedError(
|
||||||
|
g.current_user, "you cant remove yourself from the portfolio"
|
||||||
|
)
|
||||||
|
|
||||||
|
portfolio_role = PortfolioRoles.get(portfolio_id=portfolio_id, user_id=user_id)
|
||||||
|
PortfolioRoles.disable(portfolio_role=portfolio_role)
|
||||||
|
|
||||||
|
flash("portfolio_member_removed", member_name=portfolio_role.user.full_name)
|
||||||
|
|
||||||
|
return redirect(
|
||||||
|
url_for(
|
||||||
|
"portfolios.portfolio_admin",
|
||||||
|
portfolio_id=portfolio_id,
|
||||||
|
_anchor="portfolio-members",
|
||||||
|
fragment="portfolio-members",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
@ -231,6 +231,7 @@ def task_order_invitations(portfolio_id, task_order_id):
|
|||||||
portfolio=portfolio,
|
portfolio=portfolio,
|
||||||
task_order=task_order,
|
task_order=task_order,
|
||||||
form=form,
|
form=form,
|
||||||
|
user=g.current_user,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
raise NotFoundError("task_order")
|
raise NotFoundError("task_order")
|
||||||
|
@ -52,7 +52,7 @@ class Invitation:
|
|||||||
inviter,
|
inviter,
|
||||||
member,
|
member,
|
||||||
email,
|
email,
|
||||||
subject="{} has invited you to a JEDI Cloud Portfolio",
|
subject="{} has invited you to a JEDI cloud portfolio",
|
||||||
email_template="emails/invitation.txt",
|
email_template="emails/invitation.txt",
|
||||||
):
|
):
|
||||||
self.inviter = inviter
|
self.inviter = inviter
|
||||||
|
@ -21,6 +21,13 @@ MESSAGES = {
|
|||||||
""",
|
""",
|
||||||
"category": "success",
|
"category": "success",
|
||||||
},
|
},
|
||||||
|
"update_portfolio_members": {
|
||||||
|
"title_template": "Success!",
|
||||||
|
"message_template": """
|
||||||
|
<p>You have successfully updated access permissions for members of {{ portfolio.name }}.</p>
|
||||||
|
""",
|
||||||
|
"category": "success",
|
||||||
|
},
|
||||||
"new_portfolio_member": {
|
"new_portfolio_member": {
|
||||||
"title_template": "Success!",
|
"title_template": "Success!",
|
||||||
"message_template": """
|
"message_template": """
|
||||||
@ -131,6 +138,11 @@ MESSAGES = {
|
|||||||
""",
|
""",
|
||||||
"category": "error",
|
"category": "error",
|
||||||
},
|
},
|
||||||
|
"portfolio_member_removed": {
|
||||||
|
"title_template": "Portfolio Member Removed",
|
||||||
|
"message_template": "You have successfully removed {{ member_name }} from the portfolio.",
|
||||||
|
"category": "success",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
47
atst/utils/logging.py
Normal file
47
atst/utils/logging.py
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from flask import g, request, has_request_context
|
||||||
|
|
||||||
|
|
||||||
|
class RequestContextFilter(logging.Filter):
|
||||||
|
def filter(self, record):
|
||||||
|
if has_request_context():
|
||||||
|
if getattr(g, "current_user", None):
|
||||||
|
record.user_id = str(g.current_user.id)
|
||||||
|
|
||||||
|
if request.environ.get("HTTP_X_REQUEST_ID"):
|
||||||
|
record.request_id = request.environ.get("HTTP_X_REQUEST_ID")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def epoch_to_iso8601(ts):
|
||||||
|
dt = datetime.datetime.utcfromtimestamp(ts)
|
||||||
|
return dt.replace(tzinfo=datetime.timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
class JsonFormatter(logging.Formatter):
|
||||||
|
_DEFAULT_RECORD_FIELDS = [
|
||||||
|
("timestamp", lambda r: epoch_to_iso8601(r.created)),
|
||||||
|
("version", lambda r: 1),
|
||||||
|
("request_id", lambda r: r.__dict__.get("request_id")),
|
||||||
|
("user_id", lambda r: r.__dict__.get("user_id")),
|
||||||
|
("severity", lambda r: r.levelname),
|
||||||
|
("tags", lambda r: r.__dict__.get("tags")),
|
||||||
|
("message", lambda r: r.msg),
|
||||||
|
]
|
||||||
|
|
||||||
|
def format(self, record):
|
||||||
|
message_dict = {}
|
||||||
|
for field, func in self._DEFAULT_RECORD_FIELDS:
|
||||||
|
message_dict[field] = func(record)
|
||||||
|
|
||||||
|
if record.__dict__.get("exc_info") is not None:
|
||||||
|
message_dict["details"] = {
|
||||||
|
"backtrace": self.formatException(record.exc_info),
|
||||||
|
"exception": str(record.exc_info[1]),
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.dumps(message_dict)
|
@ -10,6 +10,7 @@ DISABLE_CRL_CHECK = false
|
|||||||
CRL_FAIL_OPEN = false
|
CRL_FAIL_OPEN = false
|
||||||
DEBUG = true
|
DEBUG = true
|
||||||
ENVIRONMENT = dev
|
ENVIRONMENT = dev
|
||||||
|
LOG_JSON = false
|
||||||
PERMANENT_SESSION_LIFETIME = 600
|
PERMANENT_SESSION_LIFETIME = 600
|
||||||
PE_NUMBER_CSV_URL = http://c95e1ebb198426ee57b8-174bb05a294821bedbf46b6384fe9b1f.r31.cf5.rackcdn.com/penumbers.csv
|
PE_NUMBER_CSV_URL = http://c95e1ebb198426ee57b8-174bb05a294821bedbf46b6384fe9b1f.r31.cf5.rackcdn.com/penumbers.csv
|
||||||
PGAPPNAME = atst
|
PGAPPNAME = atst
|
||||||
|
@ -10,7 +10,7 @@ APP_USER="atst"
|
|||||||
APP_UID="8010"
|
APP_UID="8010"
|
||||||
|
|
||||||
# Add additional packages required by app dependencies
|
# Add additional packages required by app dependencies
|
||||||
ADDITIONAL_PACKAGES="postgresql-libs python3 rsync uwsgi uwsgi-python3"
|
ADDITIONAL_PACKAGES="postgresql-libs python3 rsync uwsgi uwsgi-python3 uwsgi-logfile"
|
||||||
|
|
||||||
# add sync-crl cronjob for atst user
|
# add sync-crl cronjob for atst user
|
||||||
echo "1 */6 * * * /opt/atat/atst/script/sync-crls tests/crl-tmp" >> /etc/crontabs/atst
|
echo "1 */6 * * * /opt/atat/atst/script/sync-crls tests/crl-tmp" >> /etc/crontabs/atst
|
||||||
|
@ -11,6 +11,8 @@
|
|||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: $footer-height;
|
height: $footer-height;
|
||||||
|
color: $color-gray-dark;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
|
||||||
.app-footer__info {
|
.app-footer__info {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
@ -291,6 +291,10 @@
|
|||||||
height: 4rem;
|
height: 4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.usa-button-danger {
|
||||||
|
background: $color-red;
|
||||||
|
}
|
||||||
|
|
||||||
.members-table-footer {
|
.members-table-footer {
|
||||||
float: right;
|
float: right;
|
||||||
padding: 3 * $gap;
|
padding: 3 * $gap;
|
||||||
|
@ -7,4 +7,9 @@
|
|||||||
<span>{{ "footer.jedi_help_link_text" | translate }}</span>
|
<span>{{ "footer.jedi_help_link_text" | translate }}</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
{% if g.last_login %}
|
||||||
|
<div class="">
|
||||||
|
Last Login: <local-datetime timestamp='{{ g.last_login }}'></local-datetime>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</footer>
|
</footer>
|
||||||
|
@ -1,4 +1,8 @@
|
|||||||
|
{% from "components/confirmation_button.html" import ConfirmationButton %}
|
||||||
|
|
||||||
{% for subform in member_perms_form.members_permissions %}
|
{% for subform in member_perms_form.members_permissions %}
|
||||||
|
{% set modal_id = "portfolio_id_{}_user_id_{}".format(portfolio.id, subform.user_id.data) %}
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td class='name'>{{ subform.member.data }}
|
<td class='name'>{{ subform.member.data }}
|
||||||
{% if subform.member.data == user.full_name %}
|
{% if subform.member.data == user.full_name %}
|
||||||
@ -14,7 +18,11 @@
|
|||||||
<td>{{ OptionsInput(subform.perms_reporting, label=False) }}</td>
|
<td>{{ OptionsInput(subform.perms_reporting, label=False) }}</td>
|
||||||
<td>{{ OptionsInput(subform.perms_portfolio_mgmt, label=False) }}</td>
|
<td>{{ OptionsInput(subform.perms_portfolio_mgmt, label=False) }}</td>
|
||||||
|
|
||||||
<td><button type="button" class='{{ archive_button_class }}'>{{ "portfolios.members.archive_button" | translate }}</button>
|
<td>
|
||||||
|
<a v-on:click="openModal('{{ modal_id }}')" class='usa-button {{ archive_button_class }}'>
|
||||||
|
{{ "portfolios.members.archive_button" | translate }}
|
||||||
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
{{ subform.user_id() }}
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
@ -1,23 +1,28 @@
|
|||||||
{% from "components/icon.html" import Icon %}
|
{% from "components/icon.html" import Icon %}
|
||||||
{% from "components/options_input.html" import OptionsInput %}
|
{% from "components/options_input.html" import OptionsInput %}
|
||||||
|
|
||||||
|
{% from "components/modal.html" import Modal %}
|
||||||
|
{% from "components/alert.html" import Alert %}
|
||||||
|
|
||||||
<section class="member-list" id="portfolio-members">
|
<section class="member-list" id="portfolio-members">
|
||||||
<div class='responsive-table-wrapper panel'>
|
<div class='responsive-table-wrapper panel'>
|
||||||
{% if g.matchesPath("portfolio-members") %}
|
{% if g.matchesPath("portfolio-members") %}
|
||||||
{% include "fragments/flash.html" %}
|
{% include "fragments/flash.html" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<form method='POST' id="member-perms" autocomplete="off" enctype="multipart/form-data">
|
<form method='POST' id="member-perms" action='{{ url_for("portfolios.edit_portfolio_members", portfolio_id=portfolio.id) }}' autocomplete="off" enctype="multipart/form-data">
|
||||||
<div class='member-list-header'>
|
{{ member_perms_form.csrf_token }}
|
||||||
<div class='left'>
|
|
||||||
<div class='h3'>{{ "portfolios.admin.portfolio_members_title" | translate }}</div>
|
<div class='member-list-header'>
|
||||||
<div class='subheading'>
|
<div class='left'>
|
||||||
{{ "portfolios.admin.portfolio_members_subheading" | translate }}
|
<div class='h3'>{{ "portfolios.admin.portfolio_members_title" | translate }}</div>
|
||||||
</div>
|
<div class='subheading'>
|
||||||
|
{{ "portfolios.admin.portfolio_members_subheading" | translate }}
|
||||||
</div>
|
</div>
|
||||||
<a class='icon-link'>
|
</div>
|
||||||
{{ Icon('info') }}
|
<a class='icon-link'>
|
||||||
{{ "portfolios.admin.settings_info" | translate }}
|
{{ Icon('info') }}
|
||||||
</a>
|
{{ "portfolios.admin.settings_info" | translate }}
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if not portfolio.members %}
|
{% if not portfolio.members %}
|
||||||
@ -49,6 +54,34 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{% if user_can(permissions.EDIT_PORTFOLIO_USERS) %}
|
||||||
|
{% for member in portfolio.members %}
|
||||||
|
{% set modal_id = "portfolio_id_{}_user_id_{}".format(portfolio.id, member.user_id) %}
|
||||||
|
{% call Modal(name=modal_id, dismissable=False) %}
|
||||||
|
<h1>Are you sure you want to archive this user?</h1>
|
||||||
|
|
||||||
|
{{
|
||||||
|
Alert(
|
||||||
|
title="Warning! You are about to archive a user from the portfolio admin.",
|
||||||
|
message="User will be removed from the portfolio, but their log history will be retained.",
|
||||||
|
level="warning"
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
|
||||||
|
<div class="action-group">
|
||||||
|
<form method="POST" action="{{ url_for('portfolios.remove_member', portfolio_id=portfolio.id, user_id=member.user_id) }}">
|
||||||
|
{{ member_perms_form.csrf_token }}
|
||||||
|
<button class="usa-button usa-button-danger">
|
||||||
|
{{ "portfolios.members.archive_button" | translate }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<a v-on:click="closeModal('{{ modal_id }}')" class="action-group__action icon-link icon-link--default">Cancel</a>
|
||||||
|
</div>
|
||||||
|
{% endcall %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="members-table-footer">
|
<div class="members-table-footer">
|
||||||
<div class="action-group">
|
<div class="action-group">
|
||||||
{% if user_can(permissions.EDIT_PORTFOLIO_USERS) %}
|
{% if user_can(permissions.EDIT_PORTFOLIO_USERS) %}
|
||||||
|
@ -31,7 +31,7 @@
|
|||||||
</ul>
|
</ul>
|
||||||
<div class="sidenav__divider--small"></div>
|
<div class="sidenav__divider--small"></div>
|
||||||
<a class="sidenav__link sidenav__link--add" href="{{ url_for("task_orders.get_started") }}" title="Fund a New Portfolio">
|
<a class="sidenav__link sidenav__link--add" href="{{ url_for("task_orders.get_started") }}" title="Fund a New Portfolio">
|
||||||
<span class="sidenav__link-label">Fund a New Portfolio</span>
|
<span class="sidenav__link-label">Fund a new portfolio</span>
|
||||||
{{ Icon("plus", classes="sidenav__link-icon icon--circle") }}
|
{{ Icon("plus", classes="sidenav__link-icon icon--circle") }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
@ -93,7 +93,7 @@
|
|||||||
<div class="portfolio-funding">
|
<div class="portfolio-funding">
|
||||||
|
|
||||||
<div class='portfolio-funding__header row'>
|
<div class='portfolio-funding__header row'>
|
||||||
<a href="{{ url_for("task_orders.new", screen=1, portfolio_id=portfolio.id) }}" class="usa-button">Start a New Task Order</a>
|
<a href="{{ url_for("task_orders.new", screen=1, portfolio_id=portfolio.id) }}" class="usa-button">Start a new task order</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% for task_order in pending_task_orders %}
|
{% for task_order in pending_task_orders %}
|
||||||
|
@ -99,21 +99,23 @@
|
|||||||
{{ Link("Update", "edit", onClick="edit") }}
|
{{ Link("Update", "edit", onClick="edit") }}
|
||||||
{% set invite_type = [prefix + "_invite"] %}
|
{% set invite_type = [prefix + "_invite"] %}
|
||||||
|
|
||||||
{{
|
{% if not (user == task_order.creator and user == task_order[officer_type]) %}
|
||||||
ConfirmationButton(
|
{{
|
||||||
btn_text="Resend Invitation",
|
ConfirmationButton(
|
||||||
confirm_btn=('task_orders.invitations.resend_btn' | translate),
|
btn_text="Resend Invitation",
|
||||||
confirm_msg=('task_orders.invitations.resend_confirmation_message' | translate),
|
confirm_btn=('task_orders.invitations.resend_btn' | translate),
|
||||||
action=url_for(
|
confirm_msg=('task_orders.invitations.resend_confirmation_message' | translate),
|
||||||
"portfolios.resend_invite",
|
action=url_for(
|
||||||
portfolio_id=portfolio.id,
|
"portfolios.resend_invite",
|
||||||
task_order_id=task_order.id,
|
portfolio_id=portfolio.id,
|
||||||
invite_type=invite_type,
|
task_order_id=task_order.id,
|
||||||
),
|
invite_type=invite_type,
|
||||||
btn_icon=Icon('avatar'),
|
),
|
||||||
btn_class="icon-link",
|
btn_icon=Icon('avatar'),
|
||||||
)
|
btn_class="icon-link",
|
||||||
}}
|
)
|
||||||
|
}}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{{ Link("Remove", "trash", classes="remove") }}
|
{{ Link("Remove", "trash", classes="remove") }}
|
||||||
</div>
|
</div>
|
||||||
|
@ -12,9 +12,9 @@
|
|||||||
|
|
||||||
{% macro officer_name(officer) -%}
|
{% macro officer_name(officer) -%}
|
||||||
{%- if not officer -%}
|
{%- if not officer -%}
|
||||||
Not specified
|
not yet invited
|
||||||
{%- elif officer == g.current_user -%}
|
{%- elif officer == g.current_user -%}
|
||||||
You
|
you
|
||||||
{%- else -%}
|
{%- else -%}
|
||||||
{{ officer.full_name }}
|
{{ officer.full_name }}
|
||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
@ -27,7 +27,7 @@
|
|||||||
{% if complete %}
|
{% if complete %}
|
||||||
<span class="label label--success">Completed</span>
|
<span class="label label--success">Completed</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="label">Not Started</span>
|
<span class="label">Not started</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="task-order-next-steps__text col col--grow">
|
<div class="task-order-next-steps__text col col--grow">
|
||||||
@ -91,7 +91,7 @@
|
|||||||
<div>{{ officer_info.phone_number | usPhone }}</div>
|
<div>{{ officer_info.phone_number | usPhone }}</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span>Not specified</span>
|
<span>not yet invited</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -118,7 +118,7 @@
|
|||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="task-order-heading__value col">
|
<div class="task-order-heading__value col">
|
||||||
<dt>Task Order Value</dt>
|
<dt>Task order value</dt>
|
||||||
<dd>{{ task_order.budget | dollars }}</dd>
|
<dd>{{ task_order.budget | dollars }}</dd>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -23,7 +23,6 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{{ TextInput(form.scope, paragraph=True) }}
|
{{ TextInput(form.scope, paragraph=True) }}
|
||||||
<p><i>{{ "task_orders.new.app_info.sample_scope" | translate | safe }}</i></p>
|
|
||||||
|
|
||||||
<div class="subheading--black">
|
<div class="subheading--black">
|
||||||
{% if portfolio %}
|
{% if portfolio %}
|
||||||
|
@ -9,8 +9,7 @@
|
|||||||
<div class='panel__heading'>
|
<div class='panel__heading'>
|
||||||
<h1>
|
<h1>
|
||||||
<div class='h2'>{{ user.first_name }} {{ user.last_name }}</div>
|
<div class='h2'>{{ user.first_name }} {{ user.last_name }}</div>
|
||||||
<div class='h3'>DOD ID: {{ user.dod_id }}</div>
|
<div class='h3'>DoD ID: {{ user.dod_id }}</div>
|
||||||
<div class='subtitle h3'>Edit user details</div>
|
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -11,6 +11,7 @@ from atst.domain.authz.decorator import user_can_access_decorator
|
|||||||
from atst.domain.permission_sets import PermissionSets
|
from atst.domain.permission_sets import PermissionSets
|
||||||
from atst.domain.exceptions import UnauthorizedError
|
from atst.domain.exceptions import UnauthorizedError
|
||||||
from atst.models.permissions import Permissions
|
from atst.models.permissions import Permissions
|
||||||
|
from atst.domain.portfolio_roles import PortfolioRoles
|
||||||
|
|
||||||
from tests.utils import FakeLogger
|
from tests.utils import FakeLogger
|
||||||
|
|
||||||
@ -75,7 +76,7 @@ def test_user_can_access():
|
|||||||
|
|
||||||
portfolio = PortfolioFactory.create(owner=edit_admin)
|
portfolio = PortfolioFactory.create(owner=edit_admin)
|
||||||
# factory gives view perms by default
|
# factory gives view perms by default
|
||||||
PortfolioRoleFactory.create(user=view_admin, portfolio=portfolio)
|
view_admin_pr = PortfolioRoleFactory.create(user=view_admin, portfolio=portfolio)
|
||||||
|
|
||||||
# check a site-wide permission
|
# check a site-wide permission
|
||||||
assert user_can_access(ccpo, Permissions.VIEW_AUDIT_LOG)
|
assert user_can_access(ccpo, Permissions.VIEW_AUDIT_LOG)
|
||||||
@ -101,6 +102,13 @@ def test_user_can_access():
|
|||||||
view_admin, Permissions.EDIT_PORTFOLIO_NAME, portfolio=portfolio
|
view_admin, Permissions.EDIT_PORTFOLIO_NAME, portfolio=portfolio
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# check when portfolio_role is disabled
|
||||||
|
PortfolioRoles.disable(portfolio_role=view_admin_pr)
|
||||||
|
with pytest.raises(UnauthorizedError):
|
||||||
|
user_can_access(
|
||||||
|
view_admin, Permissions.EDIT_PORTFOLIO_NAME, portfolio=portfolio
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def set_current_user(request_ctx):
|
def set_current_user(request_ctx):
|
||||||
|
@ -29,3 +29,11 @@ def test_add_portfolio_role_with_permission_sets():
|
|||||||
]
|
]
|
||||||
actual_names = [prms.name for prms in port_role.permission_sets]
|
actual_names = [prms.name for prms in port_role.permission_sets]
|
||||||
assert expected_names == expected_names
|
assert expected_names == expected_names
|
||||||
|
|
||||||
|
|
||||||
|
def test_disable_portfolio_role():
|
||||||
|
portfolio_role = PortfolioRoleFactory.create(status=PortfolioRoleStatus.ACTIVE)
|
||||||
|
assert portfolio_role.status == PortfolioRoleStatus.ACTIVE
|
||||||
|
|
||||||
|
PortfolioRoles.disable(portfolio_role=portfolio_role)
|
||||||
|
assert portfolio_role.status == PortfolioRoleStatus.DISABLED
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
from datetime import datetime
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from atst.domain.users import Users
|
from atst.domain.users import Users
|
||||||
@ -65,3 +66,11 @@ def test_update_user_with_dod_id():
|
|||||||
Users.update(new_user, {"dod_id": "1234567890"})
|
Users.update(new_user, {"dod_id": "1234567890"})
|
||||||
|
|
||||||
assert "dod_id" in str(excinfo.value)
|
assert "dod_id" in str(excinfo.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_user_with_last_login():
|
||||||
|
new_user = UserFactory.create()
|
||||||
|
Users.update_last_login(new_user)
|
||||||
|
last_login = new_user.last_login
|
||||||
|
Users.update_last_login(new_user)
|
||||||
|
assert new_user.last_login > last_login
|
||||||
|
@ -26,3 +26,107 @@ def test_member_table_access(client, user_session):
|
|||||||
user_session(rando)
|
user_session(rando)
|
||||||
view_resp = client.get(url)
|
view_resp = client.get(url)
|
||||||
assert "<select" not in view_resp.data.decode()
|
assert "<select" not in view_resp.data.decode()
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_member_permissions(client, user_session):
|
||||||
|
portfolio = PortfolioFactory.create()
|
||||||
|
rando = UserFactory.create()
|
||||||
|
rando_pf_role = PortfolioRoleFactory.create(
|
||||||
|
user=rando,
|
||||||
|
portfolio=portfolio,
|
||||||
|
permission_sets=[PermissionSets.get(PermissionSets.VIEW_PORTFOLIO_ADMIN)],
|
||||||
|
)
|
||||||
|
|
||||||
|
user = UserFactory.create()
|
||||||
|
PortfolioRoleFactory.create(
|
||||||
|
user=user,
|
||||||
|
portfolio=portfolio,
|
||||||
|
permission_sets=PermissionSets.get_many(
|
||||||
|
[PermissionSets.EDIT_PORTFOLIO_ADMIN, PermissionSets.VIEW_PORTFOLIO_ADMIN]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
user_session(user)
|
||||||
|
|
||||||
|
form_data = {
|
||||||
|
"members_permissions-0-user_id": rando.id,
|
||||||
|
"members_permissions-0-perms_app_mgmt": "edit_portfolio_application_management",
|
||||||
|
"members_permissions-0-perms_funding": "view_portfolio_funding",
|
||||||
|
"members_permissions-0-perms_reporting": "view_portfolio_reports",
|
||||||
|
"members_permissions-0-perms_portfolio_mgmt": "view_portfolio_admin",
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
url_for("portfolios.edit_portfolio_members", portfolio_id=portfolio.id),
|
||||||
|
data=form_data,
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert rando_pf_role.has_permission_set(
|
||||||
|
PermissionSets.EDIT_PORTFOLIO_APPLICATION_MANAGEMENT
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_update_member_permissions_without_edit_access(client, user_session):
|
||||||
|
portfolio = PortfolioFactory.create()
|
||||||
|
rando = UserFactory.create()
|
||||||
|
rando_pf_role = PortfolioRoleFactory.create(
|
||||||
|
user=rando,
|
||||||
|
portfolio=portfolio,
|
||||||
|
permission_sets=[PermissionSets.get(PermissionSets.VIEW_PORTFOLIO_ADMIN)],
|
||||||
|
)
|
||||||
|
|
||||||
|
user = UserFactory.create()
|
||||||
|
PortfolioRoleFactory.create(
|
||||||
|
user=user,
|
||||||
|
portfolio=portfolio,
|
||||||
|
permission_sets=[PermissionSets.get(PermissionSets.VIEW_PORTFOLIO_ADMIN)],
|
||||||
|
)
|
||||||
|
user_session(user)
|
||||||
|
|
||||||
|
form_data = {
|
||||||
|
"members_permissions-0-user_id": rando.id,
|
||||||
|
"members_permissions-0-perms_app_mgmt": "edit_portfolio_application_management",
|
||||||
|
"members_permissions-0-perms_funding": "view_portfolio_funding",
|
||||||
|
"members_permissions-0-perms_reporting": "view_portfolio_reports",
|
||||||
|
"members_permissions-0-perms_portfolio_mgmt": "view_portfolio_admin",
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
url_for("portfolios.edit_portfolio_members", portfolio_id=portfolio.id),
|
||||||
|
data=form_data,
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
assert not rando_pf_role.has_permission_set(
|
||||||
|
PermissionSets.EDIT_PORTFOLIO_APPLICATION_MANAGEMENT
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rerender_admin_page_if_member_perms_form_does_not_validate(
|
||||||
|
client, user_session
|
||||||
|
):
|
||||||
|
portfolio = PortfolioFactory.create()
|
||||||
|
user = UserFactory.create()
|
||||||
|
PortfolioRoleFactory.create(
|
||||||
|
user=user,
|
||||||
|
portfolio=portfolio,
|
||||||
|
permission_sets=[PermissionSets.get(PermissionSets.EDIT_PORTFOLIO_ADMIN)],
|
||||||
|
)
|
||||||
|
user_session(user)
|
||||||
|
form_data = {
|
||||||
|
"members_permissions-0-user_id": user.id,
|
||||||
|
"members_permissions-0-perms_app_mgmt": "bad input",
|
||||||
|
"members_permissions-0-perms_funding": "view_portfolio_funding",
|
||||||
|
"members_permissions-0-perms_reporting": "view_portfolio_reports",
|
||||||
|
"members_permissions-0-perms_portfolio_mgmt": "view_portfolio_admin",
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
url_for("portfolios.edit_portfolio_members", portfolio_id=portfolio.id),
|
||||||
|
data=form_data,
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Portfolio Administration" in response.data.decode()
|
||||||
|
@ -2,6 +2,8 @@ from flask import url_for
|
|||||||
|
|
||||||
from atst.domain.permission_sets import PermissionSets
|
from atst.domain.permission_sets import PermissionSets
|
||||||
from atst.models.permissions import Permissions
|
from atst.models.permissions import Permissions
|
||||||
|
from atst.domain.portfolio_roles import PortfolioRoles
|
||||||
|
from atst.models.portfolio_role import Status as PortfolioRoleStatus
|
||||||
|
|
||||||
from tests.factories import (
|
from tests.factories import (
|
||||||
random_future_date,
|
random_future_date,
|
||||||
@ -81,6 +83,54 @@ def test_portfolio_admin_screen_when_not_ppoc(client, user_session):
|
|||||||
assert translate("fragments.ppoc.update_btn").encode("utf8") not in response.data
|
assert translate("fragments.ppoc.update_btn").encode("utf8") not in response.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_portfolio_member(client, user_session):
|
||||||
|
portfolio = PortfolioFactory.create()
|
||||||
|
|
||||||
|
user = UserFactory.create()
|
||||||
|
PortfolioRoleFactory.create(portfolio=portfolio, user=user)
|
||||||
|
|
||||||
|
user_session(portfolio.owner)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
url_for("portfolios.remove_member", portfolio_id=portfolio.id, user_id=user.id),
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 302
|
||||||
|
assert response.headers["Location"] == url_for(
|
||||||
|
"portfolios.portfolio_admin",
|
||||||
|
portfolio_id=portfolio.id,
|
||||||
|
_anchor="portfolio-members",
|
||||||
|
fragment="portfolio-members",
|
||||||
|
_external=True,
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
PortfolioRoles.get(portfolio_id=portfolio.id, user_id=user.id).status
|
||||||
|
== PortfolioRoleStatus.DISABLED
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_portfolio_member_self(client, user_session):
|
||||||
|
portfolio = PortfolioFactory.create()
|
||||||
|
|
||||||
|
user_session(portfolio.owner)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
url_for(
|
||||||
|
"portfolios.remove_member",
|
||||||
|
portfolio_id=portfolio.id,
|
||||||
|
user_id=portfolio.owner.id,
|
||||||
|
),
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
assert (
|
||||||
|
PortfolioRoles.get(portfolio_id=portfolio.id, user_id=portfolio.owner.id).status
|
||||||
|
== PortfolioRoleStatus.ACTIVE
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_portfolio_reports(client, user_session):
|
def test_portfolio_reports(client, user_session):
|
||||||
portfolio = PortfolioFactory.create(
|
portfolio = PortfolioFactory.create(
|
||||||
applications=[
|
applications=[
|
||||||
|
@ -289,6 +289,57 @@ class TestTaskOrderInvitations:
|
|||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
assert time_updated == other_task_order.time_updated
|
assert time_updated == other_task_order.time_updated
|
||||||
|
|
||||||
|
def test_does_not_render_resend_invite_if_user_is_mo_and_user_is_cor(
|
||||||
|
self, client, user_session
|
||||||
|
):
|
||||||
|
task_order = TaskOrderFactory.create(
|
||||||
|
portfolio=self.portfolio,
|
||||||
|
creator=self.portfolio.owner,
|
||||||
|
cor_first_name=self.portfolio.owner.first_name,
|
||||||
|
cor_last_name=self.portfolio.owner.last_name,
|
||||||
|
cor_email=self.portfolio.owner.email,
|
||||||
|
cor_phone_number=self.portfolio.owner.phone_number,
|
||||||
|
cor_dod_id=self.portfolio.owner.dod_id,
|
||||||
|
cor_invite=True,
|
||||||
|
)
|
||||||
|
user_session(self.portfolio.owner)
|
||||||
|
response = client.get(
|
||||||
|
url_for(
|
||||||
|
"portfolios.task_order_invitations",
|
||||||
|
portfolio_id=self.portfolio.id,
|
||||||
|
task_order_id=task_order.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert "Resend Invitation" not in response.data.decode()
|
||||||
|
|
||||||
|
def test_renders_resend_invite_if_user_is_mo_and_user_is_not_cor(
|
||||||
|
self, client, user_session
|
||||||
|
):
|
||||||
|
cor = UserFactory.create()
|
||||||
|
task_order = TaskOrderFactory.create(
|
||||||
|
portfolio=self.portfolio,
|
||||||
|
creator=self.portfolio.owner,
|
||||||
|
contracting_officer_representative=cor,
|
||||||
|
cor_invite=True,
|
||||||
|
)
|
||||||
|
portfolio_role = PortfolioRoleFactory.create(portfolio=self.portfolio, user=cor)
|
||||||
|
invitation = InvitationFactory.create(
|
||||||
|
inviter=self.portfolio.owner,
|
||||||
|
portfolio_role=portfolio_role,
|
||||||
|
user=cor,
|
||||||
|
status=InvitationStatus.PENDING,
|
||||||
|
)
|
||||||
|
|
||||||
|
user_session(self.portfolio.owner)
|
||||||
|
response = client.get(
|
||||||
|
url_for(
|
||||||
|
"portfolios.task_order_invitations",
|
||||||
|
portfolio_id=self.portfolio.id,
|
||||||
|
task_order_id=task_order.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert "Resend Invitation" in response.data.decode()
|
||||||
|
|
||||||
|
|
||||||
def test_ko_can_view_task_order(client, user_session, portfolio, user):
|
def test_ko_can_view_task_order(client, user_session, portfolio, user):
|
||||||
PortfolioRoleFactory.create(
|
PortfolioRoleFactory.create(
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from datetime import datetime
|
||||||
from flask import session, url_for
|
from flask import session, url_for
|
||||||
from .mocks import DOD_SDN_INFO, DOD_SDN, FIXTURE_EMAIL_ADDRESS
|
from .mocks import DOD_SDN_INFO, DOD_SDN, FIXTURE_EMAIL_ADDRESS
|
||||||
from atst.domain.users import Users
|
from atst.domain.users import Users
|
||||||
@ -224,3 +225,18 @@ def test_error_on_invalid_crl(client, monkeypatch):
|
|||||||
response = _login(client)
|
response = _login(client)
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
assert "Error Code 008" in response.data.decode()
|
assert "Error Code 008" in response.data.decode()
|
||||||
|
|
||||||
|
|
||||||
|
def test_last_login_set_when_user_logs_in(client, monkeypatch):
|
||||||
|
last_login = datetime.now()
|
||||||
|
user = UserFactory.create(last_login=last_login)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"atst.domain.authnid.AuthenticationContext.authenticate", lambda *args: True
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"atst.domain.authnid.AuthenticationContext.get_user", lambda *args: user
|
||||||
|
)
|
||||||
|
response = _login(client)
|
||||||
|
assert session["last_login"]
|
||||||
|
assert user.last_login > session["last_login"]
|
||||||
|
assert isinstance(session["last_login"], datetime)
|
||||||
|
@ -20,11 +20,14 @@ class FakeLogger:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.messages = []
|
self.messages = []
|
||||||
|
|
||||||
def info(self, msg):
|
def log(self, _lvl, msg, *args, **kwargs):
|
||||||
self.messages.append(msg)
|
self.messages.append(msg)
|
||||||
|
|
||||||
def warning(self, msg):
|
def info(self, msg, *args, **kwargs):
|
||||||
self.messages.append(msg)
|
self.messages.append(msg)
|
||||||
|
|
||||||
def error(self, msg):
|
def warning(self, msg, *args, **kwargs):
|
||||||
|
self.messages.append(msg)
|
||||||
|
|
||||||
|
def error(self, msg, *args, **kwargs):
|
||||||
self.messages.append(msg)
|
self.messages.append(msg)
|
||||||
|
74
tests/utils/test_logging.py
Normal file
74
tests/utils/test_logging.py
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
from io import StringIO
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from atst.utils.logging import JsonFormatter, RequestContextFilter
|
||||||
|
|
||||||
|
from tests.factories import UserFactory
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def log_stream():
|
||||||
|
return StringIO()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def log_stream_content(log_stream):
|
||||||
|
def _log_stream_content():
|
||||||
|
log_stream.seek(0)
|
||||||
|
return log_stream.read()
|
||||||
|
|
||||||
|
return _log_stream_content
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def logger(log_stream):
|
||||||
|
logger = logging.getLogger()
|
||||||
|
for handler in logger.handlers:
|
||||||
|
logger.removeHandler(handler)
|
||||||
|
|
||||||
|
logHandler = logging.StreamHandler(log_stream)
|
||||||
|
formatter = JsonFormatter()
|
||||||
|
logHandler.setFormatter(formatter)
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
logger.addFilter(RequestContextFilter())
|
||||||
|
logger.addHandler(logHandler)
|
||||||
|
|
||||||
|
return logger
|
||||||
|
|
||||||
|
|
||||||
|
def test_json_formatter(logger, log_stream_content):
|
||||||
|
logger.warning("do or do not", extra={"tags": ["wisdom", "jedi"]})
|
||||||
|
|
||||||
|
log = json.loads(log_stream_content())
|
||||||
|
|
||||||
|
assert log["tags"] == ["wisdom", "jedi"]
|
||||||
|
assert log["message"] == "do or do not"
|
||||||
|
assert log["severity"] == "WARNING"
|
||||||
|
assert log.get("details") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_json_formatter_for_exceptions(logger, log_stream_content):
|
||||||
|
try:
|
||||||
|
raise Exception()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("you found the ventilation shaft!")
|
||||||
|
|
||||||
|
log = json.loads(log_stream_content())
|
||||||
|
assert log["severity"] == "ERROR"
|
||||||
|
assert log.get("details")
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_context_filter(logger, log_stream_content, request_ctx):
|
||||||
|
user = UserFactory.create()
|
||||||
|
uuid = str(uuid4())
|
||||||
|
|
||||||
|
request_ctx.g.current_user = user
|
||||||
|
request_ctx.request.environ["HTTP_X_REQUEST_ID"] = uuid
|
||||||
|
logger.info("this user is doing something")
|
||||||
|
log = json.loads(log_stream_content())
|
||||||
|
assert log["user_id"] == str(user.id)
|
||||||
|
assert log["request_id"] == uuid
|
@ -48,7 +48,7 @@ components:
|
|||||||
footer:
|
footer:
|
||||||
about_link_text: Joint Enterprise Defense Infrastructure
|
about_link_text: Joint Enterprise Defense Infrastructure
|
||||||
browser_support: JEDI Cloud supported on these web browsers
|
browser_support: JEDI Cloud supported on these web browsers
|
||||||
jedi_help_link_text: Questions? Contact your CCPO Representative
|
jedi_help_link_text: Questions? Contact your CCPO representative
|
||||||
forms:
|
forms:
|
||||||
ccpo_review:
|
ccpo_review:
|
||||||
comment_description: Provide instructions or notes for additional information that is necessary to approve the request here. The requestor may then re-submit the updated request or initiate contact outside of AT-AT if further discussion is required. <strong>This message will be shared with the person making the JEDI request.</strong>.
|
comment_description: Provide instructions or notes for additional information that is necessary to approve the request here. The requestor may then re-submit the updated request or initiate contact outside of AT-AT if further discussion is required. <strong>This message will be shared with the person making the JEDI request.</strong>.
|
||||||
@ -81,7 +81,7 @@ forms:
|
|||||||
</a> website.
|
</a> website.
|
||||||
date_latest_training_label: Latest Information Assurance (IA) training completion date
|
date_latest_training_label: Latest Information Assurance (IA) training completion date
|
||||||
designation_description: What is your designation within the DoD?
|
designation_description: What is your designation within the DoD?
|
||||||
designation_label: Designation of Person
|
designation_label: Designation of person
|
||||||
email_label: Email Address
|
email_label: Email Address
|
||||||
first_name_label: First Name
|
first_name_label: First Name
|
||||||
last_name_label: Last Name
|
last_name_label: Last Name
|
||||||
@ -146,18 +146,18 @@ forms:
|
|||||||
average_daily_traffic_label: Average Daily Traffic (Number of Requests)
|
average_daily_traffic_label: Average Daily Traffic (Number of Requests)
|
||||||
cloud_native_description: Are your software systems being developed cloud native?
|
cloud_native_description: Are your software systems being developed cloud native?
|
||||||
data_transfers_description: How much data is being transferred to the cloud?
|
data_transfers_description: How much data is being transferred to the cloud?
|
||||||
dod_component_description: Identify the DoD component that is requesting access to the JEDI Cloud
|
dod_component_description: Identify the DoD component that is requesting access to the JEDI cloud
|
||||||
dod_component_label: DoD Component
|
dod_component_label: DoD Component
|
||||||
dodid_poc_label: DoD ID
|
dodid_poc_label: DoD ID
|
||||||
dollar_value_description: What is your total expected budget for this JEDI Cloud Request?
|
dollar_value_description: What is your total expected budget for this JEDI cloud Request?
|
||||||
dollar_value_label: Total Spend
|
dollar_value_label: Total Spend
|
||||||
email_poc_label: Email Address
|
email_poc_label: Email Address
|
||||||
engineering_assessment_description: Have you completed an engineering assessment of your systems for cloud readiness?
|
engineering_assessment_description: Have you completed an engineering assessment of your systems for cloud readiness?
|
||||||
estimated_monthly_spend_description: 'Use the <a href="/jedi-csp-calculator" target="_blank" class="icon-link">JEDI CSP Calculator</a> to estimate your <b>monthly</b> cloud resource usage and enter the dollar amount below. Note these estimates are for initial approval only. After the request is approved, you will be asked to provide a valid task order number with specific CLIN amounts for cloud services.'
|
estimated_monthly_spend_description: 'Use the <a href="/jedi-csp-calculator" target="_blank" class="icon-link">JEDI CSP Calculator</a> to estimate your <b>monthly</b> cloud resource usage and enter the dollar amount below. Note these estimates are for initial approval only. After the request is approved, you will be asked to provide a valid task order number with specific CLIN amounts for cloud services.'
|
||||||
estimated_monthly_spend_label: Estimated Monthly Spend
|
estimated_monthly_spend_label: Estimated Monthly Spend
|
||||||
expected_completion_date_description: When do you expect to complete your migration to the JEDI Cloud?
|
expected_completion_date_description: When do you expect to complete your migration to the JEDI cloud?
|
||||||
fname_poc_label: First Name
|
fname_poc_label: First Name
|
||||||
jedi_migration_description: Are you using the JEDI Cloud to migrate existing systems?
|
jedi_migration_description: Are you using the JEDI cloud to migrate existing systems?
|
||||||
jedi_migration_label: JEDI Migration
|
jedi_migration_label: JEDI Migration
|
||||||
jedi_usage_description: Your answer will help us provide tangible examples to DoD leadership how and why commercial cloud resources are accelerating the Department's missions
|
jedi_usage_description: Your answer will help us provide tangible examples to DoD leadership how and why commercial cloud resources are accelerating the Department's missions
|
||||||
jedi_usage_label: JEDI Usage
|
jedi_usage_label: JEDI Usage
|
||||||
@ -165,14 +165,14 @@ forms:
|
|||||||
name_description: This name serves as a reference for your initial request and the associated portfolio that will be created once this request is approved. You may edit this name later.
|
name_description: This name serves as a reference for your initial request and the associated portfolio that will be created once this request is approved. You may edit this name later.
|
||||||
name_label: Name Your Request
|
name_label: Name Your Request
|
||||||
name_length_validation_message: Request names must be at least 4 and not more than 100 characters
|
name_length_validation_message: Request names must be at least 4 and not more than 100 characters
|
||||||
num_software_systems_description: Estimate the number of software systems that will be supported by this JEDI Cloud access request
|
num_software_systems_description: Estimate the number of software systems that will be supported by this JEDI cloud access request
|
||||||
num_software_systems_label: Number of Software Systems
|
num_software_systems_label: Number of Software Systems
|
||||||
number_user_sessions_description: How many user sessions do you expect on these systems each day?
|
number_user_sessions_description: How many user sessions do you expect on these systems each day?
|
||||||
organization_providing_assistance_description: 'If you are receiving migration assistance, what is the type of organization providing assistance?'
|
organization_providing_assistance_description: 'If you are receiving migration assistance, what is the type of organization providing assistance?'
|
||||||
rationalization_software_systems_description: Have you completed a “rationalization” of your software systems to move to the cloud?
|
rationalization_software_systems_description: Have you completed a “rationalization” of your software systems to move to the cloud?
|
||||||
reviewed_label: I have reviewed this data and it is correct.
|
reviewed_label: I have reviewed this data and it is correct.
|
||||||
start_date_date_range_validation_message: Must be a date in the future.
|
start_date_date_range_validation_message: Must be a date in the future.
|
||||||
start_date_label: When do you expect to start using the JEDI Cloud (not for billing purposes)?
|
start_date_label: When do you expect to start using the JEDI cloud (not for billing purposes)?
|
||||||
technical_support_team_description: Are you working with a technical support team experienced in cloud migrations?
|
technical_support_team_description: Are you working with a technical support team experienced in cloud migrations?
|
||||||
application:
|
application:
|
||||||
description_label: Description
|
description_label: Description
|
||||||
@ -181,13 +181,13 @@ forms:
|
|||||||
environment_names_unique_validation_message: Environment names must be unique.
|
environment_names_unique_validation_message: Environment names must be unique.
|
||||||
name_label: Name
|
name_label: Name
|
||||||
task_order:
|
task_order:
|
||||||
portfolio_name_label: Organization Portfolio Name
|
portfolio_name_label: Portfolio name
|
||||||
portfolio_name_description: The name of your office or organization. You can add multiple applications to your portfolio. Your task orders are used to pay for these applications and their environments.
|
portfolio_name_description: The name of your office or organization. You can add multiple applications to your portfolio. Your task orders are used to pay for these applications and their environments.
|
||||||
scope_label: Cloud Project Scope
|
scope_label: Cloud project scope
|
||||||
scope_description: Your team's plan for using the cloud, such as migrating an existing application or creating a prototype.
|
scope_description: Your team's plan for using the cloud, such as migrating an existing application or creating a prototype. <p>Not sure how to describe your scope? <a href="#">Read some examples</a> to get some inspiration.</p>
|
||||||
defense_component_label: Department of Defense Component
|
defense_component_label: Department of Defense component
|
||||||
app_migration:
|
app_migration:
|
||||||
label: App Migration
|
label: App migration
|
||||||
description: Do you plan to migrate one or more existing application(s) to the cloud?
|
description: Do you plan to migrate one or more existing application(s) to the cloud?
|
||||||
on_premise: Yes, migrating from an <strong>on-premise data center</strong>
|
on_premise: Yes, migrating from an <strong>on-premise data center</strong>
|
||||||
cloud: Yes, migrating from <strong>another cloud provider</strong>
|
cloud: Yes, migrating from <strong>another cloud provider</strong>
|
||||||
@ -195,43 +195,43 @@ forms:
|
|||||||
none: Not planning to migrate any applications
|
none: Not planning to migrate any applications
|
||||||
not_sure: "Not sure"
|
not_sure: "Not sure"
|
||||||
native_apps:
|
native_apps:
|
||||||
label: Native Apps
|
label: Native apps
|
||||||
description: Do you plan to develop any applications natively in the cloud?
|
description: Do you plan to develop any applications natively in the cloud?
|
||||||
'yes': Yes, planning to develop natively in the cloud
|
'yes': Yes, planning to develop natively in the cloud
|
||||||
'no': No, not planning to develop natively in the cloud
|
'no': No, not planning to develop natively in the cloud
|
||||||
not_sure: Not sure, unsure if planning to develop natively in the cloud
|
not_sure: Not sure, unsure if planning to develop natively in the cloud
|
||||||
complexity:
|
complexity:
|
||||||
label: Project Complexity
|
label: Project complexity
|
||||||
description: Which of these describes how complex your team's use of the cloud will be? Select all that apply.
|
description: Which of these describes how complex your team's use of the cloud will be? Select all that apply.
|
||||||
storage: Storage
|
storage: Storage
|
||||||
data_analytics: Data Analytics
|
data_analytics: Data analytics
|
||||||
conus: CONUS Access
|
conus: CONUS access
|
||||||
oconus: OCONUS Access
|
oconus: OCONUS access
|
||||||
tactical_edge: Tactical Edge Access
|
tactical_edge: Tactical edge access
|
||||||
not_sure: Not sure
|
not_sure: Not sure
|
||||||
other: Other
|
other: Other
|
||||||
complexity_other_label: Project Complexity Other
|
complexity_other_label: Project Complexity Other
|
||||||
dev_team:
|
dev_team:
|
||||||
label: Development Team
|
label: Development team
|
||||||
description: Who will be completing the development work for your cloud application(s)? Select all that apply.
|
description: Who will be completing the development work for your cloud application(s)? Select all that apply.
|
||||||
civilians: Government Civilians
|
civilians: Government civilians
|
||||||
military: Military
|
military: Military
|
||||||
contractor: Contractor
|
contractor: Contractor
|
||||||
other: "Other <em class='description'>(E.g. University or other partner)</em>"
|
other: "Other <em class='description'>(E.g. University or other partner)</em>"
|
||||||
dev_team_other_label: Development Team Other
|
dev_team_other_label: Development Team Other
|
||||||
team_experience:
|
team_experience:
|
||||||
label: Team Experience
|
label: Team experience
|
||||||
description: How much experience does your team have with development in the cloud?
|
description: How much experience does your team have with development in the cloud?
|
||||||
none: No previous experience
|
none: No previous experience
|
||||||
planned: Researched or planned a cloud build or migration
|
planned: Researched or planned a cloud build or migration
|
||||||
built_1: Built or migrated 1-2 applications
|
built_1: Built or migrated 1-2 applications
|
||||||
built_3: Built or migrated 3-5 applications
|
built_3: Built or migrated 3-5 applications
|
||||||
built_many: Built or migrated many applications, or consulted on several such projects
|
built_many: Built or migrated many applications, or consulted on several such projects
|
||||||
performance_length:
|
performance_length:
|
||||||
label: Period of Performance length
|
label: Period of performance length
|
||||||
start_date_label: Start Date
|
start_date_label: Start Date
|
||||||
end_date_label: End Date
|
end_date_label: End Date
|
||||||
csp_estimate_label: Upload a copy of your CSP Cost Estimate Research
|
csp_estimate_label: Upload a copy of your CSP cost estimate research
|
||||||
csp_estimate_description: Upload a PDF or screenshot of your usage estimate from the calculator.
|
csp_estimate_description: Upload a PDF or screenshot of your usage estimate from the calculator.
|
||||||
file_format_not_allowed: Only PDF or PNG files can be uploaded.
|
file_format_not_allowed: Only PDF or PNG files can be uploaded.
|
||||||
clin_01_label: 'CLIN 01 : Unclassified'
|
clin_01_label: 'CLIN 01 : Unclassified'
|
||||||
@ -273,7 +273,7 @@ forms:
|
|||||||
phone_number_message: Please enter a valid 5 or 10 digit phone number.
|
phone_number_message: Please enter a valid 5 or 10 digit phone number.
|
||||||
is_required: This field is required.
|
is_required: This field is required.
|
||||||
portfolio:
|
portfolio:
|
||||||
name_label: Portfolio Name
|
name_label: Portfolio name
|
||||||
name_length_validation_message: Portfolio names can be between 4-100 characters
|
name_length_validation_message: Portfolio names can be between 4-100 characters
|
||||||
officers:
|
officers:
|
||||||
contracting_officer_invite: Invite KO to Task Order Builder
|
contracting_officer_invite: Invite KO to Task Order Builder
|
||||||
@ -287,15 +287,15 @@ fragments:
|
|||||||
date_last_training_tooltip: When was the last time you completed the IA training? <br> Information Assurance (IA) training is an important step in cyber awareness.
|
date_last_training_tooltip: When was the last time you completed the IA training? <br> Information Assurance (IA) training is an important step in cyber awareness.
|
||||||
save_details_button: Save Details
|
save_details_button: Save Details
|
||||||
pending_ccpo_acceptance_alert:
|
pending_ccpo_acceptance_alert:
|
||||||
learn_more_link_text: Learn more about the JEDI Cloud task order and the financial verification process.
|
learn_more_link_text: Learn more about the JEDI cloud task order and the financial verification process.
|
||||||
paragraph_1: The CCPO will review and respond to your request in 3 business days. You’ll be notified via email or phone. Please note if your request is for over $1M of JEDI cloud resources it will require a manual review by the CCPO.
|
paragraph_1: The CCPO will review and respond to your request in 3 business days. You’ll be notified via email or phone. Please note if your request is for over $1M of JEDI cloud resources it will require a manual review by the CCPO.
|
||||||
paragraph_2: 'While your request is being reviewed, your next step is to create a task order (TO) associated with the JEDI Cloud. Please contact a Contracting Officer (KO), Contracting Officer Representative (COR), or a Financial Manager to help with this step.'
|
paragraph_2: 'While your request is being reviewed, your next step is to create a task order (TO) associated with the JEDI cloud. Please contact a Contracting Officer (KO), Contracting Officer Representative (COR), or a Financial Manager to help with this step.'
|
||||||
pending_ccpo_approval_modal:
|
pending_ccpo_approval_modal:
|
||||||
paragraph_1: The CCPO will review and respond to your Financial Verification submission in 3 business days. You will be notified via email or phone.
|
paragraph_1: The CCPO will review and respond to your Financial Verification submission in 3 business days. You will be notified via email or phone.
|
||||||
paragraph_2: Once the financial verification is approved you will be invited to create your JEDI Portfolio and set-up your applications. Click here for more details.
|
paragraph_2: Once the financial verification is approved you will be invited to create your JEDI Portfolio and set-up your applications. Click here for more details.
|
||||||
pending_financial_verification:
|
pending_financial_verification:
|
||||||
learn_more_link_text: Learn more about the JEDI Cloud task order and the financial verification process.
|
learn_more_link_text: Learn more about the JEDI cloud task order and the financial verification process.
|
||||||
paragraph_1: 'The next step is to create a task order associated with JEDI Cloud. Please contact a Contracting Officer (KO), Contracting Officer Representative (COR), or a Financial Manager to help with this step.'
|
paragraph_1: 'The next step is to create a task order associated with JEDI cloud. Please contact a Contracting Officer (KO), Contracting Officer Representative (COR), or a Financial Manager to help with this step.'
|
||||||
paragraph_2: 'Once the task order has been created, you will be asked to provide details about the task order in the Financial Verification step.'
|
paragraph_2: 'Once the task order has been created, you will be asked to provide details about the task order in the Financial Verification step.'
|
||||||
ko_review_message:
|
ko_review_message:
|
||||||
title: Steps
|
title: Steps
|
||||||
@ -314,13 +314,13 @@ login:
|
|||||||
learn_more: Learn more
|
learn_more: Learn more
|
||||||
message: 'When you are prompted to select a certificate, please select Email Certificate from the provided choices.'
|
message: 'When you are prompted to select a certificate, please select Email Certificate from the provided choices.'
|
||||||
title: Certificate Selection
|
title: Certificate Selection
|
||||||
h1_title: Access the JEDI Cloud
|
h1_title: Access the JEDI cloud
|
||||||
login_button: Sign in with CAC
|
login_button: Sign in with CAC
|
||||||
title_tag: Sign in | JEDI Cloud
|
title_tag: Sign in | JEDI cloud
|
||||||
navigation:
|
navigation:
|
||||||
topbar:
|
topbar:
|
||||||
jedi_cloud_link_text: JEDI
|
jedi_cloud_link_text: JEDI
|
||||||
logout_link_title: Log out of JEDI Cloud
|
logout_link_title: Log out of JEDI cloud
|
||||||
named_portfolio: 'Portfolio {portfolio}'
|
named_portfolio: 'Portfolio {portfolio}'
|
||||||
no_other_active_portfolios: You have no other active JEDI portfolios.
|
no_other_active_portfolios: You have no other active JEDI portfolios.
|
||||||
other_active_portfolios: Other Active Portfolios
|
other_active_portfolios: Other Active Portfolios
|
||||||
@ -329,7 +329,7 @@ navigation:
|
|||||||
add_new_member_label: Add new member
|
add_new_member_label: Add new member
|
||||||
add_new_application_label: Add new application
|
add_new_application_label: Add new application
|
||||||
budget_report: Budget Report
|
budget_report: Budget Report
|
||||||
activity_log: Activity Log
|
activity_log: Activity log
|
||||||
members: Members
|
members: Members
|
||||||
applications: Applications
|
applications: Applications
|
||||||
portfolio_funding: Funding
|
portfolio_funding: Funding
|
||||||
@ -456,44 +456,44 @@ task_orders:
|
|||||||
project_title: About your project
|
project_title: About your project
|
||||||
team_title: About your team
|
team_title: About your team
|
||||||
market_research_title: Market research
|
market_research_title: Market research
|
||||||
market_research_paragraph: 'The JEDI Cloud Computing Program Office (CCPO) has completed the market research requirements for all related task orders. The Department of Defense CIO has approved this research.<br /><a href="#">View JEDI Market Research Memo</a>'
|
market_research_paragraph: 'The JEDI Cloud Computing Program Office (CCPO) has completed the market research requirements for all related task orders. The Department of Defense CIO has approved this research.<br /><a href="#">View JEDI market research memo</a>'
|
||||||
funding:
|
funding:
|
||||||
section_title: Funding
|
section_title: Funding
|
||||||
performance_period_title: Period of Performance
|
performance_period_title: Period of performance
|
||||||
performance_period_description: Choose the length of time your task order will cover.
|
performance_period_description: Choose the length of time your task order will cover.
|
||||||
performance_period_paragraph: Be aware that your funds will be lost if you don’t use them. Because of this, we strongly recommend submitting small, short-duration task orders, usually around a three month period. We’ll notify you when your period of performance is nearing its end so you can request your next set of funds with a new task order.
|
performance_period_paragraph: Be aware that your funds will be lost if you don’t use them. Because of this, we strongly recommend submitting small, short-duration task orders, usually around a three month period. We’ll notify you when your period of performance is nearing its end so you can request your next set of funds with a new task order.
|
||||||
estimate_usage_title: Estimate Your Cloud Usage
|
estimate_usage_title: Estimate your cloud usage
|
||||||
estimate_usage_description: Calculate how much your cloud usage will cost. A technical representative from your team should help you complete this calculation. These calculations will become your CLINs.
|
estimate_usage_description: Calculate how much your cloud usage will cost. A technical representative from your team should help you complete this calculation. These calculations will become your CLINs.
|
||||||
estimate_usage_paragraph: This is only an estimation tool to help you make an informed evaluation of what you expect to use. While you're tied to the dollar amount you specify in your task order, you're not obligated by the resources you indicate in the calculator.
|
estimate_usage_paragraph: This is only an estimation tool to help you make an informed evaluation of what you expect to use. While you're tied to the dollar amount you specify in your task order, you're not obligated by the resources you indicate in the calculator.
|
||||||
cloud_calculations_title: Cloud Usage Calculations
|
cloud_calculations_title: Cloud usage calculations
|
||||||
cloud_calculations_paragraph: Enter the results of your cloud usage calculations below.
|
cloud_calculations_paragraph: Enter the results of your cloud usage calculations below.
|
||||||
cloud_offerings_title: Cloud Offerings
|
cloud_offerings_title: Cloud offerings
|
||||||
cloud_offerings_paragraph: Infrastructure as a Service (IaaS) and Platform as a Service (PaaS) offerings
|
cloud_offerings_paragraph: Infrastructure as a Service (IaaS) and Platform as a Service (PaaS) offerings
|
||||||
support_assistance_title: Cloud Support and Assistance
|
support_assistance_title: Cloud support and assistance
|
||||||
support_assistance_paragraph: Technical guidance from the cloud service provider, including architecture, configuration of IaaS and PaaS, integration, troubleshooting assistance, and other services.
|
support_assistance_paragraph: Technical guidance from the cloud service provider, including architecture, configuration of IaaS and PaaS, integration, troubleshooting assistance, and other services.
|
||||||
total: 'Total Task Order Value:'
|
total: 'Total task order value:'
|
||||||
oversight:
|
oversight:
|
||||||
section_title: Oversight
|
section_title: Oversight
|
||||||
ko_info_title: Contracting Officer (KO) Information
|
ko_info_title: Contracting Officer (KO) Information
|
||||||
ko_info_paragraph: Your KO will need to approve funding for this task order by logging into the JEDI Cloud Portal, submitting the task order documents within their official system of record, and then finally providing a digital signature. It might be helpful to work with your program's Financial Manager to get your TO documents moving.
|
ko_info_paragraph: Your KO will need to approve funding for this task order by logging into the JEDI cloud portal, submitting the task order documents within their official system of record, and then finally providing a digital signature. It might be helpful to work with your program's Financial Manager to get your TO documents moving.
|
||||||
skip_ko_label: "Skip for now (We'll remind you to enter one later)"
|
skip_ko_label: "Skip for now (We'll remind you to enter one later)"
|
||||||
dod_id_tooltip: "The DoD ID is needed to verify the identity of the indicated officer or representative."
|
dod_id_tooltip: "The DoD ID is needed to verify the identity of the indicated officer or representative."
|
||||||
cor_info_title: Contracting Officer Representative (COR) Information
|
cor_info_title: Contracting Officer Representative (COR) Information
|
||||||
cor_info_paragraph: Your COR may assist in submitting the task order documents within their official system of record. They may also be invited to log in and manage the Task Order entry within the JEDI Cloud portal.
|
cor_info_paragraph: Your COR may assist in submitting the task order documents within their official system of record. They may also be invited to log in and manage the Task Order entry within the JEDI cloud portal.
|
||||||
so_info_title: Security Officer Information
|
so_info_title: Security Officer Information
|
||||||
so_info_paragraph: Your Security Officer will need to answer some security configuration questions in order to generate a DD-254 document, then electronically sign.
|
so_info_paragraph: Your Security Officer will need to answer some security configuration questions in order to generate a DD-254 document, then electronically sign.
|
||||||
review:
|
review:
|
||||||
section_title: Review Your Task Order
|
section_title: Review Your Task Order
|
||||||
app_info: What you're making
|
app_info: What you're making
|
||||||
portfolio: Portfolio
|
portfolio: Portfolio
|
||||||
dod: DoD Component
|
dod: DoD component
|
||||||
scope: Scope (Statement of Work)
|
scope: Scope (statement of work)
|
||||||
reporting: Reporting
|
reporting: Reporting
|
||||||
complexity: Project complexity
|
complexity: Project complexity
|
||||||
team: Development team
|
team: Development team
|
||||||
funding: Funding
|
funding: Funding
|
||||||
performance_period: Period of performance
|
performance_period: Period of performance
|
||||||
usage_est_link: View Usage Estimate
|
usage_est_link: View usage estimate
|
||||||
to_value: Task order value
|
to_value: Task order value
|
||||||
clin_1: 'CLIN #1: Unclassified Cloud'
|
clin_1: 'CLIN #1: Unclassified Cloud'
|
||||||
clin_2: 'CLIN #2: Classified Cloud'
|
clin_2: 'CLIN #2: Classified Cloud'
|
||||||
@ -546,7 +546,7 @@ portfolios:
|
|||||||
index:
|
index:
|
||||||
empty:
|
empty:
|
||||||
title: You have no apps yet
|
title: You have no apps yet
|
||||||
start_button: Start a New JEDI Portfolio
|
start_button: Start a new JEDI portfolio
|
||||||
applications:
|
applications:
|
||||||
add_application_text: Add a new application
|
add_application_text: Add a new application
|
||||||
app_settings_text: App settings
|
app_settings_text: App settings
|
||||||
@ -558,7 +558,7 @@ portfolios:
|
|||||||
environments_heading: Environments
|
environments_heading: Environments
|
||||||
environments_description: Each environment created within an application is logically separated from one another for easier management and security.
|
environments_description: Each environment created within an application is logically separated from one another for easier management and security.
|
||||||
update_button_text: Save
|
update_button_text: Save
|
||||||
create_button_text: Create Application
|
create_button_text: Create
|
||||||
team_management:
|
team_management:
|
||||||
title: '{application_name} Team Management'
|
title: '{application_name} Team Management'
|
||||||
subheading: Team Management
|
subheading: Team Management
|
||||||
@ -574,12 +574,12 @@ portfolios:
|
|||||||
archive_button: Delete member
|
archive_button: Delete member
|
||||||
permissions:
|
permissions:
|
||||||
name: Name
|
name: Name
|
||||||
app_mgmt: App Mgmt
|
app_mgmt: App management
|
||||||
funding: Funding
|
funding: Funding
|
||||||
reporting: Reporting
|
reporting: Reporting
|
||||||
portfolio_mgmt: Portfolio Mgmt
|
portfolio_mgmt: Portfolio management
|
||||||
view_only: View Only
|
view_only: View only
|
||||||
edit_access: Edit Access
|
edit_access: Edit access
|
||||||
testing:
|
testing:
|
||||||
example_string: Hello World
|
example_string: Hello World
|
||||||
example_with_variables: 'Hello, {name}!'
|
example_with_variables: 'Hello, {name}!'
|
||||||
|
Loading…
x
Reference in New Issue
Block a user