diff --git a/.github/workflows/build-release.yaml b/.github/workflows/build-release.yaml new file mode 100644 index 00000000..9530ace8 --- /dev/null +++ b/.github/workflows/build-release.yaml @@ -0,0 +1,119 @@ +## For each release, the value of workflow name, branches and PR_NUMBER need to be adjusted accordingly + +name: CI/CD ITVR release-1.10.0 + +on: + push: + branches: [ release-1.10.0 ] + workflow_dispatch: + workflow_call: + +env: + ## The pull request number of the Tracking pull request to merge the release branch to main + PR_NUMBER: 270 + RELEASE_NAME: release-1.10.0 + +jobs: + + ## This is the CI job + build: + + name: Build ITVR on Openshift + runs-on: ubuntu-latest + timeout-minutes: 60 + + steps: + + ## it will checkout to /home/runner/work/itvr/itvr + - name: Check out repository + uses: actions/checkout@v2 + + ## Log in to Openshift with a token of service account + - name: Log in to Openshift + uses: redhat-actions/oc-login@v1 + with: + openshift_server_url: ${{ secrets.OPENSHIFT_SERVER }} + openshift_token: ${{ secrets.OPENSHIFT_TOKEN }} + insecure_skip_tls_verify: true + namespace: ${{ secrets.OPENSHIFT_NAMESPACE_PLATE }}-tools + + ## Run build on Openshift + - name: Run build + run: | + cd .pipeline + npm install + npm run build -- --pr=${{ env.PR_NUMBER }} --env=build + + deploy-on-test: + + name: Deploy ITVR on Test Environment + runs-on: ubuntu-latest + timeout-minutes: 240 + needs: build + + steps: + + ## it will checkout to /home/runner/work/itvr/itvr + - name: Check out repository + uses: actions/checkout@v2 + + - name: Log in to Openshift + uses: redhat-actions/oc-login@v1 + with: + openshift_server_url: ${{ secrets.OPENSHIFT_SERVER }} + openshift_token: ${{ secrets.OPENSHIFT_TOKEN }} + insecure_skip_tls_verify: true + namespace: ${{ secrets.OPENSHIFT_NAMESPACE_PLATE }}-tools + + - name: Ask for approval for ITVR Test deployment + uses: trstringer/manual-approval@v1.6.0 + with: + secret: ${{ github.TOKEN }} + approvers: AlexZorkin,emi-hi,tim738745,vibhiquartech,kuanfandevops + minimum-approvals: 1 + issue-title: "ITVR ${{ env.RELEASE_NAME }} Test Deployment" + timeout-minutes: 240 + + - name: Run BCDK deployment on ITVR Test environment + run: | + cd .pipeline + echo "Deploying ITVR ${{ env.RELEASE_NAME }} on Test" + npm install + npm run deploy -- --pr=${{ env.PR_NUMBER }} --env=test + + deploy-on-prod: + + name: Deploy ITVR on Prod Environment + runs-on: ubuntu-latest + timeout-minutes: 2880 + needs: deploy-on-test + + steps: + + ## it will checkout to /home/runner/work/itvr/itvr + - name: Check out repository + uses: actions/checkout@v2 + + - name: Log in to Openshift + uses: redhat-actions/oc-login@v1 + with: + openshift_server_url: ${{ secrets.OPENSHIFT_SERVER }} + openshift_token: ${{ secrets.OPENSHIFT_TOKEN }} + insecure_skip_tls_verify: true + namespace: ${{ secrets.OPENSHIFT_NAMESPACE_PLATE }}-tools + + - name: Ask for approval for ITVR Prod deployment + uses: trstringer/manual-approval@v1.6.0 + with: + secret: ${{ github.TOKEN }} + approvers: AlexZorkin,kuanfandevops,tim738745 + minimum-approvals: 2 + issue-title: "ITVR ${{ env.RELEASE_NAME }} Prod Deployment" + timeout-minutes: 2880 + + - name: Run BCDK deployment on ITVR Test environment + run: | + cd .pipeline + echo "Deploying ITVR ${{ env.RELEASE_NAME }} on Prod" + npm install + npm run deploy -- --pr=${{ env.PR_NUMBER }} --env=prod \ No newline at end of file diff --git a/.pipeline/lib/config.js b/.pipeline/lib/config.js index 5f6d44c2..a675f600 100644 --- a/.pipeline/lib/config.js +++ b/.pipeline/lib/config.js @@ -1,7 +1,7 @@ 'use strict'; const options= require('@bcgov/pipeline-cli').Util.parseArguments() const changeId = options.pr //aka pull-request -const version = '1.0.0' +const version = '1.10.0' const name = 'itvr' const ocpName = 'apps.silver.devops' @@ -27,6 +27,9 @@ Set the limit as two times of request electric-vehicle-rebates.gov.bc.ca */ options.git.owner='bcgov' +//Have to set options.git.repository to be itvr otherwise an error will be thrown as the label github-repo +//will contain https://github.com/bcgov/itvr which is not allowed as a valid label +options.git.repository='itvr' const phases = { diff --git a/README.md b/README.md index e66d4a01..778a99f2 100644 --- a/README.md +++ b/README.md @@ -155,3 +155,7 @@ python manage.py test or to run specific test files, point to the folder or file python manage.py test api.services.tests.test_calculate_rebate + +### Scheduled Jobs + +Currently, when the task-queue application starts, it creates scheduled jobs only if those jobs don't already exist in the database. This means that if some aspects of a job are changed (e.g. its arguments concerning timeout time, etc), one has to delete the job first in the admin console before deploying, or update the job manually in the admin console after deploying. This is an open issue, see: https://apps.nrs.gov.bc.ca/int/jira/browse/ZELDA-436 diff --git a/django/api/admin.py b/django/api/admin.py index 5fdd5667..a067f712 100644 --- a/django/api/admin.py +++ b/django/api/admin.py @@ -1,6 +1,7 @@ from django.contrib import admin from .models.go_electric_rebate_application import ( GoElectricRebateApplication, + GoElectricRebateApplicationWithFailedEmail, SearchableGoElectricRebateApplication, SubmittedGoElectricRebateApplication, CancellableGoElectricRebateApplication, @@ -84,6 +85,8 @@ class SubmittedGoElectricRebateApplicationAdmin(admin.ModelAdmin): "consent_personal", "consent_tax", "is_legacy", + "confirmation_email_success", + "spouse_email_success", ) def get_queryset(self, request): @@ -159,6 +162,8 @@ class CancellableGoElectricRebateApplicationAdmin(admin.ModelAdmin): "date_of_birth", "tax_year", "is_legacy", + "confirmation_email_success", + "spouse_email_success", ) def get_queryset(self, request): @@ -223,6 +228,8 @@ class SearchableGoElectricRebateApplicationAdmin(admin.ModelAdmin): "consent_personal", "consent_tax", "is_legacy", + "confirmation_email_success", + "spouse_email_success", ) def get_queryset(self, request): @@ -233,3 +240,46 @@ def has_delete_permission(self, request, obj=None): def get_inlines(self, request, obj=None): return get_inlines(obj) + + +@admin.register(GoElectricRebateApplicationWithFailedEmail) +class GoElectricRebateApplicationWithFailedEmailAdmin(admin.ModelAdmin): + actions = None + search_fields = ["drivers_licence", "id", "status"] + exclude = ( + "sin", + "doc1", + "doc2", + "user", + "tax_year", + "consent_personal", + "consent_tax", + "is_legacy", + ) + readonly_fields = ( + "id", + "last_name", + "first_name", + "middle_names", + "status", + "email", + "user_is_bcsc", + "address", + "city", + "postal_code", + "drivers_licence", + "date_of_birth", + "application_type", + "doc1_tag", + "doc2_tag", + "confirmation_email_success", + "spouse_email_success", + ) + + def get_queryset(self, request): + return GoElectricRebateApplication.objects.filter( + Q(confirmation_email_success=False) | Q(spouse_email_success=False) + ) + + def has_delete_permission(self, request, obj=None): + return False diff --git a/django/api/apps.py b/django/api/apps.py index c4df6c00..a70b286d 100644 --- a/django/api/apps.py +++ b/django/api/apps.py @@ -16,7 +16,6 @@ def ready(self): schedule_get_ncda_redeemed_rebates, schedule_cancel_untouched_household_applications, schedule_expire_expired_applications, - schedule_send_mass_approval_email_once, ) if settings.RUN_JOBS and "qcluster" in sys.argv: @@ -24,7 +23,6 @@ def ready(self): schedule_get_ncda_redeemed_rebates() schedule_cancel_untouched_household_applications() schedule_expire_expired_applications() - schedule_send_mass_approval_email_once() class ITVRAdminConfig(AdminConfig): diff --git a/django/api/constants.py b/django/api/constants.py new file mode 100644 index 00000000..e6900951 --- /dev/null +++ b/django/api/constants.py @@ -0,0 +1,28 @@ +from enum import Enum + +# for each income tested maximum rebate ($4000, $2000, $1000), there are different rebate levels for certain ZEV types and lease terms +class FOUR_THOUSAND_REBATE(Enum): + ZEV_MAX = 4000 + ZEV_MID = 2688 + ZEV_MIN = 1332 + PHEV_MAX = 2000 + PHEV_MID = 1334 + PHEV_MIN = 666 + + +class TWO_THOUSAND_REBATE(Enum): + ZEV_MAX = 2000 + ZEV_MID = 1334 + ZEV_MIN = 666 + PHEV_MAX = 1000 + PHEV_MID = 667 + PHEV_MIN = 333 + + +class ONE_THOUSAND_REBATE(Enum): + ZEV_MAX = 1000 + ZEV_MID = 667 + ZEV_MIN = 333 + PHEV_MAX = 500 + PHEV_MID = 334 + PHEV_MIN = 167 diff --git a/django/api/hooks.py b/django/api/hooks.py new file mode 100644 index 00000000..9bba6284 --- /dev/null +++ b/django/api/hooks.py @@ -0,0 +1,18 @@ +# django_q hooks + + +from api.models.go_electric_rebate_application import GoElectricRebateApplication + + +def set_email_status(task): + func = task.func + application_id = task.args[1] + email_successful = task.success + if func == "api.tasks.send_individual_confirm": + GoElectricRebateApplication.objects.filter(pk=application_id).update( + confirmation_email_success=email_successful + ) + elif task.func == "api.tasks.send_spouse_initial_message": + GoElectricRebateApplication.objects.filter(pk=application_id).update( + spouse_email_success=email_successful + ) diff --git a/django/api/migrations/0011_goelectricrebateapplication_approval_email_sent.py b/django/api/migrations/0011_goelectricrebateapplication_approval_email_sent.py new file mode 100644 index 00000000..ed0dcb62 --- /dev/null +++ b/django/api/migrations/0011_goelectricrebateapplication_approval_email_sent.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0.1 on 2022-08-25 17:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0010_alter_cancellablegoelectricrebateapplication_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='goelectricrebateapplication', + name='approval_email_sent', + field=models.BooleanField(null=True), + ), + ] diff --git a/django/api/migrations/0012_goelectricrebateapplicationwithfailedemail_and_more.py b/django/api/migrations/0012_goelectricrebateapplicationwithfailedemail_and_more.py new file mode 100644 index 00000000..1271ac59 --- /dev/null +++ b/django/api/migrations/0012_goelectricrebateapplicationwithfailedemail_and_more.py @@ -0,0 +1,39 @@ +# Generated by Django 4.0.1 on 2022-09-06 17:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0011_goelectricrebateapplication_approval_email_sent'), + ] + + operations = [ + migrations.CreateModel( + name='GoElectricRebateApplicationWithFailedEmail', + fields=[ + ], + options={ + 'ordering': ['-created'], + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('api.goelectricrebateapplication',), + ), + migrations.RemoveField( + model_name='goelectricrebateapplication', + name='approval_email_sent', + ), + migrations.AddField( + model_name='goelectricrebateapplication', + name='confirmation_email_success', + field=models.BooleanField(null=True), + ), + migrations.AddField( + model_name='goelectricrebateapplication', + name='spouse_email_success', + field=models.BooleanField(null=True), + ), + ] diff --git a/django/api/models/go_electric_rebate_application.py b/django/api/models/go_electric_rebate_application.py index de782aba..b4d2d8d7 100644 --- a/django/api/models/go_electric_rebate_application.py +++ b/django/api/models/go_electric_rebate_application.py @@ -81,6 +81,8 @@ class Status(TextChoices): ) date_of_birth = DateField(validators=[validate_driving_age], null=True) tax_year = IntegerField(null=True) + confirmation_email_success = BooleanField(null=True) + spouse_email_success = BooleanField(null=True) doc1 = ImageField( upload_to="docs", blank=True, @@ -249,3 +251,17 @@ def admin_label(cls): @classproperty def admin_hide_view_change_buttons(cls): return True + + +class GoElectricRebateApplicationWithFailedEmail(GoElectricRebateApplication): + class Meta: + proxy = True + ordering = ["-created"] + + @classproperty + def admin_label(cls): + return "Applications with failed emails" + + @classproperty + def admin_hide_view_change_buttons(cls): + return True diff --git a/django/api/scheduled_jobs.py b/django/api/scheduled_jobs.py index 9034f1ef..53d017e7 100644 --- a/django/api/scheduled_jobs.py +++ b/django/api/scheduled_jobs.py @@ -12,6 +12,7 @@ def schedule_send_rebates_to_ncda(): name="send_rebates_to_ncda", schedule_type="C", cron="15 * * * *", + q_options={"timeout": 1200, "ack_failure": True}, ) except IntegrityError: pass @@ -52,15 +53,3 @@ def schedule_expire_expired_applications(): ) except IntegrityError: pass - - -def schedule_send_mass_approval_email_once(): - try: - schedule( - "api.tasks.send_mass_approval_email_once", - name="send_mass_approval_email_once", - schedule_type="O", - repeats=1, - ) - except IntegrityError: - pass diff --git a/django/api/services/cra.py b/django/api/services/cra.py index 49c2d468..4acce87f 100644 --- a/django/api/services/cra.py +++ b/django/api/services/cra.py @@ -33,6 +33,8 @@ def read(file): sin = line[4:13] year = line[13:17] income = line[21:30].lstrip("0") + if income == "": + income = "0" current_application.append({"sin": sin, "year": year, "income": income}) return results diff --git a/django/api/settings.py b/django/api/settings.py index 7ea45cbc..255c9285 100644 --- a/django/api/settings.py +++ b/django/api/settings.py @@ -197,12 +197,13 @@ Q_CLUSTER = { "name": "ITVR", "workers": 4, - "timeout": 1200, + "timeout": 90, "retry": 1260, "queue_limit": 50, "bulk": 10, "orm": "default", "save_limit": 20 if DEBUG else -1, + "max_attempts": 100, } CACHES = { diff --git a/django/api/signal_receivers.py b/django/api/signal_receivers.py index b9c15bfd..73b49fa7 100644 --- a/django/api/signal_receivers.py +++ b/django/api/signal_receivers.py @@ -14,7 +14,12 @@ @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) + async_task( + "api.tasks.send_individual_confirm", + instance.email, + instance.id, + hook="api.hooks.set_email_status", + ) @receiver(household_application_saved, sender=GoElectricRebateApplication) @@ -26,6 +31,7 @@ def after_household_application_created(sender, instance, created, **kwargs): spouse_email, instance.id, instance.email, + hook="api.hooks.set_email_status", ) diff --git a/django/api/tasks.py b/django/api/tasks.py index 0ddf9fa4..2280cca0 100644 --- a/django/api/tasks.py +++ b/django/api/tasks.py @@ -14,7 +14,14 @@ from datetime import timedelta from django.db.models.signals import post_save from api.services.ncda import notify +from api.constants import ( + FOUR_THOUSAND_REBATE, + ONE_THOUSAND_REBATE, + TWO_THOUSAND_REBATE, +) +from api.utility import get_applicant_full_name from django_q.tasks import async_task +from func_timeout import func_timeout, FunctionTimedOut def get_email_service_token() -> str: @@ -223,7 +230,7 @@ def send_reject(recipient_email, application_id): ) -def send_approve(recipient_email, application_id, rebate_amount): +def send_approve(recipient_email, application_id, applicant_full_name, rebate_amounts): message = """\
@@ -231,15 +238,37 @@ def send_approve(recipient_email, application_id, rebate_amount):This email was generated by the CleanBC Go Electric Passenger Vehicle Rebate program application.
-Dear Applicant,
+Dear {applicant_full_name},
-- Your application has been approved for a rebate amount of up to ${rebate_amount}. - The full amount applies to purchases of battery electric and long-range plug-in hybrids. - For plug-in hybrids with ranges less than 85 km the rebate amount is half. -
+Your application has been approved for a maximum rebate amount of up to ${zev_max}.
+ +${zev_max} rebate for long-range ZEV purchase (BEV, FCEV, ER-EV, and PHEV with an electric range of 85 km or more)
+Your rebate will expire one year from today’s date.
+${phev_max} rebate for short-range PHEV purchase (PHEV with an electric range of less than 85 km)
+This rebate approval will expire one year from today’s date.
Next steps:
+ The information you enter (name, date of birth, address and BC + Driver's Licence number) must exactly match the ID you upload or + your application will be declined. +
{kcToken.identity_provider === 'bcsc' ? (- Date of birth cannot be blank and you must be 16 years or - older to request a rebate, please check the date of birth - entered. -
- )}- Date of birth cannot be blank and you must be 16 years or - older to request a rebate, please check the date of birth - entered. -
- )}diff --git a/frontend/src/utility.js b/frontend/src/utility.js index 5b43dd61..195d9355 100644 --- a/frontend/src/utility.js +++ b/frontend/src/utility.js @@ -32,7 +32,7 @@ export const isAgeValid = (dob, lowerBound, upperBound) => { if (!dob) { return false; } - dob = dob.toISOString().slice(0,10) + dob = dob.toISOString().slice(0, 10); const dobSplit = dob.split('-'); const dobYear = parseInt(dobSplit[0]); const dobMonthIndex = parseInt(dobSplit[1]) - 1; @@ -53,3 +53,15 @@ export const isAgeValid = (dob, lowerBound, upperBound) => { } return true; }; + +export const getDateWithYearOffset = (date, offset) => { + return new Date(date.getFullYear() + offset, date.getMonth(), date.getDate()); +}; + +export const getLastDayOfMonth = (date) => { + return new Date(date.getFullYear(), date.getMonth() + 1, 0); +}; + +export const getFirstDayOfMonth = (date) => { + return new Date(date.getFullYear(), date.getMonth(), 1); +};