diff --git a/.gitignore b/.gitignore index f6629ff9..10ee1d9b 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ db.sqlite3 # Editor directories and files .idea +.editorconfig # Byte-compiled / optimized / DLL files __pycache__/ @@ -24,3 +25,6 @@ __pycache__/ *$py.class .vscode + +# Static files +staticfiles/ \ No newline at end of file diff --git a/authentication/admin.py b/authentication/admin.py index 28ede927..5714dc1c 100644 --- a/authentication/admin.py +++ b/authentication/admin.py @@ -1,27 +1,26 @@ from django.contrib import admin -from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Group +from django.contrib.auth.admin import GroupAdmin +from rest_framework.authtoken.models import TokenProxy +from rest_framework.authtoken.admin import TokenAdmin +from unfold.admin import ModelAdmin from .models import User -class CustomUserAdmin(UserAdmin): +class CustomUserAdmin(ModelAdmin): + search_fields = ("email",) ordering = ('email_or_cell', 'email') list_display = ('email_or_cell', 'is_staff') - list_filter = ('is_staff',) - fieldsets = ( - (None, {'fields': ('email_or_cell', 'password')}), - ('Personal info', {'fields': ('email', 'cell')}), - ('Permissions', {'fields': ('is_staff', 'tcpa_consent', 'groups')}), - ) - # add_fieldsets is not a standard ModelAdmin attribute. UserAdmin - # overrides get_fieldsets to use this attribute when creating a user. - add_fieldsets = ( - (None, { - 'classes': ('wide',), - 'fields': ('email_or_cell', 'password1', 'password2', 'email', 'cell', 'tcpa_consent', 'is_staff'), - }), - ) +class CustomGroupAdmin(ModelAdmin, GroupAdmin): + pass +class CustomTokenAdmin(ModelAdmin, TokenAdmin): + pass admin.site.register(User, CustomUserAdmin) +admin.site.unregister(Group) +admin.site.register(Group, CustomGroupAdmin) +admin.site.unregister(TokenProxy) +admin.site.register(TokenProxy, CustomTokenAdmin) \ No newline at end of file diff --git a/authentication/views.py b/authentication/views.py index f9fefbb3..ab72bbe8 100644 --- a/authentication/views.py +++ b/authentication/views.py @@ -5,23 +5,23 @@ from rest_framework import viewsets, permissions, mixins from rest_framework.response import Response from authentication.serializers import UserSerializer, UserOffersSerializer -from sentry_sdk import capture_message +from sentry_sdk import capture_exception from integrations.services.hubspot.integration import update_send_offers_hubspot, upsert_user_hubspot import uuid -class UserViewSet(mixins.UpdateModelMixin, - viewsets.GenericViewSet): +class UserViewSet(mixins.UpdateModelMixin, viewsets.GenericViewSet): """ API endpoint that allows users to be viewed or edited. """ - queryset = User.objects.all().order_by('-email_or_cell') + + queryset = User.objects.all().order_by("-email_or_cell") serializer_class = UserSerializer permission_classes = [permissions.DjangoModelPermissions] def update(self, request, pk=None): if pk is None: - return Response('Must have an associated screen', status=400) + return Response("Must have an associated screen", status=400) screen = Screen.objects.get(uuid=pk) user = screen.user if user: @@ -43,11 +43,8 @@ def update(self, request, pk=None): try: upsert_user_to_hubspot(screen, screen.user) - except Exception: - capture_message( - 'HubSpot upsert failed', - level='warning', - ) + except Exception as e: + capture_exception(e, level="warning") return Response("Invalid Email", status=400) return Response(status=204) @@ -65,14 +62,13 @@ def upsert_user_to_hubspot(screen, user): hubspot_id = upsert_user_hubspot(user, screen=screen) if hubspot_id: - random_id = str(uuid.uuid4()).replace('-', '') + random_id = str(uuid.uuid4()).replace("-", "") user.external_id = hubspot_id - user.email_or_cell = f'{hubspot_id}+{random_id}@myfriendben.org' + user.email_or_cell = f"{hubspot_id}+{random_id}@myfriendben.org" user.first_name = None user.last_name = None user.cell = None user.email = None user.save() else: - raise Exception('Failed to upsert user') - + raise Exception("Failed to upsert user") diff --git a/benefits/settings.py b/benefits/settings.py index 058f6a11..e4ba34ed 100644 --- a/benefits/settings.py +++ b/benefits/settings.py @@ -15,6 +15,7 @@ from decouple import config from pathlib import Path +from django.urls import reverse_lazy from django.utils.translation import gettext_lazy as _ import sentry_sdk from sentry_sdk.integrations.django import DjangoIntegration @@ -62,6 +63,13 @@ # Application definition INSTALLED_APPS = [ + "unfold", + "unfold.contrib.filters", # optional, if special filters are needed + "unfold.contrib.forms", # optional, if special form elements are needed + "unfold.contrib.import_export", # optional, if django-import-export package is used + "unfold.contrib.guardian", # optional, if django-guardian package is used + # optional, if django-simple-history package is used + "unfold.contrib.simple_history", 'authentication.apps.AuthConfig', 'corsheaders', 'screener.apps.ScreenerConfig', @@ -193,7 +201,8 @@ ), 'default': { 'fallbacks': ['en-us'], # defaults to PARLER_DEFAULT_LANGUAGE_CODE - 'hide_untranslated': True, # the default; let .active_translations() return fallbacks too. + # the default; let .active_translations() return fallbacks too. + 'hide_untranslated': True, }, } @@ -221,3 +230,79 @@ ) django_heroku.settings(locals()) + + +# UNFOLD SETTINGS +UNFOLD = { + "SITE_HEADER": _("MFB Admin"), + "SITE_TITLE": _("MFB Admin"), + 'APP_NAME': 'Benefits', + 'APP_VERSION': '1.0.0', + 'APP_DESCRIPTION': 'Benefits is a Django application that helps people find and apply for benefits.', + "SIDEBAR": { + "show_search": True, + "show_all_applications": True, + "navigation": [ + { + "title": _("Navigation"), + "items": [ + { + "title": _("Programs"), + "icon": "other_admission", + "link": reverse_lazy("admin:programs_program_changelist"), + }, + { + "title": _("Urgent Needs"), + "icon": "breaking_news", + "link": reverse_lazy("admin:programs_urgentneed_changelist"), + }, + { + "title": _("Navigators"), + "icon": "near_me", + "link": reverse_lazy("admin:programs_navigator_changelist"), + }, + { + "title": _("Configurations"), + "icon": "tune", + "link": reverse_lazy("admin:configuration_configuration_changelist"), + }, + { + "title": _("Translations"), + "icon": "translate", + "link": reverse_lazy("admin:translations_translation_changelist"), + }, + ] + }, + { + "separator": True, + "items": [ + { + "title": _("Dashboard"), + "icon": "dashboard", + "link": reverse_lazy("admin:index"), + }, + { + "title": _("Translations API"), + "icon": "settings", + "link": reverse_lazy("translations_api_url"), + }, + { + "title": _("Users"), + "icon": "person", + "link": reverse_lazy("admin:authentication_user_changelist"), + }, + { + "title": _("Groups"), + "icon": "group", + "link": reverse_lazy("admin:auth_group_changelist"), + }, + { + "title": _("Tokens"), + "icon": "key", + "link": reverse_lazy("admin:authtoken_tokenproxy_changelist"), + }, + ], + }, + ], + } +} diff --git a/configuration/admin.py b/configuration/admin.py index 8374284d..8db196be 100644 --- a/configuration/admin.py +++ b/configuration/admin.py @@ -1,7 +1,10 @@ from django.contrib import admin +from unfold.admin import ModelAdmin from .models import Configuration -class ConfigurationAdmin(admin.ModelAdmin): + +class ConfigurationAdmin(ModelAdmin): search_fields = ('name',) -admin.site.register(Configuration, ConfigurationAdmin) \ No newline at end of file + +admin.site.register(Configuration, ConfigurationAdmin) diff --git a/integrations/services/hubspot/integration.py b/integrations/services/hubspot/integration.py index 2f792d4d..628a91c7 100644 --- a/integrations/services/hubspot/integration.py +++ b/integrations/services/hubspot/integration.py @@ -4,6 +4,9 @@ from decouple import config import json import re +from authentication.models import User +from screener.models import Screen +from sentry_sdk import capture_exception, capture_message def upsert_user_hubspot(user, screen=None): @@ -16,8 +19,8 @@ def upsert_user_hubspot(user, screen=None): def update_send_offers_hubspot(external_id, send_offers, send_updates): hubspot = Hubspot() contact = { - 'ab01___send_offers': send_offers, - 'ab01___send_updates': send_updates, + "ab01___send_offers": send_offers, + "ab01___send_updates": send_updates, } try: hubspot.update_contact(external_id, contact) @@ -25,9 +28,11 @@ def update_send_offers_hubspot(external_id, send_offers, send_updates): print(e) -class Hubspot(): +class Hubspot: + MAX_HOUSEHOLD_SIZE = 8 + def __init__(self): - self.api_client = HubSpot(access_token=config('HUBSPOT')) + self.api_client = HubSpot(access_token=config("HUBSPOT")) # Hubspot has no insert or update option in their latest API, so the code # below first attempts to create a contact. If there is already a contact @@ -40,33 +45,30 @@ def upsert_contact(self, contact): contact_id = api_response.id except ApiException as e: http_body = json.loads(e.body) - if http_body['category'] == 'CONFLICT': + print(http_body) + if http_body["category"] == "CONFLICT": try: contact_id = self.get_conflict_contact_id(e) self.update_contact(contact_id, contact) except ApiException as f: - print(f) + capture_exception(f) return False else: + capture_exception(e) return False return contact_id def create_contact(self, user): - simple_public_object_input = SimplePublicObjectInput( - properties=user - ) + simple_public_object_input = SimplePublicObjectInput(properties=user) api_response = self.api_client.crm.contacts.basic_api.create( simple_public_object_input=simple_public_object_input ) return api_response def update_contact(self, contact_id, user): - simple_public_object_input = SimplePublicObjectInput( - properties=user - ) + simple_public_object_input = SimplePublicObjectInput(properties=user) api_response = self.api_client.crm.contacts.basic_api.update( - contact_id, - simple_public_object_input=simple_public_object_input + contact_id, simple_public_object_input=simple_public_object_input ) return api_response @@ -83,38 +85,48 @@ def get_conflict_contact_id(self, e): http_body = json.loads(e.body) # strip everything out of the error message except the contact id # https://community.hubspot.com/t5/APIs-Integrations/Contacts-v3-contact-exists-error/m-p/364629 - contact_id = re.sub('[^0-9]', '', http_body['message']) + contact_id = re.sub("[^0-9]", "", http_body["message"]) return contact_id - def mfb_user_to_hubspot_contact(self, user, screen=None): + def mfb_user_to_hubspot_contact(self, user: User, screen: Screen = None): contact = { - 'email': user.email, - 'firstname': user.first_name, - 'lastname': user.last_name, - 'phone': str(user.cell), - 'benefits_screener_id': user.id, - 'ab01___send_offers': user.send_offers, - 'ab01___send_updates': user.send_updates, - 'ab01___tcpa_consent_to_contact': user.tcpa_consent, - 'hs_language': user.language_code, - 'ab01___screener_id': None, - 'ab01___screener_uuid': None, - 'ab01___1st_mfb_completion_date': user.date_joined.date().isoformat(), - 'full_name': f'{user.first_name} {user.last_name}' + "email": user.email, + "firstname": user.first_name, + "lastname": user.last_name, + "phone": str(user.cell), + "benefits_screener_id": user.id, + "ab01___send_offers": user.send_offers, + "ab01___send_updates": user.send_updates, + "ab01___tcpa_consent_to_contact": user.tcpa_consent, + "hs_language": user.language_code, + "ab01___1st_mfb_completion_date": user.date_joined.date().isoformat(), + "full_name": f"{user.first_name} {user.last_name}", } if screen: - contact['ab01___screener_id'] = screen.id - contact['ab01___uuid'] = str(screen.uuid) + contact["ab01___screener_id"] = screen.id + contact["ab01___uuid"] = str(screen.uuid) + contact["ab01___county"] = screen.county + contact["ab01___number_of_household_members"] = screen.household_size + + members = screen.household_members.all() + if len(members) > self.MAX_HOUSEHOLD_SIZE: + capture_message(f"screen has more than {self.MAX_HOUSEHOLD_SIZE} household members", level="error") + + for i, member in enumerate(members): + if i >= self.MAX_HOUSEHOLD_SIZE: + break + + contact[f"ab01___hhm{i + 1}_age"] = member.age return contact def format_email_new_benefit(self, user, num_benefits, value_benefits): contact = { - 'id': user.external_id, - 'properties': { - 'ab01___number_of_new_benefits': int(num_benefits), - 'ab01___new_benefit_total_value': int(value_benefits), - } + "id": user.external_id, + "properties": { + "ab01___number_of_new_benefits": int(num_benefits), + "ab01___new_benefit_total_value": int(value_benefits), + }, } return contact diff --git a/integrations/util/cache.py b/integrations/util/cache.py index a340fb3e..ca61ad37 100644 --- a/integrations/util/cache.py +++ b/integrations/util/cache.py @@ -1,8 +1,8 @@ -from sentry_sdk import capture_message +from sentry_sdk import capture_exception import datetime -class Cache(): +class Cache: expire_time = 0 default = 0 @@ -17,8 +17,8 @@ def _update_cache(self): try: self.data = self.update() self.last_update = datetime.datetime.now() - except Exception: - capture_message(f'Failed to update {self.__class__.__name__}', level='warning') + except Exception as e: + capture_exception(e, level="warning") def should_update(self): return datetime.datetime.now() > self.last_update + datetime.timedelta(seconds=self.expire_time) diff --git a/programs/admin.py b/programs/admin.py index 8fdf1b8a..77d63c3d 100644 --- a/programs/admin.py +++ b/programs/admin.py @@ -1,4 +1,7 @@ from django.contrib import admin +from django.urls import reverse +from django.utils.html import format_html +from unfold.admin import ModelAdmin from .models import ( LegalStatus, Program, @@ -14,50 +17,186 @@ ) -class ProgramAdmin(admin.ModelAdmin): - search_fields = ('name_abbreviated',) +class ProgramAdmin(ModelAdmin): + search_fields = ("name__translations__text",) + list_display = ["get_str", "name_abbreviated", "active", "action_buttons"] + filter_horizontal = ('legal_status_required', 'documents',) + + def get_str(self, obj): + return str(obj) if str(obj).strip() else 'unnamed' + + get_str.admin_order_field = "name" + get_str.short_description = "Program" + + def action_buttons(self, obj): + name = obj.name + description = obj.description + 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 + warning = obj.warning + website_description = obj.website_description + + return format_html( + """ + + """, + 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]), + reverse("translation_admin_url", args=[ + estimated_application_time.id]), + reverse("translation_admin_url", args=[value_type.id]), + reverse("translation_admin_url", args=[warning.id]), + reverse("translation_admin_url", args=[website_description.id]), + ) + + action_buttons.short_description = "Translate:" + action_buttons.allow_tags = True + + +class LegalStatusAdmin(ModelAdmin): + search_fields = ("status",) + + +class NavigatorCountiesAdmin(ModelAdmin): + search_fields = ("name",) + + +class NavigatorAdmin(ModelAdmin): + search_fields = ("name__translations__text",) + list_display = ["get_str", "external_name", "action_buttons"] + filter_horizontal = ('program', 'counties',) + + def get_str(self, obj): + return str(obj) if str(obj).strip() else 'unnamed' + + get_str.admin_order_field = "name" + get_str.short_description = "Navigator" + + def action_buttons(self, obj): + name = obj.name + email = obj.email + assistance_link = obj.assistance_link + description = obj.description + + return format_html( + """ + + """, + reverse("translation_admin_url", args=[name.id]), + reverse("translation_admin_url", args=[email.id]), + reverse("translation_admin_url", args=[assistance_link.id]), + reverse("translation_admin_url", args=[description.id]), + ) + + action_buttons.short_description = "Translate:" + action_buttons.allow_tags = True + + +class UrgentNeedAdmin(ModelAdmin): + search_fields = ("name__translations__text",) + list_display = ["get_str", "external_name", "active", "action_buttons"] + filter_horizontal = ('type_short', 'functions',) + + def get_str(self, obj): + return str(obj) if str(obj).strip() else 'unnamed' + + get_str.admin_order_field = "name" + get_str.short_description = "Urgent Need" + + def action_buttons(self, obj): + name = obj.name + description = obj.description + link = obj.link + type = obj.type + warning = obj.warning + website_description = obj.website_description + + return format_html( + """ + + """, + reverse("translation_admin_url", args=[name.id]), + reverse("translation_admin_url", args=[description.id]), + reverse("translation_admin_url", args=[link.id]), + reverse("translation_admin_url", args=[type.id]), + reverse("translation_admin_url", args=[warning.id]), + reverse("translation_admin_url", args=[website_description.id]), + ) + + action_buttons.short_description = "Translate:" + action_buttons.allow_tags = True + +class UrgentNeedCategoryAdmin(ModelAdmin): + search_fields = ("name",) + fields = ("name",) -class LegalStatusAdmin(admin.ModelAdmin): - search_fields = ('status',) - -class NavigatorCountiesAdmin(admin.ModelAdmin): - search_fields = ('name',) - - -class NavigatorAdmin(admin.ModelAdmin): - search_fields = ('translations__name',) - - -class UrgentNeedAdmin(admin.ModelAdmin): - search_fields = ('translations__name',) - - -class UrgentNeedCategoryAdmin(admin.ModelAdmin): - search_fields = ('name',) - fields = ('name',) - - -class UrgentNeedFunctionAdmin(admin.ModelAdmin): - search_fields = ('name',) - fields = ('name',) - - -class FederalPovertyLimitAdmin(admin.ModelAdmin): - search_fields = ('year',) - - -class DocumentAdmin(admin.ModelAdmin): - search_fields = ('name',) - - -class ReferrerAdmin(admin.ModelAdmin): - search_fields = ('referrer_code',) - - -class WebHookFunctionsAdmin(admin.ModelAdmin): - search_fields = ('name',) +class UrgentNeedFunctionAdmin(ModelAdmin): + search_fields = ("name",) + fields = ("name",) + + +class FederalPovertyLimitAdmin(ModelAdmin): + search_fields = ("year",) + + +class DocumentAdmin(ModelAdmin): + search_fields = ("external_name",) + + +class ReferrerAdmin(ModelAdmin): + search_fields = ("referrer_code",) + filter_horizontal = ('webhook_functions', + 'primary_navigators', 'remove_programs',) + + +class WebHookFunctionsAdmin(ModelAdmin): + search_fields = ("name",) admin.site.register(LegalStatus, LegalStatusAdmin) diff --git a/programs/migrations/0072_program_website_description.py b/programs/migrations/0072_program_website_description.py new file mode 100644 index 00000000..518fa67f --- /dev/null +++ b/programs/migrations/0072_program_website_description.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.11 on 2024-05-21 17:26 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("translations", "0004_translation_no_auto"), + ("programs", "0071_alter_program_estimated_value"), + ] + + operations = [ + migrations.AddField( + model_name="program", + name="website_description", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="program_website_description", + to="translations.translation", + ), + ), + ] diff --git a/programs/migrations/0073_auto_20240521_1727.py b/programs/migrations/0073_auto_20240521_1727.py new file mode 100644 index 00000000..a784a87f --- /dev/null +++ b/programs/migrations/0073_auto_20240521_1727.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.11 on 2024-05-21 17:27 +from django.db import migrations + + +def add_website_description(apps, schema_editor): + Program = apps.get_model("programs", "Program") + Translation = apps.get_model("translations", "Translation") + + for program in Program.objects.all(): + translation = Translation.objects.add_translation( + f"programs.{program.name_abbreviated}-{program.id}_website_description", "[PLACEHOLDER]" + ) + Program.objects.filter(pk=program.id).update(website_description=translation.id) + + +class Migration(migrations.Migration): + dependencies = [ + ("programs", "0072_program_website_description"), + ("translations", "0004_translation_no_auto"), + ] + + operations = [migrations.RunPython(add_website_description, migrations.RunPython.noop)] diff --git a/programs/migrations/0074_alter_program_website_description.py b/programs/migrations/0074_alter_program_website_description.py new file mode 100644 index 00000000..7d2f3556 --- /dev/null +++ b/programs/migrations/0074_alter_program_website_description.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.11 on 2024-05-21 17:31 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("translations", "0004_translation_no_auto"), + ("programs", "0073_auto_20240521_1727"), + ] + + operations = [ + migrations.AlterField( + model_name="program", + name="website_description", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="program_website_description", + to="translations.translation", + ), + ), + ] diff --git a/programs/migrations/0075_urgentneed_website_description.py b/programs/migrations/0075_urgentneed_website_description.py new file mode 100644 index 00000000..e9ad6368 --- /dev/null +++ b/programs/migrations/0075_urgentneed_website_description.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.11 on 2024-05-21 20:54 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("translations", "0004_translation_no_auto"), + ("programs", "0074_alter_program_website_description"), + ] + + operations = [ + migrations.AddField( + model_name="urgentneed", + name="website_description", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="urgent_website_description", + to="translations.translation", + ), + ), + ] diff --git a/programs/migrations/0076_auto_20240521_2055.py b/programs/migrations/0076_auto_20240521_2055.py new file mode 100644 index 00000000..60ffa57f --- /dev/null +++ b/programs/migrations/0076_auto_20240521_2055.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.11 on 2024-05-21 20:55 +import uuid +from django.db import migrations + + +def add_estimated_value(apps, schema_editor): + UrgentNeed = apps.get_model("programs", "UrgentNeed") + Translation = apps.get_model("translations", "Translation") + + for urgent_need in UrgentNeed.objects.all(): + translation = Translation.objects.add_translation( + f"urgent_need.{urgent_need.external_name or uuid.uuid4()}-{urgent_need.id}_website_description", + "[PLACEHOLDER]", + ) + UrgentNeed.objects.filter(pk=urgent_need.id).update(website_description=translation.id) + + +class Migration(migrations.Migration): + dependencies = [ + ("programs", "0075_urgentneed_website_description"), + ("translations", "0004_translation_no_auto"), + ] + + operations = [migrations.RunPython(add_estimated_value, migrations.RunPython.noop)] diff --git a/programs/migrations/0077_alter_urgentneed_website_description.py b/programs/migrations/0077_alter_urgentneed_website_description.py new file mode 100644 index 00000000..24c403f3 --- /dev/null +++ b/programs/migrations/0077_alter_urgentneed_website_description.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.11 on 2024-05-21 21:02 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("translations", "0004_translation_no_auto"), + ("programs", "0076_auto_20240521_2055"), + ] + + operations = [ + migrations.AlterField( + model_name="urgentneed", + name="website_description", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="urgent_website_description", + to="translations.translation", + ), + ), + ] diff --git a/programs/models.py b/programs/models.py index 1a5f69ea..ed553084 100644 --- a/programs/models.py +++ b/programs/models.py @@ -34,34 +34,33 @@ def __str__(self): class LegalStatus(models.Model): status = models.CharField(max_length=256) - parent = models.ForeignKey('self', related_name='children', blank=True, null=True, on_delete=models.SET_NULL) + parent = models.ForeignKey( + "self", related_name="children", blank=True, null=True, on_delete=models.SET_NULL) def __str__(self): return self.status class DocumentManager(models.Manager): - translated_fields = ('text',) + translated_fields = ("text",) def new_document(self, external_name): translation = Translation.objects.add_translation( - f'document.{external_name}_temporary_key', '' - ) + f"document.{external_name}_temporary_key") - document = self.create( - external_name=external_name, - text=translation - ) + document = self.create(external_name=external_name, text=translation) - translation.label = f'document.{external_name}_{document.id}' + translation.label = f"document.{external_name}_{document.id}" translation.save() return document class Document(models.Model): - external_name = models.CharField(max_length=120, blank=True, null=True, unique=True) - text = models.ForeignKey(Translation, related_name='documents', blank=False, null=False, on_delete=models.PROTECT) + external_name = models.CharField( + max_length=120, blank=True, null=True, unique=True) + text = models.ForeignKey(Translation, related_name="documents", + blank=False, null=False, on_delete=models.PROTECT) objects = DocumentManager() @@ -71,28 +70,34 @@ def __str__(self) -> str: class ProgramManager(models.Manager): translated_fields = ( - 'description_short', - 'name', - 'description', - 'learn_more_link', - 'apply_button_link', - 'value_type', - 'estimated_delivery_time', - 'estimated_application_time', - 'category', - 'warning', - 'estimated_value', + "description_short", + "name", + "description", + "learn_more_link", + "apply_button_link", + "value_type", + "estimated_delivery_time", + "estimated_application_time", + "category", + "warning", + "estimated_value", + "website_description", ) def new_program(self, name_abbreviated): translations = {} for field in self.translated_fields: translations[field] = Translation.objects.add_translation( - f'program.{name_abbreviated}_temporary_key-{field}', '' + f"program.{name_abbreviated}_temporary_key-{field}" ) + # try to set the external_name to the name_abbreviated + external_name_exists = self.filter( + external_name=name_abbreviated).count() > 0 + program = self.create( name_abbreviated=name_abbreviated, + external_name=name_abbreviated if not external_name_exists else None, fpl=None, active=False, low_confidence=False, @@ -100,7 +105,7 @@ def new_program(self, name_abbreviated): ) for [field, translation] in translations.items(): - translation.label = f'program.{name_abbreviated}_{program.id}-{field}' + translation.label = f"program.{name_abbreviated}_{program.id}-{field}" translation.save() return program @@ -111,76 +116,65 @@ def new_program(self, name_abbreviated): # logic for eligibility and value is stored. class Program(models.Model): name_abbreviated = models.CharField(max_length=120) - external_name = models.CharField(max_length=120, blank=True, null=True, unique=True) - legal_status_required = models.ManyToManyField(LegalStatus, related_name='programs', blank=True) - documents = models.ManyToManyField(Document, related_name='program_documents', blank=True) + external_name = models.CharField( + max_length=120, blank=True, null=True, unique=True) + legal_status_required = models.ManyToManyField( + LegalStatus, related_name="programs", blank=True) + documents = models.ManyToManyField( + Document, related_name="program_documents", blank=True) 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) + fpl = models.ForeignKey(FederalPoveryLimit, related_name="fpl", + 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) + Translation, related_name="program_description_short", blank=False, null=False, on_delete=models.PROTECT + ) name = models.ForeignKey( - Translation, - related_name='program_name', - blank=False, - null=False, - on_delete=models.PROTECT) + Translation, related_name="program_name", blank=False, null=False, on_delete=models.PROTECT + ) description = models.ForeignKey( - Translation, - related_name='program_description', - blank=False, - null=False, - on_delete=models.PROTECT) + Translation, related_name="program_description", blank=False, null=False, on_delete=models.PROTECT + ) learn_more_link = models.ForeignKey( - Translation, - related_name='program_learn_more_link', - blank=False, - null=False, - on_delete=models.PROTECT) + Translation, related_name="program_learn_more_link", blank=False, null=False, on_delete=models.PROTECT + ) apply_button_link = models.ForeignKey( - Translation, - related_name='program_apply_button_link', - null=False, - on_delete=models.PROTECT) + Translation, related_name="program_apply_button_link", null=False, on_delete=models.PROTECT + ) value_type = models.ForeignKey( - Translation, - related_name='program_value_type', - blank=False, - null=False, - on_delete=models.PROTECT) + Translation, related_name="program_value_type", blank=False, null=False, on_delete=models.PROTECT + ) estimated_delivery_time = models.ForeignKey( - Translation, - related_name='program_estimated_delivery_time', - blank=False, - null=False, - on_delete=models.PROTECT) + Translation, related_name="program_estimated_delivery_time", blank=False, null=False, on_delete=models.PROTECT + ) estimated_application_time = models.ForeignKey( Translation, - related_name='program_estimated_application_time', + related_name="program_estimated_application_time", blank=False, null=False, - on_delete=models.PROTECT) + on_delete=models.PROTECT, + ) category = models.ForeignKey( + Translation, related_name="program_category", blank=False, null=False, on_delete=models.PROTECT + ) + warning = models.ForeignKey( Translation, - related_name='program_category', + related_name="program_warning", blank=False, null=False, - on_delete=models.PROTECT) - warning = models.ForeignKey( + on_delete=models.PROTECT, + ) + estimated_value = models.ForeignKey( Translation, - related_name='program_warning', + related_name="program_estimated_value", blank=False, null=False, on_delete=models.PROTECT, ) - estimated_value = models.ForeignKey( + website_description = models.ForeignKey( Translation, - related_name='program_estimated_value', + related_name="program_website_description", blank=False, null=False, on_delete=models.PROTECT, @@ -228,7 +222,7 @@ class UrgentNeedCategory(models.Model): name = models.CharField(max_length=120) class Meta: - verbose_name_plural = 'Urgent Need Categories' + verbose_name_plural = "Urgent Need Categories" def __str__(self): return self.name @@ -236,70 +230,66 @@ def __str__(self): class UrgentNeedManager(models.Manager): translated_fields = ( - 'name', - 'description', - 'link', - 'type', - 'warning', + "name", + "description", + "link", + "type", + "warning", + "website_description", ) def new_urgent_need(self, name, phone_number): translations = {} for field in self.translated_fields: - translations[field] = Translation.objects.add_translation(f'urgent_need.{name}_temporary_key-{field}', '') + translations[field] = Translation.objects.add_translation( + f"urgent_need.{name}_temporary_key-{field}") + + # try to set the external_name to the name + external_name_exists = self.filter(external_name=name).count() > 0 urgent_need = self.create( phone_number=phone_number, + external_name=name if not external_name_exists else None, active=False, low_confidence=False, **translations, ) for [field, translation] in translations.items(): - translation.label = f'urgent_need.{name}_{urgent_need.id}-{field}' + translation.label = f"urgent_need.{name}_{urgent_need.id}-{field}" translation.save() return urgent_need class UrgentNeed(models.Model): - external_name = models.CharField(max_length=120, blank=True, null=True, unique=True) + external_name = models.CharField( + max_length=120, blank=True, null=True, unique=True) phone_number = PhoneNumberField(blank=True, null=True) - type_short = models.ManyToManyField(UrgentNeedCategory, related_name='urgent_needs') + type_short = models.ManyToManyField( + UrgentNeedCategory, related_name="urgent_needs") active = models.BooleanField(blank=True, null=False, default=True) low_confidence = models.BooleanField(blank=True, null=False, default=False) - functions = models.ManyToManyField(UrgentNeedFunction, related_name='function', blank=True) + functions = models.ManyToManyField( + UrgentNeedFunction, related_name="function", blank=True) name = models.ForeignKey( - Translation, - related_name='urgent_need_name', - blank=False, - null=False, - on_delete=models.PROTECT) + Translation, related_name="urgent_need_name", blank=False, null=False, on_delete=models.PROTECT + ) description = models.ForeignKey( - Translation, - related_name='urgent_need_description', - blank=False, - null=False, - on_delete=models.PROTECT) + Translation, related_name="urgent_need_description", blank=False, null=False, on_delete=models.PROTECT + ) link = models.ForeignKey( - Translation, - related_name='urgent_need_link', - blank=False, - null=False, - on_delete=models.PROTECT) + Translation, related_name="urgent_need_link", blank=False, null=False, on_delete=models.PROTECT + ) type = models.ForeignKey( - Translation, - related_name='urgent_need_type', - blank=False, - null=False, - on_delete=models.PROTECT) + Translation, related_name="urgent_need_type", blank=False, null=False, on_delete=models.PROTECT + ) warning = models.ForeignKey( - Translation, - related_name='urgent_need_warning', - blank=False, - null=False, - on_delete=models.PROTECT + Translation, related_name="urgent_need_warning", blank=False, null=False, on_delete=models.PROTECT + ) + website_description = models.ForeignKey( + Translation, related_name="urgent_website_description", blank=False, null=False, on_delete=models.PROTECT ) objects = UrgentNeedManager() @@ -317,59 +307,55 @@ def __str__(self) -> str: class NavigatorManager(models.Manager): translated_fields = ( - 'name', - 'email', - 'assistance_link', - 'description', + "name", + "email", + "assistance_link", + "description", ) def new_navigator(self, name, phone_number): translations = {} for field in self.translated_fields: - translations[field] = Translation.objects.add_translation(f'navigator.{name}_temporary_key-{field}', '') + translations[field] = Translation.objects.add_translation( + f"navigator.{name}_temporary_key-{field}") + + # try to set the external_name to the name + external_name_exists = self.filter(external_name=name).count() > 0 navigator = self.create( phone_number=phone_number, + external_name=name if not external_name_exists else None, **translations, ) for [field, translation] in translations.items(): - translation.label = f'navigator.{name}_{navigator.id}-{field}' + translation.label = f"navigator.{name}_{navigator.id}-{field}" translation.save() return navigator class Navigator(models.Model): - program = models.ManyToManyField(Program, related_name='navigator', blank=True) - external_name = models.CharField(max_length=120, blank=True, null=True, unique=True) + program = models.ManyToManyField( + Program, related_name="navigator", blank=True) + external_name = models.CharField( + max_length=120, blank=True, null=True, unique=True) phone_number = PhoneNumberField(blank=True, null=True) - counties = models.ManyToManyField(NavigatorCounty, related_name='navigator', blank=True) + counties = models.ManyToManyField( + NavigatorCounty, related_name="navigator", blank=True) name = models.ForeignKey( - Translation, - related_name='navigator_name', - blank=False, - null=False, - on_delete=models.PROTECT) + Translation, related_name="navigator_name", blank=False, null=False, on_delete=models.PROTECT + ) email = models.ForeignKey( - Translation, - related_name='navigator_email', - blank=False, - null=False, - on_delete=models.PROTECT) + Translation, related_name="navigator_email", blank=False, null=False, on_delete=models.PROTECT + ) assistance_link = models.ForeignKey( - Translation, - related_name='navigator_assistance_link', - blank=False, - null=False, - on_delete=models.PROTECT) + Translation, related_name="navigator_assistance_link", blank=False, null=False, on_delete=models.PROTECT + ) description = models.ForeignKey( - Translation, - related_name='navigator_name_description', - blank=False, - null=False, - on_delete=models.PROTECT) + Translation, related_name="navigator_name_description", blank=False, null=False, on_delete=models.PROTECT + ) objects = NavigatorManager() @@ -387,9 +373,12 @@ def __str__(self): class Referrer(models.Model): referrer_code = models.CharField(max_length=64, unique=True) webhook_url = models.CharField(max_length=320, blank=True, null=True) - webhook_functions = models.ManyToManyField(WebHookFunction, related_name='web_hook', blank=True) - primary_navigators = models.ManyToManyField(Navigator, related_name='primary_navigators', blank=True) - remove_programs = models.ManyToManyField(Program, related_name='removed_programs', blank=True) + webhook_functions = models.ManyToManyField( + WebHookFunction, related_name="web_hook", blank=True) + primary_navigators = models.ManyToManyField( + Navigator, related_name="primary_navigators", blank=True) + remove_programs = models.ManyToManyField( + Program, related_name="removed_programs", blank=True) def __str__(self): return self.referrer_code diff --git a/programs/programs/co/pe/member.py b/programs/programs/co/pe/member.py index 0258cdc8..f1276202 100644 --- a/programs/programs/co/pe/member.py +++ b/programs/programs/co/pe/member.py @@ -61,11 +61,12 @@ class Chp(PolicyEngineMembersCalculator): def value(self): total = 0 - for pkey, pvalue in self.get_data().items(): - if not self.in_tax_unit(pkey): + for member in self.screen.household_members.all(): + if not self.in_tax_unit(member.id): continue - if pvalue['co_chp_eligible'][self.pe_period] > 0 and self.screen.has_insurance_types(('none',)): + chp_eligible = self.sim.value(self.pe_category, str(member.id), 'co_chp_eligible', self.pe_period) > 0 + if chp_eligible and self.screen.has_insurance_types(('none',)): total += self.amount return total diff --git a/programs/programs/co/pe/tax.py b/programs/programs/co/pe/tax.py index a500674e..53e4aa62 100644 --- a/programs/programs/co/pe/tax.py +++ b/programs/programs/co/pe/tax.py @@ -38,4 +38,4 @@ def value(self): multiplier = band['percent'] break - return self.get_data()[self.pe_name][self.pe_period] * multiplier + return self.get_variable() * multiplier diff --git a/programs/programs/federal/pe/member.py b/programs/programs/federal/pe/member.py index a68d4735..e6d842fb 100644 --- a/programs/programs/federal/pe/member.py +++ b/programs/programs/federal/pe/member.py @@ -23,9 +23,10 @@ class Wic(PolicyEngineMembersCalculator): def value(self): total = 0 - for _, pvalue in self.get_data().items(): - if pvalue[self.pe_name][self.pe_period] > 0: - total += self.wic_categories[pvalue['wic_category'][self.pe_period]] * 12 + for member in self.screen.household_members.all(): + if self.get_member_variable(member.id) > 0: + wic_category = self.sim.value('people', str(member.id), 'wic_category', self.pe_period) + total += self.wic_categories[wic_category] * 12 return total @@ -67,18 +68,29 @@ def _value_by_age(self, age: int): def value(self): total = 0 - for pkey, pvalue in self.get_data().items(): - # Skip any members that are not in the tax unit. - if not self.in_tax_unit(pkey): - continue + members = self.screen.household_members.all() - if pvalue[self.pe_name][self.pe_period] <= 0: + for member in members: + if self.get_member_variable(member.id) <= 0: continue - total += self._value_by_age(pvalue['age'][self.pe_period]) + # here we need to adjust for children as policy engine + # just uses the average which skews very high for adults and + # aged adults + + if self._get_age(member.id) <= 18: + medicaid_estimated_value = self.child_medicaid_average + elif self._get_age(member.id) > 18 and self._get_age(member.id) < 65: + medicaid_estimated_value = self.adult_medicaid_average + elif self._get_age(member.id) >= 65: + medicaid_estimated_value = self.aged_medicaid_average + else: + medicaid_estimated_value = 0 + + total += medicaid_estimated_value in_wic_demographic = False - for member in self.screen.household_members.all(): + for member in members: if member.pregnant is True or member.age <= 5: in_wic_demographic = True if total == 0 and in_wic_demographic: @@ -89,6 +101,9 @@ def value(self): return total + def _get_age(self, member_id: int) -> int: + return self.sim.value(self.pe_category, str(member_id), 'age', self.pe_period) + class PellGrant(PolicyEngineMembersCalculator): pe_name = 'pell_grant' diff --git a/programs/programs/federal/pe/spm.py b/programs/programs/federal/pe/spm.py index ec430ff7..c312d267 100644 --- a/programs/programs/federal/pe/spm.py +++ b/programs/programs/federal/pe/spm.py @@ -25,7 +25,7 @@ class Snap(PolicyEngineSpmCalulator): pe_output_period = SNAP_PERIOD def value(self): - return int(self.get_data()[self.pe_name][self.pe_output_period]) * 12 + return int(self.sim.value(self.pe_category, self.pe_sub_category, self.pe_name, self.pe_output_period)) * 12 class SchoolLunch(PolicyEngineSpmCalulator): @@ -39,8 +39,8 @@ def value(self): total = 0 num_children = self.screen.num_children(3, 18) - if self.get_data()[self.pe_name][self.pe_period] > 0 and num_children > 0: - if self.get_data()['school_meal_tier'][self.pe_period] != 'PAID': + if self.get_variable() > 0 and num_children > 0: + if self.sim.value(self.pe_category, self.pe_sub_category, 'school_meal_tier', self.pe_period) != 'PAID': total = SchoolLunch.amount * num_children return total diff --git a/programs/programs/policyengine/calculators/base.py b/programs/programs/policyengine/calculators/base.py index 8f3999bf..1eaea971 100644 --- a/programs/programs/policyengine/calculators/base.py +++ b/programs/programs/policyengine/calculators/base.py @@ -4,6 +4,7 @@ from .dependencies.base import PolicyEngineScreenInput from typing import List from .constants import YEAR, PREVIOUS_YEAR +from ..engines import Sim class PolicyEngineCalulator(ProgramCalculator): @@ -19,9 +20,9 @@ class PolicyEngineCalulator(ProgramCalculator): pe_sub_category = '' pe_period = YEAR - def __init__(self, screen: Screen, pe_data): + def __init__(self, screen: Screen, sim: Sim): self.screen = screen - self.pe_data = pe_data + self.sim = sim def eligible(self) -> Eligibility: e = Eligibility() @@ -31,13 +32,13 @@ def eligible(self) -> Eligibility: return e def value(self): - return self.get_data()[self.pe_name][self.pe_period] + return int(self.get_variable()) - def get_data(self): + def get_variable(self): ''' - Return Policy Engine dictionary of the program category and subcategory + Return value of the default variable ''' - return self.pe_data[self.pe_category][self.pe_sub_category] + return self.sim.value(self.pe_category, self.pe_sub_category, self.pe_name, self.pe_period) @classmethod def can_calc(cls, missing_dependencies: Dependencies): @@ -47,37 +48,40 @@ def can_calc(cls, missing_dependencies: Dependencies): return True + class PolicyEngineTaxUnitCalulator(PolicyEngineCalulator): pe_category = 'tax_units' pe_sub_category = 'tax_unit' tax_unit_dependent = True pe_period = PREVIOUS_YEAR + class PolicyEngineSpmCalulator(PolicyEngineCalulator): pe_category = 'spm_units' pe_sub_category = 'spm_unit' + class PolicyEngineMembersCalculator(PolicyEngineCalulator): tax_unit_dependent = True pe_category = 'people' def value(self): total = 0 - for pkey, pvalue in self.get_data().items(): + for member in self.screen.household_members.all(): # The following programs use income from the tax unit, # so we want to skip any members that are not in the tax unit. - if not self.in_tax_unit(pkey) and self.tax_unit_dependent: + if not self.in_tax_unit(member.id) and self.tax_unit_dependent: continue - pe_value = pvalue[self.pe_name][self.pe_period] + pe_value = self.get_member_variable(member.id) total += pe_value return total - def in_tax_unit(self, member_id) -> bool: - return str(member_id) in self.pe_data['tax_units']['tax_unit']['members'] + def in_tax_unit(self, member_id: int) -> bool: + return str(member_id) in self.sim.members('tax_units', 'tax_unit') - def get_data(self): - return self.pe_data[self.pe_category] + def get_member_variable(self, member_id: int): + return self.sim.value(self.pe_category, str(member_id), self.pe_name, self.pe_period) diff --git a/programs/programs/policyengine/engines.py b/programs/programs/policyengine/engines.py new file mode 100644 index 00000000..4e117681 --- /dev/null +++ b/programs/programs/policyengine/engines.py @@ -0,0 +1,114 @@ +from integrations.util.cache import Cache +from decouple import config +import requests + + +class Sim: + method_name = "" + + def __init__(self, data) -> None: + self.data = data + + def value(self, unit, sub_unit, variable, period): + """ + Calculate variable at the period + """ + raise NotImplementedError + + def members(self, unit, sub_unit): + """ + Return a list of the members in the sub unit + """ + raise NotImplementedError + + +class ApiSim(Sim): + method_name = "Policy Engine API" + pe_url = "https://api.policyengine.org/us/calculate" + + def __init__(self, data) -> None: + response = requests.post(self.pe_url, json=data) + self.data = response.json()["result"] + + def value(self, unit, sub_unit, variable, period): + return self.data[unit][sub_unit][variable][period] + + def members(self, unit, sub_unit): + return self.data[unit][sub_unit]["members"] + + +class PolicyEngineBearerTokenCache(Cache): + expire_time = 60 * 60 * 24 * 29 + default = "" + client_id: str = config("POLICY_ENGINE_CLIENT_ID", "") + client_secret: str = config("POLICY_ENGINE_CLIENT_SECRET", "") + domain = "https://policyengine.uk.auth0.com" + endpoint = "/oauth/token" + + def update(self): + # https://policyengine.org/us/api#fetch_token + + if self.client_id == "" or self.client_secret == "": + raise Exception("client id or secret not configured") + + payload = { + "client_id": self.client_id, + "client_secret": self.client_secret, + "grant_type": "client_credentials", + "audience": "https://household.api.policyengine.org", + } + + res = requests.post(self.domain + self.endpoint, json=payload) + + return res.json()["access_token"] + + +class PrivateApiSim(ApiSim): + method_name = "Private Policy Engine API" + token = PolicyEngineBearerTokenCache() + pe_url = "https://household.api.policyengine.org/us/calculate" + + def __init__(self, data) -> None: + token = self.token.fetch() + + headers = { + "Authorization": f"Bearer {token}", + } + + res = requests.post(self.pe_url, json=data, headers=headers) + + self.data = res.json()["result"] + + +# NOTE: Code to run Policy Engine locally. This is currently too CPU expensive to run in production. +# Requires the Policy Engine package to be installed and imported. +# +# class LocalSim(Sim): +# method_name = 'local package' +# +# def __init__(self, data) -> None: +# self.household = data['household'] +# +# self.entity_map = {} +# for entity in self.household.keys(): +# group_map = {} +# +# for i, group in enumerate(self.household[entity].keys()): +# group_map[group] = i +# +# self.entity_map[entity] = group_map +# +# self.sim = Simulation(situation=self.household) +# +# def value(self, unit, sub_unit, variable, period): +# data = self.sim.calculate(variable, period) +# +# index = self.entity_map[unit][sub_unit] +# +# return data[index] +# +# def members(self, unit, sub_unit): +# return self.household[unit][sub_unit]['members'] + + +pe_engines: list[Sim] = [PrivateApiSim, ApiSim] diff --git a/programs/programs/policyengine/policy_engine.py b/programs/programs/policyengine/policy_engine.py index c9c425db..5e5593da 100644 --- a/programs/programs/policyengine/policy_engine.py +++ b/programs/programs/policyengine/policy_engine.py @@ -1,21 +1,17 @@ from screener.models import HouseholdMember, Screen -from .calculators import all_calculators, PolicyEngineCalulator +from .calculators import PolicyEngineCalulator from programs.programs.calc import Eligibility from programs.util import Dependencies from .calculators.dependencies.base import DependencyError from typing import List -import requests -from .calculators.dependencies.member import ( - TaxUnitDependentDependency, - TaxUnitHeadDependency, - TaxUnitSpouseDependency, -) +from sentry_sdk import capture_exception, capture_message +from .engines import Sim, pe_engines def calc_pe_eligibility( - screen: Screen, - missing_fields: Dependencies, - calculators: dict[str, type[PolicyEngineCalulator]], + screen: Screen, + missing_fields: Dependencies, + calculators: dict[str, type[PolicyEngineCalulator]], ) -> dict[str, Eligibility]: valid_programs: dict[str, type[PolicyEngineCalulator]] = {} @@ -28,37 +24,34 @@ def calc_pe_eligibility( if len(valid_programs.values()) == 0 or len(screen.household_members.all()) == 0: return {} - data = policy_engine_calculate(pe_input(screen, valid_programs.values()))['result'] + input_data = pe_input(screen, valid_programs.values()) + for Method in pe_engines: + try: + return all_eligibility(Method(input_data), valid_programs, screen) + except Exception as e: + capture_exception(e, level="warning", message="") + capture_message(f"Failed to calculate eligibility with the {Method.method_name} method", level="warning") + + raise Exception("Failed to calculate Policy Engine eligibility") + + +def all_eligibility(method: Sim, valid_programs: dict[str, type[PolicyEngineCalulator]], screen: Screen): all_eligibility: dict[str, Eligibility] = {} - has_non_tax_unit_members = screen.has_members_ouside_of_tax_unit() for name_abbr, Calculator in valid_programs.items(): - calc = Calculator(screen, data) + calc = Calculator(screen, method) e = calc.eligible() e.value = calc.value() - - if Calculator.tax_unit_dependent and has_non_tax_unit_members: - e.multiple_tax_units = True - all_eligibility[name_abbr] = e.to_dict() return all_eligibility -def policy_engine_calculate(data): - response = requests.post( - "https://api.policyengine.org/us/calculate", - json=data - ) - data = response.json() - return data - - def pe_input(screen: Screen, programs: List[type[PolicyEngineCalulator]]): - ''' + """ Generate Policy Engine API request from the list of programs. - ''' + """ raw_input = { "household": { "people": {}, @@ -67,22 +60,14 @@ def pe_input(screen: Screen, programs: List[type[PolicyEngineCalulator]]): "members": [], } }, - "families": { - "family": { - "members": [] - } - }, - "households": { - "household": { - "members": [] - } - }, + "families": {"family": {"members": []}}, + "households": {"household": {"members": []}}, "spm_units": { "spm_unit": { "members": [], } }, - "marital_units": {} + "marital_units": {}, } } members: list[HouseholdMember] = screen.household_members.all() @@ -90,15 +75,15 @@ def pe_input(screen: Screen, programs: List[type[PolicyEngineCalulator]]): for member in members: member_id = str(member.id) - household = raw_input['household'] + household = raw_input["household"] - household['families']['family']['members'].append(member_id) - household['households']['household']['members'].append(member_id) - household['spm_units']['spm_unit']['members'].append(member_id) - household['people'][member_id] = {} + household["families"]["family"]["members"].append(member_id) + household["households"]["household"]["members"].append(member_id) + household["spm_units"]["spm_unit"]["members"].append(member_id) + household["people"][member_id] = {} if member.is_in_tax_unit(): - household['tax_units']['tax_unit']['members'].append(member_id) + household["tax_units"]["tax_unit"]["members"].append(member_id) already_added = set() for member_1, member_2 in relationship_map.items(): @@ -106,14 +91,14 @@ def pe_input(screen: Screen, programs: List[type[PolicyEngineCalulator]]): continue marital_unit = (str(member_1), str(member_2)) - raw_input['household']['marital_units']['-'.join(marital_unit)] = {'members': marital_unit} + raw_input["household"]["marital_units"]["-".join(marital_unit)] = {"members": marital_unit} already_added.add(member_1) already_added.add(member_2) for Program in programs: for Data in Program.pe_inputs + Program.pe_outputs: period = Program.pe_period - if hasattr(Program, 'pe_output_period') and Data in Program.pe_outputs: + if hasattr(Program, "pe_output_period") and Data in Program.pe_outputs: period = Program.pe_output_period if not Data.member: diff --git a/programs/programs/urgent_needs/urgent_need_functions.py b/programs/programs/urgent_needs/urgent_need_functions.py index 0cd44e0a..65c9a0a4 100644 --- a/programs/programs/urgent_needs/urgent_need_functions.py +++ b/programs/programs/urgent_needs/urgent_need_functions.py @@ -103,7 +103,7 @@ def eligible(cls, screen: Screen): ''' Return True if someone is younger than 18 ''' - return screen.num_children(child_relationship=['all']) >= 1 + return screen.num_children(child_relationship=['all']) > 0 class BiaFoodDelivery(UrgentNeedFunction): @@ -216,7 +216,7 @@ def eligible(cls, screen: Screen): is_income_eligible = ( screen.calc_gross_income('yearly', ['all']) < fpl[screen.household_size] ) - is_age_eligible = screen.num_adults(age_max=60) + is_age_eligible = screen.num_adults(age_max=60) > 0 return is_income_eligible or is_age_eligible @@ -258,7 +258,7 @@ def eligible(cls, screen: Screen): ''' Return True if the household has a child aged 0-5 and lives in an eligible county ''' - is_age_eligible = screen.num_children(age_max=5) + is_age_eligible = screen.num_children(age_max=5) > 0 eligible_counties = [ 'Adams County', 'Alamosa County', @@ -286,4 +286,96 @@ def eligible(cls, screen: Screen): 'Weld County', ] - return is_age_eligible and screen.county in eligible_counties \ No newline at end of file + return is_age_eligible and screen.county in eligible_counties + + +class EarlyChildhoodMentalHealthSupport(UrgentNeedFunction): + dependencies = ['age'] + max_age = 5 + + @classmethod + def eligible(cls, screen: Screen): + ''' + Return True if the householdh as a child aged 0-5 + ''' + return screen.num_children(age_max=cls.max_age) > 0 + + +class ParentsOfPreschoolYoungsters(UrgentNeedFunction): + dependencies = ['age', 'county'] + counties = [ + 'Adams County', + 'Alamosa County', + 'Arapahoe County', + 'Costilla County', + 'Crowley County', + 'Denver County', + 'Dolores County', + 'Jefferson County', + 'Montezuma County', + 'Otero County', + 'Pueblo County', + 'Saguache County', + 'Weld County', + ] + min_age = 2 + max_age = 5 + + @classmethod + def eligible(cls, screen: Screen): + ''' + Return True if a child is between 2 and 5 and lives in an eligible county + ''' + age_eligible = screen.num_children(age_min=cls.min_age, age_max=cls.max_age) > 0 + county_eligible = screen.county in cls.counties + + return age_eligible and county_eligible + + +class ParentsAsTeacher(UrgentNeedFunction): + dependencies = ['age', 'county'] + counties = [ + 'Adams County', + 'Alamosa County', + 'Arapahoe County', + 'Bent County', + 'Boulder County', + 'Conejos County', + 'Costilla County', + 'Crowley County', + 'Delta County', + 'Denver County', + 'Dolores County', + 'El Paso County', + 'Fremont County', + 'Gunnison County', + 'Huerfano County', + 'Jefferson County', + 'La Plata County', + 'Larimer County', + 'Las Animas County', + 'Mesa County', + 'Montezuma County', + 'Montrose County', + 'Morgan County', + 'Otero County', + 'Ouray County', + 'Park County', + 'Pueblo County', + 'Routt County', + 'Saguache County', + 'San Miguel County', + 'Teller County', + ] + max_age = 5 + + @classmethod + def eligible(cls, screen: Screen): + ''' + Return True if there is a child younger than 5 and lives in an eligible county + ''' + age_eligible = screen.num_children(age_max=cls.max_age) > 0 + county_eligible = screen.county in cls.counties + + return age_eligible and county_eligible + diff --git a/programs/serializers.py b/programs/serializers.py index 420f1335..841ea9ce 100644 --- a/programs/serializers.py +++ b/programs/serializers.py @@ -1,20 +1,29 @@ from programs.models import Program, UrgentNeed, Navigator from rest_framework import serializers +from translations.serializers import ModelTranslationSerializer, TranslationSerializer class NavigatorAPISerializer(serializers.ModelSerializer): class Meta: model = Navigator - fields = '__all__' + fields = "__all__" class ProgramSerializer(serializers.ModelSerializer): + name = ModelTranslationSerializer() + website_description = ModelTranslationSerializer() + category = ModelTranslationSerializer() + class Meta: model = Program - fields = '__all__' + fields = ("id", "name", "website_description", "category") class UrgentNeedAPISerializer(serializers.ModelSerializer): + name = ModelTranslationSerializer() + website_description = ModelTranslationSerializer() + type = ModelTranslationSerializer() + class Meta: model = UrgentNeed - fields = '__all__' + fields = ("id", "name", "website_description", "type") diff --git a/programs/views.py b/programs/views.py index fbd38a66..8d16cfc5 100644 --- a/programs/views.py +++ b/programs/views.py @@ -4,32 +4,31 @@ from programs.serializers import ProgramSerializer, NavigatorAPISerializer, UrgentNeedAPISerializer -class ProgramViewSet(mixins.RetrieveModelMixin, - viewsets.GenericViewSet): +class ProgramViewSet(mixins.RetrieveModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet): """ API endpoint that allows programs to be viewed or edited. """ - queryset = Program.objects.all() + + queryset = Program.objects.filter(active=True) serializer_class = ProgramSerializer permission_classes = [permissions.IsAuthenticated] - # filterset_fields = ['legal_status_required', 'value_type'] -class NavigatorViewSet(mixins.RetrieveModelMixin, - viewsets.GenericViewSet): +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, - viewsets.GenericViewSet): +class UrgentNeedViewSet(mixins.RetrieveModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet): """ API endpoint that allows programs to be viewed or edited. """ - queryset = UrgentNeed.objects.all() + + queryset = UrgentNeed.objects.filter(active=True) serializer_class = UrgentNeedAPISerializer permission_classes = [permissions.IsAuthenticated] diff --git a/requirements.txt b/requirements.txt index 5cf28784..4b7456e1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ asgiref==3.7.2 attrs==21.4.0 +Babel==2.15.0 beautifulsoup4==4.12.2 black==23.3.0 cachetools==4.2.4 @@ -18,6 +19,8 @@ django-parler-rest==2.2 django-phonenumber-field==6.3.0 django-sesame==3.1 djangorestframework==3.14.0 +django-import-export==3.3.8 +django-unfold==0.22.0 drf-yasg==1.21.7 fonttools==4.51.0 geographiclib==1.52 diff --git a/screener/admin.py b/screener/admin.py index e159b13a..dd553bd2 100644 --- a/screener/admin.py +++ b/screener/admin.py @@ -1,4 +1,6 @@ from django.contrib import admin +from unfold.admin import ModelAdmin + from .models import ( Message, Screen, @@ -6,11 +8,18 @@ ) -class screenAdmin(admin.ModelAdmin): +class screenAdmin(ModelAdmin): search_fields = ('id',) -admin.site.register(Screen, screenAdmin) -admin.site.register(Message) -admin.site.register(IncomeStream) +class CustomMessageAdmin(ModelAdmin): + pass + +class CustomIncomeStreamAdmin(ModelAdmin): + pass + + +admin.site.register(Screen, screenAdmin) +admin.site.register(Message, CustomMessageAdmin) +admin.site.register(IncomeStream, CustomIncomeStreamAdmin) diff --git a/screener/models.py b/screener/models.py index 6ed440a5..1cab30f1 100644 --- a/screener/models.py +++ b/screener/models.py @@ -257,7 +257,8 @@ def has_benefit(self, name_abbreviated): 'rag': self.has_rag, 'cowap': self.has_cowap, 'ubp': self.has_ubp, - 'medicaid': self.has_medicaid or self.has_medicaid_hi, + 'co_medicaid': self.has_medicaid or self.has_medicaid_hi, + 'nc_medicaid': self.has_medicaid or self.has_medicaid_hi, 'medicare': self.has_medicare_hi, 'chp': self.has_chp or self.has_chp_hi, 'va': self.has_va, @@ -596,6 +597,8 @@ def insurance_map(self): 'private': self.private, 'chp': self.chp, 'medicaid': self.medicaid, + 'nc_medicaid': self.medicaid, + 'co_medicaid': self.medicaid, 'medicare': self.medicare, 'emergency_medicaid': self.emergency_medicaid, 'family_planning': self.family_planning, diff --git a/screener/serializers.py b/screener/serializers.py index f8208311..d8f4bc37 100644 --- a/screener/serializers.py +++ b/screener/serializers.py @@ -1,6 +1,7 @@ from screener.models import Screen, HouseholdMember, IncomeStream, Expense, Message, Insurance from authentication.serializers import UserOffersSerializer from rest_framework import serializers +from translations.serializers import TranslationSerializer class MessageSerializer(serializers.ModelSerializer): @@ -195,11 +196,6 @@ def update(self, instance, validated_data): return instance -class TranslationSerializer(serializers.Serializer): - default_message = serializers.CharField() - label = serializers.CharField() - - class NavigatorSerializer(serializers.Serializer): name = TranslationSerializer() phone_number = serializers.CharField() diff --git a/screener/views.py b/screener/views.py index 31221d68..894f73cf 100644 --- a/screener/views.py +++ b/screener/views.py @@ -386,6 +386,9 @@ def urgent_need_results(screen): screen, missing_dependencies ), 'child_first': urgent_need_functions.ChildFirst.calc(screen, missing_dependencies), + 'ecmh': urgent_need_functions.EarlyChildhoodMentalHealthSupport.calc(screen, missing_dependencies), + 'hippy': urgent_need_functions.ParentsOfPreschoolYoungsters.calc(screen, missing_dependencies), + 'pat': urgent_need_functions.ParentsAsTeacher.calc(screen, missing_dependencies), } list_of_needs = [] diff --git a/templates/admin/programs/change_list.html b/templates/admin/programs/change_list.html new file mode 100644 index 00000000..a869d9c7 --- /dev/null +++ b/templates/admin/programs/change_list.html @@ -0,0 +1,70 @@ +{% extends "admin/change_list.html" %} {% block extrahead %} + + + +{% endblock %} diff --git a/translations/admin.py b/translations/admin.py index e9bcd3a5..569aca08 100644 --- a/translations/admin.py +++ b/translations/admin.py @@ -1,10 +1,27 @@ from django.contrib import admin +from django.urls import reverse_lazy +from django.utils.html import format_html from parler.admin import TranslatableAdmin +from unfold.admin import ModelAdmin from .models import Translation -class TranslationAdmin(TranslatableAdmin): +class TranslationAdmin(ModelAdmin, TranslatableAdmin): search_fields = ('label',) + list_display = ['label', 'used_model', 'no_auto', + 'edited', 'active', 'go_to'] + def used_model(self, obj): + model_name = obj.used_by['model_name'] + return model_name.capitalize() -admin.site.register(Translation, TranslatableAdmin) + used_model.short_description = 'Used by (Model)' + + def go_to(self, obj): + url = reverse_lazy('translation_admin_url', args=[obj.pk]) + return format_html('Label', url) + + go_to.short_description = 'Translate:' + + +admin.site.register(Translation, TranslationAdmin) diff --git a/translations/bulk_import_translations.py b/translations/bulk_import_translations.py index 65b16bc1..0b6a6d78 100644 --- a/translations/bulk_import_translations.py +++ b/translations/bulk_import_translations.py @@ -43,10 +43,10 @@ def bulk_add(translations): if ref[0] == 'programs_program': try: obj = Program.objects.get(external_name=ref[1]) - obj.active = True except ObjectDoesNotExist: obj = Program.objects.new_program(ref[1]) obj.external_name = ref[1] + obj.active = ref[3] if len(ref) == 4 else False obj.save() elif ref[0] == 'programs_navigator': try: @@ -58,10 +58,10 @@ def bulk_add(translations): elif ref[0] == 'programs_urgentneed': try: obj = UrgentNeed.objects.get(external_name=ref[1]) - obj.active = True except ObjectDoesNotExist: obj = UrgentNeed.objects.new_urgent_need(ref[1], None) obj.external_name = ref[1] + obj.active = ref[3] if len(ref) == 4 else False obj.save() elif ref[0] == 'programs_document': try: diff --git a/translations/models.py b/translations/models.py index a94e3900..d3be47de 100644 --- a/translations/models.py +++ b/translations/models.py @@ -3,25 +3,30 @@ from django.conf import settings +BLANK_TRANSLATION_PLACEHOLDER = '[PLACEHOLDER]' + + class TranslationManager(TranslatableManager): use_in_migrations = True - def add_translation(self, label, default_message, active=True, no_auto=False): + def add_translation(self, label, default_message=BLANK_TRANSLATION_PLACEHOLDER, active=True, no_auto=False): default_lang = settings.LANGUAGE_CODE - parent = self.get_or_create(label=label, defaults={'active': active, 'no_auto': no_auto})[0] + parent = self.get_or_create(label=label, defaults={ + "active": active, "no_auto": no_auto})[0] if parent.active != active or parent.active != no_auto: parent.active = active parent.no_auto = no_auto parent.save() - parent.create_translation(default_lang, text=default_message, edited=True) + parent.create_translation( + default_lang, text=default_message, edited=True) return parent def edit_translation(self, label, lang, translation, manual=True): parent = self.language(lang).get(label=label) lang_trans = parent.get_lang(lang) - is_edited = lang_trans is not None and lang_trans.edited is True and lang_trans.text != '' + is_edited = lang_trans is not None and lang_trans.edited is True and lang_trans.text != "" if manual is False and (is_edited or parent.no_auto): return parent @@ -31,10 +36,11 @@ def edit_translation(self, label, lang, translation, manual=True): return parent def edit_translation_by_id(self, id, lang, translation, manual=True): - parent = self.prefetch_related('translations').language(lang).get(pk=id) + parent = self.prefetch_related( + "translations").language(lang).get(pk=id) lang_trans = parent.get_lang(lang) - is_edited = lang_trans is not None and lang_trans.edited is True and lang_trans.text != '' + is_edited = lang_trans is not None and lang_trans.edited is True and lang_trans.text != "" if manual is False and (is_edited or parent.no_auto): return parent @@ -43,8 +49,8 @@ def edit_translation_by_id(self, id, lang, translation, manual=True): parent.save() return parent - def all_translations(self, langs=[lang['code'] for lang in settings.PARLER_LANGUAGES[None]]): - translations = self.prefetch_related('translations') + def all_translations(self, langs=[lang["code"] for lang in settings.PARLER_LANGUAGES[None]]): + translations = self.prefetch_related("translations") translations_dict = {} for lang in langs: lang_translations = {} @@ -57,7 +63,7 @@ def all_translations(self, langs=[lang['code'] for lang in settings.PARLER_LANGU def export_translations(self): all_langs = settings.PARLER_LANGUAGES[None] - translations = self.prefetch_related('translations') + translations = self.prefetch_related("translations") translations_export = {} for translation in translations: @@ -67,59 +73,108 @@ def export_translations(self): continue translations_export[translation.label] = { - 'active': translation.active, - 'no_auto': translation.no_auto, - 'langs': {}, - 'reference': reference, + "active": translation.active, + "no_auto": translation.no_auto, + "langs": {}, + "reference": reference, } for lang in all_langs: - translation.set_current_language(lang['code']) - translations_export[translation.label]['langs'][lang['code']] = (translation.text, translation.edited) + translation.set_current_language(lang["code"]) + translations_export[translation.label]["langs"][lang["code"]] = ( + translation.text, translation.edited) return translations_export class Translation(TranslatableModel): translations = TranslatedFields( - text=models.TextField(null=True, blank=True), - edited=models.BooleanField(default=False, null=False) + text=models.TextField(null=True, blank=True), edited=models.BooleanField(default=False, null=False) ) active = models.BooleanField(default=True, null=False) no_auto = models.BooleanField(default=False, null=False) - label = models.CharField(max_length=128, null=False, blank=False, unique=True) + label = models.CharField(max_length=128, null=False, + blank=False, unique=True) objects = TranslationManager() - def get_lang(self, lang): - return self.translations.filter(language_code=lang).first() + """ + This method checks if the current Translation object is referenced by any related models + such as Program, Navigator, UrgentNeed, or Document. It iterates through all reverse + relationships of the Translation model and determines if any related object exists. + If a related object is found and `label_unpack` is True, it returns the reverse relationship. + Otherwise, it returns details of the related object including the table name, external name, + related name, and active status. If no related object is found, it returns False. + """ - def in_program(self): + def find_used_model(self, label_unpack=False): + has_relationship = False # determine if a translation is refrenced by either a program, navigator, urgent_need, or document # https://stackoverflow.com/questions/54711671/django-how-to-determine-if-an-object-is-referenced-by-any-other-object - has_relationship = False for reverse in (f for f in self._meta.get_fields() if f.auto_created and not f.concrete): - if reverse.related_name == 'translations': + if reverse.related_name == "translations": continue + name = reverse.get_accessor_name() has_reverse_other = getattr(self, name).count() if has_reverse_other: + if label_unpack: + return reverse try: active = getattr(self, reverse.related_name).first().active except AttributeError: active = True - if not active: - has_relationship = True - continue - - external_name = getattr(self, reverse.related_name).first().external_name - table = getattr(self, reverse.related_name).first()._meta.db_table + external_name = getattr( + self, reverse.related_name).first().external_name + table = getattr( + self, reverse.related_name).first()._meta.db_table if external_name: - return (table, external_name, reverse.related_name) + return (table, external_name, reverse.related_name, active) has_relationship = True return has_relationship + """ + This property field stores information about the first model instance that uses + this Translation object. It checks for related models and, if a relationship + is found, retrieves the instance's ID, model name, field name, and display name (either + an external name or an abbreviated name). If no relationship is found, it returns + default values indicating the translation is unassigned. + """ + @property + def used_by(self): + reverse = self.find_used_model(label_unpack=True) + if reverse: + instance = getattr(self, reverse.get_accessor_name()).first() + model_name = reverse.related_model._meta.model_name + field_name = reverse.field.name + external_name = getattr(instance, 'external_name', None) + abbreviated_name = getattr(instance, 'abbreviated_name', None) + display_name = external_name if external_name else abbreviated_name + return { + 'id': instance.id, + 'model_name': model_name, + 'field_name': field_name, + 'display_name': display_name + } + return { + 'id': None, + 'model_name': 'unassigned', + 'field_name': None, + 'display_name': None + } + + def get_lang(self, lang): + return self.translations.filter(language_code=lang).first() + + def in_program(self): + has_relationship = self.find_used_model() + return has_relationship + + @property + def default_message(self): + return self.get_lang(settings.LANGUAGE_CODE).text + def __str__(self): return self.label diff --git a/translations/serializers.py b/translations/serializers.py new file mode 100644 index 00000000..f2d98364 --- /dev/null +++ b/translations/serializers.py @@ -0,0 +1,17 @@ +from parler_rest.serializers import TranslatableModelSerializer +from parler_rest.fields import TranslatedFieldsField +from rest_framework import serializers +from translations.models import Translation + + +class ModelTranslationSerializer(TranslatableModelSerializer): + default_message = serializers.CharField(read_only=True) + + class Meta: + model = Translation + fields = ("label", "default_message") + + +class TranslationSerializer(serializers.Serializer): + default_message = serializers.CharField() + label = serializers.CharField() diff --git a/translations/static/css/bulma_switch.css b/translations/static/css/bulma_switch.css new file mode 100644 index 00000000..5ec9dcad --- /dev/null +++ b/translations/static/css/bulma_switch.css @@ -0,0 +1 @@ +.switch{-webkit-touch-callout:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.switch{cursor:pointer;display:inline-flex;align-items:center;position:relative;margin-right:0.5em}.switch+.switch:last-child{margin-right:0}.switch input[type=checkbox]{position:absolute;left:0;opacity:0;outline:none;z-index:-1}.switch input[type=checkbox]+.check{display:flex;align-items:center;flex-shrink:0;width:2.75em;height:1.575em;padding:.2em;background:#b5b5b5;border-radius:4px;transition:background 150ms ease-out,box-shadow 150ms ease-out}.switch input[type=checkbox]+.check.is-white-passive,.switch input[type=checkbox]+.check:hover{background:#fff}.switch input[type=checkbox]+.check.input[type=checkbox]+.switch input[type=checkbox]+.check.check{background:'pink'}.switch input[type=checkbox]+.check.is-black-passive,.switch input[type=checkbox]+.check:hover{background:#0a0a0a}.switch input[type=checkbox]+.check.input[type=checkbox]+.switch input[type=checkbox]+.check.check{background:'pink'}.switch input[type=checkbox]+.check.is-light-passive,.switch input[type=checkbox]+.check:hover{background:#f5f5f5}.switch input[type=checkbox]+.check.input[type=checkbox]+.switch input[type=checkbox]+.check.check{background:'pink'}.switch input[type=checkbox]+.check.is-dark-passive,.switch input[type=checkbox]+.check:hover{background:#363636}.switch input[type=checkbox]+.check.input[type=checkbox]+.switch input[type=checkbox]+.check.check{background:'pink'}.switch input[type=checkbox]+.check.is-primary-passive,.switch input[type=checkbox]+.check:hover{background:#00d1b2}.switch input[type=checkbox]+.check.input[type=checkbox]+.switch input[type=checkbox]+.check.check{background:'pink'}.switch input[type=checkbox]+.check.is-link-passive,.switch input[type=checkbox]+.check:hover{background:#485fc7}.switch input[type=checkbox]+.check.input[type=checkbox]+.switch input[type=checkbox]+.check.check{background:'pink'}.switch input[type=checkbox]+.check.is-info-passive,.switch input[type=checkbox]+.check:hover{background:#3e8ed0}.switch input[type=checkbox]+.check.input[type=checkbox]+.switch input[type=checkbox]+.check.check{background:'pink'}.switch input[type=checkbox]+.check.is-success-passive,.switch input[type=checkbox]+.check:hover{background:#48c78e}.switch input[type=checkbox]+.check.input[type=checkbox]+.switch input[type=checkbox]+.check.check{background:'pink'}.switch input[type=checkbox]+.check.is-warning-passive,.switch input[type=checkbox]+.check:hover{background:#ffe08a}.switch input[type=checkbox]+.check.input[type=checkbox]+.switch input[type=checkbox]+.check.check{background:'pink'}.switch input[type=checkbox]+.check.is-danger-passive,.switch input[type=checkbox]+.check:hover{background:#f14668}.switch input[type=checkbox]+.check.input[type=checkbox]+.switch input[type=checkbox]+.check.check{background:'pink'}.switch input[type=checkbox]+.check:before{content:"";display:block;border-radius:4px;width:1.175em;height:1.175em;background:#f5f5f5;transition:transform 150ms ease-out;will-change:transform;transform-origin:left}.switch input[type=checkbox]+.check.is-elastic:before{transform:scaleX(1.5);border-radius:4px}.switch input[type=checkbox]:checked+.check{background:#00d1b2}.switch input[type=checkbox]:checked+.check.is-white{background:#fff}.switch input[type=checkbox]:checked+.check.is-black{background:#0a0a0a}.switch input[type=checkbox]:checked+.check.is-light{background:#f5f5f5}.switch input[type=checkbox]:checked+.check.is-dark{background:#363636}.switch input[type=checkbox]:checked+.check.is-primary{background:#00d1b2}.switch input[type=checkbox]:checked+.check.is-link{background:#485fc7}.switch input[type=checkbox]:checked+.check.is-info{background:#3e8ed0}.switch input[type=checkbox]:checked+.check.is-success{background:#48c78e}.switch input[type=checkbox]:checked+.check.is-warning{background:#ffe08a}.switch input[type=checkbox]:checked+.check.is-danger{background:#f14668}.switch input[type=checkbox]:checked+.check:before{transform:translate3d(100%, 0, 0)}.switch input[type=checkbox]:checked+.check.is-elastic:before{transform:translate3d(50%, 0, 0) scaleX(1.5)}.switch input[type=checkbox]:focus,.switch input[type=checkbox]:active{outline:none}.switch .control-label{padding-left:0.5em}.switch:hover input[type=checkbox]+.check{background:rgba(181,181,181,0.9)}.switch:hover input[type=checkbox]+.check.is-white-passive{background:rgba(255,255,255,0.9)}.switch:hover input[type=checkbox]+.check.is-black-passive{background:rgba(10,10,10,0.9)}.switch:hover input[type=checkbox]+.check.is-light-passive{background:rgba(245,245,245,0.9)}.switch:hover input[type=checkbox]+.check.is-dark-passive{background:rgba(54,54,54,0.9)}.switch:hover input[type=checkbox]+.check.is-primary-passive{background:rgba(0,209,178,0.9)}.switch:hover input[type=checkbox]+.check.is-link-passive{background:rgba(72,95,199,0.9)}.switch:hover input[type=checkbox]+.check.is-info-passive{background:rgba(62,142,208,0.9)}.switch:hover input[type=checkbox]+.check.is-success-passive{background:rgba(72,199,142,0.9)}.switch:hover input[type=checkbox]+.check.is-warning-passive{background:rgba(255,224,138,0.9)}.switch:hover input[type=checkbox]+.check.is-danger-passive{background:rgba(241,70,104,0.9)}.switch:hover input[type=checkbox]:checked+.check{background:rgba(0,209,178,0.9)}.switch:hover input[type=checkbox]:checked+.check.is-white{background:rgba(255,255,255,0.9)}.switch:hover input[type=checkbox]:checked+.check.is-black{background:rgba(10,10,10,0.9)}.switch:hover input[type=checkbox]:checked+.check.is-light{background:rgba(245,245,245,0.9)}.switch:hover input[type=checkbox]:checked+.check.is-dark{background:rgba(54,54,54,0.9)}.switch:hover input[type=checkbox]:checked+.check.is-primary{background:rgba(0,209,178,0.9)}.switch:hover input[type=checkbox]:checked+.check.is-link{background:rgba(72,95,199,0.9)}.switch:hover input[type=checkbox]:checked+.check.is-info{background:rgba(62,142,208,0.9)}.switch:hover input[type=checkbox]:checked+.check.is-success{background:rgba(72,199,142,0.9)}.switch:hover input[type=checkbox]:checked+.check.is-warning{background:rgba(255,224,138,0.9)}.switch:hover input[type=checkbox]:checked+.check.is-danger{background:rgba(241,70,104,0.9)}.switch.is-rounded input[type=checkbox]+.check{border-radius:9999px}.switch.is-rounded input[type=checkbox]+.check:before{border-radius:9999px}.switch.is-rounded input[type=checkbox].is-elastic:before{transform:scaleX(1.5);border-radius:9999px}.switch.is-outlined input[type=checkbox]+.check{background:transparent;border:0.1rem solid #b5b5b5;padding:.1em}.switch.is-outlined input[type=checkbox]+.check.is-white-passive{border:0.1rem solid rgba(255,255,255,0.9)}.switch.is-outlined input[type=checkbox]+.check.is-white-passive:before{background:#fff}.switch.is-outlined input[type=checkbox]+.check.is-white-passive:hover{border-color:rgba(255,255,255,0.9)}.switch.is-outlined input[type=checkbox]+.check.is-black-passive{border:0.1rem solid rgba(10,10,10,0.9)}.switch.is-outlined input[type=checkbox]+.check.is-black-passive:before{background:#0a0a0a}.switch.is-outlined input[type=checkbox]+.check.is-black-passive:hover{border-color:rgba(10,10,10,0.9)}.switch.is-outlined input[type=checkbox]+.check.is-light-passive{border:0.1rem solid rgba(245,245,245,0.9)}.switch.is-outlined input[type=checkbox]+.check.is-light-passive:before{background:#f5f5f5}.switch.is-outlined input[type=checkbox]+.check.is-light-passive:hover{border-color:rgba(245,245,245,0.9)}.switch.is-outlined input[type=checkbox]+.check.is-dark-passive{border:0.1rem solid rgba(54,54,54,0.9)}.switch.is-outlined input[type=checkbox]+.check.is-dark-passive:before{background:#363636}.switch.is-outlined input[type=checkbox]+.check.is-dark-passive:hover{border-color:rgba(54,54,54,0.9)}.switch.is-outlined input[type=checkbox]+.check.is-primary-passive{border:0.1rem solid rgba(0,209,178,0.9)}.switch.is-outlined input[type=checkbox]+.check.is-primary-passive:before{background:#00d1b2}.switch.is-outlined input[type=checkbox]+.check.is-primary-passive:hover{border-color:rgba(0,209,178,0.9)}.switch.is-outlined input[type=checkbox]+.check.is-link-passive{border:0.1rem solid rgba(72,95,199,0.9)}.switch.is-outlined input[type=checkbox]+.check.is-link-passive:before{background:#485fc7}.switch.is-outlined input[type=checkbox]+.check.is-link-passive:hover{border-color:rgba(72,95,199,0.9)}.switch.is-outlined input[type=checkbox]+.check.is-info-passive{border:0.1rem solid rgba(62,142,208,0.9)}.switch.is-outlined input[type=checkbox]+.check.is-info-passive:before{background:#3e8ed0}.switch.is-outlined input[type=checkbox]+.check.is-info-passive:hover{border-color:rgba(62,142,208,0.9)}.switch.is-outlined input[type=checkbox]+.check.is-success-passive{border:0.1rem solid rgba(72,199,142,0.9)}.switch.is-outlined input[type=checkbox]+.check.is-success-passive:before{background:#48c78e}.switch.is-outlined input[type=checkbox]+.check.is-success-passive:hover{border-color:rgba(72,199,142,0.9)}.switch.is-outlined input[type=checkbox]+.check.is-warning-passive{border:0.1rem solid rgba(255,224,138,0.9)}.switch.is-outlined input[type=checkbox]+.check.is-warning-passive:before{background:#ffe08a}.switch.is-outlined input[type=checkbox]+.check.is-warning-passive:hover{border-color:rgba(255,224,138,0.9)}.switch.is-outlined input[type=checkbox]+.check.is-danger-passive{border:0.1rem solid rgba(241,70,104,0.9)}.switch.is-outlined input[type=checkbox]+.check.is-danger-passive:before{background:#f14668}.switch.is-outlined input[type=checkbox]+.check.is-danger-passive:hover{border-color:rgba(241,70,104,0.9)}.switch.is-outlined input[type=checkbox]+.check:before{background:#b5b5b5}.switch.is-outlined input[type=checkbox]:checked+.check{border-color:#00d1b2}.switch.is-outlined input[type=checkbox]:checked+.check.is-white{background:transparent;border-color:#fff}.switch.is-outlined input[type=checkbox]:checked+.check.is-white:before{background:#fff}.switch.is-outlined input[type=checkbox]:checked+.check.is-black{background:transparent;border-color:#0a0a0a}.switch.is-outlined input[type=checkbox]:checked+.check.is-black:before{background:#0a0a0a}.switch.is-outlined input[type=checkbox]:checked+.check.is-light{background:transparent;border-color:#f5f5f5}.switch.is-outlined input[type=checkbox]:checked+.check.is-light:before{background:#f5f5f5}.switch.is-outlined input[type=checkbox]:checked+.check.is-dark{background:transparent;border-color:#363636}.switch.is-outlined input[type=checkbox]:checked+.check.is-dark:before{background:#363636}.switch.is-outlined input[type=checkbox]:checked+.check.is-primary{background:transparent;border-color:#00d1b2}.switch.is-outlined input[type=checkbox]:checked+.check.is-primary:before{background:#00d1b2}.switch.is-outlined input[type=checkbox]:checked+.check.is-link{background:transparent;border-color:#485fc7}.switch.is-outlined input[type=checkbox]:checked+.check.is-link:before{background:#485fc7}.switch.is-outlined input[type=checkbox]:checked+.check.is-info{background:transparent;border-color:#3e8ed0}.switch.is-outlined input[type=checkbox]:checked+.check.is-info:before{background:#3e8ed0}.switch.is-outlined input[type=checkbox]:checked+.check.is-success{background:transparent;border-color:#48c78e}.switch.is-outlined input[type=checkbox]:checked+.check.is-success:before{background:#48c78e}.switch.is-outlined input[type=checkbox]:checked+.check.is-warning{background:transparent;border-color:#ffe08a}.switch.is-outlined input[type=checkbox]:checked+.check.is-warning:before{background:#ffe08a}.switch.is-outlined input[type=checkbox]:checked+.check.is-danger{background:transparent;border-color:#f14668}.switch.is-outlined input[type=checkbox]:checked+.check.is-danger:before{background:#f14668}.switch.is-outlined input[type=checkbox]:checked+.check:before{background:#00d1b2}.switch.is-outlined:hover input[type=checkbox]+.check{background:transparent;border-color:rgba(181,181,181,0.9)}.switch.is-outlined:hover input[type=checkbox]:checked+.check{background:transparent;border-color:rgba(0,209,178,0.9)}.switch.is-outlined:hover input[type=checkbox]:checked+.check.is-white{border-color:rgba(255,255,255,0.9)}.switch.is-outlined:hover input[type=checkbox]:checked+.check.is-black{border-color:rgba(10,10,10,0.9)}.switch.is-outlined:hover input[type=checkbox]:checked+.check.is-light{border-color:rgba(245,245,245,0.9)}.switch.is-outlined:hover input[type=checkbox]:checked+.check.is-dark{border-color:rgba(54,54,54,0.9)}.switch.is-outlined:hover input[type=checkbox]:checked+.check.is-primary{border-color:rgba(0,209,178,0.9)}.switch.is-outlined:hover input[type=checkbox]:checked+.check.is-link{border-color:rgba(72,95,199,0.9)}.switch.is-outlined:hover input[type=checkbox]:checked+.check.is-info{border-color:rgba(62,142,208,0.9)}.switch.is-outlined:hover input[type=checkbox]:checked+.check.is-success{border-color:rgba(72,199,142,0.9)}.switch.is-outlined:hover input[type=checkbox]:checked+.check.is-warning{border-color:rgba(255,224,138,0.9)}.switch.is-outlined:hover input[type=checkbox]:checked+.check.is-danger{border-color:rgba(241,70,104,0.9)}.switch.is-small{border-radius:2px;font-size:.75rem}.switch.is-medium{font-size:1.25rem}.switch.is-large{font-size:1.5rem}.switch[disabled]{opacity:0.5;cursor:not-allowed;color:#7a7a7a} \ No newline at end of file diff --git a/translations/static/css/styles.css b/translations/static/css/styles.css new file mode 100644 index 00000000..abaaa0b4 --- /dev/null +++ b/translations/static/css/styles.css @@ -0,0 +1,144 @@ +@import url('https://fonts.google.com/materialsymbols?family=Material+Symbols:opsz,wght@48,400'); + +/* Global styling */ +* { + font-family: Inter, sans-serif; +} + +.box { + box-shadow: none; + border-radius: 0; + border: 1.5px solid rgba(230, 230, 230, 0.6); +} + +html[data-theme="dark"] .box { + border-color: rgba(56, 56, 56, 0.5); /* Change this to the color you want */ +} + +.table tbody tr td:last-child { + text-align: center; +} + +.columns:not(:last-child) { + margin-bottom: 0; +} + +/* Table th tooltip styling */ +th { + position: relative; + cursor: pointer; + } + + th[data-tooltip]::after { + content: attr(data-tooltip); + visibility: hidden; + width: 300px; + background-color: #555; + color: #fff; + text-align: center; + border-radius: 6px; + padding: 10px; + position: absolute; + z-index: 1; + bottom: 150%; + left: 50%; + transform: translateX(-50%); + opacity: 0; + transition: opacity 0.3s; + } + + th[data-tooltip]:hover::after { + visibility: visible; + opacity: 1; + } + + th[data-tooltip]:hover::after { + left: auto; + right: 0; + transform: translateX(10%); + } + + th[data-tooltip]:first-child:hover::after { + left: 0; + right: auto; + transform: translateX(-10%); + } + +/* Sidebar styling */ +.sidebar-nav { + position: fixed; + top: 0; + padding: 1.5rem; + height: 100vh; + overflow-y: auto; +} + +.sidebar-header { + display: flex; + align-items: center; + margin-bottom: 1.5rem; +} + +.sidebar-item.is-active { + color: #fff; + background-color: rgba(88, 88, 141, 0.5); +} + +/* Sidebar menu icon styling */ +.text-icon { + display: flex; + align-items: center; +} + +.text-icon span:first-child { + margin-right: 5px; +} + +.sidebar-item .text-icon span:first-child { + margin-right: 15px; +} + +.theme-switch { + margin-top: auto; + margin-bottom: 2rem; +} + +.theme-switch-icon { + height: 20; + width: 20px; + margin: 0 10px; +} + +/* Edit Dropdown button styling */ +.dropdown-content { + text-align: left; + position: absolute; + right: 95px; + bottom: 100%; + transform: translateY(-40px); /* Adjust the vertical spacing */ + display: none; +} + +.dropdown.is-active .dropdown-content { + display: block; +} + +.dropdown.is-top .dropdown-content { + bottom: auto; + top: 100%; + transform: translateY(0); /* Adjust the vertical spacing */ +} + +/* Pagination styling */ +.pagination-link.is-current { + background-color: rgba(88, 88, 141, 0.5); +} + +.new-actions { + display: flex; flex-direction: column; align-items: flex-start; +} + +/* Forms */ +.label-form-input { + width: 600px; +} \ No newline at end of file diff --git a/translations/static/js/base.js b/translations/static/js/base.js new file mode 100644 index 00000000..931fb805 --- /dev/null +++ b/translations/static/js/base.js @@ -0,0 +1,122 @@ +// For handing the sorting of tables +function initializeTableSorting() { + let table = document.querySelector(".table"); + let headers = table.querySelectorAll("th"); + let currentSortColumn = null; + let sortDirection = "asc"; + + headers.forEach(function (header, index) { + header.addEventListener("click", function () { + sortTable(index); + }); + }); + + function sortTable(columnIndex) { + let rows = Array.from(table.querySelectorAll("tbody tr")); + if (columnIndex === currentSortColumn) { + sortDirection = sortDirection === "asc" ? "desc" : "asc"; + } else { + currentSortColumn = columnIndex; + sortDirection = "asc"; + } + + rows.sort(function (a, b) { + let cellA = a.querySelectorAll("td")[columnIndex].textContent.trim(); + let cellB = b.querySelectorAll("td")[columnIndex].textContent.trim(); + + let valueA = isNaN(cellA) ? cellA.toLowerCase() : parseInt(cellA, 10); + let valueB = isNaN(cellB) ? cellB.toLowerCase() : parseInt(cellB, 10); + + if (valueA < valueB) return sortDirection === "asc" ? -1 : 1; + if (valueA > valueB) return sortDirection === "asc" ? 1 : -1; + return 0; + }); + + let tbody = table.querySelector("tbody"); + rows.forEach(function (row) { + tbody.appendChild(row); + }); + } +} + +// For handling the sidebar menu +function initializeSidebarMenu() { + let menuItems = document.querySelectorAll(".menu-list .sidebar-item"); + let currentUrl = window.location.href; + + menuItems.forEach(function (menuItem) { + let linkUrl = menuItem.href; + + if (linkUrl === currentUrl) { + menuItem.classList.add("is-active"); + } + + menuItem.addEventListener("click", function (event) { + menuItems.forEach(function (item) { + item.classList.remove("is-active"); + }); + + event.currentTarget.classList.add("is-active"); + }); + }); +} + +// For handling the dropdown menu in the tables +function initializeDropdowns() { + let dropdowns = document.querySelectorAll("#" + tableId + " .dropdown"); + dropdowns.forEach(function (dropdown, index) { + let dropdownButton = dropdown.querySelector(".button"); + let dropdownSpan = dropdown.querySelector(".material-symbols-outlined"); + let dropdownContent = dropdown.querySelector(".dropdown-content"); + + dropdownButton.addEventListener("click", function (event) { + event.preventDefault(); + dropdown.classList.toggle("is-active"); + if (dropdown.classList.contains("is-active")) { + dropdownSpan.textContent = "expand_less"; + checkDropdownPosition(dropdown, dropdownContent); + } else { + dropdownSpan.textContent = "expand_more"; + dropdown.classList.remove("is-top"); + } + }); + + // Close the dropdown if the user clicks outside of it + document.addEventListener("click", function (event) { + let isClickInside = dropdown.contains(event.target); + if (!isClickInside) { + dropdown.classList.remove("is-active"); + dropdown.classList.remove("is-top"); + dropdownSpan.textContent = "expand_more"; + } + }); + + // Close dropdowns when the page is scrolled + window.addEventListener("scroll", function () { + dropdown.classList.remove("is-active"); + dropdown.classList.remove("is-top"); + dropdownSpan.textContent = "expand_more"; + }); + }); +} + +function checkDropdownPosition(dropdown, dropdownContent) { + let dropdownRect = dropdown.getBoundingClientRect(); + let dropdownContentRect = dropdownContent.getBoundingClientRect(); + let viewportHeight = window.innerHeight; + + if (dropdownRect.bottom + dropdownContentRect.height > viewportHeight) { + dropdown.classList.remove("is-top"); + } else { + dropdown.classList.add("is-top"); + } +} + +function initializeAll() { + initializeTableSorting(); + initializeSidebarMenu(); + initializeDropdowns(); +} + +document.addEventListener("DOMContentLoaded", initializeAll); +document.body.addEventListener("htmx:afterSwap", initializeAll); diff --git a/translations/static/js/theme.js b/translations/static/js/theme.js new file mode 100644 index 00000000..692d7cbf --- /dev/null +++ b/translations/static/js/theme.js @@ -0,0 +1,27 @@ +(() => { + "use strict"; + const prefersDarkMode = window.matchMedia( + "(prefers-color-scheme: dark)" + ).matches; + const defaultTheme = prefersDarkMode ? "dark" : "light"; + const preferredTheme = localStorage.getItem("theme"); + const toggleDarkMode = document.querySelector("#bd-theme"); + + if (!preferredTheme) { + localStorage.setItem("theme", defaultTheme); + } + + document.documentElement.setAttribute( + "data-theme", + preferredTheme || defaultTheme + ); + + toggleDarkMode.checked = preferredTheme === "dark" ? true : false; + + toggleDarkMode.addEventListener("change", function () { + const isDarkTheme = localStorage.getItem("theme") === "dark"; + const newTheme = isDarkTheme ? "light" : "dark"; + localStorage.setItem("theme", newTheme); + document.documentElement.setAttribute("data-theme", newTheme); + }); + })(); \ No newline at end of file diff --git a/translations/templates/base.html b/translations/templates/base.html index 8b44ec50..3db3ac25 100644 --- a/translations/templates/base.html +++ b/translations/templates/base.html @@ -1,21 +1,122 @@ +{% load static %} - - - - - Translations Admin - + + + + + Translations Admin + + + + + - - - {% block content %} {% endblock content %} - - + +
+ +
+
+
+
{% block content %} {% endblock content %}
+
+
+
+ + + + diff --git a/translations/templates/documents/document.html b/translations/templates/documents/document.html index 0760946c..dbedb26d 100644 --- a/translations/templates/documents/document.html +++ b/translations/templates/documents/document.html @@ -1,15 +1,33 @@ {% extends "base.html" %}{% block content %} -

{{ document.external_name }}

-
- - Django Admin - -
-
- Text +
+

Document Actions

+
+
+
+ + + + + + + + + + + + + +
External NameActions (add)
{{ document.external_name }} + +
{% endblock content %} diff --git a/translations/templates/documents/filter.html b/translations/templates/documents/filter.html index e0a171f8..f40a066b 100644 --- a/translations/templates/documents/filter.html +++ b/translations/templates/documents/filter.html @@ -1,9 +1,20 @@ +{% load static %}
- - - +
+ + + +
diff --git a/translations/templates/documents/list.html b/translations/templates/documents/list.html index f3d6ca6d..2f58bc49 100644 --- a/translations/templates/documents/list.html +++ b/translations/templates/documents/list.html @@ -1,8 +1,74 @@ - +{% load static %} +
+ + + + + + + + + + + {% for document in page_obj %} + + + + + + + {% empty %} + + + + {% endfor %} + + + + + + +
IDExternal NameExternal Name LabelActions
{{ document.id }}{{ document.external_name }}{{ document.name }} + +
No results
Total: {{ page_obj.paginator.count }}
+ + {% include "../pagination.html" %} + + +
diff --git a/translations/templates/documents/main.html b/translations/templates/documents/main.html index 6b8f1bb7..ef9b8860 100644 --- a/translations/templates/documents/main.html +++ b/translations/templates/documents/main.html @@ -1,6 +1,19 @@ {% extends "base.html" %} {% block content %} -

Documents

-New -{% include "./filter.html" %} +
+

Documents

+
+
+
{% include "./filter.html" %}
+
+ +
+

{% include "./list.html" %} {% endblock content %} diff --git a/translations/templates/edit/form.html b/translations/templates/edit/form.html index cef32da5..c7e7fb74 100644 --- a/translations/templates/edit/form.html +++ b/translations/templates/edit/form.html @@ -1,5 +1,5 @@ - {{ form }} - - {% if error_message %} {% include "error.html" %} {% endif %} + {{ form }} + + {% if error_message %} {% include "error.html" %} {% endif %} diff --git a/translations/templates/edit/label_form.html b/translations/templates/edit/label_form.html new file mode 100644 index 00000000..4ac5dfaa --- /dev/null +++ b/translations/templates/edit/label_form.html @@ -0,0 +1,24 @@ + +
+
+ +
+
+
{{ form.label }}
+
+
+ +
+
+
{{ form.active }}
+
+
+ +
+
+
{{ form.no_auto }}
+
+
+ + {% if error_message %} {% include "error.html" %} {% endif %} +
diff --git a/translations/templates/edit/lang_form.html b/translations/templates/edit/lang_form.html index 07e01e39..c887eb7f 100644 --- a/translations/templates/edit/lang_form.html +++ b/translations/templates/edit/lang_form.html @@ -1,10 +1,23 @@ -{% include "./form.html" with form=form %} - + + {% for field in form %} +
+ {{ field }} {% if field.help_text %} +

{{ field.help_text }}

+ {% endif %} {% for error in field.errors %} +

{{ error }}

+ {% endfor %} +
+ {% endfor %} + + + {% if error_message %} {% include "error.html" %} {% endif %} +
diff --git a/translations/templates/edit/langs.html b/translations/templates/edit/langs.html index 78f5a2ff..f32f5dc4 100644 --- a/translations/templates/edit/langs.html +++ b/translations/templates/edit/langs.html @@ -1,13 +1,15 @@
- {% for lang, form in langs.items %} -

{{ lang }}

-
- {% include "./lang_form.html" with lang=lang form=form %} -
- {% endfor %} + {% for lang, form in langs.items %} +
+ +
+ {% include "./lang_form.html" with lang=lang form=form %} +
+
+ {% endfor %}
diff --git a/translations/templates/edit/main.html b/translations/templates/edit/main.html index d508abab..a71af25e 100644 --- a/translations/templates/edit/main.html +++ b/translations/templates/edit/main.html @@ -1,16 +1,51 @@ {% extends "base.html" %} {% block content %} -

{{ translation.label }}

-
- {% include "./form.html" with form=label_form %} -
-{% include "./langs.html" %} - -
-{% endblock content %} +
+

{{ translation.label }}

+
+
+
+
+
+
+
+
+ {% include "./label_form.html" with form=label_form %} +
+
+
+
+
+ +
+
+
+
+
+
+ {% for lang, form in langs.items %} +
+ +
+ {% include "./lang_form.html" with lang=lang form=form %} +
+
+ {% endfor %} +
+
+
+ + {% endblock content %} +
diff --git a/translations/templates/filter.html b/translations/templates/filter.html index d121d0da..7285836b 100644 --- a/translations/templates/filter.html +++ b/translations/templates/filter.html @@ -1,7 +1,35 @@ -
- - - - - +{% load static %} + +
+
+ + +
+
+
+ +
+
+ +
+
+
diff --git a/translations/templates/main.html b/translations/templates/main.html index 7f962439..9ad5a954 100644 --- a/translations/templates/main.html +++ b/translations/templates/main.html @@ -1,7 +1,19 @@ -{% extends "base.html" %} {% block content %} -

Translations

-New -
-{% include 'filter.html' %} +{% extends "base.html" %} {% load static %} {% block content %} +
+

Translations

+
+
+
{% include "./filter.html" %}
+
+ +
+

{% include 'translations.html' %} {% endblock content %} diff --git a/translations/templates/navigators/filter.html b/translations/templates/navigators/filter.html index 509bb925..331c12f7 100644 --- a/translations/templates/navigators/filter.html +++ b/translations/templates/navigators/filter.html @@ -1,9 +1,20 @@ +{% load static %}
- - - +
+ + + +
diff --git a/translations/templates/navigators/list.html b/translations/templates/navigators/list.html index c5a3e310..6617b57e 100644 --- a/translations/templates/navigators/list.html +++ b/translations/templates/navigators/list.html @@ -1,8 +1,93 @@ - +{% load static %} + diff --git a/translations/templates/navigators/main.html b/translations/templates/navigators/main.html index 9b1e51dd..ba9776df 100644 --- a/translations/templates/navigators/main.html +++ b/translations/templates/navigators/main.html @@ -1,6 +1,19 @@ {% extends "base.html" %} {% block content %} -

Navigator

-New -{% include "./filter.html" %} +
+

Navigators

+
+
+
{% include "./filter.html" %}
+
+ +
+

{% include "./list.html" %} {% endblock content %} diff --git a/translations/templates/navigators/navigator.html b/translations/templates/navigators/navigator.html index 9e5b04a0..ef31e30f 100644 --- a/translations/templates/navigators/navigator.html +++ b/translations/templates/navigators/navigator.html @@ -1,28 +1,40 @@ {% extends "base.html" %}{% block content %} -

{{ navigator.name.text }}

-
- - Django Admin - -
-
- Name -
-
- Email -
-
- - Assistance Link - -
-
- - Description - +
+

Navigator Actions

+
+
+ {% endblock content %} diff --git a/translations/templates/pagination.html b/translations/templates/pagination.html new file mode 100644 index 00000000..99253887 --- /dev/null +++ b/translations/templates/pagination.html @@ -0,0 +1,63 @@ + diff --git a/translations/templates/programs/filter.html b/translations/templates/programs/filter.html index 7238f167..314f7b8f 100644 --- a/translations/templates/programs/filter.html +++ b/translations/templates/programs/filter.html @@ -1,5 +1,20 @@ -
- - - +{% load static %} + +
+ + + +
diff --git a/translations/templates/programs/list.html b/translations/templates/programs/list.html index 16091dcc..f3d972c8 100644 --- a/translations/templates/programs/list.html +++ b/translations/templates/programs/list.html @@ -1,8 +1,128 @@ - +{% load static %} +
+ + + + + + + + + + + + + {% for program in page_obj %} + + + + + + + + + {% empty %} + + + + {% endfor %} + + + + + + +
IDNameAbbreviated NameActiveName LabelActions
{{ program.id }}{{ program.name.text }}{{ program.name_abbreviated }}{{ program.active }}{{ program.name }} + +
No results
Total: {{ page_obj.paginator.count }}
+ + {% include "../pagination.html" %} + + +
diff --git a/translations/templates/programs/main.html b/translations/templates/programs/main.html index facda867..bbf5665d 100644 --- a/translations/templates/programs/main.html +++ b/translations/templates/programs/main.html @@ -1,6 +1,19 @@ {% extends "base.html" %} {% block content %} -

Programs

-New -{% include "./filter.html" %} +
+

Programs

+
+
+
{% include "./filter.html" %}
+
+ +
+

{% include "./list.html" %} {% endblock content %} diff --git a/translations/templates/programs/program.html b/translations/templates/programs/program.html index fdb57bbb..66ea6efd 100644 --- a/translations/templates/programs/program.html +++ b/translations/templates/programs/program.html @@ -1,55 +1,72 @@ {% extends "base.html" %}{% block content %} -

{{ program.name.text }}

-
- - Django Admin - -
-
- - Description short - -
-
- Name -
-
- Description -
-
- - Learn more link - -
-
- - Apply button link - -
-
- Value type -
-
- - Estimated delivery time - -
-
- - Estimated application time - -
-
- Category -
-
- Warning -
-
- Estimated value +
+

Program Actions

+
+
+ {% endblock content %} diff --git a/translations/templates/translations.html b/translations/templates/translations.html index f85a2fdb..ce5a6993 100644 --- a/translations/templates/translations.html +++ b/translations/templates/translations.html @@ -1,8 +1,90 @@ -
    - {% for translation in translations %} -
  • - {{translation.label}} - Edit -
  • - {% endfor %} -
+{% load static %} +
+ + + + + + + + + + + + + + + {% for translation in page_obj %} + + + + + + + + + + + {% empty %} + + + + {% endfor %} + + + + + + +
+ ID + Label + Model + Abbreviated/External Name + Field + EditedActiveActions
{{ translation.entry_id }}{{ translation.label }}{{ translation.model_name|capfirst }}{{ translation.display_name }}{{ translation.field_name }}{{ translation.edited }}{{ translation.active }} + +
No results
Total: {{ page_obj.paginator.count }}
+ + {% include "pagination.html" %} +
diff --git a/translations/templates/urgent_needs/filter.html b/translations/templates/urgent_needs/filter.html index 963bdd00..9f5c902c 100644 --- a/translations/templates/urgent_needs/filter.html +++ b/translations/templates/urgent_needs/filter.html @@ -1,9 +1,20 @@ +{% load static %}
- - - +
+ + + +
diff --git a/translations/templates/urgent_needs/list.html b/translations/templates/urgent_needs/list.html index 6677f55a..e4658c72 100644 --- a/translations/templates/urgent_needs/list.html +++ b/translations/templates/urgent_needs/list.html @@ -1,8 +1,101 @@ -
    - {% for urgent_need in urgent_needs %} -
  • - {{ urgent_need.name.text }} - Edit -
  • - {% endfor %} -
+{% load static %} +
+ + + + + + + + + + + + {% for urgent_need in page_obj %} + + + + + + + + {% empty %} + + + + {% endfor %} + + + + + + +
IDNameExternal NameName LabelActions
{{ urgent_need.id }}{{ urgent_need.name.text }}{{ urgent_need.external_name }}{{ urgent_need.name }} + +
No results
Total: {{ page_obj.paginator.count }}
+ + {% include "../pagination.html" %} + + +
diff --git a/translations/templates/urgent_needs/main.html b/translations/templates/urgent_needs/main.html index a979ed8c..9d8d1356 100644 --- a/translations/templates/urgent_needs/main.html +++ b/translations/templates/urgent_needs/main.html @@ -1,6 +1,19 @@ {% extends "base.html" %} {% block content %} -

Urgent Needs

-New -{% include "./filter.html" %} +
+

Urgent Needs

+
+
+
{% include "./filter.html" %}
+
+ +
+

{% include "./list.html" %} {% endblock content %} diff --git a/translations/templates/urgent_needs/urgent_need.html b/translations/templates/urgent_needs/urgent_need.html index d70b15ee..17285066 100644 --- a/translations/templates/urgent_needs/urgent_need.html +++ b/translations/templates/urgent_needs/urgent_need.html @@ -1,29 +1,44 @@ {% extends "base.html" %}{% block content %} -

{{ urgent_need.name.text }}

-
- - Django Admin - -
-
- Name -
-
- - Description - -
-
- Link -
-
- Type -
-
- Warning +
+

Urgent Need Actions

+
+
+
+ + + + + + + + + + + + + +
LabelActions (add)
{{ urgent_need.name }} + +
{% endblock content %} diff --git a/translations/templates/util/create_form.html b/translations/templates/util/create_form.html index 587bc041..04172e39 100644 --- a/translations/templates/util/create_form.html +++ b/translations/templates/util/create_form.html @@ -1,6 +1,24 @@ {% extends "base.html" %} {% block content %} -

Create

-
- {{ form }} -
+
+

Create

+
+
+
+
+
+ {% for field in form %} +
+ + {{ field }} {% if field.help_text %} +

{{ field.help_text }}

+ {% endif %} {% for error in field.errors %} +

{{ error }}

+ {% endfor %} +
+ {% endfor %} + +
+
+
+ {% endblock content %} diff --git a/translations/urls.py b/translations/urls.py index 62e42591..aa5c9cc4 100644 --- a/translations/urls.py +++ b/translations/urls.py @@ -4,7 +4,7 @@ urlpatterns = [ path('', views.TranslationView.as_view()), - path('admin', views.admin_view), + path('admin', views.admin_view, name='translations_api_url'), path('admin/filter', views.filter_view), path('admin/create', views.create_translation_view), path('admin/programs', views.programs_view), @@ -23,7 +23,7 @@ path('admin/urgent_needs/filter', views.urgent_need_filter_view), path('admin/urgent_needs/create', views.create_urgent_need_view), path('admin/urgent_needs/', views.urgent_need_view), - path('admin/', views.translation_view), + path('admin/', views.translation_view, name='translation_admin_url'), path('admin//', views.edit_translation), path('admin///auto', views.auto_translate), ] diff --git a/translations/views.py b/translations/views.py index 049ba724..aa014dc5 100644 --- a/translations/views.py +++ b/translations/views.py @@ -1,3 +1,5 @@ +import re +from django.core.paginator import Paginator from django.shortcuts import render from django.conf import settings from .models import Translation @@ -8,6 +10,7 @@ from django.db.models import ProtectedError from programs.models import Program, Navigator, UrgentNeed, Document from phonenumber_field.formfields import PhoneNumberField +from phonenumber_field.widgets import PhoneNumberPrefixWidget from django.contrib.auth.decorators import login_required from django.contrib.admin.views.decorators import staff_member_required from integrations.services.google_translate.integration import Translate @@ -28,18 +31,32 @@ def get(self, request): class NewTranslationForm(forms.Form): - label = forms.CharField(max_length=128) - default_message = forms.CharField(widget=forms.Textarea(attrs={'name': 'text', 'rows': 3, 'cols': 50})) + label = forms.CharField(max_length=128, widget=forms.TextInput( + attrs={'class': 'input'})) + default_message = forms.CharField(widget=forms.Textarea( + attrs={'name': 'text', 'rows': 3, 'cols': 50, 'class': 'textarea'})) @login_required(login_url='/admin/login') @staff_member_required def admin_view(request): if request.method == 'GET': - translations = Translation.objects.all() + translations = Translation.objects.all().order_by('id') + # Display 50 translations per page + paginator = Paginator(translations, 50) + page_number = request.GET.get('page') + page_obj = paginator.get_page(page_number) + + for translation in page_obj: + used_by_info = translation.used_by + + translation.entry_id = used_by_info['id'] + translation.model_name = used_by_info['model_name'] + translation.field_name = used_by_info['field_name'] + translation.display_name = used_by_info['display_name'] context = { - 'translations': translations, + 'page_obj': page_obj } return render(request, "main.html", context) @@ -47,12 +64,15 @@ def admin_view(request): form = NewTranslationForm(request.POST) if form.is_valid(): text = form['default_message'].value() - translation = Translation.objects.add_translation(form['label'].value(), text) + translation = Translation.objects.add_translation( + form['label'].value(), text) - auto_translations = Translate().bulk_translate(['__all__'], [text])[text] + auto_translations = Translate().bulk_translate( + ['__all__'], [text])[text] for [language, auto_text] in auto_translations.items(): - Translation.objects.edit_translation_by_id(translation.id, language, auto_text, False) + Translation.objects.edit_translation_by_id( + translation.id, language, auto_text, False) response = HttpResponse() response.headers["HX-Redirect"] = f"/api/translations/admin/{translation.id}" @@ -74,22 +94,35 @@ def create_translation_view(request): @staff_member_required def filter_view(request): translations = Translation.objects \ - .filter(label__contains=request.GET.get('label', '')) \ - .translated(text__contains=request.GET.get('text', '')) + .filter(label__icontains=request.GET.get('label', '')) \ + .translated(text__icontains=request.GET.get('text', '')) + paginator = Paginator(translations, 50) + page_number = request.GET.get('page') + page_obj = paginator.get_page(page_number) + + for translation in page_obj: + used_by_info = translation.used_by + + translation.entry_id = used_by_info['id'] + translation.model_name = used_by_info['model_name'] + translation.field_name = used_by_info['field_name'] + translation.display_name = used_by_info['display_name'] context = { - 'translations': translations + 'page_obj': page_obj } return render(request, "translations.html", context) class TranslationForm(forms.Form): - text = forms.CharField(widget=forms.Textarea(attrs={'name': 'text', 'rows': 3, 'cols': 50}), required=False) + text = forms.CharField(widget=forms.Textarea( + attrs={'name': 'text', 'rows': 3, 'cols': 50, 'class': 'textarea'}), required=False) class LabelForm(forms.Form): - label = forms.CharField(max_length=128) + label = forms.CharField(max_length=128, widget=forms.TextInput( + attrs={'class': 'input'})) active = forms.BooleanField(required=False) no_auto = forms.BooleanField(required=False) @@ -98,10 +131,12 @@ class LabelForm(forms.Form): @staff_member_required def translation_view(request, id=0): if request.method == 'GET': - translation = Translation.objects.prefetch_related('translations').get(pk=id) + translation = Translation.objects.prefetch_related( + 'translations').get(pk=id) langs = [lang['code'] for lang in settings.PARLER_LANGUAGES[None]] - translations = {t.language_code: TranslationForm({'text': t.text}) for t in translation.translations.all()} + translations = {t.language_code: TranslationForm( + {'text': t.text}) for t in translation.translations.all()} for lang in langs: if lang not in translations: @@ -132,9 +167,9 @@ def translation_view(request, id=0): 'label': translation.label, 'active': translation.active, 'no_auto': translation.no_auto - }) + }), } - return render(request, "edit/form.html", context) + return render(request, "edit/label_form.html", context) elif request.method == 'DELETE': try: Translation.objects.get(pk=id).delete() @@ -156,16 +191,20 @@ def edit_translation(request, id=0, lang='en-us'): form = TranslationForm(request.POST) if form.is_valid(): text = form['text'].value() - translation = Translation.objects.edit_translation_by_id(id, lang, text) + translation = Translation.objects.edit_translation_by_id( + id, lang, text) if lang == settings.LANGUAGE_CODE: - translations = Translate().bulk_translate(['__all__'], [text])[text] + translations = Translate().bulk_translate( + ['__all__'], [text])[text] for [language, translation] in translations.items(): - Translation.objects.edit_translation_by_id(id, language, translation, False) + Translation.objects.edit_translation_by_id( + id, language, translation, False) parent = Translation.objects.get(pk=id) - forms = {t.language_code: TranslationForm({'text': t.text}) for t in parent.translations.all()} + forms = {t.language_code: TranslationForm( + {'text': t.text}) for t in parent.translations.all()} context = { 'translation': parent, 'langs': forms, @@ -177,12 +216,14 @@ def edit_translation(request, id=0, lang='en-us'): @staff_member_required def auto_translate(request, id=0, lang='en-us'): if request.method == 'POST': - translation = Translation.objects.language(settings.LANGUAGE_CODE).get(pk=id) + translation = Translation.objects.language( + settings.LANGUAGE_CODE).get(pk=id) auto = Translate().translate(lang, translation.text) # Set text to manualy edited initially in order to update, and then set it to not edited - new_translation = Translation.objects.edit_translation_by_id(translation.id, lang, auto) + new_translation = Translation.objects.edit_translation_by_id( + translation.id, lang, auto) new_translation.edited = False new_translation.save() @@ -195,16 +236,22 @@ def auto_translate(request, id=0, lang='en-us'): class NewProgramForm(forms.Form): - name_abbreviated = forms.CharField(max_length=120) + name_abbreviated = forms.CharField(max_length=120, widget=forms.TextInput( + attrs={'class': 'input'})) @login_required(login_url='/admin/login') @staff_member_required def programs_view(request): if request.method == 'GET': - programs = Program.objects.all() + programs = Program.objects.all().order_by('external_name') + + paginator = Paginator(programs, 50) + page_number = request.GET.get('page') + page_obj = paginator.get_page(page_number) + context = { - 'programs': programs + 'page_obj': page_obj } return render(request, 'programs/main.html', context) @@ -247,30 +294,41 @@ def program_view(request, id=0): @staff_member_required def programs_filter_view(request): if request.method == 'GET': - programs = Program.objects.all() - query = request.GET.get('name', '') - programs = filter(lambda p: query in p.name.text, programs) + programs = Program.objects.filter( + name__translations__text__icontains=request.GET.get('name', '')).distinct().order_by('external_name') + + paginator = Paginator(programs, 50) + page_number = request.GET.get('page') + page_obj = paginator.get_page(page_number) context = { - 'programs': programs + 'page_obj': page_obj } return render(request, 'programs/list.html', context) class NewNavigatorForm(forms.Form): - label = forms.CharField(max_length=50) - phone_number = PhoneNumberField(required=False) + label = forms.CharField(max_length=50, widget=forms.TextInput( + attrs={'class': 'input'})) + phone_number = PhoneNumberField(required=False, + widget=forms.TextInput( + attrs={'class': 'input'}) + ) @login_required(login_url='/admin/login') @staff_member_required def navigators_view(request): if request.method == 'GET': - navigators = Navigator.objects.all() + navigators = Navigator.objects.all().order_by('external_name') + + paginator = Paginator(navigators, 50) + page_number = request.GET.get('page') + page_obj = paginator.get_page(page_number) context = { - 'navigators': navigators + 'page_obj': page_obj } return render(request, 'navigators/main.html', context) @@ -282,7 +340,8 @@ def navigators_view(request): form['phone_number'].value(), ) response = HttpResponse() - response.headers["HX-Redirect"] = f"/api/translations/admin/navigators/{navigator.id}" + response.headers[ + "HX-Redirect"] = f"/api/translations/admin/navigators/{navigator.id}" return response @@ -314,32 +373,42 @@ def navigator_view(request, id=0): @staff_member_required def navigator_filter_view(request): if request.method == 'GET': - navigators = Navigator.objects.all() - query = request.GET.get('name', '') - navigators = filter(lambda p: query in p.name.text, navigators) + navigators = Navigator.objects.filter( + name__translations__text__icontains=request.GET.get('name', '')).distinct().order_by('external_name') + + paginator = Paginator(navigators, 50) + page_number = request.GET.get('page') + page_obj = paginator.get_page(page_number) context = { - 'navigators': navigators + 'page_obj': page_obj } return render(request, 'navigators/list.html', context) class NewUrgentNeedForm(forms.Form): - label = forms.CharField(max_length=50) - phone_number = PhoneNumberField(required=False) + label = forms.CharField(max_length=50, widget=forms.TextInput( + attrs={'class': 'input'})) + phone_number = PhoneNumberField(required=False, + widget=forms.TextInput( + attrs={'class': 'input'}) + ) @login_required(login_url='/admin/login') @staff_member_required def urgent_needs_view(request): if request.method == 'GET': - urgent_needs = UrgentNeed.objects.all() + urgent_needs = UrgentNeed.objects.all().order_by('external_name') + + paginator = Paginator(urgent_needs, 50) + page_number = request.GET.get('page') + page_obj = paginator.get_page(page_number) context = { - 'urgent_needs': urgent_needs + 'page_obj': page_obj } - return render(request, 'urgent_needs/main.html', context) if request.method == 'POST': form = NewUrgentNeedForm(request.POST) @@ -349,7 +418,8 @@ def urgent_needs_view(request): form['phone_number'].value(), ) response = HttpResponse() - response.headers["HX-Redirect"] = f"/api/translations/admin/urgent_needs/{urgent_need.id}" + response.headers[ + "HX-Redirect"] = f"/api/translations/admin/urgent_needs/{urgent_need.id}" return response @@ -381,36 +451,44 @@ def urgent_need_view(request, id=0): @staff_member_required def urgent_need_filter_view(request): if request.method == 'GET': - urgent_needs = UrgentNeed.objects.all() - query = request.GET.get('name', '') - urgent_needs = filter(lambda p: query in p.name.text, urgent_needs) + urgent_needs = UrgentNeed.objects.filter( + name__translations__text__icontains=request.GET.get('name', '')).distinct().order_by('external_name') + + paginator = Paginator(urgent_needs, 50) + page_number = request.GET.get('page') + page_obj = paginator.get_page(page_number) context = { - 'urgent_needs': urgent_needs + 'page_obj': page_obj } return render(request, 'urgent_needs/list.html', context) class NewDocumentForm(forms.Form): - external_name = forms.CharField(max_length=120) + external_name = forms.CharField(max_length=120, widget=forms.TextInput( + attrs={'class': 'input'})) @login_required(login_url='/admin/login') @staff_member_required def documents_view(request): if request.method == 'GET': - documents = Document.objects.all() + documents = Document.objects.all().order_by('external_name') + + paginator = Paginator(documents, 50) + page_number = request.GET.get('page') + page_obj = paginator.get_page(page_number) context = { - 'documents': documents + 'page_obj': page_obj } - return render(request, 'documents/main.html', context) if request.method == 'POST': form = NewDocumentForm(request.POST) if form.is_valid(): - document = Document.objects.new_document(form['external_name'].value()) + document = Document.objects.new_document( + form['external_name'].value()) response = HttpResponse() response.headers["HX-Redirect"] = f"/api/translations/admin/documents/{document.id}" return response @@ -445,10 +523,15 @@ def document_view(request, id=0): def document_filter_view(request): if request.method == 'GET': query = request.GET.get('name', '') - documents = Document.objects.filter(external_name__contains=query) + documents = Document.objects.filter( + external_name__contains=query).order_by('external_name') + + paginator = Paginator(documents, 50) + page_number = request.GET.get('page') + page_obj = paginator.get_page(page_number) context = { - 'documents': documents + 'page_obj': page_obj } return render(request, 'documents/list.html', context)