diff --git a/django/api/admin.py b/django/api/admin.py index fcff99ee..40f138c5 100644 --- a/django/api/admin.py +++ b/django/api/admin.py @@ -2,6 +2,7 @@ from .models.go_electric_rebate_application import ( GoElectricRebateApplication, SubmittedGoElectricRebateApplication, + InitiatedGoElectricRebateApplication, ) from .models.household_member import HouseholdMember from .models.go_electric_rebate import GoElectricRebate @@ -42,6 +43,7 @@ class GoElectricRebateApplicationAdmin(admin.ModelAdmin): # by BCeID users. @admin.register(SubmittedGoElectricRebateApplication) class SubmittedGoElectricRebateApplicationAdmin(admin.ModelAdmin): + search_fields = ["drivers_licence", "id", "status"] # disable bulk actions actions = None exclude = ( @@ -70,6 +72,7 @@ class SubmittedGoElectricRebateApplicationAdmin(admin.ModelAdmin): "doc2_tag", "consent_personal", "consent_tax", + "is_legacy", ) def get_queryset(self, request): @@ -114,3 +117,66 @@ def message_user( @admin.register(GoElectricRebate) class GoElectricRebateAdmin(admin.ModelAdmin): pass + + +@admin.register(InitiatedGoElectricRebateApplication) +class InitiatedGoElectricRebateApplicationAdmin(admin.ModelAdmin): + search_fields = ["drivers_licence", "id", "status"] + # disable bulk actions + actions = None + exclude = ( + "sin", + "doc1", + "doc2", + "user", + "spouse_email", + "status", + "address", + "city", + "postal_code", + "application_type", + "doc1_tag", + "doc2_tag", + "consent_personal", + "consent_tax", + ) + readonly_fields = ( + "id", + "last_name", + "first_name", + "middle_names", + "email", + "user_is_bcsc", + "drivers_licence", + "date_of_birth", + "tax_year", + "is_legacy", + ) + + def get_queryset(self, request): + return GoElectricRebateApplication.objects.filter( + status=GoElectricRebateApplication.Status.HOUSEHOLD_INITIATED + ) + + def has_delete_permission(self, request, obj=None): + return False + + def response_change(self, request, obj): + ret = super().response_change(request, obj) + if "cancel_application" in request.POST: + obj.status = GoElectricRebateApplication.Status.CANCELLED + obj.save(update_fields=["status"]) + return ret + + def message_user( + self, + request, + message, + level=messages.INFO, + extra_tags="", + fail_silently=False, + ): + revised_level = level + if "cancel_application" in request.POST: + revised_level = messages_custom.NEGATIVE_SUCCESS + super().message_user(request, message, revised_level, extra_tags, fail_silently) diff --git a/django/api/apps.py b/django/api/apps.py index 7ef94cfc..bf9b64ef 100644 --- a/django/api/apps.py +++ b/django/api/apps.py @@ -1,5 +1,7 @@ from django.apps import AppConfig from django.contrib.admin.apps import AdminConfig +from . import settings +import sys class ApiConfig(AppConfig): @@ -7,7 +9,11 @@ class ApiConfig(AppConfig): verbose_name = "Submitted Rebate Applications" def ready(self): - import api.signals + import api.signal_receivers + from api.scheduled_jobs import schedule_get_ncda_redeemed_rebates + + if settings.RUN_JOBS and "qcluster" in sys.argv: + schedule_get_ncda_redeemed_rebates() class ITVRAdminConfig(AdminConfig): diff --git a/django/api/authentication/keycloak.py b/django/api/authentication/keycloak.py index a9b4c5d0..51f99e6c 100644 --- a/django/api/authentication/keycloak.py +++ b/django/api/authentication/keycloak.py @@ -69,9 +69,9 @@ def authenticate_credentials(self, token): user, created = ITVRUser.objects.get_or_create( username=token_info.get("sub"), defaults={ - "display_name": token_info.get("display_name"), + "display_name": token_info.get("display_name", ""), "email": token_info.get("email", ""), - "identity_provider": token_info.get("identity_provider"), + "identity_provider": token_info.get("identity_provider", ""), }, ) diff --git a/django/api/migrations/0003_goelectricrebateapplication_is_legacy_and_more.py b/django/api/migrations/0003_goelectricrebateapplication_is_legacy_and_more.py new file mode 100644 index 00000000..6d457bcd --- /dev/null +++ b/django/api/migrations/0003_goelectricrebateapplication_is_legacy_and_more.py @@ -0,0 +1,127 @@ +# Generated by Django 4.0.1 on 2022-06-30 15:56 + +import api.validators +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import encrypted_fields.fields + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('api', '0002_alter_goelectricrebate_ncda_id'), + ] + + operations = [ + migrations.AddField( + model_name='goelectricrebateapplication', + name='is_legacy', + field=models.BooleanField(default=False, editable=False), + ), + migrations.AlterField( + model_name='goelectricrebateapplication', + name='address', + field=models.CharField(max_length=250, null=True), + ), + migrations.AlterField( + model_name='goelectricrebateapplication', + name='application_type', + field=models.CharField(max_length=25, null=True), + ), + migrations.AlterField( + model_name='goelectricrebateapplication', + name='city', + field=models.CharField(max_length=250, null=True), + ), + migrations.AlterField( + model_name='goelectricrebateapplication', + name='consent_personal', + field=models.BooleanField(null=True, validators=[api.validators.validate_consent]), + ), + migrations.AlterField( + model_name='goelectricrebateapplication', + name='consent_tax', + field=models.BooleanField(null=True, validators=[api.validators.validate_consent]), + ), + migrations.AlterField( + model_name='goelectricrebateapplication', + name='date_of_birth', + field=models.DateField(null=True, validators=[api.validators.validate_driving_age]), + ), + migrations.AlterField( + model_name='goelectricrebateapplication', + name='email', + field=models.EmailField(max_length=250, null=True), + ), + migrations.AlterField( + model_name='goelectricrebateapplication', + name='first_name', + field=models.CharField(max_length=250, null=True), + ), + migrations.AlterField( + model_name='goelectricrebateapplication', + name='last_name', + field=models.CharField(max_length=250, null=True), + ), + migrations.AlterField( + model_name='goelectricrebateapplication', + name='sin', + field=encrypted_fields.fields.EncryptedCharField(max_length=9, null=True, validators=[api.validators.validate_sin]), + ), + migrations.AlterField( + model_name='goelectricrebateapplication', + name='tax_year', + field=models.IntegerField(null=True), + ), + migrations.AlterField( + model_name='goelectricrebateapplication', + name='user', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL), + ), + migrations.AddConstraint( + model_name='goelectricrebateapplication', + constraint=models.CheckConstraint(check=models.Q(('is_legacy__exact', True), ('user__isnull', False), _connector='OR'), name='user_null_constraint'), + ), + migrations.AddConstraint( + model_name='goelectricrebateapplication', + constraint=models.CheckConstraint(check=models.Q(('is_legacy__exact', True), ('sin__isnull', False), _connector='OR'), name='sin_null_constraint'), + ), + migrations.AddConstraint( + model_name='goelectricrebateapplication', + constraint=models.CheckConstraint(check=models.Q(('is_legacy__exact', True), ('last_name__isnull', False), _connector='OR'), name='last_name_null_constraint'), + ), + migrations.AddConstraint( + model_name='goelectricrebateapplication', + constraint=models.CheckConstraint(check=models.Q(('is_legacy__exact', True), ('first_name__isnull', False), _connector='OR'), name='first_name_null_constraint'), + ), + migrations.AddConstraint( + model_name='goelectricrebateapplication', + constraint=models.CheckConstraint(check=models.Q(('is_legacy__exact', True), ('email__isnull', False), _connector='OR'), name='email_null_constraint'), + ), + migrations.AddConstraint( + model_name='goelectricrebateapplication', + constraint=models.CheckConstraint(check=models.Q(('is_legacy__exact', True), ('address__isnull', False), _connector='OR'), name='address_null_constraint'), + ), + migrations.AddConstraint( + model_name='goelectricrebateapplication', + constraint=models.CheckConstraint(check=models.Q(('is_legacy__exact', True), ('city__isnull', False), _connector='OR'), name='city_null_constraint'), + ), + migrations.AddConstraint( + model_name='goelectricrebateapplication', + constraint=models.CheckConstraint(check=models.Q(('is_legacy__exact', True), ('date_of_birth__isnull', False), _connector='OR'), name='date_of_birth_null_constraint'), + ), + migrations.AddConstraint( + model_name='goelectricrebateapplication', + constraint=models.CheckConstraint(check=models.Q(('is_legacy__exact', True), ('tax_year__isnull', False), _connector='OR'), name='tax_year_null_constraint'), + ), + migrations.AddConstraint( + model_name='goelectricrebateapplication', + constraint=models.CheckConstraint(check=models.Q(('is_legacy__exact', True), ('consent_personal__isnull', False), _connector='OR'), name='consent_personal_null_constraint'), + ), + migrations.AddConstraint( + model_name='goelectricrebateapplication', + constraint=models.CheckConstraint(check=models.Q(('is_legacy__exact', True), ('consent_tax__isnull', False), _connector='OR'), name='consent_tax_null_constraint'), + ), + ] diff --git a/django/api/migrations/0004_alter_goelectricrebateapplication_status.py b/django/api/migrations/0004_alter_goelectricrebateapplication_status.py new file mode 100644 index 00000000..a444000b --- /dev/null +++ b/django/api/migrations/0004_alter_goelectricrebateapplication_status.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0.1 on 2022-07-06 20:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0003_goelectricrebateapplication_is_legacy_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='goelectricrebateapplication', + name='status', + field=models.CharField(choices=[('household_initiated', 'Household Initiated'), ('submitted', 'Submitted'), ('verified', 'Verified'), ('declined', 'Declined'), ('approved', 'Approved'), ('not_approved', 'Not Approved'), ('redeemed', 'Redeemed'), ('expired', 'Expired'), ('cancelled', 'Cancelled')], max_length=250), + ), + ] diff --git a/django/api/migrations/0005_goelectricrebateapplication_application_type_null_constraint.py b/django/api/migrations/0005_goelectricrebateapplication_application_type_null_constraint.py new file mode 100644 index 00000000..fd7cded8 --- /dev/null +++ b/django/api/migrations/0005_goelectricrebateapplication_application_type_null_constraint.py @@ -0,0 +1,17 @@ +# Generated by Django 4.0.1 on 2022-07-11 18:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0004_alter_goelectricrebateapplication_status'), + ] + + operations = [ + migrations.AddConstraint( + model_name='goelectricrebateapplication', + constraint=models.CheckConstraint(check=models.Q(('is_legacy__exact', True), ('application_type__isnull', False), _connector='OR'), name='application_type_null_constraint'), + ), + ] diff --git a/django/api/migrations/0006_initiatedgoelectricrebateapplication.py b/django/api/migrations/0006_initiatedgoelectricrebateapplication.py new file mode 100644 index 00000000..36111cc9 --- /dev/null +++ b/django/api/migrations/0006_initiatedgoelectricrebateapplication.py @@ -0,0 +1,24 @@ +# Generated by Django 4.0.1 on 2022-07-12 17:24 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0005_goelectricrebateapplication_application_type_null_constraint'), + ] + + operations = [ + migrations.CreateModel( + name='InitiatedGoElectricRebateApplication', + fields=[ + ], + options={ + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('api.goelectricrebateapplication',), + ), + ] diff --git a/django/api/migrations/0007_alter_goelectricrebate_options_and_more.py b/django/api/migrations/0007_alter_goelectricrebate_options_and_more.py new file mode 100644 index 00000000..d4b1be25 --- /dev/null +++ b/django/api/migrations/0007_alter_goelectricrebate_options_and_more.py @@ -0,0 +1,42 @@ +# Generated by Django 4.0.1 on 2022-07-12 21:56 + +import api.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0006_initiatedgoelectricrebateapplication'), + ] + + operations = [ + migrations.AlterModelOptions( + name='goelectricrebate', + options={}, + ), + migrations.AlterField( + model_name='goelectricrebateapplication', + name='doc1', + field=models.ImageField(blank=True, null=True, upload_to='docs', validators=[api.validators.validate_file_size]), + ), + migrations.AlterField( + model_name='goelectricrebateapplication', + name='doc2', + field=models.ImageField(blank=True, null=True, upload_to='docs', validators=[api.validators.validate_file_size]), + ), + migrations.AlterField( + model_name='householdmember', + name='doc1', + field=models.ImageField(blank=True, null=True, upload_to='docs', validators=[api.validators.validate_file_size]), + ), + migrations.AlterField( + model_name='householdmember', + name='doc2', + field=models.ImageField(blank=True, null=True, upload_to='docs', validators=[api.validators.validate_file_size]), + ), + migrations.AlterModelTable( + name='goelectricrebate', + table='go_electric_rebate', + ), + ] diff --git a/django/api/models/go_electric_rebate.py b/django/api/models/go_electric_rebate.py index 5e3b8634..3c46fb88 100644 --- a/django/api/models/go_electric_rebate.py +++ b/django/api/models/go_electric_rebate.py @@ -5,7 +5,6 @@ BooleanField, PROTECT, ForeignKey, - UUIDField, ) from django.core.validators import MinLengthValidator @@ -31,3 +30,6 @@ class GoElectricRebate(TimeStampedModel): def __str__(self): return "DL: " + self.drivers_licence + ", $" + str(self.rebate_max_amount) + + class Meta: + db_table = "go_electric_rebate" diff --git a/django/api/models/go_electric_rebate_application.py b/django/api/models/go_electric_rebate_application.py index de8c0aa8..b1f38032 100644 --- a/django/api/models/go_electric_rebate_application.py +++ b/django/api/models/go_electric_rebate_application.py @@ -13,6 +13,7 @@ Manager, Q, UniqueConstraint, + CheckConstraint, ) from encrypted_fields.fields import EncryptedCharField from django.utils.html import mark_safe @@ -22,10 +23,12 @@ validate_driving_age, validate_sin, validate_consent, + validate_file_size, ) from django_extensions.db.models import TimeStampedModel from django.utils.translation import gettext_lazy as _ from django.utils.functional import classproperty +from api.signals import household_application_saved media_storage = get_storage_class()() @@ -33,11 +36,14 @@ class ApplicationManager(Manager): def create(self, **kwargs): spouse_email = kwargs.pop("spouse_email", None) - obj = self.model(**kwargs) - self._for_write = True + obj = super().create(**kwargs) if spouse_email: - obj.spouse_email = spouse_email - obj.save(force_insert=True, using=self.db) + household_application_saved.send( + sender=GoElectricRebateApplication, + instance=obj, + created=True, + spouse_email=spouse_email, + ) return obj @@ -53,27 +59,30 @@ class Status(TextChoices): NOT_APPROVED = ("not_approved", _("Not Approved")) REDEEMED = ("redeemed", _("Redeemed")) EXPIRED = ("expired", _("Expired")) + CANCELLED = ("cancelled", _("Cancelled")) - user = ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=PROTECT, - ) + user = ForeignKey(settings.AUTH_USER_MODEL, on_delete=PROTECT, null=True) id = ShortUUIDField(length=16, primary_key=True, editable=False) - sin = EncryptedCharField(max_length=9, unique=False, validators=[validate_sin]) + is_legacy = BooleanField(editable=False, default=False) + sin = EncryptedCharField( + max_length=9, unique=False, validators=[validate_sin], null=True + ) status = CharField(max_length=250, choices=Status.choices, unique=False) - last_name = CharField(max_length=250, unique=False) - first_name = CharField(max_length=250, unique=False) + last_name = CharField(max_length=250, unique=False, null=True) + first_name = CharField(max_length=250, unique=False, null=True) middle_names = CharField(max_length=250, unique=False, blank=True, null=True) - email = EmailField(max_length=250, unique=False) - address = CharField(max_length=250, unique=False) - city = CharField(max_length=250, unique=False) + email = EmailField(max_length=250, unique=False, null=True) + address = CharField(max_length=250, unique=False, null=True) + city = CharField(max_length=250, unique=False, null=True) postal_code = CharField(max_length=6, unique=False, blank=True, null=True) drivers_licence = CharField( max_length=8, unique=False, validators=[MinLengthValidator(7)] ) - date_of_birth = DateField(validators=[validate_driving_age]) - tax_year = IntegerField() - doc1 = ImageField(upload_to="docs", blank=True, null=True) + date_of_birth = DateField(validators=[validate_driving_age], null=True) + tax_year = IntegerField(null=True) + doc1 = ImageField( + upload_to="docs", blank=True, null=True, validators=[validate_file_size] + ) def doc1_tag(self): return mark_safe( @@ -83,7 +92,9 @@ def doc1_tag(self): doc1_tag.short_description = "First Uploaded Document" - doc2 = ImageField(upload_to="docs", blank=True, null=True) + doc2 = ImageField( + upload_to="docs", blank=True, null=True, validators=[validate_file_size] + ) def doc2_tag(self): return mark_safe( @@ -97,9 +108,10 @@ def doc2_tag(self): application_type = CharField( max_length=25, unique=False, + null=True, ) - consent_personal = BooleanField(validators=[validate_consent]) - consent_tax = BooleanField(validators=[validate_consent]) + consent_personal = BooleanField(validators=[validate_consent], null=True) + consent_tax = BooleanField(validators=[validate_consent], null=True) def user_is_bcsc(self): if self.user.identity_provider == "bcsc": @@ -109,7 +121,18 @@ def user_is_bcsc(self): user_is_bcsc.short_description = "Address is BCSC Verified" def __str__(self): - return self.last_name + ", " + self.first_name + ": " + str(self.id) + if self.is_legacy: + return "preITVR " + str(self.id) + else: + return ( + self.last_name + + ", " + + self.first_name + + ": " + + str(self.id) + + ": " + + self.status + ) class Meta: db_table = "go_electric_rebate_application" @@ -126,7 +149,55 @@ class Meta: ] ), name="verify_rebate_status", - ) + ), + CheckConstraint( + check=Q(is_legacy__exact=True) | Q(user__isnull=False), + name="user_null_constraint", + ), + CheckConstraint( + check=Q(is_legacy__exact=True) | Q(sin__isnull=False), + name="sin_null_constraint", + ), + CheckConstraint( + check=Q(is_legacy__exact=True) | Q(last_name__isnull=False), + name="last_name_null_constraint", + ), + CheckConstraint( + check=Q(is_legacy__exact=True) | Q(first_name__isnull=False), + name="first_name_null_constraint", + ), + CheckConstraint( + check=Q(is_legacy__exact=True) | Q(email__isnull=False), + name="email_null_constraint", + ), + CheckConstraint( + check=Q(is_legacy__exact=True) | Q(address__isnull=False), + name="address_null_constraint", + ), + CheckConstraint( + check=Q(is_legacy__exact=True) | Q(city__isnull=False), + name="city_null_constraint", + ), + CheckConstraint( + check=Q(is_legacy__exact=True) | Q(date_of_birth__isnull=False), + name="date_of_birth_null_constraint", + ), + CheckConstraint( + check=Q(is_legacy__exact=True) | Q(tax_year__isnull=False), + name="tax_year_null_constraint", + ), + CheckConstraint( + check=Q(is_legacy__exact=True) | Q(application_type__isnull=False), + name="application_type_null_constraint", + ), + CheckConstraint( + check=Q(is_legacy__exact=True) | Q(consent_personal__isnull=False), + name="consent_personal_null_constraint", + ), + CheckConstraint( + check=Q(is_legacy__exact=True) | Q(consent_tax__isnull=False), + name="consent_tax_null_constraint", + ), ] @@ -142,3 +213,16 @@ def admin_label(cls): @classproperty def admin_display_change(cls): return False + + +class InitiatedGoElectricRebateApplication(GoElectricRebateApplication): + class Meta: + proxy = True + + @classproperty + def admin_label(cls): + return "Cancel Applications" + + @classproperty + def admin_display_change(cls): + return False diff --git a/django/api/models/household_member.py b/django/api/models/household_member.py index fd015038..33949c4e 100644 --- a/django/api/models/household_member.py +++ b/django/api/models/household_member.py @@ -16,6 +16,7 @@ validate_driving_age, validate_sin, validate_consent, + validate_file_size, ) media_storage = get_storage_class()() @@ -38,7 +39,9 @@ class HouseholdMember(TimeStampedModel): bcsc_address = CharField(max_length=250, unique=False, blank=True, null=True) bcsc_city = CharField(max_length=250, unique=False, blank=True, null=True) bcsc_postal_code = CharField(max_length=6, unique=False, blank=True, null=True) - doc1 = ImageField(upload_to="docs", blank=True, null=True) + doc1 = ImageField( + upload_to="docs", blank=True, null=True, validators=[validate_file_size] + ) def doc1_tag(self): return mark_safe( @@ -48,7 +51,9 @@ def doc1_tag(self): doc1_tag.short_description = "First Uploaded Document" - doc2 = ImageField(upload_to="docs", blank=True, null=True) + doc2 = ImageField( + upload_to="docs", blank=True, null=True, validators=[validate_file_size] + ) def doc2_tag(self): return mark_safe( diff --git a/django/api/scheduled_jobs.py b/django/api/scheduled_jobs.py new file mode 100644 index 00000000..b910b79b --- /dev/null +++ b/django/api/scheduled_jobs.py @@ -0,0 +1,12 @@ +from django_q.tasks import schedule +from django_q.models import Schedule + + +def schedule_exists(func_name): + return Schedule.objects.filter(func__exact=func_name).exists() + + +def schedule_get_ncda_redeemed_rebates(): + task_name = "api.tasks.check_rebates_redeemed_since" + if not schedule_exists(task_name): + schedule(task_name, None, task_name, schedule_type="D") diff --git a/django/api/serializers/application_form.py b/django/api/serializers/application_form.py index 6649575d..8fdc1d27 100644 --- a/django/api/serializers/application_form.py +++ b/django/api/serializers/application_form.py @@ -1,12 +1,11 @@ -from api.validators import validate_file_size from rest_framework.serializers import ModelSerializer, SerializerMethodField from api.models.go_electric_rebate_application import GoElectricRebateApplication from rest_framework.parsers import FormParser, MultiPartParser from datetime import date -from django.core.exceptions import ValidationError from rest_framework.response import Response from rest_framework import status + class ApplicationFormCreateSerializer(ModelSerializer): parser_classes = ( MultiPartParser, @@ -15,7 +14,7 @@ class ApplicationFormCreateSerializer(ModelSerializer): class Meta: model = GoElectricRebateApplication - exclude = ["user", "status", "tax_year"] + exclude = ["user", "status", "tax_year", "is_legacy"] def _get_tax_year(self): today = date.today() @@ -27,13 +26,6 @@ def _get_tax_year(self): class ApplicationFormCreateSerializerDefault(ApplicationFormCreateSerializer): - def validate(self, data): - if data.get("doc1") is None or data.get("doc2") is None: - raise ValidationError("Missing required document.") - validate_file_size(data["doc1"]) - validate_file_size(data["doc2"]) - return data - def create(self, validated_data): request = self.context["request"] user = request.user @@ -152,8 +144,4 @@ class Meta: class ApplicationFormSpouseSerializer(ModelSerializer): class Meta: model = GoElectricRebateApplication - fields = [ - "address", - "city", - "postal_code", - ] + fields = ["address", "city", "postal_code", "status"] diff --git a/django/api/serializers/household_member.py b/django/api/serializers/household_member.py index 06f15ca8..c92908df 100644 --- a/django/api/serializers/household_member.py +++ b/django/api/serializers/household_member.py @@ -1,8 +1,6 @@ from rest_framework.serializers import ModelSerializer, SerializerMethodField from api.models.household_member import HouseholdMember from rest_framework.parsers import FormParser, MultiPartParser -from api.validators import validate_file_size -from django.core.exceptions import ValidationError class HouseholdMemberApplicationCreateSerializer(ModelSerializer): @@ -19,13 +17,6 @@ class Meta: class HouseholdMemberApplicationCreateSerializerDefault( HouseholdMemberApplicationCreateSerializer ): - def validate(self, data): - if data.get("doc1") is None or data.get("doc2") is None: - raise ValidationError("Missing required document.") - validate_file_size(data["doc1"]) - validate_file_size(data["doc2"]) - return data - def create(self, validated_data): user = self.context["request"].user diff --git a/django/api/settings.py b/django/api/settings.py index 72495d6c..f3e3743b 100644 --- a/django/api/settings.py +++ b/django/api/settings.py @@ -251,3 +251,5 @@ ) MESSAGE_TAGS = messages_custom.TAGS + +RUN_JOBS = os.getenv("RUN_JOBS", False) diff --git a/django/api/signal_receivers.py b/django/api/signal_receivers.py new file mode 100644 index 00000000..7b1ec91e --- /dev/null +++ b/django/api/signal_receivers.py @@ -0,0 +1,104 @@ +from django.db.models.signals import post_save +from .models.go_electric_rebate_application import ( + GoElectricRebateApplication, +) +from .models.household_member import HouseholdMember +from .models.go_electric_rebate import GoElectricRebate +from django.dispatch import receiver +from django.conf import settings +from api.models.household_member import HouseholdMember +from django_q.tasks import async_task +from api.utility import addresses_match +from .signals import household_application_saved + + +@receiver(post_save, sender=GoElectricRebateApplication) +def create_application(sender, instance, created, **kwargs): + if created and settings.EMAIL["SEND_EMAIL"]: + async_task("api.tasks.send_individual_confirm", instance.email, instance.id) + + +@receiver(household_application_saved, sender=GoElectricRebateApplication) +def after_household_application_created(sender, instance, created, **kwargs): + spouse_email = kwargs.get("spouse_email") + async_task( + "api.tasks.send_spouse_initial_message", + spouse_email, + instance.id, + instance.email, + ) + + +@receiver(post_save, sender=HouseholdMember) +def after_household_member_save(sender, instance, created, **kwargs): + if created: + application = instance.application + primary_user = application.user + secondary_user = instance.user + if application.status != GoElectricRebateApplication.Status.CANCELLED: + if ( + primary_user.identity_provider == "bcsc" + and secondary_user.identity_provider == "bcsc" + and addresses_match(application, secondary_user) + ): + application.status = GoElectricRebateApplication.Status.VERIFIED + application.save() + else: + application.status = GoElectricRebateApplication.Status.SUBMITTED + application.save() + + if settings.EMAIL["SEND_EMAIL"]: + async_task( + "api.tasks.send_household_confirm", + application.email, + application.id, + ) + + +@receiver(post_save, sender=GoElectricRebateApplication) +def after_status_change(sender, instance, created, **kwargs): + if ( + (not created) + and (kwargs.get("update_fields") == {"status"}) + and (settings.EMAIL["SEND_EMAIL"]) + ): + # if identity is declined (eg id doesnt match address) + if instance.status == GoElectricRebateApplication.Status.DECLINED: + async_task( + "api.tasks.send_reject", + instance.email, + instance.id, + ) + # if rebate is approved, send an email with amount + elif instance.status == GoElectricRebateApplication.Status.APPROVED: + rebate_amount = kwargs.get("rebate_amount") + async_task( + "api.tasks.send_approve", instance.email, instance.id, rebate_amount + ) + # if application is not approved due to cra: + elif instance.status == GoElectricRebateApplication.Status.NOT_APPROVED: + async_task( + "api.tasks.send_not_approve", + instance.email, + instance.id, + instance.tax_year, + ) + elif instance.status == GoElectricRebateApplication.Status.CANCELLED: + async_task( + "api.tasks.send_cancel", + instance.email, + instance.id, + ) + + +@receiver(post_save, sender=GoElectricRebate) +def after_rebate_issued(sender, instance, created, **kwargs): + if created: + async_task( + "api.services.ncda.notify", + instance.drivers_licence, + instance.last_name, + instance.expiry_date.strftime("%m/%d/%Y"), + str(instance.rebate_max_amount), + instance.id, + ) diff --git a/django/api/signals.py b/django/api/signals.py index 1174b7ec..7a454624 100644 --- a/django/api/signals.py +++ b/django/api/signals.py @@ -1,92 +1,5 @@ -from django.db.models.signals import post_save -from .models.go_electric_rebate_application import ( - GoElectricRebateApplication, -) -from .models.household_member import HouseholdMember -from .models.go_electric_rebate import GoElectricRebate -from django.dispatch import receiver -from django.conf import settings -from api.models.household_member import HouseholdMember -from django_q.tasks import async_task -from api.utility import addresses_match +import django.dispatch +# custom signals -@receiver(post_save, sender=GoElectricRebateApplication) -def create_application(sender, instance, created, **kwargs): - if created and settings.EMAIL["SEND_EMAIL"]: - async_task("api.tasks.send_individual_confirm", instance.email, instance.id) - if instance.application_type == "household": - async_task( - "api.tasks.send_spouse_initial_message", - instance.spouse_email, - instance.id, - instance.email, - ) - - -@receiver(post_save, sender=HouseholdMember) -def after_household_member_save(sender, instance, created, **kwargs): - if created: - application = instance.application - primary_user = application.user - secondary_user = instance.user - if ( - primary_user.identity_provider == "bcsc" - and secondary_user.identity_provider == "bcsc" - and addresses_match(application, secondary_user) - ): - application.status = GoElectricRebateApplication.Status.VERIFIED - application.save() - else: - application.status = GoElectricRebateApplication.Status.SUBMITTED - application.save() - - if settings.EMAIL["SEND_EMAIL"]: - async_task( - "api.tasks.send_household_confirm", - application.email, - application.id, - ) - - -@receiver(post_save, sender=GoElectricRebateApplication) -def after_status_change(sender, instance, created, **kwargs): - if ( - (not created) - and (kwargs.get("update_fields") == {"status"}) - and (settings.EMAIL["SEND_EMAIL"]) - ): - # if identity is declined (eg id doesnt match address) - if instance.status == GoElectricRebateApplication.Status.DECLINED: - async_task( - "api.tasks.send_reject", - instance.email, - instance.id, - ) - # if rebate is approved, send an email with amount - elif instance.status == GoElectricRebateApplication.Status.APPROVED: - rebate_amount = kwargs.get("rebate_amount") - async_task( - "api.tasks.send_approve", instance.email, instance.id, rebate_amount - ) - # if application is not approved due to cra: - elif instance.status == GoElectricRebateApplication.Status.NOT_APPROVED: - async_task( - "api.tasks.send_not_approve", - instance.email, - instance.id, - instance.tax_year, - ) - - -@receiver(post_save, sender=GoElectricRebate) -def after_rebate_issued(sender, instance, created, **kwargs): - if created: - async_task( - "api.services.ncda.notify", - instance.drivers_licence, - instance.last_name, - instance.expiry_date.strftime("%m/%d/%Y"), - str(instance.rebate_max_amount), - instance.id, - ) +household_application_saved = django.dispatch.Signal() diff --git a/django/api/tasks.py b/django/api/tasks.py index d1968ecd..2ed50e71 100644 --- a/django/api/tasks.py +++ b/django/api/tasks.py @@ -6,12 +6,13 @@ from email.header import Header from email.utils import formataddr from requests.auth import HTTPBasicAuth -from django_q.tasks import schedule from api.services.ncda import get_rebates_redeemed_since from api.models.go_electric_rebate import GoElectricRebate from api.models.go_electric_rebate_application import ( GoElectricRebateApplication, ) +from django_q.models import Schedule +from datetime import timedelta def get_email_service_token() -> str: @@ -302,10 +303,50 @@ def send_not_approve(recipient_email, application_id, tax_year): ) +def send_cancel(recipient_email, application_id): + message = """\ + + + +

This email was generated by the CleanBC Go Electric Passenger + Vehicle Rebate program application.

+ +

Your application has been cancelled.

+ +

Some examples of why this may have happened include:

+ + + +

Questions?

+ +

Please feel free to contact us at ZEVPrograms@gov.bc.ca

+ + + """ + send_email( + recipient_email, + application_id, + message, + cc_list=[], + optional_subject=" – Cancelled", + ) + + # check for newly redeemed rebates -# TODO schedule this task to automatically run. -def check_rebates_redeemed_since(iso_ts=None): - ts = iso_ts if iso_ts else timezone.now().strftime("%Y-%m-%dT00:00:00Z") +def check_rebates_redeemed_since(iso_ts=None, schedule_func_name=None): + ts = timezone.now().strftime("%Y-%m-%dT00:00:00Z") + if iso_ts: + ts = iso_ts + elif schedule_func_name: + schedule = Schedule.objects.get(func__exact=schedule_func_name) + ts = (schedule.next_run - timedelta(days=2)).strftime("%Y-%m-%dT%H:%M:%SZ") print("check_rebate_status " + ts) ncda_ids = get_rebates_redeemed_since(ts) print(ncda_ids) diff --git a/django/api/templates/admin/api/change_form.html b/django/api/templates/admin/api/change_form.html new file mode 100644 index 00000000..1c801089 --- /dev/null +++ b/django/api/templates/admin/api/change_form.html @@ -0,0 +1,18 @@ +{% extends "admin/change_form.html" %} {% load i18n admin_urls jazzmin %} {% +get_jazzmin_ui_tweaks as jazzmin_ui %} + + {% block field_sets %} +
+
+
+
+ {% block itvr_subheader %}{{title}}{% endblock %} +
+
+
+ {% get_changeform_template adminform as changeform_template %} + {% include changeform_template %} +
+
+
+ {% endblock %} \ No newline at end of file diff --git a/django/api/templates/admin/api/change_list.html b/django/api/templates/admin/api/change_list.html new file mode 100644 index 00000000..0234d005 --- /dev/null +++ b/django/api/templates/admin/api/change_list.html @@ -0,0 +1,75 @@ +{% extends "admin/change_list.html" %} +{% load i18n admin_urls static admin_list jazzmin %} + +{% block content %} +
+
+
+

{% block itvr_subtitle %}{{title}}{% endblock %}

+
+ {% block date_hierarchy %}{% if cl.date_hierarchy %}{% date_hierarchy cl %}{% endif %}{% endblock %} + {% block search %} + {% search_form cl %} + {% endblock %} +
+
+ +
+
{% csrf_token %} +
+ {% if cl.formset and cl.formset.errors %} +

+ {% if cl.formset.total_error_count == 1 %} + {% trans "Please correct the error below." %} + {% else %} + {% trans "Please correct the errors below." %} + {% endif %} +

+ {{ cl.formset.non_form_errors }} + {% endif %} +
+
+
+ {% if cl.formset %} +
{{ cl.formset.management_form }}
+ {% endif %} + + {% block result_list %} +
+
+ {% if action_form and actions_on_top and cl.show_admin_actions %} + {% admin_actions %} + {% endif %} +
+
+ {% block object-tools %} + {% block object-tools-items %} + {% change_list_object_tools %} + {% endblock %} + {% endblock %} +
+
+
+ {% result_list cl %} + {% if action_form and actions_on_bottom and cl.show_admin_actions %} +
+
+ {% admin_actions %} +
+
+ {% endif %} + {% endblock %} +
+
+
+ {% block pagination %}{% pagination cl %}{% endblock %} +
+
+
+
+
+ +
+
+
+{% endblock %} \ No newline at end of file diff --git a/django/api/templates/admin/api/initiatedgoelectricrebateapplication/change_form.html b/django/api/templates/admin/api/initiatedgoelectricrebateapplication/change_form.html new file mode 100644 index 00000000..730c36f9 --- /dev/null +++ b/django/api/templates/admin/api/initiatedgoelectricrebateapplication/change_form.html @@ -0,0 +1,4 @@ +{% extends "admin/api/change_form.html" %} {% load i18n admin_urls jazzmin %} {% +get_jazzmin_ui_tweaks as jazzmin_ui %} + +{% block itvr_subheader %}{% endblock %} \ No newline at end of file diff --git a/django/api/templates/admin/api/initiatedgoelectricrebateapplication/change_list.html b/django/api/templates/admin/api/initiatedgoelectricrebateapplication/change_list.html new file mode 100644 index 00000000..e3f3f9fe --- /dev/null +++ b/django/api/templates/admin/api/initiatedgoelectricrebateapplication/change_list.html @@ -0,0 +1,5 @@ +{% extends "admin/api/change_list.html" %} +{% load i18n admin_urls static admin_list jazzmin %} {% + +{% block content_title %} Initiated Rebate Applications {% endblock %} +{% block itvr_subtitle %} Select rebate applications to cancel {% endblock %} \ No newline at end of file diff --git a/django/api/templates/admin/api/initiatedgoelectricrebateapplication/submit_line.html b/django/api/templates/admin/api/initiatedgoelectricrebateapplication/submit_line.html new file mode 100644 index 00000000..19490515 --- /dev/null +++ b/django/api/templates/admin/api/initiatedgoelectricrebateapplication/submit_line.html @@ -0,0 +1,23 @@ +{% extends "admin/submit_line.html" %} +{% load i18n admin_urls jazzmin %} +{% get_jazzmin_ui_tweaks as jazzmin_ui %} +{% block submit-row %} +
+
+

+ + {% trans 'Actions' %} +

+
+
+
+ +
+
+
+{% endblock %} diff --git a/django/api/templates/admin/api/submittedgoelectricrebateapplication/change_form.html b/django/api/templates/admin/api/submittedgoelectricrebateapplication/change_form.html index e8cdc639..3e7bcf05 100644 --- a/django/api/templates/admin/api/submittedgoelectricrebateapplication/change_form.html +++ b/django/api/templates/admin/api/submittedgoelectricrebateapplication/change_form.html @@ -1,18 +1,4 @@ -{% extends "admin/change_form.html" %} {% load i18n admin_urls jazzmin %} {% +{% extends "admin/api/change_form.html" %} {% load i18n admin_urls jazzmin %} {% get_jazzmin_ui_tweaks as jazzmin_ui %} - {% block field_sets %} -
-
-
-
- {{original.application_type}} application {{original.id}} -
-
-
- {% get_changeform_template adminform as changeform_template %} - {% include changeform_template %} -
-
-
-{% endblock %} +{% block itvr_subheader %}{{original.application_type}} application {{original.id}}{% endblock %} diff --git a/django/api/templates/admin/api/submittedgoelectricrebateapplication/change_list.html b/django/api/templates/admin/api/submittedgoelectricrebateapplication/change_list.html index 1f8ce941..d0c1ad5e 100644 --- a/django/api/templates/admin/api/submittedgoelectricrebateapplication/change_list.html +++ b/django/api/templates/admin/api/submittedgoelectricrebateapplication/change_list.html @@ -1,76 +1,5 @@ -{% extends "admin/change_list.html" %} +{% extends "admin/api/change_list.html" %} {% load i18n admin_urls static admin_list jazzmin %} {% {% block content_title %} Submitted Rebate Applications {% endblock %} -{% block content %} -
-
-
-

Select rebate applications to verify identity

-
- {% block date_hierarchy %}{% if cl.date_hierarchy %}{% date_hierarchy cl %}{% endif %}{% endblock %} - {% block search %} - {% search_form cl %} - {% endblock %} -
-
- -
-
{% csrf_token %} -
- {% if cl.formset and cl.formset.errors %} -

- {% if cl.formset.total_error_count == 1 %} - {% trans "Please correct the error below." %} - {% else %} - {% trans "Please correct the errors below." %} - {% endif %} -

- {{ cl.formset.non_form_errors }} - {% endif %} -
-
-
- {% if cl.formset %} -
{{ cl.formset.management_form }}
- {% endif %} - - {% block result_list %} -
-
- {% if action_form and actions_on_top and cl.show_admin_actions %} - {% admin_actions %} - {% endif %} -
-
- {% block object-tools %} - {% block object-tools-items %} - {% change_list_object_tools %} - {% endblock %} - {% endblock %} -
-
-
- {% result_list cl %} - {% if action_form and actions_on_bottom and cl.show_admin_actions %} -
-
- {% admin_actions %} -
-
- {% endif %} - {% endblock %} -
-
-
- {% block pagination %}{% pagination cl %}{% endblock %} -
-
-
-
-
- -
-
-
-{% endblock %} \ No newline at end of file +{% block itvr_subtitle %} Select rebate applications to verify identity {% endblock %} \ No newline at end of file diff --git a/django/api/urls.py b/django/api/urls.py index eddc4946..97133a4a 100644 --- a/django/api/urls.py +++ b/django/api/urls.py @@ -1,7 +1,6 @@ from django.contrib import admin from django.urls import path, include from rest_framework import routers - from api.viewsets.application_form import ApplicationFormViewset from api.viewsets.household_member import HouseholdMemberApplicationViewset diff --git a/django/api/utility.py b/django/api/utility.py index ba545c5f..0c4ff930 100644 --- a/django/api/utility.py +++ b/django/api/utility.py @@ -5,24 +5,32 @@ def format_postal_code(postal_code): def addresses_match(application, household_user): - result = True application_street_address = application.address + household_street_address = household_user.street_address application_city = application.city + household_city = household_user.locality application_postal_code = application.postal_code + household_postal_code = household_user.postal_code - if application_street_address != household_user.street_address: + if (not application_street_address) or (not household_street_address): return False - if application_city != household_user.locality: + if (not application_city) or (not household_city): return False - if application_postal_code is not None and household_user.postal_code is None: + if application_street_address != household_street_address: return False - if application_postal_code is None and household_user.postal_code is not None: + if application_city != household_city: + return False + + if application_postal_code and (not household_postal_code): + return False + + if (not application_postal_code) and household_postal_code: return False if application_postal_code != household_user.postal_code: return False - return result + return True diff --git a/django/api/validators.py b/django/api/validators.py index baadbcd2..eb040b2e 100644 --- a/django/api/validators.py +++ b/django/api/validators.py @@ -58,7 +58,8 @@ def validate_consent(has_consented): # uses max filesize of 5MB def validate_file_size(file): - max_size = 5242880 - filesize = file.size - if filesize > max_size: - raise ValidationError("File too large.") + if file: + max_size = 5242880 + filesize = file.size + if filesize > max_size: + raise ValidationError("File too large.") diff --git a/django/api/viewsets/application_form.py b/django/api/viewsets/application_form.py index e2860acd..4b0c6bc0 100644 --- a/django/api/viewsets/application_form.py +++ b/django/api/viewsets/application_form.py @@ -2,7 +2,7 @@ from rest_framework.decorators import action from rest_framework.response import Response from rest_framework import status -from rest_framework.mixins import CreateModelMixin, RetrieveModelMixin +from rest_framework.mixins import CreateModelMixin, RetrieveModelMixin, UpdateModelMixin from api.serializers.application_form import ( ApplicationFormSerializer, @@ -13,17 +13,17 @@ from api.models.go_electric_rebate_application import GoElectricRebateApplication -class ApplicationFormViewset(GenericViewSet, CreateModelMixin, RetrieveModelMixin): +class ApplicationFormViewset( + GenericViewSet, CreateModelMixin, RetrieveModelMixin, UpdateModelMixin +): queryset = GoElectricRebateApplication.objects.all() - @action(detail=True, methods=["GET"], url_path="household") - def household(self, request, pk=None): - # not possible to restrict this endpoint to only the spouse because, at this point, - # no household_member record associated with the spouse has been created yet, and we're not storing the spouse email - # associated with household applications - application = GoElectricRebateApplication.objects.get(pk=pk) - serializer = ApplicationFormSpouseSerializer(application) - return Response(serializer.data) + def get_serializer_class(self): + if self.action == "create": + if self.request.user.identity_provider == "bcsc": + return ApplicationFormCreateSerializerBCSC + return ApplicationFormCreateSerializerDefault + return ApplicationFormSerializer def retrieve(self, request, pk=None): application = GoElectricRebateApplication.objects.get(pk=pk) @@ -33,12 +33,34 @@ def retrieve(self, request, pk=None): response = {"message": "Forbidden"} return Response(response, status=status.HTTP_403_FORBIDDEN) - def get_serializer_class(self): - if self.action == "create": - if self.request.user.identity_provider == "bcsc": - return ApplicationFormCreateSerializerBCSC - return ApplicationFormCreateSerializerDefault - return ApplicationFormSerializer + def update(self, request, pk=None): + return Response(status=status.HTTP_403_FORBIDDEN) + + # currently only used for cancelling household_initiated applications; consider using a serializer if the logic becomes more complicated + def partial_update(self, request, pk=None): + if request.data.get("status") == GoElectricRebateApplication.Status.CANCELLED: + application = GoElectricRebateApplication.objects.get(pk=pk) + if ( + application.status + == GoElectricRebateApplication.Status.HOUSEHOLD_INITIATED + ): + application.status = GoElectricRebateApplication.Status.CANCELLED + application.save(update_fields=["status"]) + return Response(status=status.HTTP_200_OK) + + @action(detail=True, methods=["GET"], url_path="household") + def household(self, request, pk=None): + application = GoElectricRebateApplication.objects.get(pk=pk) + if application.status == GoElectricRebateApplication.Status.CANCELLED: + error = {"error": "application_cancelled"} + return Response(error, status=status.HTTP_401_UNAUTHORIZED) + application_user_id = application.user.id + household_user_id = request.user.id + if application_user_id == household_user_id: + error = {"error": "same_user"} + return Response(error, status=status.HTTP_401_UNAUTHORIZED) + serializer = ApplicationFormSpouseSerializer(application) + return Response(serializer.data) @action(detail=False, methods=["GET"], url_path="check_status") def check_status(self, request, pk=None): diff --git a/docker-compose.yml b/docker-compose.yml index 4db019e0..1e4e8170 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -89,6 +89,7 @@ services: - CHES_EMAIL_URL - SEND_EMAIL - NCDA_CLIENT_SECRET + - RUN_JOBS=TRUE volumes: - ./django:/api depends_on: diff --git a/frontend/package.json b/frontend/package.json index dc401dad..3d777150 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "frontend", - "version": "1.4.0", + "version": "1.5.0", "private": true, "dependencies": { "@date-io/date-fns": "^2.14.0", diff --git a/frontend/public/tracker.js b/frontend/public/tracker.js index 51641efd..dce226d2 100644 --- a/frontend/public/tracker.js +++ b/frontend/public/tracker.js @@ -35,6 +35,5 @@ if (window.itvr_config && window.itvr_config.REACT_APP_ENV === 'test') { }); window.snowplow('enableActivityTracking', 30, 30); // Ping every 30 seconds after 30 seconds window.snowplow('enableLinkClickTracking'); - window.snowplow('trackPageView'); // } diff --git a/frontend/src/components/ApplicationSummary.js b/frontend/src/components/ApplicationSummary.js index af51c4df..a7adb10c 100644 --- a/frontend/src/components/ApplicationSummary.js +++ b/frontend/src/components/ApplicationSummary.js @@ -6,11 +6,13 @@ import DetailsTable from './DetailsTable'; import { useKeycloak } from '@react-keycloak/web'; import INeedHelp from './INeedHelp'; import Loading from './Loading'; +import Button from '@mui/material/Button'; const ApplicationSummary = ({ id, applicationType = '' }) => { const axiosInstance = useAxios(); const { keycloak } = useKeycloak(); const idp = keycloak.tokenParsed.identity_provider; + const displayName = keycloak.tokenParsed.display_name; const detailUrl = applicationType === 'household' ? `/api/spouse-application/${id}` @@ -104,13 +106,28 @@ const ApplicationSummary = ({ id, applicationType = '' }) => { information you’ve submitted." />

What you submitted

- +

Other rebate offers for you

Return to the Go Electric site to learn about other rebate offers.

+ ); }; diff --git a/frontend/src/components/DetailsTable.js b/frontend/src/components/DetailsTable.js index 021893a0..0ab25caa 100644 --- a/frontend/src/components/DetailsTable.js +++ b/frontend/src/components/DetailsTable.js @@ -10,7 +10,7 @@ function createData(name, answer) { return { name, answer }; } -function createConsentValue(consent, firstName, lastName, timestamp, idp) { +function createConsentValue(consent, displayName, timestamp, idp) { const timestampSplit = timestamp.split('T'); const date = timestampSplit[0]; const time = timestampSplit[1].split('.')[0]; @@ -26,8 +26,7 @@ function createConsentValue(consent, firstName, lastName, timestamp, idp) { return ( authType + '\\' + - firstName.charAt(0).toUpperCase() + - lastName.toUpperCase() + + displayName + ' ' + date + ' ' + @@ -57,8 +56,7 @@ const DetailsTable = ({ data }) => { 'Consent to Disclosure and Storage of, and Access to, Personal Information:', createConsentValue( data.consent_personal, - data.first_name, - data.last_name, + data.displayName, data.created, data.idp ) @@ -67,8 +65,7 @@ const DetailsTable = ({ data }) => { 'Consent to Disclosure of Information from Income Tax Records:', createConsentValue( data.consent_tax, - data.first_name, - data.last_name, + data.displayName, data.created, data.idp ) diff --git a/frontend/src/components/Header.js b/frontend/src/components/Header.js index 613c404b..b2b94fa5 100644 --- a/frontend/src/components/Header.js +++ b/frontend/src/components/Header.js @@ -2,7 +2,7 @@ import React from 'react'; import logo from '../styles/images/BCID_H_rgb_rev.png'; import Logout from './Logout'; -const Header = (props) => { +const Header = ({ logoutUri }) => { return (
@@ -23,7 +23,7 @@ const Header = (props) => {
- +
diff --git a/frontend/src/components/HouseholdLoginError.js b/frontend/src/components/HouseholdLoginError.js new file mode 100644 index 00000000..de822ecb --- /dev/null +++ b/frontend/src/components/HouseholdLoginError.js @@ -0,0 +1,39 @@ +import { useKeycloak } from '@react-keycloak/web'; +import Box from '@mui/material/Box'; + +const HouseholdLoginError = ({ id }) => { + const { keycloak } = useKeycloak(); + if (keycloak.authenticated) { + return ( + +

Log in error

+

+ You must use your own BC Services Card app or Basic BCeID account to + log in. +

+

+ You cannot use the same log in credentials as the applicant to + complete your household rebate application. +

+

+ Click the Log out button and log in using your own BC Services Card + app or Basic BCeID account. +

+ +
+ ); + } + return null; +}; + +export default HouseholdLoginError; diff --git a/frontend/src/components/Layout.js b/frontend/src/components/Layout.js index efe71a04..24195423 100644 --- a/frontend/src/components/Layout.js +++ b/frontend/src/components/Layout.js @@ -1,14 +1,22 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import Header from './Header'; import Footer from './Footer'; +import { useLocation } from 'react-router-dom'; + +const Layout = ({ children, logoutUri }) => { + const location = useLocation(); + useEffect(() => { + if (window.snowplow) { + window.snowplow('trackPageView'); + } + }, [location]); -const Layout = ({ children }) => { return ( - <> -
+
+
{children}
- +
); }; export default Layout; diff --git a/frontend/src/components/Logout.js b/frontend/src/components/Logout.js index 4ac7b52f..9e686c53 100644 --- a/frontend/src/components/Logout.js +++ b/frontend/src/components/Logout.js @@ -1,7 +1,7 @@ import React from 'react'; import { useKeycloak } from '@react-keycloak/web'; -const Logout = () => { +const Logout = ({ logoutUri }) => { const { keycloak } = useKeycloak(); if (keycloak.authenticated) { const kcToken = keycloak.tokenParsed; @@ -11,7 +11,11 @@ const Logout = () => { { - keycloak.logout(); + if (logoutUri) { + keycloak.logout({ redirectUri: logoutUri }); + } else { + keycloak.logout(); + } }} >  Log out diff --git a/frontend/src/components/SpouseForm.js b/frontend/src/components/SpouseForm.js index 02350ccd..7f42fb4a 100644 --- a/frontend/src/components/SpouseForm.js +++ b/frontend/src/components/SpouseForm.js @@ -37,8 +37,14 @@ export const defaultValues = { consent_tax: false }; -const SpouseForm = ({ id, setNumberOfErrors, setErrorsExistCounter }) => { +const SpouseForm = ({ + id, + setNumberOfErrors, + setErrorsExistCounter, + setSameUser +}) => { const [loading, setLoading] = useState(false); + const [applicationCancelled, setApplicationCancelled] = useState(false); const { keycloak } = useKeycloak(); const kcToken = keycloak.tokenParsed; const queryClient = useQueryClient(); @@ -62,10 +68,30 @@ const SpouseForm = ({ id, setNumberOfErrors, setErrorsExistCounter }) => { .get(`/api/application-form/${id}/household`) .then((response) => response.data); - const { data, isLoading, isError, error } = useQuery( - ['spouse-application', id], - queryFn - ); + const { data, isLoading, isError, error } = useQuery({ + queryKey: ['spouse-application', id], + queryFn: queryFn, + retry: (failureCount, error) => { + const errorResponse = error.response; + if ( + errorResponse && + errorResponse.data && + errorResponse.data.error === 'same_user' + ) { + return false; + } else if ( + errorResponse && + errorResponse.data && + errorResponse.data.error === 'application_cancelled' + ) { + return false; + } else if (failureCount >= 2) { + return false; + } + return true; + }, + refetchOnWindowFocus: false + }); const navigate = useNavigate(); const mutation = useMutation((data) => { @@ -117,13 +143,46 @@ const SpouseForm = ({ id, setNumberOfErrors, setErrorsExistCounter }) => { } }; + const cancelApplication = () => { + setLoading(true); + axiosInstance.current + .patch(`/api/application-form/${id}`, { status: 'cancelled' }) + .then((response) => { + setApplicationCancelled(true); + setLoading(false); + }) + .catch((error) => { + setLoading(false); + }); + }; + if (isLoading) { - return

Loading...

; + return ; + } + if (applicationCancelled) { + return

This application has been cancelled

; } if (isError) { + const errorResponse = error.response; + if ( + errorResponse && + errorResponse.data && + errorResponse.data.error === 'same_user' + ) { + setSameUser({ + error: true, + logoutUri: `${window.location.origin}/household?q=${id}` + }); + } else if ( + errorResponse && + errorResponse.data && + errorResponse.data.error === 'application_cancelled' + ) { + setApplicationCancelled(true); + } return

{error.message}

; } - const { address, city, postal_code: postalCode } = data; + const { address, city, postal_code: postalCode, status } = data; return ( @@ -289,6 +348,30 @@ const SpouseForm = ({ id, setNumberOfErrors, setErrorsExistCounter }) => { Submit Application + {status === 'household_initiated' && ( + +

+ If you are unable to complete this application click Cancel + Application. This will notify the primary applicant and enable them + to start a new application. +

+ +
+ )}
); }; diff --git a/frontend/src/pages/HouseholdForm.js b/frontend/src/pages/HouseholdForm.js index 5d7d9346..c4826730 100644 --- a/frontend/src/pages/HouseholdForm.js +++ b/frontend/src/pages/HouseholdForm.js @@ -3,6 +3,7 @@ import SpouseForm from '../components/SpouseForm'; import Layout from '../components/Layout'; import { useSearchParams } from 'react-router-dom'; import { Helmet } from 'react-helmet'; +import HouseholdLoginError from '../components/HouseholdLoginError'; const HouseholdFormPage = () => { const [searchParams] = useSearchParams(); @@ -10,6 +11,7 @@ const HouseholdFormPage = () => { const [numberOfErrors, setNumberOfErrors] = useState(0); const [errorsExistCounter, setErrorsExistCounter] = useState(0); const errorMessageRef = useRef(null); + const [sameUser, setSameUser] = useState({ error: false }); useEffect(() => { if (numberOfErrors > 0) { @@ -24,17 +26,22 @@ const HouseholdFormPage = () => { Passenger Vehicle Rebate Application Form – CleanBC Go Electric - + {numberOfErrors > 0 && ( Errors below, please ensure all fields are complete )} - + {sameUser.error ? ( + + ) : ( + + )}
); diff --git a/frontend/src/styles/App.scss b/frontend/src/styles/App.scss index 25cc74a8..3aa636ec 100644 --- a/frontend/src/styles/App.scss +++ b/frontend/src/styles/App.scss @@ -30,3 +30,8 @@ ie General Container padding: 0 1rem; } } + +.layout { + position: relative; + min-height: 100vh; +} diff --git a/frontend/src/styles/ApplicationDetails.scss b/frontend/src/styles/ApplicationDetails.scss index beefc8e6..4fefd00e 100644 --- a/frontend/src/styles/ApplicationDetails.scss +++ b/frontend/src/styles/ApplicationDetails.scss @@ -1,3 +1,7 @@ .application-details-table-answer { color: $text-grey-paragraph !important; } + +.logout-button { + text-transform: none !important; +} diff --git a/frontend/src/styles/Footer.scss b/frontend/src/styles/Footer.scss index 190b4f70..50b1638d 100644 --- a/frontend/src/styles/Footer.scss +++ b/frontend/src/styles/Footer.scss @@ -1,4 +1,7 @@ footer { + position: absolute; + width: 100%; + bottom: 0; background-color: #036; border-top: 2px solid #fcba19; color: #fff; diff --git a/frontend/src/styles/index.scss b/frontend/src/styles/index.scss index 8de902c7..f8c6431e 100644 --- a/frontend/src/styles/index.scss +++ b/frontend/src/styles/index.scss @@ -86,10 +86,7 @@ h4 { font-size: 1.5rem; font-weight: bold; } -.button { - font-size: 1.5rem; - font-weight: normal; -} + h5 { font-size: 1.4rem; font-weight: bold; @@ -121,7 +118,9 @@ a { margin-bottom: 1rem; border-radius: 4px; border: 0 solid $button-blue; - cursor:pointer; + cursor: pointer; + font-size: 1.5rem; + font-weight: normal; &:disabled { color: $grey-dark; @@ -132,7 +131,7 @@ a { .page-content { margin: 0 6rem 0 6rem; - padding-bottom: 2rem; + padding-bottom: 6rem; } span.validated { diff --git a/openshift/templates/task-queue/task-queue-dc.yaml b/openshift/templates/task-queue/task-queue-dc.yaml index fb67d317..53bcf75c 100644 --- a/openshift/templates/task-queue/task-queue-dc.yaml +++ b/openshift/templates/task-queue/task-queue-dc.yaml @@ -203,8 +203,35 @@ objects: value: 'true' - name: CORS_ORIGIN_WHITELIST value: "https://${NAME}${SUFFIX}.apps.silver.devops.gov.bc.ca" + - name: NCDA_CLIENT_ID + valueFrom: + secretKeyRef: + name: itvr-ncda + key: NCDA_CLIENT_ID + - name: NCDA_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: itvr-ncda + key: NCDA_CLIENT_SECRET + - name: NCDA_RESOURCE + valueFrom: + secretKeyRef: + name: itvr-ncda + key: NCDA_RESOURCE + - name: NCDA_AUTH_URL + valueFrom: + secretKeyRef: + name: itvr-ncda + key: NCDA_AUTH_URL + - name: NCDA_SHAREPOINT_URL + valueFrom: + secretKeyRef: + name: itvr-ncda + key: NCDA_SHAREPOINT_URL - name: CRA_ENVIRONMENT - value: ${CRA_ENVIRONMENT} + value: ${CRA_ENVIRONMENT} + - name: RUN_JOBS + value: 'true' readinessProbe: exec: command: diff --git a/utils/import_redeemed_rebates.py b/utils/import_redeemed_rebates.py new file mode 100644 index 00000000..383ac07c --- /dev/null +++ b/utils/import_redeemed_rebates.py @@ -0,0 +1,134 @@ +import pandas as pd +import numpy as np +import psycopg2 as pg +from io import StringIO +import psycopg2.extras as extras +from psycopg2 import OperationalError, errorcodes, errors +import sys +from datetime import datetime +import shortuuid +import argparse + +# to run in terminal python3 import_redeemed_rebates.py -H localhost -P 5432 -F "filePath" +# Connect to an existing database +# engine = pg.connect( +# "dbname='itvr' user='postgres' host='127.0.0.1' port='5432' password='admin@123'" +# ) + +parser = argparse.ArgumentParser() +parser.add_argument("-H", "--host", help="hostname", default="localhost") +parser.add_argument("-P", "--port", help="port", default="5432") +parser.add_argument("-F", "--file", help="Spreadsheet", default="") +parser.add_argument("-U", "--user", help="user", default="postgres") +parser.add_argument("-PW", "--password", help="password", default="postgres") + +args = parser.parse_args() + +conn_params = { + "host": args.host, + "port": args.port, + "database": "itvr", + "user": args.user, + "password": args.password, +} + + +# Define a connect function for PostgreSQL database server +def connect(conn_params): + conn = None + try: + conn = pg.connect(**conn_params) + print("Database Connection successful..................") + + except OperationalError as err: + # passing exception to function + show_psycopg2_exception(err) + # set the connection to 'None' in case of error + conn = None + return conn + + +excelfile = args.file +print("now processing: ", excelfile) +df = pd.read_excel(excelfile) + +# drop columns aside from drivers license and status +df.drop( + columns=df.columns.difference(["BCDriverLicenseNo", "Status"]), axis=1, inplace=True +) + +# drop rows where drivers license is a string or greater than 8 characters +df.applymap(lambda x: x.strip() if isinstance(x, str) else x) +df = df[df["BCDriverLicenseNo"].str.len() <= 8] +df = df[pd.to_numeric(df["BCDriverLicenseNo"], errors="coerce").notnull()] + +# convert statuses to uppercase and dropo +df["Status"] = df["Status"].str.upper() +df.drop(df[(df.Status == "Cancelled") | (df.Status == "CANCELLED")].index, inplace=True) +df = df.assign(Status="redeemed") + +df.rename( + columns={"BCDriverLicenseNo": "drivers_licence", "Status": "status"}, inplace=True +) + +# drop duplicate drivers licenses +df.drop_duplicates(subset="drivers_licence", keep="first", inplace=True) + +timestamp = datetime.now() +df["created"] = timestamp +df["modified"] = timestamp + +df["is_legacy"] = True + +for idx, row in df.iterrows(): + df.loc[idx, "id"] = shortuuid.ShortUUID().random(length=16) + + +# Define a function that handles and parses psycopg2 exceptions +def show_psycopg2_exception(err): + # get details about the exception + err_type, err_obj, traceback = sys.exc_info() + # get the line number when exception occured + line_n = traceback.tb_lineno + # print the connect() error + print("\npsycopg2 ERROR:", err, "on line number:", line_n) + print("psycopg2 traceback:", traceback, "-- type:", err_type) + # psycopg2 extensions.Diagnostics object attribute + print("\nextensions.Diagnostics:", err.diag) + # print the pgcode and pgerror exceptions + print("pgerror:", err.pgerror) + print("pgcode:", err.pgcode, "\n") + + +def single_inserts(conn, df, table): + for i in df.index: + cols = ",".join(list(df.columns)) + vals = [df.at[i, col] for col in list(df.columns)] + query = "INSERT INTO %s(%s) VALUES('%s','%s','%s','%s',%s, '%s')" % ( + table, + cols, + vals[0], + vals[1], + vals[2], + vals[3], + vals[4], + vals[5], + ) + cursor = conn.cursor() + try: + cursor.execute(query) + conn.commit() + except (Exception, pg.DatabaseError) as error: + print("Error:", error) + show_psycopg2_exception(error) + conn.rollback() + cursor.close() + # return 1 + print("single_inserts() done") + + +# Connect to the database +conn = connect(conn_params) +conn.autocommit = True + +single_inserts(conn, df, "go_electric_rebate_application") diff --git a/utils/requirements.txt b/utils/requirements.txt new file mode 100644 index 00000000..a87b642a --- /dev/null +++ b/utils/requirements.txt @@ -0,0 +1,3 @@ +pandas==1.3.5 +psycopg2==2.9.3 +openpyxl==3.0.10 \ No newline at end of file