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 %} - +
-+ We will review and respond to your request in 72 hours. You’ll be notified via email or phone. +
+ ++ While your request is being reviewed, your next step is to create a Task Order associated with JEDI Cloud. Please contact a Contracting Officer (KO), Contracting Officer Representative (COR), or a Financial Manager to help with this step. +
+ ++ Learn more about the JEDI Task Order and the Financial Verification process. +
diff --git a/templates/fragments/pending_ccpo_approval_modal.html b/templates/fragments/pending_ccpo_approval_modal.html new file mode 100644 index 00000000..68a48c2d --- /dev/null +++ b/templates/fragments/pending_ccpo_approval_modal.html @@ -0,0 +1,35 @@ ++ We will review and respond to your request in 72 hours. You’ll be notified via email or phone. +
+ ++ Your request is being reviewed because: +
+ While your request is being reviewed, your next step is to create a Task Order associated with JEDI Cloud. Please contact a Contracting Officer (KO), Contracting Officer Representative (COR), or a Financial Manager to help with this step. +
+ ++ Once the Task Order has been created, you will be asked to provide details about the task order in the Financial Verification step. +
+ ++ Learn more about the JEDI Task Order and the Financial Verification process. +
diff --git a/templates/navigation/workspace_navigation.html b/templates/navigation/workspace_navigation.html index e39ee185..f785c055 100644 --- a/templates/navigation/workspace_navigation.html +++ b/templates/navigation/workspace_navigation.html @@ -26,11 +26,6 @@ "href": "", "active": g.matchesPath('/workspaces/members/new'), "icon": "plus" - }, - { - "label": "Editing Member", - "href": "", - "active": g.matchesPath('/workspaces/123456/members/789/edit') } ] ) }} diff --git a/templates/requests.html b/templates/requests.html index df6999ca..53950e10 100644 --- a/templates/requests.html +++ b/templates/requests.html @@ -16,6 +16,15 @@Order ID | -Request Date | -Requester | -Total Apps | -Status | -Actions | +JEDI Cloud Request ID | +Date Request Initiated / Created | + {% if extended_view %} +Requester | +Reason Flagged | + {% endif %} +Projected Annual Usage ($) | +Request Status | {{ r['date'] }} | -{{ r['full_name'] }} | -{{ r['app_count'] }} | + {% if extended_view %} +{{ r['full_name'] }} | ++ {% endif %} + | ${{ r['annual_usage'] }} | {{ r['status'] }} | -- Download - Approval - | {% 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 %}
---|