diff --git a/programs/admin.py b/programs/admin.py index 31fed005..e72b5d53 100644 --- a/programs/admin.py +++ b/programs/admin.py @@ -5,6 +5,7 @@ from .models import ( LegalStatus, Program, + ProgramCategory, UrgentNeed, Navigator, UrgentNeedFunction, @@ -301,6 +302,35 @@ def action_buttons(self, obj): action_buttons.allow_tags = True +class ProgramCategoryAdmin(ModelAdmin): + search_fields = ("external_name",) + list_display = ["get_str", "external_name", "action_buttons"] + + def get_str(self, obj): + return str(obj) + + get_str.admin_order_field = "external_name" + get_str.short_description = "Name" + + def action_buttons(self, obj): + return format_html( + """ + + """, + reverse("translation_admin_url", args=[obj.name.id]), + reverse("translation_admin_url", args=[obj.description.id]), + ) + + action_buttons.short_description = "Translate:" + action_buttons.allow_tags = True + + admin.site.register(LegalStatus, LegalStatusAdmin) admin.site.register(Program, ProgramAdmin) admin.site.register(County, CountiesAdmin) @@ -315,3 +345,4 @@ def action_buttons(self, obj): admin.site.register(Referrer, ReferrerAdmin) admin.site.register(WebHookFunction, WebHookFunctionsAdmin) admin.site.register(TranslationOverride, TranslationOverrideAdmin) +admin.site.register(ProgramCategory, ProgramCategoryAdmin) diff --git a/programs/migrations/0090_programcategory.py b/programs/migrations/0090_programcategory.py new file mode 100644 index 00000000..bb07facf --- /dev/null +++ b/programs/migrations/0090_programcategory.py @@ -0,0 +1,50 @@ +# Generated by Django 4.2.15 on 2024-10-09 18:49 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("translations", "0004_translation_no_auto"), + ("programs", "0089_alter_translationoverride_counties_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="ProgramCategory", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "external_name", + models.CharField(blank=True, max_length=120, null=True, unique=True), + ), + ("calculator", models.CharField(blank=True, max_length=120, null=True)), + ( + "description", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="program_category_description", + to="translations.translation", + ), + ), + ( + "name", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="program_category_name", + to="translations.translation", + ), + ), + ], + ), + ] diff --git a/programs/migrations/0091_program_category_v2.py b/programs/migrations/0091_program_category_v2.py new file mode 100644 index 00000000..eeae4b0e --- /dev/null +++ b/programs/migrations/0091_program_category_v2.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.15 on 2024-10-09 18:53 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("programs", "0090_programcategory"), + ] + + operations = [ + migrations.AddField( + model_name="program", + name="category_v2", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="programs", + to="programs.programcategory", + ), + ), + ] diff --git a/programs/migrations/0092_programcategory_icon.py b/programs/migrations/0092_programcategory_icon.py new file mode 100644 index 00000000..b23a39fd --- /dev/null +++ b/programs/migrations/0092_programcategory_icon.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.15 on 2024-10-09 20:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("programs", "0091_program_category_v2"), + ] + + operations = [ + migrations.AddField( + model_name="programcategory", + name="icon", + field=models.CharField(default="housing", max_length=120), + preserve_default=False, + ), + ] diff --git a/programs/models.py b/programs/models.py index a7bef9ce..b8590033 100644 --- a/programs/models.py +++ b/programs/models.py @@ -6,7 +6,7 @@ from programs.util import Dependencies import requests from integrations.util.cache import Cache -from typing import Optional, Type, TypedDict +from typing import Optional, TypedDict from programs.programs.translation_overrides import warning_calculators @@ -97,6 +97,70 @@ def __str__(self): return self.status +class ProgramCategoryManager(models.Manager): + translated_fields = ("name", "description") + + def new_program_category(self, external_name: str, icon: str): + translations = {} + for field in self.translated_fields: + translations[field] = Translation.objects.add_translation( + f"program_category.{external_name}_temporary_key-{field}" + ) + + program_category = self.create(external_name=external_name, icon=icon, **translations) + + for [field, translation] in translations.items(): + translation.label = f"program_category.{external_name}_{program_category.id}-{field}" + translation.save() + + return program_category + + +class ProgramCategoryDataController(ModelDataController["ProgramCategory"]): + _model_name = "ProgramCategory" + + DataType = TypedDict( + "DataType", + { + "calculator": str, + "icon": str, + }, + ) + + def to_model_data(self) -> DataType: + program_category = self.instance + return {"calculator": program_category.calculator, "icon": program_category.icon} + + def from_model_data(self, data: DataType): + program_category = self.instance + + program_category.calculator = data["calculator"] + program_category.icon = data["icon"] + + @classmethod + def create_instance(cls, external_name: str, Model: type["ProgramCategory"]) -> "ProgramCategory": + return Model.objects.new_program_category(external_name, "housing") + + +class ProgramCategory(models.Model): + external_name = models.CharField(max_length=120, blank=True, null=True, unique=True) + calculator = models.CharField(max_length=120, blank=True, null=True) + icon = models.CharField(max_length=120, blank=False, null=False) + name = models.ForeignKey( + Translation, related_name="program_category_name", blank=False, null=False, on_delete=models.PROTECT + ) + description = models.ForeignKey( + Translation, related_name="program_category_description", blank=False, null=False, on_delete=models.PROTECT + ) + + objects = ProgramCategoryManager() + + TranslationExportBuilder = ProgramCategoryDataController + + def __str__(self): + return self.name.text + + class DocumentManager(models.Manager): translated_fields = ("text",) @@ -128,7 +192,7 @@ class Document(models.Model): TranslationExportBuilder = DocumentDataController def __str__(self) -> str: - return self.external_name + return self.text.text class ProgramManager(models.Manager): @@ -174,7 +238,7 @@ def new_program(self, name_abbreviated): class ProgramDataController(ModelDataController["Program"]): _model_name = "Program" - dependencies = ["Document"] + dependencies = ["Document", "ProgramCategory"] FplDataType = TypedDict("FplDataType", {"year": str, "period": str}) LegalStatusesDataType = list[TypedDict("LegalStatusDataType", {"status": str})] @@ -187,6 +251,7 @@ class ProgramDataController(ModelDataController["Program"]): "active": bool, "low_confidence": bool, "documents": list[str], + "category": str, }, ) @@ -207,6 +272,7 @@ def to_model_data(self) -> DataType: "low_confidence": program.low_confidence, "name_abbreviated": program.name_abbreviated, "documents": [d.external_name for d in program.documents.all()], + "category": program.category_v2.external_name, } def from_model_data(self, data: DataType): @@ -248,6 +314,10 @@ def from_model_data(self, data: DataType): documents.append(doc) program.documents.set(documents) + # get program category + program_category = ProgramCategory.objects.get(external_name=data["category"]) + program.category_v2 = program_category + program.save() @classmethod @@ -266,6 +336,9 @@ class Program(models.Model): active = models.BooleanField(blank=True, default=True) low_confidence = models.BooleanField(blank=True, null=False, default=False) fpl = models.ForeignKey(FederalPoveryLimit, related_name="fpl", blank=True, null=True, on_delete=models.SET_NULL) + category_v2 = models.ForeignKey( + ProgramCategory, related_name="programs", blank=True, null=True, on_delete=models.SET_NULL + ) description_short = models.ForeignKey( Translation, related_name="program_description_short", blank=False, null=False, on_delete=models.PROTECT diff --git a/programs/programs/calc.py b/programs/programs/calc.py index 527f83ac..8ce92ea1 100644 --- a/programs/programs/calc.py +++ b/programs/programs/calc.py @@ -10,7 +10,7 @@ class MemberEligibility: def __init__(self, member: HouseholdMember) -> None: self.member = member self.eligible = True - self.value = 0 + self.value: int = 0 def condition(self, passed: bool): """ diff --git a/programs/programs/categories/__init__.py b/programs/programs/categories/__init__.py new file mode 100644 index 00000000..71eb61d6 --- /dev/null +++ b/programs/programs/categories/__init__.py @@ -0,0 +1,7 @@ +from .base import ProgramCategoryCapCalculator +from .co import co_category_cap_calculators + +category_cap_calculators: dict[str, type[ProgramCategoryCapCalculator]] = { + "no_cap": ProgramCategoryCapCalculator, + **co_category_cap_calculators, +} diff --git a/programs/programs/categories/base.py b/programs/programs/categories/base.py new file mode 100644 index 00000000..2dda2e0c --- /dev/null +++ b/programs/programs/categories/base.py @@ -0,0 +1,110 @@ +from collections.abc import Callable +from programs.programs.calc import Eligibility +from dataclasses import dataclass + + +@dataclass +class CategoryCap: + programs: list[str] + cap: int = 0 + member_cap: bool = False + + +class ProgramCategoryCapCalculator: + # caps with a constant max + static_caps: list[CategoryCap] = [] + + # caps where the cap is the highest program value + max_caps: list[CategoryCap] = [] + + # caps where the cap is the average value of the program + average_caps: list[CategoryCap] = [] + + def __init__(self, eligibility: dict[str, Eligibility]): + self.eligibility = eligibility + + def caps(self) -> list[CategoryCap]: + static_caps = self._handle_caps(self.static_caps, self.calc_static_cap) + max_caps = self._handle_caps(self.max_caps, self.calc_max_cap) + average_caps = self._handle_caps(self.average_caps, self.calc_average_cap) + + return static_caps + max_caps + average_caps + self.other_caps() + + def other_caps(self) -> list[CategoryCap]: + """ + Override this method to add custom caps + """ + return [] + + def calc_static_cap(self, cap: CategoryCap, values: list[int]): + if any(v > 0 for v in values): + return cap.cap + + return 0 + + def calc_max_cap(self, cap: CategoryCap, values: list[int]): + return max(*values) + + def calc_average_cap(self, cap: CategoryCap, values: list[int]): + return sum(values) / len(values) + + def _handle_caps(self, caps: list[CategoryCap], func: Callable[[CategoryCap, list[int]], int]) -> list[CategoryCap]: + """ + Take a caps and a function and calculate the category caps with that function + """ + calculated_caps = [] + + for cap in caps: + if cap.member_cap: + calculated_caps.append(self._handle_member_cap(cap, func)) + continue + + calculated_caps.append(self._handle_household_cap(cap, func)) + + return calculated_caps + + def _handle_member_cap(self, cap: CategoryCap, func: Callable[[CategoryCap, list[int]], int]) -> CategoryCap: + """ + Take a cap and a function and calculate the category cap for each member with that function + """ + member_values: dict[int, list[int]] = {} + + new_cap = CategoryCap(cap.programs.copy()) + + for program in cap.programs: + if program not in self.eligibility: + new_cap.programs.remove(program) + continue + + eligibility = self.eligibility[program] + for member_eligibility in eligibility.eligible_members: + member_id = member_eligibility.member.id + + if member_id not in member_values: + member_values[member_id] = [] + + member_values[member_id].append(member_eligibility.value) + + for values in member_values.values(): + new_cap.cap += func(cap, values) + + return new_cap + + def _handle_household_cap(self, cap: CategoryCap, func: Callable[[CategoryCap, list[int]], int]) -> CategoryCap: + """ + Take a cap and a function and calculate the category cap for the household with that function + """ + values: list[int] = [] + + new_cap = CategoryCap(cap.programs.copy()) + + for program in cap.programs: + if program not in self.eligibility: + new_cap.programs.remove(program) + continue + + values.append(self.eligibility[program]) + + new_cap.cap = func(cap, values) + + return new_cap diff --git a/programs/programs/categories/co/__init__.py b/programs/programs/categories/co/__init__.py new file mode 100644 index 00000000..15c3bca4 --- /dev/null +++ b/programs/programs/categories/co/__init__.py @@ -0,0 +1,4 @@ +from programs.programs.categories.co.preschool import PreschoolCategoryCap +from ..base import ProgramCategoryCapCalculator + +co_category_cap_calculators: dict[str, type[ProgramCategoryCapCalculator]] = {"co_preschool": PreschoolCategoryCap} diff --git a/programs/programs/categories/co/preschool.py b/programs/programs/categories/co/preschool.py new file mode 100644 index 00000000..b1ac5971 --- /dev/null +++ b/programs/programs/categories/co/preschool.py @@ -0,0 +1,5 @@ +from programs.programs.categories.base import CategoryCap, ProgramCategoryCapCalculator + + +class PreschoolCategoryCap(ProgramCategoryCapCalculator): + static_caps = [CategoryCap(["dpp", "upk", "chs"], cap=8_640, member_cap=True)] diff --git a/programs/programs/co/pe/member.py b/programs/programs/co/pe/member.py index 99cb95a1..5eb19e6a 100644 --- a/programs/programs/co/pe/member.py +++ b/programs/programs/co/pe/member.py @@ -85,10 +85,10 @@ class CoWic(Wic): wic_categories = { "NONE": 0, "INFANT": 130, - "CHILD": 26, - "PREGNANT": 47, - "POSTPARTUM": 47, - "BREASTFEEDING": 52, + "CHILD": 79, + "PREGNANT": 104, + "POSTPARTUM": 88, + "BREASTFEEDING": 121, } pe_inputs = [ *Wic.pe_inputs, diff --git a/programs/programs/federal/pe/spm.py b/programs/programs/federal/pe/spm.py index 74393ead..1ab67cc8 100644 --- a/programs/programs/federal/pe/spm.py +++ b/programs/programs/federal/pe/spm.py @@ -1,6 +1,5 @@ from programs.programs.policyengine.calculators.base import PolicyEngineSpmCalulator import programs.programs.policyengine.calculators.dependencies as dependency -from programs.programs.policyengine.calculators.dependencies.base import Member, SpmUnit class Snap(PolicyEngineSpmCalulator): @@ -27,6 +26,7 @@ class Snap(PolicyEngineSpmCalulator): dependency.member.AgeDependency, dependency.member.MedicalExpenseDependency, dependency.member.IsDisabledDependency, + dependency.member.SnapIneligibleStudentDependency, # NOTE: remove this to always use the SUA in CO. dependency.spm.SnapAlwaysUseSuaDependency, ] diff --git a/programs/programs/helpers.py b/programs/programs/helpers.py index 0bc927c5..10763ba6 100644 --- a/programs/programs/helpers.py +++ b/programs/programs/helpers.py @@ -1,4 +1,5 @@ from programs.programs.calc import Eligibility +from screener.models import Screen, HouseholdMember STATE_MEDICAID_OPTIONS = ("co_medicaid", "nc_medicaid") @@ -8,3 +9,24 @@ def medicaid_eligible(data: dict[str, Eligibility]): for name in STATE_MEDICAID_OPTIONS: if name in data: return data[name].eligible + + +def snap_ineligible_student(screen: Screen, member: HouseholdMember): + if not member.student: + return False + + if member.age < 18 or member.age >= 50: + return False + + if member.disabled: + return False + + head_or_spouse = member.is_head() or member.is_spouse() + if head_or_spouse and screen.num_children(age_max=5) > 0: + return False + + single_parent = member.is_head() and not member.is_married()["is_married"] + if single_parent and screen.num_children(age_max=11) > 0: + return False + + return True diff --git a/programs/programs/policyengine/calculators/dependencies/member.py b/programs/programs/policyengine/calculators/dependencies/member.py index 55713027..ec71b4e7 100644 --- a/programs/programs/policyengine/calculators/dependencies/member.py +++ b/programs/programs/policyengine/calculators/dependencies/member.py @@ -1,3 +1,4 @@ +from programs.programs.helpers import snap_ineligible_student from .base import Member @@ -221,6 +222,16 @@ class CommoditySupplementalFoodProgram(Member): field = "commodity_supplemental_food_program" +class SnapIneligibleStudentDependency(Member): + field = "is_snap_ineligible_student" + + dependencies = ("age",) + + # PE does not take the age of the children into acount, so we calculate this ourselves + def value(self): + return snap_ineligible_student(self.screen, self.member) + + class IncomeDependency(Member): dependencies = ( "income_type", diff --git a/programs/programs/warnings/__init__.py b/programs/programs/warnings/__init__.py index 712cfa86..7fad1a8e 100644 --- a/programs/programs/warnings/__init__.py +++ b/programs/programs/warnings/__init__.py @@ -1,6 +1,7 @@ from .tax_unit import TaxUnit from .base import WarningCalculator from .dont_show import DontShow +from .co import co_warning_calculators general_calculators: dict[str, type[WarningCalculator]] = { @@ -9,6 +10,6 @@ "_tax_unit": TaxUnit, } -specific_calculators: dict[str, type[WarningCalculator]] = {} +specific_calculators: dict[str, type[WarningCalculator]] = {**co_warning_calculators} warning_calculators: dict[str, type[WarningCalculator]] = {**general_calculators, **specific_calculators} diff --git a/programs/programs/warnings/co/__init__.py b/programs/programs/warnings/co/__init__.py new file mode 100644 index 00000000..c1e87720 --- /dev/null +++ b/programs/programs/warnings/co/__init__.py @@ -0,0 +1,5 @@ +from programs.programs.warnings.base import WarningCalculator +from programs.programs.warnings.co.snap_student import SnapStudentWarning + + +co_warning_calculators: dict[str, type[WarningCalculator]] = {"co_snap_student": SnapStudentWarning} diff --git a/programs/programs/warnings/co/snap_student.py b/programs/programs/warnings/co/snap_student.py new file mode 100644 index 00000000..c1d7a340 --- /dev/null +++ b/programs/programs/warnings/co/snap_student.py @@ -0,0 +1,15 @@ +from programs.programs.helpers import snap_ineligible_student +from programs.programs.warnings.base import WarningCalculator + + +class SnapStudentWarning(WarningCalculator): + dependencies = [ + "age", + ] + + def eligible(self) -> bool: + for member in self.screen.household_members.all(): + if snap_ineligible_student(self.screen, member): + return True + + return False diff --git a/screener/models.py b/screener/models.py index ae27e042..78e93c65 100644 --- a/screener/models.py +++ b/screener/models.py @@ -449,10 +449,10 @@ def is_married(self): def has_disability(self): return self.disabled or self.visually_impaired or self.long_term_disability - def is_head(self): + def is_head(self) -> bool: return self.relationship == "headOfHousehold" - def is_spouse(self): + def is_spouse(self) -> bool: return self.screen.relationship_map()[self.screen.get_head().id] == self.id def is_dependent(self): diff --git a/screener/serializers.py b/screener/serializers.py index 1ccb9bee..9959b654 100644 --- a/screener/serializers.py +++ b/screener/serializers.py @@ -275,6 +275,7 @@ class EligibilitySerializer(serializers.Serializer): multiple_tax_units = serializers.BooleanField() estimated_value_override = TranslationSerializer() warning_messages = TranslationSerializer(many=True) + category_id = serializers.CharField() class Meta: fields = "__all__" @@ -287,6 +288,18 @@ class Meta: fields = ("translations",) +class ProgramCategoryCapSerializer(serializers.Serializer): + programs = serializers.ListSerializer(child=serializers.CharField()) + cap = serializers.IntegerField() + + +class ProgramCategorySerializer(serializers.Serializer): + icon = serializers.CharField() + name = TranslationSerializer() + description = TranslationSerializer() + caps = ProgramCategoryCapSerializer(many=True) + + class UrgentNeedSerializer(serializers.Serializer): name = TranslationSerializer() description = TranslationSerializer() @@ -302,3 +315,4 @@ class ResultsSerializer(serializers.Serializer): default_language = serializers.CharField() missing_programs = serializers.BooleanField() validations = ValidationSerializer(many=True) + program_categories = serializers.DictField(child=ProgramCategorySerializer()) diff --git a/screener/views.py b/screener/views.py index 822e0c66..1381561e 100644 --- a/screener/views.py +++ b/screener/views.py @@ -2,6 +2,7 @@ from django.http import HttpResponse from django.shortcuts import get_object_or_404 from integrations.services.communications import MessageUser +from programs.programs import categories from programs.programs.helpers import STATE_MEDICAID_OPTIONS from programs.programs.policyengine.calculators import all_calculators from screener.models import ( @@ -25,10 +26,20 @@ MessageSerializer, ResultsSerializer, ) -from programs.programs.policyengine.policy_engine import calc_pe_eligibility +from programs.programs.policyengine.policy_engine import all_eligibility, calc_pe_eligibility from programs.util import DependencyError, Dependencies from programs.programs.urgent_needs.urgent_need_functions import urgent_need_functions -from programs.models import Document, Navigator, UrgentNeed, Program, Referrer, WarningMessage, TranslationOverride +from programs.models import ( + Document, + Navigator, + ProgramCategory, + UrgentNeed, + Program, + Referrer, + WarningMessage, + TranslationOverride, +) +from programs.programs.categories import ProgramCategoryCapCalculator, category_cap_calculators from django.core.exceptions import ObjectDoesNotExist from programs.programs.warnings import warning_calculators from validations.serializers import ValidationSerializer @@ -124,7 +135,7 @@ def get(self, request, id): screen = Screen.objects.prefetch_related( "household_members", "household_members__income_streams", "household_members__insurance", "expenses" ).get(uuid=id) - eligibility, missing_programs = eligibility_results(screen) + eligibility, missing_programs, categories = eligibility_results(screen) urgent_needs = urgent_need_results(screen, eligibility) validations = ValidationSerializer(screen.validations.all(), many=True).data @@ -135,6 +146,7 @@ def get(self, request, id): "default_language": screen.request_language_code, "missing_programs": missing_programs, "validations": validations, + "program_categories": categories, } hooks = eligibility_hooks() if screen.submission_date is None: @@ -187,6 +199,8 @@ def eligibility_results(screen: Screen, batch=False): all_programs = ( Program.objects.filter(active=True) + # NOTE: uncomment when categories are ready + # .filter(active=True, category_v2__isnull=False) .prefetch_related( "legal_status_required", "fpl", @@ -204,8 +218,9 @@ def eligibility_results(screen: Screen, batch=False): "translation_overrides", "translation_overrides__counties", *translations_prefetch_name("translation_overrides__", TranslationOverride.objects.translated_fields), - ) - .exclude(id__in=excluded_programs) + "category_v2", + *translations_prefetch_name("category_v2__", ProgramCategory.objects.translated_fields), + ).exclude(id__in=excluded_programs) ) data = [] @@ -360,9 +375,34 @@ def sort_first(program): "low_confidence": program.low_confidence, "documents": [default_message(d.text) for d in program.documents.all()], "warning_messages": [default_message(w.message) for w in warnings], + "category_id": None if program.category_v2 is None else str(program.category_v2.id), } ) + categories = {} + # NOTE: uncomment when categories are ready + # for program in all_programs: + # category = program.category_v2 + # if category.id in categories: + # continue + # + # CategoryCalculator = ProgramCategoryCapCalculator + # if category.calculator is not None and category.calculator != "": + # CategoryCalculator = category_cap_calculators[category.calculator] + # + # calculator = CategoryCalculator(program_eligibility) + # + # caps = [] + # for cap in calculator.caps(): + # caps.append({"programs": cap.programs, "cap": cap.cap}) + # + # categories[category.id] = { + # "icon": category.icon, + # "name": default_message(category.name), + # "description": default_message(category.description), + # "cap": caps, + # } + ProgramEligibilitySnapshot.objects.bulk_create(program_snapshots) snapshot.had_error = False snapshot.save() @@ -373,7 +413,7 @@ def sort_first(program): clean_program["estimated_value"] = math.trunc(clean_program["estimated_value"]) eligible_programs.append(clean_program) - return eligible_programs, missing_programs + return eligible_programs, missing_programs, categories class GetProgramTranslation: diff --git a/translations/templates/base.html b/translations/templates/base.html index a90dc587..84722ea6 100644 --- a/translations/templates/base.html +++ b/translations/templates/base.html @@ -31,6 +31,14 @@

Translations Admin