diff --git a/templates/taxbrain/input_form.html b/templates/taxbrain/input_form.html index 85b0f56a..15a33163 100644 --- a/templates/taxbrain/input_form.html +++ b/templates/taxbrain/input_form.html @@ -192,6 +192,7 @@

User tips

DO NOT use commas as thousands separators.
  • Express rates as decimals.
  • You can represent a value that would otherwise manifest itself in a year with the * operator. So, for example, if you enter 0.1, *, 0.2 in a tax rate field, the rate will be 10 percent in the Start Year, 10 percent a year after the Start Year, and 20 percent two years after the Start Year and forward. If you enter 100, *, 200 in a CPI-indexed field for a credit amount, the credit will be $100 in the Start Year, $100*(1+CPI) the next year, and $200 the following year.
  • +
  • You may specify a parameter change in the year before the Start Year with the < operator. For example, if you set the Start Year as 2018 and a Parameter Indexing Offset parameter to <,-0.0025,-0.001 then this sets the Offset to -0.0025 in 2017 and -0.001 in 2018.
  • Read the Docs
  • Leave Feedback
  • diff --git a/webapp/apps/taxbrain/forms.py b/webapp/apps/taxbrain/forms.py index e60b9d90..f6c09ed6 100644 --- a/webapp/apps/taxbrain/forms.py +++ b/webapp/apps/taxbrain/forms.py @@ -275,11 +275,12 @@ def do_taxcalc_validations(self): continue submitted_col_values_raw = self.cleaned_data[col_field.id] - if len(submitted_col_values_raw) > 0 and submitted_col_values_raw not in BOOLEAN_FLAGS: try: INPUT.parseString(submitted_col_values_raw) - except ParseException as pe: + # reverse character is not at the beginning + assert submitted_col_values_raw.find('<') <= 0 + except (ParseException, AssertionError): # Parse Error - we don't recognize what they gave us self.add_error(col_field.id, "Unrecognized value: {}".format(submitted_col_values_raw)) diff --git a/webapp/apps/taxbrain/helpers.py b/webapp/apps/taxbrain/helpers.py index e2169975..d77aa858 100644 --- a/webapp/apps/taxbrain/helpers.py +++ b/webapp/apps/taxbrain/helpers.py @@ -37,13 +37,14 @@ DEC_POINT = pp.Word('.', exact=1) FLOAT_LIT_FULL = pp.Word(pp.nums + '.' + pp.nums) COMMON = pp.Word(",", exact=1) +REVERSE = pp.Word("<") + COMMON VALUE = WILDCARD | NEG_DASH | FLOAT_LIT_FULL | FLOAT_LIT | INT_LIT MORE_VALUES = COMMON + VALUE BOOL = WILDCARD | TRUE | FALSE MORE_BOOLS = COMMON + BOOL -INPUT = BOOL + pp.ZeroOrMore(MORE_BOOLS) | VALUE + pp.ZeroOrMore(MORE_VALUES) +INPUT = pp.Optional(REVERSE) + BOOL + pp.ZeroOrMore(MORE_BOOLS) | pp.Optional(REVERSE) + VALUE + pp.ZeroOrMore(MORE_VALUES) TRUE_REGEX = re.compile('(?i)true') FALSE_REGEX = re.compile('(?i)false') @@ -54,6 +55,14 @@ def is_wildcard(x): else: return False + +def is_reverse(x): + if isinstance(x, six.string_types): + return x in ['<', u'<'] or x.strip() in ['<', u'<'] + else: + return False + + def check_wildcards(x): if isinstance(x, list): return any([check_wildcards(i) for i in x]) @@ -88,6 +97,8 @@ def make_bool(x): def convert_val(x): if is_wildcard(x): return x + if is_reverse(x): + return x try: return float(x) except ValueError: @@ -399,6 +410,7 @@ def to_json_reform(fields, start_year, cls=taxcalc.Policy): returns json style reform """ map_back_to_tb = {} + number_reverse_operators = 1 default_params = cls.default_data(start_year=start_year, metadata=True) ignore = (u'has_errors', u'csrfmiddlewaretoken', u'start_year', @@ -417,14 +429,32 @@ def to_json_reform(fields, start_year, cls=taxcalc.Policy): assert isinstance(fields[param], bool) and param.endswith('_cpi') reform[param_name][str(start_year)] = fields[param] continue - for i in range(len(fields[param])): + i = 0 + while i < len(fields[param]): if is_wildcard(fields[param][i]): # may need to do something here pass + elif is_reverse(fields[param][i]): + # only the first character can be a reverse char + # and there must be a following character + assert len(fields[param]) > 1 + # set value for parameter in start_year - 1 + assert (isinstance(fields[param][i + 1], (int, float)) or + isinstance(fields[param][i + 1], bool)) + reform[param_name][str(start_year - 1)] = \ + [fields[param][i + 1]] + + # realign year and parameter indices + for op in (0, number_reverse_operators + 1): + fields[param].pop(0) + continue else: assert (isinstance(fields[param][i], (int, float)) or isinstance(fields[param][i], bool)) - reform[param_name][str(start_year + i)] = [fields[param][i]] + reform[param_name][str(start_year + i)] = \ + [fields[param][i]] + + i += 1 return reform, map_back_to_tb diff --git a/webapp/apps/taxbrain/models.py b/webapp/apps/taxbrain/models.py index 12e2c68a..c87f455e 100644 --- a/webapp/apps/taxbrain/models.py +++ b/webapp/apps/taxbrain/models.py @@ -37,7 +37,7 @@ def numberfy(x): COMMASEP_REGEX = "(\\d*\\.\\d+|\\d+)|((?i)(true|false))" class CommaSeparatedField(models.CharField): - default_validators = [validators.RegexValidator(regex="(\\d*\\.\\d+|\\d+)|((?i)(true|false))")] + default_validators = [validators.RegexValidator(regex="(<,)|(\\d*\\.\\d+|\\d+)|((?i)(true|false))")] description = "A comma separated field that allows multiple floats." def __init__(self, verbose_name=None, name=None, **kwargs): diff --git a/webapp/apps/taxbrain/tests/test_helpers.py b/webapp/apps/taxbrain/tests/test_helpers.py index b0cd566f..0c98fbb9 100644 --- a/webapp/apps/taxbrain/tests/test_helpers.py +++ b/webapp/apps/taxbrain/tests/test_helpers.py @@ -4,7 +4,8 @@ import taxcalc import pyparsing as pp from ..helpers import (nested_form_parameters, rename_keys, INPUT, make_bool, - TaxCalcParam) + is_reverse, TaxCalcParam) + CURRENT_LAW_POLICY = """ { @@ -139,12 +140,14 @@ def test_rename_keys(monkeypatch): 'False', 'false', 'FALSE','faLSe', 'true,*', '*, true', '*,*,false', 'true,*,false,*,*,true', - '1,*,False', '0.0,True', '1.0,False'] + '1,*,False', '0.0,True', '1.0,False', + '<,True', '<,1'] ) def test_parsing_pass(item): INPUT.parseString(item) -def test_parsing_fail(): +@pytest.mark.parametrize('item', ['abc', '<,', '<', '1,<', '0,<,1', 'True,<', '-0.002,<,-0.001']) +def test_parsing_fail(item): with pytest.raises(pp.ParseException): INPUT.parseString('abc') @@ -166,6 +169,12 @@ def test_make_bool_fail(item): with pytest.raises((ValueError, TypeError)): make_bool(item) +@pytest.mark.parametrize( + 'item,exp', + [('<', True), ('a', False), ('1', False), (1, False), (False, False)]) +def test_is_reverse(item, exp): + assert is_reverse(item) is exp + def test_make_taxcalc_param(): """ diff --git a/webapp/apps/taxbrain/tests/test_views.py b/webapp/apps/taxbrain/tests/test_views.py index a82d2ec6..0953622e 100644 --- a/webapp/apps/taxbrain/tests/test_views.py +++ b/webapp/apps/taxbrain/tests/test_views.py @@ -361,7 +361,7 @@ def test_taxbrain_wildcard_params_with_validation_gives_error(self): assert response.context['has_errors'] is True - def test_taxbrain_wildcard_in_validation_params_OK(self): + def test_taxbrain_spec_operators_in_validation_params_OK(self): """ Set upper threshold for income tax bracket 1 to *, 38000 Set upper threshold for income tax bracket 2 to *, *, 39500 @@ -369,9 +369,27 @@ def test_taxbrain_wildcard_in_validation_params_OK(self): """ data = get_post_data(START_YEAR, _ID_BenefitSurtax_Switches=False) mod = {u'II_brk1_0': [u'*, *, 38000'], - u'II_brk2_0': [u'*, *, 39500']} + u'II_brk2_0': [u'*, *, 39500'], + u'cpi_offset': [u'<,-0.0025'], + u'FICA_ss_trt': [u'< ,0.1,*,0.15,0.2']} data.update(mod) - do_micro_sim(self.client, data) + result = do_micro_sim(self.client, data) + + truth_mods = { + START_YEAR - 1: { + '_cpi_offset': [-0.0025], + '_FICA_ss_trt': [0.1] + }, + START_YEAR + 1: { + '_FICA_ss_trt': [0.15] + }, + START_YEAR + 2: { + '_FICA_ss_trt': [0.2] + } + } + + check_posted_params(result['tb_dropq_compute'], truth_mods, START_YEAR) + def test_taxbrain_wildcard_in_validation_params_gives_error(self): @@ -395,6 +413,39 @@ def test_taxbrain_wildcard_in_validation_params_gives_error(self): self.assertEqual(response.status_code, 200) assert response.context['has_errors'] is True + + def test_taxbrain_improper_reverse_gives_error1(self): + """ + Check reverse operator post without other numbers throws error + """ + #Monkey patch to mock out running of compute jobs + get_dropq_compute_from_module('webapp.apps.taxbrain.views') + + data = get_post_data(START_YEAR, _ID_BenefitSurtax_Switches=False) + mod = {u'cpi_offset': [u'<,']} + data.update(mod) + + response = self.client.post('/taxbrain/', data) + # Check that redirect happens + self.assertEqual(response.status_code, 200) + assert response.context['has_errors'] is True + + def test_taxbrain_improper_reverse_gives_error2(self): + """ + Check reverse operator not in first position throws error + """ + #Monkey patch to mock out running of compute jobs + get_dropq_compute_from_module('webapp.apps.taxbrain.views') + + data = get_post_data(START_YEAR, _ID_BenefitSurtax_Switches=False) + mod = {u'cpi_offset': [u'-0.002,<,-0.001']} + data.update(mod) + + response = self.client.post('/taxbrain/', data) + # Check that redirect happens + self.assertEqual(response.status_code, 200) + assert response.context['has_errors'] is True + def test_taxbrain_bool_separated_values(self): """ Test _DependentCredit_before_CTC can be posted as comma separated diff --git a/webapp/apps/taxbrain/views.py b/webapp/apps/taxbrain/views.py index 4998a236..0e0c9339 100644 --- a/webapp/apps/taxbrain/views.py +++ b/webapp/apps/taxbrain/views.py @@ -798,7 +798,8 @@ def edit_personal_results(request, pk): 'webapp_version': webapp_vers_disp, 'start_years': START_YEARS, 'start_year': str(start_year), - 'is_edit_page': True + 'is_edit_page': True, + 'has_errors': False } return render(request, 'taxbrain/input_form.html', init_context) diff --git a/webapp/apps/test_assets/test_reform.py b/webapp/apps/test_assets/test_reform.py index fa1c8973..31d89200 100644 --- a/webapp/apps/test_assets/test_reform.py +++ b/webapp/apps/test_assets/test_reform.py @@ -204,8 +204,10 @@ } test_coverage_fields = dict( + cpi_offset = ['<', -0.0025], CG_nodiff = [False], - FICA_ss_trt = [u'*', 0.1, u'*', 0.2], + FICA_ss_trt = ['<',0.1,'*',0.15,0.2], + FICA_mc_trt = ['<',0.1,0.15], STD_0 = [8000.0, '*', 10000.0], ID_BenefitSurtax_Switch_0 = [True], ID_Charity_c_cpi = True, @@ -214,8 +216,10 @@ ) test_coverage_reform = { + '_cpi_offset': {'2016': [-0.0025]}, '_CG_nodiff': {'2017': [False]}, - '_FICA_ss_trt': {'2020': [0.2], '2018': [0.1]}, + '_FICA_ss_trt': {'2016': [0.1], '2018': [0.15], '2019': [0.2]}, + '_FICA_mc_trt': {'2016': [0.1], '2017': [0.15]}, '_STD_single': {'2017': [8000.0], '2019': [10000.0]}, '_ID_Charity_c_cpi': {'2017': True}, '_ID_BenefitSurtax_Switch_medical': {'2017': [True]}, @@ -267,7 +271,8 @@ u'_ID_BenefitCap_Switch_realestate': 'ID_BenefitCap_Switch_2', u'_STD_single': 'STD_0', u'_STD_headhousehold': 'STD_3', - u'_II_brk4_single': 'II_brk4_0' + u'_II_brk4_single': 'II_brk4_0', + u'_cpi_offset': 'cpi_offset' } test_coverage_json_reform = """