diff --git a/atst/app.py b/atst/app.py index 06f3c475..ae207b01 100644 --- a/atst/app.py +++ b/atst/app.py @@ -63,7 +63,7 @@ def make_flask_callbacks(app): def _set_globals(): g.navigationContext = ( "workspace" - if re.match("\/workspaces\/[A-Za-z0-9]*", request.url) + if re.match("\/workspaces\/[A-Za-z0-9]*", request.path) else "global" ) g.dev = os.getenv("FLASK_ENV", "dev") == "dev" diff --git a/atst/domain/authnid/crl/util.py b/atst/domain/authnid/crl/util.py index 7e9948e1..a26835c5 100644 --- a/atst/domain/authnid/crl/util.py +++ b/atst/domain/authnid/crl/util.py @@ -1,10 +1,13 @@ import requests import re import os +import pendulum from html.parser import HTMLParser _DISA_CRLS = "https://iasecontent.disa.mil/pki-pke/data/crls/dod_crldps.htm" +MODIFIED_TIME_BUFFER = 15 * 60 + def fetch_disa(): response = requests.get(_DISA_CRLS) @@ -29,29 +32,67 @@ def crl_list_from_disa_html(html): return parser.crl_list -def write_crl(out_dir, crl_location): +def crl_local_path(out_dir, crl_location): name = re.split("/", crl_location)[-1] crl = os.path.join(out_dir, name) - with requests.get(crl_location, stream=True) as r: + return crl + + +def existing_crl_modification_time(crl): + if os.path.exists(crl): + prev_time = os.path.getmtime(crl) + buffered = prev_time + MODIFIED_TIME_BUFFER + mod_time = prev_time if pendulum.now().timestamp() < buffered else buffered + dt = pendulum.from_timestamp(mod_time, tz="GMT") + return dt.format("ddd, DD MMM YYYY HH:mm:ss zz") + + else: + return False + + +def write_crl(out_dir, target_dir, crl_location): + crl = crl_local_path(out_dir, crl_location) + existing = crl_local_path(target_dir, crl_location) + options = {"stream": True} + mod_time = existing_crl_modification_time(existing) + if mod_time: + options["headers"] = {"If-Modified-Since": mod_time} + + with requests.get(crl_location, **options) as response: + if response.status_code == 304: + return False + with open(crl, "wb") as crl_file: - for chunk in r.iter_content(chunk_size=1024): + for chunk in response.iter_content(chunk_size=1024): if chunk: crl_file.write(chunk) + return True -def refresh_crls(out_dir, logger=None): + +def remove_bad_crl(out_dir, crl_location): + crl = crl_local_path(out_dir, crl_location) + os.remove(crl) + + +def refresh_crls(out_dir, target_dir, logger): disa_html = fetch_disa() crl_list = crl_list_from_disa_html(disa_html) for crl_location in crl_list: - if logger: - logger.info("updating CRL from {}".format(crl_location)) + logger.info("updating CRL from {}".format(crl_location)) try: - write_crl(out_dir, crl_location) + if write_crl(out_dir, target_dir, crl_location): + logger.info("successfully synced CRL from {}".format(crl_location)) + else: + logger.info("no updates for CRL from {}".format(crl_location)) except requests.exceptions.ChunkedEncodingError: if logger: logger.error( - "Error downloading {}, continuing anyway".format(crl_location) + "Error downloading {}, removing file and continuing anyway".format( + crl_location + ) ) + remove_bad_crl(out_dir, crl_location) if __name__ == "__main__": @@ -64,7 +105,7 @@ if __name__ == "__main__": logger = logging.getLogger() logger.info("Updating CRLs") try: - refresh_crls(sys.argv[1], logger=logger) + refresh_crls(sys.argv[1], sys.argv[2], logger) except Exception as err: logger.exception("Fatal error encountered, stopping") sys.exit(1) diff --git a/atst/domain/date.py b/atst/domain/date.py new file mode 100644 index 00000000..4a131671 --- /dev/null +++ b/atst/domain/date.py @@ -0,0 +1,12 @@ +import pendulum + + +def parse_date(data): + date_formats = ["YYYY-MM-DD", "MM/DD/YYYY"] + for _format in date_formats: + try: + return pendulum.from_format(data, _format).date() + except (ValueError, pendulum.parsing.exceptions.ParserError): + pass + + raise ValueError("Unable to parse string {}".format(data)) diff --git a/atst/domain/requests.py b/atst/domain/requests.py index 3986a849..fa122d71 100644 --- a/atst/domain/requests.py +++ b/atst/domain/requests.py @@ -1,4 +1,6 @@ +from enum import Enum from sqlalchemy import exists, and_, exc +from sqlalchemy.sql import text from sqlalchemy.orm.exc import NoResultFound from sqlalchemy.orm.attributes import flag_modified @@ -150,3 +152,47 @@ class Requests(object): @classmethod def is_pending_financial_verification(cls, request): return request.status == RequestStatus.PENDING_FINANCIAL_VERIFICATION + + @classmethod + def is_pending_ccpo_approval(cls, request): + return request.status == RequestStatus.PENDING_CCPO_APPROVAL + + @classmethod + def status_count(cls, status, creator=None): + if isinstance(status, Enum): + status = status.name + bindings = {"status": status} + raw = """ +SELECT count(requests_with_status.id) +FROM ( + SELECT DISTINCT ON (rse.request_id) r.*, rse.new_status as status + FROM request_status_events rse JOIN requests r ON r.id = rse.request_id + ORDER BY rse.request_id, rse.sequence DESC +) as requests_with_status +WHERE requests_with_status.status = :status + """ + + if creator: + raw += " AND requests_with_status.user_id = :user_id" + bindings["user_id"] = creator.id + + results = db.session.execute(text(raw), bindings).fetchone() + (count,) = results + return count + + @classmethod + def in_progress_count(cls): + return sum([ + Requests.status_count(RequestStatus.STARTED), + Requests.status_count(RequestStatus.PENDING_FINANCIAL_VERIFICATION), + Requests.status_count(RequestStatus.CHANGES_REQUESTED), + ]) + + @classmethod + def pending_ccpo_count(cls): + return Requests.status_count(RequestStatus.PENDING_CCPO_APPROVAL) + + @classmethod + def completed_count(cls): + return Requests.status_count(RequestStatus.APPROVED) + diff --git a/atst/forms/fields.py b/atst/forms/fields.py index 00e53529..2c06154a 100644 --- a/atst/forms/fields.py +++ b/atst/forms/fields.py @@ -1,20 +1,14 @@ from wtforms.fields.html5 import DateField from wtforms.fields import Field from wtforms.widgets import TextArea -import pendulum + +from atst.domain.date import parse_date class DateField(DateField): def _value(self): if self.data: - date_formats = ["YYYY-MM-DD", "MM/DD/YYYY"] - for _format in date_formats: - try: - return pendulum.from_format(self.data, _format).date() - except (ValueError, pendulum.parsing.exceptions.ParserError): - pass - - raise ValueError("Unable to parse string {}".format(self.data)) + return parse_date(self.data) else: return None diff --git a/atst/forms/financial.py b/atst/forms/financial.py index 674eace4..994e84bf 100644 --- a/atst/forms/financial.py +++ b/atst/forms/financial.py @@ -1,7 +1,6 @@ import re from wtforms.fields.html5 import EmailField from wtforms.fields import StringField, SelectField -from wtforms.form import Form from wtforms.validators import Required, Email from atst.domain.exceptions import NotFoundError @@ -41,7 +40,7 @@ def suggest_pe_id(pe_id): def validate_pe_id(field, existing_request): try: - pe_number = PENumbers.get(field.data) + PENumbers.get(field.data) except NotFoundError: suggestion = suggest_pe_id(field.data) error_str = ( diff --git a/atst/forms/forms.py b/atst/forms/forms.py index 2aaa4973..ce0ff791 100644 --- a/atst/forms/forms.py +++ b/atst/forms/forms.py @@ -6,3 +6,9 @@ class ValidatedForm(FlaskForm): """Performs any applicable extra validation. Must return True if the form is valid or False otherwise.""" return True + + @property + def data(self): + _data = super().data + _data.pop("csrf_token", None) + return _data diff --git a/atst/forms/org.py b/atst/forms/org.py index 7fc21986..a812ce4e 100644 --- a/atst/forms/org.py +++ b/atst/forms/org.py @@ -1,5 +1,5 @@ from wtforms.fields.html5 import EmailField, TelField -from wtforms.fields import RadioField, StringField +from wtforms.fields import RadioField, StringField, SelectField from wtforms.validators import Required, Email import pendulum from .fields import DateField @@ -12,13 +12,67 @@ class OrgForm(ValidatedForm): lname_request = StringField("Last Name", validators=[Required(), Alphabet()]) - email_request = EmailField("Email Address", validators=[Required(), Email()]) + email_request = EmailField("E-mail Address", validators=[Required(), Email()]) - phone_number = TelField("Phone Number", validators=[Required(), PhoneNumber()]) + phone_number = TelField("Phone Number", + description='Enter a 10-digit phone number', + validators=[Required(), PhoneNumber()]) - service_branch = StringField("Service Branch or Agency", validators=[Required()]) + service_branch = SelectField( + "Service Branch or Agency", + description="Which services and organizations do you belong to within the DoD?", + choices=[ + ("null", "Select an option"), + ("Air Force, Department of the", "Air Force, Department of the"), + ("Army and Air Force Exchange Service", "Army and Air Force Exchange Service"), + ("Army, Department of the", "Army, Department of the"), + ("Defense Advanced Research Projects Agency", "Defense Advanced Research Projects Agency"), + ("Defense Commissary Agency", "Defense Commissary Agency"), + ("Defense Contract Audit Agency", "Defense Contract Audit Agency"), + ("Defense Contract Management Agency", "Defense Contract Management Agency"), + ("Defense Finance & Accounting Service", "Defense Finance & Accounting Service"), + ("Defense Health Agency", "Defense Health Agency"), + ("Defense Information System Agency", "Defense Information System Agency"), + ("Defense Intelligence Agency", "Defense Intelligence Agency"), + ("Defense Legal Services Agency", "Defense Legal Services Agency"), + ("Defense Logistics Agency", "Defense Logistics Agency"), + ("Defense Media Activity", "Defense Media Activity"), + ("Defense Micro Electronics Activity", "Defense Micro Electronics Activity"), + ("Defense POW-MIA Accounting Agency", "Defense POW-MIA Accounting Agency"), + ("Defense Security Cooperation Agency", "Defense Security Cooperation Agency"), + ("Defense Security Service", "Defense Security Service"), + ("Defense Technical Information Center", "Defense Technical Information Center"), + ("Defense Technology Security Administration", "Defense Technology Security Administration"), + ("Defense Threat Reduction Agency", "Defense Threat Reduction Agency"), + ("DoD Education Activity", "DoD Education Activity"), + ("DoD Human Recourses Activity", "DoD Human Recourses Activity"), + ("DoD Inspector General", "DoD Inspector General"), + ("DoD Test Resource Management Center", "DoD Test Resource Management Center"), + ("Headquarters Defense Human Resource Activity ", "Headquarters Defense Human Resource Activity "), + ("Joint Staff", "Joint Staff"), + ("Missile Defense Agency", "Missile Defense Agency"), + ("National Defense University", "National Defense University"), + ("National Geospatial Intelligence Agency (NGA)", "National Geospatial Intelligence Agency (NGA)"), + ("National Oceanic and Atmospheric Administration (NOAA)", "National Oceanic and Atmospheric Administration (NOAA)"), + ("National Reconnaissance Office", "National Reconnaissance Office"), + ("National Reconnaissance Office (NRO)", "National Reconnaissance Office (NRO)"), + ("National Security Agency (NSA)", "National Security Agency (NSA)"), + ("National Security Agency-Central Security Service", "National Security Agency-Central Security Service"), + ("Navy, Department of the", "Navy, Department of the"), + ("Office of Economic Adjustment", "Office of Economic Adjustment"), + ("Office of the Secretary of Defense", "Office of the Secretary of Defense"), + ("Pentagon Force Protection Agency", "Pentagon Force Protection Agency"), + ("Uniform Services University of the Health Sciences", "Uniform Services University of the Health Sciences"), + ("US Cyber Command (USCYBERCOM)", "US Cyber Command (USCYBERCOM)"), + ("US Special Operations Command (USSOCOM)", "US Special Operations Command (USSOCOM)"), + ("US Strategic Command (USSTRATCOM)", "US Strategic Command (USSTRATCOM)"), + ("US Transportation Command (USTRANSCOM)", "US Transportation Command (USTRANSCOM)"), + ("Washington Headquarters Services", "Washington Headquarters Services"), + ], + ) citizenship = RadioField( + description="What is your citizenship status?", choices=[ ("United States", "United States"), ("Foreign National", "Foreign National"), @@ -29,6 +83,7 @@ class OrgForm(ValidatedForm): designation = RadioField( "Designation of Person", + description="What is your designation within the DoD?", choices=[ ("military", "Military"), ("civilian", "Civilian"), @@ -39,6 +94,7 @@ class OrgForm(ValidatedForm): date_latest_training = DateField( "Latest Information Assurance (IA) Training completion date", + description="To complete the training, you can find it in Information Assurance Cyber Awareness Challange website.", validators=[ Required(), DateRange( diff --git a/atst/forms/poc.py b/atst/forms/poc.py index 66b77064..827bf1ae 100644 --- a/atst/forms/poc.py +++ b/atst/forms/poc.py @@ -1,16 +1,35 @@ -from wtforms.fields import StringField +from wtforms.fields import StringField, BooleanField from wtforms.fields.html5 import EmailField -from wtforms.validators import Required, Email, Length +from wtforms.validators import Required, Email, Length, Optional from .forms import ValidatedForm -from .validators import IsNumber, Alphabet +from .validators import IsNumber class POCForm(ValidatedForm): - fname_poc = StringField("POC First Name", validators=[Required()]) - lname_poc = StringField("POC Last Name", validators=[Required()]) + def validate(self, *args, **kwargs): + if self.am_poc.data: + # Prepend Optional validators so that the validation chain + # halts if no data exists. + self.fname_poc.validators.insert(0, Optional()) + self.lname_poc.validators.insert(0, Optional()) + self.email_poc.validators.insert(0, Optional()) + self.dodid_poc.validators.insert(0, Optional()) - email_poc = EmailField("POC Email Address", validators=[Required(), Email()]) + return super().validate(*args, **kwargs) + + + am_poc = BooleanField( + "I am the Workspace Owner.", + default=False, + false_values=(False, "false", "False", "no", "") + ) + + fname_poc = StringField("First Name", validators=[Required()]) + + lname_poc = StringField("Last Name", validators=[Required()]) + + email_poc = EmailField("Email Address", validators=[Required(), Email()]) dodid_poc = StringField( "DOD ID", validators=[Required(), Length(min=10), IsNumber()] diff --git a/atst/forms/request.py b/atst/forms/request.py index fb179587..31b1967d 100644 --- a/atst/forms/request.py +++ b/atst/forms/request.py @@ -1,36 +1,98 @@ from wtforms.fields.html5 import IntegerField -from wtforms.fields import RadioField, StringField, TextAreaField, SelectField -from wtforms.validators import NumberRange, InputRequired +from wtforms.fields import RadioField, TextAreaField, SelectField +from wtforms.validators import Optional, Required + from .fields import DateField from .forms import ValidatedForm -from .validators import DateRange -import pendulum +from atst.domain.requests import Requests class RequestForm(ValidatedForm): + def validate(self, *args, **kwargs): + if self.jedi_migration.data == 'no': + self.rationalization_software_systems.validators.append(Optional()) + self.technical_support_team.validators.append(Optional()) + self.organization_providing_assistance.validators.append(Optional()) + self.engineering_assessment.validators.append(Optional()) + self.data_transfers.validators.append(Optional()) + self.expected_completion_date.validators.append(Optional()) + elif self.jedi_migration.data == 'yes': + if self.technical_support_team.data == 'no': + self.organization_providing_assistance.validators.append(Optional()) + self.cloud_native.validators.append(Optional()) + + try: + annual_spend = int(self.estimated_monthly_spend.data or 0) * 12 + except ValueError: + annual_spend = 0 + + if annual_spend > Requests.AUTO_APPROVE_THRESHOLD: + self.number_user_sessions.validators.append(Required()) + self.average_daily_traffic.validators.append(Required()) + + return super(RequestForm, self).validate(*args, **kwargs) + # Details of Use: General dod_component = SelectField( "DoD Component", description="Identify the DoD component that is requesting access to the JEDI Cloud", choices=[ ("null", "Select an option"), - ("us_air_force", "US Air Force"), - ("us_army", "US Army"), - ("us_navy", "US Navy"), - ("us_marine_corps", "US Marine Corps"), - ("joint_chiefs_of_staff", "Joint Chiefs of Staff"), + ("Air Force, Department of the", "Air Force, Department of the"), + ("Army and Air Force Exchange Service", "Army and Air Force Exchange Service"), + ("Army, Department of the", "Army, Department of the"), + ("Defense Advanced Research Projects Agency", "Defense Advanced Research Projects Agency"), + ("Defense Commissary Agency", "Defense Commissary Agency"), + ("Defense Contract Audit Agency", "Defense Contract Audit Agency"), + ("Defense Contract Management Agency", "Defense Contract Management Agency"), + ("Defense Finance & Accounting Service", "Defense Finance & Accounting Service"), + ("Defense Health Agency", "Defense Health Agency"), + ("Defense Information System Agency", "Defense Information System Agency"), + ("Defense Intelligence Agency", "Defense Intelligence Agency"), + ("Defense Legal Services Agency", "Defense Legal Services Agency"), + ("Defense Logistics Agency", "Defense Logistics Agency"), + ("Defense Media Activity", "Defense Media Activity"), + ("Defense Micro Electronics Activity", "Defense Micro Electronics Activity"), + ("Defense POW-MIA Accounting Agency", "Defense POW-MIA Accounting Agency"), + ("Defense Security Cooperation Agency", "Defense Security Cooperation Agency"), + ("Defense Security Service", "Defense Security Service"), + ("Defense Technical Information Center", "Defense Technical Information Center"), + ("Defense Technology Security Administration", "Defense Technology Security Administration"), + ("Defense Threat Reduction Agency", "Defense Threat Reduction Agency"), + ("DoD Education Activity", "DoD Education Activity"), + ("DoD Human Recourses Activity", "DoD Human Recourses Activity"), + ("DoD Inspector General", "DoD Inspector General"), + ("DoD Test Resource Management Center", "DoD Test Resource Management Center"), + ("Headquarters Defense Human Resource Activity ", "Headquarters Defense Human Resource Activity "), + ("Joint Staff", "Joint Staff"), + ("Missile Defense Agency", "Missile Defense Agency"), + ("National Defense University", "National Defense University"), + ("National Geospatial Intelligence Agency (NGA)", "National Geospatial Intelligence Agency (NGA)"), + ("National Oceanic and Atmospheric Administration (NOAA)", "National Oceanic and Atmospheric Administration (NOAA)"), + ("National Reconnaissance Office", "National Reconnaissance Office"), + ("National Reconnaissance Office (NRO)", "National Reconnaissance Office (NRO)"), + ("National Security Agency (NSA)", "National Security Agency (NSA)"), + ("National Security Agency-Central Security Service", "National Security Agency-Central Security Service"), + ("Navy, Department of the", "Navy, Department of the"), + ("Office of Economic Adjustment", "Office of Economic Adjustment"), + ("Office of the Secretary of Defense", "Office of the Secretary of Defense"), + ("Pentagon Force Protection Agency", "Pentagon Force Protection Agency"), + ("Uniform Services University of the Health Sciences", "Uniform Services University of the Health Sciences"), + ("US Cyber Command (USCYBERCOM)", "US Cyber Command (USCYBERCOM)"), + ("US Special Operations Command (USSOCOM)", "US Special Operations Command (USSOCOM)"), + ("US Strategic Command (USSTRATCOM)", "US Strategic Command (USSTRATCOM)"), + ("US Transportation Command (USTRANSCOM)", "US Transportation Command (USTRANSCOM)"), + ("Washington Headquarters Services", "Washington Headquarters Services"), ], ) jedi_usage = TextAreaField( "JEDI Usage", - description="Briefly describe how you are expecting to use the JEDI Cloud", - render_kw={ - "placeholder": "e.g. We are migrating XYZ application to the cloud so that..." - }, + description="Your answer will help us provide tangible examples to DoD leadership how and why commercial cloud resources are accelerating the Department's missions", ) + # Details of Use: Cloud Readiness num_software_systems = IntegerField( "Number of Software System", @@ -38,32 +100,39 @@ class RequestForm(ValidatedForm): ) jedi_migration = RadioField( - "Are you using the JEDI Cloud to migrate existing systems?", + "JEDI Migration", + description="Are you using the JEDI Cloud to migrate existing systems?", choices=[("yes", "Yes"), ("no", "No")], + default="", ) rationalization_software_systems = RadioField( - "Have you completed a “rationalization” of your software systems to move to the cloud?", + description="Have you completed a “rationalization” of your software systems to move to the cloud?", choices=[("yes", "Yes"), ("no", "No"), ("in_progress", "In Progress")], + default="", ) technical_support_team = RadioField( - "Are you working with a technical support team experienced in cloud migrations?", + description="Are you working with a technical support team experienced in cloud migrations?", choices=[("yes", "Yes"), ("no", "No")], + default="", ) organization_providing_assistance = RadioField( # this needs to be updated to use checkboxes instead of radio - "If you are receiving migration assistance, indicate the type of organization providing assistance below:", + description="If you are receiving migration assistance, what is the type of organization providing assistance?", choices=[ ("in_house_staff", "In-house staff"), ("contractor", "Contractor"), ("other_dod_organization", "Other DoD organization"), + ("none", "None"), ], + default="", ) engineering_assessment = RadioField( - "Have you completed an engineering assessment of your software systems for cloud readiness?", + description="Have you completed an engineering assessment of your systems for cloud readiness?", choices=[("yes", "Yes"), ("no", "No"), ("in_progress", "In Progress")], + default="", ) data_transfers = SelectField( @@ -95,14 +164,15 @@ class RequestForm(ValidatedForm): ) cloud_native = RadioField( - "Are your software systems being developed cloud native?", + description="Are your software systems being developed cloud native?", choices=[("yes", "Yes"), ("no", "No")], + default="", ) # Details of Use: Financial Usage estimated_monthly_spend = IntegerField( "Estimated monthly spend", - description='Use the JEDI CSP Calculator to estimate your monthly cloud resource usage and enter the dollar amount below. Note these estimates are for initial approval only. After the request is approved, you will be asked to provide a valid Task Order number with specific CLIN amounts for cloud services.', + description='Use the JEDI CSP Calculator to estimate your monthly cloud resource usage and enter the dollar amount below. Note these estimates are for initial approval only. After the request is approved, you will be asked to provide a valid Task Order number with specific CLIN amounts for cloud services.', ) dollar_value = IntegerField( @@ -115,6 +185,12 @@ class RequestForm(ValidatedForm): ) average_daily_traffic = IntegerField( + "Average Daily Traffic (Number of Requests)", + description="What is the average daily traffic you expect the systems under this cloud contract to use?" + ) + + average_daily_traffic_gb = IntegerField( + "Average Daily Traffic (GB)", description="What is the average daily traffic you expect the systems under this cloud contract to use?" ) diff --git a/atst/forms/validators.py b/atst/forms/validators.py index 3937dabb..241a5401 100644 --- a/atst/forms/validators.py +++ b/atst/forms/validators.py @@ -2,18 +2,19 @@ import re from wtforms.validators import ValidationError import pendulum +from atst.domain.date import parse_date + def DateRange(lower_bound=None, upper_bound=None, message=None): def _date_range(form, field): now = pendulum.now().date() + date = parse_date(field.data) if lower_bound is not None: - date = pendulum.parse(field.data).date() if (now - lower_bound) > date: raise ValidationError(message) if upper_bound is not None: - date = pendulum.parse(field.data).date() if (now + upper_bound) < date: raise ValidationError(message) diff --git a/atst/models/request_status_event.py b/atst/models/request_status_event.py index 81505f2a..810a4427 100644 --- a/atst/models/request_status_event.py +++ b/atst/models/request_status_event.py @@ -12,9 +12,10 @@ class RequestStatus(Enum): STARTED = "Started" PENDING_FINANCIAL_VERIFICATION = "Pending Financial Verification" PENDING_CCPO_APPROVAL = "Pending CCPO Approval" + CHANGES_REQUESTED = "Changes Requested" APPROVED = "Approved" EXPIRED = "Expired" - DELETED = "Deleted" + CANCELED = "Canceled" class RequestStatusEvent(Base): diff --git a/atst/routes/requests/index.py b/atst/routes/requests/index.py index f756836f..69da2058 100644 --- a/atst/routes/requests/index.py +++ b/atst/routes/requests/index.py @@ -3,14 +3,18 @@ from flask import render_template, g, url_for from . import requests_bp from atst.domain.requests import Requests +from atst.models.permissions import Permissions def map_request(request): time_created = pendulum.instance(request.time_created) is_new = time_created.add(days=1) > pendulum.now() app_count = request.body.get("details_of_use", {}).get("num_software_systems", 0) - update_url = url_for('requests.requests_form_update', screen=1, request_id=request.id) - verify_url = url_for('requests.financial_verification', request_id=request.id) + annual_usage = request.body.get("details_of_use", {}).get("dollar_value", 0) + update_url = url_for( + "requests.requests_form_update", screen=1, request_id=request.id + ) + verify_url = url_for("requests.financial_verification", request_id=request.id) return { "order_id": request.id, @@ -19,20 +23,49 @@ def map_request(request): "app_count": app_count, "date": time_created.format("M/DD/YYYY"), "full_name": request.creator.full_name, - "edit_link": verify_url if Requests.is_pending_financial_verification(request) else update_url + "annual_usage": annual_usage, + "edit_link": verify_url if Requests.is_pending_financial_verification( + request + ) else update_url, } @requests_bp.route("/requests", methods=["GET"]) def requests_index(): - requests = [] - if "review_and_approve_jedi_workspace_request" in g.current_user.atat_permissions: - requests = Requests.get_many() - else: - requests = Requests.get_many(creator=g.current_user) + if Permissions.REVIEW_AND_APPROVE_JEDI_WORKSPACE_REQUEST in g.current_user.atat_permissions: + return _ccpo_view() + else: + return _non_ccpo_view() + + +def _ccpo_view(): + requests = Requests.get_many() + mapped_requests = [map_request(r) for r in requests] + + return render_template( + "requests.html", + requests=mapped_requests, + pending_financial_verification=False, + pending_ccpo_approval=False, + extended_view=True, + kpi_inprogress=Requests.in_progress_count(), + kpi_pending=Requests.pending_ccpo_count(), + kpi_completed=Requests.completed_count(), + ) + + +def _non_ccpo_view(): + requests = Requests.get_many(creator=g.current_user) mapped_requests = [map_request(r) for r in requests] pending_fv = any(Requests.is_pending_financial_verification(r) for r in requests) + pending_ccpo = any(Requests.is_pending_ccpo_approval(r) for r in requests) - return render_template("requests.html", requests=mapped_requests, pending_financial_verification=pending_fv) + return render_template( + "requests.html", + requests=mapped_requests, + pending_financial_verification=pending_fv, + pending_ccpo_approval=pending_ccpo, + extended_view=False, + ) diff --git a/atst/routes/requests/jedi_request_flow.py b/atst/routes/requests/jedi_request_flow.py index 67a59cc0..0b56743b 100644 --- a/atst/routes/requests/jedi_request_flow.py +++ b/atst/routes/requests/jedi_request_flow.py @@ -65,7 +65,7 @@ class JEDIRequestFlow(object): return { "fname_request": user.first_name, "lname_request": user.last_name, - "email_request": user.email + "email_request": user.email, } @property @@ -78,15 +78,15 @@ class JEDIRequestFlow(object): if self.request: if self.form_section == "review_submit": data = self.request.body - if self.form_section == "information_about_you": + elif self.form_section == "information_about_you": form_data = self.request.body.get(self.form_section, {}) - data = { **self.map_user_data(self.request.creator), **form_data } + data = {**self.map_user_data(self.request.creator), **form_data} else: data = self.request.body.get(self.form_section, {}) elif self.form_section == "information_about_you": data = self.map_user_data(self.current_user) - return defaultdict(lambda: defaultdict(lambda: "Input required"), data) + return defaultdict(lambda: defaultdict(lambda: None), data) @property def can_submit(self): @@ -103,40 +103,36 @@ class JEDIRequestFlow(object): "title": "Details of Use", "section": "details_of_use", "form": RequestForm, - "subitems": [ - { - "title": "Overall request details", - "id": "overall-request-details", - }, - {"title": "Cloud Resources", "id": "cloud-resources"}, - {"title": "Support Staff", "id": "support-staff"}, - ], - "show": True, }, { "title": "Information About You", "section": "information_about_you", "form": OrgForm, - "show": True, - }, - { - "title": "Primary Point of Contact", - "section": "primary_poc", - "form": POCForm, - "show": True, }, + {"title": "Workspace Owner", "section": "primary_poc", "form": POCForm}, { "title": "Review & Submit", "section": "review_submit", "form": ReviewForm, - "show": True, }, ] def create_or_update_request(self): - request_data = {self.form_section: self.form.data} + request_data = self.map_request_data(self.form_section, self.form.data) if self.request_id: Requests.update(self.request_id, request_data) else: request = Requests.create(self.current_user, request_data) self.request_id = request.id + + def map_request_data(self, section, data): + if section == "primary_poc": + if data.get("am_poc", False): + data = { + **data, + "dodid_poc": self.current_user.dod_id, + "fname_poc": self.current_user.first_name, + "lname_poc": self.current_user.last_name, + "email_poc": self.current_user.email, + } + return {section: data} diff --git a/atst/routes/requests/requests_form.py b/atst/routes/requests/requests_form.py index ca474d61..4839b059 100644 --- a/atst/routes/requests/requests_form.py +++ b/atst/routes/requests/requests_form.py @@ -42,6 +42,7 @@ def requests_form_update(screen=1, request_id=None): current=screen, next_screen=screen + 1, request_id=request_id, + jedi_request=jedi_flow.request, can_submit=jedi_flow.can_submit, ) @@ -63,35 +64,30 @@ def requests_update(screen=1, request_id=None): existing_request=existing_request, ) - rerender_args = dict( - f=jedi_flow.form, - data=post_data, - screens=jedi_flow.screens, - current=screen, - next_screen=jedi_flow.next_screen, - request_id=jedi_flow.request_id, - ) + has_next_screen = jedi_flow.next_screen <= len(jedi_flow.screens) + valid = jedi_flow.validate() and jedi_flow.validate_warnings() - if jedi_flow.validate(): + if valid: jedi_flow.create_or_update_request() - valid = jedi_flow.validate_warnings() - if valid: - if jedi_flow.next_screen > len(jedi_flow.screens): - where = "/requests" - else: - where = url_for( - "requests.requests_form_update", - screen=jedi_flow.next_screen, - request_id=jedi_flow.request_id, - ) - return redirect(where) - else: - return render_template( - "requests/screen-%d.html" % int(screen), **rerender_args + if has_next_screen: + where = url_for( + "requests.requests_form_update", + screen=jedi_flow.next_screen, + request_id=jedi_flow.request_id, ) - + else: + where = "/requests" + return redirect(where) else: + rerender_args = dict( + f=jedi_flow.form, + data=post_data, + screens=jedi_flow.screens, + current=screen, + next_screen=jedi_flow.next_screen, + request_id=jedi_flow.request_id, + ) return render_template("requests/screen-%d.html" % int(screen), **rerender_args) @@ -104,7 +100,7 @@ def requests_submit(request_id=None): return redirect("/requests?modal=pendingFinancialVerification") else: - return redirect("/requests") + return redirect("/requests?modal=pendingCCPOApproval") # TODO: generalize this, along with other authorizations, into a policy-pattern diff --git a/js/components/checkbox_input.js b/js/components/checkbox_input.js new file mode 100644 index 00000000..6ed5e821 --- /dev/null +++ b/js/components/checkbox_input.js @@ -0,0 +1,16 @@ +export default { + name: 'checkboxinput', + + props: { + name: String, + }, + + methods: { + onInput: function (e) { + this.$root.$emit('field-change', { + value: e.target.checked, + name: this.name + }) + } + } +} diff --git a/js/components/forms/details_of_use.js b/js/components/forms/details_of_use.js new file mode 100644 index 00000000..ff1abccc --- /dev/null +++ b/js/components/forms/details_of_use.js @@ -0,0 +1,71 @@ +import createNumberMask from 'text-mask-addons/dist/createNumberMask' +import { conformToMask } from 'vue-text-mask' + +import textinput from '../text_input' +import optionsinput from '../options_input' + +export default { + name: 'details-of-use', + + components: { + textinput, + optionsinput, + }, + + props: { + initialData: { + type: Object, + default: () => ({}) + } + }, + + data: function () { + const { + estimated_monthly_spend = 0, + jedi_migration = '', + technical_support_team = '' + } = this.initialData + + return { + estimated_monthly_spend, + jedi_migration, + technical_support_team + } + }, + + mounted: function () { + this.$root.$on('field-change', this.handleFieldChange) + }, + + computed: { + annualSpend: function () { + const monthlySpend = this.estimated_monthly_spend || 0 + return monthlySpend * 12 + }, + annualSpendStr: function () { + return this.formatDollars(this.annualSpend) + }, + jediMigrationOptionSelected: function () { + return this.jedi_migration !== '' + }, + isJediMigration: function () { + return this.jedi_migration === 'yes' + }, + hasTechnicalSupportTeam: function () { + return this.technical_support_team === 'yes' + } + }, + + methods: { + formatDollars: function (intValue) { + const mask = createNumberMask({ prefix: '$', allowDecimal: true }) + return conformToMask(intValue.toString(), mask).conformedValue + }, + handleFieldChange: function (event) { + const { value, name } = event + if (typeof this[name] !== undefined) { + this[name] = value + } + }, + } +} diff --git a/js/components/forms/poc.js b/js/components/forms/poc.js new file mode 100644 index 00000000..255c1b04 --- /dev/null +++ b/js/components/forms/poc.js @@ -0,0 +1,43 @@ +import optionsinput from '../options_input' +import textinput from '../text_input' +import checkboxinput from '../checkbox_input' + +export default { + name: 'poc', + + components: { + optionsinput, + textinput, + checkboxinput, + }, + + props: { + initialData: { + type: Object, + default: () => ({}) + } + }, + + data: function () { + const { + am_poc = false + } = this.initialData + + return { + am_poc + } + }, + + mounted: function () { + this.$root.$on('field-change', this.handleFieldChange) + }, + + methods: { + handleFieldChange: function (event) { + const { value, name } = event + if (typeof this[name] !== undefined) { + this[name] = value + } + }, + } +} diff --git a/js/components/options_input.js b/js/components/options_input.js new file mode 100644 index 00000000..eb16f706 --- /dev/null +++ b/js/components/options_input.js @@ -0,0 +1,16 @@ +export default { + name: 'optionsinput', + + props: { + name: String + }, + + methods: { + onInput: function (e) { + this.$root.$emit('field-change', { + value: e.target.value, + name: this.name + }) + } + } +} diff --git a/js/components/text_input.js b/js/components/text_input.js index e027a800..8e4f8048 100644 --- a/js/components/text_input.js +++ b/js/components/text_input.js @@ -14,32 +14,55 @@ export default { type: String, default: () => 'anything' }, - value: { + initialValue: { type: String, default: () => '' - } + }, + initialErrors: Array, + paragraph: String }, data: function () { return { - showError: false, + showError: (this.initialErrors && this.initialErrors.length) || false, showValid: false, mask: inputValidations[this.validation].mask, - renderedValue: this.value + pipe: inputValidations[this.validation].pipe || undefined, + keepCharPositions: inputValidations[this.validation].keepCharPositions || false, + validationError: inputValidations[this.validation].validationError || '', + value: this.initialValue, + modified: false + } + }, + + computed:{ + rawValue: function () { + return this._rawValue(this.value) } }, mounted: function () { - const value = this.$refs.input.value - if (value) { - this._checkIfValid({ value, invalidate: true }) - this.renderedValue = conformToMask(value, this.mask).conformedValue + if (this.value) { + this._checkIfValid({ value: this.value, invalidate: true }) + + if (this.mask && this.validation !== 'email') { + const mask = typeof this.mask.mask !== 'function' + ? this.mask + : mask.mask(this.value).filter((val) => val !== '[]') + + this.value = conformToMask(this.value, mask).conformedValue + } } }, methods: { // When user types a character - onInput: function (value) { + onInput: function (e) { + // When we use the native textarea element, we receive an event object + // When we use the masked-input component, we receive the value directly + const value = typeof e === 'object' ? e.target.value : e + this.value = value + this.modified = true this._checkIfValid({ value }) }, @@ -52,7 +75,11 @@ export default { // _checkIfValid: function ({ value, invalidate = false}) { // Validate the value - const valid = this._validate(value) + let valid = this._validate(value) + + if (!this.modified && this.initialErrors && this.initialErrors.length) { + valid = false + } // Show error messages or not if (valid) { @@ -63,20 +90,21 @@ export default { this.showValid = valid // Emit a change event - this.$emit('fieldChange', { - value, + this.$root.$emit('field-change', { + value: this._rawValue(value), valid, name: this.name }) }, - _validate: function (value) { - // Strip out all the mask characters - let rawValue = inputValidations[this.validation].unmask.reduce((currentValue, character) => { + _rawValue: function (value) { + return inputValidations[this.validation].unmask.reduce((currentValue, character) => { return currentValue.split(character).join('') }, value) + }, - return inputValidations[this.validation].match.test(rawValue) + _validate: function (value) { + return inputValidations[this.validation].match.test(this._rawValue(value)) } } } diff --git a/js/index.js b/js/index.js index 07da8d42..afe6961d 100644 --- a/js/index.js +++ b/js/index.js @@ -1,12 +1,24 @@ import classes from '../styles/atat.scss' import Vue from 'vue/dist/vue' +import VTooltip from 'v-tooltip' +import optionsinput from './components/options_input' import textinput from './components/text_input' +import checkboxinput from './components/checkbox_input' +import DetailsOfUse from './components/forms/details_of_use' +import poc from './components/forms/poc' + +Vue.use(VTooltip) + const app = new Vue({ el: '#app-root', components: { - textinput + optionsinput, + textinput, + checkboxinput, + DetailsOfUse, + poc, }, methods: { closeModal: function(name) { @@ -21,6 +33,7 @@ const app = new Vue({ modals: { styleguideModal: false, pendingFinancialVerification: false, + pendingCCPOApproval: false, } } }, @@ -30,5 +43,6 @@ const app = new Vue({ const modal = modalOpen.getAttribute("data-modal"); this.modals[modal] = true; } - } + }, + delimiters: ['!{', '}'] }) diff --git a/js/lib/input_validations.js b/js/lib/input_validations.js index 6e7a066d..3806af8c 100644 --- a/js/lib/input_validations.js +++ b/js/lib/input_validations.js @@ -1,20 +1,56 @@ import createNumberMask from 'text-mask-addons/dist/createNumberMask' import emailMask from 'text-mask-addons/dist/emailMask' +import createAutoCorrectedDatePipe from 'text-mask-addons/dist/createAutoCorrectedDatePipe' export default { anything: { mask: false, match: /^(?!\s*$).+/, unmask: [], + validationError: 'Please enter a response' + }, + integer: { + mask: createNumberMask({ prefix: '', allowDecimal: false }), + match: /^[1-9]\d*$/, + unmask: [','], + validationError: 'Please enter a number' }, dollars: { mask: createNumberMask({ prefix: '$', allowDecimal: true }), match: /^-?\d+\.?\d*$/, - unmask: ['$',','] + unmask: ['$',','], + validationError: 'Please enter a dollar amount' + }, + gigabytes: { + mask: createNumberMask({ prefix: '', suffix:' GB', allowDecimal: false }), + match: /^[1-9]\d*$/, + unmask: [',',' GB'], + validationError: 'Please enter an amount of data in gigabytes' }, email: { mask: emailMask, match: /(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/, unmask: [], + validationError: 'Please enter a valid e-mail address' + }, + date: { + mask: [/\d/,/\d/,'/',/\d/,/\d/,'/',/\d/,/\d/,/\d/,/\d/], + match: /(0[1-9]|1[012])[- \/.](0[1-9]|[12][0-9]|3[01])[- \/.](19|20)\d\d/, + unmask: [], + pipe: createAutoCorrectedDatePipe('mm/dd/yyyy HH:MM'), + keepCharPositions: true, + validationError: 'Please enter a valid date in the form MM/DD/YYYY' + }, + usPhone: { + mask: ['(', /[1-9]/, /\d/, /\d/, ')', ' ', /\d/, /\d/, /\d/, '-', /\d/, /\d/, /\d/, /\d/], + match: /^\d{10}$/, + unmask: ['(',')','-',' '], + validationError: 'Please enter a 10-digit phone number' + }, + dodId: { + mask: createNumberMask({ prefix: '', allowDecimal: false, includeThousandsSeparator: false }), + match: /^\d{10}$/, + unmask: [], + validationError: 'Please enter a 10-digit DoD ID number' } } diff --git a/package.json b/package.json index b52327bf..5918bf60 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "parcel": "^1.9.7", "text-mask-addons": "^3.8.0", "uswds": "^1.6.3", + "v-tooltip": "^2.0.0-rc.33", "vue": "^2.5.17", "vue-text-mask": "^6.1.2" }, diff --git a/script/sync-crls b/script/sync-crls index 3c02ac93..a8a3ff97 100755 --- a/script/sync-crls +++ b/script/sync-crls @@ -5,9 +5,9 @@ set -e cd "$(dirname "$0")/.." mkdir -p crl-tmp -pipenv run python ./atst/domain/authnid/crl/util.py crl-tmp +pipenv run python ./atst/domain/authnid/crl/util.py crl-tmp crl mkdir -p crl -rsync -rq crl-tmp/. crl/. +rsync -rq --min-size 400 crl-tmp/. crl/. rm -rf crl-tmp if [[ $FLASK_ENV != "prod" ]]; then diff --git a/script/test b/script/test index bd231db8..d1e22bbf 100755 --- a/script/test +++ b/script/test @@ -10,7 +10,7 @@ export FLASK_ENV=test RESET_DB="true" # Define all relevant python files and directories for this app -PYTHON_FILES="./app.py ./atst ./config" +PYTHON_FILES="./app.py ./atst/** ./config" # Enable Python testing RUN_PYTHON_TESTS="true" diff --git a/styles/atat.scss b/styles/atat.scss index ad650bbd..bb29e97f 100644 --- a/styles/atat.scss +++ b/styles/atat.scss @@ -3,6 +3,7 @@ @import 'core/grid'; @import 'core/util'; +@import 'core/transitions'; @import 'elements/typography'; @import 'elements/icons'; @@ -16,6 +17,8 @@ @import 'elements/action_group'; @import 'elements/labels'; @import 'elements/diff'; +@import 'elements/tooltip'; +@import 'elements/kpi'; @import 'components/topbar'; @import 'components/global_layout'; @@ -28,6 +31,7 @@ @import 'components/footer'; @import 'components/progress_menu.scss'; @import 'components/search_bar'; +@import 'components/forms'; @import 'sections/login'; @import 'sections/request_approval'; diff --git a/styles/components/_forms.scss b/styles/components/_forms.scss new file mode 100644 index 00000000..83ab5b83 --- /dev/null +++ b/styles/components/_forms.scss @@ -0,0 +1,77 @@ +// Form Grid +.form-row { + margin: ($gap * 4) 0; + + .form-col { + flex-grow: 1; + + &:first-child .usa-input { + &:first-child { + margin-top: 0; + } + } + + &:last-child .usa-input { + &:first-child { + margin-top: 0; + } + } + } + + @include media($medium-screen) { + @include grid-row; + align-items: flex-start; + + .form-col { + .usa-input { + margin-left: ($gap * 4); + margin-right: ($gap * 4); + } + + &:first-child { + .usa-input { + margin-left: 0; + } + } + + &:last-child { + .usa-input { + margin-right: 0; + } + } + } + } +} + + +.form__sub-fields { + @include alert; + @include alert-level('default'); + + .usa-input { + &:first-child { + margin-top: 0; + } + + &:last-child { + margin-bottom: 0; + } + } + + > h1, > h2, > h3, > h4, > h5, > h6, > legend { + @include h3; + + margin: ($gap * 4) 0; + + &:first-child { + margin-top: 0; + } + + &.usa-sr-only { + + .usa-input { + margin-top: 0; + } + } + } +} + diff --git a/styles/components/_modal.scss b/styles/components/_modal.scss index 868476b9..98851565 100644 --- a/styles/components/_modal.scss +++ b/styles/components/_modal.scss @@ -29,6 +29,7 @@ flex-grow: 1; overflow-y: auto; max-width: 80rem; + position: relative; @include media($medium-screen) { padding: $gap * 4; @@ -39,9 +40,24 @@ @include h3; } - :first-child { + > :first-child { margin-top: 0; } + + .modal__dismiss { + position: absolute; + top: 0; + right: 0; + width: 8rem; + } + } + } + + &.modal--dismissable { + .modal__body { + > :first-child { + margin-right: 8rem; + } } } } diff --git a/styles/components/_workspace_layout.scss b/styles/components/_workspace_layout.scss index e2cf4c24..cf6e79bd 100644 --- a/styles/components/_workspace_layout.scss +++ b/styles/components/_workspace_layout.scss @@ -6,15 +6,20 @@ .workspace-navigation { @include panel-margin; + margin-bottom: $gap * 4; ul { display: flex; - flex-wrap: wrap; + flex-direction: column; li { flex-grow: 1; } } + @include media($medium-screen) { + margin-bottom: $gap * 5; + } + @include media($large-screen) { width: 20rem; margin-right: $gap * 2; diff --git a/styles/core/_transitions.scss b/styles/core/_transitions.scss new file mode 100644 index 00000000..69c5f6ee --- /dev/null +++ b/styles/core/_transitions.scss @@ -0,0 +1,36 @@ +// Slide up/down transition +.slide-enter-active { + transform-origin: 0 0; + transition: transform 0.5s ease-out; + + > * { + transition: opacity 0.5s ease-in; + } +} + +.slide-leave-active { + transform-origin: 0 0; + transition: transform 0.5s ease-in; + + > * { + transition: opacity 0.5s ease-out; + } +} + +.slide-enter, +.slide-leave-to { + transform: scaleY(0); + + > * { + opacity: 0; + } +} + +.slide-enter-to, +.slide-leave { + transform: scaleY(1); + + > * { + opacity: 1; + } +} diff --git a/styles/elements/_inputs.scss b/styles/elements/_inputs.scss index eff35e34..35640fbe 100644 --- a/styles/elements/_inputs.scss +++ b/styles/elements/_inputs.scss @@ -27,7 +27,7 @@ $state-color: $color-green; } - .icon { + .icon-validation { @include icon-color($state-color); } @@ -70,22 +70,37 @@ @include h4; @include line-max; position: relative; + clear: both; - .icon { + .icon-validation { position: absolute; - left: 100%; top: 100%; margin-top: 1.4rem; margin-left: $gap; } + + } + + .usa-input__title { + display: flex; + align-items: center; + + .icon-tooltip { + padding: 0 $gap/2; + cursor: default; + margin-left: $gap/2; + } } .usa-input__help { - display: block; @include h4; font-weight: normal; padding: $gap/2 0; @include line-max; + + .icon-link { + padding: 0 $gap/2; + } } input, @@ -93,6 +108,18 @@ select { @include line-max; margin: 0; + box-sizing: border-box; + max-width: 32em; + + &:hover, + &:focus { + border-color: $color-blue !important; + color: $color-blue-darker; + box-shadow: inset 0 0 0 1px $color-blue; + &::placeholder { + color: $color-blue; + } + } } .usa-input__choices { // checkbox & radio sets @@ -104,10 +131,6 @@ font-weight: $font-bold; } - .icon { - vertical-align: middle; - } - } ul { @@ -121,6 +144,12 @@ [type='radio'] + label, [type='checkbox'] + label { margin: 0; + &:hover { + color: $color-blue; + &:before { + box-shadow: 0 0 0 1px $color-white, 0 0 0 3px $color-blue; + } + } } } } @@ -145,7 +174,55 @@ .usa-input__message { @include h5; display: inline-block; - padding-top: $gap; + } + + &--validation { + + &--anything, + &--email { + input { + max-width: 26em; + } + .icon-validation { + left: 26em; + } + } + + &--paragraph { + .icon-validation { + left: 32em; + } + } + + &--integer, + &--dollars, + &--gigabytes, { + input { + max-width: 16em; + } + .icon-validation { + left: 16em; + } + } + + &--date, + &--usPhone { + input { + max-width: 10em; + } + .icon-validation { + left: 10em; + } + } + + &--dodId { + input { + max-width: 18em; + } + .icon-validation { + left: 18em; + } + } } &.usa-input--error { @@ -198,49 +275,3 @@ select { } } - - -// Form Grid -.form-row { - margin: ($gap * 4) 0; - - .form-col { - flex-grow: 1; - - &:first-child .usa-input { - &:first-child { - margin-top: 0; - } - } - - &:last-child .usa-input { - &:first-child { - margin-top: 0; - } - } - } - - @include media($medium-screen) { - @include grid-row; - align-items: flex-start; - - .form-col { - .usa-input { - margin-left: ($gap * 4); - margin-right: ($gap * 4); - } - - &:first-child { - .usa-input { - margin-left: 0; - } - } - - &:last-child { - .usa-input { - margin-right: 0; - } - } - } - } -} diff --git a/styles/elements/_kpi.scss b/styles/elements/_kpi.scss new file mode 100644 index 00000000..f68fe30c --- /dev/null +++ b/styles/elements/_kpi.scss @@ -0,0 +1,25 @@ +.kpi { + + margin-bottom: $gap; + + .kpi__item { + @include panel-base; + text-align: center; + margin: $gap; + padding: $gap * 2; + + &:first-child { + margin-left: -$gap; + } + + &:last-child { + margin-right: -$gap; + } + } + + .kpi__item__value { + @include h1; + padding-bottom: $gap / 2; + } + +} \ No newline at end of file diff --git a/styles/elements/_panels.scss b/styles/elements/_panels.scss index af5decf4..27fdc144 100644 --- a/styles/elements/_panels.scss +++ b/styles/elements/_panels.scss @@ -29,6 +29,19 @@ } } +@mixin panel-row { + @include grid-row; + + .col { + margin: 0 $site-margins-mobile * 2; + + @include media($medium-screen) { + margin: 0 $site-margins * 2; + } + } + +} + @mixin panel-actions { padding: $gap; } diff --git a/styles/elements/_sidenav.scss b/styles/elements/_sidenav.scss index d4c67366..e335036c 100644 --- a/styles/elements/_sidenav.scss +++ b/styles/elements/_sidenav.scss @@ -29,8 +29,8 @@ &.sidenav__link--active { @include h4; - background-color: $color-white; color: $color-primary; + background-color: $color-white; box-shadow: inset ($gap / 2) 0 0 0 $color-primary; .sidenav__link-icon { @@ -38,20 +38,32 @@ } + ul { - background-color: $color-white; + background-color: $color-primary; .sidenav__link { + color: $color-white; + background-color: $color-primary; + + &:hover { + background-color: $color-blue-darker; + } + &--active { @include h5; - color: $color-primary; + color: $color-white; + background-color: $color-primary; box-shadow: none; } + + .icon { + @include icon-color($color-white); + } } } } + ul { - padding-bottom: $gap / 2; + // padding-bottom: $gap / 2; li { .sidenav__link { diff --git a/styles/elements/_tooltip.scss b/styles/elements/_tooltip.scss new file mode 100644 index 00000000..44876171 --- /dev/null +++ b/styles/elements/_tooltip.scss @@ -0,0 +1,100 @@ +.tooltip { + display: block; + z-index: 10000; + max-width: $text-max-width; + box-shadow: 0 2px 4px rgba(0,0,0,0.25); + + .tooltip-inner { + background-color: $color-aqua-lightest; + padding: $gap * 3; + border-left: ($gap / 2) solid $color-blue; + } + + .tooltip-arrow { + width: 1rem; + height: 1rem; + position: absolute; + background-color: $color-aqua-lightest; + z-index: 1; + box-shadow: -2px 2px 2px 0 rgba(0,0,0,0.25); + } + + &[x-placement^="top"] { + margin-bottom: 5px; + + .tooltip-arrow { + bottom: -5px; + left: calc(50% - 5px); + transform: rotate(-45deg); + box-shadow: -2px 2px 2px 0 rgba(0,0,0,0.25); + } + } + + &[x-placement^="bottom"] { + margin-top: 5px; + + .tooltip-arrow { + top: -5px; + left: calc(50% - 5px); + transform: rotate(135deg); + box-shadow: -2px 2px 2px -2px rgba(0,0,0,0.25); + } + } + + &[x-placement^="right"] { + margin-left: 5px; + + .tooltip-arrow { + left: -5px; + top: calc(50% - 5px); + transform: rotate(-135deg); + } + } + + &[x-placement^="left"] { + margin-right: 5px; + + .tooltip-arrow { + right: -5px; + top: calc(50% - 5px); + transform: rotate(45deg); + } + } + + &.popover { + $color: #f9f9f9; + + .popover-inner { + background: $color; + color: black; + padding: 24px; + border-radius: 5px; + box-shadow: 0 5px 30px rgba(black, .1); + } + + .popover-arrow { + border-color: $color; + } + } + + &[aria-hidden='true'] { + visibility: hidden; + opacity: 0; + transition: opacity .15s, visibility .15s; + } + + &[aria-hidden='false'] { + visibility: visible; + opacity: 1; + transition: opacity .15s; + } +} + + +.icon-tooltip { + @include icon-link; + + .icon { + @include icon-size(16); + } +} diff --git a/styles/elements/_typography.scss b/styles/elements/_typography.scss index b1af1be1..a207c0a1 100644 --- a/styles/elements/_typography.scss +++ b/styles/elements/_typography.scss @@ -52,9 +52,13 @@ dl { } dd { -webkit-margin-start: 0; + + .label { + margin-left: 0; + } } > div { margin-bottom: $gap * 2; } -} \ No newline at end of file +} diff --git a/templates/base.html b/templates/base.html index a29a6c04..3f5125ce 100644 --- a/templates/base.html +++ b/templates/base.html @@ -3,11 +3,12 @@ {% set context=g.navigationContext %} - + - {% block title %}JEDI{% endblock %} + + {% block title %}JEDI Cloud{% endblock %} {% assets "css" %} {% endassets %} diff --git a/templates/components/checkbox_input.html b/templates/components/checkbox_input.html new file mode 100644 index 00000000..92538843 --- /dev/null +++ b/templates/components/checkbox_input.html @@ -0,0 +1,18 @@ + +{% macro CheckboxInput(field, inline=False) -%} + +
+ +
+ + {{ field() }} + {{ field.label }} + + {% if field.description %} + {{ field.description | safe }} + {% endif %} +
+
+
+ +{%- endmacro %} diff --git a/templates/components/modal.html b/templates/components/modal.html index aedebd42..b0bafd12 100644 --- a/templates/components/modal.html +++ b/templates/components/modal.html @@ -2,18 +2,17 @@ {% macro Modal(name, dismissable=False) -%}
- {% endcall %} + {% call Modal(name='pendingCCPOApproval', dismissable=True) %} + + {% include 'fragments/pending_ccpo_approval_modal.html' %} + +
+ Close +
+ {% endcall %} + {% if not requests %} {{ EmptyState( @@ -33,38 +42,65 @@ {% endif %} + {% if pending_ccpo_approval %} + + {{ Alert('Request submitted. Approval pending.', fragment="fragments/pending_ccpo_approval_alert.html") }} + + {% endif %} + + {% if extended_view %} +
+
+
{{ kpi_inprogress }}
+
In Progress
+
+
+
{{ kpi_pending }}
+
Pending CCPO Action
+
+
+
{{ kpi_completed }}
+
Completed (Overall)
+
+
+ {% endif %} +
- +
+ + +
+ + {% endif %}
- - - - - - + + + {% if extended_view %} + + + {% endif %} + + @@ -76,13 +112,12 @@ {% endif %} - - + {% if extended_view %} + + + {% endif %} + - {% endfor %} diff --git a/templates/requests/financial_verification.html b/templates/requests/financial_verification.html index 0f0d8110..e8236ece 100644 --- a/templates/requests/financial_verification.html +++ b/templates/requests/financial_verification.html @@ -1,17 +1,21 @@ {% extends "base.html" %} +{% from "components/alert.html" import Alert %} +{% from "components/text_input.html" import TextInput %} +{% from "components/options_input.html" import OptionsInput %} + {% block content %}
-
+
+

Order #{{ request_id }}

+

Financial Verification

+
-
-

Order #{{ request_id }}

-

Financial Verification

-
+
{% block form_action %}
@@ -20,192 +24,47 @@ {{ f.csrf_token }} {% block form %} {% autoescape false %} + {% if f.errors %} - There were some errors, see below. + {{ Alert('There were some errors', + message="

Please see below.

", + level='error' + ) }} {% endif %}

In order to get you access to the JEDI Cloud, we will need you to enter the details below that will help us verify and account for your Task Order.

- {{ f.task_order_id.label }} - {{ f.task_order_id(placeholder="Example: 1234567899C0001") }} - {% for e in f.task_order_id.errors %} -
- {{ e }} -
- {% endfor %} - - {{ f.uii_ids.label }} - {{ f.uii_ids(placeholder="Example: \nDI 0CVA5786950 \nUN1945326361234786950") }} - {% for e in f.uii_ids.errors %} -
- {{ e }} -
- {% endfor %} - - {{ f.pe_id.label }} - {{ f.pe_id(placeholder="Example: 0203752A") }} - {% for e in f.pe_id.errors %} -
- {{ e }} -
- {% endfor %} - - {{ f.treasury_code.label }} - {{ f.treasury_code(placeholder="Example: 1200") }} - {% for e in f.treasury_code.errors %} -
- {{ e }} -
- {% endfor %} - - {{ f.ba_code.label }} - {{ f.ba_code(placeholder="Example: 02") }} - {% for e in f.ba_code.errors %} -
- {{ e }} -
- {% endfor %} - - + {{ TextInput(f.task_order_id,placeholder="e.g.: 1234567899C0001",tooltip="Note that there may be a lag between the time you have created and approved the task order to the time it is searchable within the electronic.
A Contracting Officer will likely be the best source for this number.") }} + {{ TextInput(f.uii_ids,paragraph=True,placeholder="e.g.: DI 0CVA5786950 \nUN1945326361234786950",tooltip="A Unique Item Identifer is a unique code that helps the Department of Defense track and report on where and how digital assets are stored.
Not all applications have an existing UII number assigned.") }} + {{ TextInput(f.pe_id,placeholder="e.g.: 0203752A",tooltip="Program Element numbers helps the Department of Defense identify which offices\\' budgets are contributing towards this resource use.") }} + {{ TextInput(f.treasury_code,placeholder="e.g.: 1200") }} + {{ TextInput(f.ba_code,placeholder="e.g.: 02") }}

Contracting Officer (KO) Information

+ {{ TextInput(f.fname_co,placeholder="Contracting Officer First Name") }} + {{ TextInput(f.lname_co,placeholder="Contracting Officer Last Name") }} + {{ TextInput(f.email_co,validation='email',placeholder="jane@mail.mil") }} + {{ TextInput(f.office_co,placeholder="e.g.: WHS") }} - {{ f.fname_co.label }} - {{ f.fname_co(placeholder="Contracting Officer first name") }} - {% for e in f.fname_co.errors %} -
- {{ e }} -
- {% endfor %} - - {{ f.lname_co.label }} - {{ f.lname_co(placeholder="Contracting Officer last name") }} - {% for e in f.lname_co.errors %} -
- {{ e }} -
- {% endfor %} - - {{ f.email_co.label }} - {{ f.email_co(placeholder="jane@mail.mil") }} - {% for e in f.email_co.errors %} -
- {{ e }} -
- {% endfor %} - - {{ f.office_co.label }} - {{ f.office_co(placeholder="Example: WHS") }} - {% for e in f.office_co.errors %} -
- {{ e }} -
- {% endfor %} - - -

Contracting Officer Representative (COR) Information

- - {{ f.fname_cor.label }} - {{ f.fname_cor(placeholder="Contracting Officer Representative first name") }} - {% for e in f.fname_cor.errors %} -
- {{ e }} -
- {% endfor %} - - {{ f.lname_cor.label }} - {{ f.lname_cor(placeholder="Contracting Officer Representative last name") }} - {% for e in f.lname_cor.errors %} -
- {{ e }} -
- {% endfor %} - - {{ f.email_cor.label }} - {{ f.email_cor(placeholder="jane@mail.mil") }} - {% for e in f.email_cor.errors %} -
- {{ e }} -
- {% endfor %} - - {{ f.office_cor.label }} - {{ f.office_cor(placeholder="Example: WHS") }} - {% for e in f.office_cor.errors %} -
- {{ e }} -
- {% endfor %} + {{ TextInput(f.fname_cor,placeholder="Contracting Officer Representative First Name") }} + {{ TextInput(f.lname_cor,placeholder="Contracting Officer Representative Last Name") }} + {{ TextInput(f.email_cor,validation='email',placeholder="jane@mail.mil") }} + {{ TextInput(f.office_cor,placeholder="e.g.: WHS") }}

↓ FIELDS NEEDED FOR MANUAL ENTRY OF TASK ORDER INFORMATION (only necessary if EDA info not available) + {{ OptionsInput(f.funding_type) }} + {{ TextInput(f.funding_type_other) }} + {{ TextInput(f.clin_0001,placeholder="50,000", validation='integer', tooltip="Review your task order document, the amounts for each CLIN must match exactly here.") }} + {{ TextInput(f.clin_0003,placeholder="13,000", validation='integer', tooltip="Review your task order document, the amounts for each CLIN must match exactly here.") }} + {{ TextInput(f.clin_1001,placeholder="30,000", validation='integer', tooltip="Review your task order document, the amounts for each CLIN must match exactly here.") }} + {{ TextInput(f.clin_1003,placeholder="7,000", validation='integer', tooltip="Review your task order document, the amounts for each CLIN must match exactly here.") }} + {{ TextInput(f.clin_2001,placeholder="30,000", validation='integer', tooltip="Review your task order document, the amounts for each CLIN must match exactly here.") }} + {{ TextInput(f.clin_2003,placeholder="7,000", validation='integer', tooltip="Review your task order document, the amounts for each CLIN must match exactly here.") }} - {{ f.funding_type.label }} - {{ f.funding_type }} - {% for e in f.funding_type.errors %} -
- {{ e }} -
- {% endfor %} - - {{ f.funding_type_other.label }} - {{ f.funding_type_other(placeholder="") }} - {% for e in f.funding_type_other.errors %} -
- {{ e }} -
- {% endfor %} - - {{ f.clin_0001.label }} - {{ f.clin_0001(placeholder="50,000") }} - {% for e in f.clin_0001.errors %} -
- {{ e }} -
- {% endfor %} - - {{ f.clin_0003.label }} - {{ f.clin_0003(placeholder="13,000") }} - {% for e in f.clin_0003.errors %} -
- {{ e }} -
- {% endfor %} - - {{ f.clin_1001.label }} - {{ f.clin_1001(placeholder="30,000") }} - {% for e in f.clin_1001.errors %} -
- {{ e }} -
- {% endfor %} - - {{ f.clin_1003.label }} - {{ f.clin_1003(placeholder="7,000") }} - {% for e in f.clin_1003.errors %} -
- {{ e }} -
- {% endfor %} - - {{ f.clin_2001.label }} - {{ f.clin_2001(placeholder="30,000") }} - {% for e in f.clin_2001.errors %} -
- {{ e }} -
- {% endfor %} - - {{ f.clin_2003.label }} - {{ f.clin_2003(placeholder="7,000") }} - {% for e in f.clin_2003.errors %} -
- {{ e }} -
- {% endfor %} {% endautoescape %} {% endblock form %} {% block next %} @@ -217,4 +76,4 @@
-{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/templates/requests/menu.html b/templates/requests/menu.html index d62de246..40de55d3 100644 --- a/templates/requests/menu.html +++ b/templates/requests/menu.html @@ -1,7 +1,7 @@
    {% for s in screens %} - {% if loop.index < current %} + {% if jedi_request and s.section in jedi_request.body %} {% set step_indicator = 'complete' %} {% elif loop.index == current %} {% set step_indicator = 'active' %} diff --git a/templates/requests/screen-1.html b/templates/requests/screen-1.html index 7dbfb367..c6ea9a05 100644 --- a/templates/requests/screen-1.html +++ b/templates/requests/screen-1.html @@ -17,32 +17,68 @@ ) }} {% endif %} + +
    -

    We’d like to know a little about how you plan to use JEDI Cloud services to process your request. Please answer the following questions to the best of your ability. Note that the CCPO does not directly help with migrating systems to JEDI Cloud. These questions are for learning about your cloud readiness and financial usage of the JEDI Cloud; your estimates will not be used for any department level reporting.

    -

    All fields are required, unless specified optional.

    +

    We’d like to know a little about how you plan to use JEDI Cloud services to process your request. Please answer the following questions to the best of your ability. Note that the CCPO does not directly help with migrating systems to JEDI Cloud. These questions are for learning about your cloud readiness and financial usage of the JEDI Cloud; your estimates will not be used for any department level reporting.

    +

    All fields are required, unless specified optional.

    -

    General

    -{{ TextInput(f.dod_component) }} -{{ TextInput(f.jedi_usage,placeholder="e.g. We are migrating XYZ application to the cloud so that...") }} +

    General

    + {{ OptionsInput(f.dod_component) }} + {{ TextInput(f.jedi_usage, paragraph=True, placeholder="Briefly describe how you are expecting to use the JEDI Cloud. \n e.g. We are migrating XYZ application to the cloud so that...") }} -

    Cloud Readiness

    -{{ TextInput(f.num_software_systems,placeholder="Number of systems") }} -{{ OptionsInput(f.jedi_migration) }} -{{ OptionsInput(f.rationalization_software_systems) }} -{{ OptionsInput(f.technical_support_team) }} -{{ OptionsInput(f.organization_providing_assistance) }} -{{ OptionsInput(f.engineering_assessment) }} -{{ TextInput(f.data_transfers) }} -{{ TextInput(f.expected_completion_date) }} -{{ OptionsInput(f.cloud_native) }} +

    Cloud Readiness

    + {{ TextInput(f.num_software_systems,validation="integer",tooltip="A software system can be any code that you plan to host on cloud infrastructure. For example, it could be a custom-developed web application, or a large ERP system.",placeholder="0") }} + {{ OptionsInput(f.jedi_migration, tooltip="Cloud migration is the process of moving data, applications or other business elements from an organization\\'s onsite computers/data centers to the cloud, or moving them from one cloud environment to another.") }} -

    Financial Usage

    -{{ TextInput(f.estimated_monthly_spend) }} -

    So this means you are spending approximately $X annually

    -{{ TextInput(f.dollar_value) }} -{{ TextInput(f.number_user_sessions) }} -{{ TextInput(f.average_daily_traffic) }} -{{ TextInput(f.start_date) }} + + + + +

    Financial Usage

    + {{ TextInput(f.estimated_monthly_spend, tooltip="Refer to financial verification step help docs", validation="dollars", placeholder="$0") }} + +
    +
    +
    +

    So this means you are spending approximately !{ annualSpendStr } annually

    +
    +
    +
    + + + + + + {{ TextInput(f.dollar_value, validation='dollars', placeholder="$0") }} + {{ TextInput(f.start_date, validation='date', placeholder='MM / DD / YYYY') }} + +
    +
    {% endblock %} diff --git a/templates/requests/screen-2.html b/templates/requests/screen-2.html index be32dc90..1e67d2ab 100644 --- a/templates/requests/screen-2.html +++ b/templates/requests/screen-2.html @@ -19,16 +19,15 @@

    Please tell us more about you.

    -{{ TextInput(f.fname_request,placeholder='First Name') }} -{{ TextInput(f.lname_request,placeholder='Last Name') }} -{{ TextInput(f.email_request,placeholder='jane@mail.mil') }} -{{ TextInput(f.phone_number,placeholder='(123) 456-7890') }} +{{ TextInput(f.fname_request, placeholder='First Name') }} +{{ TextInput(f.lname_request, placeholder='Last Name') }} +{{ TextInput(f.email_request, placeholder='jane@mail.mil', validation='email') }} +{{ TextInput(f.phone_number, placeholder='e.g. (123) 456-7890', validation='usPhone') }} -

    We want to collect the following information from you for security auditing and determining priviledged user access

    +

    We want to collect the following information from you for security auditing and determining priviledged user access.

    -{{ TextInput(f.service_branch,placeholder='e.g. US Air Force, US Army, US Navy, Marine Corps, Defense Media Agency') }} +{{ OptionsInput(f.service_branch) }} {{ OptionsInput(f.citizenship) }} {{ OptionsInput(f.designation) }} -{{ TextInput(f.date_latest_training) }} - +{{ TextInput(f.date_latest_training,tooltip="When was the last time you completed the IA training?
    Information Assurance (IA) training is an important step in cyber awareness.",placeholder="MM / DD / YYYY", validation="date") }} {% endblock %} diff --git a/templates/requests/screen-3.html b/templates/requests/screen-3.html index 7de95813..79f4b3a0 100644 --- a/templates/requests/screen-3.html +++ b/templates/requests/screen-3.html @@ -2,9 +2,10 @@ {% from "components/alert.html" import Alert %} {% from "components/text_input.html" import TextInput %} +{% from "components/checkbox_input.html" import CheckboxInput %} {% block subtitle %} -

    Primary Government/Military
    Point of Contact (POC)

    +

    Designate a Workspace Owner

    {% endblock %} {% block form %} @@ -16,21 +17,31 @@ ) }} {% endif %} -

    Please designate a Primary Point of Contact that will be responsible for owning the workspace in the JEDI Cloud.

    -

    The Point of Contact will become the primary owner of the workspace created to use the JEDI Cloud. As a workspace owner, this person will have the ability to: -

      -
    • Create multiple application stacks and environments in the workspace to access the commercial cloud service provider portal
    • -
    • Add and manage users in the workspace
    • -
    • View the budget and billing history related to this workspace
    • -
    • Manage access to the Cloud Service Provider's Console
    • -
    • Transfer Workspace ownership to another person
    • -
    - This POC may be you. -

    + +
    -{{ TextInput(f.fname_poc,placeholder='First Name') }} -{{ TextInput(f.lname_poc,placeholder='Last Name') }} -{{ TextInput(f.email_poc,placeholder='jane@mail.mil') }} -{{ TextInput(f.dodid_poc,placeholder='10-digit number on the back of the CAC') }} +

    The Workspace Owner is the primary point of contact and technical administrator of the JEDI Workspace and will have the + following responsibilities:

    +
      +
    • Organize your cloud-hosted systems into projects and environments
    • +
    • Add users to this workspace and manage members
    • +
    • Manage access to the JEDI Cloud service provider’s portal
    • +
    +

    +

    This person must be a DoD employee (not a contractor).

    +

    The Workspace Owner may be you. You will be able to add other administrators later. This person will be invited via email + once your request is approved.

    + + {{ CheckboxInput(f.am_poc) }} + + + +
    +
    {% endblock %} diff --git a/templates/requests/screen-4.html b/templates/requests/screen-4.html index 2e940e57..84c0e91e 100644 --- a/templates/requests/screen-4.html +++ b/templates/requests/screen-4.html @@ -1,7 +1,12 @@ +{% macro RequiredLabel() -%} + Response Required +{%- endmacro %} + {% extends 'requests_new.html' %} {% from "components/alert.html" import Alert %} {% from "components/text_input.html" import TextInput %} +{% from "components/icon.html" import Icon %} {% block subtitle %}

    Review & Submit

    @@ -14,167 +19,201 @@ {% block form %} - {% if f.errors %} - {{ Alert('There were some errors', - message="

    Please complete all required fields before submitting.

    ", +

    Before you can submit your request, please take a moment to review the information entered in the form. You may make changes by clicking the edit link on each section. When all information looks right, go ahead and submit.

    + + {% if f.errors or not can_submit%} + {{ Alert('Please complete all sections', + message="

    In order to submit your JEDI Cloud request, you'll need to complete all required sections of this form without error. Missing or invalid fields are noted below.

    ", level='error' ) }} {% endif %} -

    Before you can submit your request, please take a moment to review the information entered in the form. You may make changes by clicking the edit link on each section. When all information looks right, go ahead and submit.

    -

    Details of Use Edit

    +

    + Details of Use + + {{ Icon('edit') }} + Edit this section + +

    DoD Component
    -
    {{data['details_of_use']['dod_component']}}
    +
    {{ data['details_of_use']['dod_component'] or RequiredLabel() }}
    JEDI Usage
    -
    {{data['details_of_use']['jedi_usage']}}
    +
    {{ data['details_of_use']['jedi_usage'] or RequiredLabel() }}
    Number of software systems
    -
    {{data['details_of_use']['num_software_systems']}}
    +
    {{ data['details_of_use']['num_software_systems'] or RequiredLabel() }}
    JEDI Migration
    -
    {{data['details_of_use']['jedi_migration']}}
    +
    {{ data['details_of_use']['jedi_migration'] or RequiredLabel() }}
    -
    -
    Rationalization of Software Systems
    -
    {{data['details_of_use']['rationalization_software_systems']}}
    -
    + {% if data['details_of_use']['jedi_migration'] == 'yes' %} +
    +
    Rationalization of Software Systems
    +
    {{ data['details_of_use']['rationalization_software_systems'] or RequiredLabel() }}
    +
    -
    -
    Technical Support Team
    -
    {{data['details_of_use']['technical_support_team']}}
    -
    +
    +
    Technical Support Team
    +
    {{ data['details_of_use']['technical_support_team'] or RequiredLabel() }}
    +
    -
    -
    Organization Providing Assistance
    -
    {{data['details_of_use']['organization_providing_assistance']}}
    -
    -
    -
    Engineering Assessment
    -
    {{data['details_of_use']['engineering_assessment']}}
    -
    + {% if data['details_of_use']['technical_support_team'] == 'yes' %} -
    -
    Data Transfers
    -
    {{data['details_of_use']['data_transfers']}}
    -
    +
    +
    Organization Providing Assistance
    +
    {{ data['details_of_use']['organization_providing_assistance'] or RequiredLabel() }}
    +
    -
    -
    Expected Completion Date
    -
    {{data['details_of_use']['expected_completion_date']}}
    -
    + {% endif %} -
    -
    Cloud Native
    -
    {{data['details_of_use']['cloud_native']}}
    -
    +
    +
    Engineering Assessment
    +
    {{ data['details_of_use']['engineering_assessment'] or RequiredLabel() }}
    +
    + +
    +
    Data Transfers
    +
    {{ data['details_of_use']['data_transfers'] or RequiredLabel() }}
    +
    + +
    +
    Expected Completion Date
    +
    {{ data['details_of_use']['expected_completion_date'] or RequiredLabel() }}
    +
    + + {% else %} + +
    +
    Cloud Native
    +
    {{ data['details_of_use']['cloud_native'] or RequiredLabel() }}
    +
    + + {% endif %}
    Estimated Monthly Spend
    -
    {{data['details_of_use']['estimated_monthly_spend']}}
    +
    {{ data['details_of_use']['estimated_monthly_spend'] or RequiredLabel() }}
    Total Spend
    -
    ${{data['details_of_use']['dollar_value']}}
    +
    {{ data['details_of_use']['dollar_value'] or RequiredLabel() }}
    Number of User Sessions
    -
    {{data['details_of_use']['number_user_sessions']}}
    +
    {{ data['details_of_use']['number_user_sessions'] or RequiredLabel() }}
    -
    Average Daily Traffic
    -
    {{data['details_of_use']['average_daily_traffic']}}
    +
    Average Daily Traffic (Number of Requests)
    +
    {{ data['details_of_use']['average_daily_traffic'] or RequiredLabel() }}
    +
    + +
    +
    Average Daily Traffic (GB)
    +
    {{ data['details_of_use']['average_daily_traffic_gb'] or RequiredLabel() }}
    Start Date
    -
    {{data['details_of_use']['start_date']}}
    +
    {{ data['details_of_use']['start_date'] or RequiredLabel() }}
    -

    Information About You Edit

    +

    + Information About You + + {{ Icon('edit') }} + Edit this section + +

    First Name
    -
    {{data['information_about_you']['fname_request']}}
    +
    {{ data['information_about_you']['fname_request'] or RequiredLabel() }}
    Last Name
    -
    {{data['information_about_you']['lname_request']}}
    +
    {{ data['information_about_you']['lname_request'] or RequiredLabel() }}
    Email Address
    -
    {{data['information_about_you']['email_request']}}
    +
    {{ data['information_about_you']['email_request'] or RequiredLabel() }}
    Phone Number
    -
    {{data['information_about_you']['phone_number']}}
    +
    {{ data['information_about_you']['phone_number'] or RequiredLabel() }}
    Service Branch or Agency
    -
    {{data['information_about_you']['service_branch']}}
    +
    {{ data['information_about_you']['service_branch'] or RequiredLabel() }}
    Citizenship
    -
    {{data['information_about_you']['citizenship']}}
    +
    {{ data['information_about_you']['citizenship'] or RequiredLabel() }}
    Designation of Person
    -
    {{data['information_about_you']['designation']}}
    +
    {{ data['information_about_you']['designation'] or RequiredLabel() }}
    Latest Information Assurance (IA) Training completion date
    -
    {{data['information_about_you']['date_latest_training']}}
    +
    {{ data['information_about_you']['date_latest_training'] or RequiredLabel() }}
    -

    Primary Point of Contact Edit

    +

    + Primary Point of Contact + + {{ Icon('edit') }} + Edit this section + +

    POC First Name
    -
    {{data['primary_poc']['fname_poc']}}
    +
    {{ data['primary_poc']['fname_poc'] or RequiredLabel() }}
    POC Last Name
    -
    {{data['primary_poc']['lname_poc']}}
    +
    {{ data['primary_poc']['lname_poc'] or RequiredLabel() }}
    POC Email Address
    -
    {{data['primary_poc']['email_poc']}}
    +
    {{ data['primary_poc']['email_poc'] or RequiredLabel() }}
    DOD ID
    -
    {{data['primary_poc']['dodid_poc']}}
    +
    {{ data['primary_poc']['dodid_poc'] or RequiredLabel() }}
    @@ -183,12 +222,6 @@ {% endblock %} {% block next %} -{% if not can_submit %} - {{ Alert('There were some errors', - message="

    Please complete all required fields before submitting.

    ", - level='error' - ) }} -{% endif %}
    diff --git a/templates/styleguide.html b/templates/styleguide.html index a65069c4..5aed52fb 100644 --- a/templates/styleguide.html +++ b/templates/styleguide.html @@ -3,6 +3,7 @@ {% from "components/icon.html" import Icon %} {% from "components/modal.html" import Modal %} {% from "components/alert.html" import Alert %} +{% from "components/tooltip.html" import Tooltip %} {% block content %} @@ -128,7 +129,6 @@
    col 12
    -
    @@ -286,6 +286,27 @@
+
+
+
Tooltips
+ {{ Tooltip('this is a tooltip text') }}default
+ {{ Icon('help') }}top-start
+ {{ Icon('help') }}right
+ {{ Icon('help') }}bottom
+
+
+ +
+
+
Labels
+ Label + Label Info + Label Warning + Label Error + Label Success +
+
+
Another panel without padding
diff --git a/tests/conftest.py b/tests/conftest.py index 12e7c320..e5094c1b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -69,6 +69,7 @@ def session(db, request): ] for factory in factory_list: factory._meta.sqlalchemy_session = session + factory._meta.sqlalchemy_session_persistence = "commit" yield session diff --git a/tests/domain/authnid/test_crl.py b/tests/domain/authnid/test_crl.py index 5593a865..1b9fa2ec 100644 --- a/tests/domain/authnid/test_crl.py +++ b/tests/domain/authnid/test_crl.py @@ -66,8 +66,9 @@ def test_parse_disa_pki_list(): assert len(crl_list) == len(href_matches) class MockStreamingResponse(): - def __init__(self, content_chunks): + def __init__(self, content_chunks, code=200): self.content_chunks = content_chunks + self.status_code = code def iter_content(self, chunk_size=0): return self.content_chunks @@ -81,6 +82,10 @@ class MockStreamingResponse(): def test_write_crl(tmpdir, monkeypatch): monkeypatch.setattr('requests.get', lambda u, **kwargs: MockStreamingResponse([b'it worked'])) crl = 'crl_1' - util.write_crl(tmpdir, crl) + assert util.write_crl(tmpdir, "random_target_dir", crl) assert [p.basename for p in tmpdir.listdir()] == [crl] assert [p.read() for p in tmpdir.listdir()] == ['it worked'] + +def test_skips_crl_if_it_has_not_been_modified(tmpdir, monkeypatch): + monkeypatch.setattr('requests.get', lambda u, **kwargs: MockStreamingResponse([b'it worked'], 304)) + assert not util.write_crl(tmpdir, "random_target_dir", 'crl_file_name') diff --git a/tests/domain/test_date.py b/tests/domain/test_date.py new file mode 100644 index 00000000..fe80530d --- /dev/null +++ b/tests/domain/test_date.py @@ -0,0 +1,21 @@ +import pytest +import pendulum + +from atst.domain.date import parse_date + + +def test_date_with_slashes(): + date_str = "1/2/2020" + assert parse_date(date_str) == pendulum.date(2020, 1, 2) + + +def test_date_with_dashes(): + date_str = "2020-1-2" + assert parse_date(date_str) == pendulum.date(2020, 1, 2) + + +def test_invalid_date(): + date_str = "This is not a valid data" + with pytest.raises(ValueError): + parse_date(date_str) + diff --git a/tests/domain/test_requests.py b/tests/domain/test_requests.py index 2044d52b..332e3c6c 100644 --- a/tests/domain/test_requests.py +++ b/tests/domain/test_requests.py @@ -3,9 +3,10 @@ from uuid import uuid4 from atst.domain.exceptions import NotFoundError from atst.domain.requests import Requests +from atst.models.request import Request from atst.models.request_status_event import RequestStatus -from tests.factories import RequestFactory, UserFactory +from tests.factories import RequestFactory, UserFactory, RequestStatusEventFactory @pytest.fixture(scope="function") @@ -63,3 +64,27 @@ def test_exists(session): request = RequestFactory.create(creator=user_allowed) assert Requests.exists(request.id, user_allowed) assert not Requests.exists(request.id, user_denied) + + +def test_status_count(session): + # make sure table is empty + session.query(Request).delete() + + request1 = RequestFactory.create() + request2 = RequestFactory.create() + RequestStatusEventFactory.create(sequence=2, request_id=request2.id, new_status=RequestStatus.PENDING_FINANCIAL_VERIFICATION) + + assert Requests.status_count(RequestStatus.PENDING_FINANCIAL_VERIFICATION) == 1 + assert Requests.status_count(RequestStatus.STARTED) == 1 + assert Requests.in_progress_count() == 2 + +def test_status_count_scoped_to_creator(session): + # make sure table is empty + session.query(Request).delete() + + user = UserFactory.create() + request1 = RequestFactory.create() + request2 = RequestFactory.create(creator=user) + + assert Requests.status_count(RequestStatus.STARTED) == 2 + assert Requests.status_count(RequestStatus.STARTED, creator=user) == 1 diff --git a/tests/factories.py b/tests/factories.py index cf81fa71..07073424 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -39,6 +39,7 @@ class RequestStatusEventFactory(factory.alchemy.SQLAlchemyModelFactory): model = RequestStatusEvent id = factory.Sequence(lambda x: uuid4()) + sequence = 1 class RequestFactory(factory.alchemy.SQLAlchemyModelFactory): @@ -56,6 +57,7 @@ class RequestFactory(factory.alchemy.SQLAlchemyModelFactory): def build_request_body(cls, user, dollar_value=1000000): return { "primary_poc": { + "am_poc": False, "dodid_poc": user.dod_id, "email_poc": user.email, "fname_poc": user.first_name, @@ -66,7 +68,7 @@ class RequestFactory(factory.alchemy.SQLAlchemyModelFactory): "start_date": "2018-08-08", "cloud_native": "yes", "dollar_value": dollar_value, - "dod_component": "us_navy", + "dod_component": "Army and Air Force Exchange Service", "data_transfers": "less_than_100gb", "jedi_migration": "yes", "num_software_systems": 1, @@ -75,6 +77,7 @@ class RequestFactory(factory.alchemy.SQLAlchemyModelFactory): "engineering_assessment": "yes", "technical_support_team": "yes", "estimated_monthly_spend": 100, + "average_daily_traffic_gb": 4, "expected_completion_date": "less_than_1_month", "rationalization_software_systems": "yes", "organization_providing_assistance": "in_house_staff" @@ -86,7 +89,7 @@ class RequestFactory(factory.alchemy.SQLAlchemyModelFactory): "email_request": user.email, "fname_request": user.first_name, "lname_request": user.last_name, - "service_branch": "ads", + "service_branch": "Air Force, Department of the", "date_latest_training": "2018-08-06" } } diff --git a/tests/forms/test_request.py b/tests/forms/test_request.py new file mode 100644 index 00000000..9d5e4201 --- /dev/null +++ b/tests/forms/test_request.py @@ -0,0 +1,85 @@ +import pytest + +from atst.forms.request import RequestForm + + +class TestRequestForm: + + form_data = { + 'dod_component': 'Army and Air Force Exchange Service', + 'jedi_usage': 'cloud-ify all the things', + 'num_software_systems': '12', + 'estimated_monthly_spend': '1000000', + 'dollar_value': '42', + 'number_user_sessions': '6', + 'average_daily_traffic': '0', + 'start_date': '12/12/2012', + } + migration_data = { + 'jedi_migration': 'yes', + 'rationalization_software_systems': 'yes', + 'technical_support_team': 'yes', + 'organization_providing_assistance': 'in_house_staff', + 'engineering_assessment': 'yes', + 'data_transfers': 'less_than_100gb', + 'expected_completion_date': 'less_than_1_month' + } + + def test_require_cloud_native_when_not_migrating(self): + extra_data = { 'jedi_migration': 'no' } + request_form = RequestForm(data={ **self.form_data, **extra_data }) + assert not request_form.validate() + assert request_form.errors == { 'cloud_native': ['Not a valid choice'] } + + def test_require_migration_questions_when_migrating(self): + extra_data = { 'jedi_migration': 'yes' } + request_form = RequestForm(data={ **self.form_data, **extra_data }) + assert not request_form.validate() + assert request_form.errors == { + 'rationalization_software_systems': ['Not a valid choice'], + 'technical_support_team': ['Not a valid choice'], + 'organization_providing_assistance': ['Not a valid choice'], + 'engineering_assessment': ['Not a valid choice'], + 'data_transfers': ['Not a valid choice'], + 'expected_completion_date': ['Not a valid choice'] + } + + def test_require_organization_when_technical_support_team(self): + data = { **self.form_data, **self.migration_data } + del data['organization_providing_assistance'] + + request_form = RequestForm(data=data) + assert not request_form.validate() + assert request_form.errors == { + 'organization_providing_assistance': ['Not a valid choice'], + } + + def test_valid_form_data(self): + data = { **self.form_data, **self.migration_data } + data['technical_support_team'] = 'no' + del data['organization_providing_assistance'] + + request_form = RequestForm(data=data) + assert request_form.validate() + + def test_sessions_required_for_large_projects(self): + data = { **self.form_data, **self.migration_data } + data['estimated_monthly_spend'] = '9999999' + del data['number_user_sessions'] + del data['average_daily_traffic'] + + request_form = RequestForm(data=data) + assert not request_form.validate() + assert request_form.errors == { + 'number_user_sessions': ['This field is required.'], + 'average_daily_traffic': ['This field is required.'], + } + + def test_sessions_not_required_invalid_monthly_spend(self): + data = { **self.form_data, **self.migration_data } + data['estimated_monthly_spend'] = 'foo' + del data['number_user_sessions'] + del data['average_daily_traffic'] + + request_form = RequestForm(data=data) + assert request_form.validate() diff --git a/tests/models/test_requests.py b/tests/models/test_requests.py index d6592a25..987723af 100644 --- a/tests/models/test_requests.py +++ b/tests/models/test_requests.py @@ -65,6 +65,6 @@ def test_request_status_pending_expired_displayname(): def test_request_status_pending_deleted_displayname(): request = RequestFactory.create() - request = Requests.set_status(request, RequestStatus.DELETED) + request = Requests.set_status(request, RequestStatus.CANCELED) - assert request.status_displayname == "Deleted" + assert request.status_displayname == "Canceled" diff --git a/tests/routes/test_financial_verification.py b/tests/routes/test_financial_verification.py index 8e62cc48..fca2aa3b 100644 --- a/tests/routes/test_financial_verification.py +++ b/tests/routes/test_financial_verification.py @@ -47,7 +47,7 @@ class TestPENumberInForm: response = self.submit_data(client, self.required_data) - assert "We couldn\'t find that PE number" in response.data.decode() + assert "We couldn't find that PE number" in response.data.decode() assert response.status_code == 200 def test_submit_request_form_with_unchanged_pe_id(self, monkeypatch, client): @@ -81,5 +81,5 @@ class TestPENumberInForm: response = self.submit_data(client, data) - assert "There were some errors, see below" in response.data.decode() + assert "There were some errors" in response.data.decode() assert response.status_code == 200 diff --git a/tests/routes/test_request_new.py b/tests/routes/test_request_new.py index 7d770a28..dd4f926c 100644 --- a/tests/routes/test_request_new.py +++ b/tests/routes/test_request_new.py @@ -1,9 +1,8 @@ import re -import pytest -import urllib -from tests.mocks import MOCK_USER, MOCK_REQUEST from tests.factories import RequestFactory, UserFactory from atst.domain.roles import Roles +from atst.domain.requests import Requests +from urllib.parse import urlencode ERROR_CLASS = "alert--error" @@ -75,9 +74,9 @@ def test_creator_info_is_autopopulated(monkeypatch, client, user_session): response = client.get("/requests/new/2/{}".format(request.id)) body = response.data.decode() - assert 'value="{}"'.format(user.first_name) in body - assert 'value="{}"'.format(user.last_name) in body - assert 'value="{}"'.format(user.email) in body + assert "initial-value='{}'".format(user.first_name) in body + assert "initial-value='{}'".format(user.last_name) in body + assert "initial-value='{}'".format(user.email) in body def test_creator_info_is_autopopulated_for_new_request(monkeypatch, client, user_session): @@ -86,9 +85,9 @@ def test_creator_info_is_autopopulated_for_new_request(monkeypatch, client, user response = client.get("/requests/new/2") body = response.data.decode() - assert 'value="{}"'.format(user.first_name) in body - assert 'value="{}"'.format(user.last_name) in body - assert 'value="{}"'.format(user.email) in body + assert "initial-value='{}'".format(user.first_name) in body + assert "initial-value='{}'".format(user.last_name) in body + assert "initial-value='{}'".format(user.email) in body def test_non_creator_info_is_not_autopopulated(monkeypatch, client, user_session): @@ -102,3 +101,72 @@ def test_non_creator_info_is_not_autopopulated(monkeypatch, client, user_session assert not user.first_name in body assert not user.last_name in body assert not user.email in body + +def test_am_poc_causes_poc_to_be_autopopulated(client, user_session): + creator = UserFactory.create() + user_session(creator) + request = RequestFactory.create(creator=creator, body={}) + client.post( + "/requests/new/3/{}".format(request.id), + headers={"Content-Type": "application/x-www-form-urlencoded"}, + data="am_poc=yes", + ) + request = Requests.get(request.id) + assert request.body["primary_poc"]["dodid_poc"] == creator.dod_id + + +def test_not_am_poc_requires_poc_info_to_be_completed(client, user_session): + creator = UserFactory.create() + user_session(creator) + request = RequestFactory.create(creator=creator, body={}) + response = client.post( + "/requests/new/3/{}".format(request.id), + headers={"Content-Type": "application/x-www-form-urlencoded"}, + data="am_poc=no", + follow_redirects=True + ) + assert ERROR_CLASS in response.data.decode() + + +def test_not_am_poc_allows_user_to_fill_in_poc_info(client, user_session): + creator = UserFactory.create() + user_session(creator) + request = RequestFactory.create(creator=creator, body={}) + poc_input = { + "am_poc": "no", + "fname_poc": "test", + "lname_poc": "user", + "email_poc": "test.user@mail.com", + "dodid_poc": "1234567890", + } + response = client.post( + "/requests/new/3/{}".format(request.id), + headers={"Content-Type": "application/x-www-form-urlencoded"}, + data=urlencode(poc_input), + ) + assert ERROR_CLASS not in response.data.decode() + + +def test_poc_details_can_be_autopopulated_on_new_request(client, user_session): + creator = UserFactory.create() + user_session(creator) + response = client.post( + "/requests/new/3", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + data="am_poc=yes", + ) + request_id = response.headers["Location"].split('/')[-1] + request = Requests.get(request_id) + + assert request.body["primary_poc"]["dodid_poc"] == creator.dod_id + + +def test_can_review_data(user_session, client): + creator = UserFactory.create() + user_session(creator) + request = RequestFactory.create(creator=creator) + response = client.get("/requests/new/4/{}".format(request.id)) + body = response.data.decode() + # assert a sampling of the request data is on the review page + assert request.body["primary_poc"]["fname_poc"] in body + assert request.body["information_about_you"]["email_request"] in body diff --git a/tests/routes/test_request_submit.py b/tests/routes/test_request_submit.py index f8e61400..480d6dc2 100644 --- a/tests/routes/test_request_submit.py +++ b/tests/routes/test_request_submit.py @@ -21,7 +21,7 @@ def test_submit_reviewed_request(monkeypatch, client, user_session): follow_redirects=False, ) assert "/requests" in response.headers["Location"] - assert "modal" not in response.headers["Location"] + assert "modal=pendingCCPOApproval" in response.headers["Location"] def test_submit_autoapproved_reviewed_request(monkeypatch, client, user_session): @@ -35,4 +35,4 @@ def test_submit_autoapproved_reviewed_request(monkeypatch, client, user_session) data="", follow_redirects=False, ) - assert "/requests?modal=" in response.headers["Location"] + assert "/requests?modal=pendingFinancialVerification" in response.headers["Location"] diff --git a/tests/test_integration.py b/tests/test_integration.py index e8fa6348..633f14b1 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -1,46 +1,67 @@ import pytest -from tests.mocks import MOCK_USER +from urllib.parse import urlencode +from .factories import UserFactory, RequestFactory + from atst.routes.requests.jedi_request_flow import JEDIRequestFlow +from atst.models.request_status_event import RequestStatus +from atst.domain.requests import Requests + @pytest.fixture def screens(app): return JEDIRequestFlow(3).screens -@pytest.mark.skip() -def test_stepthrough_request_form(monkeypatch, screens, client): - monkeypatch.setattr( - "atst.handlers.request_new.RequestNew.get_current_user", lambda s: MOCK_USER - ) - monkeypatch.setattr( - "atst.handlers.request_new.RequestNew.check_xsrf_cookie", lambda s: True - ) - monkeypatch.setattr( - "atst.handlers.request_new.JEDIRequestFlow.validate", lambda s: True - ) +def test_stepthrough_request_form(user_session, screens, client): + user = UserFactory.create() + user_session(user) + mock_request = RequestFactory.stub() - def take_a_step(inc, req=None): + def post_form(url, redirects=False, data=""): + return client.post( + url, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + data=data, + follow_redirects=redirects, + ) + + def take_a_step(inc, req=None, data=None): req_url = "/requests/new/{}".format(inc) if req: req_url += "/" + req - response = client.post( - req_url, - headers={"Content-Type": "application/x-www-form-urlencoded"}, - data="meaning=42", - ) - return response + # we do it twice, with and without redirect, in order to get the + # destination url + prelim_resp = post_form(req_url, data=data) + response = post_form(req_url, True, data=data) + return (prelim_resp.headers.get("Location"), response) # GET the initial form - response = client.get("/requests/new") + response = client.get("/requests/new/1") assert screens[0]["title"] in response.data.decode() # POST to each of the form pages up until review and submit req_id = None for i in range(1, len(screens)): - resp = take_a_step(i, req=req_id) - __import__('ipdb').set_trace() - req_id = resp.effective_url.split("/")[-1] + # get appropriate form data to POST for this section + section = screens[i - 1]["section"] + post_data = urlencode(mock_request.body[section]) + + effective_url, resp = take_a_step(i, req=req_id, data=post_data) + req_id = effective_url.split("/")[-1] screen_title = screens[i]["title"].replace("&", "&") - assert "/requests/new/{}/{}".format(i + 1, req_id) in resp.effective_url + assert "/requests/new/{}/{}".format(i + 1, req_id) in effective_url assert screen_title in resp.data.decode() + + # at this point, the real request we made and the mock_request bodies + # should be equivalent + assert Requests.get(req_id).body == mock_request.body + + # finish the review and submit step + client.post( + "/requests/submit/{}".format(req_id), + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + + finished_request = Requests.get(req_id) + assert finished_request.status == RequestStatus.PENDING_CCPO_APPROVAL diff --git a/yarn.lock b/yarn.lock index ab510b61..d0665a54 100644 --- a/yarn.lock +++ b/yarn.lock @@ -127,14 +127,10 @@ anymatch@^2.0.0: micromatch "^3.1.4" normalize-path "^2.1.1" -aproba@^1.0.3, aproba@^1.1.1, aproba@^1.1.2, aproba@~1.2.0: +aproba@^1.0.3, aproba@^1.1.1, aproba@^1.1.2, "aproba@^1.1.2 || 2", aproba@~1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a" -"aproba@^1.1.2 || 2": - version "2.0.0" - resolved "https://registry.yarnpkg.com/aproba/-/aproba-2.0.0.tgz#52520b8ae5b569215b354efc0caa3fe1e45a8adc" - archy@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/archy/-/archy-1.0.0.tgz#f9c8c13757cc1dd7bc379ac77b2c62a5c2868c40" @@ -1355,14 +1351,10 @@ color-convert@^1.3.0, color-convert@^1.9.0, color-convert@^1.9.1: dependencies: color-name "1.1.1" -color-name@1.1.1: +color-name@1.1.1, color-name@^1.0.0: version "1.1.1" resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.1.tgz#4b1415304cf50028ea81643643bd82ea05803689" -color-name@^1.0.0: - version "1.1.3" - resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" - color-string@^0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/color-string/-/color-string-0.3.0.tgz#27d46fb67025c5c2fa25993bfbf579e47841b991" @@ -2148,18 +2140,7 @@ escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" -escodegen@^1.8.1: - version "1.11.0" - resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-1.11.0.tgz#b27a9389481d5bfd5bec76f7bb1eb3f8f4556589" - dependencies: - esprima "^3.1.3" - estraverse "^4.2.0" - esutils "^2.0.2" - optionator "^0.8.1" - optionalDependencies: - source-map "~0.6.1" - -escodegen@~1.9.0: +escodegen@^1.8.1, escodegen@~1.9.0: version "1.9.1" resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-1.9.1.tgz#dbae17ef96c8e4bedb1356f4504fa4cc2f7cb7e2" dependencies: @@ -2259,14 +2240,10 @@ extglob@^2.0.4: snapdragon "^0.8.1" to-regex "^3.0.1" -extsprintf@1.3.0: +extsprintf@1.3.0, extsprintf@^1.2.0: version "1.3.0" resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05" -extsprintf@^1.2.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f" - falafel@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/falafel/-/falafel-2.1.0.tgz#96bb17761daba94f46d001738b3cedf3a67fe06c" @@ -3214,11 +3191,7 @@ js-beautify@^1.7.5: mkdirp "~0.5.0" nopt "~3.0.1" -"js-tokens@^3.0.0 || ^4.0.0": - version "4.0.0" - resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" - -js-tokens@^3.0.2: +"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b" @@ -3482,6 +3455,10 @@ lodash.memoize@~3.0.3: version "3.0.4" resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-3.0.4.tgz#2dcbd2c287cbc0a55cc42328bd0c736150d53e3f" +lodash.merge@^4.6.0: + version "4.6.1" + resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.1.tgz#adc25d9cb99b9391c59624f379fbba60d7111d54" + lodash.mergewith@^4.6.0: version "4.6.1" resolved "https://registry.yarnpkg.com/lodash.mergewith/-/lodash.mergewith-4.6.1.tgz#639057e726c3afbdb3e7d42741caa8d6e4335927" @@ -3802,14 +3779,10 @@ move-concurrently@^1.0.1: rimraf "^2.5.4" run-queue "^1.0.3" -ms@2.0.0: +ms@2.0.0, ms@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" -ms@^2.0.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a" - mute-stream@~0.0.4: version "0.0.7" resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.7.tgz#3075ce93bc21b8fab43e1bc4da7e8115ed1e7bab" @@ -4556,14 +4529,10 @@ pascalcase@^0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14" -path-browserify@0.0.0: +path-browserify@0.0.0, path-browserify@~0.0.0: version "0.0.0" resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-0.0.0.tgz#a0b870729aae214005b7d5032ec2cbbb0fb4451a" -path-browserify@~0.0.0: - version "0.0.1" - resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-0.0.1.tgz#e6c4ddd7ed3aa27c68a20cc4e50e1a4ee83bbc4a" - path-dirname@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/path-dirname/-/path-dirname-1.0.2.tgz#cc33d24d525e099a5388c0336c6e32b9160609e0" @@ -4652,6 +4621,10 @@ pinkie@^2.0.0: version "2.0.4" resolved "https://registry.yarnpkg.com/pinkie/-/pinkie-2.0.4.tgz#72556b80cfa0d48a974e80e77248e80ed4f7f870" +popper.js@^1.12.9: + version "1.14.4" + resolved "https://registry.yarnpkg.com/popper.js/-/popper.js-1.14.4.tgz#8eec1d8ff02a5a3a152dd43414a15c7b79fd69b6" + posix-character-classes@^0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab" @@ -6084,11 +6057,7 @@ static-module@^2.2.0: static-eval "^2.0.0" through2 "~2.0.3" -"statuses@>= 1.4.0 < 2": - version "1.5.0" - resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" - -statuses@~1.4.0: +"statuses@>= 1.4.0 < 2", statuses@~1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.4.0.tgz#bb73d446da2796106efcc1b601a253d6c46bd087" @@ -6430,14 +6399,10 @@ trim-right@^1.0.1: dependencies: glob "^6.0.4" -tty-browserify@0.0.0: +tty-browserify@0.0.0, tty-browserify@~0.0.0: version "0.0.0" resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.0.tgz#a157ba402da24e9bf957f9aa69d524eed42901a6" -tty-browserify@~0.0.0: - version "0.0.1" - resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.1.tgz#3f05251ee17904dfd0677546670db9651682b811" - tunnel-agent@^0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd" @@ -6640,6 +6605,14 @@ uuid@^3.0.0, uuid@^3.1.0, uuid@^3.3.2: version "3.3.2" resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131" +v-tooltip@^2.0.0-rc.33: + version "2.0.0-rc.33" + resolved "https://registry.yarnpkg.com/v-tooltip/-/v-tooltip-2.0.0-rc.33.tgz#78f7d8e9c34265622be65ba9dc78c67f1dc02b73" + dependencies: + lodash.merge "^4.6.0" + popper.js "^1.12.9" + vue-resize "^0.4.3" + v8-compile-cache@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.0.0.tgz#526492e35fc616864284700b7043e01baee09f0a" @@ -6679,6 +6652,10 @@ vm-browserify@0.0.4, vm-browserify@~0.0.1: dependencies: indexof "0.0.1" +vue-resize@^0.4.3: + version "0.4.4" + resolved "https://registry.yarnpkg.com/vue-resize/-/vue-resize-0.4.4.tgz#dee9b8dd1b189e7e3f6ec47f86c60694a241bb17" + vue-text-mask@^6.1.2: version "6.1.2" resolved "https://registry.yarnpkg.com/vue-text-mask/-/vue-text-mask-6.1.2.tgz#2cc18a1ca04ea66798518a9373929a12256d14b9"
Order IDRequest DateRequesterTotal AppsStatusActionsJEDI Cloud Request IDDate Request Initiated / CreatedRequesterReason FlaggedProjected Annual Usage ($)Request Status
{{ r['date'] }}{{ r['full_name'] }}{{ r['app_count'] }}{{ r['full_name'] }}${{ r['annual_usage'] }} {{ r['status'] }} - Download - Approval -