From fc4fbbbe5bd39adbf4645c32c129947038b1c579 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Sep 2025 13:03:07 +0000 Subject: [PATCH 01/12] Initial plan From 8bac5e507d566b7431a48969a64994505f7bd50f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Sep 2025 13:12:17 +0000 Subject: [PATCH 02/12] Add demographic data collection methods and questions Co-authored-by: nonprofittechy <7645641+nonprofittechy@users.noreply.github.com> --- DEMOGRAPHIC_FIELDS.md | 174 +++++++++++++ docassemble/AssemblyLine/al_general.py | 236 ++++++++++++++++++ .../data/questions/ql_baseline.yml | 104 +++++++- .../data/questions/test_question_library.yml | 35 ++- .../sources/test_question_library.feature | 13 + docassemble/AssemblyLine/test_al_general.py | 129 +++++++++- 6 files changed, 687 insertions(+), 4 deletions(-) create mode 100644 DEMOGRAPHIC_FIELDS.md diff --git a/DEMOGRAPHIC_FIELDS.md b/DEMOGRAPHIC_FIELDS.md new file mode 100644 index 00000000..3b9e09a0 --- /dev/null +++ b/DEMOGRAPHIC_FIELDS.md @@ -0,0 +1,174 @@ +# Demographic Data Fields + +This document describes the demographic data collection fields available in the AssemblyLine library. + +## Overview + +The AssemblyLine library now includes methods for collecting demographic information from users. These methods follow the same pattern as existing field methods like `gender_fields()` and `language_fields()`. + +## Available Methods + +### `race_and_ethnicity_fields()` + +Collects race and ethnicity information using checkboxes to allow multiple selections. + +**Attributes:** +- `race_ethnicity`: DACheckboxes object containing selected race/ethnicity values +- `race_ethnicity_other`: Text field for custom race/ethnicity when "Other" is selected + +**Possible Values:** +- `american_indian_alaska_native`: American Indian or Alaska Native +- `asian`: Asian +- `black_african_american`: Black or African American +- `hispanic_latino`: Hispanic or Latino +- `native_hawaiian_pacific_islander`: Native Hawaiian or Other Pacific Islander +- `white`: White +- `two_or_more_races`: Two or more races +- `other`: Other (enables text field for specification) +- `prefer_not_to_say`: Prefer not to say + +**Usage:** +```yaml +fields: + - code: | + users[0].race_and_ethnicity_fields(show_help=True) +``` + +### `age_range_fields()` + +Collects age range information using radio buttons. + +**Attributes:** +- `age_range`: Single value representing the selected age range + +**Possible Values:** +- `under_18`: Under 18 +- `18_24`: 18-24 +- `25_34`: 25-34 +- `35_44`: 35-44 +- `45_54`: 45-54 +- `55_64`: 55-64 +- `65_74`: 65-74 +- `75_and_over`: 75 and over +- `prefer_not_to_say`: Prefer not to say + +**Usage:** +```yaml +fields: + - code: | + users[0].age_range_fields(show_help=True) +``` + +### `income_range_fields()` + +Collects household income range information using radio buttons. + +**Attributes:** +- `income_range`: Single value representing the selected income range + +**Possible Values:** +- `under_15k`: Less than $15,000 +- `15k_24k`: $15,000 - $24,999 +- `25k_34k`: $25,000 - $34,999 +- `35k_49k`: $35,000 - $49,999 +- `50k_74k`: $50,000 - $74,999 +- `75k_99k`: $75,000 - $99,999 +- `100k_149k`: $100,000 - $149,999 +- `150k_and_over`: $150,000 or more +- `prefer_not_to_say`: Prefer not to say + +**Usage:** +```yaml +fields: + - code: | + users[0].income_range_fields(show_help=True) +``` + +### `occupation_fields()` + +Collects occupation classification information using radio buttons. + +**Attributes:** +- `occupation`: Single value representing the selected occupation category +- `occupation_other`: Text field for custom occupation when "Other" is selected + +**Possible Values:** +- `management_business_science_arts`: Management, business, science, and arts +- `service`: Service +- `sales_office`: Sales and office +- `natural_resources_construction_maintenance`: Natural resources, construction, and maintenance +- `production_transportation_material_moving`: Production, transportation, and material moving +- `military`: Military +- `student`: Student +- `retired`: Retired +- `unemployed`: Unemployed +- `other`: Other (enables text field for specification) +- `prefer_not_to_say`: Prefer not to say + +**Usage:** +```yaml +fields: + - code: | + users[0].occupation_fields(show_help=True) +``` + +## Method Parameters + +All demographic field methods support the following parameters: + +- `show_help` (bool): Whether to show additional help text. Defaults to False. +- `show_if` (Union[str, Dict[str, str], None]): Condition to determine if the field should be shown. Defaults to None. +- `maxlengths` (Dict[str, int], optional): A dictionary of field names and their maximum lengths. Default is None. +- `choices` (Optional[Union[List[Dict[str, str]], Callable]]): Custom choices or a callable that returns choices. Defaults to standard options. + +## Example Usage + +### Single Demographics Question + +```yaml +--- +sets: + - users[0].race_ethnicity + - users[0].age_range + - users[0].income_range + - users[0].occupation +id: demographic information +question: | + Tell us about yourself +subquestion: | + This information helps us understand who we serve. All questions are optional. +fields: + - code: | + users[0].race_and_ethnicity_fields(show_help=True) + - code: | + users[0].age_range_fields(show_help=True) + - code: | + users[0].income_range_fields(show_help=True) + - code: | + users[0].occupation_fields(show_help=True) +``` + +### Individual Questions + +```yaml +--- +sets: + - users[0].race_ethnicity +question: | + What is your race and ethnicity? +fields: + - code: | + users[0].race_and_ethnicity_fields(show_help=True) +``` + +## Integration with Weaver + +The demographic fields are designed to be compatible with the Assembly Line Weaver. The field methods can be used in Weaver-generated interviews by adding them to the appropriate question blocks. + +## Privacy Considerations + +All demographic questions include "Prefer not to say" options to respect user privacy. The questions are designed to be optional and can be conditionally shown using the `show_if` parameter. + +## Customization + +All field methods accept custom choices through the `choices` parameter, allowing developers to adapt the categories to their specific needs or jurisdictional requirements. \ No newline at end of file diff --git a/docassemble/AssemblyLine/al_general.py b/docassemble/AssemblyLine/al_general.py index e4e9d41c..3016225b 100644 --- a/docassemble/AssemblyLine/al_general.py +++ b/docassemble/AssemblyLine/al_general.py @@ -1476,6 +1476,242 @@ def list_pronouns(self) -> str: """ return comma_list(sorted(self.get_pronouns())) + def race_and_ethnicity_fields( + self, + show_help: bool = False, + show_if: Union[str, Dict[str, str], None] = None, + maxlengths: Optional[Dict[str, int]] = None, + choices: Optional[Union[List[Dict[str, str]], Callable]] = None, + ) -> List[Dict[str, str]]: + """ + Generate fields for capturing race and ethnicity information. + + Args: + show_help (bool): Whether to show additional help text. Defaults to False. + show_if (Union[str, Dict[str, str], None]): Condition to determine if the field should be shown. Defaults to None. + maxlengths (Dict[str, int], optional): A dictionary of field names and their maximum lengths. Default is None. + choices (Optional[Union[List[Dict[str, str]], Callable]]): A list of choices for race/ethnicity or a callable that returns such a list. Defaults to standard categories. + + Returns: + List[Dict[str, str]]: A list of dictionaries with field prompts for race and ethnicity. + """ + if not choices: + choices = [ + {"American Indian or Alaska Native": "american_indian_alaska_native"}, + {"Asian": "asian"}, + {"Black or African American": "black_african_american"}, + {"Hispanic or Latino": "hispanic_latino"}, + {"Native Hawaiian or Other Pacific Islander": "native_hawaiian_pacific_islander"}, + {"White": "white"}, + {"Two or more races": "two_or_more_races"}, + {"Other": "other"}, + {"Prefer not to say": "prefer_not_to_say"}, + ] + if callable(choices): + choices = choices() + + other_input = { + "label": "Please specify", + "field": self.attr_name("race_ethnicity_other"), + "show if": {"variable": self.attr_name("race_ethnicity"), "is": "other"}, + } + + fields = [ + { + "label": "Race and ethnicity", + "field": self.attr_name("race_ethnicity"), + "datatype": "checkboxes", + "choices": choices, + }, + other_input, + ] + + if show_help: + fields[0]["help"] = "You may select more than one category that applies to you." + if show_if: + fields[0]["show if"] = show_if + + if maxlengths: + for field in fields: + if field["field"] in maxlengths: + field["maxlength"] = maxlengths[field["field"]] + + return fields + + def age_range_fields( + self, + show_help: bool = False, + show_if: Union[str, Dict[str, str], None] = None, + maxlengths: Optional[Dict[str, int]] = None, + choices: Optional[Union[List[Dict[str, str]], Callable]] = None, + ) -> List[Dict[str, str]]: + """ + Generate fields for capturing age range information. + + Args: + show_help (bool): Whether to show additional help text. Defaults to False. + show_if (Union[str, Dict[str, str], None]): Condition to determine if the field should be shown. Defaults to None. + maxlengths (Dict[str, int], optional): A dictionary of field names and their maximum lengths. Default is None. + choices (Optional[Union[List[Dict[str, str]], Callable]]): A list of age range choices or a callable that returns such a list. Defaults to standard ranges. + + Returns: + List[Dict[str, str]]: A list of dictionaries with field prompts for age range. + """ + if not choices: + choices = [ + {"Under 18": "under_18"}, + {"18-24": "18_24"}, + {"25-34": "25_34"}, + {"35-44": "35_44"}, + {"45-54": "45_54"}, + {"55-64": "55_64"}, + {"65-74": "65_74"}, + {"75 and over": "75_and_over"}, + {"Prefer not to say": "prefer_not_to_say"}, + ] + if callable(choices): + choices = choices() + + fields = [ + { + "label": "Age range", + "field": self.attr_name("age_range"), + "input type": "radio", + "choices": choices, + } + ] + + if show_help: + fields[0]["help"] = "Select the age range that applies to you." + if show_if: + fields[0]["show if"] = show_if + + if maxlengths: + for field in fields: + if field["field"] in maxlengths: + field["maxlength"] = maxlengths[field["field"]] + + return fields + + def income_range_fields( + self, + show_help: bool = False, + show_if: Union[str, Dict[str, str], None] = None, + maxlengths: Optional[Dict[str, int]] = None, + choices: Optional[Union[List[Dict[str, str]], Callable]] = None, + ) -> List[Dict[str, str]]: + """ + Generate fields for capturing household income range information. + + Args: + show_help (bool): Whether to show additional help text. Defaults to False. + show_if (Union[str, Dict[str, str], None]): Condition to determine if the field should be shown. Defaults to None. + maxlengths (Dict[str, int], optional): A dictionary of field names and their maximum lengths. Default is None. + choices (Optional[Union[List[Dict[str, str]], Callable]]): A list of income range choices or a callable that returns such a list. Defaults to standard ranges. + + Returns: + List[Dict[str, str]]: A list of dictionaries with field prompts for income range. + """ + if not choices: + choices = [ + {"Less than $15,000": "under_15k"}, + {"$15,000 - $24,999": "15k_24k"}, + {"$25,000 - $34,999": "25k_34k"}, + {"$35,000 - $49,999": "35k_49k"}, + {"$50,000 - $74,999": "50k_74k"}, + {"$75,000 - $99,999": "75k_99k"}, + {"$100,000 - $149,999": "100k_149k"}, + {"$150,000 or more": "150k_and_over"}, + {"Prefer not to say": "prefer_not_to_say"}, + ] + if callable(choices): + choices = choices() + + fields = [ + { + "label": "Household income range", + "field": self.attr_name("income_range"), + "input type": "radio", + "choices": choices, + } + ] + + if show_help: + fields[0]["help"] = "Select the range that best describes your household's total income before taxes in the last 12 months." + if show_if: + fields[0]["show if"] = show_if + + if maxlengths: + for field in fields: + if field["field"] in maxlengths: + field["maxlength"] = maxlengths[field["field"]] + + return fields + + def occupation_fields( + self, + show_help: bool = False, + show_if: Union[str, Dict[str, str], None] = None, + maxlengths: Optional[Dict[str, int]] = None, + choices: Optional[Union[List[Dict[str, str]], Callable]] = None, + ) -> List[Dict[str, str]]: + """ + Generate fields for capturing occupation classification information. + + Args: + show_help (bool): Whether to show additional help text. Defaults to False. + show_if (Union[str, Dict[str, str], None]): Condition to determine if the field should be shown. Defaults to None. + maxlengths (Dict[str, int], optional): A dictionary of field names and their maximum lengths. Default is None. + choices (Optional[Union[List[Dict[str, str]], Callable]]): A list of occupation choices or a callable that returns such a list. Defaults to standard classifications. + + Returns: + List[Dict[str, str]]: A list of dictionaries with field prompts for occupation. + """ + if not choices: + choices = [ + {"Management, business, science, and arts": "management_business_science_arts"}, + {"Service": "service"}, + {"Sales and office": "sales_office"}, + {"Natural resources, construction, and maintenance": "natural_resources_construction_maintenance"}, + {"Production, transportation, and material moving": "production_transportation_material_moving"}, + {"Military": "military"}, + {"Student": "student"}, + {"Retired": "retired"}, + {"Unemployed": "unemployed"}, + {"Other": "other"}, + {"Prefer not to say": "prefer_not_to_say"}, + ] + if callable(choices): + choices = choices() + + other_input = { + "label": "Please specify your occupation", + "field": self.attr_name("occupation_other"), + "show if": {"variable": self.attr_name("occupation"), "is": "other"}, + } + + fields = [ + { + "label": "Occupation", + "field": self.attr_name("occupation"), + "input type": "radio", + "choices": choices, + }, + other_input, + ] + + if show_help: + fields[0]["help"] = "Select the category that best describes your current work or situation." + if show_if: + fields[0]["show if"] = show_if + + if maxlengths: + for field in fields: + if field["field"] in maxlengths: + field["maxlength"] = maxlengths[field["field"]] + + return fields + def language_fields( self, choices: Optional[Union[List[Dict[str, str]], Callable]] = None, diff --git a/docassemble/AssemblyLine/data/questions/ql_baseline.yml b/docassemble/AssemblyLine/data/questions/ql_baseline.yml index 93967871..ad82846d 100644 --- a/docassemble/AssemblyLine/data/questions/ql_baseline.yml +++ b/docassemble/AssemblyLine/data/questions/ql_baseline.yml @@ -2593,4 +2593,106 @@ data: - Lt. - Sgt. - Fr. - - Sr. \ No newline at end of file + - Sr. +--- +################## Demographic Questions ###################### +--- +sets: + - users[i].race_ethnicity +id: user race and ethnicity +question: | + % if i == 0 and al_person_answering == "user": + What is your race and ethnicity? + % else: + What is ${ users[i] }'s race and ethnicity? + % endif +subquestion: | + % if i == 0 and al_person_answering == "user": + You may select all categories that apply to you. + % else: + ${ users[i] } may select all categories that apply. + % endif +fields: + - code: | + users[i].race_and_ethnicity_fields(show_help=True) +--- +generic object: ALIndividual +sets: + - x.race_ethnicity +id: x race and ethnicity +question: | + What is ${ x }'s race and ethnicity? +subquestion: | + You may select all categories that apply. +fields: + - code: | + x.race_and_ethnicity_fields(show_help=True) +--- +sets: + - users[i].age_range +id: user age range +question: | + % if i == 0 and al_person_answering == "user": + What is your age range? + % else: + What is ${ users[i] }'s age range? + % endif +fields: + - code: | + users[i].age_range_fields(show_help=True) +--- +generic object: ALIndividual +sets: + - x.age_range +id: x age range +question: | + What is ${ x }'s age range? +fields: + - code: | + x.age_range_fields(show_help=True) +--- +sets: + - users[i].income_range +id: user income range +question: | + % if i == 0 and al_person_answering == "user": + What is your household income range? + % else: + What is ${ users[i] }'s household income range? + % endif +fields: + - code: | + users[i].income_range_fields(show_help=True) +--- +generic object: ALIndividual +sets: + - x.income_range +id: x income range +question: | + What is ${ x }'s household income range? +fields: + - code: | + x.income_range_fields(show_help=True) +--- +sets: + - users[i].occupation +id: user occupation +question: | + % if i == 0 and al_person_answering == "user": + What is your occupation? + % else: + What is ${ users[i] }'s occupation? + % endif +fields: + - code: | + users[i].occupation_fields(show_help=True) +--- +generic object: ALIndividual +sets: + - x.occupation +id: x occupation +question: | + What is ${ x }'s occupation? +fields: + - code: | + x.occupation_fields(show_help=True) \ No newline at end of file diff --git a/docassemble/AssemblyLine/data/questions/test_question_library.yml b/docassemble/AssemblyLine/data/questions/test_question_library.yml index 6addbc5c..21e6fe1d 100644 --- a/docassemble/AssemblyLine/data/questions/test_question_library.yml +++ b/docassemble/AssemblyLine/data/questions/test_question_library.yml @@ -23,12 +23,17 @@ code: | users[0].phone_number elif which_to_test == 'pronouns': users[0].pronouns + elif which_to_test == 'demographics': + users[0].race_ethnicity + users[0].age_range + users[0].income_range + users[0].occupation all_done --- event: all_done question: All done! subquestion: | - % for item in ["name", "pronouns", "address", "address_no_address", "contact"]: + % for item in ["name", "pronouns", "address", "address_no_address", "contact", "demographics"]: % if which_to_test == item: % if item == "contact": Your contact information is: @@ -41,6 +46,12 @@ subquestion: | Your pronouns are: ${ users[0].list_pronouns() }. In the past, ${users[0].pronoun_subjective(person="3") } called ${ users[0].pronoun_reflexive(person="3") } ${ users[0].pronoun_possessive('', person="3") }. + % elif item == "demographics": + Your demographic information: + Race/Ethnicity: ${ users[0].race_ethnicity }[BR] + Age Range: ${ users[0].age_range }[BR] + Income Range: ${ users[0].income_range }[BR] + Occupation: ${ users[0].occupation }[BR] % else: ${ getattr(users[0], item) } % endif @@ -55,6 +66,7 @@ choices: - Address: address - Address (with the option to not have one): address_no_address - Contact Information: contact + - Demographics: demographics --- sets: users[0].address.has_no_address question: | @@ -62,4 +74,23 @@ question: | fields: - code: | users[0].address_fields(allow_no_address=True, country_code=AL_DEFAULT_COUNTRY, default_state=AL_DEFAULT_STATE) - +--- +sets: + - users[0].race_ethnicity + - users[0].age_range + - users[0].income_range + - users[0].occupation +id: demographic information +question: | + Tell us about yourself +subquestion: | + This information helps us understand who we serve. All questions are optional. +fields: + - code: | + users[0].race_and_ethnicity_fields(show_help=True) + - code: | + users[0].age_range_fields(show_help=True) + - code: | + users[0].income_range_fields(show_help=True) + - code: | + users[0].occupation_fields(show_help=True) diff --git a/docassemble/AssemblyLine/data/sources/test_question_library.feature b/docassemble/AssemblyLine/data/sources/test_question_library.feature index e698d923..03a9ce61 100644 --- a/docassemble/AssemblyLine/data/sources/test_question_library.feature +++ b/docassemble/AssemblyLine/data/sources/test_question_library.feature @@ -41,3 +41,16 @@ Scenario: Can enter all parts of contact info And I set the variable "users[0].mobile_number" to "617-555-5555" And I tap to continue Then I should see the phrase "All done!" + +@alql @ql4 +Scenario: Can enter demographic information + Given the max seconds for each step is 50 + And I start the interview at "test_question_library" + Then I set the variable "which_to_test" to "demographics" + And I tap to continue + And I set the variable "users[0].race_ethnicity['white']" to "True" + And I set the variable "users[0].age_range" to "25_34" + And I set the variable "users[0].income_range" to "50k_74k" + And I set the variable "users[0].occupation" to "service" + And I tap to continue + Then I should see the phrase "All done!" diff --git a/docassemble/AssemblyLine/test_al_general.py b/docassemble/AssemblyLine/test_al_general.py index 90ecef7c..dd3689fc 100644 --- a/docassemble/AssemblyLine/test_al_general.py +++ b/docassemble/AssemblyLine/test_al_general.py @@ -1,6 +1,6 @@ import unittest from .al_general import ALIndividual, ALAddress, get_visible_al_nav_items -from unittest.mock import Mock +from unittest.mock import Mock, patch from docassemble.base.util import DADict, DAAttributeError @@ -554,5 +554,132 @@ def test_case_4(self): self.assertEqual(get_visible_al_nav_items(data), expected) +class test_demographic_fields(unittest.TestCase): + def setUp(self): + self.individual = ALIndividual() + self.individual.instanceName = "test_person" + + def test_race_and_ethnicity_fields_basic(self): + """Test basic race_and_ethnicity_fields functionality""" + fields = self.individual.race_and_ethnicity_fields() + + # Should return 2 fields: the main field and the "other" text field + self.assertEqual(len(fields), 2) + + # Check main field structure + main_field = fields[0] + self.assertEqual(main_field["label"], "Race and ethnicity") + self.assertEqual(main_field["field"], "test_person.race_ethnicity") + self.assertEqual(main_field["datatype"], "checkboxes") + self.assertIsInstance(main_field["choices"], list) + + # Check "other" field structure + other_field = fields[1] + self.assertEqual(other_field["field"], "test_person.race_ethnicity_other") + self.assertIn("show if", other_field) + + def test_age_range_fields_basic(self): + """Test basic age_range_fields functionality""" + fields = self.individual.age_range_fields() + + # Should return 1 field + self.assertEqual(len(fields), 1) + + # Check field structure + field = fields[0] + self.assertEqual(field["label"], "Age range") + self.assertEqual(field["field"], "test_person.age_range") + self.assertEqual(field["input type"], "radio") + self.assertIsInstance(field["choices"], list) + + def test_income_range_fields_basic(self): + """Test basic income_range_fields functionality""" + fields = self.individual.income_range_fields() + + # Should return 1 field + self.assertEqual(len(fields), 1) + + # Check field structure + field = fields[0] + self.assertEqual(field["label"], "Household income range") + self.assertEqual(field["field"], "test_person.income_range") + self.assertEqual(field["input type"], "radio") + self.assertIsInstance(field["choices"], list) + + def test_occupation_fields_basic(self): + """Test basic occupation_fields functionality""" + fields = self.individual.occupation_fields() + + # Should return 2 fields: the main field and the "other" text field + self.assertEqual(len(fields), 2) + + # Check main field structure + main_field = fields[0] + self.assertEqual(main_field["label"], "Occupation") + self.assertEqual(main_field["field"], "test_person.occupation") + self.assertEqual(main_field["input type"], "radio") + self.assertIsInstance(main_field["choices"], list) + + # Check "other" field structure + other_field = fields[1] + self.assertEqual(other_field["field"], "test_person.occupation_other") + self.assertIn("show if", other_field) + + def test_demographic_fields_with_show_help(self): + """Test that show_help parameter adds help text""" + fields = self.individual.race_and_ethnicity_fields(show_help=True) + self.assertIn("help", fields[0]) + + fields = self.individual.age_range_fields(show_help=True) + self.assertIn("help", fields[0]) + + fields = self.individual.income_range_fields(show_help=True) + self.assertIn("help", fields[0]) + + fields = self.individual.occupation_fields(show_help=True) + self.assertIn("help", fields[0]) + + def test_demographic_fields_with_show_if(self): + """Test that show_if parameter is applied""" + show_if_condition = {"variable": "some_condition", "is": "true"} + + fields = self.individual.race_and_ethnicity_fields(show_if=show_if_condition) + self.assertEqual(fields[0]["show if"], show_if_condition) + + fields = self.individual.age_range_fields(show_if=show_if_condition) + self.assertEqual(fields[0]["show if"], show_if_condition) + + fields = self.individual.income_range_fields(show_if=show_if_condition) + self.assertEqual(fields[0]["show if"], show_if_condition) + + fields = self.individual.occupation_fields(show_if=show_if_condition) + self.assertEqual(fields[0]["show if"], show_if_condition) + + def test_demographic_fields_with_custom_choices(self): + """Test that custom choices parameter works""" + custom_choices = [{"Custom Option": "custom_value"}] + + fields = self.individual.race_and_ethnicity_fields(choices=custom_choices) + self.assertEqual(fields[0]["choices"], custom_choices) + + fields = self.individual.age_range_fields(choices=custom_choices) + self.assertEqual(fields[0]["choices"], custom_choices) + + fields = self.individual.income_range_fields(choices=custom_choices) + self.assertEqual(fields[0]["choices"], custom_choices) + + fields = self.individual.occupation_fields(choices=custom_choices) + self.assertEqual(fields[0]["choices"], custom_choices) + + def test_demographic_fields_with_maxlengths(self): + """Test that maxlengths parameter is applied""" + maxlengths = {"test_person.race_ethnicity_other": 100} + + fields = self.individual.race_and_ethnicity_fields(maxlengths=maxlengths) + # Find the "other" field + other_field = next(f for f in fields if "race_ethnicity_other" in f["field"]) + self.assertEqual(other_field["maxlength"], 100) + + if __name__ == "__main__": unittest.main() From 79be9d12bf250d18dbdf3bb3832e44e3c2dcc00d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Sep 2025 14:35:39 +0000 Subject: [PATCH 03/12] Update demographic methods with required parameter, al_ variables, and test file Co-authored-by: nonprofittechy <7645641+nonprofittechy@users.noreply.github.com> --- docassemble/AssemblyLine/al_general.py | 78 ++++------ .../data/questions/ql_baseline.yml | 55 +++++++ .../data/questions/test_demographics.yml | 139 ++++++++++++++++++ docassemble/AssemblyLine/test_al_general.py | 73 ++++++++- 4 files changed, 292 insertions(+), 53 deletions(-) create mode 100644 docassemble/AssemblyLine/data/questions/test_demographics.yml diff --git a/docassemble/AssemblyLine/al_general.py b/docassemble/AssemblyLine/al_general.py index 3016225b..2c4dff16 100644 --- a/docassemble/AssemblyLine/al_general.py +++ b/docassemble/AssemblyLine/al_general.py @@ -1482,6 +1482,7 @@ def race_and_ethnicity_fields( show_if: Union[str, Dict[str, str], None] = None, maxlengths: Optional[Dict[str, int]] = None, choices: Optional[Union[List[Dict[str, str]], Callable]] = None, + required: Optional[Dict[str, bool]] = None, ) -> List[Dict[str, str]]: """ Generate fields for capturing race and ethnicity information. @@ -1491,22 +1492,13 @@ def race_and_ethnicity_fields( show_if (Union[str, Dict[str, str], None]): Condition to determine if the field should be shown. Defaults to None. maxlengths (Dict[str, int], optional): A dictionary of field names and their maximum lengths. Default is None. choices (Optional[Union[List[Dict[str, str]], Callable]]): A list of choices for race/ethnicity or a callable that returns such a list. Defaults to standard categories. + required (Optional[Dict[str, bool]]): A dictionary of field names and their required status. Default is None. Returns: List[Dict[str, str]]: A list of dictionaries with field prompts for race and ethnicity. """ if not choices: - choices = [ - {"American Indian or Alaska Native": "american_indian_alaska_native"}, - {"Asian": "asian"}, - {"Black or African American": "black_african_american"}, - {"Hispanic or Latino": "hispanic_latino"}, - {"Native Hawaiian or Other Pacific Islander": "native_hawaiian_pacific_islander"}, - {"White": "white"}, - {"Two or more races": "two_or_more_races"}, - {"Other": "other"}, - {"Prefer not to say": "prefer_not_to_say"}, - ] + choices = value("al_race_ethnicity_choices") if callable(choices): choices = choices() @@ -1536,6 +1528,11 @@ def race_and_ethnicity_fields( if field["field"] in maxlengths: field["maxlength"] = maxlengths[field["field"]] + if required: + for field in fields: + if field["field"] in required: + field["required"] = required[field["field"]] + return fields def age_range_fields( @@ -1544,6 +1541,7 @@ def age_range_fields( show_if: Union[str, Dict[str, str], None] = None, maxlengths: Optional[Dict[str, int]] = None, choices: Optional[Union[List[Dict[str, str]], Callable]] = None, + required: Optional[Dict[str, bool]] = None, ) -> List[Dict[str, str]]: """ Generate fields for capturing age range information. @@ -1553,22 +1551,13 @@ def age_range_fields( show_if (Union[str, Dict[str, str], None]): Condition to determine if the field should be shown. Defaults to None. maxlengths (Dict[str, int], optional): A dictionary of field names and their maximum lengths. Default is None. choices (Optional[Union[List[Dict[str, str]], Callable]]): A list of age range choices or a callable that returns such a list. Defaults to standard ranges. + required (Optional[Dict[str, bool]]): A dictionary of field names and their required status. Default is None. Returns: List[Dict[str, str]]: A list of dictionaries with field prompts for age range. """ if not choices: - choices = [ - {"Under 18": "under_18"}, - {"18-24": "18_24"}, - {"25-34": "25_34"}, - {"35-44": "35_44"}, - {"45-54": "45_54"}, - {"55-64": "55_64"}, - {"65-74": "65_74"}, - {"75 and over": "75_and_over"}, - {"Prefer not to say": "prefer_not_to_say"}, - ] + choices = value("al_age_range_choices") if callable(choices): choices = choices() @@ -1591,6 +1580,11 @@ def age_range_fields( if field["field"] in maxlengths: field["maxlength"] = maxlengths[field["field"]] + if required: + for field in fields: + if field["field"] in required: + field["required"] = required[field["field"]] + return fields def income_range_fields( @@ -1599,6 +1593,7 @@ def income_range_fields( show_if: Union[str, Dict[str, str], None] = None, maxlengths: Optional[Dict[str, int]] = None, choices: Optional[Union[List[Dict[str, str]], Callable]] = None, + required: Optional[Dict[str, bool]] = None, ) -> List[Dict[str, str]]: """ Generate fields for capturing household income range information. @@ -1608,22 +1603,13 @@ def income_range_fields( show_if (Union[str, Dict[str, str], None]): Condition to determine if the field should be shown. Defaults to None. maxlengths (Dict[str, int], optional): A dictionary of field names and their maximum lengths. Default is None. choices (Optional[Union[List[Dict[str, str]], Callable]]): A list of income range choices or a callable that returns such a list. Defaults to standard ranges. + required (Optional[Dict[str, bool]]): A dictionary of field names and their required status. Default is None. Returns: List[Dict[str, str]]: A list of dictionaries with field prompts for income range. """ if not choices: - choices = [ - {"Less than $15,000": "under_15k"}, - {"$15,000 - $24,999": "15k_24k"}, - {"$25,000 - $34,999": "25k_34k"}, - {"$35,000 - $49,999": "35k_49k"}, - {"$50,000 - $74,999": "50k_74k"}, - {"$75,000 - $99,999": "75k_99k"}, - {"$100,000 - $149,999": "100k_149k"}, - {"$150,000 or more": "150k_and_over"}, - {"Prefer not to say": "prefer_not_to_say"}, - ] + choices = value("al_income_range_choices") if callable(choices): choices = choices() @@ -1646,6 +1632,11 @@ def income_range_fields( if field["field"] in maxlengths: field["maxlength"] = maxlengths[field["field"]] + if required: + for field in fields: + if field["field"] in required: + field["required"] = required[field["field"]] + return fields def occupation_fields( @@ -1654,6 +1645,7 @@ def occupation_fields( show_if: Union[str, Dict[str, str], None] = None, maxlengths: Optional[Dict[str, int]] = None, choices: Optional[Union[List[Dict[str, str]], Callable]] = None, + required: Optional[Dict[str, bool]] = None, ) -> List[Dict[str, str]]: """ Generate fields for capturing occupation classification information. @@ -1663,24 +1655,13 @@ def occupation_fields( show_if (Union[str, Dict[str, str], None]): Condition to determine if the field should be shown. Defaults to None. maxlengths (Dict[str, int], optional): A dictionary of field names and their maximum lengths. Default is None. choices (Optional[Union[List[Dict[str, str]], Callable]]): A list of occupation choices or a callable that returns such a list. Defaults to standard classifications. + required (Optional[Dict[str, bool]]): A dictionary of field names and their required status. Default is None. Returns: List[Dict[str, str]]: A list of dictionaries with field prompts for occupation. """ if not choices: - choices = [ - {"Management, business, science, and arts": "management_business_science_arts"}, - {"Service": "service"}, - {"Sales and office": "sales_office"}, - {"Natural resources, construction, and maintenance": "natural_resources_construction_maintenance"}, - {"Production, transportation, and material moving": "production_transportation_material_moving"}, - {"Military": "military"}, - {"Student": "student"}, - {"Retired": "retired"}, - {"Unemployed": "unemployed"}, - {"Other": "other"}, - {"Prefer not to say": "prefer_not_to_say"}, - ] + choices = value("al_occupation_choices") if callable(choices): choices = choices() @@ -1710,6 +1691,11 @@ def occupation_fields( if field["field"] in maxlengths: field["maxlength"] = maxlengths[field["field"]] + if required: + for field in fields: + if field["field"] in required: + field["required"] = required[field["field"]] + return fields def language_fields( diff --git a/docassemble/AssemblyLine/data/questions/ql_baseline.yml b/docassemble/AssemblyLine/data/questions/ql_baseline.yml index ad82846d..cf454e24 100644 --- a/docassemble/AssemblyLine/data/questions/ql_baseline.yml +++ b/docassemble/AssemblyLine/data/questions/ql_baseline.yml @@ -2572,6 +2572,61 @@ data: - Bart. - Bt. --- +################ Demographic Choice Variables ################ +comment: | + These variables define the default choices for demographic fields. + They can be customized by redefining them in your interview. +--- +variable name: al_race_ethnicity_choices +data: + - American Indian or Alaska Native: american_indian_alaska_native + - Asian: asian + - Black or African American: black_african_american + - Hispanic or Latino: hispanic_latino + - Native Hawaiian or Other Pacific Islander: native_hawaiian_pacific_islander + - White: white + - Two or more races: two_or_more_races + - Other: other + - Prefer not to say: prefer_not_to_say +--- +variable name: al_age_range_choices +data: + - Under 18: under_18 + - 18-24: 18_24 + - 25-34: 25_34 + - 35-44: 35_44 + - 45-54: 45_54 + - 55-64: 55_64 + - 65-74: 65_74 + - 75 and over: 75_and_over + - Prefer not to say: prefer_not_to_say +--- +variable name: al_income_range_choices +data: + - Less than $15,000: under_15k + - $15,000 - $24,999: 15k_24k + - $25,000 - $34,999: 25k_34k + - $35,000 - $49,999: 35k_49k + - $50,000 - $74,999: 50k_74k + - $75,000 - $99,999: 75k_99k + - $100,000 - $149,999: 100k_149k + - $150,000 or more: 150k_and_over + - Prefer not to say: prefer_not_to_say +--- +variable name: al_occupation_choices +data: + - Management, business, science, and arts: management_business_science_arts + - Service: service + - Sales and office: sales_office + - Natural resources, construction, and maintenance: natural_resources_construction_maintenance + - Production, transportation, and material moving: production_transportation_material_moving + - Military: military + - Student: student + - Retired: retired + - Unemployed: unemployed + - Other: other + - Prefer not to say: prefer_not_to_say +--- variable name: al_name_titles data: - Mr. diff --git a/docassemble/AssemblyLine/data/questions/test_demographics.yml b/docassemble/AssemblyLine/data/questions/test_demographics.yml new file mode 100644 index 00000000..57c10ffb --- /dev/null +++ b/docassemble/AssemblyLine/data/questions/test_demographics.yml @@ -0,0 +1,139 @@ +--- +include: + - assembly_line.yml +--- +metadata: + title: | + Test Demographic Fields + description: | + Interactive test for exploring Assembly Line demographic data collection fields +--- +objects: + - test_person: ALIndividual +--- +mandatory: True +code: | + intro_screen + test_person.name.first + test_demographic_fields + review_demographics + end_screen +--- +continue button field: intro_screen +question: | + Test Demographic Fields +subquestion: | + This interview allows you to test the demographic data collection fields available in the Assembly Line library. + + You'll be able to explore: + + * Race and ethnicity questions (checkboxes) + * Age range questions (radio buttons) + * Income range questions (radio buttons) + * Occupation questions (radio buttons) + + The questions support customization through the `show_help`, `show_if`, `maxlengths`, `choices`, and `required` parameters. +--- +sets: + - test_person.name.first + - test_person.name.last +question: | + What is your name? +subquestion: | + We'll use this to personalize the demographic questions. +fields: + - code: | + test_person.name_fields() +--- +continue button field: test_demographic_fields +question: | + Test Individual Demographic Fields +subquestion: | + Here are examples of each demographic field type. All questions are optional for testing purposes. +fields: + - note: | + **Race and Ethnicity** (checkboxes - allows multiple selections) + - code: | + test_person.race_and_ethnicity_fields(show_help=True) + - note: | + **Age Range** (radio buttons - single selection) + - code: | + test_person.age_range_fields(show_help=True) + - note: | + **Household Income Range** (radio buttons - single selection) + - code: | + test_person.income_range_fields(show_help=True) + - note: | + **Occupation** (radio buttons - single selection) + - code: | + test_person.occupation_fields(show_help=True) +--- +continue button field: review_demographics +question: | + Review Your Demographic Information +subquestion: | + Here's what you entered: + + **Name:** ${ test_person.name } + + % if defined('test_person.race_ethnicity'): + **Race/Ethnicity:** + % if hasattr(test_person.race_ethnicity, 'true_values'): + ${ comma_and_list(test_person.race_ethnicity.true_values()) } + % else: + ${ test_person.race_ethnicity } + % endif + % if defined('test_person.race_ethnicity_other') and test_person.race_ethnicity_other: + (Other: ${ test_person.race_ethnicity_other }) + % endif + % endif + + % if defined('test_person.age_range'): + **Age Range:** ${ test_person.age_range } + % endif + + % if defined('test_person.income_range'): + **Income Range:** ${ test_person.income_range } + % endif + + % if defined('test_person.occupation'): + **Occupation:** ${ test_person.occupation } + % if defined('test_person.occupation_other') and test_person.occupation_other: + (Other: ${ test_person.occupation_other }) + % endif + % endif + + This data would be available in templates and can be used for analytics and reporting. +--- +continue button field: end_screen +question: | + Test Complete +subquestion: | + You've successfully tested the Assembly Line demographic fields! + + ## Key Features Demonstrated: + + * **Customizable choices**: Each field method accepts a `choices` parameter + * **Help text**: The `show_help` parameter adds explanatory text + * **Conditional display**: The `show_if` parameter can hide/show fields + * **Required fields**: The `required` parameter controls field requirements + * **Other options**: Text fields appear when "Other" is selected + * **Privacy-focused**: All questions include "Prefer not to say" options + + ## Implementation: + + These fields can be used individually or combined in your interviews: + + ```yaml + fields: + - code: | + users[0].race_and_ethnicity_fields(show_help=True) + - code: | + users[0].age_range_fields(show_help=True) + - code: | + users[0].income_range_fields(show_help=True) + - code: | + users[0].occupation_fields(show_help=True) + ``` + + The choice lists are defined as `al_` variables and can be customized in your interview. \ No newline at end of file diff --git a/docassemble/AssemblyLine/test_al_general.py b/docassemble/AssemblyLine/test_al_general.py index dd3689fc..bb1c3d4b 100644 --- a/docassemble/AssemblyLine/test_al_general.py +++ b/docassemble/AssemblyLine/test_al_general.py @@ -559,8 +559,17 @@ def setUp(self): self.individual = ALIndividual() self.individual.instanceName = "test_person" - def test_race_and_ethnicity_fields_basic(self): + @patch('docassemble.AssemblyLine.al_general.value') + def test_race_and_ethnicity_fields_basic(self, mock_value): """Test basic race_and_ethnicity_fields functionality""" + mock_value.return_value = [ + {"American Indian or Alaska Native": "american_indian_alaska_native"}, + {"Asian": "asian"}, + {"White": "white"}, + {"Other": "other"}, + {"Prefer not to say": "prefer_not_to_say"}, + ] + fields = self.individual.race_and_ethnicity_fields() # Should return 2 fields: the main field and the "other" text field @@ -578,8 +587,15 @@ def test_race_and_ethnicity_fields_basic(self): self.assertEqual(other_field["field"], "test_person.race_ethnicity_other") self.assertIn("show if", other_field) - def test_age_range_fields_basic(self): + @patch('docassemble.AssemblyLine.al_general.value') + def test_age_range_fields_basic(self, mock_value): """Test basic age_range_fields functionality""" + mock_value.return_value = [ + {"Under 18": "under_18"}, + {"25-34": "25_34"}, + {"Prefer not to say": "prefer_not_to_say"}, + ] + fields = self.individual.age_range_fields() # Should return 1 field @@ -592,8 +608,15 @@ def test_age_range_fields_basic(self): self.assertEqual(field["input type"], "radio") self.assertIsInstance(field["choices"], list) - def test_income_range_fields_basic(self): + @patch('docassemble.AssemblyLine.al_general.value') + def test_income_range_fields_basic(self, mock_value): """Test basic income_range_fields functionality""" + mock_value.return_value = [ + {"Less than $15,000": "under_15k"}, + {"$50,000 - $74,999": "50k_74k"}, + {"Prefer not to say": "prefer_not_to_say"}, + ] + fields = self.individual.income_range_fields() # Should return 1 field @@ -606,8 +629,16 @@ def test_income_range_fields_basic(self): self.assertEqual(field["input type"], "radio") self.assertIsInstance(field["choices"], list) - def test_occupation_fields_basic(self): + @patch('docassemble.AssemblyLine.al_general.value') + def test_occupation_fields_basic(self, mock_value): """Test basic occupation_fields functionality""" + mock_value.return_value = [ + {"Service": "service"}, + {"Student": "student"}, + {"Other": "other"}, + {"Prefer not to say": "prefer_not_to_say"}, + ] + fields = self.individual.occupation_fields() # Should return 2 fields: the main field and the "other" text field @@ -625,8 +656,11 @@ def test_occupation_fields_basic(self): self.assertEqual(other_field["field"], "test_person.occupation_other") self.assertIn("show if", other_field) - def test_demographic_fields_with_show_help(self): + @patch('docassemble.AssemblyLine.al_general.value') + def test_demographic_fields_with_show_help(self, mock_value): """Test that show_help parameter adds help text""" + mock_value.return_value = [{"Test": "test"}] + fields = self.individual.race_and_ethnicity_fields(show_help=True) self.assertIn("help", fields[0]) @@ -639,8 +673,10 @@ def test_demographic_fields_with_show_help(self): fields = self.individual.occupation_fields(show_help=True) self.assertIn("help", fields[0]) - def test_demographic_fields_with_show_if(self): + @patch('docassemble.AssemblyLine.al_general.value') + def test_demographic_fields_with_show_if(self, mock_value): """Test that show_if parameter is applied""" + mock_value.return_value = [{"Test": "test"}] show_if_condition = {"variable": "some_condition", "is": "true"} fields = self.individual.race_and_ethnicity_fields(show_if=show_if_condition) @@ -655,6 +691,27 @@ def test_demographic_fields_with_show_if(self): fields = self.individual.occupation_fields(show_if=show_if_condition) self.assertEqual(fields[0]["show if"], show_if_condition) + @patch('docassemble.AssemblyLine.al_general.value') + def test_demographic_fields_with_required(self, mock_value): + """Test that required parameter is applied""" + mock_value.return_value = [{"Test": "test"}] + required_dict = {"test_person.race_ethnicity": True} + + fields = self.individual.race_and_ethnicity_fields(required=required_dict) + # Find the main field + main_field = next(f for f in fields if "race_ethnicity" in f["field"] and "other" not in f["field"]) + self.assertEqual(main_field["required"], True) + + fields = self.individual.age_range_fields(required={"test_person.age_range": False}) + self.assertEqual(fields[0]["required"], False) + + fields = self.individual.income_range_fields(required={"test_person.income_range": True}) + self.assertEqual(fields[0]["required"], True) + + fields = self.individual.occupation_fields(required={"test_person.occupation": False}) + main_field = next(f for f in fields if "occupation" in f["field"] and "other" not in f["field"]) + self.assertEqual(main_field["required"], False) + def test_demographic_fields_with_custom_choices(self): """Test that custom choices parameter works""" custom_choices = [{"Custom Option": "custom_value"}] @@ -671,8 +728,10 @@ def test_demographic_fields_with_custom_choices(self): fields = self.individual.occupation_fields(choices=custom_choices) self.assertEqual(fields[0]["choices"], custom_choices) - def test_demographic_fields_with_maxlengths(self): + @patch('docassemble.AssemblyLine.al_general.value') + def test_demographic_fields_with_maxlengths(self, mock_value): """Test that maxlengths parameter is applied""" + mock_value.return_value = [{"Test": "test"}] maxlengths = {"test_person.race_ethnicity_other": 100} fields = self.individual.race_and_ethnicity_fields(maxlengths=maxlengths) From 2d8221d0fd576cdc0f750f0de2010180ec5267fa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Sep 2025 14:42:29 +0000 Subject: [PATCH 04/12] Update demographic method docstrings to reference al_*_choices variables Co-authored-by: nonprofittechy <7645641+nonprofittechy@users.noreply.github.com> --- docassemble/AssemblyLine/al_general.py | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/docassemble/AssemblyLine/al_general.py b/docassemble/AssemblyLine/al_general.py index 2c4dff16..354ab0ec 100644 --- a/docassemble/AssemblyLine/al_general.py +++ b/docassemble/AssemblyLine/al_general.py @@ -1491,11 +1491,15 @@ def race_and_ethnicity_fields( show_help (bool): Whether to show additional help text. Defaults to False. show_if (Union[str, Dict[str, str], None]): Condition to determine if the field should be shown. Defaults to None. maxlengths (Dict[str, int], optional): A dictionary of field names and their maximum lengths. Default is None. - choices (Optional[Union[List[Dict[str, str]], Callable]]): A list of choices for race/ethnicity or a callable that returns such a list. Defaults to standard categories. + choices (Optional[Union[List[Dict[str, str]], Callable]]): A list of choices for race/ethnicity or a callable that returns such a list. If not provided, uses the `al_race_ethnicity_choices` variable defined in ql_baseline.yml. required (Optional[Dict[str, bool]]): A dictionary of field names and their required status. Default is None. Returns: List[Dict[str, str]]: A list of dictionaries with field prompts for race and ethnicity. + + Note: + The default choices are defined in the `al_race_ethnicity_choices` variable in ql_baseline.yml + and can be customized by redefining this variable in your interview. """ if not choices: choices = value("al_race_ethnicity_choices") @@ -1550,11 +1554,15 @@ def age_range_fields( show_help (bool): Whether to show additional help text. Defaults to False. show_if (Union[str, Dict[str, str], None]): Condition to determine if the field should be shown. Defaults to None. maxlengths (Dict[str, int], optional): A dictionary of field names and their maximum lengths. Default is None. - choices (Optional[Union[List[Dict[str, str]], Callable]]): A list of age range choices or a callable that returns such a list. Defaults to standard ranges. + choices (Optional[Union[List[Dict[str, str]], Callable]]): A list of age range choices or a callable that returns such a list. If not provided, uses the `al_age_range_choices` variable defined in ql_baseline.yml. required (Optional[Dict[str, bool]]): A dictionary of field names and their required status. Default is None. Returns: List[Dict[str, str]]: A list of dictionaries with field prompts for age range. + + Note: + The default choices are defined in the `al_age_range_choices` variable in ql_baseline.yml + and can be customized by redefining this variable in your interview. """ if not choices: choices = value("al_age_range_choices") @@ -1602,11 +1610,15 @@ def income_range_fields( show_help (bool): Whether to show additional help text. Defaults to False. show_if (Union[str, Dict[str, str], None]): Condition to determine if the field should be shown. Defaults to None. maxlengths (Dict[str, int], optional): A dictionary of field names and their maximum lengths. Default is None. - choices (Optional[Union[List[Dict[str, str]], Callable]]): A list of income range choices or a callable that returns such a list. Defaults to standard ranges. + choices (Optional[Union[List[Dict[str, str]], Callable]]): A list of income range choices or a callable that returns such a list. If not provided, uses the `al_income_range_choices` variable defined in ql_baseline.yml. required (Optional[Dict[str, bool]]): A dictionary of field names and their required status. Default is None. Returns: List[Dict[str, str]]: A list of dictionaries with field prompts for income range. + + Note: + The default choices are defined in the `al_income_range_choices` variable in ql_baseline.yml + and can be customized by redefining this variable in your interview. """ if not choices: choices = value("al_income_range_choices") @@ -1654,11 +1666,15 @@ def occupation_fields( show_help (bool): Whether to show additional help text. Defaults to False. show_if (Union[str, Dict[str, str], None]): Condition to determine if the field should be shown. Defaults to None. maxlengths (Dict[str, int], optional): A dictionary of field names and their maximum lengths. Default is None. - choices (Optional[Union[List[Dict[str, str]], Callable]]): A list of occupation choices or a callable that returns such a list. Defaults to standard classifications. + choices (Optional[Union[List[Dict[str, str]], Callable]]): A list of occupation choices or a callable that returns such a list. If not provided, uses the `al_occupation_choices` variable defined in ql_baseline.yml. required (Optional[Dict[str, bool]]): A dictionary of field names and their required status. Default is None. Returns: List[Dict[str, str]]: A list of dictionaries with field prompts for occupation. + + Note: + The default choices are defined in the `al_occupation_choices` variable in ql_baseline.yml + and can be customized by redefining this variable in your interview. """ if not choices: choices = value("al_occupation_choices") From 81648d3d923bfb559ef3e2dcad03bac590f771c2 Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Fri, 19 Sep 2025 10:45:55 -0400 Subject: [PATCH 05/12] Format with black --- docassemble/AssemblyLine/al_general.py | 20 ++-- docassemble/AssemblyLine/test_al_general.py | 102 +++++++++++--------- 2 files changed, 71 insertions(+), 51 deletions(-) diff --git a/docassemble/AssemblyLine/al_general.py b/docassemble/AssemblyLine/al_general.py index 354ab0ec..f3096601 100644 --- a/docassemble/AssemblyLine/al_general.py +++ b/docassemble/AssemblyLine/al_general.py @@ -1498,7 +1498,7 @@ def race_and_ethnicity_fields( List[Dict[str, str]]: A list of dictionaries with field prompts for race and ethnicity. Note: - The default choices are defined in the `al_race_ethnicity_choices` variable in ql_baseline.yml + The default choices are defined in the `al_race_ethnicity_choices` variable in ql_baseline.yml and can be customized by redefining this variable in your interview. """ if not choices: @@ -1523,7 +1523,9 @@ def race_and_ethnicity_fields( ] if show_help: - fields[0]["help"] = "You may select more than one category that applies to you." + fields[0][ + "help" + ] = "You may select more than one category that applies to you." if show_if: fields[0]["show if"] = show_if @@ -1561,7 +1563,7 @@ def age_range_fields( List[Dict[str, str]]: A list of dictionaries with field prompts for age range. Note: - The default choices are defined in the `al_age_range_choices` variable in ql_baseline.yml + The default choices are defined in the `al_age_range_choices` variable in ql_baseline.yml and can be customized by redefining this variable in your interview. """ if not choices: @@ -1617,7 +1619,7 @@ def income_range_fields( List[Dict[str, str]]: A list of dictionaries with field prompts for income range. Note: - The default choices are defined in the `al_income_range_choices` variable in ql_baseline.yml + The default choices are defined in the `al_income_range_choices` variable in ql_baseline.yml and can be customized by redefining this variable in your interview. """ if not choices: @@ -1635,7 +1637,9 @@ def income_range_fields( ] if show_help: - fields[0]["help"] = "Select the range that best describes your household's total income before taxes in the last 12 months." + fields[0][ + "help" + ] = "Select the range that best describes your household's total income before taxes in the last 12 months." if show_if: fields[0]["show if"] = show_if @@ -1673,7 +1677,7 @@ def occupation_fields( List[Dict[str, str]]: A list of dictionaries with field prompts for occupation. Note: - The default choices are defined in the `al_occupation_choices` variable in ql_baseline.yml + The default choices are defined in the `al_occupation_choices` variable in ql_baseline.yml and can be customized by redefining this variable in your interview. """ if not choices: @@ -1698,7 +1702,9 @@ def occupation_fields( ] if show_help: - fields[0]["help"] = "Select the category that best describes your current work or situation." + fields[0][ + "help" + ] = "Select the category that best describes your current work or situation." if show_if: fields[0]["show if"] = show_if diff --git a/docassemble/AssemblyLine/test_al_general.py b/docassemble/AssemblyLine/test_al_general.py index bb1c3d4b..a5e2b19b 100644 --- a/docassemble/AssemblyLine/test_al_general.py +++ b/docassemble/AssemblyLine/test_al_general.py @@ -559,7 +559,7 @@ def setUp(self): self.individual = ALIndividual() self.individual.instanceName = "test_person" - @patch('docassemble.AssemblyLine.al_general.value') + @patch("docassemble.AssemblyLine.al_general.value") def test_race_and_ethnicity_fields_basic(self, mock_value): """Test basic race_and_ethnicity_fields functionality""" mock_value.return_value = [ @@ -569,25 +569,25 @@ def test_race_and_ethnicity_fields_basic(self, mock_value): {"Other": "other"}, {"Prefer not to say": "prefer_not_to_say"}, ] - + fields = self.individual.race_and_ethnicity_fields() - + # Should return 2 fields: the main field and the "other" text field self.assertEqual(len(fields), 2) - + # Check main field structure main_field = fields[0] self.assertEqual(main_field["label"], "Race and ethnicity") self.assertEqual(main_field["field"], "test_person.race_ethnicity") self.assertEqual(main_field["datatype"], "checkboxes") self.assertIsInstance(main_field["choices"], list) - + # Check "other" field structure other_field = fields[1] self.assertEqual(other_field["field"], "test_person.race_ethnicity_other") self.assertIn("show if", other_field) - @patch('docassemble.AssemblyLine.al_general.value') + @patch("docassemble.AssemblyLine.al_general.value") def test_age_range_fields_basic(self, mock_value): """Test basic age_range_fields functionality""" mock_value.return_value = [ @@ -595,12 +595,12 @@ def test_age_range_fields_basic(self, mock_value): {"25-34": "25_34"}, {"Prefer not to say": "prefer_not_to_say"}, ] - + fields = self.individual.age_range_fields() - + # Should return 1 field self.assertEqual(len(fields), 1) - + # Check field structure field = fields[0] self.assertEqual(field["label"], "Age range") @@ -608,7 +608,7 @@ def test_age_range_fields_basic(self, mock_value): self.assertEqual(field["input type"], "radio") self.assertIsInstance(field["choices"], list) - @patch('docassemble.AssemblyLine.al_general.value') + @patch("docassemble.AssemblyLine.al_general.value") def test_income_range_fields_basic(self, mock_value): """Test basic income_range_fields functionality""" mock_value.return_value = [ @@ -616,12 +616,12 @@ def test_income_range_fields_basic(self, mock_value): {"$50,000 - $74,999": "50k_74k"}, {"Prefer not to say": "prefer_not_to_say"}, ] - + fields = self.individual.income_range_fields() - + # Should return 1 field self.assertEqual(len(fields), 1) - + # Check field structure field = fields[0] self.assertEqual(field["label"], "Household income range") @@ -629,7 +629,7 @@ def test_income_range_fields_basic(self, mock_value): self.assertEqual(field["input type"], "radio") self.assertIsInstance(field["choices"], list) - @patch('docassemble.AssemblyLine.al_general.value') + @patch("docassemble.AssemblyLine.al_general.value") def test_occupation_fields_basic(self, mock_value): """Test basic occupation_fields functionality""" mock_value.return_value = [ @@ -638,102 +638,116 @@ def test_occupation_fields_basic(self, mock_value): {"Other": "other"}, {"Prefer not to say": "prefer_not_to_say"}, ] - + fields = self.individual.occupation_fields() - + # Should return 2 fields: the main field and the "other" text field self.assertEqual(len(fields), 2) - + # Check main field structure main_field = fields[0] self.assertEqual(main_field["label"], "Occupation") self.assertEqual(main_field["field"], "test_person.occupation") self.assertEqual(main_field["input type"], "radio") self.assertIsInstance(main_field["choices"], list) - + # Check "other" field structure other_field = fields[1] self.assertEqual(other_field["field"], "test_person.occupation_other") self.assertIn("show if", other_field) - @patch('docassemble.AssemblyLine.al_general.value') + @patch("docassemble.AssemblyLine.al_general.value") def test_demographic_fields_with_show_help(self, mock_value): """Test that show_help parameter adds help text""" mock_value.return_value = [{"Test": "test"}] - + fields = self.individual.race_and_ethnicity_fields(show_help=True) self.assertIn("help", fields[0]) - + fields = self.individual.age_range_fields(show_help=True) self.assertIn("help", fields[0]) - + fields = self.individual.income_range_fields(show_help=True) self.assertIn("help", fields[0]) - + fields = self.individual.occupation_fields(show_help=True) self.assertIn("help", fields[0]) - @patch('docassemble.AssemblyLine.al_general.value') + @patch("docassemble.AssemblyLine.al_general.value") def test_demographic_fields_with_show_if(self, mock_value): """Test that show_if parameter is applied""" mock_value.return_value = [{"Test": "test"}] show_if_condition = {"variable": "some_condition", "is": "true"} - + fields = self.individual.race_and_ethnicity_fields(show_if=show_if_condition) self.assertEqual(fields[0]["show if"], show_if_condition) - + fields = self.individual.age_range_fields(show_if=show_if_condition) self.assertEqual(fields[0]["show if"], show_if_condition) - + fields = self.individual.income_range_fields(show_if=show_if_condition) self.assertEqual(fields[0]["show if"], show_if_condition) - + fields = self.individual.occupation_fields(show_if=show_if_condition) self.assertEqual(fields[0]["show if"], show_if_condition) - @patch('docassemble.AssemblyLine.al_general.value') + @patch("docassemble.AssemblyLine.al_general.value") def test_demographic_fields_with_required(self, mock_value): """Test that required parameter is applied""" mock_value.return_value = [{"Test": "test"}] required_dict = {"test_person.race_ethnicity": True} - + fields = self.individual.race_and_ethnicity_fields(required=required_dict) # Find the main field - main_field = next(f for f in fields if "race_ethnicity" in f["field"] and "other" not in f["field"]) + main_field = next( + f + for f in fields + if "race_ethnicity" in f["field"] and "other" not in f["field"] + ) self.assertEqual(main_field["required"], True) - - fields = self.individual.age_range_fields(required={"test_person.age_range": False}) + + fields = self.individual.age_range_fields( + required={"test_person.age_range": False} + ) self.assertEqual(fields[0]["required"], False) - - fields = self.individual.income_range_fields(required={"test_person.income_range": True}) + + fields = self.individual.income_range_fields( + required={"test_person.income_range": True} + ) self.assertEqual(fields[0]["required"], True) - - fields = self.individual.occupation_fields(required={"test_person.occupation": False}) - main_field = next(f for f in fields if "occupation" in f["field"] and "other" not in f["field"]) + + fields = self.individual.occupation_fields( + required={"test_person.occupation": False} + ) + main_field = next( + f + for f in fields + if "occupation" in f["field"] and "other" not in f["field"] + ) self.assertEqual(main_field["required"], False) def test_demographic_fields_with_custom_choices(self): """Test that custom choices parameter works""" custom_choices = [{"Custom Option": "custom_value"}] - + fields = self.individual.race_and_ethnicity_fields(choices=custom_choices) self.assertEqual(fields[0]["choices"], custom_choices) - + fields = self.individual.age_range_fields(choices=custom_choices) self.assertEqual(fields[0]["choices"], custom_choices) - + fields = self.individual.income_range_fields(choices=custom_choices) self.assertEqual(fields[0]["choices"], custom_choices) - + fields = self.individual.occupation_fields(choices=custom_choices) self.assertEqual(fields[0]["choices"], custom_choices) - @patch('docassemble.AssemblyLine.al_general.value') + @patch("docassemble.AssemblyLine.al_general.value") def test_demographic_fields_with_maxlengths(self, mock_value): """Test that maxlengths parameter is applied""" mock_value.return_value = [{"Test": "test"}] maxlengths = {"test_person.race_ethnicity_other": 100} - + fields = self.individual.race_and_ethnicity_fields(maxlengths=maxlengths) # Find the "other" field other_field = next(f for f in fields if "race_ethnicity_other" in f["field"]) From df49d72433f63174f10ed9587d386320eab55ba1 Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Fri, 19 Sep 2025 10:55:42 -0400 Subject: [PATCH 06/12] Additional mocks --- docassemble/AssemblyLine/test_al_general.py | 24 ++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/docassemble/AssemblyLine/test_al_general.py b/docassemble/AssemblyLine/test_al_general.py index a5e2b19b..837098c1 100644 --- a/docassemble/AssemblyLine/test_al_general.py +++ b/docassemble/AssemblyLine/test_al_general.py @@ -1,7 +1,7 @@ import unittest from .al_general import ALIndividual, ALAddress, get_visible_al_nav_items from unittest.mock import Mock, patch -from docassemble.base.util import DADict, DAAttributeError +from docassemble.base.util import DADict, DAAttributeError, value as da_value class test_aladdress(unittest.TestCase): @@ -81,6 +81,28 @@ def setUp(self): self.individual.name.first = "John" + self._value_patcher = patch( + "docassemble.AssemblyLine.al_general.value", + side_effect=self._mock_value_with_defaults, + ) + self._value_patcher.start() + + def tearDown(self): + self._value_patcher.stop() + + def _mock_value_with_defaults(self, variable_name, *args, **kwargs): + if variable_name == "al_name_suffixes": + return ["Jr.", "Sr."] + if variable_name == "al_name_titles": + return ["Mr.", "Ms."] + if variable_name == "al_pronoun_choices": + return [ + {"He/him/his": "he/him/his"}, + {"She/her/hers": "she/her/hers"}, + {"They/them/theirs": "they/them/theirs"}, + ] + return da_value(variable_name, *args, **kwargs) + def test_phone_numbers(self): self.assertEqual(self.individual.phone_numbers(), "") self.individual.phone_number = "" From b1cce1e2fdfed6030a9d1a45e49f42604bdf0333 Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Fri, 19 Sep 2025 12:11:05 -0400 Subject: [PATCH 07/12] More mocks --- docassemble/AssemblyLine/test_al_general.py | 23 +++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docassemble/AssemblyLine/test_al_general.py b/docassemble/AssemblyLine/test_al_general.py index 837098c1..0930053d 100644 --- a/docassemble/AssemblyLine/test_al_general.py +++ b/docassemble/AssemblyLine/test_al_general.py @@ -101,6 +101,29 @@ def _mock_value_with_defaults(self, variable_name, *args, **kwargs): {"She/her/hers": "she/her/hers"}, {"They/them/theirs": "they/them/theirs"}, ] + if variable_name == "al_race_ethnicity_choices": + return [ + {"American Indian or Alaska Native": "american_indian_alaska_native"}, + {"Asian": "asian"}, + {"White": "white"}, + {"Other": "other"}, + {"Prefer not to say": "prefer_not_to_say"}, + ] + if variable_name == "al_income_range_choices": + return [ + {"Under $25,000": "under_25k"}, + {"$25,000 - $49,999": "25k_49k"}, + {"$50,000 - $74,999": "50k_74k"}, + {"Prefer not to say": "prefer_not_to_say"}, + ] + if variable_name == "al_occupation_choices": + return [ + {"Service": "service"}, + {"Student": "student"}, + {"Professional": "professional"}, + {"Other": "other"}, + {"Prefer not to say": "prefer_not_to_say"}, + ] return da_value(variable_name, *args, **kwargs) def test_phone_numbers(self): From 68e89ca7127e70562270e6f195cf79f54fd07c9e Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Fri, 19 Sep 2025 12:40:05 -0400 Subject: [PATCH 08/12] Trying again --- docassemble/AssemblyLine/test_al_general.py | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/docassemble/AssemblyLine/test_al_general.py b/docassemble/AssemblyLine/test_al_general.py index 0930053d..68af1649 100644 --- a/docassemble/AssemblyLine/test_al_general.py +++ b/docassemble/AssemblyLine/test_al_general.py @@ -81,14 +81,27 @@ def setUp(self): self.individual.name.first = "John" - self._value_patcher = patch( + self._real_value = da_value + self._functions_value_patcher = patch( + "docassemble.base.functions.value", + side_effect=self._mock_value_with_defaults, + ) + self._util_value_patcher = patch( + "docassemble.base.util.value", + side_effect=self._mock_value_with_defaults, + ) + self._al_general_value_patcher = patch( "docassemble.AssemblyLine.al_general.value", side_effect=self._mock_value_with_defaults, ) - self._value_patcher.start() + self._functions_value_patcher.start() + self._util_value_patcher.start() + self._al_general_value_patcher.start() def tearDown(self): - self._value_patcher.stop() + self._al_general_value_patcher.stop() + self._util_value_patcher.stop() + self._functions_value_patcher.stop() def _mock_value_with_defaults(self, variable_name, *args, **kwargs): if variable_name == "al_name_suffixes": @@ -124,7 +137,7 @@ def _mock_value_with_defaults(self, variable_name, *args, **kwargs): {"Other": "other"}, {"Prefer not to say": "prefer_not_to_say"}, ] - return da_value(variable_name, *args, **kwargs) + return self._real_value(variable_name, *args, **kwargs) def test_phone_numbers(self): self.assertEqual(self.individual.phone_numbers(), "") From f545e1b862a8fd5d1ba6a557cd387f6f370024a1 Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Fri, 19 Sep 2025 12:49:13 -0400 Subject: [PATCH 09/12] General mocking instead of just once --- docassemble/AssemblyLine/test_al_general.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/docassemble/AssemblyLine/test_al_general.py b/docassemble/AssemblyLine/test_al_general.py index 68af1649..3cd84e40 100644 --- a/docassemble/AssemblyLine/test_al_general.py +++ b/docassemble/AssemblyLine/test_al_general.py @@ -1,7 +1,7 @@ import unittest from .al_general import ALIndividual, ALAddress, get_visible_al_nav_items from unittest.mock import Mock, patch -from docassemble.base.util import DADict, DAAttributeError, value as da_value +from docassemble.base.util import DADict, DAAttributeError class test_aladdress(unittest.TestCase): @@ -81,7 +81,6 @@ def setUp(self): self.individual.name.first = "John" - self._real_value = da_value self._functions_value_patcher = patch( "docassemble.base.functions.value", side_effect=self._mock_value_with_defaults, @@ -137,7 +136,19 @@ def _mock_value_with_defaults(self, variable_name, *args, **kwargs): {"Other": "other"}, {"Prefer not to say": "prefer_not_to_say"}, ] - return self._real_value(variable_name, *args, **kwargs) + if variable_name == "al_age_range_choices": + return [ + {"Under 18": "under_18"}, + {"18-24": "18_24"}, + {"25-34": "25_34"}, + {"35-44": "35_44"}, + {"45-54": "45_54"}, + {"55-64": "55_64"}, + {"65 or older": "65_older"}, + {"Prefer not to say": "prefer_not_to_say"}, + ] + # Never pass through to the real value() function as it only works in an interactive DA server + raise ValueError(f"Unmocked call to value() with variable_name='{variable_name}'. Please add a mock for this variable in the test.") def test_phone_numbers(self): self.assertEqual(self.individual.phone_numbers(), "") From 814e5ccd159eb58d356451dafeac26814604f39f Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Fri, 19 Sep 2025 12:51:29 -0400 Subject: [PATCH 10/12] Fix formatting --- docassemble/AssemblyLine/test_al_general.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docassemble/AssemblyLine/test_al_general.py b/docassemble/AssemblyLine/test_al_general.py index 3cd84e40..68f419c4 100644 --- a/docassemble/AssemblyLine/test_al_general.py +++ b/docassemble/AssemblyLine/test_al_general.py @@ -148,7 +148,9 @@ def _mock_value_with_defaults(self, variable_name, *args, **kwargs): {"Prefer not to say": "prefer_not_to_say"}, ] # Never pass through to the real value() function as it only works in an interactive DA server - raise ValueError(f"Unmocked call to value() with variable_name='{variable_name}'. Please add a mock for this variable in the test.") + raise ValueError( + f"Unmocked call to value() with variable_name='{variable_name}'. Please add a mock for this variable in the test." + ) def test_phone_numbers(self): self.assertEqual(self.individual.phone_numbers(), "") From 935bf15ef5200f78f6f786654e3c99acda3a02b2 Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Fri, 19 Sep 2025 14:31:57 -0400 Subject: [PATCH 11/12] Remove odd documentation file we don't need --- DEMOGRAPHIC_FIELDS.md | 174 ------------------------------------------ 1 file changed, 174 deletions(-) delete mode 100644 DEMOGRAPHIC_FIELDS.md diff --git a/DEMOGRAPHIC_FIELDS.md b/DEMOGRAPHIC_FIELDS.md deleted file mode 100644 index 3b9e09a0..00000000 --- a/DEMOGRAPHIC_FIELDS.md +++ /dev/null @@ -1,174 +0,0 @@ -# Demographic Data Fields - -This document describes the demographic data collection fields available in the AssemblyLine library. - -## Overview - -The AssemblyLine library now includes methods for collecting demographic information from users. These methods follow the same pattern as existing field methods like `gender_fields()` and `language_fields()`. - -## Available Methods - -### `race_and_ethnicity_fields()` - -Collects race and ethnicity information using checkboxes to allow multiple selections. - -**Attributes:** -- `race_ethnicity`: DACheckboxes object containing selected race/ethnicity values -- `race_ethnicity_other`: Text field for custom race/ethnicity when "Other" is selected - -**Possible Values:** -- `american_indian_alaska_native`: American Indian or Alaska Native -- `asian`: Asian -- `black_african_american`: Black or African American -- `hispanic_latino`: Hispanic or Latino -- `native_hawaiian_pacific_islander`: Native Hawaiian or Other Pacific Islander -- `white`: White -- `two_or_more_races`: Two or more races -- `other`: Other (enables text field for specification) -- `prefer_not_to_say`: Prefer not to say - -**Usage:** -```yaml -fields: - - code: | - users[0].race_and_ethnicity_fields(show_help=True) -``` - -### `age_range_fields()` - -Collects age range information using radio buttons. - -**Attributes:** -- `age_range`: Single value representing the selected age range - -**Possible Values:** -- `under_18`: Under 18 -- `18_24`: 18-24 -- `25_34`: 25-34 -- `35_44`: 35-44 -- `45_54`: 45-54 -- `55_64`: 55-64 -- `65_74`: 65-74 -- `75_and_over`: 75 and over -- `prefer_not_to_say`: Prefer not to say - -**Usage:** -```yaml -fields: - - code: | - users[0].age_range_fields(show_help=True) -``` - -### `income_range_fields()` - -Collects household income range information using radio buttons. - -**Attributes:** -- `income_range`: Single value representing the selected income range - -**Possible Values:** -- `under_15k`: Less than $15,000 -- `15k_24k`: $15,000 - $24,999 -- `25k_34k`: $25,000 - $34,999 -- `35k_49k`: $35,000 - $49,999 -- `50k_74k`: $50,000 - $74,999 -- `75k_99k`: $75,000 - $99,999 -- `100k_149k`: $100,000 - $149,999 -- `150k_and_over`: $150,000 or more -- `prefer_not_to_say`: Prefer not to say - -**Usage:** -```yaml -fields: - - code: | - users[0].income_range_fields(show_help=True) -``` - -### `occupation_fields()` - -Collects occupation classification information using radio buttons. - -**Attributes:** -- `occupation`: Single value representing the selected occupation category -- `occupation_other`: Text field for custom occupation when "Other" is selected - -**Possible Values:** -- `management_business_science_arts`: Management, business, science, and arts -- `service`: Service -- `sales_office`: Sales and office -- `natural_resources_construction_maintenance`: Natural resources, construction, and maintenance -- `production_transportation_material_moving`: Production, transportation, and material moving -- `military`: Military -- `student`: Student -- `retired`: Retired -- `unemployed`: Unemployed -- `other`: Other (enables text field for specification) -- `prefer_not_to_say`: Prefer not to say - -**Usage:** -```yaml -fields: - - code: | - users[0].occupation_fields(show_help=True) -``` - -## Method Parameters - -All demographic field methods support the following parameters: - -- `show_help` (bool): Whether to show additional help text. Defaults to False. -- `show_if` (Union[str, Dict[str, str], None]): Condition to determine if the field should be shown. Defaults to None. -- `maxlengths` (Dict[str, int], optional): A dictionary of field names and their maximum lengths. Default is None. -- `choices` (Optional[Union[List[Dict[str, str]], Callable]]): Custom choices or a callable that returns choices. Defaults to standard options. - -## Example Usage - -### Single Demographics Question - -```yaml ---- -sets: - - users[0].race_ethnicity - - users[0].age_range - - users[0].income_range - - users[0].occupation -id: demographic information -question: | - Tell us about yourself -subquestion: | - This information helps us understand who we serve. All questions are optional. -fields: - - code: | - users[0].race_and_ethnicity_fields(show_help=True) - - code: | - users[0].age_range_fields(show_help=True) - - code: | - users[0].income_range_fields(show_help=True) - - code: | - users[0].occupation_fields(show_help=True) -``` - -### Individual Questions - -```yaml ---- -sets: - - users[0].race_ethnicity -question: | - What is your race and ethnicity? -fields: - - code: | - users[0].race_and_ethnicity_fields(show_help=True) -``` - -## Integration with Weaver - -The demographic fields are designed to be compatible with the Assembly Line Weaver. The field methods can be used in Weaver-generated interviews by adding them to the appropriate question blocks. - -## Privacy Considerations - -All demographic questions include "Prefer not to say" options to respect user privacy. The questions are designed to be optional and can be conditionally shown using the `show_if` parameter. - -## Customization - -All field methods accept custom choices through the `choices` parameter, allowing developers to adapt the categories to their specific needs or jurisdictional requirements. \ No newline at end of file From db91d9f6c41d782c626defff25144681c76df619 Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Fri, 19 Sep 2025 16:16:53 -0400 Subject: [PATCH 12/12] WIP - still need to work on tests --- docassemble/AssemblyLine/al_general.py | 26 ++++----- .../data/questions/ql_baseline.yml | 54 +++++++++++++++++++ docassemble/AssemblyLine/test_al_general.py | 24 +++++++++ 3 files changed, 88 insertions(+), 16 deletions(-) diff --git a/docassemble/AssemblyLine/al_general.py b/docassemble/AssemblyLine/al_general.py index f3096601..a39ac441 100644 --- a/docassemble/AssemblyLine/al_general.py +++ b/docassemble/AssemblyLine/al_general.py @@ -1507,14 +1507,14 @@ def race_and_ethnicity_fields( choices = choices() other_input = { - "label": "Please specify", + "label": str(self.race_ethnicity_other_label), "field": self.attr_name("race_ethnicity_other"), "show if": {"variable": self.attr_name("race_ethnicity"), "is": "other"}, } fields = [ { - "label": "Race and ethnicity", + "label": str(self.race_ethnicity_label), "field": self.attr_name("race_ethnicity"), "datatype": "checkboxes", "choices": choices, @@ -1523,9 +1523,7 @@ def race_and_ethnicity_fields( ] if show_help: - fields[0][ - "help" - ] = "You may select more than one category that applies to you." + fields[0]["help"] = str(self.race_ethnicity_help_text) if show_if: fields[0]["show if"] = show_if @@ -1573,7 +1571,7 @@ def age_range_fields( fields = [ { - "label": "Age range", + "label": str(self.age_range_label), "field": self.attr_name("age_range"), "input type": "radio", "choices": choices, @@ -1581,7 +1579,7 @@ def age_range_fields( ] if show_help: - fields[0]["help"] = "Select the age range that applies to you." + fields[0]["help"] = str(self.age_range_help_text) if show_if: fields[0]["show if"] = show_if @@ -1629,7 +1627,7 @@ def income_range_fields( fields = [ { - "label": "Household income range", + "label": str(self.income_range_label), "field": self.attr_name("income_range"), "input type": "radio", "choices": choices, @@ -1637,9 +1635,7 @@ def income_range_fields( ] if show_help: - fields[0][ - "help" - ] = "Select the range that best describes your household's total income before taxes in the last 12 months." + fields[0]["help"] = str(self.income_range_help_text) if show_if: fields[0]["show if"] = show_if @@ -1686,14 +1682,14 @@ def occupation_fields( choices = choices() other_input = { - "label": "Please specify your occupation", + "label": str(self.occupation_other_label), "field": self.attr_name("occupation_other"), "show if": {"variable": self.attr_name("occupation"), "is": "other"}, } fields = [ { - "label": "Occupation", + "label": str(self.occupation_label), "field": self.attr_name("occupation"), "input type": "radio", "choices": choices, @@ -1702,9 +1698,7 @@ def occupation_fields( ] if show_help: - fields[0][ - "help" - ] = "Select the category that best describes your current work or situation." + fields[0]["help"] = str(self.occupation_help_text) if show_if: fields[0]["show if"] = show_if diff --git a/docassemble/AssemblyLine/data/questions/ql_baseline.yml b/docassemble/AssemblyLine/data/questions/ql_baseline.yml index cf454e24..1327d910 100644 --- a/docassemble/AssemblyLine/data/questions/ql_baseline.yml +++ b/docassemble/AssemblyLine/data/questions/ql_baseline.yml @@ -2627,6 +2627,60 @@ data: - Other: other - Prefer not to say: prefer_not_to_say --- +################ Demographic Templates ################ +comment: | + These templates define the labels and help text for demographic fields. +--- +generic object: ALIndividual +template: x.race_ethnicity_label +content: | + Race and ethnicity +--- +generic object: ALIndividual +template: x.race_ethnicity_other_label +content: | + Other +--- +generic object: ALIndividual +template: x.race_ethnicity_help_text +content: | + You may select more than one race or ethnicity that applies to you. +--- +generic object: ALIndividual +template: x.age_range_label +content: | + Age range +--- +generic object: ALIndividual +template: x.age_range_help_text +content: | + Pick the age range that describes you best. +--- +generic object: ALIndividual +template: x.income_range_label +content: | + Household income range +--- +generic object: ALIndividual +template: x.income_range_help_text +content: | + Pick the range that best describes your household's total income before taxes in the last 12 months. +--- +generic object: ALIndividual +template: x.occupation_label +content: | + Occupation (job) +--- +generic object: ALIndividual +template: x.occupation_other_label +content: | + Other +--- +generic object: ALIndividual +template: x.occupation_help_text +content: | + Pick the category that best describes your current work or situation. +--- variable name: al_name_titles data: - Mr. diff --git a/docassemble/AssemblyLine/test_al_general.py b/docassemble/AssemblyLine/test_al_general.py index 68f419c4..c1182d92 100644 --- a/docassemble/AssemblyLine/test_al_general.py +++ b/docassemble/AssemblyLine/test_al_general.py @@ -81,6 +81,18 @@ def setUp(self): self.individual.name.first = "John" + # Mock template attributes for demographic fields + self.individual.race_ethnicity_label = "Race and ethnicity" + self.individual.race_ethnicity_other_label = "Please specify" + self.individual.race_ethnicity_help_text = "You may select more than one category that applies to you." + self.individual.age_range_label = "Age range" + self.individual.age_range_help_text = "Select the age range that applies to you." + self.individual.income_range_label = "Household income range" + self.individual.income_range_help_text = "Select the range that best describes your household's total income before taxes in the last 12 months." + self.individual.occupation_label = "Occupation" + self.individual.occupation_other_label = "Please specify your occupation" + self.individual.occupation_help_text = "Select the category that best describes your current work or situation." + self._functions_value_patcher = patch( "docassemble.base.functions.value", side_effect=self._mock_value_with_defaults, @@ -629,6 +641,18 @@ class test_demographic_fields(unittest.TestCase): def setUp(self): self.individual = ALIndividual() self.individual.instanceName = "test_person" + + # Mock template attributes for demographic fields + self.individual.race_ethnicity_label = "Race and ethnicity" + self.individual.race_ethnicity_other_label = "Please specify" + self.individual.race_ethnicity_help_text = "You may select more than one category that applies to you." + self.individual.age_range_label = "Age range" + self.individual.age_range_help_text = "Select the age range that applies to you." + self.individual.income_range_label = "Household income range" + self.individual.income_range_help_text = "Select the range that best describes your household's total income before taxes in the last 12 months." + self.individual.occupation_label = "Occupation" + self.individual.occupation_other_label = "Please specify your occupation" + self.individual.occupation_help_text = "Select the category that best describes your current work or situation." @patch("docassemble.AssemblyLine.al_general.value") def test_race_and_ethnicity_fields_basic(self, mock_value):