Skip to content

Commit

Permalink
Oct 1 2024 stuff -- build-on-dev (#2226)
Browse files Browse the repository at this point in the history
* feat: 2207 - sales forecast (#2211)

* initial commit

* small change

* sales forecast backend

* feat: 2204 and 2209 - sales forecast (#2218)

* feat: 2204 and 2209 = sales forecast

* some changes

* wording changes

* some changes

* oct 1 stuff - spreadsheet column name changes (#2225)

* address codeQL-raised issue

* Task: Frontend Spreadsheet Record Validation #2220 (#2228)

* Frontend validation for SalesForecastRecord records.

* Fixing casing

* Cleanup

* feat: 2219 - forecast report column mapping + automatic summation of ZEVs supplied (#2231)

* feat: 2219 - forecast report colum mapping + automatic summation of ZEVs supplied

* small change

* fix totals display upon discard of records

* feat: 2221 - some styling (#2236)

* Task: Sales Forecast Report Checkbox Assertion #2222 (#2232)

* Adding new signing authority assertion for Sales Forecast

* Cleaning up

* wording changes

* formatting

* fix: amend download() function to take into account case where content-disposition is not an exposed header

* update template

* spreadsheet validation updates

* spreadsheet validation cleanup

* Added message for a successful or unsuccessful upload (#2278)

Co-authored-by: julianforeman <[email protected]>

* cleanup

---------

Co-authored-by: JulianForeman <[email protected]>
Co-authored-by: julianforeman <[email protected]>
  • Loading branch information
3 people authored Sep 27, 2024
1 parent c694017 commit 80512e9
Show file tree
Hide file tree
Showing 31 changed files with 1,052 additions and 49 deletions.
Binary file not shown.
9 changes: 9 additions & 0 deletions backend/api/constants/zev_type.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from enum import Enum, unique


@unique
class ZEV_TYPE(Enum):
BEV = "BEV"
PHEV = "PHEV"
FCEV = "FCEV"
EREV = "EREV"
65 changes: 65 additions & 0 deletions backend/api/migrations/0007_auto_20240821_1401.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Generated by Django 3.2.25 on 2024-08-21 21:01

import api.constants.zev_type
import db_comments.model_mixins
from django.db import migrations, models
import django.db.models.deletion
import enumfields.fields


class Migration(migrations.Migration):

dependencies = [
('api', '0006_auto_20240508_1553'),
]

operations = [
migrations.CreateModel(
name='SalesForecast',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('create_timestamp', models.DateTimeField(auto_now_add=True, null=True)),
('create_user', models.CharField(default='SYSTEM', max_length=130)),
('update_timestamp', models.DateTimeField(auto_now=True, null=True)),
('update_user', models.CharField(max_length=130, null=True)),
('ice_vehicles_one', models.IntegerField(blank=True, null=True)),
('ice_vehicles_two', models.IntegerField(blank=True, null=True)),
('ice_vehicles_three', models.IntegerField(blank=True, null=True)),
('zev_vehicles_one', models.IntegerField(blank=True, null=True)),
('zev_vehicles_two', models.IntegerField(blank=True, null=True)),
('zev_vehicles_three', models.IntegerField(blank=True, null=True)),
],
options={
'db_table': 'sales_forecast',
},
bases=(models.Model, db_comments.model_mixins.DBComments),
),
migrations.CreateModel(
name='SalesForecastRecord',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('create_timestamp', models.DateTimeField(auto_now_add=True, null=True)),
('create_user', models.CharField(default='SYSTEM', max_length=130)),
('update_timestamp', models.DateTimeField(auto_now=True, null=True)),
('update_user', models.CharField(max_length=130, null=True)),
('model_year', models.CharField(max_length=4)),
('make', models.CharField(max_length=250)),
('model_name', models.CharField(max_length=250)),
('type', enumfields.fields.EnumField(enum=api.constants.zev_type.ZEV_TYPE, max_length=10)),
('range', models.DecimalField(decimal_places=2, max_digits=20)),
('zev_class', models.CharField(max_length=1)),
('vehicle_class_interior_volume', models.CharField(max_length=250)),
('total_supplied', models.IntegerField()),
('sales_forecast', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='api.salesforecast')),
],
options={
'db_table': 'sales_forecast_record',
},
bases=(models.Model, db_comments.model_mixins.DBComments),
),
migrations.AddField(
model_name='salesforecast',
name='model_year_report',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='api.modelyearreport', unique=True),
),
]
21 changes: 21 additions & 0 deletions backend/api/migrations/0008_auto_20240828_1034.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from django.db import migrations, models

def add_sales_forecast_authority_assertion(apps, schema_editor):
SigningAuthorityAssertion = apps.get_model('api', 'SigningAuthorityAssertion')

SigningAuthorityAssertion.objects.get_or_create(
description="I confirm that the Forecast Report is complete.",
display_order=8,
effective_date="2020-01-01",
module="consumer_sales"
)

class Migration(migrations.Migration):

dependencies = [
('api', '0007_auto_20240821_1401'), # Replace with the name of the previous migration file
]

operations = [
migrations.RunPython(add_sales_forecast_authority_assertion),
]
2 changes: 2 additions & 0 deletions backend/api/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,5 @@
from . import supplemental_report_history
from . import supplemental_report_statuses
from . import icbc_snapshot_data
from . import sales_forecast
from . import sales_forecast_record
26 changes: 26 additions & 0 deletions backend/api/models/sales_forecast.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from django.db import models
from api.models.model_year_report import ModelYearReport
from auditable.models import Auditable


class SalesForecast(Auditable):
model_year_report = models.ForeignKey(
to=ModelYearReport, unique=True, on_delete=models.CASCADE
)

ice_vehicles_one = models.IntegerField(blank=True, null=True)

ice_vehicles_two = models.IntegerField(blank=True, null=True)

ice_vehicles_three = models.IntegerField(blank=True, null=True)

zev_vehicles_one = models.IntegerField(blank=True, null=True)

zev_vehicles_two = models.IntegerField(blank=True, null=True)

zev_vehicles_three = models.IntegerField(blank=True, null=True)

class Meta:
db_table = "sales_forecast"

db_table_comment = "Stores sales forecast information"
32 changes: 32 additions & 0 deletions backend/api/models/sales_forecast_record.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from django.db import models
from enumfields import EnumField
from api.models.sales_forecast import SalesForecast
from api.models.model_year import ModelYear
from api.models.credit_class import CreditClass
from api.constants.zev_type import ZEV_TYPE
from auditable.models import Auditable


class SalesForecastRecord(Auditable):
sales_forecast = models.ForeignKey(to=SalesForecast, on_delete=models.CASCADE)

model_year = models.CharField(max_length=4)

make = models.CharField(max_length=250)

model_name = models.CharField(max_length=250)

type = EnumField(ZEV_TYPE)

range = models.DecimalField(max_digits=20, decimal_places=2)

zev_class = models.CharField(max_length=1)

vehicle_class_interior_volume = models.CharField(max_length=250)

total_supplied = models.IntegerField()

class Meta:
db_table = "sales_forecast_record"

db_table_comment = "Stores sales forecast records"
36 changes: 36 additions & 0 deletions backend/api/permissions/sales_forecast.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from rest_framework import permissions
from api.services.model_year_report import get_model_year_report
from api.models.model_year_report_statuses import ModelYearReportStatuses


class SalesForecastPermissions(permissions.BasePermission):

def has_permission(self, request, view):
model_year_report_id = view.kwargs.get("pk")
model_year_report = None
user = request.user
is_government = user.is_government
organization_matches = False
if model_year_report_id is not None:
model_year_report = get_model_year_report(model_year_report_id)
if (
model_year_report is not None
and model_year_report.organization == user.organization
):
organization_matches = True

if view.action == "save" and not is_government and organization_matches:
return True
elif view.action == "records" or view.action == "totals":
if model_year_report is not None:
if is_government and model_year_report.validation_status not in [
ModelYearReportStatuses.DRAFT,
ModelYearReportStatuses.DELETED,
]:
return True
elif not is_government and organization_matches is True:
return True
elif view.action == "template_url":
return True

return False
35 changes: 35 additions & 0 deletions backend/api/serializers/sales_forecast.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from rest_framework.serializers import ModelSerializer, SlugRelatedField
from enumfields.drf import EnumField
from api.models.sales_forecast import SalesForecast
from api.models.sales_forecast_record import SalesForecastRecord
from api.constants.zev_type import ZEV_TYPE


class SalesForecastSerializer(ModelSerializer):
class Meta:
model = SalesForecast
fields = [
"ice_vehicles_one",
"ice_vehicles_two",
"ice_vehicles_three",
"zev_vehicles_one",
"zev_vehicles_two",
"zev_vehicles_three",
]


class SalesForecastRecordSerializer(ModelSerializer):
type = EnumField(ZEV_TYPE)

class Meta:
model = SalesForecastRecord
fields = [
"model_year",
"make",
"model_name",
"type",
"range",
"zev_class",
"vehicle_class_interior_volume",
"total_supplied",
]
8 changes: 8 additions & 0 deletions backend/api/services/generic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# this is probably only useful when the key refers to a unique field of the model associated with the qs
def get_model_instances_map(qs, key):
result = {}
for instance in qs:
value = getattr(instance, key)
if value is not None:
result[value] = instance
return result
5 changes: 3 additions & 2 deletions backend/api/services/minio.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,12 @@ def get_refined_object_name(object_name):
return object_name


def minio_get_object(object_name):
def minio_get_object(object_name, response_headers=None):
return minio.presigned_get_object(
bucket_name=MINIO['BUCKET_NAME'],
object_name=get_refined_object_name(object_name),
expires=timedelta(seconds=3600)
expires=timedelta(seconds=3600),
response_headers=response_headers
)


Expand Down
57 changes: 57 additions & 0 deletions backend/api/services/sales_forecast.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
from zeva.settings import MINIO
from api.models.sales_forecast import SalesForecast
from api.models.sales_forecast_record import SalesForecastRecord
from api.services.minio import minio_get_object
from api.constants.zev_type import ZEV_TYPE


def update_or_create(model_year_report_id, user, totals):
user_info = {"create_user": user.id, "update_user": user.id}
defaults = user_info | totals
forecast, created = SalesForecast.objects.update_or_create(
model_year_report_id=model_year_report_id, defaults=defaults
)
return forecast


def delete_records(sales_forecast):
SalesForecastRecord.objects.filter(sales_forecast=sales_forecast).delete()


def create_records(sales_forecast, records, user):
to_create = []
if records:
for record in records:
zev_type = ZEV_TYPE[record.pop("type")]
record_to_create = SalesForecastRecord(
sales_forecast=sales_forecast,
type=zev_type,
create_user=user.id,
update_user=user.id,
**record
)
to_create.append(record_to_create)
SalesForecastRecord.objects.bulk_create(to_create)


def get_forecast_records_qs(model_year_report_id):
qs = SalesForecastRecord.objects.filter(
sales_forecast__model_year_report_id=model_year_report_id
).order_by("pk")
return qs


def get_forecast(model_year_report_id):
try:
forecast = SalesForecast.objects.get(model_year_report_id=model_year_report_id)
return forecast
except SalesForecast.DoesNotExist:
return None


def get_minio_template_url():
template_name = MINIO["FORECAST_REPORT_TEMPLATE"]
response_headers = {
"response-content-disposition": "attachment; filename=forecast_report_template.xlsx"
}
return minio_get_object(template_name, response_headers)
4 changes: 4 additions & 0 deletions backend/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from .viewsets.model_year_report_compliance_obligation import ModelYearReportComplianceObligationViewset
from .viewsets.credit_agreement import CreditAgreementViewSet
from .viewsets.dashboard import DashboardViewset
from .viewsets.sales_forecast import SalesForecastViewset


router = routers.SimpleRouter(trailing_slash=False)
Expand Down Expand Up @@ -61,5 +62,8 @@
router.register(
r'dashboard/list', DashboardViewset, basename='list'
)
router.register(
r'forecasts', SalesForecastViewset, basename='forecast'
)

urlpatterns = router.urls
54 changes: 54 additions & 0 deletions backend/api/viewsets/sales_forecast.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework import status
from api.paginations import BasicPagination
from api.permissions.sales_forecast import SalesForecastPermissions
from api.services.sales_forecast import (
update_or_create,
delete_records,
create_records,
get_forecast_records_qs,
get_forecast,
get_minio_template_url,
)
from api.serializers.sales_forecast import (
SalesForecastSerializer,
SalesForecastRecordSerializer,
)


class SalesForecastViewset(viewsets.GenericViewSet):
permission_classes = [SalesForecastPermissions]
pagination_class = BasicPagination

# pk should be a myr_id
@action(detail=True, methods=["post"])
def save(self, request, pk=None):
user = request.user
data = request.data
forecast_records = data.pop("forecast_records")
forecast = update_or_create(pk, user, data)
if forecast_records:
delete_records(forecast)
create_records(forecast, forecast_records, user)
return Response(status=status.HTTP_201_CREATED)

# pk should be a myr id
@action(detail=True)
def records(self, request, pk=None):
qs = get_forecast_records_qs(pk)
page = self.paginate_queryset(qs)
serializer = SalesForecastRecordSerializer(page, many=True)
return self.get_paginated_response(serializer.data)

# pk should be a myr id
@action(detail=True)
def totals(self, request, pk=None):
forecast = get_forecast(pk)
serializer = SalesForecastSerializer(forecast)
return Response(serializer.data)

@action(detail=False)
def template_url(self, request):
return Response({"url": get_minio_template_url()})
Loading

0 comments on commit 80512e9

Please sign in to comment.