diff --git a/blocnote/apps/financialInputs/forms.py b/blocnote/apps/financialInputs/forms.py index 584eda407a4749a7625223279e4fce023af0f1fb..64f7084670ac2d08b4983f102bd429aa2592239e 100644 --- a/blocnote/apps/financialInputs/forms.py +++ b/blocnote/apps/financialInputs/forms.py @@ -49,4 +49,3 @@ class BlocNoteHeaderForm(ModelForm): self.fields['fund'].widget.attrs.update({ 'class': 'custom-select' }) - self.initial['fund'] = 1 diff --git a/blocnote/apps/financialInputs/migrations/0003_auto_20170503_2200.py b/blocnote/apps/financialInputs/migrations/0003_auto_20170503_2200.py new file mode 100644 index 0000000000000000000000000000000000000000..867ed62cfa99a8566c57cea2bbfed2ad379d157f --- /dev/null +++ b/blocnote/apps/financialInputs/migrations/0003_auto_20170503_2200.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.6 on 2017-05-03 22:00 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('financialInputs', '0002_incomestatement'), + ] + + operations = [ + migrations.CreateModel( + name='DefaultLoan', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('interest_rate', models.DecimalField(decimal_places=3, max_digits=5)), + ('duration', models.DecimalField(decimal_places=0, max_digits=3)), + ('max_loan_amount', models.DecimalField(decimal_places=2, max_digits=10)), + ('fund', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='financialInputs.Fund')), + ], + ), + migrations.CreateModel( + name='Lender', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=200)), + ], + ), + migrations.CreateModel( + name='LoanOptions', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('building_id', models.IntegerField()), + ('interest_rate', models.DecimalField(decimal_places=3, max_digits=5)), + ('duration', models.DecimalField(decimal_places=0, max_digits=3)), + ('max_loan_amount', models.DecimalField(decimal_places=2, max_digits=10)), + ('lender', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='financialInputs.Lender')), + ], + ), + migrations.AddField( + model_name='defaultloan', + name='lender', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='financialInputs.Lender'), + ), + ] diff --git a/blocnote/apps/financialInputs/models.py b/blocnote/apps/financialInputs/models.py index 030d735693c38f2d256f93b362629ac929368886..36dbbdbcdc21db561ba4ce0b6c8fafa72c48259e 100644 --- a/blocnote/apps/financialInputs/models.py +++ b/blocnote/apps/financialInputs/models.py @@ -108,3 +108,35 @@ class IncomeStatement(models.Model): utility_expense = models.DecimalField(max_digits=10, decimal_places=2) other_utility_expense = models.DecimalField(max_digits=10, decimal_places=2) non_utility_operating_expense = models.DecimalField(max_digits=10, decimal_places=2) + + +class Lender(models.Model): + """Lenders for the different funds.""" + + name = models.CharField(max_length=200) + + +class DefaultLoan(models.Model): + """Default loan options. + + Each fund-lender combination has default loan terms which will be used as template. + """ + + lender = models.ForeignKey(Lender, on_delete=models.CASCADE, blank=True, null=True) + fund = models.ForeignKey(Fund, on_delete=models.CASCADE, blank=True, null=True) + interest_rate = models.DecimalField(max_digits=5, decimal_places=3) + duration = models.DecimalField(max_digits=3, decimal_places=0) + max_loan_amount = models.DecimalField(max_digits=10, decimal_places=2) + + +class LoanOptions(models.Model): + """Loan options for a building. + + This could be the same as the default options present or modified. + """ + + building_id = models.IntegerField() + lender = models.ForeignKey(Lender, on_delete=models.CASCADE, blank=True, null=True) + interest_rate = models.DecimalField(max_digits=5, decimal_places=3) + duration = models.DecimalField(max_digits=3, decimal_places=0) + max_loan_amount = models.DecimalField(max_digits=10, decimal_places=2) diff --git a/blocnote/apps/financialInputs/static/financialInputs/scripts/app.js b/blocnote/apps/financialInputs/static/financialInputs/scripts/app.js index e4aa613f378f98d83e83042c39d177f8dca1bdc3..964c36ca97349be716c6401c88382d2af9011d19 100644 --- a/blocnote/apps/financialInputs/static/financialInputs/scripts/app.js +++ b/blocnote/apps/financialInputs/static/financialInputs/scripts/app.js @@ -13,7 +13,12 @@ getIncomeStatementTable(); getCustomerPreferenceTable(); getLiabilitiesTable(); getCashBalanceForm(); +getLoanOptionsTable(); +/** + * The following 2 functions display a warning message saying if fund is changed, it will affect the loan options. + * This message is displayed when the mouse is over the fund select box. + */ var fund = document.querySelector('#id_fund'); fund.onmouseenter = function() { var errorDiv = document.querySelector('#show-error'); @@ -67,6 +72,23 @@ function billProjectionDatesForm(form) { 'Content-Type': 'application/json', 'X-CSRFToken': Cookies.get('csrftoken') }) + }).then(res => { + /** + * delete-loan-options request to the backend to delete loan options for this building id if previosly stored. + * This ensures that the loan options table is up to date with the latest fund. Clear the loan options table + * and reload with new fund's default loan options. + */ + request(`loan-options/?loans=delete-loan-options`, { + method: 'GET', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json' + }, + }).then(res => { + var tableBody = document.querySelector('#loan-options-table tbody'); + tableBody.innerHTML = ``; + getLoanOptionsTable(); + }); }); } return false; @@ -579,7 +601,6 @@ function liabilitiesSubmitForm(form) { 'year': Number(table.rows[rowInd].cells[4].children[2].value), } if (!validateDate(date, todaysDate)) { - console.log('Invalid date'); alert('Invalid date'); return false; } @@ -686,7 +707,7 @@ function addCashBalanceRow(balance, month, day, year, isFromBalanceSheet) { var rowCount = table.rows.length; var row = table.insertRow(rowCount); var cell = row.insertCell(0); - cell.innerHTML += `${rowCount}`; + cell.innerHTML += `${rowCount + 1}`; cell = row.insertCell(1); cell.innerHTML += ` @@ -1102,3 +1123,224 @@ function updateIncomeStatementTable(rowIndex, cellIndex, value) { var body = document.querySelector('#income-statement-table tbody'); body.rows[rowIndex].cells[cellIndex].innerHTML = value; } + +/** + * This loads the NOI DSCR and Cash DSCR inputs. + */ +function loadLoanOptionsTextBox(field, id, value) { + inputDiv = document.querySelector('#'+id); + inputDiv.innerHTML = ` + ${field} + + `; +} + +/** + * Load the column heading for loan options. + */ +function loadLoanOptionsColumnHeadings() { + head = document.querySelector('#loan-options-table thead'); + head.innerHTML = ` + + Loan Options + Lender + Interest Rate + Duration + Maximum Loan Amount + Option + + `; +} + +/** + * Add a new row to the loan options table. + */ +function addLoanOptionsRow(lenderList, lender, interestRate, duration, maxLoanAmount) { + body = document.querySelector('#loan-options-table tbody') + var rowCount = body.rows.length; + var row = body.insertRow(rowCount); + var cell = row.insertCell(0); + cell.innerHTML = `Loan ${rowCount+1}`; + cell = row.insertCell(1); + cell.innerHTML = `${lenderOptions(lenderList, lender)}`; + cell = row.insertCell(2); + cell.innerHTML = `%`; + cell = row.insertCell(3); + cell.innerHTML = `months`; + cell = row.insertCell(4); + cell.innerHTML = `$`; + cell = row.insertCell(5); + cell.innerHTML = ` +
+ +
+ `; + return false; +} + +/** + * Delete a row from loan options table. + */ +function deleteLoanOptionsRow(row) { + table = document.querySelector('#loan-options-table'); + var result = confirm("Are you sure you want to delete this row?"); + if (result) { + table.deleteRow(row); + for (rowInd = 1; rowInd < table.rows.length; rowInd++) { + row = table.rows.item(rowInd).cells; + row.item(0).innerHTML = `Loan ${rowInd}`; + } + } + return false; +} + +/** + * Generate the lender dropdown box given the list of lenders. + */ +function lenderOptions(lenderList, lender) { + var text = ` + + `; + return text; +} + +/** + * Implement add row button. + */ +function addRowButtonFunction() { + request(`loan-options/?loans=default`, { + method: 'GET', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json' + }, + }).then(res => { + var lenders = res.payload.lenders; + var defaultLoansList = res.payload.status; + var lender = defaultLoansList[0].lender; + var interestRate = defaultLoansList[0].interest_rate; + var duration = defaultLoansList[0].duration; + var maxLoanAmount = defaultLoansList[0].max_loan_amount; + addLoanOptionsRow(lenders, lender, interestRate, duration, maxLoanAmount); + }) + return false; +} + +/** + * Update a cell in the loan options table given the row and cell index and the value. + */ +function updateLoanOptionsCell(body, rowIndex, cellIndex, value) { + body.rows[rowIndex].cells[cellIndex].innerHTML = value; +} + +/** + * request backend for list of lenders and default loan options. + */ +function loadDefaultLoan(data) { + body = document.querySelector('#loan-options-table tbody'); + lender = body.rows[data-1].cells[1].children[0].value; + request(`loan-options/?loans=default`, { + method: 'GET', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json' + }, + }).then(res => { + var defaultLoanList = res.payload.status; + for (var index = 0; index < defaultLoanList.length; index++) { + if (lender === defaultLoanList[index].lender) { + defaultLoan = defaultLoanList[index]; + } + } + updateLoanOptionsCell(body, data - 1, 2, `%`); + updateLoanOptionsCell(body, data - 1, 3, `months`); + updateLoanOptionsCell(body, data - 1, 4, `$`); + }); +} + +/** + * Save loan options table. Send the the rows in the form of a dictionary to the backend to store in the database. + */ +function submitLoanOptionsForm(form) { + var body = document.querySelector('#loan-options-table tbody') + var rowCount = body.rows.length; + var result = {}; + result['noi-dscr'] = document.querySelector('#required-noi-dscr-input').value + result['cash-dscr'] = document.querySelector('#required-cash-dscr-input').value + if (result['noi-dscr'] === '') { + alert('Please enter NOI DSCR'); + return false; + } + if (result['cash-dscr'] === '') { + alert('Please enter Cash DSCR'); + return false; + } + var instance = [] + for (var rowIndex = 0; rowIndex < rowCount; rowIndex++) { + var record = {}; + record['lender'] = body.rows[rowIndex].cells[1].children[0].value; + record['interest-rate'] = body.rows[rowIndex].cells[2].children[0].value; + record['duration'] = body.rows[rowIndex].cells[3].children[0].value; + record['maximum-loan-amount'] = body.rows[rowIndex].cells[4].children[0].value; + instance.push(record); + } + result['instance'] = instance; + request('loan-options/', { + method: 'PUT', + credentials: 'same-origin', + body: JSON.stringify(result), + headers: new Headers({ + 'Content-Type': 'application/json', + 'X-CSRFToken': Cookies.get('csrftoken') + }) + }); + return false; +} + +/** + * HTTP GET request to get the loan options table if present. If not, get default loan options and lenders list. + */ +function getLoanOptionsTable() { + request(`loan-options/?loans=all-loans`, { + method: 'GET', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json' + }, + }).then(res => { + if (res.payload.load) { + loadLoanOptionsColumnHeadings(); + if (res.payload.status.length > 0) { + var lenders = res.payload.lenders; + var LoansList = res.payload.status; + if (res.payload.present) { + loadLoanOptionsTextBox('Required NOI DSCR', 'required-noi-dscr', res.payload.noi); + loadLoanOptionsTextBox('Required Cash DSCR', 'required-cash-dscr', res.payload.cash); + for (var index = 0; index < LoansList.length; index++) { + addLoanOptionsRow(lenders,LoansList[index].lender,LoansList[index].interest_rate,LoansList[index].duration,LoansList[index].max_loan_amount); + } + } + else { + loadLoanOptionsTextBox('Required NOI DSCR', 'required-noi-dscr', ''); + loadLoanOptionsTextBox('Required Cash DSCR', 'required-cash-dscr', ''); + addLoanOptionsRow(lenders,LoansList[0].lender,LoansList[0].interest_rate,LoansList[0].duration,LoansList[0].max_loan_amount); + } + } + } + }); +} diff --git a/blocnote/apps/financialInputs/templates/financialInputs/index.html b/blocnote/apps/financialInputs/templates/financialInputs/index.html index 1cd3039f5a1f30b7552c28f584dce1dd8877ee8f..11d2565f02f74eadccf89eee1e5ee029050a0211 100644 --- a/blocnote/apps/financialInputs/templates/financialInputs/index.html +++ b/blocnote/apps/financialInputs/templates/financialInputs/index.html @@ -39,6 +39,11 @@ {% include "financialInputs/liabilities.html" %} +
+
+ {% include "financialInputs/loanOptions.html" %} +
+
{% include "financialInputs/customerPreference.html" %} diff --git a/blocnote/apps/financialInputs/templates/financialInputs/loanOptions.html b/blocnote/apps/financialInputs/templates/financialInputs/loanOptions.html new file mode 100644 index 0000000000000000000000000000000000000000..08bddccfd144d1b85e1d698fb9ea77ff4a45c8d1 --- /dev/null +++ b/blocnote/apps/financialInputs/templates/financialInputs/loanOptions.html @@ -0,0 +1,29 @@ +
+

Loan Options

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + + + + +
+ +
+
+
diff --git a/blocnote/apps/financialInputs/urls.py b/blocnote/apps/financialInputs/urls.py index 8856aed8d065ed1e90ccf7034c71e7d213bb24e7..ce55b448c3ba52ec85c79e4b01602416bed513a1 100644 --- a/blocnote/apps/financialInputs/urls.py +++ b/blocnote/apps/financialInputs/urls.py @@ -11,4 +11,5 @@ urlpatterns = [ url(r'^liabilities/$', views.LiabilitiesTable.as_view(), name='liabilities'), url(r'^cash-balance/$', views.CashBalanceView.as_view(), name='cash_balance'), url(r'^income-statement/$', views.IncomeStatementTable.as_view(), name='income_statement'), + url(r'^loan-options/$', views.LoanOptionsTable.as_view(), name='loan_options'), ] diff --git a/blocnote/apps/financialInputs/views.py b/blocnote/apps/financialInputs/views.py index b909292d6b7fa60662cd5d1d2a0afe9e2234ed0a..905c7b25475ece6d45f4f31deebb47c602928b1d 100644 --- a/blocnote/apps/financialInputs/views.py +++ b/blocnote/apps/financialInputs/views.py @@ -9,7 +9,7 @@ from bpfin.utilbills.bill_backend_call import bill_prior_proj_rough_annual from bpfin.financials.financial_lib import organize_bill_overview from bpfin.financials.financial_lib import Income_Statement_Table -from .models import FinancingOverview, Bills, BillsOverview, CustomerPreference, EstimationAlgorithm, Liabilities, CashBalance, IncomeStatement +from .models import Fund, FinancingOverview, Bills, BillsOverview, CustomerPreference, EstimationAlgorithm, Liabilities, CashBalance, IncomeStatement, LoanOptions, DefaultLoan, Lender from .forms import BlocNoteHeaderForm @@ -952,3 +952,155 @@ class IncomeStatementTable(View): result.append(record) instance['result'] = result return JsonResponse({'instance': instance}) + + +class LoanOptionsTable(View): + """Select loan options for the building.""" + + model = LoanOptions + + def get_default_loans(self, default_loan_options_objs): + """Return the default loans given model object. + + Args: + default_loan_options_objs: List of default loan objects for the stored fund. + + Returns: + result: Dictionary with loan terms for the fund. + """ + result = [] + for obj in default_loan_options_objs: + record = {} + record['lender'] = obj.lender.name + record['interest_rate'] = obj.interest_rate + record['duration'] = obj.duration + record['max_loan_amount'] = obj.max_loan_amount + result.append(record) + return result + + def get(self, request, building_id): + """Handle HTTP GET request. + + Args: + request: HTTP GET request. Request has 3 options- delete-loan-options, default and all-loans. + building_id: id of the building. + + Returns: + JsonResponse: response is a dictionary. Detailed explanation is provided below. + """ + result = [] + lenders = [] + response = {} + # Obtain the type of request. + get_type = request.GET.get('loans') + + # delete-loan-options request deletes existing loan options stored. This happens when the user changes the fund + # for the building. Since fund and lender loans go in pair, anything stored prior to this is not needed. + if get_type == 'delete-loan-options': + self.model.objects.filter(building_id=building_id).delete() + return JsonResponse(response) + + # Fetch the fund selected for this building. If fund not selected yet, return load as false to indicate the + # frontend not to load the loan options table. + financing_overview_obj = FinancingOverview.objects.filter( + building_id=building_id, + ) + if financing_overview_obj: + fund_id = financing_overview_obj[0].fund.id + else: + response = { + 'load': False, + } + return JsonResponse(response) + + # Retreive the default loan options objects for the fund selected. + default_loan_options_objs = DefaultLoan.objects.filter( + fund=fund_id, + ) + + # Retrieve the list of lenders for the fund. + for obj in default_loan_options_objs: + lenders.append(obj.lender.name) + + # default request sends the list of lenders and default loan terms to the frontend. + if get_type == 'default': + result = self.get_default_loans(default_loan_options_objs) + response = { + 'status': result, + 'lenders': lenders, + } + return JsonResponse(response) + + # The other request is all-loans which fetches the loan options stored for this building id. + else: + loan_options_objs = self.model.objects.filter( + building_id=building_id, + ) + if loan_options_objs: + financing_overview_obj = FinancingOverview.objects.get( + building_id=building_id, + ) + noi_dscr = financing_overview_obj.required_noi_dscr + cash_dscr = financing_overview_obj.requrired_cash_dscr + for obj in loan_options_objs: + record = {} + record['lender'] = obj.lender.name + record['interest_rate'] = obj.interest_rate + record['duration'] = obj.duration + record['max_loan_amount'] = obj.max_loan_amount + result.append(record) + + # load tells the frontend that a fund has been selected for this building and it must load the loan + # options table. present tells the frontend that loan options have previosly been saved and to load + # them. If present is not true, the frontend knows to load one of the default loan options. + response = { + 'load': True, + 'present': True, + 'status': result, + 'lenders': lenders, + 'noi': noi_dscr, + 'cash': cash_dscr, + } + return JsonResponse(response) + else: + result = self.get_default_loans(default_loan_options_objs) + response = { + 'load': True, + 'status': result, + 'lenders': lenders, + } + return JsonResponse(response) + + def put(self, request, building_id): + """Handle HTTP PUT request. + + fetch the fund from the database.Delete all existing loan options for the biulding and store the new data. + + Args: + request: HTTP PUT request with the loan options rows in the body. + building_id: id of the building. + + Returns: + JsonResponse: status OK. + """ + put = json.loads(request.body.decode()) + financing_overview_obj = FinancingOverview.objects.filter( + building_id=building_id, + ) + financing_overview_obj.update( + required_noi_dscr=float(put['noi-dscr']), + requrired_cash_dscr=float(put['cash-dscr']), + ) + self.model.objects.filter(building_id=building_id).delete() + for record in put['instance']: + lender_id = Lender.objects.filter( + name=record['lender'], + )[0].id + self.model.objects.create( + building_id=building_id, + lender_id=lender_id, + interest_rate=record['interest-rate'], + duration=record['duration'], + max_loan_amount=record['maximum-loan-amount'] + ) + return JsonResponse({'status': 'OK'})