Merge branch 'master' into title-error-message

This commit is contained in:
rachel-dtr 2019-02-14 10:53:47 -05:00 committed by GitHub
commit 942b557067
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
108 changed files with 4803 additions and 2121 deletions

View File

@ -19,14 +19,15 @@ Before running the setup scripts, a couple of dependencies need to be installed
locally: locally:
* `python` == 3.6 * `python` == 3.6
Python version 3.6 must be installed on your machine before installing `pipenv`. Python version 3.6 **must** be installed on your machine before installing `pipenv`.
You can download Python 3.6 [from python.org](https://www.python.org/downloads/) You can download Python 3.6 [from python.org](https://www.python.org/downloads/)
or use your preferred system package manager. or use your preferred system package manager. Multiple versions of Python can exist on one
computer, but 3.6 is required for ATAT.
* `pipenv` * `pipenv`
ATST requires `pipenv` to be installed for python dependency management. `pipenv` ATST requires `pipenv` to be installed for python dependency management. `pipenv`
will create the virtual environment that the app requires. [See will create the virtual environment that the app requires. [See
`pipenv`'s documentation for instructions on installing `pipenv]( `pipenv`'s documentation for instructions on installing `pipenv`](
https://pipenv.readthedocs.io/en/latest/install/#installing-pipenv). https://pipenv.readthedocs.io/en/latest/install/#installing-pipenv).
* `yarn` * `yarn`
@ -35,7 +36,10 @@ locally:
* `postgres` >= 9.6 * `postgres` >= 9.6
ATST requires a PostgreSQL instance (>= 9.6) for persistence. Have PostgresSQL installed ATST requires a PostgreSQL instance (>= 9.6) for persistence. Have PostgresSQL installed
and running on the default port of 5432. You can verify that PostgresSQL is running and running on the default port of 5432. (A good resource for installing and running
PostgreSQL for Macs is [Postgres.app](https://postgresapp.com/). Follow the instructions,
including the optional Step 3, and add `/Applications/Postgres.app/Contents/Versions/latest/bin`
to your `PATH` environment variable.) You can verify that PostgresSQL is running
by executing `psql` and ensuring that a connection is successfully made. by executing `psql` and ensuring that a connection is successfully made.
* `redis` * `redis`

View File

@ -0,0 +1,36 @@
"""Add PDF to Task Order
Revision ID: 1f690989e38e
Revises: 0ff4c31c4d28
Create Date: 2019-02-04 15:56:57.642156
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '1f690989e38e'
down_revision = '0ff4c31c4d28'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('task_orders', sa.Column('pdf_attachment_id', postgresql.UUID(as_uuid=True), nullable=True))
op.drop_constraint('task_orders_attachments_attachment_id', 'task_orders', type_='foreignkey')
op.alter_column('task_orders', 'attachment_id', new_column_name='csp_attachment_id')
op.create_foreign_key('task_orders_attachments_pdf_attachment_id', 'task_orders', 'attachments', ['pdf_attachment_id'], ['id'])
op.create_foreign_key('task_orders_attachments_csp_attachment_id', 'task_orders', 'attachments', ['csp_attachment_id'], ['id'])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint('task_orders_attachments_csp_attachment_id', 'task_orders', type_='foreignkey')
op.drop_constraint('task_orders_attachments_pdf_attachment_id', 'task_orders', type_='foreignkey')
op.alter_column('task_orders', 'csp_attachment_id', new_column_name='attachment_id')
op.create_foreign_key('task_orders_attachments_attachment_id', 'task_orders', 'attachments', ['attachment_id'], ['id'])
op.drop_column('task_orders', 'pdf_attachment_id')
# ### end Alembic commands ###

View File

@ -0,0 +1,34 @@
"""Record signer DOD ID
Revision ID: b3a1a07cf30b
Revises: c98adf9bb431
Create Date: 2019-02-12 10:16:19.349083
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'b3a1a07cf30b'
down_revision = 'c98adf9bb431'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('task_orders', sa.Column('level_of_warrant', sa.Numeric(scale=2), nullable=True))
op.add_column('task_orders', sa.Column('signed_at', sa.DateTime(), nullable=True))
op.add_column('task_orders', sa.Column('signer_dod_id', sa.String(), nullable=True))
op.add_column('task_orders', sa.Column('unlimited_level_of_warrant', sa.Boolean(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('task_orders', 'unlimited_level_of_warrant')
op.drop_column('task_orders', 'signer_dod_id')
op.drop_column('task_orders', 'signed_at')
op.drop_column('task_orders', 'level_of_warrant')
# ### end Alembic commands ###

View File

@ -0,0 +1,32 @@
"""record invitation status
Revision ID: c98adf9bb431
Revises: 1f690989e38e
Create Date: 2019-02-06 09:02:28.617202
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'c98adf9bb431'
down_revision = '1f690989e38e'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('task_orders', sa.Column('cor_invite', sa.Boolean(), nullable=True))
op.add_column('task_orders', sa.Column('ko_invite', sa.Boolean(), nullable=True))
op.add_column('task_orders', sa.Column('so_invite', sa.Boolean(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('task_orders', 'so_invite')
op.drop_column('task_orders', 'ko_invite')
op.drop_column('task_orders', 'cor_invite')
# ### end Alembic commands ###

View File

@ -1,3 +1,4 @@
import datetime
from itertools import groupby from itertools import groupby
from collections import OrderedDict from collections import OrderedDict
import pendulum import pendulum
@ -31,131 +32,152 @@ class MockApplication:
self.environments = [make_env(env_name) for env_name in envs] self.environments = [make_env(env_name) for env_name in envs]
def generate_sample_dates(_max=8):
current = datetime.datetime.today()
sample_dates = []
for _i in range(_max):
current = current - datetime.timedelta(days=29)
sample_dates.append(current.strftime("%m/%Y"))
reversed(sample_dates)
return sample_dates
class MockReportingProvider(ReportingInterface): class MockReportingProvider(ReportingInterface):
FIXTURE_MONTHS = generate_sample_dates()
MONTHLY_SPEND_BY_ENVIRONMENT = { MONTHLY_SPEND_BY_ENVIRONMENT = {
"LC04_Integ": { "LC04_Integ": {
"02/2018": 284, FIXTURE_MONTHS[7]: 284,
"03/2018": 1210, FIXTURE_MONTHS[6]: 1210,
"04/2018": 1430, FIXTURE_MONTHS[5]: 1430,
"05/2018": 1366, FIXTURE_MONTHS[4]: 1366,
"06/2018": 1169, FIXTURE_MONTHS[3]: 1169,
"07/2018": 991, FIXTURE_MONTHS[2]: 991,
"08/2018": 978, FIXTURE_MONTHS[1]: 978,
"09/2018": 737, FIXTURE_MONTHS[0]: 737,
}, },
"LC04_PreProd": { "LC04_PreProd": {
"02/2018": 812, FIXTURE_MONTHS[7]: 812,
"03/2018": 1389, FIXTURE_MONTHS[6]: 1389,
"04/2018": 1425, FIXTURE_MONTHS[5]: 1425,
"05/2018": 1306, FIXTURE_MONTHS[4]: 1306,
"06/2018": 1112, FIXTURE_MONTHS[3]: 1112,
"07/2018": 936, FIXTURE_MONTHS[2]: 936,
"08/2018": 921, FIXTURE_MONTHS[1]: 921,
"09/2018": 694, FIXTURE_MONTHS[0]: 694,
}, },
"LC04_Prod": { "LC04_Prod": {
"02/2018": 1742, FIXTURE_MONTHS[7]: 1742,
"03/2018": 1716, FIXTURE_MONTHS[6]: 1716,
"04/2018": 1866, FIXTURE_MONTHS[5]: 1866,
"05/2018": 1809, FIXTURE_MONTHS[4]: 1809,
"06/2018": 1839, FIXTURE_MONTHS[3]: 1839,
"07/2018": 1633, FIXTURE_MONTHS[2]: 1633,
"08/2018": 1654, FIXTURE_MONTHS[1]: 1654,
"09/2018": 1103, FIXTURE_MONTHS[0]: 1103,
}, },
"SF18_Integ": { "SF18_Integ": {
"04/2018": 1498, FIXTURE_MONTHS[5]: 1498,
"05/2018": 1400, FIXTURE_MONTHS[4]: 1400,
"06/2018": 1394, FIXTURE_MONTHS[3]: 1394,
"07/2018": 1171, FIXTURE_MONTHS[2]: 1171,
"08/2018": 1200, FIXTURE_MONTHS[1]: 1200,
"09/2018": 963, FIXTURE_MONTHS[0]: 963,
}, },
"SF18_PreProd": { "SF18_PreProd": {
"04/2018": 1780, FIXTURE_MONTHS[5]: 1780,
"05/2018": 1667, FIXTURE_MONTHS[4]: 1667,
"06/2018": 1703, FIXTURE_MONTHS[3]: 1703,
"07/2018": 1474, FIXTURE_MONTHS[2]: 1474,
"08/2018": 1441, FIXTURE_MONTHS[1]: 1441,
"09/2018": 933, FIXTURE_MONTHS[0]: 933,
}, },
"SF18_Prod": { "SF18_Prod": {
"04/2018": 1686, FIXTURE_MONTHS[5]: 1686,
"05/2018": 1779, FIXTURE_MONTHS[4]: 1779,
"06/2018": 1792, FIXTURE_MONTHS[3]: 1792,
"07/2018": 1570, FIXTURE_MONTHS[2]: 1570,
"08/2018": 1539, FIXTURE_MONTHS[1]: 1539,
"09/2018": 986, FIXTURE_MONTHS[0]: 986,
}, },
"Canton_Prod": { "Canton_Prod": {
"05/2018": 28699, FIXTURE_MONTHS[4]: 28699,
"06/2018": 26766, FIXTURE_MONTHS[3]: 26766,
"07/2018": 22619, FIXTURE_MONTHS[2]: 22619,
"08/2018": 24090, FIXTURE_MONTHS[1]: 24090,
"09/2018": 16719, FIXTURE_MONTHS[0]: 16719,
}, },
"BD04_Integ": {}, "BD04_Integ": {},
"BD04_PreProd": { "BD04_PreProd": {
"02/2018": 7019, FIXTURE_MONTHS[7]: 7019,
"03/2018": 3004, FIXTURE_MONTHS[6]: 3004,
"04/2018": 2691, FIXTURE_MONTHS[5]: 2691,
"05/2018": 2901, FIXTURE_MONTHS[4]: 2901,
"06/2018": 3463, FIXTURE_MONTHS[3]: 3463,
"07/2018": 3314, FIXTURE_MONTHS[2]: 3314,
"08/2018": 3432, FIXTURE_MONTHS[1]: 3432,
"09/2018": 723, FIXTURE_MONTHS[0]: 723,
}, },
"SCV18_Dev": {"05/2019": 9797}, "SCV18_Dev": {FIXTURE_MONTHS[1]: 9797},
"Crown_CR Portal Dev": { "Crown_CR Portal Dev": {
"03/2018": 208, FIXTURE_MONTHS[6]: 208,
"04/2018": 457, FIXTURE_MONTHS[5]: 457,
"05/2018": 671, FIXTURE_MONTHS[4]: 671,
"06/2018": 136, FIXTURE_MONTHS[3]: 136,
"07/2018": 1524, FIXTURE_MONTHS[2]: 1524,
"08/2018": 2077, FIXTURE_MONTHS[1]: 2077,
"09/2018": 1858, FIXTURE_MONTHS[0]: 1858,
}, },
"Crown_CR Staging": { "Crown_CR Staging": {
"03/2018": 208, FIXTURE_MONTHS[6]: 208,
"04/2018": 457, FIXTURE_MONTHS[5]: 457,
"05/2018": 671, FIXTURE_MONTHS[4]: 671,
"06/2018": 136, FIXTURE_MONTHS[3]: 136,
"07/2018": 1524, FIXTURE_MONTHS[2]: 1524,
"08/2018": 2077, FIXTURE_MONTHS[1]: 2077,
"09/2018": 1858, FIXTURE_MONTHS[0]: 1858,
},
"Crown_CR Portal Test 1": {
FIXTURE_MONTHS[2]: 806,
FIXTURE_MONTHS[1]: 1966,
FIXTURE_MONTHS[0]: 2597,
},
"Crown_Jewels Prod": {
FIXTURE_MONTHS[2]: 806,
FIXTURE_MONTHS[1]: 1966,
FIXTURE_MONTHS[0]: 2597,
}, },
"Crown_CR Portal Test 1": {"07/2018": 806, "08/2018": 1966, "09/2018": 2597},
"Crown_Jewels Prod": {"07/2018": 806, "08/2018": 1966, "09/2018": 2597},
"Crown_Jewels Dev": { "Crown_Jewels Dev": {
"03/2018": 145, FIXTURE_MONTHS[6]: 145,
"04/2018": 719, FIXTURE_MONTHS[5]: 719,
"05/2018": 1243, FIXTURE_MONTHS[4]: 1243,
"06/2018": 2214, FIXTURE_MONTHS[3]: 2214,
"07/2018": 2959, FIXTURE_MONTHS[2]: 2959,
"08/2018": 4151, FIXTURE_MONTHS[1]: 4151,
"09/2018": 4260, FIXTURE_MONTHS[0]: 4260,
}, },
"NP02_Integ": {"08/2018": 284, "09/2018": 1210}, "NP02_Integ": {FIXTURE_MONTHS[1]: 284, FIXTURE_MONTHS[0]: 1210},
"NP02_PreProd": {"08/2018": 812, "09/2018": 1389}, "NP02_PreProd": {FIXTURE_MONTHS[1]: 812, FIXTURE_MONTHS[0]: 1389},
"NP02_Prod": {"08/2018": 3742, "09/2018": 4716}, "NP02_Prod": {FIXTURE_MONTHS[1]: 3742, FIXTURE_MONTHS[0]: 4716},
"FM_Integ": {"08/2018": 1498}, "FM_Integ": {FIXTURE_MONTHS[1]: 1498},
"FM_Prod": {"09/2018": 5686}, "FM_Prod": {FIXTURE_MONTHS[0]: 5686},
} }
CUMULATIVE_BUDGET_AARDVARK = { CUMULATIVE_BUDGET_AARDVARK = {
"02/2018": {"spend": 9857, "cumulative": 9857}, FIXTURE_MONTHS[7]: {"spend": 9857, "cumulative": 9857},
"03/2018": {"spend": 7881, "cumulative": 17738}, FIXTURE_MONTHS[6]: {"spend": 7881, "cumulative": 17738},
"04/2018": {"spend": 14010, "cumulative": 31748}, FIXTURE_MONTHS[5]: {"spend": 14010, "cumulative": 31748},
"05/2018": {"spend": 43510, "cumulative": 75259}, FIXTURE_MONTHS[4]: {"spend": 43510, "cumulative": 75259},
"06/2018": {"spend": 41725, "cumulative": 116_984}, FIXTURE_MONTHS[3]: {"spend": 41725, "cumulative": 116_984},
"07/2018": {"spend": 41328, "cumulative": 158_312}, FIXTURE_MONTHS[2]: {"spend": 41328, "cumulative": 158_312},
"08/2018": {"spend": 47491, "cumulative": 205_803}, FIXTURE_MONTHS[1]: {"spend": 47491, "cumulative": 205_803},
"09/2018": {"spend": 36028, "cumulative": 241_831}, FIXTURE_MONTHS[0]: {"spend": 36028, "cumulative": 241_831},
} }
CUMULATIVE_BUDGET_BELUGA = { CUMULATIVE_BUDGET_BELUGA = {
"08/2018": {"spend": 4838, "cumulative": 4838}, FIXTURE_MONTHS[1]: {"spend": 4838, "cumulative": 4838},
"09/2018": {"spend": 14500, "cumulative": 19338}, FIXTURE_MONTHS[0]: {"spend": 14500, "cumulative": 19338},
} }
REPORT_FIXTURE_MAP = { REPORT_FIXTURE_MAP = {

View File

@ -19,7 +19,19 @@ def dollars(value):
return "${:,.2f}".format(numberValue) return "${:,.2f}".format(numberValue)
def justDollars(value):
raw = dollars(value)
return raw.split(".")[0]
def justCents(value):
raw = dollars(value)
return raw.split(".")[1]
def usPhone(number): def usPhone(number):
if not number:
return ""
phone = re.sub(r"\D", "", number) phone = re.sub(r"\D", "", number)
return "+1 ({}) {} - {}".format(phone[0:3], phone[3:6], phone[6:]) return "+1 ({}) {} - {}".format(phone[0:3], phone[3:6], phone[6:])
@ -99,6 +111,8 @@ def normalizeOrder(title):
def register_filters(app): def register_filters(app):
app.jinja_env.filters["iconSvg"] = iconSvg app.jinja_env.filters["iconSvg"] = iconSvg
app.jinja_env.filters["dollars"] = dollars app.jinja_env.filters["dollars"] = dollars
app.jinja_env.filters["justDollars"] = justDollars
app.jinja_env.filters["justCents"] = justCents
app.jinja_env.filters["usPhone"] = usPhone app.jinja_env.filters["usPhone"] = usPhone
app.jinja_env.filters["readableInteger"] = readableInteger app.jinja_env.filters["readableInteger"] = readableInteger
app.jinja_env.filters["getOptionLabel"] = getOptionLabel app.jinja_env.filters["getOptionLabel"] = getOptionLabel

View File

@ -1,5 +1,5 @@
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms.fields import StringField from wtforms.fields import StringField, BooleanField
from wtforms.fields.html5 import TelField from wtforms.fields.html5 import TelField
from wtforms.validators import Email, Length, Optional from wtforms.validators import Email, Length, Optional
@ -15,6 +15,7 @@ class OfficerForm(FlaskForm):
email = StringField("Email", validators=[Optional(), Email()]) email = StringField("Email", validators=[Optional(), Email()])
phone_number = TelField("Phone Number", validators=[PhoneNumber()]) phone_number = TelField("Phone Number", validators=[PhoneNumber()])
dod_id = StringField("DoD ID", validators=[Optional(), Length(min=10), IsNumber()]) dod_id = StringField("DoD ID", validators=[Optional(), Length(min=10), IsNumber()])
invite = BooleanField("Invite to Task Order Builder")
class EditTaskOrderOfficersForm(CacheableForm): class EditTaskOrderOfficersForm(CacheableForm):

View File

@ -222,3 +222,28 @@ class OversightForm(CacheableForm):
class ReviewForm(CacheableForm): class ReviewForm(CacheableForm):
pass pass
class SignatureForm(CacheableForm):
level_of_warrant = DecimalField(
translate("task_orders.sign.level_of_warrant_label"),
description=translate("task_orders.sign.level_of_warrant_description"),
validators=[
RequiredIf(
lambda form: (
form._fields.get("unlimited_level_of_warrant").data is not True
)
)
],
)
unlimited_level_of_warrant = BooleanField(
translate("task_orders.sign.unlimited_level_of_warrant_description"),
validators=[Optional()],
)
signature = BooleanField(
translate("task_orders.sign.digital_signature_label"),
description=translate("task_orders.sign.digital_signature_description"),
validators=[Required()],
)

View File

@ -17,6 +17,14 @@ class Application(Base, mixins.TimestampsMixin, mixins.AuditableMixin):
portfolio = relationship("Portfolio") portfolio = relationship("Portfolio")
environments = relationship("Environment", back_populates="application") environments = relationship("Environment", back_populates="application")
@property
def users(self):
return set([user for env in self.environments for user in env.users])
@property
def num_users(self):
return len(self.users)
@property @property
def displayname(self): def displayname(self):
return self.name return self.name

View File

@ -2,7 +2,16 @@ from enum import Enum
from datetime import date from datetime import date
import pendulum import pendulum
from sqlalchemy import Column, Numeric, String, ForeignKey, Date, Integer from sqlalchemy import (
Column,
Numeric,
String,
ForeignKey,
Date,
Integer,
DateTime,
Boolean,
)
from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.types import ARRAY from sqlalchemy.types import ARRAY
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
@ -12,6 +21,7 @@ from atst.models import Attachment, Base, types, mixins
class Status(Enum): class Status(Enum):
STARTED = "Started"
PENDING = "Pending" PENDING = "Pending"
ACTIVE = "Active" ACTIVE = "Active"
EXPIRED = "Expired" EXPIRED = "Expired"
@ -51,8 +61,8 @@ class TaskOrder(Base, mixins.TimestampsMixin):
start_date = Column(Date) # Period of Performance start_date = Column(Date) # Period of Performance
end_date = Column(Date) end_date = Column(Date)
performance_length = Column(Integer) performance_length = Column(Integer)
attachment_id = Column(ForeignKey("attachments.id")) csp_attachment_id = Column(ForeignKey("attachments.id"))
_csp_estimate = relationship("Attachment") _csp_estimate = relationship("Attachment", foreign_keys=[csp_attachment_id])
clin_01 = Column(Numeric(scale=2)) clin_01 = Column(Numeric(scale=2))
clin_02 = Column(Numeric(scale=2)) clin_02 = Column(Numeric(scale=2))
clin_03 = Column(Numeric(scale=2)) clin_03 = Column(Numeric(scale=2))
@ -62,19 +72,28 @@ class TaskOrder(Base, mixins.TimestampsMixin):
ko_email = Column(String) # Email ko_email = Column(String) # Email
ko_phone_number = Column(String) # Phone Number ko_phone_number = Column(String) # Phone Number
ko_dod_id = Column(String) # DOD ID ko_dod_id = Column(String) # DOD ID
ko_invite = Column(Boolean)
cor_first_name = Column(String) # First Name cor_first_name = Column(String) # First Name
cor_last_name = Column(String) # Last Name cor_last_name = Column(String) # Last Name
cor_email = Column(String) # Email cor_email = Column(String) # Email
cor_phone_number = Column(String) # Phone Number cor_phone_number = Column(String) # Phone Number
cor_dod_id = Column(String) # DOD ID cor_dod_id = Column(String) # DOD ID
cor_invite = Column(Boolean)
so_first_name = Column(String) # First Name so_first_name = Column(String) # First Name
so_last_name = Column(String) # Last Name so_last_name = Column(String) # Last Name
so_email = Column(String) # Email so_email = Column(String) # Email
so_phone_number = Column(String) # Phone Number so_phone_number = Column(String) # Phone Number
so_dod_id = Column(String) # DOD ID so_dod_id = Column(String) # DOD ID
so_invite = Column(Boolean)
pdf_attachment_id = Column(ForeignKey("attachments.id"))
_pdf = relationship("Attachment", foreign_keys=[pdf_attachment_id])
number = Column(String, unique=True) # Task Order Number number = Column(String, unique=True) # Task Order Number
loa = Column(String) # Line of Accounting (LOA) loa = Column(String) # Line of Accounting (LOA)
custom_clauses = Column(String) # Custom Clauses custom_clauses = Column(String) # Custom Clauses
signer_dod_id = Column(String)
signed_at = Column(DateTime)
level_of_warrant = Column(Numeric(scale=2))
unlimited_level_of_warrant = Column(Boolean)
@hybrid_property @hybrid_property
def csp_estimate(self): def csp_estimate(self):
@ -82,26 +101,38 @@ class TaskOrder(Base, mixins.TimestampsMixin):
@csp_estimate.setter @csp_estimate.setter
def csp_estimate(self, new_csp_estimate): def csp_estimate(self, new_csp_estimate):
if isinstance(new_csp_estimate, Attachment): self._csp_estimate = self._set_attachment(new_csp_estimate, "_csp_estimate")
self._csp_estimate = new_csp_estimate
elif isinstance(new_csp_estimate, FileStorage): @hybrid_property
self._csp_estimate = Attachment.attach( def pdf(self):
new_csp_estimate, "task_order", self.id return self._pdf
)
elif not new_csp_estimate and self._csp_estimate: @pdf.setter
self._csp_estimate = None def pdf(self, new_pdf):
elif new_csp_estimate: self._pdf = self._set_attachment(new_pdf, "_pdf")
raise TypeError("Could not set csp_estimate with invalid type")
def _set_attachment(self, new_attachment, attribute):
if isinstance(new_attachment, Attachment):
return new_attachment
elif isinstance(new_attachment, FileStorage):
return Attachment.attach(new_attachment, "task_order", self.id)
elif not new_attachment and hasattr(self, attribute):
return None
else:
raise TypeError("Could not set attachment with invalid type")
@property @property
def is_submitted(self): def is_submitted(self):
return ( return (
self.number is not None self.number is not None
and self.start_date is not None and self.start_date is not None
and self.end_date is not None and self.end_date is not None
) )
@property
def is_active(self):
return self.status == Status.ACTIVE
@property @property
def status(self): def status(self):
if self.is_submitted: if self.is_submitted:
@ -112,7 +143,7 @@ class TaskOrder(Base, mixins.TimestampsMixin):
return Status.EXPIRED return Status.EXPIRED
return Status.ACTIVE return Status.ACTIVE
else: else:
return Status.PENDING return Status.STARTED
@property @property
def display_status(self): def display_status(self):
@ -142,6 +173,44 @@ class TaskOrder(Base, mixins.TimestampsMixin):
def is_pending(self): def is_pending(self):
return self.status == Status.PENDING return self.status == Status.PENDING
@property
def ko_invitable(self):
"""
The MO has indicated that the KO should be invited but we have not sent
an invite and attached the KO user
"""
return self.ko_invite and not self.contracting_officer
@property
def cor_invitable(self):
"""
The MO has indicated that the COR should be invited but we have not sent
an invite and attached the COR user
"""
return self.cor_invite and not self.contracting_officer_representative
@property
def so_invitable(self):
"""
The MO has indicated that the SO should be invited but we have not sent
an invite and attached the SO user
"""
return self.so_invite and not self.security_officer
_OFFICER_PREFIXES = {
"contracting_officer": "ko",
"contracting_officer_representative": "cor",
"security_officer": "so",
}
_OFFICER_PROPERTIES = ["first_name", "last_name", "phone_number", "email", "dod_id"]
def officer_dictionary(self, officer_type):
prefix = self._OFFICER_PREFIXES[officer_type]
return {
field: getattr(self, "{}_{}".format(prefix, field))
for field in self._OFFICER_PROPERTIES
}
def to_dictionary(self): def to_dictionary(self):
return { return {
"portfolio_name": self.portfolio_name, "portfolio_name": self.portfolio_name,

View File

@ -1,5 +1,14 @@
import urllib.parse as url import urllib.parse as url
from flask import Blueprint, render_template, g, redirect, session, url_for, request from flask import (
Blueprint,
render_template,
g,
redirect,
session,
url_for,
request,
make_response,
)
from flask import current_app as app from flask import current_app as app
from jinja2.exceptions import TemplateNotFound from jinja2.exceptions import TemplateNotFound
@ -56,7 +65,7 @@ def home():
num_portfolios = len([role for role in user.portfolio_roles if role.is_active]) num_portfolios = len([role for role in user.portfolio_roles if role.is_active])
if num_portfolios == 0: if num_portfolios == 0:
return redirect(url_for("requests.requests_index")) return redirect(url_for("portfolios.portfolios"))
elif num_portfolios == 1: elif num_portfolios == 1:
portfolio_role = user.portfolio_roles[0] portfolio_role = user.portfolio_roles[0]
portfolio_id = portfolio_role.portfolio.id portfolio_id = portfolio_role.portfolio.id
@ -131,7 +140,9 @@ def login_redirect():
@bp.route("/logout") @bp.route("/logout")
def logout(): def logout():
_logout() _logout()
return redirect(url_for(".root")) response = make_response(redirect(url_for(".root")))
response.set_cookie("expandSidenav", "", expires=0)
return response
@bp.route("/activity-history") @bp.route("/activity-history")

View File

@ -1,4 +1,5 @@
from flask import Blueprint, request as http_request, g, render_template from flask import Blueprint, request as http_request, g, render_template
from operator import attrgetter
portfolios_bp = Blueprint("portfolios", __name__) portfolios_bp = Blueprint("portfolios", __name__)
@ -31,4 +32,24 @@ def portfolio():
) )
return False return False
return {"portfolio": portfolio, "permissions": Permissions, "user_can": user_can} if not portfolio is None:
active_task_orders = [
task_order for task_order in portfolio.task_orders if task_order.is_active
]
funding_end_date = (
sorted(active_task_orders, key=attrgetter("end_date"))[-1].end_date
if active_task_orders
else None
)
funded = len(active_task_orders) > 1
else:
funding_end_date = None
funded = None
return {
"portfolio": portfolio,
"permissions": Permissions,
"user_can": user_can,
"funding_end_date": funding_end_date,
"funded": funded,
}

View File

@ -15,14 +15,27 @@ from atst.models.permissions import Permissions
@portfolios_bp.route("/portfolios") @portfolios_bp.route("/portfolios")
def portfolios(): def portfolios():
portfolios = Portfolios.for_user(g.current_user) portfolios = Portfolios.for_user(g.current_user)
if portfolios:
return render_template("portfolios/index.html", page=5, portfolios=portfolios) return render_template("portfolios/index.html", page=5, portfolios=portfolios)
else:
return render_template("portfolios/blank_slate.html")
@portfolios_bp.route("/portfolios/<portfolio_id>/edit") @portfolios_bp.route("/portfolios/<portfolio_id>/admin")
def portfolio(portfolio_id): def portfolio_admin(portfolio_id):
portfolio = Portfolios.get_for_update_information(g.current_user, portfolio_id) portfolio = Portfolios.get_for_update_information(g.current_user, portfolio_id)
form = PortfolioForm(data={"name": portfolio.name}) form = PortfolioForm(data={"name": portfolio.name})
return render_template("portfolios/edit.html", form=form, portfolio=portfolio) pagination_opts = Paginator.get_pagination_opts(http_request)
audit_events = AuditLog.get_portfolio_events(
g.current_user, portfolio, pagination_opts
)
return render_template(
"portfolios/admin.html",
form=form,
portfolio=portfolio,
audit_events=audit_events,
)
@portfolios_bp.route("/portfolios/<portfolio_id>/edit", methods=["POST"]) @portfolios_bp.route("/portfolios/<portfolio_id>/edit", methods=["POST"])
@ -62,9 +75,11 @@ def portfolio_reports(portfolio_id):
prev_month = current_month - timedelta(days=28) prev_month = current_month - timedelta(days=28)
two_months_ago = prev_month - timedelta(days=28) two_months_ago = prev_month - timedelta(days=28)
expiration_date = ( task_order = next(
portfolio.legacy_task_order and portfolio.legacy_task_order.expiration_date (task_order for task_order in portfolio.task_orders if task_order.is_active),
None,
) )
expiration_date = task_order and task_order.end_date
if expiration_date: if expiration_date:
remaining_difference = expiration_date - today remaining_difference = expiration_date - today
remaining_days = remaining_difference.days remaining_days = remaining_difference.days
@ -76,8 +91,7 @@ def portfolio_reports(portfolio_id):
cumulative_budget=Reports.cumulative_budget(portfolio), cumulative_budget=Reports.cumulative_budget(portfolio),
portfolio_totals=Reports.portfolio_totals(portfolio), portfolio_totals=Reports.portfolio_totals(portfolio),
monthly_totals=Reports.monthly_totals(portfolio), monthly_totals=Reports.monthly_totals(portfolio),
jedi_request=portfolio.request, task_order=task_order,
legacy_task_order=portfolio.legacy_task_order,
current_month=current_month, current_month=current_month,
prev_month=prev_month, prev_month=prev_month,
two_months_ago=two_months_ago, two_months_ago=two_months_ago,

View File

@ -23,26 +23,25 @@ from atst.models.permissions import Permissions
from atst.utils.flash import formatted_flash as flash from atst.utils.flash import formatted_flash as flash
def serialize_portfolio_role(portfolio_role):
return {
"name": portfolio_role.user_name,
"status": portfolio_role.display_status,
"id": portfolio_role.user_id,
"role": portfolio_role.role_displayname,
"num_env": portfolio_role.num_environment_roles,
"edit_link": url_for(
"portfolios.view_member",
portfolio_id=portfolio_role.portfolio_id,
member_id=portfolio_role.user_id,
),
}
@portfolios_bp.route("/portfolios/<portfolio_id>/members") @portfolios_bp.route("/portfolios/<portfolio_id>/members")
def portfolio_members(portfolio_id): def portfolio_members(portfolio_id):
portfolio = Portfolios.get_with_members(g.current_user, portfolio_id) portfolio = Portfolios.get_with_members(g.current_user, portfolio_id)
new_member_name = http_request.args.get("newMemberName") members_list = [serialize_portfolio_role(k) for k in portfolio.members]
new_member = next(
filter(lambda m: m.user_name == new_member_name, portfolio.members), None
)
members_list = [
{
"name": k.user_name,
"status": k.display_status,
"id": k.user_id,
"role": k.role_displayname,
"num_env": k.num_environment_roles,
"edit_link": url_for(
"portfolios.view_member", portfolio_id=portfolio.id, member_id=k.user_id
),
}
for k in portfolio.members
]
return render_template( return render_template(
"portfolios/members/index.html", "portfolios/members/index.html",
@ -50,7 +49,21 @@ def portfolio_members(portfolio_id):
role_choices=PORTFOLIO_ROLE_DEFINITIONS, role_choices=PORTFOLIO_ROLE_DEFINITIONS,
status_choices=MEMBER_STATUS_CHOICES, status_choices=MEMBER_STATUS_CHOICES,
members=members_list, members=members_list,
new_member=new_member, )
@portfolios_bp.route("/portfolios/<portfolio_id>/applications/<application_id>/members")
def application_members(portfolio_id, application_id):
portfolio = Portfolios.get_with_members(g.current_user, portfolio_id)
application = Applications.get(g.current_user, portfolio, application_id)
# TODO: this should show only members that have env roles in this application
members_list = [serialize_portfolio_role(k) for k in portfolio.members]
return render_template(
"portfolios/applications/members.html",
portfolio=portfolio,
application=application,
members=members_list,
) )
@ -76,7 +89,7 @@ def create_member(portfolio_id):
) )
invite_service.invite() invite_service.invite()
flash("new_portfolio_member", new_member=new_member, portfolio=portfolio) flash("new_portfolio_member", new_member=member, portfolio=portfolio)
return redirect( return redirect(
url_for("portfolios.portfolio_members", portfolio_id=portfolio.id) url_for("portfolios.portfolio_members", portfolio_id=portfolio.id)

View File

@ -1,5 +1,4 @@
from collections import defaultdict from collections import defaultdict
from operator import itemgetter
from flask import g, redirect, render_template, url_for, request as http_request from flask import g, redirect, render_template, url_for, request as http_request
@ -41,22 +40,17 @@ def portfolio_funding(portfolio_id):
task_orders_by_status[task_order.status].append(serialized_task_order) task_orders_by_status[task_order.status].append(serialized_task_order)
active_task_orders = task_orders_by_status.get(TaskOrderStatus.ACTIVE, []) active_task_orders = task_orders_by_status.get(TaskOrderStatus.ACTIVE, [])
funding_end_date = (
sorted(active_task_orders, key=itemgetter("end_date"))[-1]["end_date"]
if active_task_orders
else None
)
funded = len(active_task_orders) > 1
total_balance = sum([task_order["balance"] for task_order in active_task_orders]) total_balance = sum([task_order["balance"] for task_order in active_task_orders])
return render_template( return render_template(
"portfolios/task_orders/index.html", "portfolios/task_orders/index.html",
portfolio=portfolio, portfolio=portfolio,
pending_task_orders=task_orders_by_status.get(TaskOrderStatus.PENDING, []), pending_task_orders=(
task_orders_by_status.get(TaskOrderStatus.STARTED, [])
+ task_orders_by_status.get(TaskOrderStatus.PENDING, [])
),
active_task_orders=active_task_orders, active_task_orders=active_task_orders,
expired_task_orders=task_orders_by_status.get(TaskOrderStatus.EXPIRED, []), expired_task_orders=task_orders_by_status.get(TaskOrderStatus.EXPIRED, []),
funding_end_date=funding_end_date,
funded=funded,
total_balance=total_balance, total_balance=total_balance,
) )
@ -101,11 +95,7 @@ def submit_ko_review(portfolio_id, task_order_id, form=None):
if form.validate(): if form.validate():
TaskOrders.update(user=g.current_user, task_order=task_order, **form.data) TaskOrders.update(user=g.current_user, task_order=task_order, **form.data)
return redirect( return redirect(
url_for( url_for("task_orders.signature_requested", task_order_id=task_order_id)
"portfolios.view_task_order",
portfolio_id=portfolio_id,
task_order_id=task_order_id,
)
) )
else: else:
return render_template( return render_template(

View File

@ -5,3 +5,4 @@ task_orders_bp = Blueprint("task_orders", __name__)
from . import new from . import new
from . import index from . import index
from . import invite from . import invite
from . import signing

View File

@ -20,20 +20,29 @@ def download_summary(task_order_id):
) )
def send_file(attachment):
generator = app.csp.files.download(attachment.object_name)
return Response(
generator,
headers={
"Content-Disposition": "attachment; filename={}".format(attachment.filename)
},
)
@task_orders_bp.route("/task_orders/csp_estimate/<task_order_id>") @task_orders_bp.route("/task_orders/csp_estimate/<task_order_id>")
def download_csp_estimate(task_order_id): def download_csp_estimate(task_order_id):
task_order = TaskOrders.get(g.current_user, task_order_id) task_order = TaskOrders.get(g.current_user, task_order_id)
if task_order.csp_estimate: if task_order.csp_estimate:
estimate = task_order.csp_estimate return send_file(task_order.csp_estimate)
generator = app.csp.files.download(estimate.object_name)
return Response(
generator,
headers={
"Content-Disposition": "attachment; filename={}".format(
estimate.filename
)
},
)
else: else:
raise NotFoundError("task_order CSP estimate") raise NotFoundError("task_order CSP estimate")
@task_orders_bp.route("/task_orders/pdf/<task_order_id>")
def download_task_order_pdf(task_order_id):
task_order = TaskOrders.get(g.current_user, task_order_id)
if task_order.pdf:
return send_file(task_order.pdf)
else:
raise NotFoundError("task_order pdf")

View File

@ -3,11 +3,57 @@ from flask import redirect, url_for, g
from . import task_orders_bp from . import task_orders_bp
from atst.domain.task_orders import TaskOrders from atst.domain.task_orders import TaskOrders
from atst.utils.flash import formatted_flash as flash from atst.utils.flash import formatted_flash as flash
from atst.domain.portfolio_roles import PortfolioRoles
from atst.services.invitation import Invitation as InvitationService
OFFICER_INVITATIONS = [
{
"field": "ko_invite",
"role": "contracting_officer",
"subject": "Review a task order",
"template": "emails/invitation.txt",
},
{
"field": "cor_invite",
"role": "contracting_officer_representative",
"subject": "Help with a task order",
"template": "emails/invitation.txt",
},
{
"field": "so_invite",
"role": "security_officer",
"subject": "Review security for a task order",
"template": "emails/invitation.txt",
},
]
def update_officer_invitations(user, task_order):
for officer_type in OFFICER_INVITATIONS:
field = officer_type["field"]
if getattr(task_order, field) and not getattr(task_order, officer_type["role"]):
officer_data = task_order.officer_dictionary(officer_type["role"])
officer = TaskOrders.add_officer(
user, task_order, officer_type["role"], officer_data
)
pf_officer_member = PortfolioRoles.get(task_order.portfolio.id, officer.id)
invite_service = InvitationService(
user,
pf_officer_member,
officer_data["email"],
subject=officer_type["subject"],
email_template=officer_type["template"],
)
invite_service.invite()
@task_orders_bp.route("/task_orders/invite/<task_order_id>", methods=["POST"]) @task_orders_bp.route("/task_orders/invite/<task_order_id>", methods=["POST"])
def invite(task_order_id): def invite(task_order_id):
task_order = TaskOrders.get(g.current_user, task_order_id) task_order = TaskOrders.get(g.current_user, task_order_id)
if TaskOrders.all_sections_complete(task_order):
update_officer_invitations(g.current_user, task_order)
portfolio = task_order.portfolio portfolio = task_order.portfolio
flash("task_order_congrats", portfolio=portfolio) flash("task_order_congrats", portfolio=portfolio)
return redirect( return redirect(
@ -17,3 +63,8 @@ def invite(task_order_id):
task_order_id=task_order.id, task_order_id=task_order.id,
) )
) )
else:
flash("task_order_incomplete")
return redirect(
url_for("task_orders.new", screen=4, task_order_id=task_order.id)
)

View File

@ -12,9 +12,7 @@ from flask import (
from . import task_orders_bp from . import task_orders_bp
from atst.domain.task_orders import TaskOrders from atst.domain.task_orders import TaskOrders
from atst.domain.portfolios import Portfolios from atst.domain.portfolios import Portfolios
from atst.domain.portfolio_roles import PortfolioRoles
import atst.forms.task_order as task_order_form import atst.forms.task_order as task_order_form
from atst.services.invitation import Invitation as InvitationService
TASK_ORDER_SECTIONS = [ TASK_ORDER_SECTIONS = [
@ -173,7 +171,7 @@ class UpdateTaskOrderWorkflow(ShowTaskOrderWorkflow):
def validate(self): def validate(self):
return self.form.validate() return self.form.validate()
def _update_task_order(self): def update(self):
if self.task_order: if self.task_order:
if "portfolio_name" in self.form.data: if "portfolio_name" in self.form.data:
new_name = self.form.data["portfolio_name"] new_name = self.form.data["portfolio_name"]
@ -189,65 +187,6 @@ class UpdateTaskOrderWorkflow(ShowTaskOrderWorkflow):
self._task_order = TaskOrders.create(portfolio=pf, creator=self.user) self._task_order = TaskOrders.create(portfolio=pf, creator=self.user)
TaskOrders.update(self.user, self.task_order, **self.task_order_form_data) TaskOrders.update(self.user, self.task_order, **self.task_order_form_data)
OFFICER_INVITATIONS = [
{
"field": "ko_invite",
"prefix": "ko",
"role": "contracting_officer",
"subject": "Review a task order",
"template": "emails/invitation.txt",
},
{
"field": "cor_invite",
"prefix": "cor",
"role": "contracting_officer_representative",
"subject": "Help with a task order",
"template": "emails/invitation.txt",
},
{
"field": "so_invite",
"prefix": "so",
"role": "security_officer",
"subject": "Review security for a task order",
"template": "emails/invitation.txt",
},
]
def _update_officer_invitations(self):
for officer_type in self.OFFICER_INVITATIONS:
field = officer_type["field"]
if (
hasattr(self.form, field)
and self.form[field].data
and not getattr(self.task_order, officer_type["role"])
):
prefix = officer_type["prefix"]
officer_data = {
field: getattr(self.task_order, prefix + "_" + field)
for field in [
"first_name",
"last_name",
"email",
"phone_number",
"dod_id",
]
}
officer = TaskOrders.add_officer(
self.user, self.task_order, officer_type["role"], officer_data
)
pf_officer_member = PortfolioRoles.get(self.portfolio.id, officer.id)
invite_service = InvitationService(
self.user,
pf_officer_member,
officer_data["email"],
subject=officer_type["subject"],
email_template=officer_type["template"],
)
invite_service.invite()
def update(self):
self._update_task_order()
self._update_officer_invitations()
return self.task_order return self.task_order

View File

@ -0,0 +1,74 @@
from flask import url_for, redirect, render_template, g, request as http_request
import datetime
from . import task_orders_bp
from atst.domain.authz import Authorization
from atst.domain.exceptions import NotFoundError
from atst.domain.task_orders import TaskOrders
from atst.forms.task_order import SignatureForm
from atst.utils.flash import formatted_flash as flash
def find_unsigned_ko_to(task_order_id):
task_order = TaskOrders.get(g.current_user, task_order_id)
Authorization.check_is_ko(g.current_user, task_order)
if task_order.signer_dod_id is not None:
raise NotFoundError("task_order")
return task_order
@task_orders_bp.route("/task_orders/<task_order_id>/digital_signature", methods=["GET"])
def signature_requested(task_order_id):
task_order = find_unsigned_ko_to(task_order_id)
return render_template(
"task_orders/signing/signature_requested.html",
task_order_id=task_order.id,
form=SignatureForm(),
)
@task_orders_bp.route(
"/task_orders/<task_order_id>/digital_signature", methods=["POST"]
)
def record_signature(task_order_id):
task_order = find_unsigned_ko_to(task_order_id)
form_data = {**http_request.form}
if "unlimited_level_of_warrant" in form_data and form_data[
"unlimited_level_of_warrant"
] == ["y"]:
del form_data["level_of_warrant"]
form = SignatureForm(form_data)
if form.validate():
TaskOrders.update(
user=g.current_user,
task_order=task_order,
signer_dod_id=g.current_user.dod_id,
signed_at=datetime.datetime.now(),
**form.data,
)
flash("task_order_signed")
return redirect(
url_for(
"portfolios.view_task_order",
portfolio_id=task_order.portfolio_id,
task_order_id=task_order.id,
)
)
else:
return (
render_template(
"task_orders/signing/signature_requested.html",
task_order_id=task_order_id,
form=form,
),
400,
)

View File

@ -1,6 +1,13 @@
from flask import flash, render_template_string from flask import flash, render_template_string
MESSAGES = { MESSAGES = {
"task_order_signed": {
"title_template": "Task Order Signed",
"message_template": """
<p>Task order has been signed successfully</p>
""",
"category": "success",
},
"new_portfolio_member": { "new_portfolio_member": {
"title_template": "Member added successfully", "title_template": "Member added successfully",
"message_template": """ "message_template": """
@ -128,6 +135,13 @@ MESSAGES = {
""", """,
"category": "success", "category": "success",
}, },
"task_order_incomplete": {
"title_template": "Task Order Incomplete",
"message_template": """
You must complete your Task Order form before submitting.
""",
"category": "error",
},
} }

View File

@ -1,4 +1,5 @@
from flask.json import JSONEncoder from flask.json import JSONEncoder
from werkzeug.datastructures import FileStorage
from datetime import date from datetime import date
from atst.models.attachment import Attachment from atst.models.attachment import Attachment
@ -7,6 +8,8 @@ class CustomJSONEncoder(JSONEncoder):
def default(self, obj): def default(self, obj):
if isinstance(obj, Attachment): if isinstance(obj, Attachment):
return obj.filename return obj.filename
if isinstance(obj, date): elif isinstance(obj, date):
return obj.strftime("%Y-%m-%d") return obj.strftime("%Y-%m-%d")
elif isinstance(obj, FileStorage):
return obj.filename
return JSONEncoder.default(self, obj) return JSONEncoder.default(self, obj)

View File

@ -4,6 +4,7 @@ import { conformToMask } from 'vue-text-mask'
import FormMixin from '../../mixins/form' import FormMixin from '../../mixins/form'
import textinput from '../text_input' import textinput from '../text_input'
import optionsinput from '../options_input' import optionsinput from '../options_input'
import uploadinput from '../upload_input'
export default { export default {
name: 'funding', name: 'funding',
@ -13,6 +14,7 @@ export default {
components: { components: {
textinput, textinput,
optionsinput, optionsinput,
uploadinput,
}, },
props: { props: {
@ -32,7 +34,6 @@ export default {
clin_02 = 0, clin_02 = 0,
clin_03 = 0, clin_03 = 0,
clin_04 = 0, clin_04 = 0,
csp_estimate,
} = this.initialData } = this.initialData
return { return {
@ -40,7 +41,6 @@ export default {
clin_02, clin_02,
clin_03, clin_03,
clin_04, clin_04,
showUpload: !csp_estimate || this.uploadErrors.length > 0,
} }
}, },
@ -63,9 +63,6 @@ export default {
}, },
methods: { methods: {
showUploadInput: function() {
this.showUpload = true
},
updateBudget: function() { updateBudget: function() {
document.querySelector('#to-target').innerText = this.totalBudgetStr document.querySelector('#to-target').innerText = this.totalBudgetStr
}, },

View File

@ -0,0 +1,27 @@
import textinput from './text_input'
import checkboxinput from './checkbox_input'
import FormMixin from '../mixins/form'
export default {
mixins: [FormMixin],
components: {
textinput,
checkboxinput,
},
props: {
initialData: {
type: Object,
default: () => ({}),
},
},
data() {
const { unlimited_level_of_warrant = false } = this.initialData
return {
unlimited_level_of_warrant,
}
},
}

View File

@ -61,8 +61,14 @@ export default {
props: { props: {
members: Array, members: Array,
role_choices: Array, role_choices: {
status_choices: Array, type: Array,
default: () => [],
},
status_choices: {
type: Array,
default: () => [],
},
}, },
data: function() { data: function() {
@ -87,7 +93,7 @@ export default {
displayName: 'Environments', displayName: 'Environments',
attr: 'num_env', attr: 'num_env',
sortFunc: numericSort, sortFunc: numericSort,
class: 'table-cell--align-right', class: 'table-cell--align-center',
}, },
{ {
displayName: 'Status', displayName: 'Status',

View File

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

View File

@ -84,6 +84,10 @@ export default {
} }
}, },
onBlur: function(e) {
this._checkIfValid({ value: e.target.value, invalidate: true })
},
// //
_checkIfValid: function({ value, invalidate = false }) { _checkIfValid: function({ value, invalidate = false }) {
// Validate the value // Validate the value

View File

@ -1,32 +1,14 @@
import ToggleMixin from '../mixins/toggle'
export default { export default {
name: 'toggler', name: 'toggler',
mixins: [ToggleMixin],
props: { props: {
defaultVisible: { defaultVisible: {
type: Boolean, type: Boolean,
default: () => false, default: () => false,
}, },
}, },
data: function() {
return {
isVisible: this.defaultVisible,
}
},
render: function(createElement) {
return createElement(this.$vnode.data.tag, [
this.$scopedSlots.default({
isVisible: this.isVisible,
toggle: this.toggle,
}),
])
},
methods: {
toggle: function(e) {
e.preventDefault()
this.isVisible = !this.isVisible
},
},
} }

View File

@ -0,0 +1,41 @@
import createNumberMask from 'text-mask-addons/dist/createNumberMask'
import { conformToMask } from 'vue-text-mask'
import FormMixin from '../mixins/form'
import textinput from './text_input'
import optionsinput from './options_input'
export default {
name: 'uploadinput',
mixins: [FormMixin],
components: {
textinput,
optionsinput,
},
props: {
initialData: {
type: String,
},
uploadErrors: {
type: Array,
default: () => [],
},
},
data: function() {
const pdf = this.initialData
return {
showUpload: !pdf || this.uploadErrors.length > 0,
}
},
methods: {
showUploadInput: function() {
this.showUpload = true
},
},
}

View File

@ -6,6 +6,7 @@ import classes from '../styles/atat.scss'
import Vue from 'vue/dist/vue' import Vue from 'vue/dist/vue'
import VTooltip from 'v-tooltip' import VTooltip from 'v-tooltip'
import levelofwarrant from './components/levelofwarrant'
import optionsinput from './components/options_input' import optionsinput from './components/options_input'
import multicheckboxinput from './components/multi_checkbox_input' import multicheckboxinput from './components/multi_checkbox_input'
import textinput from './components/text_input' import textinput from './components/text_input'
@ -20,6 +21,7 @@ import NewApplication from './components/forms/new_application'
import EditEnvironmentRole from './components/forms/edit_environment_role' import EditEnvironmentRole from './components/forms/edit_environment_role'
import EditApplicationRoles from './components/forms/edit_application_roles' import EditApplicationRoles from './components/forms/edit_application_roles'
import funding from './components/forms/funding' import funding from './components/forms/funding'
import uploadinput from './components/upload_input'
import Modal from './mixins/modal' import Modal from './mixins/modal'
import selector from './components/selector' import selector from './components/selector'
import BudgetChart from './components/charts/budget_chart' import BudgetChart from './components/charts/budget_chart'
@ -32,6 +34,7 @@ import RequestsList from './components/requests_list'
import ConfirmationPopover from './components/confirmation_popover' import ConfirmationPopover from './components/confirmation_popover'
import { isNotInVerticalViewport } from './lib/viewport' import { isNotInVerticalViewport } from './lib/viewport'
import DateSelector from './components/date_selector' import DateSelector from './components/date_selector'
import SidenavToggler from './components/sidenav_toggler'
Vue.config.productionTip = false Vue.config.productionTip = false
@ -43,6 +46,7 @@ const app = new Vue({
el: '#app-root', el: '#app-root',
components: { components: {
toggler, toggler,
levelofwarrant,
optionsinput, optionsinput,
multicheckboxinput, multicheckboxinput,
textinput, textinput,
@ -64,8 +68,10 @@ const app = new Vue({
RequestsList, RequestsList,
ConfirmationPopover, ConfirmationPopover,
funding, funding,
uploadinput,
DateSelector, DateSelector,
EditOfficerForm, EditOfficerForm,
SidenavToggler,
}, },
mounted: function() { mounted: function() {

View File

@ -5,6 +5,10 @@ export const formatDollars = (value, cents = true) => {
currency: 'USD', currency: 'USD',
}) })
} else if (typeof value === 'string') { } else if (typeof value === 'string') {
if (value === '') {
return value
}
return parseFloat(value).toLocaleString('us-US', { return parseFloat(value).toLocaleString('us-US', {
style: 'currency', style: 'currency',
currency: 'USD', currency: 'USD',

23
js/mixins/toggle.js Normal file
View File

@ -0,0 +1,23 @@
export default {
data: function() {
return {
isVisible: this.defaultVisible,
}
},
render: function(createElement) {
return createElement(this.$vnode.data.tag, [
this.$scopedSlots.default({
isVisible: this.isVisible,
toggle: this.toggle,
}),
])
},
methods: {
toggle: function(e) {
e.preventDefault()
this.isVisible = !this.isVisible
},
},
}

View File

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="angle-double-left" class="svg-inline--fa fa-angle-double-left fa-w-14" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M223.7 239l136-136c9.4-9.4 24.6-9.4 33.9 0l22.6 22.6c9.4 9.4 9.4 24.6 0 33.9L319.9 256l96.4 96.4c9.4 9.4 9.4 24.6 0 33.9L393.7 409c-9.4 9.4-24.6 9.4-33.9 0l-136-136c-9.5-9.4-9.5-24.6-.1-34zm-192 34l136 136c9.4 9.4 24.6 9.4 33.9 0l22.6-22.6c9.4-9.4 9.4-24.6 0-33.9L127.9 256l96.4-96.4c9.4-9.4 9.4-24.6 0-33.9L201.7 103c-9.4-9.4-24.6-9.4-33.9 0l-136 136c-9.5 9.4-9.5 24.6-.1 34z"></path></svg>

After

Width:  |  Height:  |  Size: 630 B

View File

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="angle-double-right" class="svg-inline--fa fa-angle-double-right fa-w-14" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M224.3 273l-136 136c-9.4 9.4-24.6 9.4-33.9 0l-22.6-22.6c-9.4-9.4-9.4-24.6 0-33.9l96.4-96.4-96.4-96.4c-9.4-9.4-9.4-24.6 0-33.9L54.3 103c9.4-9.4 24.6-9.4 33.9 0l136 136c9.5 9.4 9.5 24.6.1 34zm192-34l-136-136c-9.4-9.4-24.6-9.4-33.9 0l-22.6 22.6c-9.4 9.4-9.4 24.6 0 33.9l96.4 96.4-96.4 96.4c-9.4 9.4-9.4 24.6 0 33.9l22.6 22.6c9.4 9.4 24.6 9.4 33.9 0l136-136c9.4-9.2 9.4-24.4 0-33.8z"></path></svg>

After

Width:  |  Height:  |  Size: 634 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 544 512"><path d="M527.79 288H290.5l158.03 158.03c6.04 6.04 15.98 6.53 22.19.68 38.7-36.46 65.32-85.61 73.13-140.86 1.34-9.46-6.51-17.85-16.06-17.85zm-15.83-64.8C503.72 103.74 408.26 8.28 288.8.04 279.68-.59 272 7.1 272 16.24V240h223.77c9.14 0 16.82-7.68 16.19-16.8zM224 288V50.71c0-9.55-8.39-17.4-17.84-16.06C86.99 51.49-4.1 155.6.14 280.37 4.5 408.51 114.83 513.59 243.03 511.98c50.4-.63 96.97-16.87 135.26-44.03 7.9-5.6 8.42-17.23 1.57-24.08L224 288z"/></svg>

After

Width:  |  Height:  |  Size: 515 B

1
static/icons/cog.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M487.4 315.7l-42.6-24.6c4.3-23.2 4.3-47 0-70.2l42.6-24.6c4.9-2.8 7.1-8.6 5.5-14-11.1-35.6-30-67.8-54.7-94.6-3.8-4.1-10-5.1-14.8-2.3L380.8 110c-17.9-15.4-38.5-27.3-60.8-35.1V25.8c0-5.6-3.9-10.5-9.4-11.7-36.7-8.2-74.3-7.8-109.2 0-5.5 1.2-9.4 6.1-9.4 11.7V75c-22.2 7.9-42.8 19.8-60.8 35.1L88.7 85.5c-4.9-2.8-11-1.9-14.8 2.3-24.7 26.7-43.6 58.9-54.7 94.6-1.7 5.4.6 11.2 5.5 14L67.3 221c-4.3 23.2-4.3 47 0 70.2l-42.6 24.6c-4.9 2.8-7.1 8.6-5.5 14 11.1 35.6 30 67.8 54.7 94.6 3.8 4.1 10 5.1 14.8 2.3l42.6-24.6c17.9 15.4 38.5 27.3 60.8 35.1v49.2c0 5.6 3.9 10.5 9.4 11.7 36.7 8.2 74.3 7.8 109.2 0 5.5-1.2 9.4-6.1 9.4-11.7v-49.2c22.2-7.9 42.8-19.8 60.8-35.1l42.6 24.6c4.9 2.8 11 1.9 14.8-2.3 24.7-26.7 43.6-58.9 54.7-94.6 1.5-5.5-.7-11.3-5.6-14.1zM256 336c-44.1 0-80-35.9-80-80s35.9-80 80-80 80 35.9 80 80-35.9 80-80 80z"/></svg>

After

Width:  |  Height:  |  Size: 890 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M464 64H48C21.49 64 0 85.49 0 112v288c0 26.51 21.49 48 48 48h416c26.51 0 48-21.49 48-48V112c0-26.51-21.49-48-48-48zm0 48v40.805c-22.422 18.259-58.168 46.651-134.587 106.49-16.841 13.247-50.201 45.072-73.413 44.701-23.208.375-56.579-31.459-73.413-44.701C106.18 199.465 70.425 171.067 48 152.805V112h416zM48 400V214.398c22.914 18.251 55.409 43.862 104.938 82.646 21.857 17.205 60.134 55.186 103.062 54.955 42.717.231 80.509-37.199 103.053-54.947 49.528-38.783 82.032-64.401 104.947-82.653V400H48z"/></svg>

After

Width:  |  Height:  |  Size: 574 B

1
static/icons/home.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path d="M280.37 148.26L96 300.11V464a16 16 0 0 0 16 16l112.06-.29a16 16 0 0 0 15.92-16V368a16 16 0 0 1 16-16h64a16 16 0 0 1 16 16v95.64a16 16 0 0 0 16 16.05L464 480a16 16 0 0 0 16-16V300L295.67 148.26a12.19 12.19 0 0 0-15.3 0zM571.6 251.47L488 182.56V44.05a12 12 0 0 0-12-12h-56a12 12 0 0 0-12 12v72.61L318.47 43a48 48 0 0 0-61 0L4.34 251.47a12 12 0 0 0-1.6 16.9l25.5 31A12 12 0 0 0 45.15 301l235.22-193.74a12.19 12.19 0 0 1 15.3 0L530.9 301a12 12 0 0 0 16.9-1.6l25.5-31a12 12 0 0 0-1.7-16.93z"/></svg>

After

Width:  |  Height:  |  Size: 565 B

1
static/icons/minus.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M416 208H32c-17.67 0-32 14.33-32 32v32c0 17.67 14.33 32 32 32h384c17.67 0 32-14.33 32-32v-32c0-17.67-14.33-32-32-32z"/></svg>

After

Width:  |  Height:  |  Size: 196 B

View File

@ -12,6 +12,7 @@
@import 'elements/buttons'; @import 'elements/buttons';
@import 'elements/panels'; @import 'elements/panels';
@import 'elements/block_lists'; @import 'elements/block_lists';
@import 'elements/accordians';
@import 'elements/tables'; @import 'elements/tables';
@import 'elements/sidenav'; @import 'elements/sidenav';
@import 'elements/action_group'; @import 'elements/action_group';
@ -23,6 +24,7 @@
@import 'elements/menu'; @import 'elements/menu';
@import 'components/topbar'; @import 'components/topbar';
@import 'components/top_message';
@import 'components/global_layout'; @import 'components/global_layout';
@import 'components/global_navigation'; @import 'components/global_navigation';
@import 'components/portfolio_layout'; @import 'components/portfolio_layout';

View File

@ -1,6 +1,6 @@
.app-footer { .app-footer {
background-color: $color-white; background-color: $color-white;
border-top: 1px solid $color-black; border-top: 1px solid $color-gray-lightest;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
@ -18,6 +18,7 @@
.app-footer__info__link { .app-footer__info__link {
margin: (-$gap * 2) (-$gap); margin: (-$gap * 2) (-$gap);
font-weight: normal;
.icon--footer { .icon--footer {
@include icon-size(16); @include icon-size(16);

View File

@ -1,5 +1,5 @@
#app-root { #app-root {
background-color: $color-gray-lightest; background-color: $color-offwhite;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: flex-start; justify-content: flex-start;

View File

@ -2,29 +2,16 @@
background-color: $color-white; background-color: $color-white;
.sidenav__link { .sidenav__link {
padding-right: $gap; padding-right: $gap * 2;
@include media($large-screen) { @include media($large-screen) {
padding-right: $gap * 2; padding-right: $gap * 2;
} }
} }
.sidenav__link-label {
@include hide;
@include media($large-screen) {
@include unhide;
padding-left: $gap;
}
}
&.global-navigation__context--portfolio { &.global-navigation__context--portfolio {
.sidenav__link { .sidenav__link {
padding-right: $gap; padding-right: $gap;
} }
.sidenav__link-label {
@include hide;
}
} }
} }

View File

@ -2,53 +2,137 @@
@include media($large-screen) { @include media($large-screen) {
@include grid-row; @include grid-row;
} }
margin-left: 2 * $gap;
.line {
box-sizing: border-box;
height: 2px;
width: 100%;
border: 1px solid $color-gray-lightest;
}
} }
.portfolio-navigation { .portfolio-breadcrumbs {
@include panel-margin; margin-bottom: $gap * 2;
margin-bottom: $gap * 4; color: $color-gray-medium;
font-size: $h5-font-size;
ul { .icon-link {
display: flex; color: $color-blue;
font-weight: normal;
}
.icon--tiny {
padding: $gap 0;
}
.icon {
@include icon-color($color-blue);
}
.portfolio-breadcrumbs__home {
&.icon-link--disabled {
color: $color-gray-medium;
opacity: 1;
.icon {
@include icon-color($color-gray-medium);
}
}
}
.portfolio-breadcrumbs__crumb {
.icon {
@include icon-color($color-gray-medium);
}
.icon-link {
color: $color-gray-medium;
pointer-events: none;
&.icon-link--disabled {
opacity: 1;
}
}
}
}
.portfolio-header {
flex-direction: column; flex-direction: column;
li { @include media($small-screen) {
flex-grow: 1; flex-direction: row;
}
} }
@include media($medium-screen) { margin: 2 * $gap;
margin-bottom: $gap * 5;
.portfolio-header__name {
@include h1;
} }
@include media($large-screen) { .portfolio-header__budget {
width: 20rem; font-size: $small-font-size;
margin-right: $gap * 2;
ul {
display: block;
}
}
}
.portfolio-funding {
.portfolio-funding__header {
padding: 0;
margin: 0 $gap;
align-items: center; align-items: center;
.portfolio-funding__header--funded-through { .icon-tooltip {
padding: 2 * $gap; margin-left: -$gap / 2;
flex-grow: 1; }
text-align: left;
button {
margin: 0;
padding: 0;
}
.portfolio-header__budget--dollars {
font-size: $h2-font-size;
font-weight: bold;
}
}
.links {
justify-content: center;
font-size: $small-font-size;
.icon-link {
&.active {
color: $color-gray;
.icon {
@include icon-color($color-gray);
}
}
.icon-link--icon {
text-align: center;
}
.icon {
@include icon-size(30);
}
}
}
.column-left {
width: 12.5rem;
float: left;
}
.column-right {
margin-left: -.4rem;
}
.portfolio-header__budget--amount {
white-space: nowrap;
}
.portfolio-header__budget--cents {
font-size: 2rem;
margin-top: .75rem;
margin-left: -.7rem;
font-weight: bold; font-weight: bold;
} }
.funded { .portfolio-funding__header--funded-through {
color: $color-green; flex-grow: 1;
.icon { text-align: left;
@include icon-color($color-green); font-weight: bold;
}
} }
.unfunded { .unfunded {
@ -57,6 +141,150 @@
@include icon-color($color-red); @include icon-color($color-red);
} }
} }
}
@mixin subheading {
color: $color-gray-dark;
padding: $gap 0;
text-transform: uppercase;
opacity: 0.54;
font-size: $small-font-size;
font-weight: bold;
margin-bottom: 3 * $gap;
}
.portfolio-content {
margin: 6 * $gap $gap 0 $gap;
.member-list {
.panel {
@include shadow-panel;
}
table {
box-shadow: 0 6px 18px 0 rgba(144,164,183,0.3);
thead {
th:first-child {
padding-left: 3 * $gap;
}
}
th {
background-color: $color-gray-lightest;
padding: $gap 2 * $gap;
border-top: none;
border-bottom: none;
color: $color-gray;
}
td {
border-bottom: 1px solid $color-gray-lightest;
}
.add-member-link {
text-align: right;
}
}
}
.application-content {
.subheading {
@include subheading;
}
.panel {
@include shadow-panel;
}
.application-list-item {
ul {
padding-left: 0;
}
.block-list__footer {
border-bottom: none;
}
.application-edit__env-list-item {
label {
color: $color-black;
}
}
}
}
.activity-log {
border-top: 3px solid $color-blue;
.subheading {
border-top: 0;
border-bottom: 1px solid $color-gray-lightest;
padding: 1.6rem 1.6rem;
font-weight: $font-bold;
}
}
}
.portfolio-applications {
.portfolio-applications__header {
.portfolio-applications__header--title {
@include subheading;
}
.portfolio-applications__header--actions {
color: $color-blue;
font-size: $small-font-size;
.icon {
@include icon-color($color-blue);
@include icon-size(14);
}
}
}
.application-list {
.toggle-link {
background-color: $color-blue-light;
.icon {
margin: $gap / 2;
}
}
.application-list-item {
border-radius: 5px;
box-shadow: 0 4px 8px 1px rgba(230,230,230,0.5), -4px 4px 8px 1px rgba(230,230,230,0.5);
margin-bottom: 6 * $gap;
.col {
max-width: 95%;
}
.application-list-item__environment__name {
}
.application-list-item__environment__csp_link {
font-size: $small-font-size;
font-weight: normal;
&:hover {
background-color: $color-aqua-light;
}
}
}
}
}
.portfolio-funding {
.panel {
@include shadow-panel;
}
.subheading {
@include subheading;
margin-top: 6 * $gap;
margin-bottom: 2 * $gap;
}
.portfolio-funding__header {
flex-direction: row-reverse;
} }
.pending-task-order { .pending-task-order {
@ -64,6 +292,7 @@
align-items: center; align-items: center;
margin: 0; margin: 0;
margin-bottom: 2 * $gap;
padding: 2 * $gap; padding: 2 * $gap;
dt { dt {
@ -96,34 +325,39 @@
} }
} }
.portfolio-total-balance { .total-balance {
margin-top: -$gap; margin-right: 2 * $gap;
margin-bottom: 3rem; text-align: right;
.row {
flex-direction: row-reverse;
margin: 2 * $gap 0;
padding-right: 14rem;
.label {
margin: 0 2 * $gap;
}
} }
.responsive-table-wrapper {
margin: 0 (-2 * $gap);
padding: 2 * $gap;
padding-top: 0;
} }
table { table {
th{ box-shadow: 0 6px 18px 0 rgba(144,164,183,0.3);
thead {
th:first-child {
padding-left: 3 * $gap;
}
}
th {
background-color: $color-gray-lightest;
padding: $gap 2 * $gap;
border-top: none;
border-bottom: none;
color: $color-gray;
.icon { .icon {
margin-left: 1rem; margin-left: 1rem;
} }
&.period-of-performance {
color: $color-blue;
.icon {
@include icon-color($color-primary)
}
} }
td {
border-bottom: 1px solid $color-gray-lightest;
} }
td.unused-balance { td.unused-balance {
@ -146,7 +380,6 @@
&.funded .to-expiration-alert { &.funded .to-expiration-alert {
color: $color-blue; color: $color-blue;
.icon { .icon {
@include icon-color($color-blue); @include icon-color($color-blue);
} }
@ -169,3 +402,39 @@
} }
} }
} }
.portfolio-reports {
.portfolio-reports__header {
margin-bottom: 4 * $gap;
.portfolio-reports__header--title {
@include subheading;
}
}
.panel {
@include shadow-panel;
margin-bottom: 4 * $gap;
}
}
.portfolio-admin {
.edit-portfolio-name.action-group {
margin-top: 2rem;
}
.form-row {
margin-bottom: 0;
.form-col {
.usa-input--validation--portfolioName {
input {
max-width: 30em;
}
.icon-validation {
left: 30em;
}
}
}
}
}

View File

@ -6,6 +6,9 @@
padding: $gap; padding: $gap;
flex-wrap: wrap; flex-wrap: wrap;
border-top: none;
border-bottom: none;
@media (min-width:1000px) { @media (min-width:1000px) {
flex-wrap: nowrap; flex-wrap: nowrap;
} }

View File

@ -0,0 +1,35 @@
.top-message {
padding: $gap * 2;
@include panel-margin;
@include media($medium-screen) {
padding: $gap * 4;
}
border-top-width: 1px;
border-bottom-width: 1px;
border-top-style: solid;
border-bottom-style: solid;
border-bottom-color: $color-gray-light;
.title {
padding-top: $gap * 2;
padding-bottom: $gap * 2;
padding-left: 0;
padding-right:0;
}
.list-title {
padding-top: $gap * 4;
font-weight: bold;
}
.list {
padding-left: $gap * 2.5;
margin-top: 0.5rem;
}
.list-item {
margin-bottom: 0.5rem;
}
}

View File

@ -43,6 +43,7 @@ $font-bold: 700;
$color-blue: #0071bc; $color-blue: #0071bc;
$color-blue-darker: #205493; $color-blue-darker: #205493;
$color-blue-darkest: #112e51; $color-blue-darkest: #112e51;
$color-blue-light: #e5f1ff;
$color-aqua: #02bfe7; $color-aqua: #02bfe7;
$color-aqua-dark: #00a6d2; $color-aqua-dark: #00a6d2;
@ -57,12 +58,13 @@ $color-red-light: #e59393;
$color-red-lightest: #f9dede; $color-red-lightest: #f9dede;
$color-white: #ffffff; $color-white: #ffffff;
$color-offwhite: #fbfbfd;
$color-black: #000000; $color-black: #000000;
$color-black-light: #212121; $color-black-light: #212121;
$color-gray-dark: #323a45; $color-gray-dark: #323a45;
$color-gray: #5b616b; $color-gray: #5b616b;
$color-gray-medium: #757575; $color-gray-medium: #9b9b9b;
$color-gray-light: #aeb0b5; $color-gray-light: #aeb0b5;
$color-gray-lighter: #d6d7d9; $color-gray-lighter: #d6d7d9;
$color-gray-lightest: #f1f1f1; $color-gray-lightest: #f1f1f1;
@ -83,7 +85,7 @@ $color-green-lighter: #94bfa2;
$color-green-lightest: #e7f4e4; $color-green-lightest: #e7f4e4;
$color-cool-blue: #205493; $color-cool-blue: #205493;
$color-cool-blue-light: #4773aa; $color-cool-blue-light: #4190e2;
$color-cool-blue-lighter: #8ba6ca; $color-cool-blue-lighter: #8ba6ca;
$color-cool-blue-lightest: #dce4ef; $color-cool-blue-lightest: #dce4ef;

View File

@ -0,0 +1,123 @@
.accordian {
@include block-list;
box-shadow: 0 4px 10px 0 rgba(193,193,193,0.5);
margin-bottom: 6 * $gap;
.icon-link {
margin: -$gap 0;
}
.icon-link,
.label {
&:first-child {
margin-left: -$gap;
}
&:last-child {
margin-right: -$gap;
}
}
}
.accordian__header {
@include block-list-header;
border-top: 3px solid $color-blue;
border-bottom: none;
box-shadow: 0 2px 4px 0 rgba(216,218,222,0.58);
&.row {
background: $color-white;
}
}
.accordian__title {
@include block-list__title;
color: $color-blue;
@include h3;
&.icon-link {
margin: 0;
display: block;
padding: 0 $gap;
text-decoration: none;
}
}
.accordian__description {
@include block-list__description;
font-style: italic;
font-size: $small-font-size;
color: $color-gray;
}
.accordian__actions {
margin-top: $gap;
display: flex;
flex-direction: row;
.icon-link {
font-size: $small-font-size;
}
.counter {
background-color: $color-cool-blue-light;
color: $color-white;
border-radius: 2px;
padding: $gap / 2 $gap;
margin-left: $gap;
}
.separator {
border: 1px solid $color-gray-medium;
opacity: 0.75;
margin: 0 .5 * $gap;
}
}
.accordian__item {
@include block-list-item;
opacity: 0.75;
background-color: $color-blue-light;
border-bottom: 1px solid rgba($color-gray-light, 0.5);
&.accordian__item--selectable {
> div {
display: flex;
flex-direction: row-reverse;
@include ie-only {
width: 100%;
}
> label {
@include block-list-selectable-label;
}
}
> label {
@include block-list-selectable-label;
}
input:checked {
+ label {
color: $color-primary;
}
}
@include ie-only {
dl {
width: 100%;
padding-left: $gap*4;
}
}
}
}
.accordian__footer {
@include block-list__footer;
border-top: 0;
}

View File

@ -1,5 +1,7 @@
@mixin block-list { @mixin block-list {
@include panel-margin; @include panel-margin;
@include shadow-panel
padding: 0;
ul, dl { ul, dl {
list-style: none; list-style: none;
@ -15,6 +17,9 @@
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
background-color: $color-gray-lightest;
padding: $gap 2 * $gap;
color: $color-gray;
.icon-tooltip { .icon-tooltip {
margin: -$gap; margin: -$gap;
@ -59,7 +64,7 @@
margin: 0; margin: 0;
padding: $gap * 2; padding: $gap * 2;
border-top: 0; border-top: 0;
border-bottom: 1px dashed $color-gray-light; border-bottom: 1px solid $color-gray-lightest;
@at-root li#{&} { @at-root li#{&} {
&:last-child { &:last-child {

View File

@ -47,6 +47,7 @@
.icon-link { .icon-link {
@include icon-link; @include icon-link;
@include icon-link-color($color-primary); @include icon-link-color($color-primary);
text-decoration: underline;
&.icon-link--vertical { &.icon-link--vertical {
@include icon-link-vertical; @include icon-link-vertical;
@ -67,6 +68,7 @@
&.icon-link--disabled { &.icon-link--disabled {
opacity: 0.3; opacity: 0.3;
pointer-events: none; pointer-events: none;
text-decoration: none;
} }
&.icon-link--left { &.icon-link--left {

View File

@ -67,7 +67,24 @@
@include icon-color($color-gray); @include icon-color($color-gray);
} }
&.icon--blue {
@include icon-color($color-blue-darker);
}
&.icon--medium { &.icon--medium {
@include icon-size(12); @include icon-size(12);
} }
&.icon--gold {
@include icon-color($color-gold-dark);
}
&.icon--circle {
svg {
border-radius: 100%;
border-style: solid;
border-width: 1px;
padding: 2px;
}
}
} }

View File

@ -12,7 +12,7 @@
@mixin input-state($state) { @mixin input-state($state) {
$border-width: 1px; $border-width: 1px;
$state-color: $color-gray; $state-color: $color-blue;
@if $state == 'error' { @if $state == 'error' {
$border-width: 2px; $border-width: 2px;
@ -283,6 +283,8 @@
} }
} }
@include input-state('default');
&.usa-input--error { &.usa-input--error {
@include input-state('error'); @include input-state('error');
} }

View File

@ -46,6 +46,13 @@
padding: $gap; padding: $gap;
} }
@mixin shadow-panel {
padding: $gap / 2 0;
box-shadow: 0 6px 18px 0 rgba(144,164,183,0.3);
border-top: none;
border-bottom: none;
}
.panel { .panel {
@include panel-base; @include panel-base;
@include panel-theme-default; @include panel-theme-default;

View File

@ -1,21 +1,40 @@
.sidenav { @mixin sidenav__header {
@include hide; padding: $gap ($gap * 2);
font-size: $small-font-size;
font-weight: bold;
}
.sidenav-container {
position: relative;
.global-navigation.sidenav {
height: 100%;
}
.sidenav {
@include media($large-screen) { @include media($large-screen) {
@include unhide;
width: 25rem;
margin: 0px; margin: 0px;
} }
width: 25rem;
box-shadow: 0 6px 18px 0 rgba(48,58,65,0.15); box-shadow: 0 6px 18px 0 rgba(48,58,65,0.15);
.sidenav__title { .sidenav__title {
color: $color-gray-dark; @include sidenav__header;
padding: $gap ($gap * 2);
text-transform: uppercase; text-transform: uppercase;
width: 50%;
color: $color-gray-dark;
opacity: 0.54; opacity: 0.54;
font-size: $small-font-size; }
font-weight: bold;
.sidenav__toggle {
@include sidenav__header;
float: right;
color: $color-blue-darker;
.toggle-arrows {
vertical-align: middle;
}
} }
ul { ul {
@ -41,11 +60,17 @@
margin-bottom: $gap; margin-bottom: $gap;
} }
.sidenav__text {
margin: 2 * $gap;
color: $color-gray;
font-style: italic;
}
.sidenav__link { .sidenav__link {
display: block; display: block;
padding: $gap ($gap * 2); padding: $gap ($gap * 2);
color: $color-black; color: $color-black;
text-decoration: none; text-decoration: underline;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@ -112,7 +137,6 @@
} }
+ ul { + ul {
// padding-bottom: $gap / 2;
li { li {
.sidenav__link { .sidenav__link {
@ -146,15 +170,12 @@
} }
} }
} }
.sidenav--minimized { .sidenav--minimized {
@extend .sidenav; @extend .sidenav;
@include unhide; width: auto;
margin: 0px; margin: 0px;
@include media($large-screen) {
@include hide;
} }
} }

View File

@ -17,6 +17,10 @@
text-align: right; text-align: right;
} }
&.table-cell--align-center {
text-align: center;
}
&.table-cell--shrink { &.table-cell--shrink {
width: 1%; width: 1%;
} }
@ -32,9 +36,20 @@
display: table-cell; display: table-cell;
} }
} }
}
thead {
tr th {
.sorting-direction { .sorting-direction {
position: absolute; position: inherit;
margin-right: -16px;
width: 16px;
.icon {
height: 14px;
width: 16px;
margin: 0;
}
}
} }
} }

View File

@ -23,4 +23,8 @@
} }
} }
} }
header.accordian__header {
padding: 1.6rem;
}
} }

View File

@ -1,3 +1,37 @@
.member-edit {
.panel {
@include shadow-panel;
margin: $gap;
padding: 2 * $gap $gap;
}
.subheading {
@include subheading;
}
.manage-access {
padding: 2 * $gap;
.subtitle {
font-style: italic;
font-size: $small-font-size;
color: $color-gray;
}
}
.application-list-item {
margin: 2 * $gap 3 * $gap;
.block-list__header {
border-top-color: $color-gray-light;
}
}
.search-bar {
margin: 2 * $gap;
}
}
.member-card { .member-card {
@include grid-row; @include grid-row;
padding: $gap*2; padding: $gap*2;

View File

@ -7,6 +7,11 @@
.funding-summary-row__col { .funding-summary-row__col {
hr {
margin: 2 * $gap 0;
border-bottom: 1px solid $color-gray-lightest;
}
@include media($medium-screen) { @include media($medium-screen) {
@include grid-pad; @include grid-pad;
flex-grow: 1; flex-grow: 1;
@ -36,6 +41,11 @@
max-width: 100%; max-width: 100%;
} }
.subheading {
@include h4;
margin: 0 $gap 2 * $gap 0;
-ms-flex-negative: 1;
}
// Spending Summary // Spending Summary
// =============================== // ===============================
@ -53,40 +63,27 @@
} }
} }
.spend-summary__heading {
@include h3;
margin: 0 $gap 0 0;
-ms-flex-negative: 1;
}
.spend-summary__budget { .spend-summary__budget {
margin: 0 0 0 $gap;
@include ie-only { @include ie-only {
margin: $gap 0 0 0; margin: $gap 0 0 0;
} }
}
> div { dl {
text-align: right; text-align: left;
margin: 0 0 ($gap / 2) 0; margin: 0 0 ($gap / 2) 0;
@include ie-only { @include ie-only {
text-align: left; text-align: left;
} }
dd, dt {
display: inline;
}
dt { dt {
color: $color-gray; text-transform: uppercase;
color: $color-gray-light;
margin-right: $gap; margin-right: $gap;
font-weight: normal;
}
dd {
font-weight: bold; font-weight: bold;
} font-size: $small-font-size;
} }
} }
@ -97,32 +94,33 @@
} }
.spend-summary__spent { .spend-summary__spent {
margin: $gap 0 0 0; margin: 2 * $gap 0;
display: flex; display: flex;
flex-direction: row-reverse; flex-direction: column;
justify-content: flex-end; justify-content: flex-end;
dd, dt {
@include h5;
}
dt { dt {
font-weight: normal; letter-spacing: 0.47px;
margin-left: $gap;
} }
} }
} }
// Task Order Summary // Task Order Summary
// =============================== // ===============================
&.to-summary { &.to-summary {
.to-summary__row { .icon-link {
font-weight: $font-normal
}
.subheading {
margin-bottom: 0;
}
.to-summary__heading { .to-summary__heading {
@include h3; @include h4;
margin: 0; margin: 0 $gap 0 0;
} }
.to-summary__to-number { .to-summary__to-number {
@ -134,7 +132,6 @@
margin-right: $gap; margin-right: $gap;
} }
} }
}
@include media($xlarge-screen) { @include media($xlarge-screen) {
display: flex; display: flex;
@ -163,23 +160,26 @@
.to-summary__expiration { .to-summary__expiration {
dl { dl {
margin: ($gap * 2) 0 0 0; text-align: right;
margin-top: -2 * $gap;
> div {
margin: 0 0 ($gap / 2) 0;
dd, dt { dd, dt {
display: block; display: inline;
} }
dt { dt {
color: $color-gray; font-size: $small-font-size;
margin-right: $gap; text-transform: uppercase;
font-weight: normal; font-weight: $font-bold;
color: $color-gray-light;
} }
dd { dd.ending-soon {
font-weight: bold; font-size: $h2-font-size;
white-space: nowrap;
.icon {
@include icon-size(28);
} }
} }
} }
@ -203,9 +203,12 @@
.spend-table { .spend-table {
box-shadow: 0 6px 18px 0 rgba(144,164,183,0.3);
.spend-table__header { .spend-table__header {
@include panel-base; @include panel-base;
@include panel-theme-default; @include panel-theme-default;
border-top: none;
border-bottom: 0; border-bottom: 0;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -215,8 +218,8 @@
padding: $gap * 2; padding: $gap * 2;
.spend-table__title { .spend-table__title {
@include h3; @include h4;
margin: 0; font-size: $lead-font-size;
flex: 2; flex: 2;
} }
@ -227,6 +230,12 @@
} }
table { table {
thead th {
text-transform: uppercase;
border-bottom: 1px solid $color-gray-lightest;
border-top: none;
}
th, td { th, td {
white-space: nowrap; white-space: nowrap;
@ -234,10 +243,6 @@
margin: 0; margin: 0;
} }
&.current-month {
background-color: $color-aqua-lightest;
}
&.previous-month { &.previous-month {
color: $color-gray; color: $color-gray;
} }
@ -286,28 +291,53 @@
.spend-table__portfolio { .spend-table__portfolio {
th, td { th, td {
font-weight: bold; font-weight: bold;
border-bottom: 1px solid $color-gray-lightest;
} }
} }
.spend-table__application { .spend-table__application {
.spend-table__application__toggler { .spend-table__application__toggler {
@include icon-link-color($color-black-light, $color-gray-lightest); @include icon-link-color($color-blue, $color-gray-lightest);
margin-left: -$gap; margin-left: -$gap;
color: $color-blue;
.icon { .icon {
@include icon-size(12); @include icon-size(12);
margin-right: $gap; margin-right: $gap;
} }
.open-indicator {
position: absolute;
bottom: 0;
left: 5 * $gap;
width: 0;
height: 0;
border-left: 10px solid transparent;
border-right: 10px solid transparent;
border-bottom: 10px solid $color-blue-light;
}
}
th, td {
border-bottom: none;
}
th[scope=rowgroup] {
position: relative;
} }
.spend-table__application__env { .spend-table__application__env {
margin-left: $gap; margin-left: 2 * $gap;
&:last-child { th, td {
td, th { .icon-link {
padding-bottom: $gap * 5; font-weight: $font-normal;
box-shadow: inset 0 (-$gap * 2.5) 0 $color-gray-lightest; font-size: $base-font-size;
} }
border-bottom: 1px dashed $color-white;
background-color: $color-blue-light;
} }
} }
} }

View File

@ -2,6 +2,11 @@
text-align: center; text-align: center;
padding: 4rem 6rem; padding: 4rem 6rem;
.panel {
@include shadow-panel;
margin-bottom: 6 * $gap;
}
.task-order-get-started__list { .task-order-get-started__list {
ul { ul {
list-style: none; list-style: none;
@ -49,6 +54,10 @@
top: 2.5rem; top: 2.5rem;
margin-left: -23rem; margin-left: -23rem;
} }
.usa-button {
margin: 0.5em;
}
} }
p { p {
@ -58,6 +67,9 @@
} }
.task-order-summary { .task-order-summary {
.panel {
@include shadow-panel;
}
.alert .alert__actions { .alert .alert__actions {
margin-top: 2 * $gap; margin-top: 2 * $gap;
@ -67,7 +79,7 @@
width: 100%; width: 100%;
} }
.label--pending { .label--pending, .label--started {
background-color: $color-gold; background-color: $color-gold;
} }
@ -112,6 +124,11 @@
.task-order-next-steps { .task-order-next-steps {
flex-grow: 1; flex-grow: 1;
.panel {
padding-bottom: 0;
}
@include media($xlarge-screen) { @include media($xlarge-screen) {
padding-right: $gap; padding-right: $gap;
} }
@ -135,8 +152,17 @@
width: 100%; width: 100%;
} }
.alert {
margin-top: 3 * $gap;
margin-bottom: 0;
padding: 2 * $gap;
.alert__message {
font-style: italic;
}
}
.task-order-next-steps__icon { .task-order-next-steps__icon {
width: 8%;
padding: $gap $gap 0 0; padding: $gap $gap 0 0;
justify-content: center; justify-content: center;
.complete { .complete {
@ -147,34 +173,29 @@
} }
} }
.task-order-next-steps__text {
width: 60%;
}
.task-order-next-steps__action { .task-order-next-steps__action {
min-width: 10 * $gap;
padding: $gap 0 0 $gap; padding: $gap 0 0 $gap;
width: 32%;
a.usa-button { a.usa-button {
width: 100%; width: 100%;
} }
} }
.task-order-next-steps__text {
display: flex;
.task-order-next-steps__heading { .task-order-next-steps__heading {
display: block;
h4 { max-width: 100%;
@include ie-only { flex-shrink: 1;
width: 100%;
} }
margin: $gap $gap 0 0;
}
}
.task-order-next-steps__description {
font-style: italic;
} }
} }
} }
.task-order-sidebar { .task-order-sidebar {
@include media($xlarge-screen) {
padding-left: 3 * $gap;
}
min-width: 35rem; min-width: 35rem;
hr { hr {
@ -193,7 +214,17 @@
} }
} }
.task-order-invitations {
.task-order-invitations__heading {
justify-content: space-between;
}
.task-order-invitation-status { .task-order-invitation-status {
margin-bottom: 3 * $gap;
.task-order-invitation-status__title {
font-weight: $font-bold;
}
.invited { .invited {
color: $color-green; color: $color-green;
@include icon-color($color-green); @include icon-color($color-green);
@ -207,6 +238,11 @@
padding: 0 0.5rem; padding: 0 0.5rem;
} }
} }
.task-order-invitation-details {
font-style: italic;
}
}
} }
.task-order-form { .task-order-form {
@ -230,14 +266,18 @@
} }
.task-order-invite-message { .task-order-invite-message {
font-weight: $font-bold;
&.not-sent { &.not-sent {
color: $color-red; color: $color-red;
font-weight: $font-bold;
} }
&.sent { &.sent {
color: $color-green; color: $color-green;
font-weight: $font-bold; }
&.pending {
color: $color-gold-dark;
} }
} }
@ -357,6 +397,48 @@
} }
.officer__form { .officer__form {
padding: 1.5rem;
background-color: $color-aqua-lightest;
border-left-color: $color-blue;
border-left-style: solid;
border-left-width: $gap / 2;
margin-top: 1.5rem;
.edit-officer {
margin-bottom: $gap * 2;
h4 {
color: $color-gray;
margin-top: 0;
}
p {
font-size: 1.5rem;
}
}
.usa-input__title {
font-weight: normal;
}
.form-row {
margin-bottom: 0rem;
margin-top: 0rem;
margin-right: 2rem;
.usa-input {
margin-bottom: 1.5rem;
}
&.officer__form--dodId {
margin-top: 1.5rem;
.usa-input {
margin-bottom: 0rem;
}
}
}
.officer__form--actions { .officer__form--actions {
display: flex; display: flex;
flex-direction: row; flex-direction: row;

View File

@ -1,11 +1,17 @@
{% macro CheckboxInput(field, inline=False, classes="") -%} {% macro CheckboxInput(
field,
label=field.label | striptags,
inline=False,
classes="") -%}
<checkboxinput name='{{ field.name }}' inline-template key='{{ field.name }}'> <checkboxinput name='{{ field.name }}' inline-template key='{{ field.name }}'>
<div class='usa-input {{ classes }} {% if field.errors %}usa-input--error{% endif %}'> <div class='usa-input {{ classes }} {% if field.errors %}usa-input--error{% endif %}'>
<fieldset v-on:change="onInput" class="usa-input__choices {% if inline %}usa-input__choices--inline{% endif %}"> <fieldset v-on:change="onInput" class="usa-input__choices {% if inline %}usa-input__choices--inline{% endif %}">
<legend> <legend>
{{ field() }} {{ field() }}
{{ field.label }} <label for={{field.name}}>
{{ label }}
</label>
{% if field.description %} {% if field.description %}
<span class='usa-input__help'>{{ field.description | safe }}</span> <span class='usa-input__help'>{{ field.description | safe }}</span>

View File

@ -64,6 +64,7 @@
<masked-input <masked-input
v-on:input='onInput' v-on:input='onInput'
v-on:blur='onBlur'
v-on:change='onChange' v-on:change='onChange'
v-bind:value='value' v-bind:value='value'
v-bind:mask='mask' v-bind:mask='mask'
@ -87,6 +88,9 @@
<template v-if='showError'> <template v-if='showError'>
<span class='usa-input__message' v-html='validationError'></span> <span class='usa-input__message' v-html='validationError'></span>
</template> </template>
<template v-else>
<span class='usa-input__message'></span>
</template>
</div> </div>
</textinput> </textinput>

View File

@ -0,0 +1,24 @@
{% macro UploadInput(field, show_label=False) -%}
<uploadinput inline-template v-bind:initial-data='{{ field.data | tojson }}' v-bind:upload-errors='{{ field.errors | list }}'>
<div>
<template v-if="showUpload">
<div class="usa-input {% if field.errors %} usa-input--error {% endif %}">
{% if show_label %}
{{ field.label }}
{% endif %}
{{ field.description }}
{{ field }}
{% for error in field.errors %}
<span class="usa-input__message">{{error}}</span>
{% endfor %}
</div>
</template>
<template v-else>
<p>Uploaded {{ field.data.filename }}</p>
<div>
<button type="button" v-on:click="showUploadInput">Change</button>
</div>
</template>
</div>
</uploadinput>
{%- endmacro %}

View File

@ -1,10 +1,7 @@
{% from "components/pagination.html" import Pagination %} {% from "components/pagination.html" import Pagination %}
<section class="block-list"> <section class="block-list activity-log">
<header class="block-list__header"> <div class='subheading'>{{ "portfolios.admin.activity_log_title" | translate }}</div>
<h1 class="block-list__title">{{ "audit_log.header_title" | translate }}</h1>
</header>
<ul> <ul>
{% for event in audit_events %} {% for event in audit_events %}
<li class="block-list__item"> <li class="block-list__item">

View File

@ -1,18 +1,8 @@
{% from "components/text_input.html" import TextInput %} {% from "components/text_input.html" import TextInput %}
{% set title_text = ('fragments.edit_application_form.existing_application_title' | translate({ "application_name": application.name })) if application else ('fragments.edit_application_form.new_application_title' | translate) %}
{{ form.csrf_token }} {{ form.csrf_token }}
<div class="panel"> <p>
<div class="panel__heading panel__heading--grow">
<h1>{{ title_text }}</h1>
</div>
<div class="panel__content">
<p>
{{ "fragments.edit_application_form.explain" | translate }} {{ "fragments.edit_application_form.explain" | translate }}
</p> </p>
{{ TextInput(form.name) }} {{ TextInput(form.name) }}
{{ TextInput(form.description, paragraph=True) }} {{ TextInput(form.description, paragraph=True) }}
</div>
</div>

View File

@ -1,7 +0,0 @@
<br>
<p>{{ "fragments.ko_review_alert.make_sure" | translate }}:</p>
<ul>
<li>{{ "fragments.ko_review_alert.bullet_1" | translate }}</li>
<li>{{ "fragments.ko_review_alert.bullet_2" | translate }}</li>
<li>{{ "fragments.ko_review_alert.bullet_3" | translate }}</li>
</ul>

View File

@ -0,0 +1,7 @@
{{ "task_orders.ko_review.message" | translate }}
<div class="list-title">{{ "fragments.ko_review_message.title" | translate }}:</div>
<ul class="list">
<li class="list-item">{{ "fragments.ko_review_message.bullet_1" | translate }}</li>
<li class="list-item">{{ "fragments.ko_review_message.bullet_2" | translate }}</li>
<li class="list-item">{{ "fragments.ko_review_message.bullet_3" | translate }}</li>
</ul>

View File

@ -8,7 +8,7 @@
</p> </p>
{% else %} {% else %}
<br/> <br/>
<a href="{{ url_for("task_orders.download_csp_estimate", task_order_id=task_order.id) }}" class='icon-link icon-link--left icon-link--disabled' aria-disabled="true">{{ Icon('download') }} {{ "task_orders.new.review.usage_est_link"| translate }}</a> <a class='icon-link icon-link--left icon-link--disabled' aria-disabled="true">{{ Icon('download') }} {{ "task_orders.new.review.usage_est_link"| translate }}</a>
{{ Icon('alert', classes='icon--red') }} <span class="task-order-invite-message not-sent">{{ "task_orders.new.review.not_uploaded"| translate }}</span> {{ Icon('alert', classes='icon--red') }} <span class="task-order-invite-message not-sent">{{ "task_orders.new.review.not_uploaded"| translate }}</span>
{% endif %} {% endif %}
{% endcall %} {% endcall %}

View File

@ -1,15 +1,17 @@
{% macro ReviewOfficerInfo(heading, first_name, last_name, email, phone_number, dod_id, officer) %} {% macro ReviewOfficerInfo(heading, officer_data, has_officer, invite_pending) %}
<div class="col col--grow"> <div class="col col--grow">
<h4 class='task-order-form__heading'>{{ heading | translate }}</h4> <h4 class='task-order-form__heading'>{{ heading | translate }}</h4>
{{ first_name }} {{ last_name }}<br> {{ officer_data.first_name }} {{ officer_data.last_name }}<br>
{{ email }}<br> {{ officer_data.email }}<br>
{% if phone_number %} {% if officer_data.phone_number %}
{{ phone_number | usPhone }} {{ officer_data.phone_number | usPhone }}
{% endif %} {% endif %}
<br> <br>
{{ "task_orders.new.review.dod_id" | translate }} {{ dod_id}}<br> {{ "task_orders.new.review.dod_id" | translate }} {{ officer_data.dod_id}}<br>
{% if officer %} {% if has_officer %}
{{ Icon('ok', classes='icon--green') }} <span class="task-order-invite-message sent">{{ "task_orders.new.review.invited"| translate }}</<span> {{ Icon('ok', classes='icon--green') }} <span class="task-order-invite-message sent">{{ "task_orders.new.review.invited"| translate }}</<span>
{% elif invite_pending %}
{{ Icon('alert', classes='icon--gold') }} <span class="task-order-invite-message pending">{{ "task_orders.new.review.pending_to"| translate }}</<span>
{% else %} {% else %}
{{ Icon('alert', classes='icon--red') }} <span class="task-order-invite-message not-sent">{{ "task_orders.new.review.not_invited"| translate }}</span> {{ Icon('alert', classes='icon--red') }} <span class="task-order-invite-message not-sent">{{ "task_orders.new.review.not_invited"| translate }}</span>
{% endif %} {% endif %}
@ -17,9 +19,24 @@
{% endmacro %} {% endmacro %}
<div class="row"> <div class="row">
{{ ReviewOfficerInfo("task_orders.new.review.ko", task_order.ko_first_name, task_order.ko_last_name, task_order.ko_email, task_order.ko_phone_number, task_order.ko_dod_id, task_order.contracting_officer) }} {{ ReviewOfficerInfo(
{{ ReviewOfficerInfo("task_orders.new.review.cor", task_order.cor_first_name, task_order.cor_last_name, task_order.cor_email, task_order.cor_phone_number, task_order.cor_dod_id, task_order.contracting_officer_representative) }} "task_orders.new.review.ko",
</div> task_order.officer_dictionary("contracting_officer"),
<div class="row"> task_order.contracting_officer,
{{ ReviewOfficerInfo("task_orders.new.review.so", task_order.so_first_name, task_order.so_last_name, task_order.so_email, task_order.so_phone_number, task_order.so_dod_id, task_order.security_officer) }} task_order.ko_invitable
</div> ) }}
{{ ReviewOfficerInfo(
"task_orders.new.review.cor",
task_order.officer_dictionary("contracting_officer_representative"),
task_order.contracting_officer_representative,
task_order.cor_invitable
) }}
</div>
<div class="row">
{{ ReviewOfficerInfo(
"task_orders.new.review.so",
task_order.officer_dictionary("security_officer"),
task_order.security_officer,
task_order.so_invitable
) }}
</div>

View File

@ -2,23 +2,39 @@
{% from "components/sidenav_item.html" import SidenavItem %} {% from "components/sidenav_item.html" import SidenavItem %}
<div class="global-navigation sidenav"> <div v-cloak is="SidenavToggler" class="sidenav-container">
<template slot-scope='props'>
<div v-bind:class="{'global-navigation': true, 'sidenav': props.isVisible, 'sidenav--minimized': !props.isVisible}">
<a href="#" v-on:click="props.toggle" class="sidenav__toggle">
<template v-if="props.isVisible">
{{ Icon('angle-double-left-solid', classes="toggle-arrows icon--blue") }}
Hide
</template>
<template v-else>
Show
{{ Icon('angle-double-right-solid', classes="toggle-arrows icon--blue") }}
</template>
</a>
<div v-if="props.isVisible">
<div class="sidenav__title">Portfolios</div> <div class="sidenav__title">Portfolios</div>
<ul class="sidenav__list--padded"> <ul class="sidenav__list--padded">
{% if portfolios %}
{% for other_portfolio in portfolios|sort(attribute='name') %} {% for other_portfolio in portfolios|sort(attribute='name') %}
{{ SidenavItem(other_portfolio.name, {{ SidenavItem(other_portfolio.name,
href=url_for("portfolios.show_portfolio", portfolio_id=other_portfolio.id), href=url_for("portfolios.show_portfolio", portfolio_id=other_portfolio.id),
active=(other_portfolio.id | string) == request.view_args.get('portfolio_id') active=(other_portfolio.id | string) == request.view_args.get('portfolio_id')
) }} ) }}
{% endfor %} {% endfor %}
{% else %}
<li><span class="sidenav__text">You have no portfolios yet</span></li>
{% endif %}
</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("plus", classes="sidenav__link-icon icon--circle") }}
</a> </a>
</div> </div>
</div>
<div class="global-navigation sidenav--minimized"> </template>
<div class="sidenav__title">Show >>></div>
</div> </div>

View File

@ -1,68 +0,0 @@
{% from "components/sidenav_item.html" import SidenavItem %}
<nav class='sidenav portfolio-navigation'>
<ul>
{{ SidenavItem(
("navigation.portfolio_navigation.applications" | translate),
href=url_for("portfolios.portfolio_applications", portfolio_id=portfolio.id),
active=request.url_rule.rule.startswith('/portfolios/<portfolio_id>/applications'),
subnav=None if not user_can(permissions.ADD_APPLICATION_IN_PORTFOLIO) else [
{
"label": ("navigation.portfolio_navigation.add_new_application_label" | translate),
"href": url_for('portfolios.new_application', portfolio_id=portfolio.id),
"active": g.matchesPath('\/portfolios\/[A-Za-z0-9-]*\/applications'),
"icon": "plus"
}
]
) }}
{% if user_can(permissions.VIEW_PORTFOLIO_MEMBERS) %}
{{ SidenavItem(
("navigation.portfolio_navigation.members" | translate),
href=url_for("portfolios.portfolio_members", portfolio_id=portfolio.id),
active=request.url_rule.rule.startswith('/portfolios/<portfolio_id>/members'),
subnav=None if not user_can(permissions.ASSIGN_AND_UNASSIGN_ATAT_ROLE) else [
{
"label": ("navigation.portfolio_navigation.add_new_member_label" | translate),
"href": url_for("portfolios.new_member", portfolio_id=portfolio.id),
"active": request.url_rule.rule.startswith('/portfolios/<portfolio_id>/members/new'),
"icon": "plus"
}
]
) }}
{% endif %}
{% if user_can(permissions.VIEW_USAGE_DOLLARS) %}
{{ SidenavItem(
("navigation.portfolio_navigation.budget_report" | translate),
href=url_for("portfolios.portfolio_reports", portfolio_id=portfolio.id),
active=request.url_rule.rule.startswith('/portfolios/<portfolio_id>/reports')
) }}
{% endif %}
{{ SidenavItem(
("navigation.portfolio_navigation.portfolio_funding" | translate),
href=url_for("portfolios.portfolio_funding", portfolio_id=portfolio.id),
active=request.url_rule.rule.startswith('/portfolios/<portfolio_id>/task_order'),
subnav=None
) }}
{% if user_can(permissions.EDIT_PORTFOLIO_INFORMATION) %}
{{ SidenavItem(
("navigation.portfolio_navigation.portfolio_settings" | translate),
href=url_for("portfolios.portfolio", portfolio_id=portfolio.id),
active=request.url_rule.rule.startswith('/portfolios/<portfolio_id>/edit'),
subnav=None
) }}
{% endif %}
{% if user_can(permissions.VIEW_PORTFOLIO_AUDIT_LOG) %}
{{ SidenavItem(
("navigation.portfolio_navigation.activity_log" | translate),
href=url_for("portfolios.portfolio_activity", portfolio_id=portfolio.id),
active=request.url_rule.rule.startswith('/portfolios/<portfolio_id>/activity')
) }}
{% endif %}
</ul>
</nav>

View File

@ -1,6 +1,8 @@
{% extends "portfolios/base.html" %} {% extends "portfolios/base.html" %}
{% from "components/pagination.html" import Pagination %} {% from "components/pagination.html" import Pagination %}
{% set secondary_breadcrumb = "navigation.portfolio_navigation.breadcrumbs.admin" | translate %}
{% block portfolio_content %} {% block portfolio_content %}
<div v-cloak> <div v-cloak>
{% include "fragments/audit_events_log.html" %} {% include "fragments/audit_events_log.html" %}

View File

@ -0,0 +1,33 @@
{% extends "portfolios/base.html" %}
{% from "components/pagination.html" import Pagination %}
{% from "components/icon.html" import Icon %}
{% from "components/text_input.html" import TextInput %}
{% set secondary_breadcrumb = "navigation.portfolio_navigation.portfolio_admin" | translate %}
{% block portfolio_content %}
{% include "fragments/flash.html" %}
<div v-cloak class="portfolio-admin portfolio-content">
<form method="POST" action="{{ url_for('portfolios.edit_portfolio', portfolio_id=portfolio.id) }}" autocomplete="false">
{{ form.csrf_token }}
<div class='form-row'>
<div class='form-col form-col--half'>
{{ TextInput(form.name, validation="portfolioName") }}
</div>
<div class='edit-portfolio-name action-group'>
<button type="submit" class="usa-button usa-button-big usa-button-primary" tabindex="0">Save</button>
</div>
</div>
</form>
{% include "fragments/audit_events_log.html" %}
{{ Pagination(audit_events, 'portfolios.portfolio_admin', portfolio_id=portfolio.id) }}
</div>
{% endblock %}

View File

@ -0,0 +1,15 @@
{% extends "portfolios/base.html" %}
{% block portfolio_header %}
<div class='portfolio-header'>
<div class='portfolio-header__name'>
{{ secondary_breadcrumb }}
</div>
</div>
{% endblock %}
{% block portfolio_content %}
<div class='application-content'>
{% block application_content %}{% endblock %}
</div>
{% endblock %}

View File

@ -1,35 +1,43 @@
{% extends "portfolios/base.html" %} {% extends "portfolios/applications/base.html" %}
{% from "components/text_input.html" import TextInput %} {% from "components/text_input.html" import TextInput %}
{% block portfolio_content %} {% set secondary_breadcrumb = 'portfolios.applications.existing_application_title' | translate({ "application_name": application.name }) %}
{% block application_content %}
<div class='subheading'>{{ 'portfolios.applications.settings_heading' | translate }}</div>
<form method="POST" action="{{ url_for('portfolios.edit_application', portfolio_id=portfolio.id, application_id=application.id) }}"> <form method="POST" action="{{ url_for('portfolios.edit_application', portfolio_id=portfolio.id, application_id=application.id) }}">
<div class="panel">
<div class="panel__content">
{% include "fragments/edit_application_form.html" %} {% include "fragments/edit_application_form.html" %}
<div class="block-list application-list-item"> <div class="application-list-item">
<header class="block-list__header block-list__header--grow"> <header>
<h2 class="block-list__title">Application Environments</h2> <h2 class="block-list__title">{{ 'portfolios.applications.environments_heading' | translate }}</h2>
<p> <p>
Each environment created within an application is an enclave of cloud resources that is logically separated from each other for increased security. {{ 'portfolios.applications.environments_description' | translate }}
</p> </p>
</header> </header>
<ul> <ul>
{% for environment in application.environments %} {% for environment in application.environments %}
<li class="block-list__item application-edit__env-list-item"> <li class="application-edit__env-list-item">
<div class="usa-input"> <div class="usa-input input--disabled">
<label>Environment Name</label> <label>Environment Name</label>
<input type="text" value="{{ environment.name }}" readonly /> <input type="text" disabled value="{{ environment.name }}" readonly />
</div> </div>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
</div> </div>
</div>
</div>
<div class="action-group"> <div class="action-group">
<button class="usa-button usa-button-primary" tabindex="0" type="submit">Update Application</button> <button class="usa-button usa-button-primary" tabindex="0" type="submit">{{ 'portfolios.applications.update_button_text' | translate }}</button>
</div> </div>
</form> </form>

View File

@ -3,12 +3,24 @@
{% extends "portfolios/base.html" %} {% extends "portfolios/base.html" %}
{% set can_create_applications = user_can(permissions.ADD_APPLICATION_IN_PORTFOLIO) %}
{% block portfolio_content %} {% block portfolio_content %}
{% if not portfolio.applications %} <div class='portfolio-applications'>
<div class='portfolio-applications__header row'>
<div class='portfolio-applications__header--title col col--grow'>Applications</div>
<div class='portfolio-applications__header--actions col'>
{% if can_create_applications %}
<a class='icon-link' href='{{ url_for('portfolios.new_application', portfolio_id=portfolio.id) }}'>
{{ 'portfolios.applications.add_application_text' | translate }}
{{ Icon("plus", classes="sidenav__link-icon icon--circle") }}
</a>
{% endif %}
</div>
</div>
{% set can_create_applications = user_can(permissions.ADD_APPLICATION_IN_PORTFOLIO) %} {% if not portfolio.applications %}
{{ EmptyState( {{ EmptyState(
'This portfolio doesnt have any applications yet.', 'This portfolio doesnt have any applications yet.',
@ -18,38 +30,63 @@
sub_message=None if can_create_applications else 'Please contact your JEDI Cloud portfolio administrator to set up a new application.' sub_message=None if can_create_applications else 'Please contact your JEDI Cloud portfolio administrator to set up a new application.'
) }} ) }}
{% else %} {% else %}
{% for application in portfolio.applications %} <div class='application-list'>
<div v-cloak class='block-list application-list-item'> {% for application in portfolio.applications|sort(attribute='name') %}
<header class='block-list__header'> <div is='toggler' v-cloak class='accordian application-list-item'>
<h2 class='block-list__title'>{{ application.name }} ({{ application.environments|length }} environments)</h2> <template slot-scope='props'>
<header class='accordian__header row'>
<div class='col col-grow'>
<h3 class='icon-link accordian__title' v-on:click="props.toggle">{{ application.name }}</h3>
<span class='accordian__description'>{{ application.description }}</span>
<div class='accordian__actions'>
{% if user_can(permissions.RENAME_APPLICATION_IN_PORTFOLIO) %} {% if user_can(permissions.RENAME_APPLICATION_IN_PORTFOLIO) %}
<a class='icon-link' href='{{ url_for("portfolios.edit_application", portfolio_id=portfolio.id, application_id=application.id) }}'> <a class='icon-link' href='{{ url_for("portfolios.edit_application", portfolio_id=portfolio.id, application_id=application.id) }}'>
{{ Icon('edit') }} <span>{{ "portfolios.applications.app_settings_text" | translate }}</span>
<span>edit</span> </a>
<div class='separator'></div>
{% endif %}
{% if user_can(permissions.VIEW_PORTFOLIO_MEMBERS) %}
<a class='icon-link' href='{{ url_for("portfolios.application_members", portfolio_id=portfolio.id, application_id=application.id) }}'>
<span>{{ "portfolios.applications.team_text" | translate }}</span>
<span class='counter'>{{ application.num_users }}</span>
</a> </a>
{% endif %} {% endif %}
</div>
</div>
<div class='col'>
<span v-on:click="props.toggle" class='icon-link toggle-link'>
<template v-if="props.isVisible">
{{ Icon('minus') }}
</template>
<template v-else>
{{ Icon('plus') }}
</template>
</span>
</div>
</header> </header>
<ul> <ul v-if="props.isVisible">
{% for environment in application.environments %} {% for environment in application.environments %}
<li class='block-list__item application-list-item__environment'> <li class='accordian__item application-list-item__environment'>
<a href='{{ url_for("portfolios.access_environment", portfolio_id=portfolio.id, environment_id=environment.id)}}' target='_blank' rel='noopener noreferrer' class='application-list-item__environment__link'> <div class='application-list-item__environment__name'>
{{ Icon('link') }}
<span>{{ environment.name }}</span> <span>{{ environment.name }}</span>
</div>
<a href='{{ url_for("portfolios.access_environment", portfolio_id=portfolio.id, environment_id=environment.id)}}' target='_blank' rel='noopener noreferrer' class='application-list-item__environment__csp_link icon-link'>
<span>{{ "portfolios.applications.csp_console_text" | translate }}</span>
</a> </a>
<div class='application-list-item__environment__members'>
<div class='label'>{{ environment.num_users }}</div>
<span>members</span>
</div>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
</template>
</div> </div>
{% endfor %} {% endfor %}
</div>
{% endif %} {% endif %}
</div>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,90 @@
{% extends "portfolios/applications/base.html" %}
{% from "components/empty_state.html" import EmptyState %}
{% from "components/icon.html" import Icon %}
{% set secondary_breadcrumb = 'portfolios.applications.team_management.title' | translate({ "application_name": application.name }) %}
{% block application_content %}
<div class='subheading'>{{ 'portfolios.applications.team_management.subheading' | translate }}</div>
{% if not portfolio.members %}
{% set user_can_invite = user_can(permissions.ASSIGN_AND_UNASSIGN_ATAT_ROLE) %}
{{ EmptyState(
'There are currently no members in this Portfolio.',
action_label='Invite a new Member' if user_can_invite else None,
action_href='/members/new' if user_can_invite else None,
sub_message=None if user_can_invite else 'Please contact your JEDI Cloud portfolio administrator to invite new members.',
icon='avatar'
) }}
{% else %}
{% include "fragments/flash.html" %}
<members-list
inline-template
id="search-template"
class='member-list'
v-bind:members='{{ members | tojson}}'>
<div class='responsive-table-wrapper panel'>
<table v-cloak v-if='searchedList && searchedList.length'>
<thead>
<tr>
<th v-for="col in getColumns()" @click="updateSort(col.displayName)" :width="col.width" :class="col.class" scope="col">
!{ col.displayName }
<span class="sorting-direction" v-if="col.displayName === sortInfo.columnName && sortInfo.isAscending">
{{ Icon("caret_down") }}
</span>
<span class="sorting-direction" v-if="col.displayName === sortInfo.columnName && !sortInfo.isAscending">
{{ Icon("caret_up") }}
</span>
</th>
</tr>
</thead>
<tbody>
<tr v-for='member in searchedList'>
<td>
<a :href="member.edit_link" class="icon-link icon-link--large" v-html="member.name"></a>
</td>
<td class="table-cell--align-center" v-if='member.num_env'>
<span v-html="member.num_env"></span>
</td>
<td class='table-cell--shrink' v-else>
<span class="label label--info">No Environment Access</span>
</td>
<td v-html="member.status"></td>
<td v-html="member.role"></td>
</tr>
<tr>
<td class="add-member-link" colspan=4>
<a class="icon-link" href="{{ url_for('portfolios.new_member', portfolio_id=portfolio.id) }}">
Add A New Member
{{ Icon('plus', classes='icon--circle') }}
</a>
</td>
</tr>
</tbody>
</table>
<div v-else>
{{ EmptyState(
'No members found.',
action_label=None,
action_href=None,
sub_message='Please try a different search.',
icon=None
) }}
</div>
</div>
</members-list>
{% endif %}
{% endblock %}

View File

@ -1,23 +1,29 @@
{% extends "portfolios/base.html" %} {% extends "portfolios/applications/base.html" %}
{% from "components/alert.html" import Alert %} {% from "components/alert.html" import Alert %}
{% from "components/icon.html" import Icon %} {% from "components/icon.html" import Icon %}
{% from "components/modal.html" import Modal %} {% from "components/modal.html" import Modal %}
{% from "components/text_input.html" import TextInput %} {% from "components/text_input.html" import TextInput %}
{% block portfolio_content %} {% set secondary_breadcrumb = 'portfolios.applications.new_application_title' | translate %}
{% block application_content %}
{% set modalName = "newApplicationConfirmation" %} {% set modalName = "newApplicationConfirmation" %}
{% include "fragments/flash.html" %} {% include "fragments/flash.html" %}
<div class='subheading'>{{ 'portfolios.applications.settings_heading' | translate }}</div>
<new-application inline-template v-bind:initial-data='{{ form.data|tojson }}' modal-name='{{ modalName }}'> <new-application inline-template v-bind:initial-data='{{ form.data|tojson }}' modal-name='{{ modalName }}'>
<form method="POST" action="{{ url_for('portfolios.create_application', portfolio_id=portfolio.id) }}" v-on:submit="handleSubmit"> <form method="POST" action="{{ url_for('portfolios.create_application', portfolio_id=portfolio.id) }}" v-on:submit="handleSubmit">
<div class="panel">
<div class="panel__content">
{% call Modal(name=modalName, dismissable=False) %} {% call Modal(name=modalName, dismissable=False) %}
<h1>Create application !{ name }</h1> <h1>Create application !{ name }</h1>
<p> <p>
When you click <em>Create Application</em>, the environments When you click <em>{{ 'portfolios.applications.create_button_text' | translate }}</em>, the environments
<span v-for="(environment, index) in environments"> <span v-for="(environment, index) in environments">
<strong>!{environment.name}</strong><template v-if="index < (environments.length - 1)">, </template> <strong>!{environment.name}</strong><template v-if="index < (environments.length - 1)">, </template>
</span> </span>
@ -25,7 +31,7 @@
</p> </p>
<div class='action-group'> <div class='action-group'>
<button autofocus type='submit' class='action-group__action usa-button' tabindex='0'>Create Application</button> <button autofocus type='submit' class='action-group__action usa-button' tabindex='0'>{{ 'portfolios.applications.create_button_text' | translate }}</button>
<button type='button' v-on:click="handleCancelSubmit" class='icon-link action-group__action' tabindex='0'>Cancel</button> <button type='button' v-on:click="handleCancelSubmit" class='icon-link action-group__action' tabindex='0'>Cancel</button>
</div> </div>
{% endcall %} {% endcall %}
@ -38,16 +44,16 @@
</div> </div>
</div> </div>
<div class="block-list application-list-item"> <div class="application-list-item">
<header class="block-list__header block-list__header--grow"> <header>
<h2 class="block-list__title">Application Environments</h2> <h2 class="block-list__title">{{ 'portfolios.applications.environments_heading' | translate }}</h2>
<p> <p>
Each environment created within an application is an enclave of cloud resources that is logically separated from each other for increased security. {{ 'portfolios.applications.environments_description' | translate }}
</p> </p>
</header> </header>
<ul> <ul>
<li v-for="(environment, i) in environments" class="block-list__item application-edit__env-list-item"> <li v-for="(environment, i) in environments" class="application-edit__env-list-item">
<div class="usa-input"> <div class="usa-input">
<label :for="'environment_names-' + i">Environment Name</label> <label :for="'environment_names-' + i">Environment Name</label>
<input type="text" :id="'environment_names-' + i" v-model="environment.name" placeholder="e.g. Development, Staging, Production"/> <input type="text" :id="'environment_names-' + i" v-model="environment.name" placeholder="e.g. Development, Staging, Production"/>
@ -65,8 +71,12 @@
</div> </div>
</div> </div>
</div>
</div>
<div class="action-group"> <div class="action-group">
<button class="usa-button usa-button-primary" tabindex="0" type="submit">Create Application</button> <button class="usa-button usa-button-primary" tabindex="0" type="submit">{{ 'portfolios.applications.create_button_text' | translate }}</button>
</div> </div>
</form> </form>
</new-application> </new-application>

View File

@ -3,13 +3,19 @@
{% block content %} {% block content %}
<div class='portfolio-panel-container'> <div class='portfolio-panel-container'>
<div class='col'>
{% include 'navigation/portfolio_navigation.html' %}
</div>
<div class='col col--grow'> <div class='col col--grow'>
{% block portfolio_breadcrumbs %}
{% include "portfolios/breadcrumbs.html" %}
{% endblock %}
<div class='line'></div>
{% block portfolio_header %}
{% include "portfolios/header.html" %}
{% endblock %}
<div class='line'></div>
<div class='portfolio-content'>
{% block portfolio_content %}{% endblock %} {% block portfolio_content %}{% endblock %}
</div> </div>
</div> </div>
</div>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,16 @@
{% extends "base.html" %}
{% from "components/empty_state.html" import EmptyState %}
{% from "components/tooltip.html" import Tooltip %}
{% block content %}
{{
EmptyState(
action_href=url_for("task_orders.get_started"),
action_label=("portfolios.index.empty.start_button" | translate),
icon="cloud",
message=("portfolios.index.empty.title" | translate),
)
}}
{% endblock %}

View File

@ -0,0 +1,19 @@
{% from "components/icon.html" import Icon %}
<div class="row portfolio-breadcrumbs">
<a class="icon-link portfolio-breadcrumbs__home {{ 'icon-link--disabled' if not secondary_breadcrumb }}" href="{{ url_for("portfolios.portfolio_applications", portfolio_id=portfolio.id) }}">
{{ Icon("home") }}
<span>
{{ portfolio.name }} Portfolio
</span>
</a>
<div class="portfolio-breadcrumbs__crumb">
{% if secondary_breadcrumb %}
{{ Icon("caret_right", classes="icon--tiny") }}
<div class="icon-link icon-link--disabled">
{{ secondary_breadcrumb }}
</div>
{% endif %}
</div>
</div
>

View File

@ -1,36 +0,0 @@
{% extends "portfolios/base.html" %}
{% from "components/icon.html" import Icon %}
{% from "components/text_input.html" import TextInput %}
{% block portfolio_content %}
{% include "fragments/flash.html" %}
<form method="POST" action="{{ url_for('portfolios.edit_portfolio', portfolio_id=portfolio.id) }}" autocomplete="false">
{{ form.csrf_token }}
<div class="panel">
<div class="panel__heading">
<h1>Portfolio Settings</h1>
</div>
<div class="panel__content">
{{ TextInput(form.name, validation="portfolioName") }}
</div>
</div>
<div class='action-group'>
<button type="submit" class="usa-button usa-button-big usa-button-primary" tabindex="0">Save</button>
<a href='{{ url_for("portfolios.portfolio_applications", portfolio_id=portfolio.id) }}' class='action-group__action icon-link'>
{{ Icon('x') }}
<span>Cancel</span>
</a>
</div>
</form>
{% endblock %}

View File

@ -0,0 +1,78 @@
{% from "components/icon.html" import Icon %}
{% macro Link(icon, text, url, active=False) %}
<a class='icon-link {{ "active icon-link--disabled" if active }}' href='{{ url }}'>
<div class='col'>
<div class='icon-link--icon'>{{ Icon(icon) }}</div>
<div class='icon-link--name'>{{ text }}</div>
</div>
</a>
{% endmacro %}
<div class='portfolio-header row'>
<div class='col col--grow'>
<div class='portfolio-header__name'>
{{ secondary_breadcrumb or portfolio.name }}
</div>
<div class='portfolio-header__budget row'>
<div class='column-left'>
<span>Available budget</span>
<button type="button" tabindex="0" class="icon-tooltip" v-tooltip.right="{content: 'The available budget shown includes the available budget of all active task orders', container: false}">
{{ Icon('info') }}
</button>
</div>
<div class='portfolio-header__budget--amount'>
<span class='portfolio-header__budget--dollars'>
{{ portfolio.task_orders | selectattr('is_active') | sum(attribute='budget') | justDollars }}
</span>
<span class='portfolio-header__budget--cents'>
.{{ portfolio.task_orders | selectattr('is_active') | sum(attribute='budget') | justCents }}
</span>
</div>
</div>
<div class='row'>
<div class='column-left'></div>
<div class='column-right portfolio-funding__header--funded-through {{ "funded" if funding_end_date is not none and funded else "unfunded"}}'>
{% if funding_end_date and funded %}
{{ Icon('ok') }}
Funded through
<local-datetime
timestamp='{{ funding_end_date }}'
format="M/D/YYYY">
</local-datetime>
{% elif funding_end_date and not funded %}
{{ Icon('alert') }}
Funded period ends
<local-datetime
timestamp='{{ funding_end_date }}'
format="M/D/YYYY">
</local-datetime>
{% endif %}
</div>
</div>
</div>
<div class='row links'>
{% if user_can(permissions.VIEW_USAGE_DOLLARS) %}
{{ Link(
icon='chart-pie',
text='navigation.portfolio_navigation.breadcrumbs.reports' | translate,
url=url_for("portfolios.portfolio_reports", portfolio_id=portfolio.id),
active=request.url_rule.endpoint == "portfolios.portfolio_reports",
) }}
{% endif %}
{{ Link(
icon='dollar-sign',
text='navigation.portfolio_navigation.breadcrumbs.funding' | translate,
url=url_for("portfolios.portfolio_funding", portfolio_id=portfolio.id),
active=request.url_rule.endpoint == "portfolios.portfolio_funding",
) }}
{% if user_can(permissions.EDIT_PORTFOLIO_INFORMATION) %}
{{ Link(
icon='cog',
text='navigation.portfolio_navigation.breadcrumbs.admin' | translate,
url=url_for("portfolios.portfolio_admin", portfolio_id=portfolio.id),
active=request.url_rule.endpoint == "portfolios.portfolio_admin",
) }}
{% endif %}
</div>
</div>

View File

@ -13,7 +13,10 @@
<form method="POST" action="{{ url_for('portfolios.update_member', portfolio_id=portfolio.id, member_id=member.user_id) }}" autocomplete="false"> <form method="POST" action="{{ url_for('portfolios.update_member', portfolio_id=portfolio.id, member_id=member.user_id) }}" autocomplete="false">
{{ form.csrf_token }} {{ form.csrf_token }}
<div class='panel member-card'> <div class='member-edit'>
<div class="subheading">Edit Portfolio Member</div>
<div class="panel">
<div class='member-card'>
<div class='member-card__header'> <div class='member-card__header'>
<h1 class='member-card__heading'>{{ member.user.full_name }}</h1> <h1 class='member-card__heading'>{{ member.user.full_name }}</h1>
@ -61,10 +64,9 @@
</div> </div>
</div> </div>
<div class="panel"> <div class="manage-access">
<div class="panel__heading panel__heading--tight"> <div class="subheading">Manage Access</div>
<h2 class="h3">Manage Access <div class="subtitle">Grant access to an environment</div></h2> <div class="subtitle">Grant access to an environment</div>
</div>
</div> </div>
<div class='search-bar'> <div class='search-bar'>
@ -173,6 +175,9 @@
</edit-application-roles> </edit-application-roles>
{% endfor %} {% endfor %}
</div>
</div>
<div class='action-group'> <div class='action-group'>
<button class='action-group__action usa-button usa-button-big'> <button class='action-group__action usa-button usa-button-big'>
{% if is_new_member %}Create{% else %}Save{% endif %} {% if is_new_member %}Create{% else %}Save{% endif %}
@ -183,8 +188,6 @@
</a> </a>
</div> </div>
</form> </form>
{% endblock %} {% endblock %}

View File

@ -3,6 +3,8 @@
{% from "components/empty_state.html" import EmptyState %} {% from "components/empty_state.html" import EmptyState %}
{% from "components/icon.html" import Icon %} {% from "components/icon.html" import Icon %}
{% set secondary_breadcrumb = 'Portfolio Team Management' %}
{% block portfolio_content %} {% block portfolio_content %}
{% if not portfolio.members %} {% if not portfolio.members %}
@ -25,6 +27,7 @@
<members-list <members-list
inline-template inline-template
id="search-template" id="search-template"
class='member-list'
v-bind:members='{{ members | tojson}}' v-bind:members='{{ members | tojson}}'
v-bind:role_choices='{{ role_choices | tojson}}' v-bind:role_choices='{{ role_choices | tojson}}'
v-bind:status_choices='{{ status_choices | tojson}}'> v-bind:status_choices='{{ status_choices | tojson}}'>
@ -61,7 +64,7 @@
</div> </div>
</form> </form>
<div class='responsive-table-wrapper'> <div class='responsive-table-wrapper panel'>
<table v-cloak v-if='searchedList && searchedList.length'> <table v-cloak v-if='searchedList && searchedList.length'>
<thead> <thead>
<tr> <tr>
@ -82,7 +85,7 @@
<td> <td>
<a :href="member.edit_link" class="icon-link icon-link--large" v-html="member.name"></a> <a :href="member.edit_link" class="icon-link icon-link--large" v-html="member.name"></a>
</td> </td>
<td class="table-cell--align-right" v-if='member.num_env'> <td class="table-cell--align-center" v-if='member.num_env'>
<span v-html="member.num_env"></span> <span v-html="member.num_env"></span>
</td> </td>
<td class='table-cell--shrink' v-else> <td class='table-cell--shrink' v-else>
@ -91,6 +94,14 @@
<td v-html="member.status"></td> <td v-html="member.status"></td>
<td v-html="member.role"></td> <td v-html="member.role"></td>
</tr> </tr>
<tr>
<td class="add-member-link" colspan=4>
<a class="icon-link" href="{{ url_for('portfolios.new_member', portfolio_id=portfolio.id) }}">
Add A New Member
{{ Icon('plus', classes='icon--circle') }}
</a>
</td>
</tr>
</tbody> </tbody>
</table> </table>
<div v-else> <div v-else>

View File

@ -1,47 +1,44 @@
{% extends "portfolios/base.html" %} {% extends "portfolios/base.html" %}
{% from "components/alert.html" import Alert %}
{% from "components/icon.html" import Icon %} {% from "components/icon.html" import Icon %}
{% from "components/empty_state.html" import EmptyState %} {% from "components/empty_state.html" import EmptyState %}
{% set secondary_breadcrumb = "navigation.portfolio_navigation.breadcrumbs.reports" | translate %}
{% block portfolio_content %} {% block portfolio_content %}
{{ Alert("Budget Report for Portfolio " + portfolio.name, <div class='portfolio-reports'>
message="<p>Track your monthly and cumulative expenditures for your portfolio, applications, and environments below.</p>\
<p>Please note that the projected spend is based on the <em>average expense over the last three completed months</em> and therefore does not account for future changes that might be made in scale or configuration of your cloud services.</p>",
actions=[
{"label": "Learn More", "href": url_for('atst.helpdocs'), "icon": "info"}
] ) }}
<div v-cloak class='funding-summary-row'> <div v-cloak class='funding-summary-row'>
<div class='funding-summary-row__col'> <div class='funding-summary-row__col'>
<div class='panel spend-summary'> <div class='panel spend-summary'>
<h4 class='spend-summary__heading subheading'>Portfolio Total Spend</h4>
<div class='row'> <div class='row'>
<h2 class='spend-summary__heading col'>Portfolio Total Spend</h2> <dl class='spend-summary__budget col col--grow row'>
<dl class='spend-summary__budget'>
{% set budget = portfolio_totals['budget'] %} {% set budget = portfolio_totals['budget'] %}
{% set spent = portfolio_totals['spent'] %} {% set spent = portfolio_totals['spent'] %}
{% set remaining = budget - spent %} {% set remaining = budget - spent %}
<div> <dl class='col col--grow'>
<dt>Budget </dt> <dt>Budget</dt>
<dd>{{ budget | dollars }}</dd> <dd>{{ budget | dollars }}</dd>
</div> </dl>
<div> <dl class='col col--grow'>
<dt>Remaining</dt> <dt>Remaining</dt>
<dd>{{ remaining | dollars }}</dd> <dd>{{ remaining | dollars }}</dd>
</div> </dl>
</dl> </dl>
</div> </div>
<hr></hr>
<div> <div>
<meter value='{{ spent }}' min='0' max='{{ budget }}' title='{{ spent | dollars }} Total spend to date'> <meter value='{{ spent }}' min='0' max='{{ budget }}' title='{{ spent | dollars }} Total spend to date'>
<div class='meter__fallback' style='width:{{ (spent / budget) * 100 if budget else 0 }}%;'></div> <div class='meter__fallback' style='width:{{ (spent / budget) * 100 if budget else 0 }}%;'></div>
</meter> </meter>
<dl class='spend-summary__spent'> <dl class='spend-summary__spent'>
<dt>Total spend to date</dt> <dt>Total spending to date</dt>
<dd>{{ spent | dollars }}</dd> <dd>{{ spent | dollars }}</dd>
</dl> </dl>
</div> </div>
@ -53,18 +50,23 @@
<div class='to-summary__row'> <div class='to-summary__row'>
<div class='to-summary__to'> <div class='to-summary__to'>
<h2 class='to-summary__heading'>Task Order</h2> <h2 class='to-summary__heading subheading'>Current Task Order</h2>
<dl class='to-summary__to-number'> <dl class='to-summary__to-number'>
<dt class='usa-sr-only'>Task Order Number</dt> <dt class='usa-sr-only'>Task Order Number</dt>
<dd>{{ legacy_task_order.number }}</dd> <dd>{{ task_order.number }}</dd>
</dl> </dl>
</div> </div>
<hr></hr>
<div class='to-summary__expiration'> <div class='to-summary__expiration'>
<dl> <div class='row'>
<h4 class='subheading'>Expiration Date</h4>
</div>
<div class='row'>
<div class='col col--grow'>
<div> <div>
<dt>Expires</dt>
<dd>
{% if expiration_date %} {% if expiration_date %}
<local-datetime <local-datetime
timestamp='{{ expiration_date }}' timestamp='{{ expiration_date }}'
@ -73,32 +75,49 @@
{% else %} {% else %}
- -
{% endif %} {% endif %}
</dd>
</div> </div>
<a href='{{ url_for("portfolios.view_task_order", portfolio_id=portfolio.id, task_order_id=task_order.id) }}' class='icon-link'>
<div> {{ Icon('cog') }}
<dt>Remaining</dt> Manage Task Order
<dd> </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 %} {% if remaining_days is not none %}
{{ remaining_days }} days {{ Icon('arrow-down') }}
<span>{{ remaining_days }}</span>
{% else %} {% else %}
- -
{% endif %} {% endif %}
</dd> </dd>
</div>
</dl> </dl>
</div>
<a href='{{ url_for("portfolios.portfolio", portfolio_id=portfolio.id) }}' class='icon-link'>
Manage Task Order
</a>
</div> </div>
</div> </div>
</div>
<hr></hr>
<dl class='to-summary__co'> <dl class='to-summary__co'>
<dt>Contracting Officer</dt> <dt class='subheading'>Contracting Officer</dt>
<dd> <dd class='row'>
{{ jedi_request.contracting_officer_full_name }} <div class='col col--grow'>
<a class='icon-link' href='mailto:{{ jedi_request.contracting_officer_email }}'>{{ jedi_request.contracting_officer_email }}</a> {% 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> </dd>
</dl> </dl>
@ -140,7 +159,7 @@
<div class='budget-chart panel' ref='panel'> <div class='budget-chart panel' ref='panel'>
<header class='budget-chart__header panel__heading panel__heading--tight'> <header class='budget-chart__header panel__heading panel__heading--tight'>
<h2 class='h3'>Cumulative Budget</h2> <h4>Cumulative Budget</h4>
<div class='budget-chart__legend'> <div class='budget-chart__legend'>
<dl class='budget-chart__legend__spend'> <dl class='budget-chart__legend__spend'>
@ -329,7 +348,7 @@
<div class='spend-table responsive-table-wrapper'> <div class='spend-table responsive-table-wrapper'>
<div class='spend-table__header'> <div class='spend-table__header'>
<h2 class='spend-table__title'>Total spend per month </h2> <h2 class='spend-table__title'>Total spent per month</h2>
<select name='month' id='month' onchange='location = this.value' class='spend-table__month-select'> <select name='month' id='month' onchange='location = this.value' class='spend-table__month-select'>
{% for m in cumulative_budget["months"] %} {% for m in cumulative_budget["months"] %}
@ -366,12 +385,12 @@
<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'>{{ 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 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 scope='col' class='table-cell--align-right current-month'>{{ current_month.strftime('%B %Y') }}</th>
<th class='current-month'>% of total spend this month</th> <th class='current-month'></th>
</thead> </thead>
<tbody class='spend-table__portfolio'> <tbody class='spend-table__portfolio'>
<tr> <tr>
<th scope='row'>Total</th> <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(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 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--align-right current-month'>{{ portfolio_totals.get(current_month_index, 0) | dollars }}</td>
@ -387,7 +406,7 @@
<tr> <tr>
<th scope='rowgroup'> <th scope='rowgroup'>
<button v-on:click='toggle($event, name)' class='icon-link icon-link--large spend-table__application__toggler'> <button v-on:click='toggle($event, name)' class='icon-link icon-link--large spend-table__application__toggler'>
<template v-if='application.isVisible'>{{ Icon('caret_down') }}</template> <template v-if='application.isVisible'>{{ Icon('caret_down') }}<div class='open-indicator'></div></template>
<template v-else>{{ Icon('caret_right') }}</template> <template v-else>{{ Icon('caret_right') }}</template>
<span v-html='name'></span> <span v-html='name'></span>
</button> </button>
@ -416,10 +435,9 @@
<tr v-for='(environment, envName) in environments[name]' v-show='application.isVisible' class='spend-table__application__env'> <tr v-for='(environment, envName) in environments[name]' v-show='application.isVisible' class='spend-table__application__env'>
<th scope='rowgroup'> <th scope='rowgroup'>
<a href='#' class='icon-link spend-table__application__env'> <div class='icon-link spend-table__application__env'>
{{ Icon('link') }}
<span v-html='envName'></span> <span v-html='envName'></span>
</a> </div>
</th> </th>
<td class='table-cell--align-right previous-month'> <td class='table-cell--align-right previous-month'>
@ -440,7 +458,7 @@
</table> </table>
</spend-table> </spend-table>
</div> </div>
{% endif %} {% endif %}
</div>
{% endblock %} {% endblock %}

View File

@ -3,6 +3,8 @@
{% extends "portfolios/base.html" %} {% extends "portfolios/base.html" %}
{% set secondary_breadcrumb = "navigation.portfolio_navigation.breadcrumbs.funding" | translate %}
{% block portfolio_content %} {% block portfolio_content %}
{% macro ViewLink(task_order) %} {% macro ViewLink(task_order) %}
@ -81,6 +83,7 @@
</a> </a>
</td> </td>
</tr> </tr>
{{ caller and caller() }}
</tbody> </tbody>
</table> </table>
</div> </div>
@ -89,33 +92,15 @@
<div class="portfolio-funding"> <div class="portfolio-funding">
<div class='panel'> <div class='portfolio-funding__header row'>
<div class='panel__content portfolio-funding__header row'>
<h3>Portfolio Funding</h3>
<div class='portfolio-funding__header--funded-through {{ "funded" if funding_end_date is not none and funded else "unfunded"}}'>
{% if funding_end_date and funded %}
{{ Icon('ok') }}
Funded through
<local-datetime
timestamp='{{ funding_end_date }}'
format="M/D/YYYY">
</local-datetime>
{% elif funding_end_date and not funded %}
{{ Icon('alert') }}
Funded period ends
<local-datetime
timestamp='{{ funding_end_date }}'
format="M/D/YYYY">
</local-datetime>
{% endif %}
</div>
<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>
</div>
{% for task_order in pending_task_orders %} {% for task_order in pending_task_orders %}
<div class='panel'> <div class='subheading'>
<div class='panel__content pending-task-order row'> Pending
</div>
<div class='panel pending-task-order row'>
<span class='label label--warning'>Pending</span> <span class='label label--warning'>Pending</span>
<div class="pending-task-order__started col"> <div class="pending-task-order__started col">
<dt>Started</dt> <dt>Started</dt>
@ -132,7 +117,6 @@
</div> </div>
{{ ViewLink(task_order) }} {{ ViewLink(task_order) }}
</div> </div>
</div>
{% endfor %} {% endfor %}
@ -146,16 +130,20 @@
{% endif %} {% endif %}
{% if active_task_orders %} {% if active_task_orders %}
{{ TaskOrderList(active_task_orders, label='success', funded=funded) }} <div class='subheading'>Active</div>
<div class='panel portfolio-total-balance'> {% call TaskOrderList(active_task_orders, label='success', funded=funded) %}
<div class='panel__content row'> <tr class='total-balance'>
<span>{{ total_balance | dollars }}</span> <td colspan='4'>
<span class='label label--success'>Total Active Balance</span> <span class='label label--success'>Total Active Balance</span>
</div> <span>{{ total_balance | dollars }}</span>
</div> </td>
<td>&nbsp;</td>
</tr>
{% endcall %}
{% endif %} {% endif %}
{% if expired_task_orders %} {% if expired_task_orders %}
<div class='subheading'>Expired</div>
{{ TaskOrderList(expired_task_orders, label='expired', expired=True) }} {{ TaskOrderList(expired_task_orders, label='expired', expired=True) }}
{% endif %} {% endif %}
</div> </div>

View File

@ -1,5 +1,7 @@
{% extends "portfolios/base.html" %} {% extends "portfolios/base.html" %}
{% set secondary_breadcrumb = "navigation.portfolio_navigation.breadcrumbs.funding" | translate %}
{% from "components/checkbox_input.html" import CheckboxInput %} {% from "components/checkbox_input.html" import CheckboxInput %}
{% from "components/icon.html" import Icon %} {% from "components/icon.html" import Icon %}
{% from "components/text_input.html" import TextInput %} {% from "components/text_input.html" import TextInput %}
@ -11,11 +13,12 @@
</a> </a>
{% endmacro %} {% endmacro %}
{% macro EditOfficerInfo(form, officer_type) -%} {% macro EditOfficerInfo(form, officer_type, invited) -%}
<div class='officer__form'>
<template v-if="editing"> <template v-if="editing">
<div class='alert'> <div class='officer__form'>
<div class='alert__content'> <div class="edit-officer">
<h4>{{ ("task_orders.invitations." + officer_type + ".edit_title") | translate}}</h4>
</div>
<div class='form-row'> <div class='form-row'>
<div class='form-col form-col--half'> <div class='form-col form-col--half'>
{{ TextInput(form.first_name) }} {{ TextInput(form.first_name) }}
@ -35,9 +38,18 @@
{{ TextInput(form.phone_number, placeholder='(123) 456-7890', validation='usPhone') }} {{ TextInput(form.phone_number, placeholder='(123) 456-7890', validation='usPhone') }}
</div> </div>
</div> </div>
{% if form.dod_id.data %} <div class='form-row officer__form--dodId'>
{{ TextInput(form.dod_id, validation='dodId', disabled=True)}} <div class="form-col">
{% if not invited %}
<div class='form-row'>
{{ CheckboxInput(form.invite, label=(("forms.officers." + officer_type + "_invite") | translate)) }}
</div>
{% endif %} {% endif %}
<div class='form-row'>
{{ TextInput(form.dod_id, tooltip="task_orders.new.oversight.dod_id_tooltip" | translate, tooltip_title='Why', validation='dodId', disabled=invited)}}
</div>
</div>
</div>
<div class='alert__actions officer__form--actions'> <div class='alert__actions officer__form--actions'>
<a href="#{{ officer_type }}" v-on:click="cancel" class="icon-link"> <a href="#{{ officer_type }}" v-on:click="cancel" class="icon-link">
{{ Icon("x") }} {{ Icon("x") }}
@ -46,9 +58,7 @@
<input type='submit' class='usa-button usa-button-primary' value='Save Changes' /> <input type='submit' class='usa-button usa-button-primary' value='Save Changes' />
</div> </div>
</div> </div>
</div>
</template> </template>
</div>
{% endmacro %} {% endmacro %}
{% macro OfficerInfo(task_order, officer_type, form) %} {% macro OfficerInfo(task_order, officer_type, form) %}
@ -65,8 +75,11 @@
{% set email = task_order[prefix + "_email"] %} {% set email = task_order[prefix + "_email"] %}
{% set phone_number = task_order[prefix + "_phone_number"] %} {% set phone_number = task_order[prefix + "_phone_number"] %}
{% set dod_id = task_order[prefix + "_dod_id"] %} {% set dod_id = task_order[prefix + "_dod_id"] %}
{% set invited = False %}
{% if task_order[officer_type] %} {% if task_order[officer_type] %}
{% set invited = True %}
<div class="officer__info"> <div class="officer__info">
<div class="row"> <div class="row">
<div class="officer__info--name">{{ first_name }} {{ last_name }}</div> <div class="officer__info--name">{{ first_name }} {{ last_name }}</div>
@ -99,7 +112,7 @@
<div class="officer__actions"> <div class="officer__actions">
{{ Link("Update", "edit", onClick="edit") }} {{ Link("Update", "edit", onClick="edit") }}
{{ Link("Remove", "trash", classes="remove") }} {{ Link("Remove", "trash", classes="remove") }}
<button type='button' class='usa-button usa-button-primary'> <button v-if="!editing" type='button' class='usa-button usa-button-primary' v-on:click="edit">
{{ ("task_orders.invitations." + officer_type + ".invite_button_text") | translate }} {{ ("task_orders.invitations." + officer_type + ".invite_button_text") | translate }}
</button> </button>
</div> </div>
@ -111,13 +124,13 @@
</div> </div>
</div> </div>
<div class="officer__actions"> <div class="officer__actions">
<button type='button' class='usa-button usa-button-primary'> <button v-if="!editing" type='button' class='usa-button usa-button-primary' v-on:click="edit">
{{ ("task_orders.invitations." + officer_type + ".add_button_text") | translate }} {{ ("task_orders.invitations." + officer_type + ".add_button_text") | translate }}
</button> </button>
</div> </div>
{% endif %} {% endif %}
{{ EditOfficerInfo(form, officer_type) }} {{ EditOfficerInfo(form, officer_type, invited) }}
</div> </div>
</edit-officer-form> </edit-officer-form>
</div> </div>

View File

@ -1,5 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% set secondary_breadcrumb = "navigation.portfolio_navigation.breadcrumbs.funding" | translate %}
{% from "components/edit_link.html" import EditLink %} {% from "components/edit_link.html" import EditLink %}
{% from "components/required_label.html" import RequiredLabel %} {% from "components/required_label.html" import RequiredLabel %}
{% from "components/icon.html" import Icon %} {% from "components/icon.html" import Icon %}
@ -7,6 +9,7 @@
{% from "components/text_input.html" import TextInput %} {% from "components/text_input.html" import TextInput %}
{% from "components/alert.html" import Alert %} {% from "components/alert.html" import Alert %}
{% from "components/review_field.html" import ReviewField %} {% from "components/review_field.html" import ReviewField %}
{% from "components/upload_input.html" import UploadInput %}
{% block content %} {% block content %}
@ -14,21 +17,26 @@
{% include "fragments/flash.html" %} {% include "fragments/flash.html" %}
{% block form_action %}
<form method='POST' action="{{ url_for('portfolios.submit_ko_review', portfolio_id=portfolio.id, task_order_id=task_order.id, form=form) }}" autocomplete="off" enctype="multipart/form-data"> <form method='POST' action="{{ url_for('portfolios.submit_ko_review', portfolio_id=portfolio.id, task_order_id=task_order.id, form=form) }}" autocomplete="off" enctype="multipart/form-data">
{% endblock %}
{{ form.csrf_token }} {{ form.csrf_token }}
{% block form %} {% block form %}
{% set message = "task_orders.ko_review.submitted_by" | translate({"name": task_order.creator.full_name}) %} <div class="top-message">
<h1 class="subheading title">
{{ Alert(("task_orders.ko_review.alert_title" | translate), message, level='warning', {{ "task_orders.ko_review.title" | translate }}
fragment="fragments/ko_review_alert.html") }} </h1>
{% include "fragments/ko_review_message.html" %}
</div>
<div class="panel"> <div class="panel">
<div class="panel__heading"> <div class="panel__heading">
<h1 class="task-order-form__heading subheading"> <h1 class="task-order-form__heading subheading">
<div class="h2">{{ "task_orders.ko_review.title" | translate }}</div> <div class="h2">{{ "task_orders.ko_review.review_title" | translate }}</div>
{{ "task_orders.new.review.section_title"| translate }} {{ "task_orders.new.review.section_title"| translate }}
</h1> </h1>
</div> </div>
@ -58,11 +66,7 @@
<div class="h2">{{ "task_orders.ko_review.task_order_information"| translate }}</div> <div class="h2">{{ "task_orders.ko_review.task_order_information"| translate }}</div>
<div class="form__sub-fields"> <div class="form__sub-fields">
<div class="usa-input"> {{ UploadInput(form.pdf) }}
<div class="usa-input__title">{{ form.pdf.label }}</div>
{{ form.pdf.description }}
{{ form.pdf }}
</div>
{{ TextInput(form.number) }} {{ TextInput(form.number) }}
{{ TextInput(form.loa) }} {{ TextInput(form.loa) }}
{{ TextInput(form.custom_clauses, paragraph=True) }} {{ TextInput(form.custom_clauses, paragraph=True) }}

View File

@ -1,22 +1,47 @@
{% extends "portfolios/base.html" %} {% extends "portfolios/base.html" %}
{% set secondary_breadcrumb = "navigation.portfolio_navigation.breadcrumbs.funding" | translate %}
{% from "components/icon.html" import Icon %} {% from "components/icon.html" import Icon %}
{% block portfolio_content %} {% block portfolio_content %}
{% macro Step(title="", description="", link_text=None, complete=True) %} {% macro officer_name(officer) -%}
<div class="task-order-next-steps__step panel__content row"> {%- if not officer -%}
Not specified
{%- elif officer == g.current_user -%}
You
{%- else -%}
{{ officer.full_name }}
{%- endif -%}
{%- endmacro -%}
{% macro Step(description="", complete=True, button_text=None, button_url=None) %}
<div class="task-order-next-steps__step panel__content">
<div class="row">
<div class="task-order-next-steps__icon col"> <div class="task-order-next-steps__icon col">
<span>{{ Icon("ok", classes="complete" if complete else "incomplete") }}</span> {% if complete %}
<span class="label label--success">Completed</span>
{% else %}
<span class="label">Not Started</span>
{% endif %}
</div> </div>
<div class="task-order-next-steps__text col"> <div class="task-order-next-steps__text col col--grow">
<div class="task-order-next-steps__heading row"> <div class="task-order-next-steps__heading row">
<h4>{{ title }}</h4> <span>{{ description }}</span>
</div>
<div class="task-order-next-steps__description">
{{ description }}
</div> </div>
</div> </div>
<div class="task-order-next-steps__action col">
{% if not task_order.is_active and button_text and button_url %}
<a
href="{{ button_url }}"
class="usa-button usa-button-primary">
{{ button_text }}
</a>
{% endif %}
</div>
</div>
{% if caller %} {% if caller %}
{{ caller() }} {{ caller() }}
{% endif %} {% endif %}
@ -27,7 +52,7 @@
{% set disabled = not link_url %} {% set disabled = not link_url %}
<div class="task-order-document-link"> <div class="task-order-document-link">
<div class="row"> <div class="row">
<a href="{{ link_url }}" class="icon-link {{ 'icon-link--disabled' if disabled }}" aria-disabled="{{ 'true' if disabled else 'false' }}"> <a {% if not disabled %}href="{{ link_url }}"{% endif %} class="icon-link {{ 'icon-link--disabled' if disabled }}" aria-disabled="{{ 'true' if disabled else 'false' }}">
<div class="task-order-document-link__icon col"> <div class="task-order-document-link__icon col">
<span>{{ Icon("download") }}</span> <span>{{ Icon("download") }}</span>
</div> </div>
@ -44,15 +69,28 @@
</div> </div>
{% endmacro %} {% endmacro %}
{% macro InvitationStatus(title, officer) %} {% macro InvitationStatus(title, officer, officer_info) %}
{% set class = "invited" if officer else "uninvited" %} {% set class = "invited" if officer else "uninvited" %}
<div class="task-order-invitation-status row"> <div class="task-order-invitation-status row">
<div class="task-order-invitation-status__icon col"> <div class="task-order-invitation-status__icon col">
<span>{{ Icon("ok" if officer else "alert", classes=class) }}</span> <span>{{ Icon("avatar" if officer else "alert", classes=class) }}</span>
</div> </div>
<div class="task-order-invitation-status__title col {{ class }}"> <div class="col">
<div class="task-order-invitation-status__title {{ class }}">
{{ title }} {{ title }}
</div> </div>
<div class="task-order-invitation-details">
{% if officer_info %}
<div class="col">
<div>{{ officer_info.first_name }} {{ officer_info.last_name }}</div>
<div>{{ officer_info.email }}</div>
<div>{{ officer_info.phone_number | usPhone }}</div>
</div>
{% else %}
<span>Not specified</span>
{% endif %}
</div>
</div>
</div> </div>
{% endmacro %} {% endmacro %}
@ -85,42 +123,42 @@
<div class="task-order-details"> <div class="task-order-details">
<div id="next-steps" class="task-order-next-steps"> <div id="next-steps" class="task-order-next-steps">
<div class="panel"> <div class="panel">
<h3 class="task-order-next-steps__panel-head panel__content">What's next?</h3> <h3 class="task-order-next-steps__panel-head panel__content">{{ "task_orders.view.whats_next" | translate }}</h3>
{% call Step( {% call Step(
title="Submit draft Task Order", description="task_orders.view.steps.draft" | translate({
description="Complete initial task order request form.", "contact": officer_name(task_order.creator)
link_text="edit", })| safe,
button_url=url_for("task_orders.new", screen=1, task_order_id=task_order.id),
button_text='Edit',
complete=all_sections_complete) %} complete=all_sections_complete) %}
<div class="task-order-next-steps__action col">
{% if user == task_order.contracting_officer %}
{% set url=url_for("portfolios.ko_review", portfolio_id=portfolio.id, task_order_id=task_order.id) %}
{% else %}
{% set url = url_for("task_orders.new", screen=1, task_order_id=task_order.id) %}
{% endif %}
<a
href="{{ url }}"
class="usa-button usa-button-primary">
Edit
</a>
</div>
{% endcall %} {% endcall %}
{{ Step( {{ Step(
title="Complete a Security Requirements Document", description="task_orders.view.steps.security" | translate({
description="The IA Security Official you specified received an email invitation to complete and sign a DD-254: Security Requirements document that's been customized for the JEDI program here.", "security_officer": officer_name(task_order.security_officer)
}) | safe,
complete=False) }} complete=False) }}
{% call Step(
description="task_orders.view.steps.record" | translate({
"contracting_officer": officer_name(task_order.contracting_officer),
"contracting_officer_representative": officer_name(task_order.contracting_officer_representative)
}) | safe,
complete=False) %}
<div class='alert alert--warning'>
<div class='alert__content'>
<div class='alert__message'>
{{ "task_orders.view.steps.record_description" | translate | safe }}
</div>
</div>
</div>
{% endcall %}
{% set is_ko = user == task_order.contracting_officer %}
{{ Step( {{ Step(
title="Prepare the Task Order Documents for your organization's contracting system", description="task_orders.view.steps.sign" | translate({
description="You'll file your task order in your organization's contracting system. Change the formatting based on your office prefers.", "contracting_officer": officer_name(task_order.contracting_officer)
}) | safe,
button_url=is_ko and url_for("portfolios.ko_review", portfolio_id=portfolio.id, task_order_id=task_order.id),
button_text=is_ko and 'Sign',
complete=False) }} complete=False) }}
{{ Step(
title="Get a funding document",
description="User your organization's normal process to get a funding document, typically from your financial manager. Your Contracting Officer's Representative (COR) or Contracting Officer (KO) can help with this, too.",
complete=False) }}
{{ Step(
title="Have your KO submit your final task order",
description="Your KO will submit the final task order into your organization's contracting system and receive an official task order number. Your KO should enter your task order number in this system, along with a copy of the submitted task order.",
complete=False) }}
<h4 class="panel__content">Once your required information is submitted in this system, you're funded and ready to start using JEDI cloud services!</h4>
</div> </div>
</div> </div>
<div class="task-order-sidebar col"> <div class="task-order-sidebar col">
@ -133,13 +171,23 @@
format="M/D/YYYY"> format="M/D/YYYY">
</local-datetime> </local-datetime>
{%- endset %} {%- endset %}
{% if task_order.pdf %}
{{ DocumentLink(
title="Task Order",
link_url=url_for('task_orders.download_task_order_pdf', task_order_id=task_order.id),
description=description) }}
{% else %}
{{ DocumentLink( {{ DocumentLink(
title="Task Order Draft", title="Task Order Draft",
link_url=all_sections_complete and url_for('task_orders.download_summary', task_order_id=task_order.id), link_url=all_sections_complete and url_for('task_orders.download_summary', task_order_id=task_order.id),
description=description) }} description=description) }}
{% endif %}
</div> </div>
<hr /> <hr />
<div class="panel__content"> <div class="panel__content">
{{ DocumentLink(
title="Instruction Sheet",
link_url="#") }}
{{ DocumentLink( {{ DocumentLink(
title="Cloud Services Estimate", title="Cloud Services Estimate",
link_url=task_order.csp_estimate and url_for("task_orders.download_csp_estimate", task_order_id=task_order.id) ) }} link_url=task_order.csp_estimate and url_for("task_orders.download_csp_estimate", task_order_id=task_order.id) ) }}
@ -153,16 +201,17 @@
</div> </div>
<div class="task-order-invitations panel"> <div class="task-order-invitations panel">
<div class="panel__content"> <div class="panel__content">
<div class="task-order-invitations__heading row">
<h3>Invitations</h3> <h3>Invitations</h3>
{{ InvitationStatus('Contracting Officer', task_order.contracting_officer) }}
{{ InvitationStatus('Contracting Officer Representative', task_order.contracting_officer_representative) }}
{{ InvitationStatus('IA Security Officer', officer=task_order.security_officer) }}
<a href="{{ url_for('portfolios.task_order_invitations', portfolio_id=portfolio.id, task_order_id=task_order.id) }}" class="icon-link"> <a href="{{ url_for('portfolios.task_order_invitations', portfolio_id=portfolio.id, task_order_id=task_order.id) }}" class="icon-link">
<span>manage</span>
{{ Icon("edit") }} {{ Icon("edit") }}
<span>manage invitations</span>
</a> </a>
</div> </div>
{{ InvitationStatus('Contracting Officer', task_order.contracting_officer, officer_info=task_order.officer_dictionary('contracting_officer')) }}
{{ InvitationStatus('Contracting Officer Representative', task_order.contracting_officer_representative, officer_info=task_order.officer_dictionary('contracting_officer_representative')) }}
{{ InvitationStatus('IA Security Officer', officer=task_order.security_officer, officer_info=task_order.officer_dictionary('security_officer')) }}
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -11,6 +11,7 @@
{% block form %} {% block form %}
<!-- App Info Section --> <!-- App Info Section -->
<h3 class="task-order-form__heading subheading">{{ "task_orders.new.app_info.basic_info_title"| translate }}</h3> <h3 class="task-order-form__heading subheading">{{ "task_orders.new.app_info.basic_info_title"| translate }}</h3>
{{ TextInput(form.portfolio_name, placeholder="The name of your office or organization", validation="portfolioName") }} {{ TextInput(form.portfolio_name, placeholder="The name of your office or organization", validation="portfolioName") }}

View File

@ -3,6 +3,7 @@
{% from "components/text_input.html" import TextInput %} {% from "components/text_input.html" import TextInput %}
{% from "components/options_input.html" import OptionsInput %} {% from "components/options_input.html" import OptionsInput %}
{% from "components/date_input.html" import DateInput %} {% from "components/date_input.html" import DateInput %}
{% from "components/upload_input.html" import UploadInput %}
{% from "components/icon.html" import Icon %} {% from "components/icon.html" import Icon %}
@ -32,22 +33,7 @@
{{ Icon("link")}} Go to Cloud Service Providers estimate calculator {{ Icon("link")}} Go to Cloud Service Providers estimate calculator
</a></p> </a></p>
<p>{{ "task_orders.new.funding.estimate_usage_paragraph" | translate }}</p> <p>{{ "task_orders.new.funding.estimate_usage_paragraph" | translate }}</p>
<template v-if="showUpload"> {{ UploadInput(form.csp_estimate, show_label=True) }}
<div class="usa-input {% if form.csp_estimate.errors %} usa-input--error {% endif %}">
{{ form.csp_estimate.label }}
{{ form.csp_estimate.description }}
{{ form.csp_estimate }}
{% for error in form.csp_estimate.errors %}
<span class="usa-input__message">{{error}}</span>
{% endfor %}
</div>
</template>
<template v-else>
<p>Uploaded {{ form.csp_estimate.data.filename }}</p>
<div>
<button type="button" v-on:click="showUploadInput">Change</button>
</div>
</template>
<hr> <hr>

View File

@ -18,7 +18,7 @@
<h1 class="panel__content">Let's get started</h1> <h1 class="panel__content">Let's get started</h1>
<div class="panel__content"> <div class="panel__content">
<p class="centered"> <p class="centered">
To create a portfolio of JEDI cloud applications, you'll complete and submit a task order in your organization's system of record. We'll help you complete the necessary pieces of that task order, including your: To create a portfolio of JEDI cloud applications, you'll need to submit a task order in your organization's system of record. We'll walk you through the necessary steps of that task order, including the following:
</p> </p>
</div> </div>
<span class="task-order-get-started__list panel__content"> <span class="task-order-get-started__list panel__content">
@ -33,7 +33,7 @@
</div> </div>
<div class="panel task-order-needs"> <div class="panel task-order-needs">
<h1 class="panel__content">You'll need help getting a task order</h1> <h1 class="panel__content">You'll need a little help getting a task order</h1>
<div class="panel__content task-order-needs__list"> <div class="panel__content task-order-needs__list">
{{ Help( {{ Help(
name="Development Lead", name="Development Lead",
@ -43,13 +43,13 @@
{{ Help( {{ Help(
name="Security Lead", name="Security Lead",
icon_name="shield", icon_name="shield",
description="Your security lead will review and approve your security classification needs, as well as a standardized DD-254.", description="Your security lead will review and approve your security classification needs. They will also review and complete a standardized DD-254.",
link_text="You'll need their DOD ID number") }} link_text="You'll need their DoD ID number") }}
{{ Help( {{ Help(
name="Contracting Officer", name="Contracting Officer",
icon_name="dollar-sign", icon_name="dollar-sign",
description="Your contracting officer will review your funding needs and ultimately approve your task order.", description="Your contracting officer will review your funding needs and ultimately approve your task order.",
link_text="You'll need their DOD ID number") }} link_text="You'll need their DoD ID number") }}
</div> </div>
</div> </div>
@ -62,7 +62,7 @@
<a href="{{ url_for("task_orders.new", screen=1) }}" class="usa-button usa-button-big">Let's do cloud!</a> <a href="{{ url_for("task_orders.new", screen=1) }}" class="usa-button usa-button-big">Let's do cloud!</a>
</div> </div>
<div> <div>
<p class="centered">Create a JEDI cloud application portfolio & start building a task order</p> <p class="centered">Create a portfolio by starting a new task order</p>
</div> </div>
</div> </div>
</div> </div>

View File

@ -19,7 +19,7 @@
{{ CheckboxInput(form.ko_invite) }} {{ CheckboxInput(form.ko_invite) }}
<keep-alive> <keep-alive>
<dodid v-bind:initial-invite="ko_invite" inline-template v-if="ko_invite"> <dodid v-bind:initial-invite="ko_invite" inline-template v-if="ko_invite">
{{ TextInput(form.ko_dod_id, placeholder="1234567890", tooltip="Why", tooltip_title='Why', validation='dodId', classes="task-order__invite-officer")}} {{ TextInput(form.ko_dod_id, placeholder="1234567890", tooltip="task_orders.new.oversight.dod_id_tooltip" | translate, tooltip_title='Why', validation='dodId', classes="task-order__invite-officer")}}
</dodid> </dodid>
</keep-alive> </keep-alive>
@ -35,7 +35,7 @@
{{ UserInfo(form.cor_first_name, form.cor_last_name, form.cor_email, form.cor_phone_number) }} {{ UserInfo(form.cor_first_name, form.cor_last_name, form.cor_email, form.cor_phone_number) }}
{{ CheckboxInput(form.cor_invite) }} {{ CheckboxInput(form.cor_invite) }}
<template v-if="cor_invite"> <template v-if="cor_invite">
{{ TextInput(form.cor_dod_id, placeholder="1234567890", tooltip="Why", tooltip_title='Why', validation='dodId', classes="task-order__invite-officer")}} {{ TextInput(form.cor_dod_id, placeholder="1234567890", tooltip="task_orders.new.oversight.dod_id_tooltip" | translate, tooltip_title='Why', validation='dodId', classes="task-order__invite-officer")}}
</template> </template>
</div> </div>
</cordata> </cordata>
@ -49,10 +49,11 @@
{{ CheckboxInput(form.so_invite) }} {{ CheckboxInput(form.so_invite) }}
<keep-alive> <keep-alive>
<dodid v-bind:initial-invite="so_invite" inline-template v-if="so_invite"> <dodid v-bind:initial-invite="so_invite" inline-template v-if="so_invite">
{{ TextInput(form.so_dod_id, placeholder="1234567890", tooltip="Why", tooltip_title='Why', validation='dodId', classes="task-order__invite-officer")}} {{ TextInput(form.so_dod_id, placeholder="1234567890", tooltip="task_orders.new.oversight.dod_id_tooltip" | translate, tooltip_title='Why', validation='dodId', classes="task-order__invite-officer")}}
</dodid> </dodid>
</keep-alive> </keep-alive>
</div> </div>
</oversight> </oversight>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,56 @@
{% extends "base.html" %}
{% from "components/text_input.html" import TextInput %}
{% from "components/checkbox_input.html" import CheckboxInput %}
{% from "components/icon.html" import Icon %}
{% block content %}
<form method="POST" action='{{ url_for("task_orders.record_signature", task_order_id=task_order_id) }}'>
{{ form.csrf_token }}
<div class="row row--pad">
<div class="col col--pad">
<div class="panel">
<div class="panel__heading">
<h1 class="task-order-form__heading subheading">
<div class="h2">{{ "task_orders.sign.task_order_builder_title" | translate }}</div>
{{ "task_orders.sign.title" | translate }}
</h1>
</div>
<div class="panel__content">
<div is="levelofwarrant" inline-template v-bind:initial-data='{{ form.data|tojson }}'>
<div>
<span v-bind:class="{ hide: !unlimited_level_of_warrant }">
{{ TextInput(form.level_of_warrant, validation='dollars', placeholder='$0.00', disabled=True) }}
</span>
<span v-bind:class="{ hide: unlimited_level_of_warrant }">
{{ TextInput(form.level_of_warrant, validation='dollars', placeholder='$0.00') }}
</span>
{{ CheckboxInput(form.unlimited_level_of_warrant) }}
</div>
</div>
{{ CheckboxInput(form.signature) }}
</div>
</div>
<div class="action-group">
<button class="usa-button usa-button-big usa-button-primary">
{{ "common.sign" | translate }}
</button>
<a
href="{{ request.referrer or url_for("atst.home") }}"
class="action-group__action icon-link">
{{ Icon('caret_left') }}
<span class="icon icon--x"></span>
{{ "common.back" | translate }}
</a>
</div>
</div>
</div>
</form>
{% endblock %}

View File

@ -0,0 +1,22 @@
from atst.domain.environments import Environments
from tests.factories import ApplicationFactory, UserFactory
def test_application_num_users():
application = ApplicationFactory.create(
environments=[{"name": "dev"}, {"name": "staging"}, {"name": "prod"}]
)
assert application.num_users == 0
first_env = application.environments[0]
user1 = UserFactory()
Environments.add_member(first_env, user1, "developer")
assert application.num_users == 1
second_env = application.environments[-1]
Environments.add_member(second_env, user1, "developer")
assert application.num_users == 1
user2 = UserFactory()
Environments.add_member(second_env, user2, "developer")
assert application.num_users == 2

View File

@ -9,11 +9,14 @@ from tests.mocks import PDF_FILENAME
class TestTaskOrderStatus: class TestTaskOrderStatus:
def test_pending_status(self): def test_started_status(self):
to = TaskOrder() to = TaskOrder()
assert to.status == Status.PENDING assert to.status == Status.STARTED
to = TaskOrder(number="42", start_date=random_future_date()) def test_pending_status(self):
to = TaskOrder(
number="42", start_date=random_future_date(), end_date=random_future_date()
)
assert to.status == Status.PENDING assert to.status == Status.PENDING
def test_active_status(self): def test_active_status(self):
@ -47,7 +50,7 @@ class TestCSPEstimate:
attachment = Attachment(filename="sample.pdf", object_name="sample") attachment = Attachment(filename="sample.pdf", object_name="sample")
to.csp_estimate = attachment to.csp_estimate = attachment
assert to.attachment_id == attachment.id assert to.csp_attachment_id == attachment.id
def test_setting_estimate_with_file_storage(self): def test_setting_estimate_with_file_storage(self):
to = TaskOrder() to = TaskOrder()
@ -77,3 +80,41 @@ class TestCSPEstimate:
to.csp_estimate = "" to.csp_estimate = ""
assert to.csp_estimate is None assert to.csp_estimate is None
class TestPDF:
def test_setting_pdf_with_attachment(self):
to = TaskOrder()
attachment = Attachment(filename="sample.pdf", object_name="sample")
to.pdf = attachment
assert to.pdf_attachment_id == attachment.id
def test_setting_pdf_with_file_storage(self):
to = TaskOrder()
with open(PDF_FILENAME, "rb") as fp:
fs = FileStorage(fp, content_type="application/pdf")
to.pdf = fs
assert to.pdf is not None
assert to.pdf.filename == PDF_FILENAME
def test_setting_pdf_with_invalid_object(self):
to = TaskOrder()
with pytest.raises(TypeError):
to.pdf = "invalid"
def test_setting_pdf_with_empty_value(self):
to = TaskOrder()
assert to.pdf is None
to.pdf = ""
assert to.pdf is None
def test_removing_pdf(self):
attachment = Attachment(filename="sample.pdf", object_name="sample")
to = TaskOrder(pdf=attachment)
assert to.pdf is not None
to.pdf = ""
assert to.pdf is None

View File

@ -1,3 +1,4 @@
import pytest
from flask import url_for from flask import url_for
from tests.factories import ( from tests.factories import (
@ -20,7 +21,7 @@ def test_user_with_permission_has_budget_report_link(client, user_session):
user_session(portfolio.owner) user_session(portfolio.owner)
response = client.get("/portfolios/{}/applications".format(portfolio.id)) response = client.get("/portfolios/{}/applications".format(portfolio.id))
assert ( assert (
'href="/portfolios/{}/reports"'.format(portfolio.id).encode() in response.data "href='/portfolios/{}/reports'".format(portfolio.id).encode() in response.data
) )
@ -38,6 +39,7 @@ def test_user_without_permission_has_no_budget_report_link(client, user_session)
) )
@pytest.mark.skip(reason="Temporarily no add activity log link")
def test_user_with_permission_has_activity_log_link(client, user_session): def test_user_with_permission_has_activity_log_link(client, user_session):
portfolio = PortfolioFactory.create() portfolio = PortfolioFactory.create()
ccpo = UserFactory.from_atat_role("ccpo") ccpo = UserFactory.from_atat_role("ccpo")
@ -69,6 +71,7 @@ def test_user_with_permission_has_activity_log_link(client, user_session):
) )
@pytest.mark.skip(reason="Temporarily no add activity log link")
def test_user_without_permission_has_no_activity_log_link(client, user_session): def test_user_without_permission_has_no_activity_log_link(client, user_session):
portfolio = PortfolioFactory.create() portfolio = PortfolioFactory.create()
developer = UserFactory.create() developer = UserFactory.create()
@ -87,6 +90,7 @@ def test_user_without_permission_has_no_activity_log_link(client, user_session):
) )
@pytest.mark.skip(reason="Temporarily no add application link")
def test_user_with_permission_has_add_application_link(client, user_session): def test_user_with_permission_has_add_application_link(client, user_session):
portfolio = PortfolioFactory.create() portfolio = PortfolioFactory.create()
user_session(portfolio.owner) user_session(portfolio.owner)
@ -97,6 +101,7 @@ def test_user_with_permission_has_add_application_link(client, user_session):
) )
@pytest.mark.skip(reason="Temporarily no add application link")
def test_user_without_permission_has_no_add_application_link(client, user_session): def test_user_without_permission_has_no_add_application_link(client, user_session):
user = UserFactory.create() user = UserFactory.create()
portfolio = PortfolioFactory.create() portfolio = PortfolioFactory.create()

View File

@ -212,27 +212,20 @@ def test_existing_member_invite_resent_to_email_submitted_in_form(
def test_contracting_officer_accepts_invite(monkeypatch, client, user_session): def test_contracting_officer_accepts_invite(monkeypatch, client, user_session):
portfolio = PortfolioFactory.create() portfolio = PortfolioFactory.create()
task_order = TaskOrderFactory.create(portfolio=portfolio)
user_info = UserFactory.dictionary() user_info = UserFactory.dictionary()
task_order = TaskOrderFactory.create(
portfolio=portfolio,
ko_first_name=user_info["first_name"],
ko_last_name=user_info["last_name"],
ko_email=user_info["email"],
ko_phone_number=user_info["phone_number"],
ko_dod_id=user_info["dod_id"],
ko_invite=True,
)
# create contracting officer # create contracting officer
user_session(portfolio.owner) user_session(portfolio.owner)
client.post( client.post(url_for("task_orders.invite", task_order_id=task_order.id))
url_for("task_orders.new", screen=3, task_order_id=task_order.id),
data={
"portfolio_role": "contracting_officer",
"ko_first_name": user_info["first_name"],
"ko_last_name": user_info["last_name"],
"ko_email": user_info["email"],
"ko_phone_number": user_info["phone_number"],
"ko_dod_id": user_info["dod_id"],
"cor_phone_number": user_info["phone_number"],
"so_phone_number": user_info["phone_number"],
"so_dod_id": task_order.so_dod_id,
"cor_dod_id": task_order.cor_dod_id,
"ko_invite": True,
},
)
# contracting officer accepts invitation # contracting officer accepts invitation
user = Users.get_by_dod_id(user_info["dod_id"]) user = Users.get_by_dod_id(user_info["dod_id"])

View File

@ -1,3 +1,4 @@
import pytest
from flask import url_for from flask import url_for
from tests.factories import ( from tests.factories import (
@ -36,6 +37,7 @@ def create_portfolio_and_invite_user(
return portfolio return portfolio
@pytest.mark.skip(reason="Temporarily no add member link")
def test_user_with_permission_has_add_member_link(client, user_session): def test_user_with_permission_has_add_member_link(client, user_session):
portfolio = PortfolioFactory.create() portfolio = PortfolioFactory.create()
user_session(portfolio.owner) user_session(portfolio.owner)
@ -46,6 +48,7 @@ def test_user_with_permission_has_add_member_link(client, user_session):
) )
@pytest.mark.skip(reason="Temporarily no add member link")
def test_user_without_permission_has_no_add_member_link(client, user_session): def test_user_without_permission_has_no_add_member_link(client, user_session):
user = UserFactory.create() user = UserFactory.create()
portfolio = PortfolioFactory.create() portfolio = PortfolioFactory.create()
@ -89,6 +92,7 @@ def test_create_member(client, user_session):
) )
assert response.status_code == 200 assert response.status_code == 200
assert user.full_name in response.data.decode()
assert user.has_portfolios assert user.has_portfolios
assert user.invitations assert user.invitations
assert len(queue.get_queue()) == queue_length + 1 assert len(queue.get_queue()) == queue_length + 1

View File

@ -1,6 +1,7 @@
from flask import url_for from flask import url_for
from tests.factories import PortfolioFactory from tests.factories import PortfolioFactory, UserFactory
from atst.utils.localization import translate
def test_update_portfolio_name(client, user_session): def test_update_portfolio_name(client, user_session):
@ -13,3 +14,29 @@ def test_update_portfolio_name(client, user_session):
) )
assert response.status_code == 200 assert response.status_code == 200
assert portfolio.name == "a cool new name" assert portfolio.name == "a cool new name"
def test_portfolio_index_with_existing_portfolios(client, user_session):
portfolio = PortfolioFactory.create()
user_session(portfolio.owner)
response = client.get(url_for("portfolios.portfolios"))
assert response.status_code == 200
assert portfolio.name.encode("utf8") in response.data
assert (
translate("portfolios.index.empty.start_button").encode("utf8")
not in response.data
)
def test_portfolio_index_without_existing_portfolios(client, user_session):
user = UserFactory.create()
user_session(user)
response = client.get(url_for("portfolios.portfolios"))
assert response.status_code == 200
assert (
translate("portfolios.index.empty.start_button").encode("utf8") in response.data
)

Some files were not shown because too many files have changed in this diff Show More