diff --git a/.env.ci b/.env.ci index b868fd46e..f5d3c52cb 100644 --- a/.env.ci +++ b/.env.ci @@ -10,3 +10,4 @@ AUTHBROKER_URL= SENTRY_ENVIRONMENT=ci SENTRY_DSN= CSP_REPORT_URI= +PAYROLL={"BASIC_PAY_NAC": "71111001", "PENSION_NAC": "71111002", "ERNIC_NAC": "71111003", "VACANCY_NAC": "71111001"} diff --git a/.env.example b/.env.example index d0afcc561..3d8059296 100644 --- a/.env.example +++ b/.env.example @@ -32,6 +32,8 @@ CSP_REPORT_URI= # Vite VITE_DEV=True +PAYROLL={"BASIC_PAY_NAC": "71111001", "PENSION_NAC": "71111002", "ERNIC_NAC": "71111003", "VACANCY_NAC": "71111001"} + # Not documented (needed?) # RESTRICT_ADMIN=True # PUBLIC_PATH="http://localhost:8000" diff --git a/config/settings/base.py b/config/settings/base.py index 4c98b9324..d63ffebbd 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -11,6 +11,7 @@ """ import os +from dataclasses import dataclass from pathlib import Path import dj_database_url @@ -414,3 +415,16 @@ def FILTERS_VERBOSE_LOOKUPS(): CSP_REPORT_ONLY = True CSP_REPORT_URI = env.str("CSP_REPORT_URI", default=None) + + +# Payroll +@dataclass +class Payroll: + BASIC_PAY_NAC: str | None = None + PENSION_NAC: str | None = None + ERNIC_NAC: str | None = None + VACANCY_NAC: str | None = None + AVERAGE_SALARY_THRESHOLD: int = 2 + + +PAYROLL: Payroll = Payroll(**env.json("PAYROLL", default={})) diff --git a/config/settings/ci.py b/config/settings/ci.py index 3a4b59593..dcb1aa5e1 100644 --- a/config/settings/ci.py +++ b/config/settings/ci.py @@ -13,3 +13,7 @@ AXES_ENABLED = False STORAGES["default"]["BACKEND"] = "django.core.files.storage.FileSystemStorage" + +# I'm not aware of any case where we need the history whilst running tests. This should +# hopefully speed up the tests a little bit. +SIMPLE_HISTORY_ENABLED = False diff --git a/core/admin.py b/core/admin.py index 006f84c45..7f36214df 100644 --- a/core/admin.py +++ b/core/admin.py @@ -12,7 +12,7 @@ from django.views.decorators.csrf import csrf_exempt from core.export_data import export_logentry_iterator -from core.models import CommandLog, FinancialYear +from core.models import CommandLog, FinancialYear, PayUplift from core.utils.export_helpers import ( export_csv_from_import, export_to_csv, @@ -368,6 +368,25 @@ def has_delete_permission(self, request, obj=None): return False +class PayUpliftAdmin(admin.ModelAdmin): + list_display = ( + "financial_year", + "apr", + "may", + "jun", + "jul", + "aug", + "sep", + "oct", + "nov", + "dec", + "jan", + "feb", + "mar", + ) + + admin.site.register(LogEntry, LogEntryAdmin) admin.site.register(FinancialYear, FinancialYearAdmin) admin.site.register(CommandLog, CustomLogModelAdmin) +admin.site.register(PayUplift, PayUpliftAdmin) diff --git a/core/constants.py b/core/constants.py new file mode 100644 index 000000000..b580d3824 --- /dev/null +++ b/core/constants.py @@ -0,0 +1,17 @@ +from .types import Months + + +MONTHS: Months = ( + "apr", + "may", + "jun", + "jul", + "aug", + "sep", + "oct", + "nov", + "dec", + "jan", + "feb", + "mar", +) diff --git a/core/migrations/0014_payuplift.py b/core/migrations/0014_payuplift.py new file mode 100644 index 000000000..ffb9f470b --- /dev/null +++ b/core/migrations/0014_payuplift.py @@ -0,0 +1,47 @@ +# Generated by Django 5.1.3 on 2024-12-06 15:33 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0013_alter_historicalgroup_options_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="PayUplift", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("apr", models.FloatField(default=1.0)), + ("may", models.FloatField(default=1.0)), + ("jun", models.FloatField(default=1.0)), + ("jul", models.FloatField(default=1.0)), + ("aug", models.FloatField(default=1.0)), + ("sep", models.FloatField(default=1.0)), + ("oct", models.FloatField(default=1.0)), + ("nov", models.FloatField(default=1.0)), + ("dec", models.FloatField(default=1.0)), + ("jan", models.FloatField(default=1.0)), + ("feb", models.FloatField(default=1.0)), + ("mar", models.FloatField(default=1.0)), + ( + "financial_year", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + to="core.financialyear", + ), + ), + ], + ), + ] diff --git a/core/models.py b/core/models.py index 0c69ae69f..fff434a5c 100644 --- a/core/models.py +++ b/core/models.py @@ -3,6 +3,8 @@ from django.db import models from simple_history import register +from core.constants import MONTHS + from .metamodels import BaseModel @@ -87,6 +89,29 @@ def __str__(self): return str(self.financial_year_display) +class PayUplift(models.Model): + @property + def periods(self) -> list[float]: + return [getattr(self, month) for month in MONTHS] + + financial_year = models.ForeignKey( + FinancialYear, + on_delete=models.PROTECT, + ) + apr = models.FloatField(default=1.0) + may = models.FloatField(default=1.0) + jun = models.FloatField(default=1.0) + jul = models.FloatField(default=1.0) + aug = models.FloatField(default=1.0) + sep = models.FloatField(default=1.0) + oct = models.FloatField(default=1.0) + nov = models.FloatField(default=1.0) + dec = models.FloatField(default=1.0) + jan = models.FloatField(default=1.0) + feb = models.FloatField(default=1.0) + mar = models.FloatField(default=1.0) + + # Track changes to permissions register(Permission, app=__package__, inherit=True) register(get_user_model(), app=__package__, inherit=True) diff --git a/core/templates/base_generic.html b/core/templates/base_generic.html index d35f42160..ec802ae80 100644 --- a/core/templates/base_generic.html +++ b/core/templates/base_generic.html @@ -1,6 +1,7 @@ {% load util %} {% load breadcrumbs %} {% load forecast_permissions %} +{% load payroll_permissions %} {% load upload_permissions %} {% load download_permissions %} {% load upload_percentage_permissions %} @@ -128,8 +129,18 @@ + {% can_edit_payroll user as can_edit_payroll %} + {% if can_edit_payroll %} +
  • + + Edit payroll + +
  • + {% endif %} + {% is_forecast_user user as is_forecast_user %} - {% if is_forecast_user == True %} + {% if is_forecast_user %}
  • @@ -137,7 +148,7 @@
  • {% can_edit_at_least_one_cost_centre user as can_edit_at_least_one_cost_centre %} - {% if can_edit_at_least_one_cost_centre == True %} + {% if can_edit_at_least_one_cost_centre %}
  • @@ -146,7 +157,7 @@
  • {% endif %} {% has_mi_report_download_permission user as has_mi_report_download_permission %} - {% if has_mi_report_download_permission == True %} + {% if has_mi_report_download_permission %}
  • @@ -156,7 +167,7 @@ {% endif %} {% has_oscar_download_permission user as has_oscar_download_permission %} - {% if has_oscar_download_permission == True %} + {% if has_oscar_download_permission %}
  • @@ -165,7 +176,7 @@
  • {% endif %} {% has_upload_permission user as has_upload_permission %} - {% if has_upload_permission == True %} + {% if has_upload_permission %}
  • @@ -175,7 +186,7 @@ {% endif %} {% endif %} {% has_project_percentage_permission user as has_project_percentage_permission %} - {% if has_project_percentage_permission == True %} + {% if has_project_percentage_permission %}
  • diff --git a/core/types.py b/core/types.py new file mode 100644 index 000000000..3e7427ef2 --- /dev/null +++ b/core/types.py @@ -0,0 +1,46 @@ +from typing import Literal, TypedDict + + +Month = Literal[ + "apr", + "may", + "jun", + "jul", + "aug", + "sep", + "oct", + "nov", + "dec", + "jan", + "feb", + "mar", +] +Months = tuple[ + Literal["apr"], + Literal["may"], + Literal["jun"], + Literal["jul"], + Literal["aug"], + Literal["sep"], + Literal["oct"], + Literal["nov"], + Literal["dec"], + Literal["jan"], + Literal["feb"], + Literal["mar"], +] + + +class MonthsDict[T](TypedDict): + apr: T + may: T + jun: T + jul: T + aug: T + sep: T + oct: T + nov: T + dec: T + jan: T + feb: T + mar: T diff --git a/forecast/templates/forecast/edit/edit.html b/forecast/templates/forecast/edit/edit.html index 8b43b6999..473420652 100644 --- a/forecast/templates/forecast/edit/edit.html +++ b/forecast/templates/forecast/edit/edit.html @@ -1,4 +1,5 @@ {% extends "forecast/edit/forecast_base.html" %} +{% load payroll_permissions %} {% load util vite %} {% load breadcrumbs %} @@ -41,6 +42,7 @@ {% endblock %} {% block scripts %} {{ view.get_payroll_forecast_report|json_script:'payroll_forecast_data' }} + {% can_edit_payroll user as can_edit_payroll %} {% vite_dev_client %} {% vite_js 'src/index.jsx' %} diff --git a/forecast/templatetags/forecast_format.py b/forecast/templatetags/forecast_format.py index 42a84a0fc..8b01103b8 100644 --- a/forecast/templatetags/forecast_format.py +++ b/forecast/templatetags/forecast_format.py @@ -37,6 +37,19 @@ ] +@register.filter +def format_money(value: float) -> str: + """Format as a monetary value. + + `value` is expected to be in pence and will be divided by 100. + + Examples: + >>> format_money(1024312) + '10,243.12' + """ + return f"{value / 100:,.2f}" + + @register.filter() def is_forecast_figure(_, column): if str(column) in forecast_figure_cols: diff --git a/forecast/views/edit_forecast.py b/forecast/views/edit_forecast.py index 2d5663b3a..fd2c8d392 100644 --- a/forecast/views/edit_forecast.py +++ b/forecast/views/edit_forecast.py @@ -528,7 +528,6 @@ def get_context_data(self, **kwargs): context["forecast_dump"] = forecast_dump context["actuals"] = actual_data context["period_display"] = period_display - context["can_toggle_payroll"] = self.request.user.is_superuser return context diff --git a/front_end/src/Apps/Payroll.jsx b/front_end/src/Apps/Payroll.jsx index bfbb0640b..75a7c8496 100644 --- a/front_end/src/Apps/Payroll.jsx +++ b/front_end/src/Apps/Payroll.jsx @@ -1,12 +1,26 @@ -import { useEffect, useReducer, useState } from "react"; +import { useEffect, useReducer, useState, useMemo } from "react"; -import EditPayroll from "../Components/EditPayroll"; import * as api from "../Components/EditPayroll/api"; +import { + payrollHeaders, + vacancyHeaders, +} from "../Components/EditPayroll/constants"; +import EmployeeRow from "../Components/EditPayroll/EmployeeRow"; +import VacancyRow from "../Components/EditPayroll/VacancyRow"; +import PayrollTable from "../Components/EditPayroll/PayrollTable"; const initialPayrollState = []; +const initialVacanciesState = []; export default function Payroll() { - const [payroll, dispatch] = useReducer(payrollReducer, initialPayrollState); + const [allPayroll, dispatch] = useReducer( + payrollReducer, + initialPayrollState, + ); + const [vacancies, dispatchVacancies] = useReducer( + vacanciesReducer, + initialVacanciesState, + ); const [saveSuccess, setSaveSuccess] = useState(false); useEffect(() => { @@ -17,12 +31,26 @@ export default function Payroll() { } api.getPayrollData().then((data) => dispatch({ type: "fetched", data })); + api + .getVacancyData() + .then((data) => dispatchVacancies({ type: "fetched", data })); }, []); + // Computed properties + const payroll = useMemo( + () => allPayroll.filter((payroll) => payroll.basic_pay > 0), + [allPayroll], + ); + const nonPayroll = useMemo( + () => allPayroll.filter((payroll) => payroll.basic_pay <= 0), + [allPayroll], + ); + // Handlers async function handleSavePayroll() { try { - await api.postPayrollData(payroll); + await api.postPayrollData(allPayroll); + await api.postVacancyData(vacancies); setSaveSuccess(true); localStorage.setItem("saveSuccess", "true"); @@ -33,43 +61,86 @@ export default function Payroll() { } } - function handleTogglePayPeriods(employeeNo, index, enabled) { - dispatch({ type: "updatePayPeriods", employeeNo, index, enabled }); + function handleTogglePayPeriods(id, index, enabled) { + dispatch({ type: "updatePayPeriods", id, index, enabled }); + } + + function handleToggleVacancyPayPeriods(id, index, enabled) { + dispatchVacancies({ type: "updatePayPeriods", id, index, enabled }); } return ( - + <> + {saveSuccess && ( +
    +
    +

    + Success +

    +
    +
    + )} +

    Payroll

    + +

    Non-payroll

    + +

    Vacancies

    + +
    + Add Vacancy + + + ); } -function payrollReducer(payroll, action) { +const positionReducer = (data, action) => { switch (action.type) { case "fetched": { return action.data; } case "updatePayPeriods": { - return payroll.map((employeeRow) => { - if (employeeRow.employee_no == action.employeeNo) { - const updatedPayPeriods = employeeRow.pay_periods.map( - (period, index) => { - if (index + 1 >= action.index + 1) { - return !action.enabled; - } - return period; - }, - ); + return data.map((row) => { + if (row.id === action.id) { + const updatedPayPeriods = row.pay_periods.map((period, index) => { + if (index + 1 >= action.index + 1) { + return !action.enabled; + } + return period; + }); return { - ...employeeRow, + ...row, pay_periods: updatedPayPeriods, }; } - return employeeRow; + return row; }); } } -} +}; + +const payrollReducer = (data, action) => positionReducer(data, action); +const vacanciesReducer = (data, action) => positionReducer(data, action); diff --git a/front_end/src/Components/EditForecast/index.jsx b/front_end/src/Components/EditForecast/index.jsx index 9732abfb6..7a8c2d69d 100644 --- a/front_end/src/Components/EditForecast/index.jsx +++ b/front_end/src/Components/EditForecast/index.jsx @@ -334,7 +334,7 @@ function EditForecast() { return ( - {window.can_toggle_payroll === "True" && ( + {window.can_edit_payroll === "True" && ( { return ( {row.name} + {row.grade} {row.employee_no} - {row.pay_periods.map((enabled, index) => { - return ( - - - onTogglePayPeriods(row.employee_no, index, enabled) - } - /> - - ); - })} + {row.fte} + {row.programme_code} + {row.budget_type} + {row.assignment_status} + ); }; diff --git a/front_end/src/Components/EditPayroll/PayPeriods/index.jsx b/front_end/src/Components/EditPayroll/PayPeriods/index.jsx new file mode 100644 index 000000000..6a2f20f19 --- /dev/null +++ b/front_end/src/Components/EditPayroll/PayPeriods/index.jsx @@ -0,0 +1,19 @@ +const PayPeriods = ({ row, id, onTogglePayPeriods }) => { + return ( + <> + {row.pay_periods.map((enabled, index) => { + return ( + + onTogglePayPeriods(id, index, enabled)} + /> + + ); + })} + + ); +}; + +export default PayPeriods; diff --git a/front_end/src/Components/EditPayroll/PayrollTable/index.jsx b/front_end/src/Components/EditPayroll/PayrollTable/index.jsx index 713089373..beb48ca2a 100644 --- a/front_end/src/Components/EditPayroll/PayrollTable/index.jsx +++ b/front_end/src/Components/EditPayroll/PayrollTable/index.jsx @@ -1,38 +1,43 @@ -import EmployeeRow from "../EmployeeRow"; - /** * * @param {object} props * @param {types.PayrollData[]} props.payroll * @returns */ -export default function PayrollTable({ headers, payroll, onTogglePayPeriods }) { +export default function PayrollTable({ + headers, + payroll, + onTogglePayPeriods, + RowComponent, +}) { return ( <> - - - - {headers.map((header) => { +
    +
    + + + {headers.map((header) => { + return ( + + ); + })} + + + + {payroll.map((row) => { return ( - + ); })} - - - - {payroll.map((row) => { - return ( - - ); - })} - -
    + {header} +
    - {header} -
    + + + ); } diff --git a/front_end/src/Components/EditPayroll/VacancyRow/index.jsx b/front_end/src/Components/EditPayroll/VacancyRow/index.jsx new file mode 100644 index 000000000..3eb29d012 --- /dev/null +++ b/front_end/src/Components/EditPayroll/VacancyRow/index.jsx @@ -0,0 +1,31 @@ +import PayPeriods from "../PayPeriods"; + +const VacancyRow = ({ row, onTogglePayPeriods }) => { + return ( + + + + Edit + + + {row.recruitment_type} + {row.grade} + {row.programme_code} + {row.budget_type} + {row.appointee_name} + {row.hiring_manager} + {row.hr_ref} + {row.recruitment_stage} + + + ); +}; + +export default VacancyRow; diff --git a/front_end/src/Components/EditPayroll/api.js b/front_end/src/Components/EditPayroll/api.js index e1d734894..f4847f6c2 100644 --- a/front_end/src/Components/EditPayroll/api.js +++ b/front_end/src/Components/EditPayroll/api.js @@ -20,6 +20,27 @@ export function postPayrollData(payrollData) { return postData(getPayrollApiUrl(), JSON.stringify(payrollData)); } +/** + * Fetch vacancy data and return it as a promise. + * @returns {Promise} A promise resolving to an array of objects containing vacancy information. + */ +export function getVacancyData() { + return getData(getPayrollApiUrl() + "vacancies/").then((data) => data.data); +} + +/** + * Post modified vacancy data. + * + * @param {types.VacancyData[]} vacancyData - Vacancy data to be sent. + * @returns {import("../../Util").PostDataResponse} Updated vacancy data received. + */ +export function postVacancyData(vacancyData) { + return postData( + getPayrollApiUrl() + "vacancies/", + JSON.stringify(vacancyData) + ); +} + /** * Return the payroll API URL. * diff --git a/front_end/src/Components/EditPayroll/constants.js b/front_end/src/Components/EditPayroll/constants.js new file mode 100644 index 000000000..6948dfbfe --- /dev/null +++ b/front_end/src/Components/EditPayroll/constants.js @@ -0,0 +1,36 @@ +const monthHeaders = [ + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + "Jan", + "Feb", + "Mar", +]; + +export const payrollHeaders = [ + "Name", + "Grade", + "Employee No", + "FTE", + "Programme Code", + "Budget Type", + "Assignment Status", +].concat(monthHeaders); + +export const vacancyHeaders = [ + "Manage", + "Recruitment Type", + "Grade", + "Programme Code", + "Budget Type", + "Appointee Name", + "Hiring Manager", + "HR Ref", + "Recruitment Stage", +].concat(monthHeaders); \ No newline at end of file diff --git a/front_end/src/Components/EditPayroll/index.jsx b/front_end/src/Components/EditPayroll/index.jsx deleted file mode 100644 index f930ff0df..000000000 --- a/front_end/src/Components/EditPayroll/index.jsx +++ /dev/null @@ -1,56 +0,0 @@ -import * as types from "./types"; -import PayrollTable from "./PayrollTable/index"; - -/** - * - * @param {object} props - * @param {types.PayrollData[]} props.payroll - * @returns - */ -export default function EditPayroll({ - payroll, - onSavePayroll, - onTogglePayPeriods, - saveSuccess, -}) { - const headers = [ - "Name", - "Employee No", - "Apr", - "May", - "Jun", - "Jul", - "Aug", - "Sep", - "Oct", - "Nov", - "Dec", - "Jan", - "Feb", - "Mar", - ]; - return ( - <> - {saveSuccess && ( -
    -
    -

    - Success -

    -
    -
    - )} - - - - ); -} diff --git a/front_end/src/Components/EditPayroll/types.js b/front_end/src/Components/EditPayroll/types.js index 13c2fadad..5db24baef 100644 --- a/front_end/src/Components/EditPayroll/types.js +++ b/front_end/src/Components/EditPayroll/types.js @@ -1,8 +1,28 @@ /** * @typedef {Object} PayrollData * @property {string} name - The employee's name. + * @property {string} grade - The employee's grade. * @property {string} employee_no - The employee's number. + * @property {number} fte - The employee's FTE. + * @property {string} programme_code - The employee's programme code. + * @property {string} budget_type - The employee's programme code budget type. + * @property {string} assignment_status - The employee's assignment status. + * @property {string} basic_pay - The employee's basic pay. * @property {boolean[]} pay_periods - Whether the employee is being paid in periods. */ +/** + * @typedef {Object} VacancyData + * @property {string} id - The vacancy's pk. + * @property {string} grade - The vacancy's grade. + * @property {string} programme_code - The vacancy's programme code. + * @property {string} budget_type - The vacancy's programme code budget type. + * @property {string} recruitment_type - The vacancy's recruitment type. + * @property {string} recruitment_stage - The vacancy's recruitment stage. + * @property {string} appointee_name - The vacancy's appointee name. + * @property {string} hiring_manager - The vacancy's hiring manager. + * @property {string} hr_ref - The vacancy's hr ref. + * @property {boolean[]} pay_periods - Whether the vacancy is being paid in periods. + */ + export const Types = {}; diff --git a/front_end/src/Util.js b/front_end/src/Util.js index afec887ca..e0dbdd170 100644 --- a/front_end/src/Util.js +++ b/front_end/src/Util.js @@ -171,10 +171,8 @@ export const processForecastData = ( let overrideAmount = null; if (isPayrollEnabled && mappedPayrollData[forecastKey]) { - const period = `period_${parseInt(key) + 1}_sum`; - // TODO (FFT-99): Decide on decimal vs pence - // Old code stores monetary values in pence whereas new code has used decimals. - overrideAmount = mappedPayrollData[forecastKey][period] * 100; + const period = months[parseInt(key)]; + overrideAmount = mappedPayrollData[forecastKey][period]; } cells[monthlyFigure.month] = { @@ -202,7 +200,7 @@ const processPayrollData = (payrollData) => { for (const [key, value] of Object.entries(payrollData)) { const generatedKey = makeFinancialCodeKey( value.programme_code, - value.pay_element__type__group__natural_code, + value.natural_account_code, ); results[generatedKey] = value; diff --git a/front_end/styles/styles.scss b/front_end/styles/styles.scss index 7f909fbbd..e2bc02e97 100644 --- a/front_end/styles/styles.scss +++ b/front_end/styles/styles.scss @@ -368,3 +368,7 @@ th { .upload-list { font-size: 16px; } + +.scrollable { + overflow-x: auto; +} diff --git a/gifthospitality/test/factories.py b/gifthospitality/test/factories.py index c43046630..abbc9db23 100644 --- a/gifthospitality/test/factories.py +++ b/gifthospitality/test/factories.py @@ -5,6 +5,7 @@ GiftAndHospitalityCategory, GiftAndHospitalityClassification, GiftAndHospitalityCompany, + Grade, ) @@ -42,3 +43,12 @@ class GiftsAndHospitalityCompanyFactory(factory.django.DjangoModelFactory): class Meta: model = GiftAndHospitalityCompany + + +class GradeFactory(factory.django.DjangoModelFactory): + class Meta: + model = Grade + django_get_or_create = ("grade",) + + grade = factory.Sequence(lambda n: f"Grade {n}") + gradedescription = factory.Sequence(lambda n: f"Description of Grade {n}") diff --git a/makefile b/makefile index 0c3c77af6..274840c61 100644 --- a/makefile +++ b/makefile @@ -41,13 +41,13 @@ create-stub-data: # Create stub data for testing $(web) $(manage) create_stub_forecast_data $(web) $(manage) create_stub_future_forecast_data $(web) $(manage) create_data_lake_stub_data + $(web) $(manage) populate_gift_hospitality_table $(web) $(manage) loaddata test_payroll_data $(web) $(manage) create_test_user --password=password setup: # Set up the project from scratch make down make create-stub-data - make gift-hospitality-table $(web) $(manage) create_test_user --password=password $(web) $(manage) create_test_user --email=finance-admin@test.com --group="Finance Administrator" --password=password # /PS-IGNORE $(web) $(manage) create_test_user --email=finance-bp@test.com --group="Finance Business Partner/BSCE" --password=password # /PS-IGNORE diff --git a/payroll/admin.py b/payroll/admin.py index c761e0c87..057b0e844 100644 --- a/payroll/admin.py +++ b/payroll/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from payroll.services.payroll import employee_created +from payroll.services.payroll import employee_created, vacancy_created from .models import ( Employee, @@ -8,6 +8,7 @@ EmployeePayPeriods, PayElementType, PayElementTypeGroup, + Vacancy, ) @@ -75,3 +76,21 @@ class PayElementTypeGroupAdmin(admin.ModelAdmin): "name", "natural_code", ] + + +@admin.register(Vacancy) +class VacancyAdmin(admin.ModelAdmin): + list_display = [ + "cost_centre", + "grade", + "programme_code", + "appointee_name", + "hiring_manager", + "hr_ref", + ] + + def save_model(self, request, obj, form, change): + super().save_model(request, obj, form, change) + + if not change: + vacancy_created(obj) diff --git a/payroll/fixtures/test_payroll_data.json b/payroll/fixtures/test_payroll_data.json index 278efd6f7..ad5af4fb5 100644 --- a/payroll/fixtures/test_payroll_data.json +++ b/payroll/fixtures/test_payroll_data.json @@ -5,8 +5,14 @@ "fields": { "cost_centre": "888812", "employee_no": "00000001", + "programme_code": "338887", "first_name": "John", - "last_name": "Smith" + "last_name": "Smith", + "grade": "Grade 7", + "assignment_status": "Active Assignment", + "basic_pay": 230000, + "pension": 40000, + "ernic": 9000 } }, { @@ -15,8 +21,46 @@ "fields": { "cost_centre": "888812", "employee_no": "00000002", + "programme_code": "338887", "first_name": "Jane", - "last_name": "Doe" + "last_name": "Doe", + "grade": "Grade 7", + "assignment_status": "Active Contingent Assignment", + "basic_pay": 268000, + "pension": 60000, + "ernic": 50000 + } + }, + { + "model": "payroll.employee", + "pk": 3, + "fields": { + "cost_centre": "888812", + "employee_no": "00000003", + "programme_code": "338887", + "first_name": "John", + "last_name": "Doe", + "grade": "Grade 7", + "assignment_status": "Loan Out - Non Payroll", + "basic_pay": 0, + "pension": 0, + "ernic": 0 + } + }, + { + "model": "payroll.employee", + "pk": 4, + "fields": { + "cost_centre": "888812", + "employee_no": "00000004", + "programme_code": "338887", + "first_name": "Jane", + "last_name": "Smith", + "grade": "Grade 7", + "assignment_status": "Active Assignment", + "basic_pay": 0, + "pension": 0, + "ernic": 0 } }, { @@ -179,6 +223,166 @@ "period_12": true } }, + { + "model": "payroll.employeepayperiods", + "pk": 9, + "fields": { + "employee": 3, + "year": 2024, + "period_1": false, + "period_2": false, + "period_3": false, + "period_4": false, + "period_5": false, + "period_6": false, + "period_7": false, + "period_8": false, + "period_9": false, + "period_10": false, + "period_11": false, + "period_12": false + } + }, + { + "model": "payroll.employeepayperiods", + "pk": 10, + "fields": { + "employee": 3, + "year": 2025, + "period_1": false, + "period_2": false, + "period_3": false, + "period_4": false, + "period_5": false, + "period_6": false, + "period_7": false, + "period_8": false, + "period_9": false, + "period_10": false, + "period_11": false, + "period_12": false + } + }, + { + "model": "payroll.employeepayperiods", + "pk": 11, + "fields": { + "employee": 3, + "year": 2026, + "period_1": false, + "period_2": false, + "period_3": false, + "period_4": false, + "period_5": false, + "period_6": false, + "period_7": false, + "period_8": false, + "period_9": false, + "period_10": false, + "period_11": false, + "period_12": false + } + }, + { + "model": "payroll.employeepayperiods", + "pk": 12, + "fields": { + "employee": 3, + "year": 2027, + "period_1": false, + "period_2": false, + "period_3": false, + "period_4": false, + "period_5": false, + "period_6": false, + "period_7": false, + "period_8": false, + "period_9": false, + "period_10": false, + "period_11": false, + "period_12": false + } + }, + { + "model": "payroll.employeepayperiods", + "pk": 13, + "fields": { + "employee": 4, + "year": 2024, + "period_1": false, + "period_2": false, + "period_3": false, + "period_4": false, + "period_5": false, + "period_6": false, + "period_7": false, + "period_8": false, + "period_9": false, + "period_10": false, + "period_11": false, + "period_12": false + } + }, + { + "model": "payroll.employeepayperiods", + "pk": 14, + "fields": { + "employee": 4, + "year": 2025, + "period_1": false, + "period_2": false, + "period_3": false, + "period_4": false, + "period_5": false, + "period_6": false, + "period_7": false, + "period_8": false, + "period_9": false, + "period_10": false, + "period_11": false, + "period_12": false + } + }, + { + "model": "payroll.employeepayperiods", + "pk": 15, + "fields": { + "employee": 4, + "year": 2026, + "period_1": false, + "period_2": false, + "period_3": false, + "period_4": false, + "period_5": false, + "period_6": false, + "period_7": false, + "period_8": false, + "period_9": false, + "period_10": false, + "period_11": false, + "period_12": false + } + }, + { + "model": "payroll.employeepayperiods", + "pk": 16, + "fields": { + "employee": 4, + "year": 2027, + "period_1": false, + "period_2": false, + "period_3": false, + "period_4": false, + "period_5": false, + "period_6": false, + "period_7": false, + "period_8": false, + "period_9": false, + "period_10": false, + "period_11": false, + "period_12": false + } + }, { "model": "payroll.payelementtypegroup", "pk": 1, diff --git a/payroll/forms.py b/payroll/forms.py new file mode 100644 index 000000000..fa80ee0cc --- /dev/null +++ b/payroll/forms.py @@ -0,0 +1,25 @@ +from django import forms + +from payroll.models import Vacancy + + +class VacancyForm(forms.ModelForm): + class Meta: + model = Vacancy + fields = "__all__" + exclude = ["cost_centre", "fte"] + widgets = { + "recruitment_type": forms.Select(attrs={"class": "govuk-select"}), + "grade": forms.Select(attrs={"class": "govuk-select"}), + "recruitment_stage": forms.Select(attrs={"class": "govuk-select"}), + "programme_code": forms.Select(attrs={"class": "govuk-select"}), + "appointee_name": forms.TextInput( + attrs={"class": "govuk-input govuk-input--width-20"} + ), + "hiring_manager": forms.TextInput( + attrs={"class": "govuk-input govuk-input--width-20"} + ), + "hr_ref": forms.TextInput( + attrs={"class": "govuk-input govuk-input--width-20"} + ), + } diff --git a/payroll/migrations/0004_vacancy.py b/payroll/migrations/0004_vacancy.py new file mode 100644 index 000000000..32c92359a --- /dev/null +++ b/payroll/migrations/0004_vacancy.py @@ -0,0 +1,64 @@ +# Generated by Django 4.2.16 on 2024-11-11 15:38 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "gifthospitality", + "0006_alter_simplehistorygiftandhospitality_options_and_more", + ), + ("chartofaccountDIT", "0015_alter_simplehistoryanalysis1_options_and_more"), + ("payroll", "0003_employee_programme_code"), + ] + + operations = [ + migrations.CreateModel( + name="Vacancy", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "programme_switch_vacancy", + models.CharField( + choices=[("PS", "Programme Switch"), ("V", "Vacancy")], + max_length=2, + verbose_name="Programme switch / Vacancy", + ), + ), + ( + "appointee_name", + models.CharField(blank=True, max_length=255, null=True), + ), + ( + "hiring_manager", + models.CharField(blank=True, max_length=255, null=True), + ), + ("hr_ref", models.CharField(blank=True, max_length=255, null=True)), + ( + "grade", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + to="gifthospitality.grade", + ), + ), + ( + "programme_code", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + to="chartofaccountDIT.programmecode", + ), + ), + ], + ), + ] diff --git a/payroll/migrations/0005_alter_vacancy_options.py b/payroll/migrations/0005_alter_vacancy_options.py new file mode 100644 index 000000000..50cbc1df2 --- /dev/null +++ b/payroll/migrations/0005_alter_vacancy_options.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.16 on 2024-11-12 11:56 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("payroll", "0004_vacancy"), + ] + + operations = [ + migrations.AlterModelOptions( + name="vacancy", + options={"verbose_name_plural": "Vacancies"}, + ), + ] diff --git a/payroll/migrations/0006_alter_vacancy_programme_switch_vacancy.py b/payroll/migrations/0006_alter_vacancy_programme_switch_vacancy.py new file mode 100644 index 000000000..672aa3ea3 --- /dev/null +++ b/payroll/migrations/0006_alter_vacancy_programme_switch_vacancy.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.16 on 2024-11-12 15:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("payroll", "0005_alter_vacancy_options"), + ] + + operations = [ + migrations.AlterField( + model_name="vacancy", + name="programme_switch_vacancy", + field=models.CharField( + choices=[ + ("programme_switch", "Programme Switch"), + ("vacancy", "Vacancy"), + ], + max_length=16, + verbose_name="Programme switch / Vacancy", + ), + ), + ] diff --git a/payroll/migrations/0007_vacancy_cost_centre.py b/payroll/migrations/0007_vacancy_cost_centre.py new file mode 100644 index 000000000..f9ab69abf --- /dev/null +++ b/payroll/migrations/0007_vacancy_cost_centre.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.16 on 2024-11-13 13:44 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("costcentre", "0008_alter_simplehistoryarchivedcostcentre_options_and_more"), + ("payroll", "0006_alter_vacancy_programme_switch_vacancy"), + ] + + operations = [ + migrations.AddField( + model_name="vacancy", + name="cost_centre", + field=models.ForeignKey( + default="888812", + on_delete=django.db.models.deletion.PROTECT, + to="costcentre.costcentre", + ), + preserve_default=False, + ), + ] diff --git a/payroll/migrations/0008_vacancy_recruitment_stage_vacancy_recruitment_type.py b/payroll/migrations/0008_vacancy_recruitment_stage_vacancy_recruitment_type.py new file mode 100644 index 000000000..e2768cdec --- /dev/null +++ b/payroll/migrations/0008_vacancy_recruitment_stage_vacancy_recruitment_type.py @@ -0,0 +1,55 @@ +# Generated by Django 4.2.16 on 2024-11-13 16:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("payroll", "0007_vacancy_cost_centre"), + ] + + operations = [ + migrations.AddField( + model_name="vacancy", + name="recruitment_stage", + field=models.IntegerField( + choices=[ + (1, "Preparing"), + (2, "Advert (Vac ref to be provided)"), + (3, "Sift"), + (4, "Interview"), + (5, "Onboarding"), + (6, "Unsuccessful recruitment"), + (7, "Not (yet) advertised"), + (8, "Not required"), + ], + default=1, + ), + ), + migrations.AddField( + model_name="vacancy", + name="recruitment_type", + field=models.CharField( + choices=[ + ("expression_of_interest", "Expression of Interest"), + ( + "external_recruitment_non_bulk", + "External Recruitment (Non Bulk)", + ), + ( + "external_recruitment_bulk", + "External Recruitment (Bulk campaign)", + ), + ("internal_managed_move", "Internal Managed Move"), + ("internal_redeployment", "Internal Redeployment"), + ("other", "Other"), + ("inactive_post", "Inactive Post"), + ("expected_unknown_leavers", "Expected Unknown Leavers"), + ("missing_staff", "Missing Staff"), + ], + default="expression_of_interest", + max_length=29, + ), + ), + ] diff --git a/payroll/migrations/0009_remove_vacancy_programme_switch_vacancy.py b/payroll/migrations/0009_remove_vacancy_programme_switch_vacancy.py new file mode 100644 index 000000000..69e878765 --- /dev/null +++ b/payroll/migrations/0009_remove_vacancy_programme_switch_vacancy.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.16 on 2024-11-13 16:13 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("payroll", "0008_vacancy_recruitment_stage_vacancy_recruitment_type"), + ] + + operations = [ + migrations.RemoveField( + model_name="vacancy", + name="programme_switch_vacancy", + ), + ] diff --git a/payroll/migrations/0010_alter_vacancy_appointee_name_and_more.py b/payroll/migrations/0010_alter_vacancy_appointee_name_and_more.py new file mode 100644 index 000000000..23dd74bae --- /dev/null +++ b/payroll/migrations/0010_alter_vacancy_appointee_name_and_more.py @@ -0,0 +1,59 @@ +# Generated by Django 4.2.16 on 2024-11-18 12:07 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("payroll", "0009_remove_vacancy_programme_switch_vacancy"), + ] + + operations = [ + migrations.AlterField( + model_name="vacancy", + name="appointee_name", + field=models.CharField( + blank=True, + max_length=255, + null=True, + validators=[ + django.core.validators.RegexValidator( + message="Only letters, spaces, - and ' are allowed", + regex="^[a-zA-Z '-]*$", + ) + ], + ), + ), + migrations.AlterField( + model_name="vacancy", + name="hiring_manager", + field=models.CharField( + blank=True, + max_length=255, + null=True, + validators=[ + django.core.validators.RegexValidator( + message="Only letters, spaces, - and ' are allowed", + regex="^[a-zA-Z '-]*$", + ) + ], + ), + ), + migrations.AlterField( + model_name="vacancy", + name="hr_ref", + field=models.CharField( + blank=True, + max_length=255, + null=True, + validators=[ + django.core.validators.RegexValidator( + message="Only letters, spaces, - and ' are allowed", + regex="^[a-zA-Z '-]*$", + ) + ], + ), + ), + ] diff --git a/payroll/migrations/0011_alter_vacancy_appointee_name_and_more.py b/payroll/migrations/0011_alter_vacancy_appointee_name_and_more.py new file mode 100644 index 000000000..61a1897a4 --- /dev/null +++ b/payroll/migrations/0011_alter_vacancy_appointee_name_and_more.py @@ -0,0 +1,59 @@ +# Generated by Django 4.2.16 on 2024-11-18 13:52 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("payroll", "0010_alter_vacancy_appointee_name_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="vacancy", + name="appointee_name", + field=models.CharField( + blank=True, + max_length=255, + null=True, + validators=[ + django.core.validators.RegexValidator( + message="Only letters, spaces, - and ' are allowed.", + regex="^[a-zA-Z '-]*$", + ) + ], + ), + ), + migrations.AlterField( + model_name="vacancy", + name="hiring_manager", + field=models.CharField( + blank=True, + max_length=255, + null=True, + validators=[ + django.core.validators.RegexValidator( + message="Only letters, spaces, - and ' are allowed.", + regex="^[a-zA-Z '-]*$", + ) + ], + ), + ), + migrations.AlterField( + model_name="vacancy", + name="hr_ref", + field=models.CharField( + blank=True, + max_length=255, + null=True, + validators=[ + django.core.validators.RegexValidator( + message="Only letters, spaces, - and ' are allowed.", + regex="^[a-zA-Z '-]*$", + ) + ], + ), + ), + ] diff --git a/payroll/migrations/0012_employee_assignment_status_employee_fte_and_more.py b/payroll/migrations/0012_employee_assignment_status_employee_fte_and_more.py new file mode 100644 index 000000000..4a2240344 --- /dev/null +++ b/payroll/migrations/0012_employee_assignment_status_employee_fte_and_more.py @@ -0,0 +1,39 @@ +# Generated by Django 4.2.16 on 2024-11-12 15:52 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "gifthospitality", + "0006_alter_simplehistorygiftandhospitality_options_and_more", + ), + ("payroll", "0011_alter_vacancy_appointee_name_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="employee", + name="assignment_status", + field=models.CharField(default="Active Assignment", max_length=32), + preserve_default=False, + ), + migrations.AddField( + model_name="employee", + name="fte", + field=models.FloatField(default=1.0), + ), + migrations.AddField( + model_name="employee", + name="grade", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + to="gifthospitality.grade", + ), + ), + ] diff --git a/payroll/migrations/0013_vacancy_fte_alter_vacancy_grade_vacancypayperiods_and_more.py b/payroll/migrations/0013_vacancy_fte_alter_vacancy_grade_vacancypayperiods_and_more.py new file mode 100644 index 000000000..8ff3a336b --- /dev/null +++ b/payroll/migrations/0013_vacancy_fte_alter_vacancy_grade_vacancypayperiods_and_more.py @@ -0,0 +1,84 @@ +# Generated by Django 4.2.16 on 2024-11-21 13:51 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0013_alter_historicalgroup_options_and_more"), + ( + "gifthospitality", + "0006_alter_simplehistorygiftandhospitality_options_and_more", + ), + ("payroll", "0012_employee_assignment_status_employee_fte_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="vacancy", + name="fte", + field=models.FloatField(default=1.0), + ), + migrations.AlterField( + model_name="vacancy", + name="grade", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + to="gifthospitality.grade", + ), + ), + migrations.CreateModel( + name="VacancyPayPeriods", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("period_1", models.BooleanField(default=True)), + ("period_2", models.BooleanField(default=True)), + ("period_3", models.BooleanField(default=True)), + ("period_4", models.BooleanField(default=True)), + ("period_5", models.BooleanField(default=True)), + ("period_6", models.BooleanField(default=True)), + ("period_7", models.BooleanField(default=True)), + ("period_8", models.BooleanField(default=True)), + ("period_9", models.BooleanField(default=True)), + ("period_10", models.BooleanField(default=True)), + ("period_11", models.BooleanField(default=True)), + ("period_12", models.BooleanField(default=True)), + ( + "vacancy", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="pay_periods", + to="payroll.vacancy", + ), + ), + ( + "year", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + to="core.financialyear", + ), + ), + ], + options={ + "verbose_name_plural": "vacancy pay periods", + }, + ), + migrations.AddConstraint( + model_name="vacancypayperiods", + constraint=models.UniqueConstraint( + fields=("vacancy", "year"), name="unique_vacancy_pay_periods" + ), + ), + ] diff --git a/payroll/migrations/0014_alter_vacancypayperiods_vacancy.py b/payroll/migrations/0014_alter_vacancypayperiods_vacancy.py new file mode 100644 index 000000000..9855d5a49 --- /dev/null +++ b/payroll/migrations/0014_alter_vacancypayperiods_vacancy.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.16 on 2024-11-27 14:02 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("payroll", "0013_vacancy_fte_alter_vacancy_grade_vacancypayperiods_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="vacancypayperiods", + name="vacancy", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="pay_periods", + to="payroll.vacancy", + ), + ), + ] diff --git a/payroll/migrations/0015_employee_basic_pay_employee_ernic_employee_pension.py b/payroll/migrations/0015_employee_basic_pay_employee_ernic_employee_pension.py new file mode 100644 index 000000000..59f056722 --- /dev/null +++ b/payroll/migrations/0015_employee_basic_pay_employee_ernic_employee_pension.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.16 on 2024-11-27 14:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("payroll", "0014_alter_vacancypayperiods_vacancy"), + ] + + operations = [ + migrations.AddField( + model_name="employee", + name="basic_pay", + field=models.BigIntegerField(db_comment="pence", default=0), + ), + migrations.AddField( + model_name="employee", + name="ernic", + field=models.BigIntegerField(db_comment="pence", default=0), + ), + migrations.AddField( + model_name="employee", + name="pension", + field=models.BigIntegerField(db_comment="pence", default=0), + ), + ] diff --git a/payroll/models.py b/payroll/models.py index d7186f089..237012aa1 100644 --- a/payroll/models.py +++ b/payroll/models.py @@ -1,45 +1,54 @@ +from django.core.validators import RegexValidator from django.db import models +from django.db.models import F, Q, Sum + + +class EmployeeQuerySet(models.QuerySet): + def with_basic_pay(self): + return self.annotate( + basic_pay=Sum( + F("pay_element__debit_amount") - F("pay_element__credit_amount"), + # TODO (FFT-107): Resolve hard-coded references to "Basic Pay" + # This might change when we get round to ingesting the data, so I'm OK + # with it staying like this for now. + filter=Q(pay_element__type__group__name="Basic Pay"), + default=0, + output_field=models.FloatField(), + ) + ) + + def payroll(self): + return self.filter(basic_pay__gt=0) -class Employee(models.Model): +class Position(models.Model): + class Meta: + abstract = True + cost_centre = models.ForeignKey( "costcentre.CostCentre", models.PROTECT, ) - # I've been informed that an employee should only be associated to a single - # programme code. However, programme codes are actually assigned on a per pay - # element basis and in some cases an employee can be associated to multiple. This is - # seen as an edge case and we want to model it such that an employee only has a - # single programme code. We will have to handle this discrepancy somewhere. programme_code = models.ForeignKey( "chartofaccountDIT.ProgrammeCode", models.PROTECT, ) - employee_no = models.CharField(max_length=8, unique=True) - first_name = models.CharField(max_length=32) - last_name = models.CharField(max_length=32) - - def __str__(self) -> str: - return f"{self.employee_no} - {self.first_name} {self.last_name}" - - def get_full_name(self) -> str: - return f"{self.first_name} {self.last_name}" + grade = models.ForeignKey( + to="gifthospitality.Grade", + on_delete=models.PROTECT, + null=True, + blank=True, + ) + fte = models.FloatField(default=1.0) -class EmployeePayPeriods(models.Model): +class PositionPayPeriods(models.Model): class Meta: - verbose_name_plural = "employee pay periods" - constraints = [ - models.UniqueConstraint( - fields=("employee", "year"), - name="unique_employee_pay_periods", - ) - ] + abstract = True - employee = models.ForeignKey(Employee, models.PROTECT, related_name="pay_periods") year = models.ForeignKey("core.FinancialYear", models.PROTECT) # period 1 = apr, period 2 = may, etc... - # pariod 1 -> 12 = apr -> mar + # period 1 -> 12 = apr -> mar # Here is a useful text snippet: # apr period_1 # may period_2 @@ -76,6 +85,45 @@ def periods(self, value: list[bool]) -> None: setattr(self, f"period_{i + 1}", enabled) +class Employee(Position): + employee_no = models.CharField(max_length=8, unique=True) + first_name = models.CharField(max_length=32) + last_name = models.CharField(max_length=32) + assignment_status = models.CharField(max_length=32) + basic_pay = models.BigIntegerField(default=0, db_comment="pence") + pension = models.BigIntegerField(default=0, db_comment="pence") + ernic = models.BigIntegerField(default=0, db_comment="pence") + + # TODO: Missing fields from Admin Tool which aren't required yet. + # EU/Non-EU (from programme code model) + + objects = EmployeeQuerySet.as_manager() + + def __str__(self) -> str: + return f"{self.employee_no} - {self.first_name} {self.last_name}" + + def get_full_name(self) -> str: + return f"{self.first_name} {self.last_name}" + + +class EmployeePayPeriods(PositionPayPeriods): + class Meta: + verbose_name_plural = "employee pay periods" + constraints = [ + models.UniqueConstraint( + fields=("employee", "year"), + name="unique_employee_pay_periods", + ) + ] + + employee = models.ForeignKey(Employee, models.PROTECT, related_name="pay_periods") + + # TODO: Missing fields from Admin Tool which aren't required yet. + # capital (Real colour of money) + # recharge = models.CharField(max_length=50, null=True, blank=True) + # recharge_reason = models.CharField(max_length=100, null=True, blank=True) + + # aka "ToolTypePayment" class PayElementTypeGroup(models.Model): name = models.CharField(max_length=32, unique=True) @@ -104,3 +152,95 @@ class EmployeePayElement(models.Model): debit_amount = models.DecimalField(max_digits=9, decimal_places=2) # Support up to 9,999,999.99. credit_amount = models.DecimalField(max_digits=9, decimal_places=2) + + +class Vacancy(Position): + class Meta: + verbose_name_plural = "Vacancies" + + class RecruitmentType(models.TextChoices): + EXPRESSION_OF_INTEREST = "expression_of_interest", "Expression of Interest" + EXTERNAL_RECRUITMENT_NON_BULK = ( + "external_recruitment_non_bulk", + "External Recruitment (Non Bulk)", + ) + EXTERNAL_RECRUITMENT_BULK = ( + "external_recruitment_bulk", + "External Recruitment (Bulk campaign)", + ) + INTERNAL_MANAGED_MOVE = "internal_managed_move", "Internal Managed Move" + INTERNAL_REDEPLOYMENT = "internal_redeployment", "Internal Redeployment" + OTHER = "other", "Other" + INACTIVE_POST = "inactive_post", "Inactive Post" + EXPECTED_UNKNOWN_LEAVERS = ( + "expected_unknown_leavers", + "Expected Unknown Leavers", + ) + MISSING_STAFF = "missing_staff", "Missing Staff" + + recruitment_type = models.CharField( + max_length=29, + choices=RecruitmentType.choices, + default=RecruitmentType.EXPRESSION_OF_INTEREST, + ) + + class RecruitmentStage(models.IntegerChoices): + PREPARING = 1, "Preparing" + ADVERT = 2, "Advert (Vac ref to be provided)" + SIFT = 3, "Sift" + INTERVIEW = 4, "Interview" + ONBOARDING = 5, "Onboarding" + UNSUCCESSFUL_RECRUITMENT = 6, "Unsuccessful recruitment" + NOT_YET_ADVERTISED = 7, "Not (yet) advertised" + NOT_REQUIRED = 8, "Not required" + + recruitment_stage = models.IntegerField( + choices=RecruitmentStage.choices, default=RecruitmentStage.PREPARING + ) + + appointee_name = models.CharField( + max_length=255, + null=True, + blank=True, + validators=[ + RegexValidator( + regex=r"^[a-zA-Z '-]*$", + message="Only letters, spaces, - and ' are allowed.", + ) + ], + ) + hiring_manager = models.CharField( + max_length=255, + null=True, + blank=True, + validators=[ + RegexValidator( + regex=r"^[a-zA-Z '-]*$", + message="Only letters, spaces, - and ' are allowed.", + ) + ], + ) + hr_ref = models.CharField( + max_length=255, + null=True, + blank=True, + validators=[ + RegexValidator( + regex=r"^[a-zA-Z '-]*$", + message="Only letters, spaces, - and ' are allowed.", + ) + ], + ) + + +class VacancyPayPeriods(PositionPayPeriods): + class Meta: + verbose_name_plural = "vacancy pay periods" + constraints = [ + models.UniqueConstraint( + fields=("vacancy", "year"), + name="unique_vacancy_pay_periods", + ) + ] + + vacancy = models.ForeignKey(Vacancy, models.CASCADE, related_name="pay_periods") diff --git a/payroll/services/payroll.py b/payroll/services/payroll.py index c7b8c6ea0..54e6bbf63 100644 --- a/payroll/services/payroll.py +++ b/payroll/services/payroll.py @@ -1,67 +1,170 @@ -from decimal import Decimal +from collections import defaultdict +from statistics import mean from typing import Iterator, TypedDict +import numpy as np +import numpy.typing as npt +from django.conf import settings from django.db import transaction -from django.db.models import F, Q, Sum +from django.db.models import Avg, Count, Q -from core.models import FinancialYear +from core.constants import MONTHS +from core.models import FinancialYear, PayUplift +from core.types import MonthsDict from costcentre.models import CostCentre +from gifthospitality.models import Grade -from ..models import Employee, EmployeePayPeriods +from ..models import Employee, EmployeePayPeriods, Vacancy, VacancyPayPeriods def employee_created(employee: Employee) -> None: """Hook to be called after an employee instance is created.""" - # Create EmployeePayPeriods records for current and future financial years. - create_employee_pay_periods(employee) + create_pay_periods(employee) + return None + +def vacancy_created(vacancy: Vacancy) -> None: + """Hook to be called after a vacancy instance is created.""" + # Create VacancyPayPeriods records for current and future financial years. + create_pay_periods(vacancy, pay_period_enabled=False) return None -def create_employee_pay_periods(employee: Employee) -> None: +def create_pay_periods(instance, pay_period_enabled=None) -> None: current_financial_year = FinancialYear.objects.current() future_financial_years = FinancialYear.objects.future() financial_years = [current_financial_year] + list(future_financial_years) - for financial_year in financial_years: - EmployeePayPeriods.objects.get_or_create(employee=employee, year=financial_year) + pay_periods_model = None + field_name = "" + if isinstance(instance, Employee): + pay_periods_model = EmployeePayPeriods + field_name = "employee" + elif isinstance(instance, Vacancy): + pay_periods_model = VacancyPayPeriods + field_name = "vacancy" + else: + raise ValueError("Unsupported instance type for creating pay periods") -def payroll_forecast_report(cost_centre: CostCentre, financial_year: FinancialYear): - period_sum_annotations = { - f"period_{i+1}_sum": Sum( - F("pay_element__debit_amount") - F("pay_element__credit_amount"), - filter=Q(**{f"pay_periods__period_{i+1}": True}), - default=Decimal(0), - ) - for i in range(12) - } + defaults = {} + if pay_period_enabled is not None: + defaults = {f"period_{i+1}": pay_period_enabled for i in range(12)} - qs = ( - Employee.objects.filter( - cost_centre=cost_centre, - pay_periods__year=financial_year, - ) - .order_by( - "programme_code", - "pay_element__type__group", + for financial_year in financial_years: + pay_periods_model.objects.get_or_create( + defaults=defaults, **{field_name: instance, "year": financial_year} ) - .values( - "programme_code", - "pay_element__type__group__natural_code", - "pay_element__type__group", - "pay_element__type__group__name", + + +class PayrollForecast(MonthsDict[float]): + programme_code: str + natural_account_code: str + + +def payroll_forecast_report( + cost_centre: CostCentre, financial_year: FinancialYear +) -> Iterator[PayrollForecast]: + # { programme_code: { natural_account_code: np.array[ np.float64 ] } } + report: dict[str, dict[str, npt.NDArray[np.float64]]] = defaultdict( + lambda: defaultdict(lambda: np.zeros(12)) + ) + + employee_qs = Employee.objects.filter( + cost_centre=cost_centre, + pay_periods__year=financial_year, + ) + pay_uplift_obj = PayUplift.objects.filter(financial_year=financial_year).first() + + pay_uplift = ( + np.array( + PayUplift.objects.filter(financial_year=financial_year).first().periods ) - .annotate(**period_sum_annotations) + if pay_uplift_obj is not None + else np.ones(12) + ) + + for employee in employee_qs.iterator(): + periods = employee.pay_periods.first().periods + periods = np.array(periods) + + periods = periods * pay_uplift + + prog_report = report[employee.programme_code_id] + prog_report[settings.PAYROLL.BASIC_PAY_NAC] += periods * employee.basic_pay + prog_report[settings.PAYROLL.PENSION_NAC] += periods * employee.pension + prog_report[settings.PAYROLL.ERNIC_NAC] += periods * employee.ernic + + vacancy_qs = Vacancy.objects.filter( + cost_centre=cost_centre, + pay_periods__year=financial_year, ) + for vacancy in vacancy_qs.iterator(): + avg_salary = get_average_salary_for_grade(vacancy.grade, cost_centre) + salary = vacancy.fte * avg_salary + + periods = vacancy.pay_periods.first().periods + periods = np.array(periods) + + prog_report = report[vacancy.programme_code_id] + prog_report[settings.PAYROLL.VACANCY_NAC] += periods * salary + + for programme_code in report: + for nac, forecast in report[programme_code].items(): + forecast_months: MonthsDict[float] = dict(zip(MONTHS, forecast, strict=False)) # type: ignore + + yield PayrollForecast( + programme_code=programme_code, + natural_account_code=nac, + **forecast_months, + ) - return qs + +# TODO (FFT-131): Apply caching to the average salary calculation +def get_average_salary_for_grade(grade: Grade, cost_centre: CostCentre) -> int: + employee_count_threshold = settings.PAYROLL.AVERAGE_SALARY_THRESHOLD + + # Expanding scope filters which start at the cost centre and end at all employees. + filters = [ + Q(cost_centre=cost_centre), + Q(cost_centre__directorate=cost_centre.directorate), + Q(cost_centre__directorate__group=cost_centre.directorate.group), + Q(), + ] + + salaries: list[int] = [] + + for filter in filters: + employee_qs = Employee.objects.payroll().filter(grade=grade).filter(filter) + + basic_pay = employee_qs.aggregate( + count=Count("basic_pay"), avg=Avg("basic_pay") + ) + + if basic_pay["count"] >= employee_count_threshold: + return basic_pay["avg"] + + if basic_pay["count"]: + salaries += list(employee_qs.values_list("basic_pay", flat=True)) + + if salaries: + return round(mean(salaries)) # pence + + # TODO: What do we do if there were no employees at all found at that grade? + return 0 class EmployeePayroll(TypedDict): + id: int name: str + grade: str employee_no: str + fte: float + programme_code: str + budget_type: str + assignment_status: str + basic_pay: float pay_periods: list[bool] @@ -69,16 +172,31 @@ def get_payroll_data( cost_centre: CostCentre, financial_year: FinancialYear, ) -> Iterator[EmployeePayroll]: - qs = EmployeePayPeriods.objects.select_related("employee") - qs = qs.filter( - employee__cost_centre=cost_centre, - year=financial_year, + qs = ( + Employee.objects.select_related( + "programme_code__budget_type", + ) + .prefetch_related( + "pay_periods", + ) + .filter( + cost_centre=cost_centre, + pay_periods__year=financial_year, + ) ) for obj in qs: yield EmployeePayroll( - name=obj.employee.get_full_name(), - employee_no=obj.employee.employee_no, - pay_periods=obj.periods, + id=obj.pk, + name=obj.get_full_name(), + grade=obj.grade.pk, + employee_no=obj.employee_no, + fte=obj.fte, + programme_code=obj.programme_code.pk, + budget_type=obj.programme_code.budget_type.budget_type_display, + assignment_status=obj.assignment_status, + basic_pay=obj.basic_pay, + # `first` is OK as there should only be one `pay_periods` with the filters. + pay_periods=obj.pay_periods.first().periods, ) @@ -86,7 +204,7 @@ def get_payroll_data( def update_payroll_data( cost_centre: CostCentre, financial_year: FinancialYear, - payroll_data: list[EmployeePayroll], + data: list[EmployeePayroll], ) -> None: """Update a cost centre payroll for a given year using the provided list. @@ -98,7 +216,7 @@ def update_payroll_data( ValueError: If there are not 12 items in the pay_periods list. ValueError: If any of the pay_periods are not of type bool. """ - for payroll in payroll_data: + for payroll in data: if not payroll["employee_no"]: raise ValueError("employee_no is empty") @@ -115,3 +233,85 @@ def update_payroll_data( ) pay_periods.periods = payroll["pay_periods"] pay_periods.save() + + +class Vacancies(TypedDict): + id: int + grade: str + programme_code: str + budget_type: str + recruitment_type: str + recruitment_stage: str + appointee_name: str + hiring_manager: str + hr_ref: str + pay_periods: list[bool] + + +def get_vacancies_data( + cost_centre: CostCentre, + financial_year: FinancialYear, +) -> Iterator[Vacancies]: + qs = ( + Vacancy.objects.filter( + cost_centre=cost_centre, + pay_periods__year=financial_year, + ) + .prefetch_related( + "pay_periods", + ) + .filter( + cost_centre=cost_centre, + pay_periods__year=financial_year, + ) + ) + for obj in qs: + yield Vacancies( + id=obj.pk, + grade=obj.grade.pk, + programme_code=obj.programme_code.pk, + budget_type=obj.programme_code.budget_type.budget_type_display, + recruitment_type=obj.get_recruitment_type_display(), + recruitment_stage=obj.get_recruitment_stage_display(), + appointee_name=obj.appointee_name, + hiring_manager=obj.hiring_manager, + hr_ref=obj.hr_ref, + # `first` is OK as there should only be one `pay_periods` with the filters. + pay_periods=obj.pay_periods.first().periods, + ) + + +@transaction.atomic +def update_vacancies_data( + cost_centre: CostCentre, + financial_year: FinancialYear, + data: list[Vacancies], +) -> None: + """Update a cost centre vacancies for a given year using the provided list. + + This function is wrapped with a transaction, so if any of the vacancy updates fail, + the whole batch will be rolled back. + + Raises: + ValueError: If a vacancy id is empty. + ValueError: If there are not 12 items in the pay_periods list. + ValueError: If any of the pay_periods are not of type bool. + """ + + for vacancy in data: + if not vacancy["id"]: + raise ValueError("id is empty") + + if len(vacancy["pay_periods"]) != 12: + raise ValueError("pay_periods list should be of length 12") + + if not all(isinstance(x, bool) for x in vacancy["pay_periods"]): + raise ValueError("pay_periods items should be of type bool") + + pay_periods = VacancyPayPeriods.objects.get( + vacancy__id=vacancy["id"], + vacancy__cost_centre=cost_centre, + year=financial_year, + ) + pay_periods.periods = vacancy["pay_periods"] + pay_periods.save() diff --git a/payroll/templates/payroll/page/delete_vacancy.html b/payroll/templates/payroll/page/delete_vacancy.html new file mode 100644 index 000000000..e9e70ca7a --- /dev/null +++ b/payroll/templates/payroll/page/delete_vacancy.html @@ -0,0 +1,27 @@ +{% extends "base_generic.html" %} +{% load breadcrumbs %} + +{% block title %}Delete Vacancy{% endblock title %} + +{% block breadcrumbs %} + {{ block.super }} + {% breadcrumb "Choose cost centre" "payroll:choose_cost_centre" %} + {% breadcrumb "Edit payroll" "payroll:edit" cost_centre_code financial_year %} + {% breadcrumb "Edit Vacancy" "payroll:edit_vacancy" cost_centre_code financial_year vacancy_id %} + {% breadcrumb "Delete Vacancy" "" %} +{% endblock breadcrumbs %} + +{% block content %} +

    Delete Vacancy

    + +
    + {% csrf_token %} +

    Are you sure you want to delete this vacancy?

    + +
    + + Cancel +
    +
    + +{% endblock content %} diff --git a/payroll/templates/payroll/page/edit_payroll.html b/payroll/templates/payroll/page/edit_payroll.html index 83fdd1b77..fb4bf739c 100644 --- a/payroll/templates/payroll/page/edit_payroll.html +++ b/payroll/templates/payroll/page/edit_payroll.html @@ -1,56 +1,54 @@ {% extends "base_generic.html" %} -{% load breadcrumbs vite %} +{% load breadcrumbs vite forecast_format %} {% block title %}Edit payroll{% endblock title %} {% block breadcrumbs %} {{ block.super }} {% breadcrumb "Choose cost centre" "payroll:choose_cost_centre" %} - {% breadcrumb "Edit payroll" "edit_payroll" %} + {% breadcrumb "Edit payroll" "" %} {% endblock breadcrumbs %} {% block content %}

    Edit payroll

    +
    -

    Forecast

    -

    - This is a temporary table to demonstrate the forecast figures. Eventually these - figures would end up in the "Edit forecast" table. -

    - - - - - - - {% for month in months %} - - {% endfor %} - - - - {% for row in payroll_forecast_report %} +

    Payroll forecast

    + +
    +
    Programme codeNatural codePay type{{ month }}
    + - - - - - - - - - - - - - - - + + + {% for month in months %} + + {% endfor %} - {% endfor %} - -
    {{ row.programme_code }}{{ row.pay_element__type__group__natural_code }}{{ row.pay_element__type__group__name }}{{ row.period_1_sum }}{{ row.period_2_sum }}{{ row.period_3_sum }}{{ row.period_4_sum }}{{ row.period_5_sum }}{{ row.period_6_sum }}{{ row.period_7_sum }}{{ row.period_8_sum }}{{ row.period_9_sum }}{{ row.period_10_sum }}{{ row.period_11_sum }}{{ row.period_12_sum }}Programme codeNatural account code{{ month }}
    + + + {% for row in payroll_forecast_report %} + + {{ row.programme_code }} + {{ row.natural_account_code }} + £{{ row.apr|format_money }} + £{{ row.may|format_money }} + £{{ row.jun|format_money }} + £{{ row.jul|format_money }} + £{{ row.aug|format_money }} + £{{ row.sep|format_money }} + £{{ row.oct|format_money }} + £{{ row.nov|format_money }} + £{{ row.dec|format_money }} + £{{ row.jan|format_money }} + £{{ row.feb|format_money }} + £{{ row.mar|format_money }} + + {% endfor %} + + + {% endblock content %} {% block scripts %} @@ -58,6 +56,7 @@

    Forecast

    {% vite_dev_client %} {% vite_js 'src/index.jsx' %} diff --git a/payroll/templates/payroll/page/vacancy_form.html b/payroll/templates/payroll/page/vacancy_form.html new file mode 100644 index 000000000..4c27e1b0f --- /dev/null +++ b/payroll/templates/payroll/page/vacancy_form.html @@ -0,0 +1,38 @@ +{% extends "base_generic.html" %} +{% load breadcrumbs %} + +{% block title %}{{ title }}{% endblock title %} + +{% block breadcrumbs %} + {{ block.super }} + {% breadcrumb "Choose cost centre" "payroll:choose_cost_centre" %} + {% breadcrumb "Edit payroll" "payroll:edit" cost_centre_code financial_year %} + {% breadcrumb title "" %} +{% endblock breadcrumbs %} + +{% block content %} +

    {{ title }}

    + + {% include "payroll/partials/_error_summary.html" with form=form %} + +
    + {% csrf_token %} +
    + {% include "payroll/partials/_form_field.html" with field=form.recruitment_type %} + {% include "payroll/partials/_form_field.html" with field=form.grade %} + {% include "payroll/partials/_form_field.html" with field=form.recruitment_stage %} + {% include "payroll/partials/_form_field.html" with field=form.programme_code %} + {% include "payroll/partials/_form_field.html" with field=form.appointee_name %} + {% include "payroll/partials/_form_field.html" with field=form.hiring_manager %} + {% include "payroll/partials/_form_field.html" with field=form.hr_ref %} +
    +
    + + {% if is_edit %} + Delete Vacancy + {% endif %} + Cancel +
    +
    + +{% endblock content %} diff --git a/payroll/templates/payroll/partials/_error_summary.html b/payroll/templates/payroll/partials/_error_summary.html new file mode 100644 index 000000000..e0864dfb6 --- /dev/null +++ b/payroll/templates/payroll/partials/_error_summary.html @@ -0,0 +1,26 @@ +{% if form.errors %} + +{% endif %} \ No newline at end of file diff --git a/payroll/templates/payroll/partials/_form_field.html b/payroll/templates/payroll/partials/_form_field.html new file mode 100644 index 000000000..bbd7e8bb7 --- /dev/null +++ b/payroll/templates/payroll/partials/_form_field.html @@ -0,0 +1,6 @@ +
    + + {{ field }} +
    diff --git a/payroll/templatetags/__init__.py b/payroll/templatetags/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/payroll/templatetags/payroll_permissions.py b/payroll/templatetags/payroll_permissions.py new file mode 100644 index 000000000..b4cdd8826 --- /dev/null +++ b/payroll/templatetags/payroll_permissions.py @@ -0,0 +1,9 @@ +from django import template + + +register = template.Library() + + +@register.simple_tag +def can_edit_payroll(user): + return user.is_superuser diff --git a/payroll/tests/__init__.py b/payroll/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/payroll/tests/conftest.py b/payroll/tests/conftest.py new file mode 100644 index 000000000..e8217fa35 --- /dev/null +++ b/payroll/tests/conftest.py @@ -0,0 +1,17 @@ +import pytest +from django.contrib.auth import get_user_model + + +User = get_user_model() + + +@pytest.fixture +def user(db, client): + user = User.objects.create_user( + username="test", + email="test@example.com", + password="password", + ) + user.save() + client.force_login(user) + return user diff --git a/payroll/tests/factories.py b/payroll/tests/factories.py new file mode 100644 index 000000000..0cba8b153 --- /dev/null +++ b/payroll/tests/factories.py @@ -0,0 +1,65 @@ +import factory +import factory.fuzzy +from faker import Faker + +from chartofaccountDIT.test.factories import NaturalCodeFactory, ProgrammeCodeFactory +from costcentre.test.factories import CostCentreFactory +from gifthospitality.test.factories import GradeFactory +from payroll.models import Employee, PayElementType, PayElementTypeGroup, Vacancy + + +fake = Faker() + + +class EmployeeFactory(factory.django.DjangoModelFactory): + class Meta: + model = Employee + django_get_or_create = ("employee_no",) + + cost_centre = factory.SubFactory(CostCentreFactory) + programme_code = factory.SubFactory(ProgrammeCodeFactory) + employee_no = factory.Sequence(lambda n: f"{n:08}") + first_name = factory.Faker("first_name") + last_name = factory.Faker("last_name") + grade = factory.SubFactory(GradeFactory) + fte = 1.0 + assignment_status = "Active Assignment" + + +class PayElementTypeGroupFactory(factory.django.DjangoModelFactory): + class Meta: + model = PayElementTypeGroup + django_get_or_create = ("name",) + + # name + natural_code = factory.SubFactory(NaturalCodeFactory) + + +class PayElementTypeFactory(factory.django.DjangoModelFactory): + class Meta: + model = PayElementType + django_get_or_create = ("name",) + + # name + natural_code = factory.SubFactory(NaturalCodeFactory) + group = factory.SubFactory(PayElementTypeGroupFactory) + + +class VacancyFactory(factory.django.DjangoModelFactory): + class Meta: + model = Vacancy + + cost_centre = factory.SubFactory(CostCentreFactory) + programme_code = factory.SubFactory(ProgrammeCodeFactory) + grade = factory.SubFactory(GradeFactory) + fte = 1.0 + + recruitment_type = factory.fuzzy.FuzzyChoice( + Vacancy.RecruitmentType.choices, getter=lambda c: c[0] + ) + recruitment_stage = factory.fuzzy.FuzzyChoice( + Vacancy.RecruitmentStage.choices, getter=lambda c: c[0] + ) + appointee_name = factory.Faker("name") + hiring_manager = factory.Faker("name") + hr_ref = factory.Faker("name") diff --git a/payroll/tests/services/__init__.py b/payroll/tests/services/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/payroll/tests/services/test_payroll.py b/payroll/tests/services/test_payroll.py new file mode 100644 index 000000000..589c43afb --- /dev/null +++ b/payroll/tests/services/test_payroll.py @@ -0,0 +1,138 @@ +from statistics import mean + +import pytest + +from core.models import FinancialYear +from costcentre.test.factories import CostCentreFactory +from payroll.services.payroll import ( + employee_created, + payroll_forecast_report, + vacancy_created, +) + +from ..factories import EmployeeFactory, VacancyFactory + + +def test_payroll_forecast(db): + # NOTE: These must match the PAYROLL.BASIC_PAY_NAC and PAYROLL.PENSION_NAC settings. + SALARY_NAC = "71111001" + PENSION_NAC = "71111002" + + cost_centre = CostCentreFactory.create(cost_centre_code="123456") + + # salary_1 = PayElementTypeFactory.create( + # name="Salary 1", + # group__name="Salary", + # group__natural_code__natural_account_code=SALARY_NAC, + # ) + # salary_2 = PayElementTypeFactory.create( + # name="Salary 2", + # group__name="Salary", + # group__natural_code__natural_account_code=SALARY_NAC, + # ) + # pension_1 = PayElementTypeFactory.create( + # name="Pension 1", + # group__name="Pension", + # group__natural_code__natural_account_code=PENSION_NAC, + # ) + + payroll_employee_1 = EmployeeFactory.create( + cost_centre=cost_centre, + programme_code__programme_code="123456", + grade__grade="Grade 7", + basic_pay=195000, + pension=7550, + ernic=0, + ) + payroll_employee_2 = EmployeeFactory.create( + cost_centre=cost_centre, + programme_code__programme_code="123456", + grade__grade="Grade 7", + basic_pay=152440, + pension=11525, + ernic=0, + ) + # non-payroll employees + _ = EmployeeFactory.create_batch( + size=2, + cost_centre=cost_centre, + programme_code__programme_code="123456", + grade__grade="Grade 7", + ) + + # TODO: Consider an ergonomic way of avoiding this pattern all the time. + employee_created(payroll_employee_1) + employee_created(payroll_employee_2) + + # payroll_employees[0].pay_element.create( + # type=salary_1, + # debit_amount=2000, + # credit_amount=100, + # ) + # payroll_employees[0].pay_element.create( + # type=salary_2, + # debit_amount=100, + # credit_amount=50, + # ) + # payroll_employees[0].pay_element.create( + # type=pension_1, + # debit_amount=75.5, + # credit_amount=0, + # ) + + # payroll_employees[1].pay_element.create( + # type=salary_1, + # debit_amount=1500, + # credit_amount=55.6, + # ) + # payroll_employees[1].pay_element.create( + # type=salary_2, + # debit_amount=80, + # credit_amount=0, + # ) + # payroll_employees[1].pay_element.create( + # type=pension_1, + # debit_amount=130.25, + # credit_amount=15, + # ) + + vacancy = VacancyFactory.create( + cost_centre=cost_centre, + programme_code__programme_code="123456", + grade__grade="Grade 7", + fte=0.5, + ) + + vacancy_created(vacancy) + + financial_year = FinancialYear.objects.current() + + # In April, both employees are paid. + # In May, only the first employee is paid. + # In June, neither employee is paid, but the vacancy is filled. + payroll_employee_1.pay_periods.filter(year=financial_year).update(period_3=False) + payroll_employee_2.pay_periods.filter(year=financial_year).update( + period_2=False, period_3=False + ) + vacancy.pay_periods.filter(year=financial_year).update(period_3=True) + + report = payroll_forecast_report(cost_centre, financial_year) + + report_by_nac = {x["natural_account_code"]: x for x in report} + + # eN = employee (e.g. employee 1) / s = salary / p = pension + # debit_amount - credit_amount + e1s = ((2000 - 100) + (100 - 50)) * 100 + e1p = (75.5 - 0) * 100 + e2s = ((1500 - 55.6) + (80 - 0)) * 100 + e2p = (130.25 - 15) * 100 + v1s = mean([e1s, e2s]) * 0.5 + + # employee 3 and 4 are non-payroll (no basic pay) + + assert float(report_by_nac[SALARY_NAC]["apr"]) == pytest.approx(e1s + e2s) + assert float(report_by_nac[PENSION_NAC]["apr"]) == pytest.approx(e1p + e2p) + assert float(report_by_nac[SALARY_NAC]["may"]) == pytest.approx(e1s) + assert float(report_by_nac[PENSION_NAC]["may"]) == pytest.approx(e1p) + assert float(report_by_nac[SALARY_NAC]["jun"]) == pytest.approx(v1s) + assert float(report_by_nac[PENSION_NAC]["jun"]) == pytest.approx(0) diff --git a/payroll/tests/test_template_tags.py b/payroll/tests/test_template_tags.py new file mode 100644 index 000000000..f5931ea1e --- /dev/null +++ b/payroll/tests/test_template_tags.py @@ -0,0 +1,16 @@ +from django.contrib.auth import get_user_model +from django.test import TestCase + +from payroll.templatetags.payroll_permissions import can_edit_payroll + + +class ViewPayrollTest(TestCase): + def test_can_edit_payroll_when_superuser(self): + test_user, _ = get_user_model().objects.get_or_create(is_superuser=True) + + assert can_edit_payroll(test_user) + + def test_cannot_edit_payroll_when_not_superuser(self): + test_user, _ = get_user_model().objects.get_or_create() + + assert not can_edit_payroll(test_user) diff --git a/payroll/tests/test_views.py b/payroll/tests/test_views.py index 971e81485..b03bce624 100644 --- a/payroll/tests/test_views.py +++ b/payroll/tests/test_views.py @@ -1,20 +1,4 @@ import pytest -from django.contrib.auth import get_user_model - - -User = get_user_model() - - -@pytest.fixture -def user(db, client): - user = User.objects.create_user( - username="test", - email="test@example.com", - password="password", - ) - user.save() - client.force_login(user) - return user @pytest.mark.parametrize( diff --git a/payroll/urls.py b/payroll/urls.py index 1e0d16784..685a91800 100644 --- a/payroll/urls.py +++ b/payroll/urls.py @@ -18,9 +18,29 @@ views.PayrollView.as_view(), name="api", ), + path( + "api///vacancies/", + views.VacancyView.as_view(), + name="api_vacancies", + ), path( "edit/choose-cost-centre/", ChooseCostCentreView.as_view(next_page="payroll"), name="choose_cost_centre", ), + path( + "edit///vacancies/create", + views.add_vacancy_page, + name="add_vacancy", + ), + path( + "edit///vacancies//edit", + views.edit_vacancy_page, + name="edit_vacancy", + ), + path( + "edit///vacancies//delete", + views.delete_vacancy_page, + name="delete_vacancy", + ), ] diff --git a/payroll/views.py b/payroll/views.py index 8e3218568..c30f31c51 100644 --- a/payroll/views.py +++ b/payroll/views.py @@ -3,18 +3,19 @@ from django.contrib.auth.mixins import UserPassesTestMixin from django.core.exceptions import PermissionDenied from django.http import HttpRequest, HttpResponse, JsonResponse -from django.shortcuts import get_object_or_404 +from django.shortcuts import get_object_or_404, redirect, render from django.template.response import TemplateResponse from django.views import View from core.models import FinancialYear from costcentre.models import CostCentre +from payroll.forms import VacancyForm +from payroll.models import Vacancy from .services import payroll as payroll_service -# TODO: check user has access to cost centre -class PayrollView(UserPassesTestMixin, View): +class PositionView(UserPassesTestMixin, View): def test_func(self) -> bool | None: return self.request.user.is_superuser @@ -29,26 +30,63 @@ def setup(self, request, *args, **kwargs) -> None: pk=self.kwargs["financial_year"], ) + def get_data(self): + raise NotImplementedError + + def post_data(self, data): + raise NotImplementedError + def get(self, request, *args, **kwargs): - data = list( - payroll_service.get_payroll_data( - cost_centre=self.cost_centre, - financial_year=self.financial_year, - ) - ) + data = list(self.get_data()) return JsonResponse({"data": data}) def post(self, request, *args, **kwargs): data = json.loads(request.body) - - payroll_service.update_payroll_data( - cost_centre=self.cost_centre, - financial_year=self.financial_year, - payroll_data=data, + self.post_data( + data, ) return JsonResponse({}) +# TODO: check user has access to cost centre +class PayrollView(PositionView): + def get_data(self): + return payroll_service.get_payroll_data( + self.cost_centre, + self.financial_year, + ) + + def post_data(self, data): + return payroll_service.update_payroll_data( + self.cost_centre, + self.financial_year, + data, + ) + + +class VacancyView(PositionView): + def get_data(self): + return payroll_service.get_vacancies_data( + self.cost_centre, + self.financial_year, + ) + + def post_data(self, data): + return payroll_service.update_vacancies_data( + self.cost_centre, + self.financial_year, + data, + ) + + +def redirect_edit_payroll(cost_centre_code, financial_year): + return redirect( + "payroll:edit", + cost_centre_code=cost_centre_code, + financial_year=financial_year, + ) + + def edit_payroll_page( request: HttpRequest, cost_centre_code: str, financial_year: int ) -> HttpResponse: @@ -60,10 +98,12 @@ def edit_payroll_page( payroll_forecast_report_data = payroll_service.payroll_forecast_report( cost_centre_obj, financial_year_obj ) + cost_centre_code = cost_centre_obj.cost_centre_code + financial_year = financial_year_obj.financial_year context = { - "cost_centre_code": cost_centre_obj.cost_centre_code, - "financial_year": financial_year_obj.financial_year, + "cost_centre_code": cost_centre_code, + "financial_year": financial_year, "payroll_forecast_report": payroll_forecast_report_data, "months": [ "Apr", @@ -82,3 +122,85 @@ def edit_payroll_page( } return TemplateResponse(request, "payroll/page/edit_payroll.html", context) + + +def add_vacancy_page( + request: HttpRequest, cost_centre_code: str, financial_year: int +) -> HttpResponse: + if not request.user.is_superuser: + raise PermissionDenied + + context = { + "cost_centre_code": cost_centre_code, + "financial_year": financial_year, + "title": "Create Vacancy", + } + cost_centre_obj = get_object_or_404(CostCentre, pk=cost_centre_code) + + if request.method == "POST": + form = VacancyForm(request.POST) + if form.is_valid(): + vacancy = form.save(commit=False) + vacancy.cost_centre = cost_centre_obj + vacancy.save() + + payroll_service.vacancy_created(vacancy) + + return redirect_edit_payroll(cost_centre_code, financial_year) + else: + context["form"] = form + return render(request, "payroll/page/vacancy_form.html", context) + else: + form = VacancyForm() + context["form"] = form + return render(request, "payroll/page/vacancy_form.html", context) + + +def edit_vacancy_page( + request: HttpRequest, cost_centre_code: str, financial_year: int, vacancy_id: int +) -> HttpResponse: + if not request.user.is_superuser: + raise PermissionDenied + + vacancy = get_object_or_404(Vacancy, pk=vacancy_id) + + context = { + "cost_centre_code": cost_centre_code, + "financial_year": financial_year, + "title": "Edit Vacancy", + "vacancy_id": vacancy.id, + "is_edit": True, + } + + if request.method == "POST": + form = VacancyForm(request.POST, instance=vacancy) + if form.is_valid(): + vacancy.save() + + return redirect_edit_payroll(cost_centre_code, financial_year) + else: + context["form"] = VacancyForm(instance=vacancy) + + return render(request, "payroll/page/vacancy_form.html", context) + + +def delete_vacancy_page( + request: HttpRequest, cost_centre_code: str, financial_year: int, vacancy_id: int +) -> HttpResponse: + if not request.user.is_superuser: + raise PermissionDenied + + vacancy = get_object_or_404(Vacancy, pk=vacancy_id) + + context = { + "cost_centre_code": cost_centre_code, + "financial_year": financial_year, + "vacancy_id": vacancy.id, + } + + if request.method == "POST": + vacancy.delete() + + return redirect_edit_payroll(cost_centre_code, financial_year) + else: + return render(request, "payroll/page/delete_vacancy.html", context) diff --git a/poetry.lock b/poetry.lock index a0c33e1b9..19294f9d1 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. [[package]] name = "amqp" @@ -1661,6 +1661,70 @@ files = [ {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, ] +[[package]] +name = "numpy" +version = "2.1.3" +description = "Fundamental package for array computing in Python" +optional = false +python-versions = ">=3.10" +files = [ + {file = "numpy-2.1.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c894b4305373b9c5576d7a12b473702afdf48ce5369c074ba304cc5ad8730dff"}, + {file = "numpy-2.1.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b47fbb433d3260adcd51eb54f92a2ffbc90a4595f8970ee00e064c644ac788f5"}, + {file = "numpy-2.1.3-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:825656d0743699c529c5943554d223c021ff0494ff1442152ce887ef4f7561a1"}, + {file = "numpy-2.1.3-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:6a4825252fcc430a182ac4dee5a505053d262c807f8a924603d411f6718b88fd"}, + {file = "numpy-2.1.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e711e02f49e176a01d0349d82cb5f05ba4db7d5e7e0defd026328e5cfb3226d3"}, + {file = "numpy-2.1.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78574ac2d1a4a02421f25da9559850d59457bac82f2b8d7a44fe83a64f770098"}, + {file = "numpy-2.1.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c7662f0e3673fe4e832fe07b65c50342ea27d989f92c80355658c7f888fcc83c"}, + {file = "numpy-2.1.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fa2d1337dc61c8dc417fbccf20f6d1e139896a30721b7f1e832b2bb6ef4eb6c4"}, + {file = "numpy-2.1.3-cp310-cp310-win32.whl", hash = "sha256:72dcc4a35a8515d83e76b58fdf8113a5c969ccd505c8a946759b24e3182d1f23"}, + {file = "numpy-2.1.3-cp310-cp310-win_amd64.whl", hash = "sha256:ecc76a9ba2911d8d37ac01de72834d8849e55473457558e12995f4cd53e778e0"}, + {file = "numpy-2.1.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4d1167c53b93f1f5d8a139a742b3c6f4d429b54e74e6b57d0eff40045187b15d"}, + {file = "numpy-2.1.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c80e4a09b3d95b4e1cac08643f1152fa71a0a821a2d4277334c88d54b2219a41"}, + {file = "numpy-2.1.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:576a1c1d25e9e02ed7fa5477f30a127fe56debd53b8d2c89d5578f9857d03ca9"}, + {file = "numpy-2.1.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:973faafebaae4c0aaa1a1ca1ce02434554d67e628b8d805e61f874b84e136b09"}, + {file = "numpy-2.1.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:762479be47a4863e261a840e8e01608d124ee1361e48b96916f38b119cfda04a"}, + {file = "numpy-2.1.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc6f24b3d1ecc1eebfbf5d6051faa49af40b03be1aaa781ebdadcbc090b4539b"}, + {file = "numpy-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:17ee83a1f4fef3c94d16dc1802b998668b5419362c8a4f4e8a491de1b41cc3ee"}, + {file = "numpy-2.1.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:15cb89f39fa6d0bdfb600ea24b250e5f1a3df23f901f51c8debaa6a5d122b2f0"}, + {file = "numpy-2.1.3-cp311-cp311-win32.whl", hash = "sha256:d9beb777a78c331580705326d2367488d5bc473b49a9bc3036c154832520aca9"}, + {file = "numpy-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:d89dd2b6da69c4fff5e39c28a382199ddedc3a5be5390115608345dec660b9e2"}, + {file = "numpy-2.1.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f55ba01150f52b1027829b50d70ef1dafd9821ea82905b63936668403c3b471e"}, + {file = "numpy-2.1.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:13138eadd4f4da03074851a698ffa7e405f41a0845a6b1ad135b81596e4e9958"}, + {file = "numpy-2.1.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a6b46587b14b888e95e4a24d7b13ae91fa22386c199ee7b418f449032b2fa3b8"}, + {file = "numpy-2.1.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:0fa14563cc46422e99daef53d725d0c326e99e468a9320a240affffe87852564"}, + {file = "numpy-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8637dcd2caa676e475503d1f8fdb327bc495554e10838019651b76d17b98e512"}, + {file = "numpy-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2312b2aa89e1f43ecea6da6ea9a810d06aae08321609d8dc0d0eda6d946a541b"}, + {file = "numpy-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a38c19106902bb19351b83802531fea19dee18e5b37b36454f27f11ff956f7fc"}, + {file = "numpy-2.1.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:02135ade8b8a84011cbb67dc44e07c58f28575cf9ecf8ab304e51c05528c19f0"}, + {file = "numpy-2.1.3-cp312-cp312-win32.whl", hash = "sha256:e6988e90fcf617da2b5c78902fe8e668361b43b4fe26dbf2d7b0f8034d4cafb9"}, + {file = "numpy-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:0d30c543f02e84e92c4b1f415b7c6b5326cbe45ee7882b6b77db7195fb971e3a"}, + {file = "numpy-2.1.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96fe52fcdb9345b7cd82ecd34547fca4321f7656d500eca497eb7ea5a926692f"}, + {file = "numpy-2.1.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f653490b33e9c3a4c1c01d41bc2aef08f9475af51146e4a7710c450cf9761598"}, + {file = "numpy-2.1.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:dc258a761a16daa791081d026f0ed4399b582712e6fc887a95af09df10c5ca57"}, + {file = "numpy-2.1.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:016d0f6f5e77b0f0d45d77387ffa4bb89816b57c835580c3ce8e099ef830befe"}, + {file = "numpy-2.1.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c181ba05ce8299c7aa3125c27b9c2167bca4a4445b7ce73d5febc411ca692e43"}, + {file = "numpy-2.1.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5641516794ca9e5f8a4d17bb45446998c6554704d888f86df9b200e66bdcce56"}, + {file = "numpy-2.1.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ea4dedd6e394a9c180b33c2c872b92f7ce0f8e7ad93e9585312b0c5a04777a4a"}, + {file = "numpy-2.1.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0df3635b9c8ef48bd3be5f862cf71b0a4716fa0e702155c45067c6b711ddcef"}, + {file = "numpy-2.1.3-cp313-cp313-win32.whl", hash = "sha256:50ca6aba6e163363f132b5c101ba078b8cbd3fa92c7865fd7d4d62d9779ac29f"}, + {file = "numpy-2.1.3-cp313-cp313-win_amd64.whl", hash = "sha256:747641635d3d44bcb380d950679462fae44f54b131be347d5ec2bce47d3df9ed"}, + {file = "numpy-2.1.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:996bb9399059c5b82f76b53ff8bb686069c05acc94656bb259b1d63d04a9506f"}, + {file = "numpy-2.1.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:45966d859916ad02b779706bb43b954281db43e185015df6eb3323120188f9e4"}, + {file = "numpy-2.1.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:baed7e8d7481bfe0874b566850cb0b85243e982388b7b23348c6db2ee2b2ae8e"}, + {file = "numpy-2.1.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:a9f7f672a3388133335589cfca93ed468509cb7b93ba3105fce780d04a6576a0"}, + {file = "numpy-2.1.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7aac50327da5d208db2eec22eb11e491e3fe13d22653dce51b0f4109101b408"}, + {file = "numpy-2.1.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4394bc0dbd074b7f9b52024832d16e019decebf86caf909d94f6b3f77a8ee3b6"}, + {file = "numpy-2.1.3-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:50d18c4358a0a8a53f12a8ba9d772ab2d460321e6a93d6064fc22443d189853f"}, + {file = "numpy-2.1.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:14e253bd43fc6b37af4921b10f6add6925878a42a0c5fe83daee390bca80bc17"}, + {file = "numpy-2.1.3-cp313-cp313t-win32.whl", hash = "sha256:08788d27a5fd867a663f6fc753fd7c3ad7e92747efc73c53bca2f19f8bc06f48"}, + {file = "numpy-2.1.3-cp313-cp313t-win_amd64.whl", hash = "sha256:2564fbdf2b99b3f815f2107c1bbc93e2de8ee655a69c261363a1172a79a257d4"}, + {file = "numpy-2.1.3-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:4f2015dfe437dfebbfce7c85c7b53d81ba49e71ba7eadbf1df40c915af75979f"}, + {file = "numpy-2.1.3-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:3522b0dfe983a575e6a9ab3a4a4dfe156c3e428468ff08ce582b9bb6bd1d71d4"}, + {file = "numpy-2.1.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c006b607a865b07cd981ccb218a04fc86b600411d83d6fc261357f1c0966755d"}, + {file = "numpy-2.1.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e14e26956e6f1696070788252dcdff11b4aca4c3e8bd166e0df1bb8f315a67cb"}, + {file = "numpy-2.1.3.tar.gz", hash = "sha256:aa08e04e08aaf974d4458def539dece0d28146d866a39da5639596f4921fd761"}, +] + [[package]] name = "oauthlib" version = "3.2.2" @@ -2997,4 +3061,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "1539e0d5377ecdf9ac46e08fe922dbc9ff67d151641d17e3f7e2958e85e7344e" +content-hash = "ecf0ddf8ee231c1cbe6775de3dfc4673d32580fee516a613098b57d201a2c90c" diff --git a/pyproject.toml b/pyproject.toml index 11dda1d6b..fb08b6be9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,7 @@ openpyxl = "^3.0.5" requests = "^2.32.0" redis = "^5.0.3" granian = "^1.6.3" +numpy = "^2.1.3" # Are these packages still needed? django-import-export = "^3.3.07" @@ -72,7 +73,7 @@ pyperclip = "^1.8.0" freezegun = "^1.0.0" isort = "^5.10.1" ruff = "^0.3.4" -mypy = "^1.12.0" +mypy = "^1.13.0" [build-system] requires = ["poetry-core"] @@ -126,3 +127,6 @@ minversion = "7.0" addopts = "--ignore=front_end --ignore=features --ignore=staticfiles --ds=config.settings.ci --reuse-db" python_files = ["test_*.py", "*_test.py", "tests.py"] filterwarnings = ["ignore::DeprecationWarning"] + +[tool.mypy] +enable_incomplete_feature = ["NewGenericSyntax"] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..e69de29bb