From f4e64119d4a4247cede5cf193d088edeec4a1819 Mon Sep 17 00:00:00 2001 From: Sam Dudley Date: Thu, 13 Feb 2025 11:46:58 +0000 Subject: [PATCH 1/2] FFT-191 Add django-waffle and add feature-flags make command --- config/settings/base.py | 2 ++ makefile | 3 +++ poetry.lock | 21 ++++++++++++++++++--- pyproject.toml | 4 ++-- 4 files changed, 25 insertions(+), 5 deletions(-) diff --git a/config/settings/base.py b/config/settings/base.py index aaf1a1ab..fa3ef995 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -83,6 +83,7 @@ "axes", "django_chunk_upload_handlers", "csp", + "waffle", ] ROOT_URLCONF = "config.urls" @@ -284,6 +285,7 @@ def FILTERS_VERBOSE_LOOKUPS(): "core.no_cache_middleware.NoCacheMiddleware", "simple_history.middleware.HistoryRequestMiddleware", "axes.middleware.AxesMiddleware", + "waffle.middleware.WaffleMiddleware", "core.middleware.CoreRequestDataMiddleware", ] diff --git a/makefile b/makefile index c78a7d85..c5b79d07 100644 --- a/makefile +++ b/makefile @@ -46,6 +46,7 @@ create-stub-data: # Create stub data for testing $(web) $(manage) populate_gift_hospitality_table $(web) $(manage) loaddata test_payroll_data $(web) $(manage) create_test_user --password=password + make feature-flags setup: # Set up the project from scratch make down @@ -85,6 +86,8 @@ test-ci: # Run tests with settings for CI superuser: # Create superuser $(web) $(manage) createsuperuser +feature-flags: # Manage feature flags for local development + echo 'Add feature flags here' # Formatting black-check: # Run black-check $(run-host) black --check . diff --git a/poetry.lock b/poetry.lock index c40530ad..86fcdb4e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1198,6 +1198,21 @@ Django = ">=3.2" [package.extras] tablib = ["tablib"] +[[package]] +name = "django-waffle" +version = "4.2.0" +description = "A feature flipper for Django." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "django_waffle-4.2.0-py3-none-any.whl", hash = "sha256:774f45b929627c9d303620c85419ce1da54066f2082d741af014f5bbd747e372"}, + {file = "django_waffle-4.2.0.tar.gz", hash = "sha256:97709550f4e75ce2a20b13e29f39777e1439a968569f2ee89398ca368afd586c"}, +] + +[package.dependencies] +django = ">=3.2" + [[package]] name = "djangorestframework" version = "3.15.2" @@ -1723,7 +1738,7 @@ version = "1.14.1" description = "Optional static typing for Python" optional = false python-versions = ">=3.8" -groups = ["main", "dev"] +groups = ["dev"] files = [ {file = "mypy-1.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:52686e37cf13d559f668aa398dd7ddf1f92c5d613e4f8cb262be2fb4fedb0fcb"}, {file = "mypy-1.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1fb545ca340537d4b45d3eecdb3def05e913299ca72c290326be19b3804b39c0"}, @@ -1782,7 +1797,7 @@ version = "1.0.0" description = "Type system extensions for programs checked with the mypy type checker." optional = false python-versions = ">=3.5" -groups = ["main", "dev"] +groups = ["dev"] files = [ {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, @@ -3274,4 +3289,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = "^3.12" -content-hash = "ac7ae0b3cbcc3cbd302c58bdb7b7634cf8eb8ad6a9185a9102f09dce40c8afc1" +content-hash = "8da35be8fa10899988720ab95eb1c74eaeebc2dd8fbea64c7a74e29cc74a8f61" diff --git a/pyproject.toml b/pyproject.toml index ee8ee33e..9c0e8c8e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,7 @@ django-audit-log-middleware = "^0.0.5" dbt-copilot-python = "^0.2.1" django-import-export = "^3.3.07" django-log-formatter-asim = "^0.0.6" -mypy = "^1.14.1" +django-waffle = "^4.2.0" [tool.poetry.group.dev] optional = true @@ -67,7 +67,7 @@ pyperclip = "^1.8.0" freezegun = "^1.0.0" isort = "^5.10.1" ruff = "^0.3.4" -mypy = "^1.13.0" +mypy = "^1.14.1" django-debug-toolbar = "^4.4.6" debugpy = "1.8.9" From 17d2dea92554ef9793bdf6f0c4796396479c47f0 Mon Sep 17 00:00:00 2001 From: Sam Dudley Date: Thu, 13 Feb 2025 11:48:26 +0000 Subject: [PATCH 2/2] FFT-111 Add actualisation to import actuals --- config/flags.py | 1 + conftest.py | 17 ++ core/constants.py | 3 +- core/types.py | 15 ++ forecast/import_actuals.py | 49 ++++- forecast/models.py | 12 +- forecast/services.py | 8 +- .../test/test_assets/actualisation_test.xlsx | Bin 0 -> 7557 bytes forecast/test/test_import_actuals.py | 189 +++++++++++++++++- makefile | 2 + 10 files changed, 280 insertions(+), 16 deletions(-) create mode 100644 config/flags.py create mode 100644 conftest.py create mode 100644 forecast/test/test_assets/actualisation_test.xlsx diff --git a/config/flags.py b/config/flags.py new file mode 100644 index 00000000..0d62a879 --- /dev/null +++ b/config/flags.py @@ -0,0 +1 @@ +ACTUALISATION = "actualisation" diff --git a/conftest.py b/conftest.py new file mode 100644 index 00000000..a275cd59 --- /dev/null +++ b/conftest.py @@ -0,0 +1,17 @@ +import pytest +from django.contrib.auth import get_user_model + + +@pytest.fixture +def test_user(db): + test_user_email = "test@test.com" + test_password = "test_password" + + test_user, _ = get_user_model().objects.get_or_create( + username="test_user", + email=test_user_email, + ) + test_user.set_password(test_password) + test_user.save() + + return test_user diff --git a/core/constants.py b/core/constants.py index b580d382..9cd05d9b 100644 --- a/core/constants.py +++ b/core/constants.py @@ -1,4 +1,4 @@ -from .types import Months +from .types import FinancialPeriods, Months MONTHS: Months = ( @@ -15,3 +15,4 @@ "feb", "mar", ) +PERIODS: FinancialPeriods = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12) diff --git a/core/types.py b/core/types.py index 3e7427ef..a3267028 100644 --- a/core/types.py +++ b/core/types.py @@ -1,6 +1,21 @@ from typing import Literal, TypedDict +FinancialPeriods = tuple[ + Literal[1], + Literal[2], + Literal[3], + Literal[4], + Literal[5], + Literal[6], + Literal[7], + Literal[8], + Literal[9], + Literal[10], + Literal[11], + Literal[12], +] + Month = Literal[ "apr", "may", diff --git a/forecast/import_actuals.py b/forecast/import_actuals.py index e769cb11..78bfe13f 100644 --- a/forecast/import_actuals.py +++ b/forecast/import_actuals.py @@ -1,8 +1,12 @@ import datetime import logging +import waffle from django.db import connection +from django.db.models import F +from config import flags +from core.constants import PERIODS from core.import_csv import get_fk, get_fk_from_field from core.models import FinancialYear from forecast.models import ( @@ -292,8 +296,16 @@ def upload_trial_balance_report(file_upload, month_number, financial_year): if check_financial_code.error_found: final_status = FileUpload.PROCESSEDWITHERROR else: + uploaded_actuals = ActualUploadMonthlyFigure.objects.filter( + financial_year=financial_year, financial_period=period_obj + ) + # Now copy the newly uploaded actuals to the correct table if year_obj.current: + if waffle.switch_is_active(flags.ACTUALISATION): + for uploaded_actual in uploaded_actuals: + actualisation(period=period_obj, actual=uploaded_actual) + copy_current_year_actuals_to_monthly_figure(period_obj, financial_year) FinancialPeriod.objects.filter( financial_period_code__lte=period_obj.financial_period_code @@ -307,11 +319,42 @@ def upload_trial_balance_report(file_upload, month_number, financial_year): if check_financial_code.warning_found: final_status = FileUpload.PROCESSEDWITHWARNING - ActualUploadMonthlyFigure.objects.filter( - financial_year=financial_year, financial_period=period_obj - ).delete() + uploaded_actuals.delete() set_file_upload_feedback( file_upload, f"Processed {rows_to_process} rows.", final_status ) return True + + +def actualisation(period: FinancialPeriod, actual: ActualUploadMonthlyFigure) -> None: + # get the current forecast that is being turned into an actual + forecast = ForecastMonthlyFigure.objects.filter( + financial_code_id=actual.financial_code_id, + financial_year_id=actual.financial_year_id, + financial_period_id=actual.financial_period_id, + archived_status__isnull=True, + ).first() + + # work out how many period we have left in the financial year + periods_left = len(PERIODS) - period.financial_period_code + # handle a missing forecast object and assume a forecast amount of 0 + forecast_amount = forecast.amount if forecast else 0 + # work out the difference the actual will leave us with + difference = forecast_amount - actual.amount + + if periods_left: + # floor divide the difference by how many periods are left in the financial year + # TODO: How should monetary values be treated with regards to rounding? + difference //= periods_left + + # adjust the remaining forecast periods by the difference + for i in range(periods_left): + ForecastMonthlyFigure.objects.update_or_create( + financial_code_id=actual.financial_code_id, + financial_year_id=actual.financial_year_id, + financial_period_id=period.pk + i + 1, + archived_status=None, + defaults={"amount": F("amount") + difference}, + create_defaults={"amount": difference}, + ) diff --git a/forecast/models.py b/forecast/models.py index 0670532c..f0271115 100644 --- a/forecast/models.py +++ b/forecast/models.py @@ -287,9 +287,12 @@ def get_max_period(self): class FinancialPeriod(BaseModel): - """Financial periods: correspond - to month, but there are 3 extra - periods at the end""" + """Financial periods: correspond to month, but there are 3 extra periods at the end. + + There are 15 objects in total. + + The objects are managed in migrations and therefore always available in tests. + """ financial_period_code = models.IntegerField(primary_key=True) # April = 1 period_long_name = models.CharField(max_length=20) @@ -1027,8 +1030,9 @@ class MonthlyFigureAbstract(BaseModel): """It contains the forecast and the actuals. The current month defines what is Actual and what is Forecast""" - amount = models.BigIntegerField(default=0) # stored in pence id = models.AutoField(primary_key=True) + + amount = models.BigIntegerField(default=0) # stored in pence financial_year = models.ForeignKey( FinancialYear, on_delete=models.PROTECT, diff --git a/forecast/services.py b/forecast/services.py index a3551467..341a9d54 100644 --- a/forecast/services.py +++ b/forecast/services.py @@ -1,4 +1,4 @@ -from core.constants import MONTHS +from core.constants import PERIODS from core.models import FinancialYear from forecast.models import FinancialCode, FinancialPeriod, ForecastMonthlyFigure @@ -40,7 +40,7 @@ def update_period(self, *, period: int | FinancialPeriod, amount: int): figure.save() def update(self, forecast: list[int]): - assert len(forecast) == len(MONTHS) + assert len(forecast) == len(PERIODS) - for i, _ in enumerate(MONTHS): - self.update_period(period=i + 1, amount=forecast[i]) + for period in PERIODS: + self.update_period(period=period, amount=forecast[period - 1]) diff --git a/forecast/test/test_assets/actualisation_test.xlsx b/forecast/test/test_assets/actualisation_test.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..e20c88da540f1898230af88353f16f1409202149 GIT binary patch literal 7557 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 k8u&do|I@&a2Lt~PhNvncK77e`W3e04EzCk^lez literal 0 HcmV?d00001 diff --git a/forecast/test/test_import_actuals.py b/forecast/test/test_import_actuals.py index ed0b91bc..5844e978 100644 --- a/forecast/test/test_import_actuals.py +++ b/forecast/test/test_import_actuals.py @@ -3,6 +3,8 @@ from unittest.mock import MagicMock, patch from zipfile import BadZipFile +import pytest +import waffle.testutils from django.contrib.auth.models import Group, Permission from django.core.files import File from django.db.models import Sum @@ -11,7 +13,9 @@ from chartofaccountDIT.models import NaturalCode, ProgrammeCode from chartofaccountDIT.test.factories import NaturalCodeFactory, ProgrammeCodeFactory +from config import flags from core.models import FinancialYear +from core.test.factories import FinancialYearFactory from core.test.test_base import TEST_COST_CENTRE, BaseTestCase from core.utils.excel_test_helpers import FakeCell, FakeWorkSheet from core.utils.generic_helpers import make_financial_year_current @@ -25,6 +29,7 @@ NAC_NOT_VALID_WITH_GENERIC_PROGRAMME, TITLE_CELL, UploadFileFormatError, + actualisation, check_trial_balance_format, copy_current_year_actuals_to_monthly_figure, save_trial_balance_row, @@ -36,6 +41,7 @@ FinancialPeriod, ForecastMonthlyFigure, ) +from forecast.test.factories import FinancialCodeFactory from forecast.utils.import_helpers import VALID_ECONOMIC_CODE_LIST, CheckFinancialCode from upload_file.models import FileUpload @@ -306,6 +312,7 @@ def test_save_row_invalid_nac(self): True, ) + @waffle.testutils.override_switch(flags.ACTUALISATION, active=True) def test_upload_trial_balance_report(self): # Check that BadZipFile is raised on # supply of incorrect file format @@ -443,25 +450,43 @@ def test_upload_trial_balance_report(self): # Check that existing figures for the same period have been deleted self.assertEqual( ForecastMonthlyFigure.objects.filter( - financial_code__cost_centre=cost_centre_code_1 + financial_code__cost_centre=cost_centre_code_1, + financial_year=self.test_year, + financial_period__period_calendar_code=self.test_period, ).count(), 0, ) # Check for existence of monthly figures self.assertEqual( ForecastMonthlyFigure.objects.filter( - financial_code__cost_centre=self.cost_centre_code + financial_code__cost_centre=self.cost_centre_code, + financial_year=self.test_year, + financial_period__period_calendar_code=self.test_period, ).count(), 4, ) result = ForecastMonthlyFigure.objects.filter( - financial_code__cost_centre=self.cost_centre_code + financial_code__cost_centre=self.cost_centre_code, + financial_year=self.test_year, + financial_period__period_calendar_code=self.test_period, ).aggregate(total=Sum("amount")) # Check that figures have correct values self.assertEqual( result["total"], - 1000000, + 1_000_000, + ) + + result_inc_actualisation = ForecastMonthlyFigure.objects.filter( + financial_code__cost_centre=self.cost_centre_code, + ).aggregate(total=Sum("amount")) + + # Check that actualisation has been applied and that the total forecast remains + # the same. In this case it will be "zero" (or close to depending on rounding) + # because there was a total forecast of 0 before uploading the actual. + self.assertEqual( + result_inc_actualisation["total"], + -8, # due to floor division ) self.assertTrue( @@ -793,3 +818,159 @@ def test_finance_admin_can_upload_actuals(self, mock_process_uploaded_file): file_path = "uploaded/actuals/{}".format(self.file_mock.name) if os.path.exists(file_path): os.remove(file_path) + + +@waffle.testutils.override_switch(flags.ACTUALISATION, active=True) +def test_actualisation_as_part_of_upload(db, test_user): + # This test is based off an example given to me. + + # figures are in pence + figures = [ + # actuals apr - aug + 10_500_00, + 5_000_00, + 2_500_00, + 3_000_00, + 6_000_00, + # forecast sep - mar + 10_000_00, + 10_000_00, + 10_000_00, + 10_000_00, + 10_000_00, + 10_000_00, + 10_000_00, + ] + expected_figures = [ + # actuals apr - sep + 10_500_00, + 5_000_00, + 2_500_00, + 3_000_00, + 6_000_00, + 7_000_00, + # forecast oct - mar + 10_500_00, + 10_500_00, + 10_500_00, + 10_500_00, + 10_500_00, + 10_500_00, + ] + + actual_period = FinancialPeriod.objects.get(pk=6) # sep + # actual_amount = 7_000 # stored in actualisation_test.xslx + + fin_code: FinancialCode = FinancialCodeFactory( + natural_account_code__economic_budget_code=VALID_ECONOMIC_CODE_LIST[0], + ) + fin_year: FinancialYear = FinancialYearFactory() + + make_financial_year_current(fin_year.pk) + + for i, amount in enumerate(figures): + FinancialPeriod.objects.filter(pk=i + 1).update(actual_loaded=True) + ForecastMonthlyFigure.objects.create( + financial_code=fin_code, + financial_year=fin_year, + financial_period_id=i + 1, + amount=amount, + ) + + excel_file = FileUpload( + s3_document_file=os.path.join( + os.path.dirname(__file__), + "test_assets/actualisation_test.xlsx", + ), + uploading_user=test_user, + document_type=FileUpload.ACTUALS, + ) + excel_file.save() + upload_trial_balance_report( + excel_file, + actual_period.period_calendar_code, + fin_year.pk, + ) + + new_figures = ( + ForecastMonthlyFigure.objects.filter( + financial_code=fin_code, + financial_year=fin_year, + archived_status__isnull=True, + ) + .order_by("financial_period") + .values_list("amount", flat=True) + ) + + assert list(new_figures) == expected_figures + + +@pytest.mark.parametrize( + ["figures", "period", "actual", "expected_figures"], + [ + # fmt: off + ( + [10_500, 5_000, 2_500, 3_000, 6_000, 10_000, 10_000, 10_000, 10_000, 10_000, 10_000, 10_000], + 6, # september + 7_000, + [10_500, 5_000, 2_500, 3_000, 6_000, 7_000, 10_500, 10_500, 10_500, 10_500, 10_500, 10_500], + ), + ( + [10_500, 5_000, 2_500, 3_000, 6_000, 10_000, 10_000, 10_000, 10_000, 10_000, 10_000, 10_000], + 6, # september + 7050, + [10_500, 5_000, 2_500, 3_000, 6_000, 7_000, 10_491.66, 10_491.66, 10_491.66, 10_491.66, 10_491.66, 10_491.66], + ), + ( + [0, 0, 0, 50_000, 0, 0, 0, 0, 0, 50_000, 0, 0], + 6, # september + 12_000, + [0, 0, 0, 50_000, 0, 12_000, -2000, -2000, -2000, 48_000, -2000, -2000], + ), + ( + [10_500, 5_000, 2_500, 3_000, 6_000, 10_000, 10_000, 10_000, 10_000, 10_000, 10_000, 10_000], + 12, # march + 7_000, + [10_500, 5_000, 2_500, 3_000, 6_000, 10_000, 10_000, 10_000, 10_000, 10_000, 10_000, 7_000], + ), + # fmt: on\ + ], +) +def test_actualisation( + db, figures: list[float], period: int, actual: float, expected_figures: list[float] +): + period_obj = FinancialPeriod.objects.get(pk=period) + + fin_code: FinancialCode = FinancialCodeFactory( + natural_account_code__economic_budget_code=VALID_ECONOMIC_CODE_LIST[0], + ) + fin_year: FinancialYear = FinancialYearFactory() + + for i, amount in enumerate(figures): + ForecastMonthlyFigure.objects.create( + financial_code=fin_code, + financial_year=fin_year, + financial_period_id=i + 1, + amount=amount * 100, + ) + + actual_obj = ActualUploadMonthlyFigure.objects.create( + financial_code=fin_code, + financial_year=fin_year, + financial_period=period_obj, + amount=actual * 100, + ) + + actualisation(period=period_obj, actual=actual_obj) + + new_figures = ( + ForecastMonthlyFigure.objects.filter( + financial_code=fin_code, + financial_year=fin_year, + archived_status__isnull=True, + ) + .order_by("financial_period") + .values_list("amount", flat=True) + ) + + assert list(new_figures[period:]) == [x * 100 for x in expected_figures][period:] diff --git a/makefile b/makefile index c5b79d07..29e353dc 100644 --- a/makefile +++ b/makefile @@ -88,6 +88,8 @@ superuser: # Create superuser feature-flags: # Manage feature flags for local development echo 'Add feature flags here' + $(web) $(manage) waffle_switch actualisation on --create + # Formatting black-check: # Run black-check $(run-host) black --check .