Merge pull request #448 from dod-ccpo/save-form-state
Save user's form state for later when their session expires before submission
This commit is contained in:
commit
b8abeeb1cf
@ -24,6 +24,7 @@ from atst.models.permissions import Permissions
|
|||||||
from atst.eda_client import MockEDAClient
|
from atst.eda_client import MockEDAClient
|
||||||
from atst.uploader import Uploader
|
from atst.uploader import Uploader
|
||||||
from atst.utils import mailer
|
from atst.utils import mailer
|
||||||
|
from atst.utils.form_cache import FormCache
|
||||||
from atst.queue import queue
|
from atst.queue import queue
|
||||||
|
|
||||||
|
|
||||||
@ -67,6 +68,8 @@ def make_app(config):
|
|||||||
if ENV != "prod":
|
if ENV != "prod":
|
||||||
app.register_blueprint(dev_routes)
|
app.register_blueprint(dev_routes)
|
||||||
|
|
||||||
|
app.form_cache = FormCache(app.redis)
|
||||||
|
|
||||||
apply_authentication(app)
|
apply_authentication(app)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
@ -2,11 +2,11 @@ from wtforms.fields.html5 import EmailField, TelField
|
|||||||
from wtforms.fields import StringField, TextAreaField
|
from wtforms.fields import StringField, TextAreaField
|
||||||
from wtforms.validators import Email, Optional
|
from wtforms.validators import Email, Optional
|
||||||
|
|
||||||
from .forms import ValidatedForm
|
from .forms import CacheableForm
|
||||||
from .validators import Name, PhoneNumber
|
from .validators import Name, PhoneNumber
|
||||||
|
|
||||||
|
|
||||||
class CCPOReviewForm(ValidatedForm):
|
class CCPOReviewForm(CacheableForm):
|
||||||
comment = TextAreaField(
|
comment = TextAreaField(
|
||||||
"Instructions or comments",
|
"Instructions or comments",
|
||||||
description="Provide instructions or notes for additional information that is necessary to approve the request here. The requestor may then re-submit the updated request or initiate contact outside of AT-AT if further discussion is required. <strong>This message will be shared with the person making the JEDI request.</strong>.",
|
description="Provide instructions or notes for additional information that is necessary to approve the request here. The requestor may then re-submit the updated request or initiate contact outside of AT-AT if further discussion is required. <strong>This message will be shared with the person making the JEDI request.</strong>.",
|
||||||
|
@ -5,7 +5,7 @@ from wtforms.fields import RadioField, StringField
|
|||||||
from wtforms.validators import Email, DataRequired, Optional
|
from wtforms.validators import Email, DataRequired, Optional
|
||||||
|
|
||||||
from .fields import SelectField
|
from .fields import SelectField
|
||||||
from .forms import ValidatedForm
|
from .forms import CacheableForm
|
||||||
from .data import SERVICE_BRANCHES
|
from .data import SERVICE_BRANCHES
|
||||||
from atst.models.user import User
|
from atst.models.user import User
|
||||||
|
|
||||||
@ -77,7 +77,7 @@ def inherit_user_field(field_name):
|
|||||||
return inherit_field(USER_FIELDS[field_name], required=required)
|
return inherit_field(USER_FIELDS[field_name], required=required)
|
||||||
|
|
||||||
|
|
||||||
class EditUserForm(ValidatedForm):
|
class EditUserForm(CacheableForm):
|
||||||
|
|
||||||
first_name = inherit_user_field("first_name")
|
first_name = inherit_user_field("first_name")
|
||||||
last_name = inherit_user_field("last_name")
|
last_name = inherit_user_field("last_name")
|
||||||
|
@ -7,7 +7,7 @@ from flask_wtf.file import FileAllowed
|
|||||||
from werkzeug.datastructures import FileStorage
|
from werkzeug.datastructures import FileStorage
|
||||||
|
|
||||||
from .fields import NewlineListField, SelectField, NumberStringField
|
from .fields import NewlineListField, SelectField, NumberStringField
|
||||||
from atst.forms.forms import ValidatedForm
|
from atst.forms.forms import CacheableForm
|
||||||
from .data import FUNDING_TYPES
|
from .data import FUNDING_TYPES
|
||||||
from .validators import DateRange
|
from .validators import DateRange
|
||||||
|
|
||||||
@ -31,7 +31,7 @@ def coerce_choice(val):
|
|||||||
return val.value
|
return val.value
|
||||||
|
|
||||||
|
|
||||||
class TaskOrderForm(ValidatedForm):
|
class TaskOrderForm(CacheableForm):
|
||||||
def do_validate_number(self):
|
def do_validate_number(self):
|
||||||
for field in self:
|
for field in self:
|
||||||
if field.name != "task_order-number":
|
if field.name != "task_order-number":
|
||||||
@ -127,7 +127,7 @@ class TaskOrderForm(ValidatedForm):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class RequestFinancialVerificationForm(ValidatedForm):
|
class RequestFinancialVerificationForm(CacheableForm):
|
||||||
uii_ids = NewlineListField(
|
uii_ids = NewlineListField(
|
||||||
"Unique Item Identifier (UII)s related to your application(s) if you already have them.",
|
"Unique Item Identifier (UII)s related to your application(s) if you already have them.",
|
||||||
description="If you have more than one UII, place each one on a new line.",
|
description="If you have more than one UII, place each one on a new line.",
|
||||||
@ -174,7 +174,7 @@ class RequestFinancialVerificationForm(ValidatedForm):
|
|||||||
self.uii_ids.process_data(self.uii_ids.data)
|
self.uii_ids.process_data(self.uii_ids.data)
|
||||||
|
|
||||||
|
|
||||||
class FinancialVerificationForm(ValidatedForm):
|
class FinancialVerificationForm(CacheableForm):
|
||||||
|
|
||||||
task_order = FormField(TaskOrderForm)
|
task_order = FormField(TaskOrderForm)
|
||||||
request = FormField(RequestFinancialVerificationForm)
|
request = FormField(RequestFinancialVerificationForm)
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
from flask_wtf import FlaskForm
|
from flask_wtf import FlaskForm
|
||||||
|
from flask import current_app, request as http_request
|
||||||
|
|
||||||
|
|
||||||
class ValidatedForm(FlaskForm):
|
class ValidatedForm(FlaskForm):
|
||||||
@ -12,3 +13,11 @@ class ValidatedForm(FlaskForm):
|
|||||||
_data = super().data
|
_data = super().data
|
||||||
_data.pop("csrf_token", None)
|
_data.pop("csrf_token", None)
|
||||||
return _data
|
return _data
|
||||||
|
|
||||||
|
|
||||||
|
class CacheableForm(ValidatedForm):
|
||||||
|
def __init__(self, formdata=None, **kwargs):
|
||||||
|
formdata = formdata or {}
|
||||||
|
cached_data = current_app.form_cache.from_request(http_request)
|
||||||
|
cached_data.update(formdata)
|
||||||
|
super().__init__(cached_data, **kwargs)
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
from wtforms.fields import TextAreaField
|
from wtforms.fields import TextAreaField
|
||||||
from wtforms.validators import InputRequired
|
from wtforms.validators import InputRequired
|
||||||
|
|
||||||
from .forms import ValidatedForm
|
from .forms import CacheableForm
|
||||||
|
|
||||||
|
|
||||||
class InternalCommentForm(ValidatedForm):
|
class InternalCommentForm(CacheableForm):
|
||||||
text = TextAreaField(
|
text = TextAreaField(
|
||||||
"CCPO Internal Notes",
|
"CCPO Internal Notes",
|
||||||
default="",
|
default="",
|
||||||
|
@ -4,7 +4,7 @@ from wtforms.fields import BooleanField, RadioField, StringField, TextAreaField
|
|||||||
from wtforms.validators import Email, Length, Optional, InputRequired, DataRequired
|
from wtforms.validators import Email, Length, Optional, InputRequired, DataRequired
|
||||||
|
|
||||||
from .fields import SelectField
|
from .fields import SelectField
|
||||||
from .forms import ValidatedForm
|
from .forms import CacheableForm
|
||||||
from .edit_user import USER_FIELDS, inherit_field
|
from .edit_user import USER_FIELDS, inherit_field
|
||||||
from .data import (
|
from .data import (
|
||||||
SERVICE_BRANCHES,
|
SERVICE_BRANCHES,
|
||||||
@ -16,7 +16,7 @@ from .validators import DateRange, IsNumber
|
|||||||
from atst.domain.requests import Requests
|
from atst.domain.requests import Requests
|
||||||
|
|
||||||
|
|
||||||
class DetailsOfUseForm(ValidatedForm):
|
class DetailsOfUseForm(CacheableForm):
|
||||||
def validate(self, *args, **kwargs):
|
def validate(self, *args, **kwargs):
|
||||||
if self.jedi_migration.data == "no":
|
if self.jedi_migration.data == "no":
|
||||||
self.rationalization_software_systems.validators.append(Optional())
|
self.rationalization_software_systems.validators.append(Optional())
|
||||||
@ -162,7 +162,7 @@ class DetailsOfUseForm(ValidatedForm):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class InformationAboutYouForm(ValidatedForm):
|
class InformationAboutYouForm(CacheableForm):
|
||||||
fname_request = inherit_field(USER_FIELDS["first_name"])
|
fname_request = inherit_field(USER_FIELDS["first_name"])
|
||||||
lname_request = inherit_field(USER_FIELDS["last_name"])
|
lname_request = inherit_field(USER_FIELDS["last_name"])
|
||||||
email_request = inherit_field(USER_FIELDS["email"])
|
email_request = inherit_field(USER_FIELDS["email"])
|
||||||
@ -174,7 +174,7 @@ class InformationAboutYouForm(ValidatedForm):
|
|||||||
date_latest_training = inherit_field(USER_FIELDS["date_latest_training"])
|
date_latest_training = inherit_field(USER_FIELDS["date_latest_training"])
|
||||||
|
|
||||||
|
|
||||||
class WorkspaceOwnerForm(ValidatedForm):
|
class WorkspaceOwnerForm(CacheableForm):
|
||||||
def validate(self, *args, **kwargs):
|
def validate(self, *args, **kwargs):
|
||||||
if self.am_poc.data:
|
if self.am_poc.data:
|
||||||
# Prepend Optional validators so that the validation chain
|
# Prepend Optional validators so that the validation chain
|
||||||
@ -203,5 +203,5 @@ class WorkspaceOwnerForm(ValidatedForm):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ReviewAndSubmitForm(ValidatedForm):
|
class ReviewAndSubmitForm(CacheableForm):
|
||||||
reviewed = BooleanField("I have reviewed this data and it is correct.")
|
reviewed = BooleanField("I have reviewed this data and it is correct.")
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
from wtforms.fields import StringField
|
from wtforms.fields import StringField
|
||||||
from wtforms.validators import Length
|
from wtforms.validators import Length
|
||||||
|
|
||||||
from .forms import ValidatedForm
|
from .forms import CacheableForm
|
||||||
|
|
||||||
|
|
||||||
class WorkspaceForm(ValidatedForm):
|
class WorkspaceForm(CacheableForm):
|
||||||
name = StringField(
|
name = StringField(
|
||||||
"Workspace Name",
|
"Workspace Name",
|
||||||
validators=[
|
validators=[
|
||||||
|
@ -96,7 +96,12 @@ def _make_authentication_context():
|
|||||||
|
|
||||||
def redirect_after_login_url():
|
def redirect_after_login_url():
|
||||||
if request.args.get("next"):
|
if request.args.get("next"):
|
||||||
return request.args.get("next")
|
returl = request.args.get("next")
|
||||||
|
if request.args.get(app.form_cache.PARAM_NAME):
|
||||||
|
returl += "?" + url.urlencode(
|
||||||
|
{app.form_cache.PARAM_NAME: request.args.get(app.form_cache.PARAM_NAME)}
|
||||||
|
)
|
||||||
|
return returl
|
||||||
else:
|
else:
|
||||||
return url_for("atst.home")
|
return url_for("atst.home")
|
||||||
|
|
||||||
|
@ -37,7 +37,10 @@ def make_error_pages(app):
|
|||||||
# pylint: disable=unused-variable
|
# pylint: disable=unused-variable
|
||||||
def session_expired(e):
|
def session_expired(e):
|
||||||
log_error(e)
|
log_error(e)
|
||||||
return redirect(url_for("atst.root", sessionExpired=True, next=request.path))
|
url_args = {"sessionExpired": True, "next": request.path}
|
||||||
|
if request.method == "POST":
|
||||||
|
url_args[app.form_cache.PARAM_NAME] = app.form_cache.write(request.form)
|
||||||
|
return redirect(url_for("atst.root", **url_args))
|
||||||
|
|
||||||
@app.errorhandler(Exception)
|
@app.errorhandler(Exception)
|
||||||
# pylint: disable=unused-variable
|
# pylint: disable=unused-variable
|
||||||
|
@ -99,6 +99,7 @@ class GetFinancialVerificationForm(FinancialVerificationBase):
|
|||||||
|
|
||||||
def execute(self):
|
def execute(self):
|
||||||
form = self._get_form(self.request, self.is_extended)
|
form = self._get_form(self.request, self.is_extended)
|
||||||
|
form.reset()
|
||||||
return form
|
return form
|
||||||
|
|
||||||
|
|
||||||
@ -178,6 +179,7 @@ class SaveFinancialVerificationDraft(FinancialVerificationBase):
|
|||||||
return updated_request
|
return updated_request
|
||||||
|
|
||||||
|
|
||||||
|
@requests_bp.route("/requests/verify/<string:request_id>/draft", methods=["GET"])
|
||||||
@requests_bp.route("/requests/verify/<string:request_id>", methods=["GET"])
|
@requests_bp.route("/requests/verify/<string:request_id>", methods=["GET"])
|
||||||
def financial_verification(request_id):
|
def financial_verification(request_id):
|
||||||
request = Requests.get(g.current_user, request_id)
|
request = Requests.get(g.current_user, request_id)
|
||||||
|
40
atst/utils/form_cache.py
Normal file
40
atst/utils/form_cache.py
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
from hashlib import sha256
|
||||||
|
import json
|
||||||
|
from werkzeug.datastructures import MultiDict
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_CACHE_NAME = "formcache"
|
||||||
|
|
||||||
|
|
||||||
|
class FormCache(object):
|
||||||
|
PARAM_NAME = "formCache"
|
||||||
|
|
||||||
|
def __init__(self, redis):
|
||||||
|
self.redis = redis
|
||||||
|
|
||||||
|
def from_request(self, http_request):
|
||||||
|
cache_key = http_request.args.get(self.PARAM_NAME)
|
||||||
|
if cache_key:
|
||||||
|
return self.read(cache_key)
|
||||||
|
return MultiDict()
|
||||||
|
|
||||||
|
def write(self, formdata, expiry_seconds=3600, key_prefix=DEFAULT_CACHE_NAME):
|
||||||
|
value = json.dumps(formdata)
|
||||||
|
hash_ = self._hash()
|
||||||
|
self.redis.setex(
|
||||||
|
name=self._key(key_prefix, hash_), value=value, time=expiry_seconds
|
||||||
|
)
|
||||||
|
return hash_
|
||||||
|
|
||||||
|
def read(self, formdata_key, key_prefix=DEFAULT_CACHE_NAME):
|
||||||
|
data = self.redis.get(self._key(key_prefix, formdata_key))
|
||||||
|
dict_data = json.loads(data) if data is not None else {}
|
||||||
|
return MultiDict(dict_data)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _key(prefix, hash_):
|
||||||
|
return "{}:{}".format(prefix, hash_)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _hash():
|
||||||
|
return sha256().hexdigest()
|
22
tests/utils/test_form_cache.py
Normal file
22
tests/utils/test_form_cache.py
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import pytest
|
||||||
|
from werkzeug.datastructures import ImmutableMultiDict
|
||||||
|
|
||||||
|
from atst.utils.form_cache import DEFAULT_CACHE_NAME, FormCache
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def form_cache(app):
|
||||||
|
return FormCache(app.redis)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_form_data(app, form_cache):
|
||||||
|
data = ImmutableMultiDict({"kessel_run": "12 parsecs"})
|
||||||
|
key = form_cache.write(data)
|
||||||
|
assert app.redis.get("{}:{}".format(DEFAULT_CACHE_NAME, key))
|
||||||
|
|
||||||
|
|
||||||
|
def test_retrieve_form_data(form_cache):
|
||||||
|
data = ImmutableMultiDict({"class": "corellian"})
|
||||||
|
key = form_cache.write(data)
|
||||||
|
retrieved = form_cache.read(key)
|
||||||
|
assert retrieved == data
|
Loading…
x
Reference in New Issue
Block a user