From 68b63f7f2b387c6f0c33da31474269e532259b70 Mon Sep 17 00:00:00 2001 From: Sam Dudley Date: Tue, 22 Oct 2024 12:49:00 +0100 Subject: [PATCH] FFT-28 v2.0 Edit payroll (#533) Co-authored-by: Caitlin Barnard --- .github/workflows/ci.yml | 34 +- config/settings/base.py | 1 + config/urls.py | 1 + core/models.py | 13 +- core/templates/base_generic.html | 6 +- costcentre/fixtures/test_costcentre_data.json | 74 ++++ docker-compose.yml | 2 - forecast/models.py | 8 +- front_end/src/Apps/Payroll.jsx | 62 ++++ .../EditPayroll/EmployeeRow/index.jsx | 25 ++ .../EditPayroll/PayrollTable/index.jsx | 38 +++ front_end/src/Components/EditPayroll/api.js | 35 ++ .../src/Components/EditPayroll/index.jsx | 43 +++ front_end/src/Components/EditPayroll/types.js | 8 + front_end/src/Util.js | 54 +++ front_end/src/index.jsx | 7 + makefile | 19 +- payroll/__init__.py | 0 payroll/admin.py | 77 +++++ payroll/apps.py | 6 + payroll/fixtures/test_payroll_data.json | 322 ++++++++++++++++++ payroll/migrations/0001_initial.py | 171 ++++++++++ .../0002_alter_employeepayperiods_options.py | 17 + payroll/migrations/__init__.py | 0 payroll/models.py | 94 +++++ payroll/services/payroll.py | 109 ++++++ .../templates/payroll/page/edit_payroll.html | 59 ++++ payroll/tests/test_views.py | 28 ++ payroll/urls.py | 20 ++ payroll/views.py | 84 +++++ poetry.lock | 53 ++- pyproject.toml | 1 + 32 files changed, 1445 insertions(+), 26 deletions(-) create mode 100644 costcentre/fixtures/test_costcentre_data.json create mode 100644 front_end/src/Apps/Payroll.jsx create mode 100644 front_end/src/Components/EditPayroll/EmployeeRow/index.jsx create mode 100644 front_end/src/Components/EditPayroll/PayrollTable/index.jsx create mode 100644 front_end/src/Components/EditPayroll/api.js create mode 100644 front_end/src/Components/EditPayroll/index.jsx create mode 100644 front_end/src/Components/EditPayroll/types.js create mode 100644 payroll/__init__.py create mode 100644 payroll/admin.py create mode 100644 payroll/apps.py create mode 100644 payroll/fixtures/test_payroll_data.json create mode 100644 payroll/migrations/0001_initial.py create mode 100644 payroll/migrations/0002_alter_employeepayperiods_options.py create mode 100644 payroll/migrations/__init__.py create mode 100644 payroll/models.py create mode 100644 payroll/services/payroll.py create mode 100644 payroll/templates/payroll/page/edit_payroll.html create mode 100644 payroll/tests/test_views.py create mode 100644 payroll/urls.py create mode 100644 payroll/views.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ddb6af06c..977623889 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,20 +22,14 @@ jobs: - name: Create a .env file run: cp .env.ci .env - - name: Build images - run: make build - - - name: Run containers - run: make up-detatched - - - name: Install React app - run: npm ci + - name: Install poetry + run: pip install poetry - - name: Collect static - run: make collectstatic + - name: Install python dependencies + run: poetry install --with dev - name: Run ruff - run: make ruff + run: make ruff-check - name: Run isort run: make isort-check @@ -43,15 +37,27 @@ jobs: - name: Run black run: make black-check + - name: Install React app + run: npm ci + + - name: Build images + run: make build + + - name: Run containers + run: make up-detatched + + - name: Run makemigrations in check mode + run: make check-migrations + + - name: Collect static + run: make collectstatic + - name: Run tests run: make test-ci - name: Bring up chrome run: docker compose up -d chrome - - name: Run makemigrations in check mode - run: make check-migrations - - name: Run BDD tests run: make bdd diff --git a/config/settings/base.py b/config/settings/base.py index 2fa2e73a4..f5648772c 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -40,6 +40,7 @@ VCAP_SERVICES = env.json("VCAP_SERVICES", {}) INSTALLED_APPS = [ + "payroll.apps.PayrollConfig", "user", "authbroker_client", "future_years.apps.FutureYearsConfig", diff --git a/config/urls.py b/config/urls.py index 9b85eacbd..193478e7e 100644 --- a/config/urls.py +++ b/config/urls.py @@ -35,6 +35,7 @@ path("data-lake/", include("data_lake.urls")), path("oscar_return/", include("oscar_return.urls")), path("upload_split_file/", include("upload_split_file.urls")), + path("payroll/", include("payroll.urls")), path("admin/", admin.site.urls), # TODO - split below out into develop only? path( diff --git a/core/models.py b/core/models.py index ee7ce1a75..0c69ae69f 100644 --- a/core/models.py +++ b/core/models.py @@ -61,6 +61,17 @@ def future_year_dictionary(self): ) +class FinancialYearQuerySet(models.QuerySet): + def current(self): + return self.filter(current=True).first() + + def future(self): + current_financial_year = self.current().financial_year + return self.filter(financial_year__gt=current_financial_year).order_by( + "-financial_year" + ) + + class FinancialYear(BaseModel): """Key and representation of the financial year""" @@ -69,7 +80,7 @@ class FinancialYear(BaseModel): current = models.BooleanField(default=False) archived = models.BooleanField(default=False) archived_at = models.DateTimeField(blank=True, null=True) - objects = models.Manager() # The default manager. + objects = FinancialYearQuerySet.as_manager() financial_year_objects = FinancialYearManager() def __str__(self): diff --git a/core/templates/base_generic.html b/core/templates/base_generic.html index 61a1b6f0c..d35f42160 100644 --- a/core/templates/base_generic.html +++ b/core/templates/base_generic.html @@ -244,7 +244,11 @@ {% block scripts %} {% endblock %} {% vite_dev_client react=False %} diff --git a/costcentre/fixtures/test_costcentre_data.json b/costcentre/fixtures/test_costcentre_data.json new file mode 100644 index 000000000..ed96055ef --- /dev/null +++ b/costcentre/fixtures/test_costcentre_data.json @@ -0,0 +1,74 @@ +[ + { + "model": "costcentre.departmentalgroup", + "pk": "8888AA", + "fields": { + "created": "2024-10-03T16:05:41.208Z", + "updated": "2024-10-03T16:05:41.208Z", + "active": true, + "group_name": "Departmental Group 0", + "director_general": null, + "treasury_segment_fk": null + } + }, + { + "model": "costcentre.directorate", + "pk": "88880A", + "fields": { + "created": "2024-10-03T16:05:41.226Z", + "updated": "2024-10-03T16:05:41.226Z", + "active": true, + "directorate_name": "Directorate 1", + "director": null, + "group": "8888AA" + } + }, + { + "model": "costcentre.costcentre", + "pk": "888812", + "fields": { + "created": "2024-10-03T16:05:41.230Z", + "updated": "2024-10-03T16:05:41.230Z", + "active": true, + "cost_centre_name": "Cost Centre 2", + "directorate": "88880A", + "deputy_director": null, + "business_partner": null, + "bsce_email": null, + "disabled_with_actual": false, + "used_for_travel": true + } + }, + { + "model": "costcentre.costcentre", + "pk": "888813", + "fields": { + "created": "2024-10-03T16:05:41.233Z", + "updated": "2024-10-03T16:05:41.233Z", + "active": true, + "cost_centre_name": "Cost Centre 3", + "directorate": "88880A", + "deputy_director": null, + "business_partner": null, + "bsce_email": null, + "disabled_with_actual": false, + "used_for_travel": true + } + }, + { + "model": "costcentre.costcentre", + "pk": "888814", + "fields": { + "created": "2024-10-03T16:05:41.236Z", + "updated": "2024-10-03T16:05:41.236Z", + "active": true, + "cost_centre_name": "Cost Centre 4", + "directorate": "88880A", + "deputy_director": null, + "business_partner": null, + "bsce_email": null, + "disabled_with_actual": false, + "used_for_travel": true + } + } +] diff --git a/docker-compose.yml b/docker-compose.yml index 0f0f48781..e93709a7f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: "3" - services: web: build: diff --git a/forecast/models.py b/forecast/models.py index 89180b7b1..f4c20daf1 100644 --- a/forecast/models.py +++ b/forecast/models.py @@ -153,6 +153,12 @@ def __str__(self): return self.forecast_expenditure_type_name +class FinancialPeriodQuerySet(models.QuerySet): + def months(self): + """Filter by real months, excluding the 3 adjustment periods (13, 14, 15).""" + return self.filter(financial_period_code__lte=12) + + class FinancialPeriodManager(models.Manager): def month_display_list(self): return list( @@ -296,7 +302,7 @@ class FinancialPeriod(BaseModel): actual_loaded_previous_year = models.BooleanField(default=False) display_figure = models.BooleanField(default=True) - objects = models.Manager() # The default manager. + objects = FinancialPeriodQuerySet.as_manager() financial_period_info = FinancialPeriodManager() class Meta: diff --git a/front_end/src/Apps/Payroll.jsx b/front_end/src/Apps/Payroll.jsx new file mode 100644 index 000000000..10a6ccb06 --- /dev/null +++ b/front_end/src/Apps/Payroll.jsx @@ -0,0 +1,62 @@ +import { useEffect, useReducer } from "react"; + +import EditPayroll from "../Components/EditPayroll"; +import * as api from "../Components/EditPayroll/api"; + +const initialPayrollState = []; + +export default function Payroll() { + const [payroll, dispatch] = useReducer(payrollReducer, initialPayrollState); + + useEffect(() => { + api.getPayrollData().then((data) => dispatch({ type: "fetched", data })); + }, []); + + // Handlers + async function handleSavePayroll() { + try { + api.postPayrollData(payroll); + } catch (error) { + console.error("Error saving payroll: ", error); + } + } + + function handleTogglePayPeriods(employeeNo, index, enabled) { + dispatch({ type: "updatePayPeriods", employeeNo, index, enabled }); + } + + return ( + + ); +} + +function payrollReducer(payroll, 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 { + ...employeeRow, + pay_periods: updatedPayPeriods, + }; + } + return employeeRow; + }); + } + } +} diff --git a/front_end/src/Components/EditPayroll/EmployeeRow/index.jsx b/front_end/src/Components/EditPayroll/EmployeeRow/index.jsx new file mode 100644 index 000000000..0463ea4d0 --- /dev/null +++ b/front_end/src/Components/EditPayroll/EmployeeRow/index.jsx @@ -0,0 +1,25 @@ +import { useState } from "react"; + +const EmployeeRow = ({ row, onTogglePayPeriods }) => { + return ( + + {row.name} + {row.employee_no} + {row.pay_periods.map((enabled, index) => { + return ( + + + onTogglePayPeriods(row.employee_no, index, enabled) + } + /> + + ); + })} + + ); +}; + +export default EmployeeRow; diff --git a/front_end/src/Components/EditPayroll/PayrollTable/index.jsx b/front_end/src/Components/EditPayroll/PayrollTable/index.jsx new file mode 100644 index 000000000..713089373 --- /dev/null +++ b/front_end/src/Components/EditPayroll/PayrollTable/index.jsx @@ -0,0 +1,38 @@ +import EmployeeRow from "../EmployeeRow"; + +/** + * + * @param {object} props + * @param {types.PayrollData[]} props.payroll + * @returns + */ +export default function PayrollTable({ headers, payroll, onTogglePayPeriods }) { + return ( + <> + + + + {headers.map((header) => { + return ( + + ); + })} + + + + {payroll.map((row) => { + return ( + + ); + })} + +
+ {header} +
+ + ); +} diff --git a/front_end/src/Components/EditPayroll/api.js b/front_end/src/Components/EditPayroll/api.js new file mode 100644 index 000000000..e1d734894 --- /dev/null +++ b/front_end/src/Components/EditPayroll/api.js @@ -0,0 +1,35 @@ +import { getData, postData } from "../../Util"; + +import * as types from "./types"; + +/** + * Fetch payroll data and return it as a promise. + * @returns {Promise} A promise resolving to an array of objects containing employee information. + */ +export function getPayrollData() { + return getData(getPayrollApiUrl()).then((data) => data.data); +} + +/** + * Post modified payroll data. + * + * @param {types.PayrollData[]} payrollData - Payroll data to be sent. + * @returns {import("../../Util").PostDataResponse} Updated payroll data received. + */ +export function postPayrollData(payrollData) { + return postData(getPayrollApiUrl(), JSON.stringify(payrollData)); +} + +/** + * Return the payroll API URL. + * + * This function relies on the `costCentreCode` and `financialYear` being available on + * the `window` object. + * + * @returns {string} The payroll API URL. + */ +function getPayrollApiUrl() { + const costCentreCode = window.costCentreCode; + const financialYear = window.financialYear; + return `/payroll/api/${costCentreCode}/${financialYear}/`; +} diff --git a/front_end/src/Components/EditPayroll/index.jsx b/front_end/src/Components/EditPayroll/index.jsx new file mode 100644 index 000000000..6985856dc --- /dev/null +++ b/front_end/src/Components/EditPayroll/index.jsx @@ -0,0 +1,43 @@ +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, +}) { + const headers = [ + "Name", + "Employee No", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + "Jan", + "Feb", + "Mar", + ]; + return ( + <> + + + + ); +} diff --git a/front_end/src/Components/EditPayroll/types.js b/front_end/src/Components/EditPayroll/types.js new file mode 100644 index 000000000..13c2fadad --- /dev/null +++ b/front_end/src/Components/EditPayroll/types.js @@ -0,0 +1,8 @@ +/** + * @typedef {Object} PayrollData + * @property {string} name - The employee's name. + * @property {string} employee_no - The employee's number. + * @property {boolean[]} pay_periods - Whether the employee is being paid in periods. + */ + +export const Types = {}; diff --git a/front_end/src/Util.js b/front_end/src/Util.js index 63257acd4..cbaf08700 100644 --- a/front_end/src/Util.js +++ b/front_end/src/Util.js @@ -38,7 +38,46 @@ export const formatValue = (value) => { return nfObject.format(pounds); } +/** + * Make a HTTP request to fetch JSON data. + * + * @param {string} url The HTTP request URL for the API. + * @returns JSON response data. + */ +export async function getData(url) { + const request = new Request( + url, + { + method: "GET", + }, + ); + + let resp = await fetch(request); + + if (!resp.ok) { + throw new Error("Something went wrong"); + } + + return await resp.json(); +} + +/** + * @typedef {object} PostDataResponse + * @property {number} status + * @property {object} data + */ + +/** + * POST data to an API. + * + * @param {string} url - URL to POST data to. + * @param {object} data - Payload to send. + * @returns {PostDataResponse} + */ export async function postData(url = '', data = {}) { + // NOTE: This doesn't work! We set `CSRF_COOKIE_HTTPONLY = True` so the code which + // uses this function include the CSRF token as part of the submitted form data by + // pulling it from DOM. var csrftoken = getCookie('csrftoken'); /* @@ -129,3 +168,18 @@ export const processForecastData = (forecastData) => { return rows; } + + +/** + * Retrieves JSON data from an HTML element with the given ID. + * + * @param {string} id The ID of the HTML element containing the JSON data. + * @returns {Promise} A promise resolving to the parsed JSON data. + */ +export function getScriptJsonData(id) { + // The promise is here to facilitate a smooth future transition to an API call. + return new Promise((resolve, reject) => { + const json = JSON.parse(document.getElementById(id).textContent); + resolve(json); + }); +} diff --git a/front_end/src/index.jsx b/front_end/src/index.jsx index 048463573..685532a93 100644 --- a/front_end/src/index.jsx +++ b/front_end/src/index.jsx @@ -3,12 +3,19 @@ import React from 'react' import ReactDOM from 'react-dom' import Forecast from './Apps/Forecast' import CostCentre from './Apps/CostCentre' +import Payroll from './Apps/Payroll' import * as serviceWorker from './serviceWorker' +import { getData, postData } from './Util' + +window.getData = getData; +window.postData = postData; if (document.getElementById('forecast-app')) { ReactDOM.render(, document.getElementById('forecast-app')) } else if (document.getElementById('cost-centre-list-app')) { ReactDOM.render(, document.getElementById('cost-centre-list-app')) +} else if (document.getElementById('payroll-app')) { + ReactDOM.render(, document.getElementById('payroll-app')) } // If you want your app to work offline and load faster, you can change diff --git a/makefile b/makefile index 2b5b9c5ce..55c865e5c 100644 --- a/makefile +++ b/makefile @@ -30,6 +30,8 @@ exec = docker compose exec web := ${if $(shell docker ps -q -f name=web),$(exec) web,$(run) web} db := ${if $(shell docker ps -q -f name=db),$(exec) db,$(run) db} +run-host = poetry run + manage = python manage.py create-stub-data: # Create stub data for testing @@ -39,6 +41,7 @@ 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) loaddata test_payroll_data $(web) $(manage) create_test_user --password=password setup: # Set up the project from scratch @@ -82,26 +85,30 @@ superuser: # Create superuser # Formatting black-check: # Run black-check - $(run-no-deps) web black --check . + $(run-host) black --check . black: # Run black - $(web) black . + $(run-host) black . isort-check: # Run isort-check - $(web) isort --check . + $(run-host) isort --check . isort: # Run isort - $(web) isort . + $(run-host) isort . + +ruff-check: # Run ruff in check mode + $(run-host) ruff check ruff: # Run ruff - $(web) ruff check + $(run-host) ruff check --fix . check: # Run formatters to see if there are any errors - make ruff + make ruff-check make black-check make isort-check fix: # Run formatters to fix any issues that can be fixed automatically + make ruff make black make isort diff --git a/payroll/__init__.py b/payroll/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/payroll/admin.py b/payroll/admin.py new file mode 100644 index 000000000..c761e0c87 --- /dev/null +++ b/payroll/admin.py @@ -0,0 +1,77 @@ +from django.contrib import admin + +from payroll.services.payroll import employee_created + +from .models import ( + Employee, + EmployeePayElement, + EmployeePayPeriods, + PayElementType, + PayElementTypeGroup, +) + + +@admin.register(Employee) +class EmployeeAdmin(admin.ModelAdmin): + list_display = [ + "employee_no", + "first_name", + "last_name", + "cost_centre", + ] + + def save_model(self, request, obj, form, change): + super().save_model(request, obj, form, change) + + if not change: + employee_created(obj) + + +@admin.register(EmployeePayPeriods) +class EmployeePayPeriodsAdmin(admin.ModelAdmin): + list_display = [ + "employee", + "year", + "period_1", + "period_2", + "period_3", + "period_4", + "period_5", + "period_6", + "period_7", + "period_8", + "period_9", + "period_10", + "period_11", + "period_12", + ] + list_filter = [ + "year", + ] + + +@admin.register(EmployeePayElement) +class EmployeePayElementAdmin(admin.ModelAdmin): + list_display = [ + "employee", + "type", + "debit_amount", + "credit_amount", + ] + + +@admin.register(PayElementType) +class PayElementTypeAdmin(admin.ModelAdmin): + list_display = [ + "name", + "natural_code", + "group", + ] + + +@admin.register(PayElementTypeGroup) +class PayElementTypeGroupAdmin(admin.ModelAdmin): + list_display = [ + "name", + "natural_code", + ] diff --git a/payroll/apps.py b/payroll/apps.py new file mode 100644 index 000000000..7b64e7214 --- /dev/null +++ b/payroll/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class PayrollConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "payroll" diff --git a/payroll/fixtures/test_payroll_data.json b/payroll/fixtures/test_payroll_data.json new file mode 100644 index 000000000..278efd6f7 --- /dev/null +++ b/payroll/fixtures/test_payroll_data.json @@ -0,0 +1,322 @@ +[ + { + "model": "payroll.employee", + "pk": 1, + "fields": { + "cost_centre": "888812", + "employee_no": "00000001", + "first_name": "John", + "last_name": "Smith" + } + }, + { + "model": "payroll.employee", + "pk": 2, + "fields": { + "cost_centre": "888812", + "employee_no": "00000002", + "first_name": "Jane", + "last_name": "Doe" + } + }, + { + "model": "payroll.employeepayperiods", + "pk": 1, + "fields": { + "employee": 1, + "year": 2024, + "period_1": true, + "period_2": true, + "period_3": true, + "period_4": true, + "period_5": true, + "period_6": true, + "period_7": true, + "period_8": true, + "period_9": true, + "period_10": true, + "period_11": true, + "period_12": true + } + }, + { + "model": "payroll.employeepayperiods", + "pk": 2, + "fields": { + "employee": 1, + "year": 2025, + "period_1": true, + "period_2": true, + "period_3": true, + "period_4": true, + "period_5": true, + "period_6": true, + "period_7": true, + "period_8": true, + "period_9": true, + "period_10": true, + "period_11": true, + "period_12": true + } + }, + { + "model": "payroll.employeepayperiods", + "pk": 3, + "fields": { + "employee": 1, + "year": 2026, + "period_1": true, + "period_2": true, + "period_3": true, + "period_4": true, + "period_5": true, + "period_6": true, + "period_7": true, + "period_8": true, + "period_9": true, + "period_10": true, + "period_11": true, + "period_12": true + } + }, + { + "model": "payroll.employeepayperiods", + "pk": 4, + "fields": { + "employee": 1, + "year": 2027, + "period_1": true, + "period_2": true, + "period_3": true, + "period_4": true, + "period_5": true, + "period_6": true, + "period_7": true, + "period_8": true, + "period_9": true, + "period_10": true, + "period_11": true, + "period_12": true + } + }, + { + "model": "payroll.employeepayperiods", + "pk": 5, + "fields": { + "employee": 2, + "year": 2024, + "period_1": true, + "period_2": true, + "period_3": true, + "period_4": true, + "period_5": true, + "period_6": true, + "period_7": true, + "period_8": true, + "period_9": true, + "period_10": true, + "period_11": true, + "period_12": true + } + }, + { + "model": "payroll.employeepayperiods", + "pk": 6, + "fields": { + "employee": 2, + "year": 2025, + "period_1": true, + "period_2": true, + "period_3": true, + "period_4": true, + "period_5": true, + "period_6": true, + "period_7": true, + "period_8": true, + "period_9": true, + "period_10": true, + "period_11": true, + "period_12": true + } + }, + { + "model": "payroll.employeepayperiods", + "pk": 7, + "fields": { + "employee": 2, + "year": 2026, + "period_1": true, + "period_2": true, + "period_3": true, + "period_4": true, + "period_5": true, + "period_6": true, + "period_7": true, + "period_8": true, + "period_9": true, + "period_10": true, + "period_11": true, + "period_12": true + } + }, + { + "model": "payroll.employeepayperiods", + "pk": 8, + "fields": { + "employee": 2, + "year": 2027, + "period_1": true, + "period_2": true, + "period_3": true, + "period_4": true, + "period_5": true, + "period_6": true, + "period_7": true, + "period_8": true, + "period_9": true, + "period_10": true, + "period_11": true, + "period_12": true + } + }, + { + "model": "payroll.payelementtypegroup", + "pk": 1, + "fields": { + "name": "Basic Pay", + "natural_code": 71111001 + } + }, + { + "model": "payroll.payelementtypegroup", + "pk": 2, + "fields": { + "name": "ERNIC", + "natural_code": 71111002 + } + }, + { + "model": "payroll.payelementtypegroup", + "pk": 3, + "fields": { + "name": "Superannuation", + "natural_code": 71111003 + } + }, + { + "model": "payroll.payelementtype", + "pk": 1, + "fields": { + "name": "Basic Pay 1", + "natural_code": 71111001, + "group": 1 + } + }, + { + "model": "payroll.payelementtype", + "pk": 2, + "fields": { + "name": "Basic Pay 2", + "natural_code": 71111001, + "group": 1 + } + }, + { + "model": "payroll.payelementtype", + "pk": 3, + "fields": { + "name": "ERNIC", + "natural_code": 71111002, + "group": 2 + } + }, + { + "model": "payroll.payelementtype", + "pk": 4, + "fields": { + "name": "Superannuation", + "natural_code": 71111003, + "group": 3 + } + }, + { + "model": "payroll.employeepayelement", + "pk": 1, + "fields": { + "employee": 1, + "type": 1, + "debit_amount": "2500.00", + "credit_amount": "500.00" + } + }, + { + "model": "payroll.employeepayelement", + "pk": 2, + "fields": { + "employee": 1, + "type": 2, + "debit_amount": "300.00", + "credit_amount": "0.00" + } + }, + { + "model": "payroll.employeepayelement", + "pk": 3, + "fields": { + "employee": 1, + "type": 3, + "debit_amount": "100.00", + "credit_amount": "10.00" + } + }, + { + "model": "payroll.employeepayelement", + "pk": 4, + "fields": { + "employee": 1, + "type": 4, + "debit_amount": "400.00", + "credit_amount": "0.00" + } + }, + { + "model": "payroll.employeepayelement", + "pk": 5, + "fields": { + "employee": 2, + "type": 1, + "debit_amount": "3000.00", + "credit_amount": "300.00" + } + }, + { + "model": "payroll.employeepayelement", + "pk": 6, + "fields": { + "employee": 2, + "type": 2, + "debit_amount": "0.00", + "credit_amount": "20.00" + } + }, + { + "model": "payroll.employeepayelement", + "pk": 7, + "fields": { + "employee": 2, + "type": 3, + "debit_amount": "500.00", + "credit_amount": "0.00" + } + }, + { + "model": "payroll.employeepayelement", + "pk": 8, + "fields": { + "employee": 2, + "type": 4, + "debit_amount": "600.00", + "credit_amount": "0.00" + } + } +] diff --git a/payroll/migrations/0001_initial.py b/payroll/migrations/0001_initial.py new file mode 100644 index 000000000..7a2ec71a4 --- /dev/null +++ b/payroll/migrations/0001_initial.py @@ -0,0 +1,171 @@ +# Generated by Django 4.2.15 on 2024-10-09 14:43 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("costcentre", "0008_alter_simplehistoryarchivedcostcentre_options_and_more"), + ("chartofaccountDIT", "0015_alter_simplehistoryanalysis1_options_and_more"), + ("core", "0013_alter_historicalgroup_options_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="Employee", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("employee_no", models.CharField(max_length=8, unique=True)), + ("first_name", models.CharField(max_length=32)), + ("last_name", models.CharField(max_length=32)), + ( + "cost_centre", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + to="costcentre.costcentre", + ), + ), + ], + ), + migrations.CreateModel( + name="PayElementTypeGroup", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=32, unique=True)), + ( + "natural_code", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + to="chartofaccountDIT.naturalcode", + ), + ), + ], + ), + migrations.CreateModel( + name="PayElementType", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=128, unique=True)), + ( + "group", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + to="payroll.payelementtypegroup", + ), + ), + ( + "natural_code", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + to="chartofaccountDIT.naturalcode", + ), + ), + ], + ), + migrations.CreateModel( + name="EmployeePayPeriods", + 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)), + ( + "employee", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="pay_periods", + to="payroll.employee", + ), + ), + ( + "year", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + to="core.financialyear", + ), + ), + ], + ), + migrations.CreateModel( + name="EmployeePayElement", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("debit_amount", models.DecimalField(decimal_places=2, max_digits=9)), + ("credit_amount", models.DecimalField(decimal_places=2, max_digits=9)), + ( + "employee", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="pay_element", + to="payroll.employee", + ), + ), + ( + "type", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + to="payroll.payelementtype", + ), + ), + ], + ), + migrations.AddConstraint( + model_name="employeepayperiods", + constraint=models.UniqueConstraint( + fields=("employee", "year"), name="unique_employee_pay_periods" + ), + ), + ] diff --git a/payroll/migrations/0002_alter_employeepayperiods_options.py b/payroll/migrations/0002_alter_employeepayperiods_options.py new file mode 100644 index 000000000..5c837fe70 --- /dev/null +++ b/payroll/migrations/0002_alter_employeepayperiods_options.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.15 on 2024-10-14 10:44 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("payroll", "0001_initial"), + ] + + operations = [ + migrations.AlterModelOptions( + name="employeepayperiods", + options={"verbose_name_plural": "employee pay periods"}, + ), + ] diff --git a/payroll/migrations/__init__.py b/payroll/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/payroll/models.py b/payroll/models.py new file mode 100644 index 000000000..263c2b237 --- /dev/null +++ b/payroll/models.py @@ -0,0 +1,94 @@ +from django.db import models + + +class Employee(models.Model): + cost_centre = models.ForeignKey("costcentre.CostCentre", 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}" + + +class EmployeePayPeriods(models.Model): + 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") + year = models.ForeignKey("core.FinancialYear", models.PROTECT) + # period 1 = apr, period 2 = may, etc... + # pariod 1 -> 12 = apr -> mar + # Here is a useful text snippet: + # apr period_1 + # may period_2 + # jun period_3 + # jul period_4 + # aug period_5 + # sep period_6 + # oct period_7 + # nov period_8 + # dec period_9 + # jan period_10 + # feb period_11 + # mar period_12 + 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) + + @property + def periods(self) -> list[bool]: + return [getattr(self, f"period_{i + 1}") for i in range(12)] + + @periods.setter + def periods(self, value: list[bool]) -> None: + for i, enabled in enumerate(value): + setattr(self, f"period_{i + 1}", enabled) + + +# aka "ToolTypePayment" +class PayElementTypeGroup(models.Model): + name = models.CharField(max_length=32, unique=True) + natural_code = models.ForeignKey("chartofaccountDIT.NaturalCode", models.PROTECT) + + def __str__(self) -> str: + return self.name + + +class PayElementType(models.Model): + name = models.CharField(max_length=128, unique=True) + # aka "account code" + natural_code = models.ForeignKey("chartofaccountDIT.NaturalCode", models.PROTECT) + group = models.ForeignKey(PayElementTypeGroup, models.PROTECT) + + def __str__(self) -> str: + return self.name + + +class EmployeePayElement(models.Model): + """A many-to-many through model that represents an employee's pay make-up.""" + + employee = models.ForeignKey(Employee, models.PROTECT, related_name="pay_element") + type = models.ForeignKey(PayElementType, models.PROTECT) + # Support up to 9,999,999.99. + 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) diff --git a/payroll/services/payroll.py b/payroll/services/payroll.py new file mode 100644 index 000000000..bb2d73029 --- /dev/null +++ b/payroll/services/payroll.py @@ -0,0 +1,109 @@ +from decimal import Decimal +from typing import Iterator, TypedDict + +from django.db import transaction +from django.db.models import F, Q, Sum + +from core.models import FinancialYear +from costcentre.models import CostCentre + +from ..models import Employee, EmployeePayPeriods + + +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) + + return None + + +def create_employee_pay_periods(employee: Employee) -> 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) + + +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) + } + + qs = ( + Employee.objects.filter( + cost_centre=cost_centre, + pay_periods__year=financial_year, + ) + .values("pay_element__type__group", "pay_element__type__group__name") + .annotate(**period_sum_annotations) + ) + + return qs + + +class EmployeePayroll(TypedDict): + name: str + employee_no: str + pay_periods: list[bool] + + +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, + ) + for obj in qs: + yield EmployeePayroll( + name=obj.employee.get_full_name(), + employee_no=obj.employee.employee_no, + pay_periods=obj.periods, + ) + + +@transaction.atomic +def update_payroll_data( + cost_centre: CostCentre, + financial_year: FinancialYear, + payroll_data: list[EmployeePayroll], +) -> None: + """Update a cost centre payroll for a given year using the provided list. + + This function is wrapped with a transaction, so if any of the payroll updates fail, + the whole batch will be rolled back. + + Raises: + ValueError: If an employee_no 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 payroll in payroll_data: + if not payroll["employee_no"]: + raise ValueError("employee_no is empty") + + if len(payroll["pay_periods"]) != 12: + raise ValueError("pay_periods list should be of length 12") + + if not all(isinstance(x, bool) for x in payroll["pay_periods"]): + raise ValueError("pay_periods items should be of type bool") + + pay_periods = EmployeePayPeriods.objects.get( + employee__employee_no=payroll["employee_no"], + employee__cost_centre=cost_centre, + year=financial_year, + ) + pay_periods.periods = payroll["pay_periods"] + pay_periods.save() diff --git a/payroll/templates/payroll/page/edit_payroll.html b/payroll/templates/payroll/page/edit_payroll.html new file mode 100644 index 000000000..5f88286ff --- /dev/null +++ b/payroll/templates/payroll/page/edit_payroll.html @@ -0,0 +1,59 @@ +{% extends "base_generic.html" %} +{% load breadcrumbs vite %} + +{% block title %}Edit payroll{% endblock title %} + +{% block breadcrumbs %} + {{ block.super }} + {% breadcrumb "Edit payroll" "edit_payroll" %} +{% endblock breadcrumbs %} + +{% block content %} +

Edit payroll

+
+ +

Forecast

+

+ This is a temporary table to demostrate 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 %} + + + + + + + + + + + + + + + + {% endfor %} + +
Pay type{{ month }}
{{ 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 }}
+{% endblock content %} + +{% block scripts %} + {{ block.super }} + + {% vite_dev_client %} + {% vite_js 'src/index.jsx' %} +{% endblock scripts %} diff --git a/payroll/tests/test_views.py b/payroll/tests/test_views.py new file mode 100644 index 000000000..971e81485 --- /dev/null +++ b/payroll/tests/test_views.py @@ -0,0 +1,28 @@ +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( + "url", + [ + "/payroll/edit/888812/2024/", + ], +) +def test_only_superuser_can_access(client, user, url): + r = client.get(url) + assert r.status_code == 403 diff --git a/payroll/urls.py b/payroll/urls.py new file mode 100644 index 000000000..3027dc48e --- /dev/null +++ b/payroll/urls.py @@ -0,0 +1,20 @@ +from django.urls import path + +from . import views + + +app_name = "payroll" + +urlpatterns = [ + # TODO: Add choose financial year and cost centre url. + path( + "edit///", + views.edit_payroll_page, + name="edit", + ), + path( + "api///", + views.PayrollView.as_view(), + name="api", + ), +] diff --git a/payroll/views.py b/payroll/views.py new file mode 100644 index 000000000..8e3218568 --- /dev/null +++ b/payroll/views.py @@ -0,0 +1,84 @@ +import json + +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.template.response import TemplateResponse +from django.views import View + +from core.models import FinancialYear +from costcentre.models import CostCentre + +from .services import payroll as payroll_service + + +# TODO: check user has access to cost centre +class PayrollView(UserPassesTestMixin, View): + def test_func(self) -> bool | None: + return self.request.user.is_superuser + + def setup(self, request, *args, **kwargs) -> None: + super().setup(request, *args, **kwargs) + self.cost_centre = get_object_or_404( + CostCentre, + pk=self.kwargs["cost_centre_code"], + ) + self.financial_year = get_object_or_404( + FinancialYear, + pk=self.kwargs["financial_year"], + ) + + def get(self, request, *args, **kwargs): + data = list( + payroll_service.get_payroll_data( + cost_centre=self.cost_centre, + financial_year=self.financial_year, + ) + ) + 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, + ) + return JsonResponse({}) + + +def edit_payroll_page( + request: HttpRequest, cost_centre_code: str, financial_year: int +) -> HttpResponse: + if not request.user.is_superuser: + raise PermissionDenied + + cost_centre_obj = get_object_or_404(CostCentre, pk=cost_centre_code) + financial_year_obj = get_object_or_404(FinancialYear, pk=financial_year) + payroll_forecast_report_data = payroll_service.payroll_forecast_report( + cost_centre_obj, financial_year_obj + ) + + context = { + "cost_centre_code": cost_centre_obj.cost_centre_code, + "financial_year": financial_year_obj.financial_year, + "payroll_forecast_report": payroll_forecast_report_data, + "months": [ + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + "Jan", + "Feb", + "Mar", + ], + } + + return TemplateResponse(request, "payroll/page/edit_payroll.html", context) diff --git a/poetry.lock b/poetry.lock index 79c03ce08..dd896f1c5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1648,6 +1648,57 @@ files = [ [package.dependencies] six = "*" +[[package]] +name = "mypy" +version = "1.12.0" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "mypy-1.12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4397081e620dc4dc18e2f124d5e1d2c288194c2c08df6bdb1db31c38cd1fe1ed"}, + {file = "mypy-1.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:684a9c508a283f324804fea3f0effeb7858eb03f85c4402a967d187f64562469"}, + {file = "mypy-1.12.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cabe4cda2fa5eca7ac94854c6c37039324baaa428ecbf4de4567279e9810f9e"}, + {file = "mypy-1.12.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:060a07b10e999ac9e7fa249ce2bdcfa9183ca2b70756f3bce9df7a92f78a3c0a"}, + {file = "mypy-1.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:0eff042d7257f39ba4ca06641d110ca7d2ad98c9c1fb52200fe6b1c865d360ff"}, + {file = "mypy-1.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4b86de37a0da945f6d48cf110d5206c5ed514b1ca2614d7ad652d4bf099c7de7"}, + {file = "mypy-1.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:20c7c5ce0c1be0b0aea628374e6cf68b420bcc772d85c3c974f675b88e3e6e57"}, + {file = "mypy-1.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a64ee25f05fc2d3d8474985c58042b6759100a475f8237da1f4faf7fcd7e6309"}, + {file = "mypy-1.12.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:faca7ab947c9f457a08dcb8d9a8664fd438080e002b0fa3e41b0535335edcf7f"}, + {file = "mypy-1.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:5bc81701d52cc8767005fdd2a08c19980de9ec61a25dbd2a937dfb1338a826f9"}, + {file = "mypy-1.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8462655b6694feb1c99e433ea905d46c478041a8b8f0c33f1dab00ae881b2164"}, + {file = "mypy-1.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:923ea66d282d8af9e0f9c21ffc6653643abb95b658c3a8a32dca1eff09c06475"}, + {file = "mypy-1.12.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ebf9e796521f99d61864ed89d1fb2926d9ab6a5fab421e457cd9c7e4dd65aa9"}, + {file = "mypy-1.12.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e478601cc3e3fa9d6734d255a59c7a2e5c2934da4378f3dd1e3411ea8a248642"}, + {file = "mypy-1.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:c72861b7139a4f738344faa0e150834467521a3fba42dc98264e5aa9507dd601"}, + {file = "mypy-1.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52b9e1492e47e1790360a43755fa04101a7ac72287b1a53ce817f35899ba0521"}, + {file = "mypy-1.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:48d3e37dd7d9403e38fa86c46191de72705166d40b8c9f91a3de77350daa0893"}, + {file = "mypy-1.12.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f106db5ccb60681b622ac768455743ee0e6a857724d648c9629a9bd2ac3f721"}, + {file = "mypy-1.12.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:233e11b3f73ee1f10efada2e6da0f555b2f3a5316e9d8a4a1224acc10e7181d3"}, + {file = "mypy-1.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:4ae8959c21abcf9d73aa6c74a313c45c0b5a188752bf37dace564e29f06e9c1b"}, + {file = "mypy-1.12.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:eafc1b7319b40ddabdc3db8d7d48e76cfc65bbeeafaa525a4e0fa6b76175467f"}, + {file = "mypy-1.12.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9b9ce1ad8daeb049c0b55fdb753d7414260bad8952645367e70ac91aec90e07e"}, + {file = "mypy-1.12.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfe012b50e1491d439172c43ccb50db66d23fab714d500b57ed52526a1020bb7"}, + {file = "mypy-1.12.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2c40658d4fa1ab27cb53d9e2f1066345596af2f8fe4827defc398a09c7c9519b"}, + {file = "mypy-1.12.0-cp38-cp38-win_amd64.whl", hash = "sha256:dee78a8b9746c30c1e617ccb1307b351ded57f0de0d287ca6276378d770006c0"}, + {file = "mypy-1.12.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6b5df6c8a8224f6b86746bda716bbe4dbe0ce89fd67b1fa4661e11bfe38e8ec8"}, + {file = "mypy-1.12.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5feee5c74eb9749e91b77f60b30771563327329e29218d95bedbe1257e2fe4b0"}, + {file = "mypy-1.12.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:77278e8c6ffe2abfba6db4125de55f1024de9a323be13d20e4f73b8ed3402bd1"}, + {file = "mypy-1.12.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:dcfb754dea911039ac12434d1950d69a2f05acd4d56f7935ed402be09fad145e"}, + {file = "mypy-1.12.0-cp39-cp39-win_amd64.whl", hash = "sha256:06de0498798527451ffb60f68db0d368bd2bae2bbfb5237eae616d4330cc87aa"}, + {file = "mypy-1.12.0-py3-none-any.whl", hash = "sha256:fd313226af375d52e1e36c383f39bf3836e1f192801116b31b090dfcd3ec5266"}, + {file = "mypy-1.12.0.tar.gz", hash = "sha256:65a22d87e757ccd95cbbf6f7e181e6caa87128255eb2b6be901bb71b26d8a99d"}, +] + +[package.dependencies] +mypy-extensions = ">=1.0.0" +typing-extensions = ">=4.6.0" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +install-types = ["pip"] +mypyc = ["setuptools (>=50)"] +reports = ["lxml"] + [[package]] name = "mypy-extensions" version = "1.0.0" @@ -2960,4 +3011,4 @@ testing = ["coverage[toml]", "zope.event", "zope.testing"] [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "feaef5b68afa6e91a5348333ffbfacafe3156cedf3280297a626c7839e308870" +content-hash = "7ccb2e45f1adfe8af998611e85a6bf025ebdb19f3b29ce09a5285013b9056e9f" diff --git a/pyproject.toml b/pyproject.toml index d88b5e164..b55c2f69d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,6 +73,7 @@ pyperclip = "^1.8.0" freezegun = "^1.0.0" isort = "^5.10.1" ruff = "^0.3.4" +mypy = "^1.12.0" [build-system] requires = ["poetry-core"]