diff --git a/configuration/management/commands/add_config.py b/configuration/management/commands/add_config.py index 23f14d45..c97aa187 100644 --- a/configuration/management/commands/add_config.py +++ b/configuration/management/commands/add_config.py @@ -120,6 +120,7 @@ class Command(BaseCommand): "cch": "Colorado Coalition for the Homeless", "frca": "Family Resource Center Association", "jeffcoHS": "Jeffco Human Services", + "dhs": "Denver Human Services", "gac": "Get Ahead Colorado", "bia": "Benefits in Action", "arapahoectypublichealth": "Arapahoe County Public Health", @@ -127,16 +128,15 @@ class Command(BaseCommand): "_label": "referralOptions.fircsummitresourcecenter", "_default_message": "FIRC Summit Resource Center", }, - "dhs": "Denver Human Services", "ccig": "Colorado Community Insight Group", "eaglecounty": "Eagle County", + "searchEngine": {"_label": "referralOptions.searchEngine", "_default_message": "Google or other search engine"}, + "socialMedia": {"_label": "referralOptions.socialMedia", "_default_message": "Social Media"}, + "other": {"_label": "referralOptions.other", "_default_message": "Other"}, "testOrProspect": { "_label": "referralOptions.testOrProspect", "_default_message": "Test / Prospective Partner", }, - "searchEngine": {"_label": "referralOptions.searchEngine", "_default_message": "Google or other search engine"}, - "socialMedia": {"_label": "referralOptions.socialMedia", "_default_message": "Social Media"}, - "other": {"_label": "referralOptions.other", "_default_message": "Other"}, } language_options = { diff --git a/programs/admin.py b/programs/admin.py index e72b5d53..dd219d66 100644 --- a/programs/admin.py +++ b/programs/admin.py @@ -41,7 +41,6 @@ def action_buttons(self, obj): description_short = obj.description_short learn_more_link = obj.learn_more_link apply_button_link = obj.apply_button_link - category = obj.category estimated_delivery_time = obj.estimated_delivery_time estimated_application_time = obj.estimated_application_time value_type = obj.value_type @@ -56,7 +55,6 @@ def action_buttons(self, obj): Name Description Short Description - Category Learn More Link Apply Button Link Estimated Delivery Time @@ -70,7 +68,6 @@ def action_buttons(self, obj): reverse("translation_admin_url", args=[name.id]), reverse("translation_admin_url", args=[description.id]), reverse("translation_admin_url", args=[description_short.id]), - reverse("translation_admin_url", args=[category.id]), reverse("translation_admin_url", args=[learn_more_link.id]), reverse("translation_admin_url", args=[apply_button_link.id]), reverse("translation_admin_url", args=[estimated_delivery_time.id]), diff --git a/programs/management/commands/remove_programs.py b/programs/management/commands/remove_programs.py index bb16524c..09874b26 100644 --- a/programs/management/commands/remove_programs.py +++ b/programs/management/commands/remove_programs.py @@ -19,7 +19,6 @@ def handle(self, *args, **options): "value_type", "estimated_delivery_time", "estimated_application_time", - "category", "warning", ) diff --git a/programs/migrations/0093_remove_program_category.py b/programs/migrations/0093_remove_program_category.py new file mode 100644 index 00000000..bcd6ffc7 --- /dev/null +++ b/programs/migrations/0093_remove_program_category.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.15 on 2024-10-14 18:13 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("programs", "0092_programcategory_icon"), + ] + + operations = [ + migrations.RemoveField( + model_name="program", + name="category", + ), + ] diff --git a/programs/migrations/0094_rename_category_v2_program_category.py b/programs/migrations/0094_rename_category_v2_program_category.py new file mode 100644 index 00000000..3656a0c3 --- /dev/null +++ b/programs/migrations/0094_rename_category_v2_program_category.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.15 on 2024-10-15 19:32 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("programs", "0093_remove_program_category"), + ] + + operations = [ + migrations.RenameField( + model_name="program", + old_name="category_v2", + new_name="category", + ), + ] diff --git a/programs/models.py b/programs/models.py index b8590033..825a38d6 100644 --- a/programs/models.py +++ b/programs/models.py @@ -205,7 +205,6 @@ class ProgramManager(models.Manager): "value_type", "estimated_delivery_time", "estimated_application_time", - "category", "estimated_value", "website_description", ) @@ -251,7 +250,7 @@ class ProgramDataController(ModelDataController["Program"]): "active": bool, "low_confidence": bool, "documents": list[str], - "category": str, + "category": Optional[str], }, ) @@ -272,7 +271,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, + "category": program.category.external_name if program.category is not None else None, } def from_model_data(self, data: DataType): @@ -315,8 +314,10 @@ def from_model_data(self, data: DataType): program.documents.set(documents) # get program category - program_category = ProgramCategory.objects.get(external_name=data["category"]) - program.category_v2 = program_category + program_category = None + if data["category"] is not None: + program_category = ProgramCategory.objects.get(external_name=data["category"]) + program.category = program_category program.save() @@ -336,7 +337,7 @@ 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( + category = models.ForeignKey( ProgramCategory, related_name="programs", blank=True, null=True, on_delete=models.SET_NULL ) @@ -368,9 +369,6 @@ class Program(models.Model): null=False, on_delete=models.PROTECT, ) - category = models.ForeignKey( - Translation, related_name="program_category", blank=False, null=False, on_delete=models.PROTECT - ) estimated_value = models.ForeignKey( Translation, related_name="program_estimated_value", diff --git a/programs/programs/categories/base.py b/programs/programs/categories/base.py index 2dda2e0c..a187cc93 100644 --- a/programs/programs/categories/base.py +++ b/programs/programs/categories/base.py @@ -46,7 +46,12 @@ 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) + non_0_values = [v for v in values if v > 0] + + if len(non_0_values) == 0: + return 0 + + return sum(non_0_values) / len(non_0_values) def _handle_caps(self, caps: list[CategoryCap], func: Callable[[CategoryCap, list[int]], int]) -> list[CategoryCap]: """ diff --git a/programs/programs/categories/co/__init__.py b/programs/programs/categories/co/__init__.py index 15c3bca4..271e003e 100644 --- a/programs/programs/categories/co/__init__.py +++ b/programs/programs/categories/co/__init__.py @@ -1,4 +1,7 @@ -from programs.programs.categories.co.preschool import PreschoolCategoryCap +from programs.programs.categories.co.caps import HealthCareCategoryCap, PreschoolCategoryCap from ..base import ProgramCategoryCapCalculator -co_category_cap_calculators: dict[str, type[ProgramCategoryCapCalculator]] = {"co_preschool": PreschoolCategoryCap} +co_category_cap_calculators: dict[str, type[ProgramCategoryCapCalculator]] = { + "co_preschool": PreschoolCategoryCap, + "co_health_care": HealthCareCategoryCap, +} diff --git a/programs/programs/categories/co/preschool.py b/programs/programs/categories/co/caps.py similarity index 60% rename from programs/programs/categories/co/preschool.py rename to programs/programs/categories/co/caps.py index b1ac5971..ce0867ba 100644 --- a/programs/programs/categories/co/preschool.py +++ b/programs/programs/categories/co/caps.py @@ -3,3 +3,7 @@ class PreschoolCategoryCap(ProgramCategoryCapCalculator): static_caps = [CategoryCap(["dpp", "upk", "chs"], cap=8_640, member_cap=True)] + + +class HealthCareCategoryCap(ProgramCategoryCapCalculator): + max_caps = [CategoryCap(["cfhc", "awd_medicaid", "cwd_medicaid"], member_cap=True)] diff --git a/programs/programs/co/pe/member.py b/programs/programs/co/pe/member.py index 5eb19e6a..006b9c97 100644 --- a/programs/programs/co/pe/member.py +++ b/programs/programs/co/pe/member.py @@ -53,6 +53,7 @@ class Chp(PolicyEngineMembersCalculator): pe_inputs = [ dependency.member.AgeDependency, dependency.member.PregnancyDependency, + dependency.member.ExpectedChildrenPregnancyDependency, dependency.household.CoStateCode, *dependency.irs_gross_income, ] diff --git a/programs/programs/co/utility_bill_pay/calculator.py b/programs/programs/co/utility_bill_pay/calculator.py index 188378e9..ee8e9b36 100644 --- a/programs/programs/co/utility_bill_pay/calculator.py +++ b/programs/programs/co/utility_bill_pay/calculator.py @@ -1,21 +1,9 @@ from programs.programs.calc import Eligibility, ProgramCalculator -import programs.programs.messages as messages class UtilityBillPay(ProgramCalculator): - income_limits = ( - 36_983, - 48_362, - 59_742, - 71_122, - 82_501, - 93_881, - 96_014, - 101_120, - ) - presumptive_eligibility = ("snap", "ssi", "andcs", "tanf", "wic") - amount = 350 - dependencies = ["household_size", "income_amount", "income_frequency"] + presumptive_eligibility = ("snap", "ssi", "andcs", "tanf", "wic", "co_medicaid", "emergency_medicaid", "chp") + amount = 400 def household_eligible(self, e: Eligibility): # has other programs @@ -23,12 +11,10 @@ def household_eligible(self, e: Eligibility): for benefit in UtilityBillPay.presumptive_eligibility: if self.screen.has_benefit(benefit): presumptive_eligible = True + elif benefit in self.data and self.data[benefit].eligible: + presumptive_eligible = True - # income - income = int(self.screen.calc_gross_income("yearly", ["all"])) - income_limit = UtilityBillPay.income_limits[self.screen.household_size - 1] - - e.condition(income < income_limit or presumptive_eligible, messages.income(income, income_limit)) + e.condition(presumptive_eligible) # has rent or mortgage expense has_rent_or_mortgage = self.screen.has_expense(["rent", "mortgage"]) diff --git a/programs/programs/federal/pe/member.py b/programs/programs/federal/pe/member.py index 61f36d85..1d93c701 100644 --- a/programs/programs/federal/pe/member.py +++ b/programs/programs/federal/pe/member.py @@ -15,6 +15,7 @@ class Wic(PolicyEngineMembersCalculator): pe_name = "wic" pe_inputs = [ dependency.member.PregnancyDependency, + dependency.member.ExpectedChildrenPregnancyDependency, dependency.member.AgeDependency, dependency.spm.SchoolMealCountableIncomeDependency, ] diff --git a/programs/programs/helpers.py b/programs/programs/helpers.py index 10763ba6..74ea9f94 100644 --- a/programs/programs/helpers.py +++ b/programs/programs/helpers.py @@ -10,6 +10,8 @@ def medicaid_eligible(data: dict[str, Eligibility]): if name in data: return data[name].eligible + return False + def snap_ineligible_student(screen: Screen, member: HouseholdMember): if not member.student: diff --git a/programs/programs/policyengine/calculators/dependencies/member.py b/programs/programs/policyengine/calculators/dependencies/member.py index dd7811c4..e3e36ed9 100644 --- a/programs/programs/policyengine/calculators/dependencies/member.py +++ b/programs/programs/policyengine/calculators/dependencies/member.py @@ -17,6 +17,13 @@ def value(self): return self.member.pregnant or False +class ExpectedChildrenPregnancyDependency(Member): + field = "current_pregnancies" + + def value(self): + return 1 if self.member.pregnant else 0 + + class FullTimeCollegeStudentDependency(Member): field = "is_full_time_college_student" diff --git a/programs/programs/urgent_needs/urgent_need_functions.py b/programs/programs/urgent_needs/urgent_need_functions.py index 21cd9267..58ea08eb 100644 --- a/programs/programs/urgent_needs/urgent_need_functions.py +++ b/programs/programs/urgent_needs/urgent_need_functions.py @@ -177,7 +177,7 @@ def eligible(self): class EocIncomeLimitCache(GoogleSheetsCache): default = {} sheet_id = "1T4RSc9jXRV5kzdhbK5uCQXqgtLDWt-wdh2R4JVsK33o" - range_name = "'2023'!A2:I65" + range_name = "'current'!A2:I65" def update(self): data = super().update() diff --git a/programs/serializers.py b/programs/serializers.py index de6a3157..14da2fb1 100644 --- a/programs/serializers.py +++ b/programs/serializers.py @@ -1,4 +1,4 @@ -from programs.models import Program, UrgentNeed, Navigator +from programs.models import Program, ProgramCategory, UrgentNeed, Navigator from rest_framework import serializers from translations.serializers import ModelTranslationSerializer @@ -9,16 +9,38 @@ class Meta: fields = "__all__" +class ProgramSerializerMeta: + model = Program + fields = ("id", "name", "website_description") + + class ProgramSerializer(serializers.ModelSerializer): name = ModelTranslationSerializer() website_description = ModelTranslationSerializer() - category = ModelTranslationSerializer() - class Meta: - model = Program + class Meta(ProgramSerializerMeta): + pass + + +class ProgramSerializerWithCategory(ProgramSerializer): + category = ModelTranslationSerializer(source="category.name") + + class Meta(ProgramSerializerMeta): fields = ("id", "name", "website_description", "category") +class ProgramCategorySerializer(serializers.ModelSerializer): + programs = serializers.SerializerMethodField() + name = ModelTranslationSerializer() + + class Meta: + model = ProgramCategory + fields = ("id", "name", "icon", "programs") + + def get_programs(self, obj: ProgramCategory): + return ProgramSerializer(obj.programs.filter(active=True), many=True).data + + class UrgentNeedAPISerializer(serializers.ModelSerializer): name = ModelTranslationSerializer() website_description = ModelTranslationSerializer() diff --git a/programs/urls.py b/programs/urls.py index ddebede9..f502e920 100644 --- a/programs/urls.py +++ b/programs/urls.py @@ -4,6 +4,7 @@ router = routers.DefaultRouter() router.register(r"programs", views.ProgramViewSet) +router.register(r"program_categories", views.ProgramCategoryViewSet) router.register(r"navigators", views.NavigatorViewSet) router.register(r"urgent-needs", views.UrgentNeedViewSet) diff --git a/programs/views.py b/programs/views.py index 8d16cfc5..fec2263b 100644 --- a/programs/views.py +++ b/programs/views.py @@ -1,34 +1,33 @@ -from programs.models import Program, Navigator, UrgentNeed +from programs.models import Program, Navigator, ProgramCategory, UrgentNeed from rest_framework import viewsets, mixins from rest_framework import permissions -from programs.serializers import ProgramSerializer, NavigatorAPISerializer, UrgentNeedAPISerializer +from programs.serializers import ( + ProgramCategorySerializer, + NavigatorAPISerializer, + ProgramSerializerWithCategory, + UrgentNeedAPISerializer, +) class ProgramViewSet(mixins.RetrieveModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet): - """ - API endpoint that allows programs to be viewed or edited. - """ + queryset = Program.objects.filter(active=True, category__isnull=False) + serializer_class = ProgramSerializerWithCategory + permission_classes = [permissions.IsAuthenticated] + - queryset = Program.objects.filter(active=True) - serializer_class = ProgramSerializer +class ProgramCategoryViewSet(mixins.RetrieveModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet): + queryset = ProgramCategory.objects.filter(programs__isnull=False, programs__active=True).distinct() + serializer_class = ProgramCategorySerializer permission_classes = [permissions.IsAuthenticated] class NavigatorViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet): - """ - API endpoint that allows programs to be viewed or edited. - """ - queryset = Navigator.objects.all() serializer_class = NavigatorAPISerializer permission_classes = [permissions.IsAuthenticated] class UrgentNeedViewSet(mixins.RetrieveModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet): - """ - API endpoint that allows programs to be viewed or edited. - """ - queryset = UrgentNeed.objects.filter(active=True) serializer_class = UrgentNeedAPISerializer permission_classes = [permissions.IsAuthenticated] diff --git a/runtime.txt b/runtime.txt index c6f7782f..57f55885 100644 --- a/runtime.txt +++ b/runtime.txt @@ -1 +1 @@ -python-3.9.13 +python-3.9.20 diff --git a/screener/serializers.py b/screener/serializers.py index 9959b654..9eb0c004 100644 --- a/screener/serializers.py +++ b/screener/serializers.py @@ -263,7 +263,6 @@ class EligibilitySerializer(serializers.Serializer): estimated_delivery_time = TranslationSerializer() estimated_application_time = TranslationSerializer() legal_status_required = serializers.ListField() - category = TranslationSerializer() eligible = serializers.BooleanField() failed_tests = serializers.ListField() passed_tests = serializers.ListField() @@ -275,7 +274,6 @@ 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__" @@ -294,10 +292,12 @@ class ProgramCategoryCapSerializer(serializers.Serializer): class ProgramCategorySerializer(serializers.Serializer): + external_name = serializers.CharField() icon = serializers.CharField() name = TranslationSerializer() description = TranslationSerializer() caps = ProgramCategoryCapSerializer(many=True) + programs = serializers.ListField(child=serializers.IntegerField()) class UrgentNeedSerializer(serializers.Serializer): @@ -315,4 +315,4 @@ class ResultsSerializer(serializers.Serializer): default_language = serializers.CharField() missing_programs = serializers.BooleanField() validations = ValidationSerializer(many=True) - program_categories = serializers.DictField(child=ProgramCategorySerializer()) + program_categories = ProgramCategorySerializer(many=True) diff --git a/screener/views.py b/screener/views.py index 1381561e..958862a6 100644 --- a/screener/views.py +++ b/screener/views.py @@ -1,6 +1,7 @@ from typing import Optional from django.http import HttpResponse from django.shortcuts import get_object_or_404 +from rest_framework.relations import reverse from integrations.services.communications import MessageUser from programs.programs import categories from programs.programs.helpers import STATE_MEDICAID_OPTIONS @@ -26,7 +27,7 @@ MessageSerializer, ResultsSerializer, ) -from programs.programs.policyengine.policy_engine import all_eligibility, calc_pe_eligibility +from programs.programs.policyengine.policy_engine import 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 ( @@ -198,9 +199,7 @@ def eligibility_results(screen: Screen, batch=False): excluded_programs = [p.id for p in referrer.remove_programs.all()] all_programs = ( - Program.objects.filter(active=True) - # NOTE: uncomment when categories are ready - # .filter(active=True, category_v2__isnull=False) + Program.objects.filter(active=True, category__isnull=False) .prefetch_related( "legal_status_required", "fpl", @@ -218,9 +217,10 @@ def eligibility_results(screen: Screen, batch=False): "translation_overrides", "translation_overrides__counties", *translations_prefetch_name("translation_overrides__", TranslationOverride.objects.translated_fields), - "category_v2", - *translations_prefetch_name("category_v2__", ProgramCategory.objects.translated_fields), - ).exclude(id__in=excluded_programs) + "category", + *translations_prefetch_name("category__", ProgramCategory.objects.translated_fields), + ) + .exclude(id__in=excluded_programs) ) data = [] @@ -252,12 +252,22 @@ def eligibility_results(screen: Screen, batch=False): pe_programs = pe_calculators.keys() def sort_first(program): - calc_first = ("tanf", "ssi", "nslp", "leap", "chp", *STATE_MEDICAID_OPTIONS) + calc_order = ( + "tanf", + "ssi", + "nslp", + "leap", + "chp", + *STATE_MEDICAID_OPTIONS, + "emergency_medicaid", + "wic", + "andcs", + ) - if program.name_abbreviated in calc_first: - return 0 - else: - return 1 + if program.name_abbreviated not in calc_order: + return len(calc_order) + + return calc_order.index(program.name_abbreviated) missing_programs = False @@ -364,7 +374,6 @@ def sort_first(program): "learn_more_link": program_translations.get_translation("learn_more_link"), "apply_button_link": program_translations.get_translation("apply_button_link"), "legal_status_required": legal_status, - "category": program_translations.get_translation("category"), "estimated_value_override": program_translations.get_translation("estimated_value"), "eligible": eligibility.eligible, "failed_tests": eligibility.fail_messages, @@ -375,33 +384,35 @@ 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, - # } + category_map = {} + for program in all_programs: + category = program.category + if category.id in category_map: + category_map[category.id]["programs"].append(program.id) + 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}) + + category_map[category.id] = { + "id": category.external_name, + "icon": category.icon, + "name": default_message(category.name), + "description": default_message(category.description), + "caps": caps, + "programs": [program.id], + } + categories = list(category_map.values()) ProgramEligibilitySnapshot.objects.bulk_create(program_snapshots) snapshot.had_error = False diff --git a/translations/templates/programs/list.html b/translations/templates/programs/list.html index f3393030..f544ab4f 100644 --- a/translations/templates/programs/list.html +++ b/translations/templates/programs/list.html @@ -80,11 +80,6 @@ class="dropdown-item" >Estimated application time - Category Program Actions > Estimated application time - - Category - Website description