From 64dfd65e97ada862c543a8260d00bf32f6bcf837 Mon Sep 17 00:00:00 2001 From: Miguel Sanda Date: Wed, 15 Jan 2025 17:45:15 -0500 Subject: [PATCH 01/35] Refactor journal entry logic and update dependencies. Removed redundant queryset logic in views and improved queryset annotations in models. Added validation for root account addition in Chart of Accounts. Updated project dependencies to the latest compatible versions. --- Pipfile.lock | 172 +++++++++--------- django_ledger/models/chart_of_accounts.py | 4 + django_ledger/models/journal_entry.py | 8 +- .../account/tags/accounts_table.html | 2 +- django_ledger/views/journal_entry.py | 7 +- 5 files changed, 97 insertions(+), 96 deletions(-) diff --git a/Pipfile.lock b/Pipfile.lock index c7b578d1..55672c04 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -26,12 +26,12 @@ }, "django": { "hashes": [ - "sha256:bd7376f90c99f96b643722eee676498706c9fd7dc759f55ebfaf2c08ebcdf4f0", - "sha256:f11aa87ad8d5617171e3f77e1d5d16f004b79a2cf5d2e1d2b97a6a1f8e9ba5ed" + "sha256:19bbca786df50b9eca23cee79d495facf55c8f5c54c529d9bf1fe7b5ea086af3", + "sha256:c46eb936111fffe6ec4bc9930035524a8be98ec2f74d8a0ff351226a3e52f459" ], "index": "pypi", "markers": "python_version >= '3.10'", - "version": "==5.1.2" + "version": "==5.1.5" }, "django-treebeard": { "hashes": [ @@ -44,12 +44,12 @@ }, "faker": { "hashes": [ - "sha256:37b5ab951f7367ea93edb865120e9717a7a649d6a4b223f1e4a47a8a20d9e85f", - "sha256:be0e548352c1be6f6d9c982003848a0d305868f160bb1fb7f945acffc347e676" + "sha256:49dde3b06a5602177bc2ad013149b6f60a290b7154539180d37b6f876ae79b20", + "sha256:ac4cf2f967ce02c898efa50651c43180bd658a7707cfd676fcc5410ad1482c03" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==30.6.0" + "version": "==33.3.1" }, "markdown": { "hashes": [ @@ -70,85 +70,81 @@ }, "pillow": { "hashes": [ - "sha256:00177a63030d612148e659b55ba99527803288cea7c75fb05766ab7981a8c1b7", - "sha256:006bcdd307cc47ba43e924099a038cbf9591062e6c50e570819743f5607404f5", - "sha256:084a07ef0821cfe4858fe86652fffac8e187b6ae677e9906e192aafcc1b69903", - "sha256:0ae08bd8ffc41aebf578c2af2f9d8749d91f448b3bfd41d7d9ff573d74f2a6b2", - "sha256:0e038b0745997c7dcaae350d35859c9715c71e92ffb7e0f4a8e8a16732150f38", - "sha256:1187739620f2b365de756ce086fdb3604573337cc28a0d3ac4a01ab6b2d2a6d2", - "sha256:16095692a253047fe3ec028e951fa4221a1f3ed3d80c397e83541a3037ff67c9", - "sha256:1a61b54f87ab5786b8479f81c4b11f4d61702830354520837f8cc791ebba0f5f", - "sha256:1c1d72714f429a521d8d2d018badc42414c3077eb187a59579f28e4270b4b0fc", - "sha256:1e2688958a840c822279fda0086fec1fdab2f95bf2b717b66871c4ad9859d7e8", - "sha256:20ec184af98a121fb2da42642dea8a29ec80fc3efbaefb86d8fdd2606619045d", - "sha256:21a0d3b115009ebb8ac3d2ebec5c2982cc693da935f4ab7bb5c8ebe2f47d36f2", - "sha256:224aaa38177597bb179f3ec87eeefcce8e4f85e608025e9cfac60de237ba6316", - "sha256:2679d2258b7f1192b378e2893a8a0a0ca472234d4c2c0e6bdd3380e8dfa21b6a", - "sha256:27a7860107500d813fcd203b4ea19b04babe79448268403172782754870dac25", - "sha256:290f2cc809f9da7d6d622550bbf4c1e57518212da51b6a30fe8e0a270a5b78bd", - "sha256:2e46773dc9f35a1dd28bd6981332fd7f27bec001a918a72a79b4133cf5291dba", - "sha256:3107c66e43bda25359d5ef446f59c497de2b5ed4c7fdba0894f8d6cf3822dafc", - "sha256:375b8dd15a1f5d2feafff536d47e22f69625c1aa92f12b339ec0b2ca40263273", - "sha256:45c566eb10b8967d71bf1ab8e4a525e5a93519e29ea071459ce517f6b903d7fa", - "sha256:499c3a1b0d6fc8213519e193796eb1a86a1be4b1877d678b30f83fd979811d1a", - "sha256:4ad70c4214f67d7466bea6a08061eba35c01b1b89eaa098040a35272a8efb22b", - "sha256:4b60c9520f7207aaf2e1d94de026682fc227806c6e1f55bba7606d1c94dd623a", - "sha256:5178952973e588b3f1360868847334e9e3bf49d19e169bbbdfaf8398002419ae", - "sha256:52a2d8323a465f84faaba5236567d212c3668f2ab53e1c74c15583cf507a0291", - "sha256:598b4e238f13276e0008299bd2482003f48158e2b11826862b1eb2ad7c768b97", - "sha256:5bd2d3bdb846d757055910f0a59792d33b555800813c3b39ada1829c372ccb06", - "sha256:5c39ed17edea3bc69c743a8dd3e9853b7509625c2462532e62baa0732163a904", - "sha256:5d203af30149ae339ad1b4f710d9844ed8796e97fda23ffbc4cc472968a47d0b", - "sha256:5ddbfd761ee00c12ee1be86c9c0683ecf5bb14c9772ddbd782085779a63dd55b", - "sha256:607bbe123c74e272e381a8d1957083a9463401f7bd01287f50521ecb05a313f8", - "sha256:61b887f9ddba63ddf62fd02a3ba7add935d053b6dd7d58998c630e6dbade8527", - "sha256:6619654954dc4936fcff82db8eb6401d3159ec6be81e33c6000dfd76ae189947", - "sha256:674629ff60030d144b7bca2b8330225a9b11c482ed408813924619c6f302fdbb", - "sha256:6ec0d5af64f2e3d64a165f490d96368bb5dea8b8f9ad04487f9ab60dc4bb6003", - "sha256:6f4dba50cfa56f910241eb7f883c20f1e7b1d8f7d91c750cd0b318bad443f4d5", - "sha256:70fbbdacd1d271b77b7721fe3cdd2d537bbbd75d29e6300c672ec6bb38d9672f", - "sha256:72bacbaf24ac003fea9bff9837d1eedb6088758d41e100c1552930151f677739", - "sha256:7326a1787e3c7b0429659e0a944725e1b03eeaa10edd945a86dead1913383944", - "sha256:73853108f56df97baf2bb8b522f3578221e56f646ba345a372c78326710d3830", - "sha256:73e3a0200cdda995c7e43dd47436c1548f87a30bb27fb871f352a22ab8dcf45f", - "sha256:75acbbeb05b86bc53cbe7b7e6fe00fbcf82ad7c684b3ad82e3d711da9ba287d3", - "sha256:8069c5179902dcdce0be9bfc8235347fdbac249d23bd90514b7a47a72d9fecf4", - "sha256:846e193e103b41e984ac921b335df59195356ce3f71dcfd155aa79c603873b84", - "sha256:8594f42df584e5b4bb9281799698403f7af489fba84c34d53d1c4bfb71b7c4e7", - "sha256:86510e3f5eca0ab87429dd77fafc04693195eec7fd6a137c389c3eeb4cfb77c6", - "sha256:8853a3bf12afddfdf15f57c4b02d7ded92c7a75a5d7331d19f4f9572a89c17e6", - "sha256:88a58d8ac0cc0e7f3a014509f0455248a76629ca9b604eca7dc5927cc593c5e9", - "sha256:8ba470552b48e5835f1d23ecb936bb7f71d206f9dfeee64245f30c3270b994de", - "sha256:8c676b587da5673d3c75bd67dd2a8cdfeb282ca38a30f37950511766b26858c4", - "sha256:8ec4a89295cd6cd4d1058a5e6aec6bf51e0eaaf9714774e1bfac7cfc9051db47", - "sha256:94f3e1780abb45062287b4614a5bc0874519c86a777d4a7ad34978e86428b8dd", - "sha256:9a0f748eaa434a41fccf8e1ee7a3eed68af1b690e75328fd7a60af123c193b50", - "sha256:a5629742881bcbc1f42e840af185fd4d83a5edeb96475a575f4da50d6ede337c", - "sha256:a65149d8ada1055029fcb665452b2814fe7d7082fcb0c5bed6db851cb69b2086", - "sha256:b3c5ac4bed7519088103d9450a1107f76308ecf91d6dabc8a33a2fcfb18d0fba", - "sha256:b4fd7bd29610a83a8c9b564d457cf5bd92b4e11e79a4ee4716a63c959699b306", - "sha256:bcd1fb5bb7b07f64c15618c89efcc2cfa3e95f0e3bcdbaf4642509de1942a699", - "sha256:c12b5ae868897c7338519c03049a806af85b9b8c237b7d675b8c5e089e4a618e", - "sha256:c26845094b1af3c91852745ae78e3ea47abf3dbcd1cf962f16b9a5fbe3ee8488", - "sha256:c6a660307ca9d4867caa8d9ca2c2658ab685de83792d1876274991adec7b93fa", - "sha256:c809a70e43c7977c4a42aefd62f0131823ebf7dd73556fa5d5950f5b354087e2", - "sha256:c8b2351c85d855293a299038e1f89db92a2f35e8d2f783489c6f0b2b5f3fe8a3", - "sha256:cb929ca942d0ec4fac404cbf520ee6cac37bf35be479b970c4ffadf2b6a1cad9", - "sha256:d2c0a187a92a1cb5ef2c8ed5412dd8d4334272617f532d4ad4de31e0495bd923", - "sha256:d69bfd8ec3219ae71bcde1f942b728903cad25fafe3100ba2258b973bd2bc1b2", - "sha256:daffdf51ee5db69a82dd127eabecce20729e21f7a3680cf7cbb23f0829189790", - "sha256:e58876c91f97b0952eb766123bfef372792ab3f4e3e1f1a2267834c2ab131734", - "sha256:eda2616eb2313cbb3eebbe51f19362eb434b18e3bb599466a1ffa76a033fb916", - "sha256:ee217c198f2e41f184f3869f3e485557296d505b5195c513b2bfe0062dc537f1", - "sha256:f02541ef64077f22bf4924f225c0fd1248c168f86e4b7abdedd87d6ebaceab0f", - "sha256:f1b82c27e89fffc6da125d5eb0ca6e68017faf5efc078128cfaa42cf5cb38798", - "sha256:fba162b8872d30fea8c52b258a542c5dfd7b235fb5cb352240c8d63b414013eb", - "sha256:fbbcb7b57dc9c794843e3d1258c0fbf0f48656d46ffe9e09b63bbd6e8cd5d0a2", - "sha256:fcb4621042ac4b7865c179bb972ed0da0218a076dc1820ffc48b1d74c1e37fe9" + "sha256:015c6e863faa4779251436db398ae75051469f7c903b043a48f078e437656f83", + "sha256:0a2f91f8a8b367e7a57c6e91cd25af510168091fb89ec5146003e424e1558a96", + "sha256:11633d58b6ee5733bde153a8dafd25e505ea3d32e261accd388827ee987baf65", + "sha256:2062ffb1d36544d42fcaa277b069c88b01bb7298f4efa06731a7fd6cc290b81a", + "sha256:31eba6bbdd27dde97b0174ddf0297d7a9c3a507a8a1480e1e60ef914fe23d352", + "sha256:3362c6ca227e65c54bf71a5f88b3d4565ff1bcbc63ae72c34b07bbb1cc59a43f", + "sha256:368da70808b36d73b4b390a8ffac11069f8a5c85f29eff1f1b01bcf3ef5b2a20", + "sha256:36ba10b9cb413e7c7dfa3e189aba252deee0602c86c309799da5a74009ac7a1c", + "sha256:3764d53e09cdedd91bee65c2527815d315c6b90d7b8b79759cc48d7bf5d4f114", + "sha256:3a5fe20a7b66e8135d7fd617b13272626a28278d0e578c98720d9ba4b2439d49", + "sha256:3cdcdb0b896e981678eee140d882b70092dac83ac1cdf6b3a60e2216a73f2b91", + "sha256:4637b88343166249fe8aa94e7c4a62a180c4b3898283bb5d3d2fd5fe10d8e4e0", + "sha256:4db853948ce4e718f2fc775b75c37ba2efb6aaea41a1a5fc57f0af59eee774b2", + "sha256:4dd43a78897793f60766563969442020e90eb7847463eca901e41ba186a7d4a5", + "sha256:54251ef02a2309b5eec99d151ebf5c9904b77976c8abdcbce7891ed22df53884", + "sha256:54ce1c9a16a9561b6d6d8cb30089ab1e5eb66918cb47d457bd996ef34182922e", + "sha256:593c5fd6be85da83656b93ffcccc2312d2d149d251e98588b14fbc288fd8909c", + "sha256:5bb94705aea800051a743aa4874bb1397d4695fb0583ba5e425ee0328757f196", + "sha256:67cd427c68926108778a9005f2a04adbd5e67c442ed21d95389fe1d595458756", + "sha256:70ca5ef3b3b1c4a0812b5c63c57c23b63e53bc38e758b37a951e5bc466449861", + "sha256:73ddde795ee9b06257dac5ad42fcb07f3b9b813f8c1f7f870f402f4dc54b5269", + "sha256:758e9d4ef15d3560214cddbc97b8ef3ef86ce04d62ddac17ad39ba87e89bd3b1", + "sha256:7d33d2fae0e8b170b6a6c57400e077412240f6f5bb2a342cf1ee512a787942bb", + "sha256:7fdadc077553621911f27ce206ffcbec7d3f8d7b50e0da39f10997e8e2bb7f6a", + "sha256:8000376f139d4d38d6851eb149b321a52bb8893a88dae8ee7d95840431977081", + "sha256:837060a8599b8f5d402e97197d4924f05a2e0d68756998345c829c33186217b1", + "sha256:89dbdb3e6e9594d512780a5a1c42801879628b38e3efc7038094430844e271d8", + "sha256:8c730dc3a83e5ac137fbc92dfcfe1511ce3b2b5d7578315b63dbbb76f7f51d90", + "sha256:8e275ee4cb11c262bd108ab2081f750db2a1c0b8c12c1897f27b160c8bd57bbc", + "sha256:9044b5e4f7083f209c4e35aa5dd54b1dd5b112b108648f5c902ad586d4f945c5", + "sha256:93a18841d09bcdd774dcdc308e4537e1f867b3dec059c131fde0327899734aa1", + "sha256:9409c080586d1f683df3f184f20e36fb647f2e0bc3988094d4fd8c9f4eb1b3b3", + "sha256:96f82000e12f23e4f29346e42702b6ed9a2f2fea34a740dd5ffffcc8c539eb35", + "sha256:9aa9aeddeed452b2f616ff5507459e7bab436916ccb10961c4a382cd3e03f47f", + "sha256:9ee85f0696a17dd28fbcfceb59f9510aa71934b483d1f5601d1030c3c8304f3c", + "sha256:a07dba04c5e22824816b2615ad7a7484432d7f540e6fa86af60d2de57b0fcee2", + "sha256:a3cd561ded2cf2bbae44d4605837221b987c216cff94f49dfeed63488bb228d2", + "sha256:a697cd8ba0383bba3d2d3ada02b34ed268cb548b369943cd349007730c92bddf", + "sha256:a76da0a31da6fcae4210aa94fd779c65c75786bc9af06289cd1c184451ef7a65", + "sha256:a85b653980faad27e88b141348707ceeef8a1186f75ecc600c395dcac19f385b", + "sha256:a8d65b38173085f24bc07f8b6c505cbb7418009fa1a1fcb111b1f4961814a442", + "sha256:aa8dd43daa836b9a8128dbe7d923423e5ad86f50a7a14dc688194b7be5c0dea2", + "sha256:ab8a209b8485d3db694fa97a896d96dd6533d63c22829043fd9de627060beade", + "sha256:abc56501c3fd148d60659aae0af6ddc149660469082859fa7b066a298bde9482", + "sha256:ad5db5781c774ab9a9b2c4302bbf0c1014960a0a7be63278d13ae6fdf88126fe", + "sha256:ae98e14432d458fc3de11a77ccb3ae65ddce70f730e7c76140653048c71bfcbc", + "sha256:b20be51b37a75cc54c2c55def3fa2c65bb94ba859dde241cd0a4fd302de5ae0a", + "sha256:b523466b1a31d0dcef7c5be1f20b942919b62fd6e9a9be199d035509cbefc0ec", + "sha256:b5d658fbd9f0d6eea113aea286b21d3cd4d3fd978157cbf2447a6035916506d3", + "sha256:b6123aa4a59d75f06e9dd3dac5bf8bc9aa383121bb3dd9a7a612e05eabc9961a", + "sha256:bd165131fd51697e22421d0e467997ad31621b74bfc0b75956608cb2906dda07", + "sha256:bf902d7413c82a1bfa08b06a070876132a5ae6b2388e2712aab3a7cbc02205c6", + "sha256:c12fc111ef090845de2bb15009372175d76ac99969bdf31e2ce9b42e4b8cd88f", + "sha256:c1eec9d950b6fe688edee07138993e54ee4ae634c51443cfb7c1e7613322718e", + "sha256:c640e5a06869c75994624551f45e5506e4256562ead981cce820d5ab39ae2192", + "sha256:cc1331b6d5a6e144aeb5e626f4375f5b7ae9934ba620c0ac6b3e43d5e683a0f0", + "sha256:cfd5cd998c2e36a862d0e27b2df63237e67273f2fc78f47445b14e73a810e7e6", + "sha256:d3d8da4a631471dfaf94c10c85f5277b1f8e42ac42bade1ac67da4b4a7359b73", + "sha256:d44ff19eea13ae4acdaaab0179fa68c0c6f2f45d66a4d8ec1eda7d6cecbcc15f", + "sha256:dd0052e9db3474df30433f83a71b9b23bd9e4ef1de13d92df21a52c0303b8ab6", + "sha256:dd0e081319328928531df7a0e63621caf67652c8464303fd102141b785ef9547", + "sha256:dda60aa465b861324e65a78c9f5cf0f4bc713e4309f83bc387be158b077963d9", + "sha256:e06695e0326d05b06833b40b7ef477e475d0b1ba3a6d27da1bb48c23209bf457", + "sha256:e1abe69aca89514737465752b4bcaf8016de61b3be1397a8fc260ba33321b3a8", + "sha256:e267b0ed063341f3e60acd25c05200df4193e15a4a5807075cd71225a2386e26", + "sha256:e5449ca63da169a2e6068dd0e2fcc8d91f9558aba89ff6d02121ca8ab11e79e5", + "sha256:e63e4e5081de46517099dc30abe418122f54531a6ae2ebc8680bcd7096860eab", + "sha256:f189805c8be5ca5add39e6f899e6ce2ed824e65fb45f3c28cb2841911da19070", + "sha256:f7955ecf5609dee9442cbface754f2c6e541d9e6eda87fad7f7a989b0bdb9d71", + "sha256:f86d3a7a9af5d826744fabf4afd15b9dfef44fe69a98541f666f66fbb8d3fef9", + "sha256:fbd43429d0d7ed6533b25fc993861b8fd512c42d04514a0dd6337fb3ccf22761" ], "index": "pypi", "markers": "python_version >= '3.9'", - "version": "==11.0.0" + "version": "==11.1.0" }, "python-dateutil": { "hashes": [ @@ -160,19 +156,19 @@ }, "six": { "hashes": [ - "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", - "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" + "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", + "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", - "version": "==1.16.0" + "version": "==1.17.0" }, "sqlparse": { "hashes": [ - "sha256:773dcbf9a5ab44a090f3441e2180efe2560220203dc2f8c0b0fa141e18b505e4", - "sha256:bb6b4df465655ef332548e24f08e205afc81b9ab86cb1c45657a7ff173a3a00e" + "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272", + "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca" ], "markers": "python_version >= '3.8'", - "version": "==0.5.1" + "version": "==0.5.3" }, "typing-extensions": { "hashes": [ diff --git a/django_ledger/models/chart_of_accounts.py b/django_ledger/models/chart_of_accounts.py index 19a623cd..aeee68f3 100644 --- a/django_ledger/models/chart_of_accounts.py +++ b/django_ledger/models/chart_of_accounts.py @@ -286,6 +286,10 @@ def get_account_root_node(self, return qs return qs.get() + raise ChartOfAccountsModelValidationError( + message='Adding Root account to Chart of Accounts is not allowed.' + ) + def get_non_root_coa_accounts_qs(self) -> AccountModelQuerySet: """ Returns a query set of non-root accounts in the chart of accounts. diff --git a/django_ledger/models/journal_entry.py b/django_ledger/models/journal_entry.py index ad9e5ad1..31ac207c 100644 --- a/django_ledger/models/journal_entry.py +++ b/django_ledger/models/journal_entry.py @@ -34,7 +34,7 @@ from django.core.exceptions import FieldError, ObjectDoesNotExist, ValidationError from django.db import models, transaction, IntegrityError -from django.db.models import Q, Sum, QuerySet, F, Manager +from django.db.models import Q, Sum, QuerySet, F, Manager, Count from django.db.models.functions import Coalesce from django.db.models.signals import pre_save from django.urls import reverse @@ -147,6 +147,12 @@ class JournalEntryModelManager(Manager): EntityModel and authenticated UserModel. """ + def get_queryset(self): + qs = JournalEntryModelQuerySet(self.model, using=self._db) + return qs.annotate( + txs_count=Count('transactionmodel') + ) + def for_user(self, user_model): qs = self.get_queryset() if user_model.is_superuser: diff --git a/django_ledger/templates/django_ledger/account/tags/accounts_table.html b/django_ledger/templates/django_ledger/account/tags/accounts_table.html index 2ed050d3..2d34495d 100644 --- a/django_ledger/templates/django_ledger/account/tags/accounts_table.html +++ b/django_ledger/templates/django_ledger/account/tags/accounts_table.html @@ -87,7 +87,7 @@ diff --git a/django_ledger/templates/django_ledger/invoice/invoice_detail.html b/django_ledger/templates/django_ledger/invoice/invoice_detail.html index cc6590b9..263889ad 100644 --- a/django_ledger/templates/django_ledger/invoice/invoice_detail.html +++ b/django_ledger/templates/django_ledger/invoice/invoice_detail.html @@ -158,7 +158,7 @@

- {% invoice_txs_table invoice %} + {% transactions_table invoice %}
diff --git a/django_ledger/templates/django_ledger/journal_entry/je_detail.html b/django_ledger/templates/django_ledger/journal_entry/je_detail.html index 4584971a..506355fb 100644 --- a/django_ledger/templates/django_ledger/journal_entry/je_detail.html +++ b/django_ledger/templates/django_ledger/journal_entry/je_detail.html @@ -14,7 +14,7 @@
{% trans 'Journal Entry Transactions' %}
- {% journal_entry_txs_table journal_entry %} + {% transactions_table journal_entry %} {% trans 'Edit TXS' %} diff --git a/django_ledger/templatetags/django_ledger.py b/django_ledger/templatetags/django_ledger.py index caff25ce..07795447 100644 --- a/django_ledger/templatetags/django_ledger.py +++ b/django_ledger/templatetags/django_ledger.py @@ -8,18 +8,20 @@ from calendar import month_abbr from random import randint +from typing import Union from django import template -from django.db.models import Sum +from django.db.models import Sum, F from django.urls import reverse from django.utils.formats import number_format +from rfc3986.exceptions import ValidationError from django_ledger import __version__ from django_ledger.forms.app_filters import EntityFilterForm, ActivityFilterForm from django_ledger.forms.feedback import BugReportForm, RequestNewFeatureForm from django_ledger.io import CREDIT, DEBIT, ROLES_ORDER_ALL from django_ledger.io.io_core import validate_activity, get_localdate -from django_ledger.models import TransactionModel, BillModel, InvoiceModel, EntityUnitModel +from django_ledger.models import TransactionModel, BillModel, InvoiceModel, EntityUnitModel, JournalEntryModel from django_ledger.settings import ( DJANGO_LEDGER_FINANCIAL_ANALYSIS, DJANGO_LEDGER_CURRENCY_SYMBOL, DJANGO_LEDGER_SPACED_CURRENCY_SYMBOL) @@ -223,43 +225,28 @@ def jes_table(context, journal_entry_qs, next_url=None): } -# todo: consolidate next 3 functions into one... -@register.inclusion_tag('django_ledger/journal_entry/tags/je_txs_table.html') -def journal_entry_txs_table(journal_entry_model, style='detail'): - transaction_model_qs = journal_entry_model.transactionmodel_set.all().select_related('account').order_by('account__code') - total_credits = sum(tx.amount for tx in transaction_model_qs if tx.tx_type == 'credit') - total_debits = sum(tx.amount for tx in transaction_model_qs if tx.tx_type == 'debit') - return { - 'style': style, - 'transaction_model_qs': transaction_model_qs, - 'total_debits': total_debits, - 'total_credits': total_credits, - } +@register.inclusion_tag('django_ledger/transactions/tags/txs_table.html') +def transactions_table(object_type: Union[JournalEntryModel, BillModel, InvoiceModel], style='detail'): + if isinstance(object_type, JournalEntryModel): + transaction_model_qs = object_type.transactionmodel_set.all().with_annotated_details().order_by( + '-timestamp') + elif isinstance(object_type, BillModel): + transaction_model_qs = object_type.get_transaction_queryset(annotated=True).order_by('-timestamp') + elif isinstance(object_type, InvoiceModel): + transaction_model_qs = object_type.get_transaction_queryset(annotated=True).order_by('-timestamp') + else: + raise ValidationError( + 'Cannot handle object of type {} to get transaction model queryset'.format(type(object_type))) + total_credits = sum(tx.amount for tx in transaction_model_qs if tx.is_credit()) + total_debits = sum(tx.amount for tx in transaction_model_qs if tx.is_debit()) -@register.inclusion_tag('django_ledger/journal_entry/tags/je_txs_table.html') -def bill_txs_table(bill_model: BillModel, style='detail'): - transaction_model_qs = bill_model.get_transaction_queryset() - total_credits = sum(tx.amount for tx in transaction_model_qs if tx.tx_type == CREDIT) - total_debits = sum(tx.amount for tx in transaction_model_qs if tx.tx_type == DEBIT) return { 'style': style, 'transaction_model_qs': transaction_model_qs, - 'total_credits': total_credits, 'total_debits': total_debits, - } - - -@register.inclusion_tag('django_ledger/journal_entry/tags/je_txs_table.html') -def invoice_txs_table(invoice_model: InvoiceModel, style='detail'): - transaction_model_qs = invoice_model.get_transaction_queryset() - total_credits = sum(tx.amount for tx in transaction_model_qs if tx.tx_type == CREDIT) - total_debits = sum(tx.amount for tx in transaction_model_qs if tx.tx_type == DEBIT) - return { - 'style': style, - 'transaction_model_qs': transaction_model_qs, 'total_credits': total_credits, - 'total_debits': total_debits, + 'object': object_type } From 4061faf89b715026a407ab769775abbd2479a9a3 Mon Sep 17 00:00:00 2001 From: Miguel Sanda Date: Fri, 24 Jan 2025 08:50:53 -0500 Subject: [PATCH 07/35] Refactor template variables and add new transaction table. Updated variable names in existing templates for consistency and clarity, replacing nested attributes with single-level ones. Added a new reusable transaction table template to support both detailed and compact display styles. These changes improve maintainability and code readability. --- .../journal_entry/tags/je_txs_table.html | 22 +++--- .../transactions/tags/txs_table.html | 69 +++++++++++++++++++ 2 files changed, 80 insertions(+), 11 deletions(-) create mode 100644 django_ledger/templates/django_ledger/transactions/tags/txs_table.html diff --git a/django_ledger/templates/django_ledger/journal_entry/tags/je_txs_table.html b/django_ledger/templates/django_ledger/journal_entry/tags/je_txs_table.html index f4c835e1..70190720 100644 --- a/django_ledger/templates/django_ledger/journal_entry/tags/je_txs_table.html +++ b/django_ledger/templates/django_ledger/journal_entry/tags/je_txs_table.html @@ -15,14 +15,14 @@ {% for transaction_model in transaction_model_qs %} - {{ transaction_model.journal_entry.timestamp }} - {{ transaction_model.account.code }} - {{ transaction_model.account.name }} - {% if transaction_model.journal_entry.entity_unit %} - {{ transaction_model.journal_entry.entity_unit.name }}{% endif %} - {% if transaction_model.tx_type == 'credit' %}$ + {{ transaction_model.timestamp }} + {{ transaction_model.account_code }} + {{ transaction_model.account_name }} + {% if transaction_model.entity_unit_name %} + {{ transaction_model.entity_unit_name }}{% endif %} + {% if transaction_model.is_credit %}$ {{ transaction_model.amount | currency_format }}{% endif %} - {% if transaction_model.tx_type == 'debit' %}$ + {% if transaction_model.is_debit %}$ {{ transaction_model.amount | currency_format }}{% endif %} {% if transaction_model.description %}{{ transaction_model.description }}{% endif %} @@ -50,10 +50,10 @@ {% for transaction_model in transaction_model_qs %} - {{ transaction_model.account.code }} - {{ transaction_model.account.name }} - {% if transaction_model.tx_type == 'credit' %}${{ transaction_model.amount | currency_format }}{% endif %} - {% if transaction_model.tx_type == 'debit' %}${{ transaction_model.amount | currency_format }}{% endif %} + {{ transaction_model.account_code }} + {{ transaction_model.account_name }} + {% if transaction_model.is_credit %}${{ transaction_model.amount | currency_format }}{% endif %} + {% if transaction_model.is_debit %}${{ transaction_model.amount | currency_format }}{% endif %} {% if transaction_model.description %}{{ transaction_model.description }}{% endif %} {% endfor %} diff --git a/django_ledger/templates/django_ledger/transactions/tags/txs_table.html b/django_ledger/templates/django_ledger/transactions/tags/txs_table.html new file mode 100644 index 00000000..70190720 --- /dev/null +++ b/django_ledger/templates/django_ledger/transactions/tags/txs_table.html @@ -0,0 +1,69 @@ +{% load i18n %} +{% load django_ledger %} + +{% if style == 'detail' %} +
+ + + + + + + + + + + {% for transaction_model in transaction_model_qs %} + + + + + + + + + + {% endfor %} + + + + + + + + + +
{% trans 'Timestamp' %}{% trans 'Account' %}{% trans 'Account Name' %}{% trans 'Unit' %}{% trans 'Credit' %}{% trans 'Debit' %}{% trans 'Description' %}
{{ transaction_model.timestamp }}{{ transaction_model.account_code }}{{ transaction_model.account_name }}{% if transaction_model.entity_unit_name %} + {{ transaction_model.entity_unit_name }}{% endif %}{% if transaction_model.is_credit %}$ + {{ transaction_model.amount | currency_format }}{% endif %}{% if transaction_model.is_debit %}$ + {{ transaction_model.amount | currency_format }}{% endif %}{% if transaction_model.description %}{{ transaction_model.description }}{% endif %}
Total{% currency_symbol %}{{ total_credits | currency_format }}{% currency_symbol %}{{ total_debits | currency_format }}
+
+{% elif style == 'compact' %} +
+ + + + + + + + + {% for transaction_model in transaction_model_qs %} + + + + + + + + {% endfor %} + + + + + + + +
{% trans 'Account' %}{% trans 'Account Name' %}{% trans 'Credit' %}{% trans 'Debit' %}{% trans 'Description' %}
{{ transaction_model.account_code }}{{ transaction_model.account_name }}{% if transaction_model.is_credit %}${{ transaction_model.amount | currency_format }}{% endif %}{% if transaction_model.is_debit %}${{ transaction_model.amount | currency_format }}{% endif %}{% if transaction_model.description %}{{ transaction_model.description }}{% endif %}
{% trans 'Total' %}{% currency_symbol %}{{ total_credits | currency_format }}{% currency_symbol %}{{ total_debits | currency_format }}
+
+{% endif %} From 04b96e199f95471ee734a324a23490aaa69f1008 Mon Sep 17 00:00:00 2001 From: Miguel Sanda Date: Fri, 24 Jan 2025 08:55:41 -0500 Subject: [PATCH 08/35] Remove redundant get_queryset methods in journal views Removed duplicate `get_queryset` methods from multiple journal entry views to simplify the codebase and reduce unnecessary database queries. Optimized logic by relying on inherited behavior and reusing existing utilities where applicable. --- django_ledger/views/journal_entry.py | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/django_ledger/views/journal_entry.py b/django_ledger/views/journal_entry.py index ed550ec8..29e34f7e 100644 --- a/django_ledger/views/journal_entry.py +++ b/django_ledger/views/journal_entry.py @@ -134,10 +134,6 @@ def get_success_url(self): je_model: JournalEntryModel = self.object return je_model.get_journal_entry_list_url() - def get_queryset(self): - qs = super().get_queryset() - return qs.prefetch_related('transactionmodel_set', 'transactionmodel_set__account') - class JournalEntryDetailView(DjangoLedgerSecurityMixIn, JournalEntryModelModelViewQuerySetMixIn, DetailView): context_object_name = 'journal_entry' @@ -152,10 +148,6 @@ class JournalEntryDetailView(DjangoLedgerSecurityMixIn, JournalEntryModelModelVi } http_method_names = ['get'] - def get_queryset(self): - qs = super().get_queryset() - return qs.prefetch_related('transactionmodel_set', 'transactionmodel_set__account') - class JournalEntryDeleteView(DjangoLedgerSecurityMixIn, JournalEntryModelModelViewQuerySetMixIn, DeleteView): template_name = 'django_ledger/journal_entry/je_delete.html' @@ -164,15 +156,10 @@ class JournalEntryDeleteView(DjangoLedgerSecurityMixIn, JournalEntryModelModelVi def get_success_url(self) -> str: je_model: JournalEntryModel = self.object - return reverse( - viewname='django_ledger:je-list', - kwargs={ - 'entity_slug': self.AUTHORIZED_ENTITY_MODEL.slug, - 'ledger_pk': je_model.ledger_id - } - ) + return je_model.get_journal_entry_list_url() +# todo:.... move this to transaction list view?..... class JournalEntryModelTXSDetailView(DjangoLedgerSecurityMixIn, JournalEntryModelModelViewQuerySetMixIn, DetailView): template_name = 'django_ledger/journal_entry/je_detail_txs.html' PAGE_TITLE = _('Edit Transactions') From cb39ec79e1b9e1a3a1024b7c1e5a9d77a42bb361 Mon Sep 17 00:00:00 2001 From: Dibas Dauliya <51583004+dibasdauliya@users.noreply.github.com> Date: Fri, 24 Jan 2025 08:58:58 -0500 Subject: [PATCH 09/35] Update README.md (#230) fix: `zsh: no matches found: django-ledger[graphql,pdf]` error --- README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b6b8ad14..403b8cc2 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,12 @@ pipenv install django * Install Django Ledger ```shell script -pipenv install django-ledger[graphql,pdf] +pipenv install "django-ledger[graphql,pdf]" +``` + +Alternatively, you can use: +```shell script +pipenv install django-ledger\[graphql,pdf\] ``` * Activate your new virtual environment: From ede4b41fd6d71e435fdd217622f072bd558fd1e1 Mon Sep 17 00:00:00 2001 From: Miguel Sanda Date: Fri, 24 Jan 2025 09:16:23 -0500 Subject: [PATCH 10/35] Refactor journal entry views to use common base class Replaced `JournalEntryModelModelViewQuerySetMixIn` with `JournalEntryModelModelBaseView` to streamline and standardize view inheritance. This improves code clarity and reduces duplication across journal entry views. Also reformatted imports for better readability. --- django_ledger/views/journal_entry.py | 51 ++++++++++++++++------------ 1 file changed, 30 insertions(+), 21 deletions(-) diff --git a/django_ledger/views/journal_entry.py b/django_ledger/views/journal_entry.py index 29e34f7e..ea015a63 100644 --- a/django_ledger/views/journal_entry.py +++ b/django_ledger/views/journal_entry.py @@ -12,13 +12,17 @@ from django.http import HttpResponseForbidden from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from django.views.generic import (YearArchiveView, MonthArchiveView, DetailView, UpdateView, CreateView, RedirectView, - ArchiveIndexView, DeleteView) +from django.views.generic import ( + YearArchiveView, MonthArchiveView, DetailView, UpdateView, CreateView, RedirectView, + ArchiveIndexView, DeleteView +) from django.views.generic.detail import SingleObjectMixin -from django_ledger.forms.journal_entry import (JournalEntryModelUpdateForm, - JournalEntryModelCannotEditForm, - JournalEntryModelCreateForm) +from django_ledger.forms.journal_entry import ( + JournalEntryModelUpdateForm, + JournalEntryModelCannotEditForm, + JournalEntryModelCreateForm +) from django_ledger.forms.transactions import get_transactionmodel_formset_class from django_ledger.io.io_core import get_localtime from django_ledger.models import EntityModel, LedgerModel @@ -26,22 +30,20 @@ from django_ledger.views.mixins import DjangoLedgerSecurityMixIn -class JournalEntryModelModelViewQuerySetMixIn: +class JournalEntryModelModelBaseView(DjangoLedgerSecurityMixIn): queryset = None def get_queryset(self): if self.queryset is None: self.queryset = JournalEntryModel.objects.for_entity( - entity_slug=self.kwargs['entity_slug'], + entity_slug=self.get_authorized_entity_instance(), user_model=self.request.user - ).for_ledger( - ledger_pk=self.kwargs['ledger_pk'] - ).select_related('entity_unit', 'ledger', 'ledger__entity') + ).for_ledger(ledger_pk=self.kwargs['ledger_pk']).select_related('entity_unit', 'ledger', 'ledger__entity') return self.queryset # JE Views --- -class JournalEntryCreateView(DjangoLedgerSecurityMixIn, CreateView): +class JournalEntryCreateView(JournalEntryModelModelBaseView, CreateView): template_name = 'django_ledger/journal_entry/je_create.html' PAGE_TITLE = _('Create Journal Entry') extra_context = { @@ -81,7 +83,8 @@ def get_success_url(self): return ledger_model.get_journal_entry_list_url() -class JournalEntryListView(DjangoLedgerSecurityMixIn, JournalEntryModelModelViewQuerySetMixIn, ArchiveIndexView): +# ARCHIVE VIEWS START.... +class JournalEntryListView(JournalEntryModelModelBaseView, ArchiveIndexView): context_object_name = 'journal_entry_list' template_name = 'django_ledger/journal_entry/je_list.html' PAGE_TITLE = _('Journal Entries') @@ -95,16 +98,18 @@ class JournalEntryListView(DjangoLedgerSecurityMixIn, JournalEntryModelModelView allow_empty = True -class JournalEntryYearListView(YearArchiveView, JournalEntryListView): +class JournalEntryYearListView(JournalEntryListView, YearArchiveView): make_object_list = True -class JournalEntryMonthListView(MonthArchiveView, JournalEntryListView): +class JournalEntryMonthListView(JournalEntryListView, MonthArchiveView): make_object_list = True month_format = '%m' -class JournalEntryUpdateView(DjangoLedgerSecurityMixIn, JournalEntryModelModelViewQuerySetMixIn, UpdateView): +# ARCHIVE VIEWS END.... + +class JournalEntryUpdateView(JournalEntryModelModelBaseView, UpdateView): context_object_name = 'journal_entry' template_name = 'django_ledger/journal_entry/je_update.html' pk_url_kwarg = 'je_pk' @@ -135,7 +140,7 @@ def get_success_url(self): return je_model.get_journal_entry_list_url() -class JournalEntryDetailView(DjangoLedgerSecurityMixIn, JournalEntryModelModelViewQuerySetMixIn, DetailView): +class JournalEntryDetailView(JournalEntryModelModelBaseView, DetailView): context_object_name = 'journal_entry' template_name = 'django_ledger/journal_entry/je_detail.html' slug_url_kwarg = 'je_pk' @@ -149,7 +154,7 @@ class JournalEntryDetailView(DjangoLedgerSecurityMixIn, JournalEntryModelModelVi http_method_names = ['get'] -class JournalEntryDeleteView(DjangoLedgerSecurityMixIn, JournalEntryModelModelViewQuerySetMixIn, DeleteView): +class JournalEntryDeleteView(JournalEntryModelModelBaseView, DeleteView): template_name = 'django_ledger/journal_entry/je_delete.html' context_object_name = 'je_model' pk_url_kwarg = 'je_pk' @@ -160,7 +165,7 @@ def get_success_url(self) -> str: # todo:.... move this to transaction list view?..... -class JournalEntryModelTXSDetailView(DjangoLedgerSecurityMixIn, JournalEntryModelModelViewQuerySetMixIn, DetailView): +class JournalEntryModelTXSDetailView(JournalEntryModelModelBaseView, DetailView): template_name = 'django_ledger/journal_entry/je_detail_txs.html' PAGE_TITLE = _('Edit Transactions') pk_url_kwarg = 'je_pk' @@ -242,7 +247,11 @@ def post(self, request, **kwargs): # ACTION VIEWS... -class BaseJournalEntryActionView(DjangoLedgerSecurityMixIn, RedirectView, SingleObjectMixin): +class BaseJournalEntryActionView( + JournalEntryModelModelBaseView, + RedirectView, + SingleObjectMixin +): http_method_names = ['get'] pk_url_kwarg = 'je_pk' action_name = None @@ -250,9 +259,9 @@ class BaseJournalEntryActionView(DjangoLedgerSecurityMixIn, RedirectView, Single def get_queryset(self): return JournalEntryModel.objects.for_entity( - entity_slug=self.kwargs['entity_slug'], + entity_slug=self.get_authorized_entity_instance(), user_model=self.request.user - ) + ).for_ledger(ledger_pk=self.kwargs['ledger_pk']) def get_redirect_url(self, *args, **kwargs): return reverse('django_ledger:je-list', From 3fdb1a00707bf1db915b576332e2c1e5492c405c Mon Sep 17 00:00:00 2001 From: Miguel Sanda Date: Mon, 27 Jan 2025 11:55:06 -0500 Subject: [PATCH 11/35] Refactor account action and detail views for consistency Replaced `AccountModelModelActionView` with `BaseAccountModelActionView` for account actions and corrected mixin order in detail views. These changes aim to ensure consistency in class naming and inheritance hierarchy, improving code readability and maintainability. --- django_ledger/urls/account.py | 8 ++++---- django_ledger/views/account.py | 14 +++++++------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/django_ledger/urls/account.py b/django_ledger/urls/account.py index d2c76dbe..e8685f4e 100644 --- a/django_ledger/urls/account.py +++ b/django_ledger/urls/account.py @@ -36,15 +36,15 @@ # Account Actions... path('//action//activate/', - views.AccountModelModelActionView.as_view(action_name='activate'), + views.BaseAccountModelActionView.as_view(action_name='activate'), name='account-action-activate'), path('//action//deactivate/', - views.AccountModelModelActionView.as_view(action_name='deactivate'), + views.BaseAccountModelActionView.as_view(action_name='deactivate'), name='account-action-deactivate'), path('//action//lock/', - views.AccountModelModelActionView.as_view(action_name='lock'), + views.BaseAccountModelActionView.as_view(action_name='lock'), name='account-action-lock'), path('//action//unlock/', - views.AccountModelModelActionView.as_view(action_name='unlock'), + views.BaseAccountModelActionView.as_view(action_name='unlock'), name='account-action-unlock') ] diff --git a/django_ledger/views/account.py b/django_ledger/views/account.py index 8c82d8e0..a8e924d8 100644 --- a/django_ledger/views/account.py +++ b/django_ledger/views/account.py @@ -200,28 +200,28 @@ def get_context_data(self, **kwargs): return context -class AccountModelQuarterDetailView(QuarterlyReportMixIn, AccountModelYearDetailView): +class AccountModelQuarterDetailView(AccountModelYearDetailView, QuarterlyReportMixIn): """ Account Model Quarter Detail View """ -class AccountModelMonthDetailView(MonthlyReportMixIn, AccountModelYearDetailView): +class AccountModelMonthDetailView(AccountModelYearDetailView, MonthlyReportMixIn): """ Account Model Month Detail View """ -class AccountModelDateDetailView(DateReportMixIn, AccountModelYearDetailView): +class AccountModelDateDetailView(AccountModelYearDetailView, DateReportMixIn): """ Account Model Date Detail View """ # ACTIONS... -class AccountModelModelActionView(BaseAccountModelBaseView, - RedirectView, - SingleObjectMixin): +class BaseAccountModelActionView(BaseAccountModelBaseView, + RedirectView, + SingleObjectMixin): http_method_names = ['get'] pk_url_kwarg = 'account_pk' action_name = None @@ -235,7 +235,7 @@ def get(self, request, *args, **kwargs): kwargs['user_model'] = self.request.user if not self.action_name: raise ImproperlyConfigured('View attribute action_name is required.') - response = super(AccountModelModelActionView, self).get(request, *args, **kwargs) + response = super(BaseAccountModelActionView, self).get(request, *args, **kwargs) account_model: AccountModel = self.get_object() try: From 26636a8fce9c13dcab0424a8ef00b850096a7811 Mon Sep 17 00:00:00 2001 From: Miguel Sanda Date: Sat, 1 Feb 2025 00:44:28 -0500 Subject: [PATCH 12/35] Refactor and enhance JournalEntryModel functionality. Improved validation, locking, and query handling for JournalEntryModel with stricter consistency checks and enhanced property methods. Updated docstrings to provide clear, detailed explanations for developers and added utility methods for ledger and transaction validation. --- django_ledger/models/journal_entry.py | 1184 +++++++++++++++---------- 1 file changed, 731 insertions(+), 453 deletions(-) diff --git a/django_ledger/models/journal_entry.py b/django_ledger/models/journal_entry.py index 9d6dca0d..90871305 100644 --- a/django_ledger/models/journal_entry.py +++ b/django_ledger/models/journal_entry.py @@ -46,7 +46,8 @@ ASSET_CA_CASH, GROUP_CFS_FIN_DIVIDENDS, GROUP_CFS_FIN_ISSUING_EQUITY, GROUP_CFS_FIN_LT_DEBT_PAYMENTS, GROUP_CFS_FIN_ST_DEBT_PAYMENTS, GROUP_CFS_INVESTING_AND_FINANCING, GROUP_CFS_INVESTING_PPE, - GROUP_CFS_INVESTING_SECURITIES, validate_roles + GROUP_CFS_INVESTING_SECURITIES, + validate_roles ) from django_ledger.models.accounts import CREDIT, DEBIT from django_ledger.models.entity import EntityStateModel, EntityModel @@ -64,6 +65,7 @@ DJANGO_LEDGER_DOCUMENT_NUMBER_PADDING, DJANGO_LEDGER_JE_NUMBER_NO_UNIT_PREFIX ) +from django_ledger.io import roles class JournalEntryValidationError(ValidationError): @@ -72,88 +74,114 @@ class JournalEntryValidationError(ValidationError): class JournalEntryModelQuerySet(QuerySet): """ - Custom defined JournalEntryQuerySet. + A custom QuerySet for working with Journal Entry models, providing additional + convenience methods and validations for specific use cases. + + This class enhances Django's default QuerySet by adding tailored methods + to manage and filter Journal Entries, such as handling posted, unposted, + locked entries, and querying entries associated with specific ledgers. """ def create(self, verify_on_save: bool = False, force_create: bool = False, **kwargs): """ - Overrides the standard Django QuerySet create() method to avoid the creation of POSTED Journal Entries without - proper business logic validation. New JEs using the create() method don't have any transactions to validate. - therefore, it is not necessary to query DB to balance TXS + Creates a new Journal Entry while enforcing business logic validations. + + This method overrides Django's default `create()` to ensure that Journal Entries + cannot be created in a "posted" state unless explicitly overridden. + Additionally, it offers optional pre-save verification. Parameters ---------- - - verify_on_save: bool - Executes a Journal Entry verification hook before saving. Avoids additional queries to - validate the Journal Entry - - force_create: bool - If True, will create return a new JournalEntryModel even if Posted at time of creation. - Use only if you know what you are doing. + verify_on_save : bool + If True, performs a verification step before saving. This avoids + additional database queries for validation when creating new entries. + Should be used when the Journal Entry needs no transactional validation. + force_create : bool + If True, allows the creation of a Journal Entry even in a "posted" + state. Use with caution and only if you are certain of the consequences. + **kwargs : dict + Additional keyword arguments passed to instantiate the Journal Entry model. Returns ------- JournalEntryModel - The newly created Journal Entry Model. + The newly created Journal Entry. + + Raises + ------ + FieldError + Raised if attempting to create a "posted" Journal Entry without + setting `force_create=True`. """ is_posted = kwargs.get('posted') - if is_posted and not force_create: - raise FieldError('Cannot create Journal Entries as posted') + raise FieldError("Cannot create Journal Entries in a posted state without 'force_create=True'.") obj = self.model(**kwargs) self._for_write = True - # verify_on_save option avoids additional queries to validate the journal entry. - # New JEs using the create() method don't have any transactions to validate. - # therefore, it is not necessary to query DB to balance TXS. + # Save the object with optional pre-save verification. obj.save(force_insert=True, using=self.db, verify=verify_on_save) return obj def posted(self): """ - Filters the QuerySet to only posted Journal Entries. + Filters the QuerySet to include only "posted" Journal Entries. Returns ------- JournalEntryModelQuerySet - A QuerySet with applied filters. + A filtered QuerySet containing only posted Journal Entries. """ return self.filter(posted=True) def unposted(self): + """ + Filters the QuerySet to include only "unposted" Journal Entries. + + Returns + ------- + JournalEntryModelQuerySet + A filtered QuerySet containing only unposted Journal Entries. + """ return self.filter(posted=False) def locked(self): """ - Filters the QuerySet to only locked Journal Entries. + Filters the QuerySet to include only "locked" Journal Entries. Returns ------- JournalEntryModelQuerySet - A QuerySet with applied filters. + A filtered QuerySet containing only locked Journal Entries. """ - return self.filter(locked=True) def unlocked(self): + """ + Filters the QuerySet to include only "unlocked" Journal Entries. + + Returns + ------- + JournalEntryModelQuerySet + A filtered QuerySet containing only unlocked Journal Entries. + """ return self.filter(locked=False) def for_ledger(self, ledger_pk: Union[str, UUID, LedgerModel]): """ - Fetches a QuerySet of JournalEntryModels associated with a specific EntityModel & UserModel & LedgerModel. - May pass an instance of EntityModel or a String representing the EntityModel slug. + Filters the QuerySet to include Journal Entries associated with a specific Ledger. Parameters ---------- - ledger_pk: str or UUID - The LedgerModel uuid as a string or UUID. + ledger_pk : str, UUID, or LedgerModel + The LedgerModel instance, its UUID, or a string representation of the UUID + to identify the Ledger. Returns ------- JournalEntryModelQuerySet - Returns a JournalEntryModelQuerySet with applied filters. + A filtered QuerySet of Journal Entries associated with the specified Ledger. """ if isinstance(ledger_pk, LedgerModel): return self.filter(ledger=ledger_pk) @@ -162,64 +190,108 @@ def for_ledger(self, ledger_pk: Union[str, UUID, LedgerModel]): class JournalEntryModelManager(Manager): """ - A custom defined Journal Entry Model Manager that supports additional complex initial Queries based on the - EntityModel and authenticated UserModel. + A custom manager for the JournalEntryModel that extends Django's default + Manager with additional query features. It allows complex query handling + based on relationships to the `EntityModel` and the authenticated `UserModel`. + + This manager provides utility methods for generating filtered querysets + (e.g., entries associated with specific users or entities), as well as + annotations for convenience in query results. """ def get_queryset(self) -> JournalEntryModelQuerySet: + """ + Returns the default queryset for JournalEntryModel with additional + annotations applied. + + Annotations: + - `_entity_slug`: The slug of the related `EntityModel`. + - `txs_count`: The count of transactions (related `TransactionModel` instances) + for each journal entry. + + Returns + ------- + JournalEntryModelQuerySet + A custom queryset enhanced with annotations. + """ qs = JournalEntryModelQuerySet(self.model, using=self._db) return qs.annotate( - _entity_slug=F('ledger__entity__slug'), - txs_count=Count('transactionmodel') + _entity_uuid=F('ledger__entity_id'), + _entity_slug=F('ledger__entity__slug'), # Annotates the entity slug + _entity_last_closing_date=F('ledger__entity__last_closing_date'), + _ledger_is_locked=F('ledger__locked'), + txs_count=Count('transactionmodel') # Annotates the count of transactions ) def for_user(self, user_model) -> JournalEntryModelQuerySet: + """ + Filters the JournalEntryModel queryset for the given user. + + - Superusers will have access to all journal entries. + - Other authenticated users will only see entries for entities where + they are admins or managers. + + Parameters + ---------- + user_model : UserModel + An authenticated Django user object. + + Returns + ------- + JournalEntryModelQuerySet + A filtered queryset restricted by the user's entity relationships. + """ qs = self.get_queryset() if user_model.is_superuser: return qs + return qs.filter( - Q(ledger__entity__admin=user_model) | - Q(ledger__entity__managers__in=[user_model]) + Q(ledger__entity__admin=user_model) | # Entries for entities where the user is admin + Q(ledger__entity__managers__in=[user_model]) # Entries for entities where the user is a manager ) def for_entity(self, entity_slug: Union[str, EntityModel], user_model) -> JournalEntryModelQuerySet: """ - Fetches a QuerySet of JournalEntryModels associated with a specific EntityModel & UserModel. - May pass an instance of EntityModel or a String representing the EntityModel slug. + Filters the JournalEntryModel queryset for a specific entity and user. + + This method provides a way to fetch journal entries related to a specific + `EntityModel`, identified by its slug or model instance, with additional + filtering scoped to the user. Parameters ---------- - entity_slug: str or EntityModel - The entity slug or EntityModel used for filtering the QuerySet. - user_model - Logged in and authenticated django UserModel instance. + entity_slug : str or EntityModel + The slug of the entity (or an instance of `EntityModel`) used for filtering. + user_model : UserModel + An authenticated Django user object. Returns ------- JournalEntryModelQuerySet - Returns a JournalEntryModelQuerySet with applied filters. + A customized queryset containing journal entries associated with the + given entity and restricted by the user's access permissions. """ qs = self.for_user(user_model) + + # Handle the `entity_slug` as either a string or an EntityModel instance if isinstance(entity_slug, EntityModel): return qs.filter(ledger__entity=entity_slug) - return qs.filter(ledger__entity__slug__iexact=entity_slug) + + return qs.filter(ledger__entity__slug__iexact=entity_slug) # Case-insensitive slug match class ActivityEnum(Enum): """ - The database string representation of each accounting activity prefix in the database. + Represents the database prefixes used for different types of accounting activities. Attributes - __________ - - OPERATING: str - The database representation prefix of a Journal Entry that is an Operating Activity. - - INVESTING: str - The database representation prefix of a Journal Entry that is an Investing Activity. - - FINANCING: str - The database representation prefix of a Journal Entry that is an Financing Activity. + ---------- + OPERATING : str + Prefix for a Journal Entry categorized as an Operating Activity. + INVESTING : str + Prefix for a Journal Entry categorized as an Investing Activity. + FINANCING : str + Prefix for a Journal Entry categorized as a Financing Activity. """ OPERATING = 'op' INVESTING = 'inv' @@ -228,51 +300,50 @@ class ActivityEnum(Enum): class JournalEntryModelAbstract(CreateUpdateMixIn): """ - The base implementation of the JournalEntryModel. + Abstract base model for handling journal entries in the bookkeeping system. Attributes ---------- - uuid: UUID - This is a unique primary key generated for the table. The default value of this field is uuid4(). - je_number: str - A unique, sequential, human-readable alphanumeric Journal Entry Number (a.k.a Voucher or Document Number in - other commercial bookkeeping software). Contains the fiscal year under which the JE takes place within the - EntityModel as a prefix. - timestamp: datetime - The date of the JournalEntryModel. This date is applied to all TransactionModels contained within the JE, and - drives the financial statements of the EntityModel. - description: str - A user defined description for the JournalEntryModel. - entity_unit: EntityUnitModel - A logical, self-contained, user defined class or structure defined withing the EntityModel. - See EntityUnitModel documentation for more details. - activity: str - Programmatically determined based on the JE transactions and must be a value from ACTIVITIES. Gives - additional insight of the nature of the JournalEntryModel in order to produce the Statement of Cash Flows for the - EntityModel. - origin: str - A string giving additional information behind the origin or trigger of the JournalEntryModel. - For example: reconciliations, migrations, auto-generated, etc. Any string value is valid. Max 30 characters. - posted: bool - Determines if the JournalLedgerModel is posted, which means is affecting the books. Defaults to False. - locked: bool - Determines if the JournalEntryModel is locked, which the creation or updates of new transactions are not - allowed. - ledger: LedgerModel - The LedgerModel associated with this JournalEntryModel. Cannot be null. + uuid : UUID + A unique identifier (primary key) for the journal entry, generated using uuid4(). + je_number : str + A human-readable, unique, alphanumeric identifier for the journal entry (e.g., Voucher or Document Number). + Includes the fiscal year as a prefix for organizational purposes. + timestamp : datetime + The date of the journal entry, used for financial statements. This timestamp applies to associated transactions. + description : str + An optional user-defined description for the journal entry. + entity_unit : EntityUnitModel + A reference to a logical and self-contained structure within the `EntityModel`. + Provides context for the journal entry. See `EntityUnitModel` documentation for details. + activity : str + Indicates the nature of the activity associated with the journal entry. + Must be one of the predefined `ACTIVITIES` (e.g., Operating, Financing, Investing) and is programmatically determined. + origin : str + Describes the origin or trigger for the journal entry (e.g., reconciliations, migrations, auto-generated). + Max length: 30 characters. + posted : bool + Determines whether the journal entry has been posted (affecting the books). Defaults to `False`. + locked : bool + Indicates whether the journal entry is locked, preventing further modifications. Defaults to `False`. + ledger : LedgerModel + A reference to the LedgerModel associated with this journal entry. This field is mandatory. + is_closing_entry : bool + Indicates if the journal entry is a closing entry. Defaults to `False`. """ + + # Constants for activity types OPERATING_ACTIVITY = ActivityEnum.OPERATING.value FINANCING_OTHER = ActivityEnum.FINANCING.value INVESTING_OTHER = ActivityEnum.INVESTING.value - INVESTING_SECURITIES = f'{ActivityEnum.INVESTING.value}_securities' INVESTING_PPE = f'{ActivityEnum.INVESTING.value}_ppe' - FINANCING_STD = f'{ActivityEnum.FINANCING.value}_std' FINANCING_LTD = f'{ActivityEnum.FINANCING.value}_ltd' FINANCING_EQUITY = f'{ActivityEnum.FINANCING.value}_equity' FINANCING_DIVIDENDS = f'{ActivityEnum.FINANCING.value}_dividends' + # Activity categories for dropdown ACTIVITIES = [ (_('Operating'), ( (OPERATING_ACTIVITY, _('Operating')), @@ -291,36 +362,43 @@ class JournalEntryModelAbstract(CreateUpdateMixIn): )), ] + # Utility mappings for activity validation VALID_ACTIVITIES = list(chain.from_iterable([[a[0] for a in cat[1]] for cat in ACTIVITIES])) MAP_ACTIVITIES = dict(chain.from_iterable([[(a[0], cat[0]) for a in cat[1]] for cat in ACTIVITIES])) NON_OPERATIONAL_ACTIVITIES = [a for a in VALID_ACTIVITIES if ActivityEnum.OPERATING.value not in a] + # Field definitions uuid = models.UUIDField(default=uuid4, editable=False, primary_key=True) je_number = models.SlugField(max_length=25, editable=False, verbose_name=_('Journal Entry Number')) timestamp = models.DateTimeField(verbose_name=_('Timestamp'), default=localtime) description = models.CharField(max_length=70, blank=True, null=True, verbose_name=_('Description')) - entity_unit = models.ForeignKey('django_ledger.EntityUnitModel', - on_delete=models.RESTRICT, - blank=True, - null=True, - verbose_name=_('Associated Entity Unit')) - activity = models.CharField(choices=ACTIVITIES, - max_length=20, - null=True, - blank=True, - editable=False, - verbose_name=_('Activity')) + entity_unit = models.ForeignKey( + 'django_ledger.EntityUnitModel', + on_delete=models.RESTRICT, + blank=True, + null=True, + verbose_name=_('Associated Entity Unit') + ) + activity = models.CharField( + choices=ACTIVITIES, + max_length=20, + null=True, + blank=True, + editable=False, + verbose_name=_('Activity') + ) origin = models.CharField(max_length=30, blank=True, null=True, verbose_name=_('Origin')) posted = models.BooleanField(default=False, verbose_name=_('Posted')) locked = models.BooleanField(default=False, verbose_name=_('Locked')) is_closing_entry = models.BooleanField(default=False) - - # todo: rename to ledger_model? - ledger = models.ForeignKey('django_ledger.LedgerModel', - verbose_name=_('Ledger'), - related_name='journal_entries', - on_delete=models.CASCADE) - + ledger = models.ForeignKey( + 'django_ledger.LedgerModel', + verbose_name=_('Ledger'), + related_name='journal_entries', + on_delete=models.CASCADE + ) + + # Custom manager objects = JournalEntryModelManager.from_queryset(queryset_class=JournalEntryModelQuerySet)() class Meta: @@ -339,165 +417,225 @@ class Meta: models.Index(fields=['is_closing_entry']), ] - def __str__(self): - if self.je_number: - return 'JE: {x1} (posted={p}, locked={l}) - Desc: {x2}'.format( - x1=self.je_number, - x2=self.description, - p=self.posted, - l=self.locked - ) - return 'JE ID: {x1} (posted={p}, locked={l}) - Desc: {x2}'.format( - x1=self.pk, - x2=self.description, - p=self.posted, - l=self.locked - ) - def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._verified = False - self._last_closing_date: Optional[date] = None + + def __str__(self): + if self.je_number: + return f"JE: {self.je_number} (posted={self.posted}, locked={self.locked}) - Desc: {self.description or ''}" + return f"JE ID: {self.pk} (posted={self.posted}, locked={self.locked}) - Desc: {self.description or ''}" + + @property + def entity_uuid(self): + try: + return getattr(self, '_entity_uuid') + except AttributeError: + pass + return self.ledger.entity_id @property def entity_slug(self): + """ + Retrieves the unique slug associated with the entity. + + The property first attempts to return the value stored in the `_entity_slug` + attribute if it exists. If `_entity_slug` is not set, it falls back to the + `ledger.entity.slug` attribute. + + Returns: + str: The slug value from `_entity_slug` if available, or `ledger.entity.slug` otherwise. + """ try: return getattr(self, '_entity_slug') except AttributeError: pass - return self.ledger.entity_slug + return self.ledger.entity.slug - def can_post(self, ignore_verify: bool = True) -> bool: + @property + def entity_last_closing_date(self) -> Optional[date]: """ - Determines if a JournalEntryModel can be posted. + Retrieves the last closing date for an entity, if available. + + This property returns the date of the most recent closing event + associated with the entity. If no closing date exists, the + result will be None. + + Returns + ------- + Optional[date] + The date of the last closing event, or None if no closing + date is available. + """ + return self.get_entity_last_closing_date() + + def validate_for_entity(self, entity_model: Union[EntityModel, str, UUID], raise_exception: bool = True) -> bool: + """ + Validates whether the given entity_model owns thr Journal Entry instance. + + This method checks if the provided entity_model owns the Journal Entry model instance. + The entity_model can be of type `EntityModel`, `str`, or + `UUID`. The method performs type-specific checks to ensure proper validation + and returns the validation result. Parameters ---------- - ignore_verify: bool - Skips JournalEntryModel verification if True. Defaults to False. + entity_model : Union[EntityModel, str, UUID] + The entity to validate against. It can either be an instance of the + `EntityModel`, a string representation of a UUID, or a UUID object. Returns ------- bool - True if JournalEntryModel can be posted, otherwise False. + A boolean value. True if the given entity_model corresponds to the current + entity's UUID, otherwise False. """ + if isinstance(entity_model, str): + is_valid = str(self.entity_uuid) == entity_model + elif isinstance(entity_model, UUID): + is_valid = self.entity_uuid == entity_model + else: + is_valid = self.entity_uuid == entity_model.uuid - return all([ - self.is_locked(), - not self.is_posted(), - self.is_verified() if not ignore_verify else True, - not self.ledger.is_locked(), - not self.is_in_locked_period() - ]) + if not is_valid and raise_exception: + raise JournalEntryValidationError( + message='The Journal Entry does not belong to the provided entity.' + ) + return is_valid - def can_unpost(self) -> bool: + def ledger_is_locked(self): """ - Determines if a JournalEntryModel can be un-posted. + Determines whether the ledger is locked. + + This method checks the current state of the ledger to determine if it is + locked and unavailable for further operations. It looks for an annotated + attribute `_ledger_is_locked` and returns its value if found. If the + attribute is not set, it delegates the check to the actual `is_locked` + method of the `ledger` object. Returns ------- bool - True if JournalEntryModel can be un-posted, otherwise False. + A boolean value indicating whether the ledger is locked. """ + try: + return getattr(self, '_ledger_is_locked') + except AttributeError: + pass + return self.ledger.is_locked() + + def can_post(self, ignore_verify: bool = True) -> bool: + """Determines if the journal entry can be posted.""" + return all([ + self.is_locked(), + not self.is_posted(), + self.is_verified() if not ignore_verify else True, # avoids db queries, will be verified before saving + not self.ledger_is_locked(), + not self.is_in_locked_period() + ]) + + def can_unpost(self) -> bool: + """Checks if the journal entry can be un-posted.""" return all([ self.is_posted(), - not self.ledger.is_locked(), + not self.ledger_is_locked(), not self.is_in_locked_period() ]) def can_lock(self) -> bool: - """ - Determines if a JournalEntryModel can be locked. - Locked JournalEntryModels cannot be modified. - - Returns - ------- - bool - True if JournalEntryModel can be locked, otherwise False. - """ + """Determines if the journal entry can be locked.""" return all([ not self.is_locked(), - not self.ledger.is_locked() + not self.ledger_is_locked() ]) def can_unlock(self) -> bool: - """ - Determines if a JournalEntryModel can be un-locked. - Locked transactions cannot be modified. - - Returns - ------- - bool - True if JournalEntryModel can be un-locked, otherwise False. - """ + """Checks if the journal entry can be unlocked.""" return all([ self.is_locked(), not self.is_posted(), not self.is_in_locked_period(), - not self.ledger.is_locked() + not self.ledger_is_locked() ]) def can_delete(self) -> bool: + """Checks if the journal entry can be deleted.""" return all([ not self.is_locked(), not self.is_posted(), ]) def can_edit(self) -> bool: - return not self.is_locked() + """Checks if the journal entry is editable.""" + return all([ + not self.is_locked(), + + ]) - def is_posted(self): + def is_posted(self) -> bool: + """Returns whether the journal entry has been posted.""" return self.posted is True def is_in_locked_period(self, new_timestamp: Optional[Union[date, datetime]] = None) -> bool: - last_closing_date = self.get_entity_last_closing_date() + """ + Checks if the current Journal Entry falls within a locked period. + + Parameters + ---------- + new_timestamp: Optional[Union[date, datetime]]) + An optional date or timestamp to be checked instead of the current timestamp. + + Returns + ------- + bool: True if the Journal Entry is in a locked period, otherwise False. + """ + last_closing_date = self.entity_last_closing_date if last_closing_date is not None: if not new_timestamp: return last_closing_date >= self.timestamp.date() elif isinstance(new_timestamp, datetime): return last_closing_date >= new_timestamp.date() - else: - return last_closing_date >= new_timestamp + return last_closing_date >= new_timestamp return False - def is_locked(self): - if self.is_posted(): - return True - return any([ - self.locked is True, - any([ - self.is_in_locked_period(), - self.ledger.is_locked() - ]) + def is_locked(self) -> bool: + """ + Determines if the Journal Entry is locked. + + A Journal Entry is considered locked if it is posted, explicitly marked + as locked, falls within a locked period, or the associated ledger is locked. + + Returns: + bool: True if the Journal Entry is locked, otherwise False. + """ + return self.is_posted() or any([ + self.locked, + self.is_in_locked_period(), + self.ledger_is_locked() ]) def is_verified(self) -> bool: """ - Determines if the JournalEntryModel is verified. + Checks if the Journal Entry is verified. - Returns - ------- - bool - True if is verified, otherwise False. + Returns: + bool: True if the Journal Entry is verified, otherwise False. """ return self._verified - # Transaction QuerySet def is_balance_valid(self, txs_qs: TransactionModelQuerySet, raise_exception: bool = True) -> bool: """ - Checks if CREDITs and DEBITs are equal. + Validates whether the DEBITs and CREDITs of the transactions balance correctly. - Parameters - ---------- - txs_qs: TransactionModelQuerySet + Parameters: + txs_qs (TransactionModelQuerySet): A QuerySet containing transactions to validate. + raise_exception (bool): Whether to raise a JournalEntryValidationError if the validation fails. - raise_exception: bool - Raises JournalEntryValidationError if TransactionModelQuerySet is not valid. - - Returns - ------- - bool - True if JE balances are valid (i.e. are equal). + Returns: + bool: True if the transactions are balanced, otherwise False. + + Raises: + JournalEntryValidationError: If the transactions are not balanced and raise_exception is True. """ if len(txs_qs) > 0: balances = self.get_txs_balances(txs_qs=txs_qs, as_dict=True) @@ -514,65 +652,78 @@ def is_balance_valid(self, txs_qs: TransactionModelQuerySet, raise_exception: bo return is_valid return True - def is_txs_qs_coa_valid(self, txs_qs: TransactionModelQuerySet) -> bool: + def is_txs_qs_coa_valid(self, txs_qs: TransactionModelQuerySet, raise_exception: bool = True) -> bool: """ - Validates that the Chart of Accounts (COA) is valid for the transactions. - Journal Entry transactions can only be associated with one Chart of Accounts (COA). - - - Parameters - ---------- - txs_qs: TransactionModelQuerySet + Validates that all transactions in the QuerySet are associated with the same Chart of Accounts (COA). - Returns - ------- - True if Transaction CoAs are valid, otherwise False. - """ + Parameters: + txs_qs (TransactionModelQuerySet): A QuerySet containing transactions to validate. + Returns: + bool: True if all transactions have the same Chart of Accounts, otherwise False. + """ if len(txs_qs) > 0: coa_count = len(set(tx.coa_id for tx in txs_qs)) - return coa_count == 1 + is_valid = coa_count == 1 + if not is_valid and raise_exception: + raise JournalEntryValidationError( + message='All transactions in the QuerySet must be associated with the same Chart of Accounts.' + ) + return is_valid return True def is_txs_qs_valid(self, txs_qs: TransactionModelQuerySet, raise_exception: bool = True) -> bool: """ - Validates a given TransactionModelQuerySet against the JournalEntryModel instance. + Validates whether the given Transaction QuerySet belongs to the current Journal Entry. - Parameters - ---------- - txs_qs: TransactionModelQuerySet - The queryset to validate. + Parameters: + txs_qs (TransactionModelQuerySet): A QuerySet containing transactions to validate. + raise_exception (bool): Whether to raise a JournalEntryValidationError if the validation fails. - raise_exception: bool - Raises JournalEntryValidationError if TransactionModelQuerySet is not valid. + Returns: + bool: True if all transactions belong to the Journal Entry, otherwise False. - Raises - ------ - JournalEntryValidationError if JE model is invalid and raise_exception is True. - - Returns - ------- - bool - True if valid, otherwise False. + Raises: + JournalEntryValidationError: If validation fails and raise_exception is True. """ if not isinstance(txs_qs, TransactionModelQuerySet): raise JournalEntryValidationError('Must pass an instance of TransactionModelQuerySet') is_valid = all(tx.journal_entry_id == self.uuid for tx in txs_qs) if not is_valid and raise_exception: - raise JournalEntryValidationError('Invalid TransactionModelQuerySet provided. All Transactions must be ', - f'associated with LedgerModel {self.uuid}') + raise JournalEntryValidationError( + f'Invalid TransactionModelQuerySet. All transactions must be associated with Journal Entry {self.uuid}.' + ) return is_valid - def is_cash_involved(self, txs_qs=None): - return ASSET_CA_CASH in self.get_txs_roles(txs_qs=None) + def is_cash_involved(self, txs_qs: Optional[TransactionModelQuerySet] = None) -> bool: + """ + Checks if the transactions involve cash assets. - def is_operating(self): - return self.activity in [ - self.OPERATING_ACTIVITY - ] + Parameters: + txs_qs (Optional[TransactionModelQuerySet]): Transactions to evaluate. If None, defaults to class behavior. + + Returns: + bool: True if cash assets are involved, otherwise False. + """ + return roles.ASSET_CA_CASH in self.get_txs_roles(txs_qs) + + def is_operating(self) -> bool: + """ + Checks if the Journal Entry is categorized as an operating activity. + + Returns: + bool: True if the activity is operating, otherwise False. + """ + return self.activity in [self.OPERATING_ACTIVITY] + + def is_financing(self) -> bool: + """ + Checks if the Journal Entry is categorized as a financing activity. - def is_financing(self): + Returns: + bool: True if the activity is financing, otherwise False. + """ return self.activity in [ self.FINANCING_EQUITY, self.FINANCING_LTD, @@ -581,19 +732,44 @@ def is_financing(self): self.FINANCING_OTHER ] - def is_investing(self): + def is_investing(self) -> bool: + """ + Checks if the Journal Entry is categorized as an investing activity. + + Returns: + bool: True if the activity is investing, otherwise False. + """ return self.activity in [ self.INVESTING_SECURITIES, self.INVESTING_PPE, self.INVESTING_OTHER ] - def get_entity_unit_name(self, no_unit_name: str = ''): + def get_entity_unit_name(self, no_unit_name: str = "") -> str: + """ + Retrieves the name of the entity unit associated with the Journal Entry. + + Parameters: + no_unit_name (str): The fallback name to return if no unit is associated. + + Returns: + str: The name of the entity unit, or the fallback provided. + """ if self.entity_unit_id: return self.entity_unit.name return no_unit_name def get_entity_last_closing_date(self) -> Optional[date]: + """ + Retrieves the last closing date for the entity associated with the Journal Entry. + + Returns: + Optional[date]: The last closing date if one exists, otherwise None. + """ + try: + return getattr(self, '_entity_last_closing_date') + except AttributeError: + pass return self.ledger.entity.last_closing_date def mark_as_posted(self, @@ -604,21 +780,16 @@ def mark_as_posted(self, **kwargs): """ Posted transactions show on the EntityModel ledger and financial statements. - Parameters ---------- commit: bool Commits changes into the Database, Defaults to False. - verify: bool Verifies JournalEntryModel before marking as posted. Defaults to False. - force_lock: bool Forces to lock the JournalEntry before is posted. - raise_exception: bool Raises JournalEntryValidationError if cannot post. Defaults to False. - kwargs: dict Additional keyword arguments. """ @@ -630,7 +801,6 @@ def mark_as_posted(self, message=_('Cannot post an empty Journal Entry.') ) return - if force_lock and not self.is_locked(): try: self.mark_as_locked(commit=False, raise_exception=True) @@ -638,13 +808,11 @@ def mark_as_posted(self, if raise_exception: raise e return - if not self.can_post(ignore_verify=False): if raise_exception: raise JournalEntryValidationError(f'Journal Entry {self.uuid} cannot post.' f' Is verified: {self.is_verified()}') return - if not self.is_posted(): self.posted = True if self.is_posted(): @@ -675,10 +843,8 @@ def mark_as_unposted(self, commit: bool = False, raise_exception: bool = False, ---------- commit: bool Commits changes into the Database, Defaults to False. - raise_exception: bool Raises JournalEntryValidationError if cannot post. Defaults to False. - kwargs: dict Additional keyword arguments. """ @@ -718,10 +884,8 @@ def mark_as_locked(self, commit: bool = False, raise_exception: bool = False, ** ---------- commit: bool Commits changes into the Database, Defaults to False. - raise_exception: bool Raises JournalEntryValidationError if cannot lock. Defaults to False. - kwargs: dict Additional keyword arguments. """ @@ -730,7 +894,6 @@ def mark_as_locked(self, commit: bool = False, raise_exception: bool = False, ** if raise_exception: raise JournalEntryValidationError(f'Journal Entry {self.uuid} is already locked.') return - if not self.is_locked(): self.generate_activity(force_update=True) self.locked = True @@ -765,7 +928,6 @@ def mark_as_unlocked(self, commit: bool = False, raise_exception: bool = False, if raise_exception: raise JournalEntryValidationError(f'Journal Entry {self.uuid} is already unlocked.') return - if self.is_locked(): self.locked = False self.activity = None @@ -785,138 +947,129 @@ def unlock(self, **kwargs): def get_transaction_queryset(self, select_accounts: bool = True) -> TransactionModelQuerySet: """ - Fetches the TransactionModelQuerySet associated with the JournalEntryModel instance. + Retrieves the `TransactionModelQuerySet` associated with this `JournalEntryModel` instance. Parameters ---------- - select_accounts: bool - Fetches the associated AccountModel of each transaction. Defaults to True. + select_accounts : bool, optional + If True, prefetches the related `AccountModel` for each transaction. Defaults to True. Returns ------- TransactionModelQuerySet - The TransactionModelQuerySet associated with the current JournalEntryModel instance. + A queryset containing transactions related to this journal entry. If `select_accounts` is + True, the accounts are included in the query as well. """ if select_accounts: return self.transactionmodel_set.all().select_related('account') return self.transactionmodel_set.all() - def get_txs_balances(self, - txs_qs: Optional[TransactionModelQuerySet] = None, - as_dict: bool = False) -> Union[TransactionModelQuerySet, Dict]: + def get_txs_balances( + self, + txs_qs: Optional[TransactionModelQuerySet] = None, + as_dict: bool = False + ) -> Union[TransactionModelQuerySet, Dict[str, Decimal]]: """ - Fetches the sum total of CREDITs and DEBITs associated with the JournalEntryModel instance. This method - performs a reduction/aggregation at the database level and fetches exactly two records. Optionally, - may pass an existing TransactionModelQuerySet if previously fetched. Additional validation occurs to ensure - that all TransactionModels in QuerySet are of the JE instance. Due to JournalEntryModel pre-save validation - and basic rules of accounting, CREDITs and DEBITS will always match. + Calculates the total CREDIT and DEBIT balances for the journal entry. + + This method performs an aggregate database query to compute the sum of CREDITs and + DEBITs across the transactions related to this journal entry. Optionally, a pre-fetched + `TransactionModelQuerySet` can be supplied for efficiency. Validation is performed to + ensure that all transactions belong to this journal entry. Parameters ---------- - txs_qs: TransactionModelQuerySet - The JE TransactionModelQuerySet to use if previously fetched. Will be validated to make sure all - TransactionModel in QuerySet belong to the JournalEntryModel instance. - - as_dict: bool - If True, returns the result as a dictionary, with exactly two keys: 'credit' and 'debit'. - The values will be the total CREDIT or DEBIT amount as Decimal. - - Examples - -------- - >>> je_model: JournalEntryModel = je_qs.first() # any existing JournalEntryModel QuerySet... - >>> balances = je_model.get_txs_balances() - >>> balances - Returns exactly two records: - - - Examples - -------- - >>> balances = je_model.get_txs_balances(as_dict=True) - >>> balances - Returns a dictionary: - {'credit': Decimal('2301.5'), 'debit': Decimal('2301.5')} + txs_qs : TransactionModelQuerySet, optional + A pre-fetched queryset of transactions. If None, the queryset is fetched automatically. + as_dict : bool, optional + If True, returns the results as a dictionary with keys "credit" and "debit". Defaults to False. + + Returns + ------- + Union[TransactionModelQuerySet, Dict[str, Decimal]] + If `as_dict` is False, returns a queryset of aggregated balances. If `as_dict` is True, + returns a dictionary containing the CREDIT and DEBIT totals. Raises ------ JournalEntryValidationError - If JE is not valid or TransactionModelQuerySet provided does not belong to JE instance. - - Returns - ------- - TransactionModelQuerySet or dict - An aggregated queryset containing exactly two records. The total CREDIT or DEBIT amount as Decimal. + If the provided queryset is invalid or does not belong to this journal entry. """ if not txs_qs: txs_qs = self.get_transaction_queryset(select_accounts=False) - else: - if not isinstance(txs_qs, TransactionModelQuerySet): - raise JournalEntryValidationError( - message=f'Must pass a TransactionModelQuerySet. Got {txs_qs.__class__.__name__}' - ) - - # todo: add maximum transactions per JE model as a setting... - is_valid = self.is_txs_qs_valid(txs_qs) - if not is_valid: - raise JournalEntryValidationError( - message='Invalid Transaction QuerySet used. Must be from same Journal Entry' - ) + elif not isinstance(txs_qs, TransactionModelQuerySet): + raise JournalEntryValidationError( + f"Expected a TransactionModelQuerySet, got {type(txs_qs).__name__}" + ) + elif not self.is_txs_qs_valid(txs_qs): + raise JournalEntryValidationError( + "Invalid TransactionModelQuerySet. All transactions must belong to the same journal entry." + ) balances = txs_qs.values('tx_type').annotate( - amount__sum=Coalesce(Sum('amount'), - Decimal('0.00'), - output_field=models.DecimalField())) + amount__sum=Coalesce(Sum('amount'), Decimal('0.00'), output_field=models.DecimalField()) + ) if as_dict: - return { - tx['tx_type']: tx['amount__sum'] for tx in balances - } + return {tx['tx_type']: tx['amount__sum'] for tx in balances} return balances - def get_txs_roles(self, - txs_qs: Optional[TransactionModelQuerySet] = None, - exclude_cash_role: bool = False) -> Set[str]: + def get_txs_roles( + self, + txs_qs: Optional[TransactionModelQuerySet] = None, + exclude_cash_role: bool = False + ) -> Set[str]: """ - Determines the list of account roles involved in the JournalEntryModel instance. - It reaches into the AccountModel associated with each TransactionModel of the JE to determine a Set of - all roles involved in transactions. This method is important in determining the nature of the + Retrieves the set of account roles involved in the journal entry's transactions. + + This method extracts the roles associated with the accounts linked to each transaction. + Optionally, the CASH role can be excluded from the results. Parameters ---------- - txs_qs: TransactionModelQuerySet - Prefetched TransactionModelQuerySet. Will be validated if provided. - Avoids additional DB query if provided. - - exclude_cash_role: bool - Removes CASH role from the Set if present. - Useful in some cases where cash role must be excluded for additional validation. + txs_qs : TransactionModelQuerySet, optional + A pre-fetched queryset of transactions. If None, the queryset is fetched automatically. + exclude_cash_role : bool, optional + If True, excludes the CASH role from the result. Defaults to False. Returns ------- - set - The set of account roles as strings associated with the JournalEntryModel instance. + Set[str] + A set of account roles associated with this journal entry's transactions. """ if not txs_qs: txs_qs = self.get_transaction_queryset(select_accounts=True) else: self.is_txs_qs_valid(txs_qs) - # todo: implement distinct for non SQLite Backends... + roles = {tx.account.role for tx in txs_qs} + if exclude_cash_role: - return set([i.account.role for i in txs_qs if i.account.role != ASSET_CA_CASH]) - return set([i.account.role for i in txs_qs]) + roles.discard(ASSET_CA_CASH) + + return roles def has_activity(self) -> bool: + """ + Checks if the journal entry has an associated activity. + + Returns + ------- + bool + True if an activity is defined for the journal entry, otherwise False. + """ return self.activity is not None def get_activity_name(self) -> Optional[str]: """ - Returns a human-readable, GAAP string representing the JournalEntryModel activity. + Gets the name of the activity associated with this journal entry. + + The activity indicates its categorization based on GAAP (e.g., operating, investing, financing). Returns ------- - str or None - Representing the JournalEntryModel activity in the statement of cash flows. + Optional[str] + The activity name if defined, otherwise None. """ if self.activity: if self.is_operating(): @@ -925,13 +1078,38 @@ def get_activity_name(self) -> Optional[str]: return ActivityEnum.INVESTING.value elif self.is_financing(): return ActivityEnum.FINANCING.value + return None @classmethod - def get_activity_from_roles(cls, - role_set: Union[List[str], Set[str]], - validate: bool = False, - raise_exception: bool = True) -> Optional[str]: + def get_activity_from_roles( + cls, + role_set: Union[List[str], Set[str]], + validate: bool = False, + raise_exception: bool = True + ) -> Optional[str]: + """ + Determines the financial activity type (e.g., operating, investing, financing) + based on a set of account roles. + + Parameters + ---------- + role_set : Union[List[str], Set[str]] + The set of roles to analyze. + validate : bool, optional + If True, validates the roles before analysis. Defaults to False. + raise_exception : bool, optional + If True, raises an exception if multiple activities are detected. Defaults to True. + + Returns + ------- + Optional[str] + The detected activity name, or None if no activity type is matched. + Raises + ------ + JournalEntryValidationError + If multiple activities are detected and `raise_exception` is True. + """ if validate: role_set = validate_roles(roles=role_set) else: @@ -1010,7 +1188,23 @@ def generate_activity(self, txs_qs: Optional[TransactionModelQuerySet] = None, raise_exception: bool = True, force_update: bool = False) -> Optional[str]: + """ + Generates the activity for the Journal Entry model based on its transactions. + Parameters + ---------- + txs_qs : Optional[TransactionModelQuerySet], default None + Queryset of TransactionModel instances for validation. If None, transactions are queried. + raise_exception : bool, default True + Determines whether exceptions are raised during processing. + force_update : bool, default False + Forces the regeneration of activity even if it exists. + + Returns + ------- + Optional[str] + Generated activity or None if not applicable. + """ if not self._state.adding: if raise_exception and self.is_closing_entry: raise_exception = False @@ -1043,7 +1237,19 @@ def generate_activity(self, # todo: add entity_model as parameter on all functions... # todo: outsource this function to EntityStateModel...?... def _get_next_state_model(self, raise_exception: bool = True) -> EntityStateModel: + """ + Retrieves or creates the next state model for the Journal Entry. + Parameters + ---------- + raise_exception : bool, default True + Determines if exceptions should be raised when the entity state is not found. + + Returns + ------- + EntityStateModel + The state model with an incremented sequence. + """ entity_model = EntityModel.objects.get(uuid__exact=self.ledger.entity_id) fy_key = entity_model.get_fy_for_date(dt=self.timestamp) @@ -1079,15 +1285,12 @@ def _get_next_state_model(self, raise_exception: bool = True) -> EntityStateMode def can_generate_je_number(self) -> bool: """ - Checks if the JournalEntryModel instance can generate its own JE number. - Conditions are: - * The JournalEntryModel must have a LedgerModel instance assigned. - * The JournalEntryModel instance must not have a pre-existing JE number. + Checks if a Journal Entry Number can be generated. Returns ------- bool - True if JournalEntryModel needs a JE number, otherwise False. + True if the Journal Entry can generate a JE number, otherwise False. """ return all([ self.ledger_id, @@ -1096,19 +1299,17 @@ def can_generate_je_number(self) -> bool: def generate_je_number(self, commit: bool = False) -> str: """ - Atomic Transaction. Generates the next Journal Entry document number available. The operation - will result in two additional queries if the Journal Entry LedgerModel & EntityUnitModel are not cached in - QuerySet via select_related('ledger', 'entity_unit'). + Generates the Journal Entry number in an atomic transaction. Parameters ---------- - commit: bool - Commits transaction into JournalEntryModel when function is called. + commit : bool, default False + Saves the generated JE number in the database. Returns ------- str - A String, representing the new or current JournalEntryModel instance Document Number. + The generated or existing JE number. """ if self.can_generate_je_number(): @@ -1138,30 +1339,24 @@ def verify(self, **kwargs) -> Tuple[TransactionModelQuerySet, bool]: """ - Verifies the JournalEntryModel. The JE Model is verified when: - * All TransactionModels associated with the JE instance are in balance (i.e. the sum of CREDITs and DEBITs are equal). - * If the JournalEntryModel is using cash, a cash flow activity is assigned. + Verifies the validity of the Journal Entry model instance. Parameters ---------- - txs_qs: TransactionModelQuerySet - Prefetched TransactionModelQuerySet. If provided avoids additional DB query. Will be verified against - JournalEntryModel instance. - force_verify: bool - If True, forces new verification of JournalEntryModel if previously verified. Defaults to False. - raise_exception: bool - If True, will raise JournalEntryValidationError if verification fails. - kwargs: dict - Additional function key-word args. - - Raises - ------ - JournalEntryValidationError if JE instance could not be verified. + txs_qs : Optional[TransactionModelQuerySet], default None + Queryset of TransactionModel instances to validate. If None, transactions are queried. + force_verify : bool, default False + Forces re-verification even if already verified. + raise_exception : bool, default True + Determines if exceptions are raised on validation failure. + kwargs : dict + Additional options. Returns ------- - tuple: TransactionModelQuerySet, bool - The TransactionModelQuerySet of the JournalEntryModel instance, verification result as True/False. + Tuple[TransactionModelQuerySet, bool] + - The TransactionModelQuerySet associated with the JournalEntryModel. + - A boolean indicating whether verification was successful. """ if not self.is_verified() or force_verify: @@ -1173,6 +1368,7 @@ def verify(self, is_txs_qs_valid = True else: try: + # if provided, it is verified... is_txs_qs_valid = self.is_txs_qs_valid(raise_exception=raise_exception, txs_qs=txs_qs) except JournalEntryValidationError as e: raise e @@ -1185,7 +1381,7 @@ def verify(self, except JournalEntryValidationError as e: raise e - # Transaction CoA if valid + # Transaction CoA if valid... try: is_coa_valid = self.is_txs_qs_coa_valid(txs_qs=txs_qs) @@ -1202,34 +1398,45 @@ def verify(self, # if raise_exception: # raise JournalEntryValidationError('At least two transactions required.') - if all([is_balance_valid, is_txs_qs_valid, is_coa_valid]): + if all([ + is_balance_valid, + is_txs_qs_valid, + is_coa_valid + ]): # activity flag... self.generate_activity(txs_qs=txs_qs, raise_exception=raise_exception) self._verified = True return txs_qs, self.is_verified() - return TransactionModel.objects.none(), self.is_verified() + return self.get_transaction_queryset(), self.is_verified() def clean(self, verify: bool = False, raise_exception: bool = True, txs_qs: Optional[TransactionModelQuerySet] = None) -> Tuple[TransactionModelQuerySet, bool]: """ - Customized JournalEntryModel clean method. Generates a JE number if needed. Optional verification hook on clean. + Cleans the JournalEntryModel instance, optionally verifying it and generating a Journal Entry (JE) number if required. Parameters ---------- - raise_exception: bool - Raises exception if JE could not be verified. Defaults to True. - verify: bool - Attempts to verify the JournalEntryModel during cleaning. - txs_qs: TransactionModelQuerySet - Prefetched TransactionModelQuerySet. If provided avoids additional DB query. Will be verified against - JournalEntryModel instance. + verify : bool, optional + If True, attempts to verify the JournalEntryModel during the cleaning process. Default is False. + raise_exception : bool, optional + If True, raises an exception when the instance fails verification. Default is True. + txs_qs : TransactionModelQuerySet, optional + A pre-fetched TransactionModelQuerySet. If provided, avoids additional database queries. The provided queryset is + validated against the JournalEntryModel instance. Returns ------- - tuple: TransactionModelQuerySet, bool - The TransactionModelQuerySet of the JournalEntryModel instance, verification result as True/False. + Tuple[TransactionModelQuerySet, bool] + A tuple containing: + - The validated TransactionModelQuerySet for the JournalEntryModel instance. + - A boolean indicating whether the instance passed verification. + + Raises + ------ + JournalEntryValidationError + If the instance has a timestamp in the future and is posted, or if verification fails and `raise_exception` is True. """ if txs_qs: @@ -1248,147 +1455,216 @@ def clean(self, if verify: txs_qs, verified = self.verify() return txs_qs, self.is_verified() - return TransactionModel.objects.none(), self.is_verified() + return self.get_transaction_queryset(), self.is_verified() def get_delete_message(self) -> str: + """ + Generates a confirmation message for deleting the JournalEntryModel instance. + + Returns + ------- + str + A confirmation message including the Journal Entry number and Ledger name. + """ return _(f'Are you sure you want to delete JournalEntry Model {self.je_number} on Ledger {self.ledger.name}?') def delete(self, **kwargs): + """ + Deletes the JournalEntryModel instance, ensuring it is allowed to be deleted. + + Parameters + ---------- + **kwargs : dict + Additional arguments passed to the parent delete method. + + Raises + ------ + JournalEntryValidationError + If the instance is not eligible for deletion. + """ if not self.can_delete(): raise JournalEntryValidationError( message=_(f'JournalEntryModel {self.uuid} cannot be deleted...') ) return super().delete(**kwargs) - def save(self, - verify: bool = True, - post_on_verify: bool = False, - *args, **kwargs): - # todo this does not show up on docs... + def save( + self, + verify: bool = True, + post_on_verify: bool = False, + *args, + **kwargs + ): """ - Custom JournalEntryModel instance save method. Additional options are added to attempt to verify JournalEntryModel - before saving into database. + Saves the JournalEntryModel instance, with optional verification and posting prior to saving. Parameters ---------- - verify: bool - If True, verifies JournalEntryModel transactions before saving. Defaults to True. - post_on_verify: bool - Posts JournalEntryModel if verification is successful and can_post() is True. + verify : bool, optional + If True, verifies the transactions of the JournalEntryModel before saving. Default is True. + post_on_verify : bool, optional + If True, posts the JournalEntryModel if verification is successful and `can_post()` is True. Default is False. Returns ------- JournalEntryModel - The saved instance. + The saved JournalEntryModel instance. + + Raises + ------ + JournalEntryValidationError + If the instance fails verification or encounters an issue during save. """ try: + # Generate the Journal Entry number prior to verification and saving self.generate_je_number(commit=False) + if verify: txs_qs, is_verified = self.clean(verify=True) if self.is_verified() and post_on_verify: - # commit is False since the super call takes place at the end of save() - # self.mark_as_locked(commit=False, raise_exception=True) + # Mark as posted if verification succeeds and posting is requested self.mark_as_posted(commit=False, verify=False, force_lock=True, raise_exception=True) + except ValidationError as e: if self.can_unpost(): self.mark_as_unposted(raise_exception=True) raise JournalEntryValidationError( - f'Something went wrong validating journal entry ID: {self.uuid}: {e.message}') + f'Error validating Journal Entry ID: {self.uuid}: {e.message}' + ) except Exception as e: - # safety net, for any unexpected error... - # no JE can be posted if not fully validated... + # Safety net for unexpected errors during save self.posted = False self._verified = False self.save(update_fields=['posted', 'updated'], verify=False) raise JournalEntryValidationError(e) + # Prevent saving an unverified Journal Entry if not self.is_verified() and verify: raise JournalEntryValidationError(message='Cannot save an unverified Journal Entry.') return super(JournalEntryModelAbstract, self).save(*args, **kwargs) # URLS Generation... + def get_absolute_url(self) -> str: + """ + Generates the URL to view the details of the journal entry. - return reverse('django_ledger:je-detail', - kwargs={ - 'je_pk': self.uuid, - 'ledger_pk': self.ledger_id, - 'entity_slug': self.ledger.entity.slug - }) + Returns + ------- + str + The absolute URL for the journal entry details. + """ + return reverse('django_ledger:je-detail', kwargs={ + 'je_pk': self.uuid, + 'ledger_pk': self.ledger_id, + 'entity_slug': self.entity_slug + }) def get_detail_url(self) -> str: """ - Determines the update URL of the LedgerModel instance. - Results in additional Database query if entity field is not selected in QuerySet. + Generates the detail URL for the journal entry. Returns ------- str - URL as a string. - """ - return reverse('django_ledger:je-detail', - kwargs={ - 'entity_slug': self.ledger.entity.slug, - 'ledger_pk': self.ledger_id, - 'je_pk': self.uuid - }) - - def get_journal_entry_list_url(self): - return reverse('django_ledger:je-list', - kwargs={ - 'entity_slug': self.entity_slug, - 'ledger_pk': self.ledger_id - }) + The URL for updating or viewing journal entry details. + """ + return reverse('django_ledger:je-detail', kwargs={ + 'entity_slug': self.entity_slug, + 'ledger_pk': self.ledger_id, + 'je_pk': self.uuid + }) + + def get_journal_entry_list_url(self) -> str: + """ + Constructs the URL to access the list of journal entries + associated with a specific ledger and entity. + + Returns + ------- + str + The URL for the journal entry list. + """ + return reverse('django_ledger:je-list', kwargs={ + 'entity_slug': self.entity_slug, + 'ledger_pk': self.ledger_id + }) def get_detail_txs_url(self) -> str: """ - Determines the update URL of the LedgerModel instance. - Results in additional Database query if entity field is not selected in QuerySet. + Generates the URL to view transaction details of the journal entry. + + Returns + ------- + str + The URL for transaction details of the journal entry. + """ + return reverse('django_ledger:je-detail-txs', kwargs={ + 'entity_slug': self.entity_slug, + 'ledger_pk': self.ledger_id, + 'je_pk': self.uuid + }) + + def get_unlock_url(self) -> str: + """ + Generates the URL to mark the journal entry as unlocked. + + Returns + ------- + str + The URL for unlocking the journal entry. + """ + return reverse('django_ledger:je-mark-as-unlocked', kwargs={ + 'entity_slug': self.entity_slug, + 'ledger_pk': self.ledger_id, + 'je_pk': self.uuid + }) + + def get_lock_url(self) -> str: + """ + Generates the URL to mark the journal entry as locked. Returns ------- str - URL as a string. - """ - return reverse('django_ledger:je-detail-txs', - kwargs={ - 'entity_slug': self.ledger.entity.slug, - 'ledger_pk': self.ledger_id, - 'je_pk': self.uuid - }) - - def get_unlock_url(self): - return reverse('django_ledger:je-mark-as-unlocked', - kwargs={ - 'entity_slug': self.ledger.entity.slug, - 'ledger_pk': self.ledger_id, - 'je_pk': self.uuid - }) - - def get_lock_url(self): - return reverse('django_ledger:je-mark-as-locked', - kwargs={ - 'entity_slug': self.ledger.entity.slug, - 'ledger_pk': self.ledger_id, - 'je_pk': self.uuid - }) - - def get_post_url(self): - return reverse('django_ledger:je-mark-as-posted', - kwargs={ - 'entity_slug': self.ledger.entity.slug, - 'ledger_pk': self.ledger_id, - 'je_pk': self.uuid - }) - - def get_unpost_url(self): - return reverse('django_ledger:je-mark-as-unposted', - kwargs={ - 'entity_slug': self.ledger.entity.slug, - 'ledger_pk': self.ledger_id, - 'je_pk': self.uuid - }) + The URL for locking the journal entry. + """ + return reverse('django_ledger:je-mark-as-locked', kwargs={ + 'entity_slug': self.entity_slug, + 'ledger_pk': self.ledger_id, + 'je_pk': self.uuid + }) + + def get_post_url(self) -> str: + """ + Generates the URL to mark the journal entry as posted. + + Returns + ------- + str + The URL for posting the journal entry. + """ + return reverse('django_ledger:je-mark-as-posted', kwargs={ + 'entity_slug': self.entity_slug, + 'ledger_pk': self.ledger_id, + 'je_pk': self.uuid + }) + + def get_unpost_url(self) -> str: + """ + Generates the URL to mark the journal entry as unposted. + + Returns + ------- + str + The URL for unposting the journal entry. + """ + return reverse('django_ledger:je-mark-as-unposted', kwargs={ + 'entity_slug': self.entity_slug, + 'ledger_pk': self.ledger_id, + 'je_pk': self.uuid + }) class JournalEntryModel(JournalEntryModelAbstract): @@ -1402,10 +1678,12 @@ class Meta(JournalEntryModelAbstract.Meta): def journalentrymodel_presave(instance: JournalEntryModel, **kwargs): - if instance._state.adding and not instance.ledger.can_edit_journal_entries(): - raise JournalEntryValidationError( - message=_(f'Cannot add Journal Entries to locked LedgerModel {instance.ledger_id}') - ) + if instance._state.adding: + # cannot add journal entries to a locked ledger... + if instance.ledger_is_locked(): + raise JournalEntryValidationError( + message=_(f'Cannot add Journal Entries to locked LedgerModel {instance.ledger_id}') + ) pre_save.connect(journalentrymodel_presave, sender=JournalEntryModel) From 30045eb3c0ec9fccd2d03d88a1da17acba6dada9 Mon Sep 17 00:00:00 2001 From: Miguel Sanda Date: Sat, 1 Feb 2025 00:45:03 -0500 Subject: [PATCH 13/35] Refactor context handling in JournalEntry views Removed redundant arguments and centralized context generation. Simplified formset initialization by leveraging `get_authorized_entity_instance` for entity model retrieval. This improves code clarity and maintainability. --- django_ledger/views/journal_entry.py | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/django_ledger/views/journal_entry.py b/django_ledger/views/journal_entry.py index ea015a63..a9bc8125 100644 --- a/django_ledger/views/journal_entry.py +++ b/django_ledger/views/journal_entry.py @@ -69,7 +69,6 @@ def get_form(self, form_class=None): return JournalEntryModelCreateForm( entity_model=self.get_authorized_entity_instance(), ledger_model=self.get_ledger_model(), - user_model=self.request.user, **self.get_form_kwargs() ) @@ -88,15 +87,19 @@ class JournalEntryListView(JournalEntryModelModelBaseView, ArchiveIndexView): context_object_name = 'journal_entry_list' template_name = 'django_ledger/journal_entry/je_list.html' PAGE_TITLE = _('Journal Entries') - extra_context = { - 'page_title': PAGE_TITLE, - 'header_title': PAGE_TITLE - } http_method_names = ['get'] date_field = 'timestamp' paginate_by = 20 allow_empty = True + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + entity_model: EntityModel = self.get_authorized_entity_instance() + context['page_title'] = self.PAGE_TITLE + context['header_title'] = self.PAGE_TITLE + context['header_subtitle'] = entity_model.name + return context + class JournalEntryYearListView(JournalEntryListView, YearArchiveView): make_object_list = True @@ -190,11 +193,8 @@ def get_context_data(self, txs_formset=None, **kwargs): if not txs_formset: TransactionModelFormSet = get_transactionmodel_formset_class(journal_entry_model=je_model) context['txs_formset'] = TransactionModelFormSet( - user_model=self.request.user, je_model=je_model, - ledger_pk=self.kwargs['ledger_pk'], - entity_slug=self.kwargs['entity_slug'], - queryset=je_model.transactionmodel_set.all().order_by('account__code') + entity_model=self.get_authorized_entity_instance(), ) else: context['txs_formset'] = txs_formset @@ -210,9 +210,7 @@ def post(self, request, **kwargs): TransactionModelFormSet = get_transactionmodel_formset_class(journal_entry_model=je_model) txs_formset = TransactionModelFormSet(request.POST, - user_model=self.request.user, - ledger_pk=kwargs['ledger_pk'], - entity_slug=kwargs['entity_slug'], + entity_model=self.get_authorized_entity_instance(), je_model=je_model) if je_model.locked: From 8db231fc40701838a031319f057a62b38167862f Mon Sep 17 00:00:00 2001 From: Miguel Sanda Date: Sat, 1 Feb 2025 00:45:29 -0500 Subject: [PATCH 14/35] Remove unused 'can_edit_journal_entries' method. The 'can_edit_journal_entries' method was redundant and not in use, so it has been removed to clean up the code. This improves maintainability by eliminating dead code. --- django_ledger/models/ledger.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/django_ledger/models/ledger.py b/django_ledger/models/ledger.py index bec215de..6286969d 100644 --- a/django_ledger/models/ledger.py +++ b/django_ledger/models/ledger.py @@ -444,9 +444,6 @@ def can_delete(self) -> bool: return True return False - def can_edit_journal_entries(self) -> bool: - return not self.is_locked() - def post(self, commit: bool = False, raise_exception: bool = True, **kwargs): """ Posts the LedgerModel. @@ -742,6 +739,7 @@ class LedgerModel(LedgerModelAbstract): """ Base LedgerModel from Abstract. """ + class Meta(LedgerModelAbstract.Meta): swappable = 'DJANGO_LEDGER_LEDGER_MODEL' abstract = False From 102906e6022eb73741a84dd9b6dbd92b7a80ce6a Mon Sep 17 00:00:00 2001 From: Miguel Sanda Date: Sat, 1 Feb 2025 00:47:41 -0500 Subject: [PATCH 15/35] Refactor TransactionModelFormSet to simplify initialization Revised `TransactionModelFormSet` to streamline initialization parameters by replacing `entity_slug` and `ledger_pk` with `EntityModel`. Utilized `EntityModel.get_coa_accounts()` for fetching accounts and added validation with `JournalEntryModel.validate_for_entity()`. This improves code readability and enhances maintainability. --- django_ledger/forms/transactions.py | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/django_ledger/forms/transactions.py b/django_ledger/forms/transactions.py index ae02dfd4..0f1fb786 100644 --- a/django_ledger/forms/transactions.py +++ b/django_ledger/forms/transactions.py @@ -3,15 +3,15 @@ Copyright© EDMA Group Inc licensed under the GPLv3 Agreement. Contributions to this module: -Miguel Sanda -Michael Noel + - Miguel Sanda + - Michael Noel """ from django.forms import ModelForm, modelformset_factory, BaseModelFormSet, TextInput, Select, ValidationError from django.utils.translation import gettext_lazy as _ from django_ledger.io.io_core import check_tx_balance -from django_ledger.models.accounts import AccountModel +from django_ledger.models import EntityModel from django_ledger.models.journal_entry import JournalEntryModel from django_ledger.models.transactions import TransactionModel from django_ledger.settings import DJANGO_LEDGER_FORM_INPUT_CLASSES @@ -44,17 +44,13 @@ class Meta: class TransactionModelFormSet(BaseModelFormSet): - def __init__(self, *args, entity_slug, user_model, ledger_pk, je_model=None, **kwargs): + def __init__(self, *args, entity_model: EntityModel, je_model: JournalEntryModel, **kwargs): super().__init__(*args, **kwargs) - self.USER_MODEL = user_model + je_model.validate_for_entity(entity_model) self.JE_MODEL: JournalEntryModel = je_model - self.LEDGER_PK = ledger_pk - self.ENTITY_SLUG = entity_slug + self.ENTITY_MODEL = entity_model - account_qs = AccountModel.objects.for_entity( - user_model=self.USER_MODEL, - entity_model=self.ENTITY_SLUG - ).available().order_by('code') + account_qs = self.ENTITY_MODEL.get_coa_accounts().active().order_by('code') for form in self.forms: form.fields['account'].queryset = account_qs @@ -64,7 +60,7 @@ def __init__(self, *args, entity_slug, user_model, ledger_pk, je_model=None, **k form.fields['amount'].disabled = True def get_queryset(self): - return self.JE_MODEL.transactionmodel_set.all().order_by('account__code') + return self.JE_MODEL.transactionmodel_set.all() def clean(self): if any(self.errors): From 3a2ad8e13bf0f89cf7408c9c1ef54d11d73d8c61 Mon Sep 17 00:00:00 2001 From: Miguel Sanda Date: Sat, 1 Feb 2025 00:48:54 -0500 Subject: [PATCH 16/35] Refactor and enhance EntityUnit model functionality Updated docstrings for clarity, introduced annotations in the manager for entity attributes, and improved the `__str__` method. Added `entity_slug` and `entity_name` as properties to streamline access to related entity data. --- django_ledger/models/unit.py | 64 +++++++++++++++++++++++------------- 1 file changed, 42 insertions(+), 22 deletions(-) diff --git a/django_ledger/models/unit.py b/django_ledger/models/unit.py index 21c92e9b..0319bde0 100644 --- a/django_ledger/models/unit.py +++ b/django_ledger/models/unit.py @@ -2,20 +2,23 @@ Django Ledger created by Miguel Sanda . Copyright© EDMA Group Inc licensed under the GPLv3 Agreement. -An EntityUnit is a logical, user-defined grouping which is assigned to JournalEntryModels to help segregate business -operations into separate components. Examples of business units may include Departments (i.e. Human Resources, IT, etc.) -office locations, a real estate property, or any other label relevant to the business. - -An EntityUnit is self contained. Meaning that double entry accounting rules apply to all transactions associated within -them. When An Invoice or Bill is updated, the migration process generates the appropriate Journal Entries associated -with each unit, if any. This means that an invoice or bill can split items into different units and the migration -process will allocate costs to each unit accordingly. - -The main advantages of EntityUnits are: - 1. Entity units can generate their own financial statements which can give additional insight to specific operations - of the business. - 2. Entity units can be assigned to specific items on Bills and Invoices, providing additional flexibility to track - inventory, expenses or income attributable to specific units of the business. +An EntityUnit is a logical, user-defined grouping assigned to JournalEntryModels, +helping to segregate business operations into distinct components. Examples of +EntityUnits may include departments (e.g., Human Resources, IT), office locations, +real estate properties, or any other labels relevant to the business. + +EntityUnits are self-contained entities, meaning that double-entry accounting rules +apply to all transactions associated with them. When an Invoice or Bill is updated, +the migration process generates the corresponding Journal Entries for each relevant +unit. This allows invoices or bills to split specific items into different units, +with the migration process allocating costs to each unit accordingly. + +Key advantages of EntityUnits: + 1. EntityUnits can generate their own financial statements, providing deeper + insights into the specific operations of the business. + 2. EntityUnits can be assigned to specific items on Bills and Invoices, offering + flexibility to track inventory, expenses, or income associated with distinct + business units. """ from random import choices @@ -25,7 +28,7 @@ from django.core.exceptions import ValidationError from django.db import models -from django.db.models import Q +from django.db.models import Q, F from django.urls import reverse from django.utils.text import slugify from django.utils.translation import gettext_lazy as _ @@ -50,6 +53,13 @@ class EntityUnitModelQuerySet(MP_NodeQuerySet): class EntityUnitModelManager(MP_NodeManager): + def get_queryset(self): + qs = EntityUnitModelQuerySet(self.model, using=self._db) + return qs.annotate( + _entity_slug=F('entity__slug'), + _entity_name=F('entity__name'), + ) + def for_user(self, user_model): qs = self.get_queryset() if user_model.is_superuser: @@ -71,12 +81,6 @@ def for_entity(self, entity_slug: str, user_model): user_model Logged in and authenticated django UserModel instance. - Examples - -------- - >>> request_user = request.user - >>> slug = kwargs['entity_slug'] # may come from request kwargs - >>> bill_model_qs = EntityUnitModel.objects.for_entity(user_model=request_user, entity_slug=slug) - Returns ------- EntityUnitModelQuerySet @@ -149,7 +153,23 @@ class Meta: ] def __str__(self): - return f'Unit: {self.name}' + return f'{self.entity_name}: {self.name}' + + @property + def entity_slug(self): + try: + return getattr(self, '_entity_slug') + except AttributeError: + pass + return self.entity.slug + + @property + def entity_name(self): + try: + return getattr(self, '_entity_name') + except AttributeError: + pass + return self.entity.name def clean(self): self.create_entity_unit_slug() From f01c094cc413bdb81ed5f50d83f162f248366c69 Mon Sep 17 00:00:00 2001 From: Miguel Sanda Date: Sat, 1 Feb 2025 00:50:32 -0500 Subject: [PATCH 17/35] Refactor JournalEntryForm initialization and validation logic Remove unnecessary user_model dependency and streamline queryset logic for entity_unit field. Update ledger validation to check for locking status, improving clarity and maintainability of the code. --- django_ledger/forms/journal_entry.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/django_ledger/forms/journal_entry.py b/django_ledger/forms/journal_entry.py index 75606918..4346f115 100644 --- a/django_ledger/forms/journal_entry.py +++ b/django_ledger/forms/journal_entry.py @@ -7,7 +7,6 @@ from django_ledger.models import EntityModel from django_ledger.models.journal_entry import JournalEntryModel from django_ledger.models.ledger import LedgerModel -from django_ledger.models.unit import EntityUnitModel from django_ledger.settings import DJANGO_LEDGER_FORM_INPUT_CLASSES @@ -15,7 +14,8 @@ class JournalEntryModelCreateForm(ModelForm): def __init__(self, entity_model: EntityModel, ledger_model: Union[str, UUID, LedgerModel], - user_model, *args, **kwargs): + *args, **kwargs + ): super().__init__(*args, **kwargs) self.ENTITY_MODEL: EntityModel = entity_model @@ -26,18 +26,14 @@ def __init__(self, self.ENTITY_MODEL.validate_ledger_model_for_entity(ledger_model) self.LEDGER_MODEL: LedgerModel = ledger_model - self.USER_MODEL = user_model if 'timestamp' in self.fields: self.fields['timestamp'].required = False if 'entity_unit' in self.fields: - self.fields['entity_unit'].queryset = EntityUnitModel.objects.for_entity( - entity_slug=self.ENTITY_MODEL, - user_model=self.USER_MODEL - ) + self.fields['entity_unit'].queryset = self.ENTITY_MODEL.entityunitmodel_set.all() def clean(self): - if not self.LEDGER_MODEL.can_edit_journal_entries(): + if self.LEDGER_MODEL.is_locked(): raise ValidationError(message=_('Cannot create new Journal Entries on a locked Ledger.')) self.instance.ledger = self.LEDGER_MODEL return super().clean() From 0ce98b99aed4f5ee99ca54766fa1fb78c902ccae Mon Sep 17 00:00:00 2001 From: Miguel Sanda Date: Sat, 1 Feb 2025 00:58:07 -0500 Subject: [PATCH 18/35] Migration --- ..._alter_transactionmodel_amount_and_more.py | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 django_ledger/migrations/0019_alter_transactionmodel_amount_and_more.py diff --git a/django_ledger/migrations/0019_alter_transactionmodel_amount_and_more.py b/django_ledger/migrations/0019_alter_transactionmodel_amount_and_more.py new file mode 100644 index 00000000..59099b8a --- /dev/null +++ b/django_ledger/migrations/0019_alter_transactionmodel_amount_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 5.1.5 on 2025-01-17 00:11 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('django_ledger', '0018_transactionmodel_cleared_transactionmodel_reconciled_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='transactionmodel', + name='amount', + field=models.DecimalField(decimal_places=2, default=0.0, help_text='Amount of the transaction.', + max_digits=20, validators=[django.core.validators.MinValueValidator(0)], + verbose_name='Amount'), + ), + migrations.AlterField( + model_name='transactionmodel', + name='description', + field=models.CharField(blank=True, + help_text='A description to be included with this individual transaction.', + max_length=100, null=True, verbose_name='Transaction Description'), + ), + migrations.AlterField( + model_name='transactionmodel', + name='tx_type', + field=models.CharField(choices=[('credit', 'Credit'), ('debit', 'Debit')], max_length=10, + verbose_name='Transaction Type'), + ), + ] From b7e00099c6edd03c8b89959332970ff7697b3f17 Mon Sep 17 00:00:00 2001 From: Miguel Sanda Date: Sat, 1 Feb 2025 01:24:34 -0500 Subject: [PATCH 19/35] Refactor JournalEntry update form handling logic Replaced `JournalEntryModelUpdateForm` base class to simplify form structure. Updated `get_form` method to `get_form_class` in the view for cleaner and more maintainable form handling. Improved separation of concerns and reduced redundant code. --- django_ledger/forms/journal_entry.py | 2 +- django_ledger/views/journal_entry.py | 16 +++------------- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/django_ledger/forms/journal_entry.py b/django_ledger/forms/journal_entry.py index 4346f115..624451f4 100644 --- a/django_ledger/forms/journal_entry.py +++ b/django_ledger/forms/journal_entry.py @@ -64,7 +64,7 @@ class Meta: } -class JournalEntryModelUpdateForm(JournalEntryModelCreateForm): +class JournalEntryModelUpdateForm(ModelForm): def clean_timestamp(self): if 'timestamp' in self.changed_data: diff --git a/django_ledger/views/journal_entry.py b/django_ledger/views/journal_entry.py index a9bc8125..08c0ec90 100644 --- a/django_ledger/views/journal_entry.py +++ b/django_ledger/views/journal_entry.py @@ -122,21 +122,11 @@ class JournalEntryUpdateView(JournalEntryModelModelBaseView, UpdateView): 'header_title': PAGE_TITLE } - def get_form(self, form_class=None): + def get_form_class(self, form_class=None): je_model: JournalEntryModel = self.object if not je_model.can_edit(): - return JournalEntryModelCannotEditForm( - entity_model=self.get_authorized_entity_instance(), - ledger_model=je_model.ledger, - user_model=self.request.user, - **self.get_form_kwargs() - ) - return JournalEntryModelUpdateForm( - entity_model=self.get_authorized_entity_instance(), - ledger_model=je_model.ledger, - user_model=self.request.user, - **self.get_form_kwargs() - ) + return JournalEntryModelCannotEditForm + return JournalEntryModelUpdateForm def get_success_url(self): je_model: JournalEntryModel = self.object From 65ea8d40b69b4fa4b69c79ac0b993a8d683d630f Mon Sep 17 00:00:00 2001 From: Ignacio Ocampo Date: Fri, 31 Jan 2025 22:29:31 -0800 Subject: [PATCH 20/35] Fixes typo on Current Maturities of Long Term Debt (#241) Replaced `Tern` with `Term` --- django_ledger/migrations/0001_initial.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django_ledger/migrations/0001_initial.py b/django_ledger/migrations/0001_initial.py index 5c541d71..bb3c11fd 100644 --- a/django_ledger/migrations/0001_initial.py +++ b/django_ledger/migrations/0001_initial.py @@ -46,7 +46,7 @@ class Migration(migrations.Migration): ('lia_cl_acc_payable', 'Accounts Payable'), ('lia_cl_wages_payable', 'Wages Payable'), ('lia_cl_int_payable', 'Interest Payable'), ('lia_cl_taxes_payable', 'Taxes Payable'), ('lia_cl_st_notes_payable', 'Notes Payable'), - ('lia_cl_ltd_mat', 'Current Maturities of Long Tern Debt'), ('lia_cl_def_rev', 'Deferred Revenue'), + ('lia_cl_ltd_mat', 'Current Maturities of Long Term Debt'), ('lia_cl_def_rev', 'Deferred Revenue'), ('lia_cl_other', 'Other Liabilities'), ('lia_ltl_notes', 'Notes Payable'), ('lia_ltl_bonds', 'Bonds Payable'), ('lia_ltl_mortgage', 'Mortgage Payable'))), ('Equity', ( ('eq_capital', 'Capital'), ('eq_stock_common', 'Common Stock'), From d3bbc3509cfc84cbf3a9caa9a79ecb804578816a Mon Sep 17 00:00:00 2001 From: Miguel Sanda Date: Sat, 1 Feb 2025 12:27:40 -0500 Subject: [PATCH 21/35] Refine amount validation and error messages. Change validation to reject only negative amounts, allowing zero. Clarify error messages for invalid amounts, improving readability and accuracy. --- django_ledger/io/io_library.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/django_ledger/io/io_library.py b/django_ledger/io/io_library.py index 63e627f6..2c5c7f6d 100644 --- a/django_ledger/io/io_library.py +++ b/django_ledger/io/io_library.py @@ -433,9 +433,9 @@ def _round_amount(self, amount: Decimal) -> Decimal: return round(amount, self.precision_decimals) def _amount(self, amount: Union[float, Decimal, int]) -> Decimal: - if amount <= 0: + if amount < 0: raise IOBluePrintValidationError( - message='Amounts must be greater than 0' + message='Amounts cannot be negative.' ) if isinstance(amount, float): @@ -448,7 +448,7 @@ def _amount(self, amount: Union[float, Decimal, int]) -> Decimal: return Decimal(str(amount)) raise IOBluePrintValidationError( - message='Amounts must be float or Decimal' + message='Amounts must be float, Decimal or int.' ) def credit(self, account_code: str, amount: Union[float, Decimal], description: str = None): From a436ba57b925cc4ede97052de2cb249af0e2af40 Mon Sep 17 00:00:00 2001 From: Miguel Sanda Date: Sat, 1 Feb 2025 15:56:48 -0500 Subject: [PATCH 22/35] Rename variable in ledgers_table inclusion tag. Updated the context dictionary key from 'ledgers' to 'ledger_model_qs' for clarity and better alignment with the actual parameter name. This ensures consistent naming and improves code readability. --- django_ledger/templatetags/django_ledger.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django_ledger/templatetags/django_ledger.py b/django_ledger/templatetags/django_ledger.py index 07795447..2f63b100 100644 --- a/django_ledger/templatetags/django_ledger.py +++ b/django_ledger/templatetags/django_ledger.py @@ -253,7 +253,7 @@ def transactions_table(object_type: Union[JournalEntryModel, BillModel, InvoiceM @register.inclusion_tag('django_ledger/ledger/tags/ledgers_table.html', takes_context=True) def ledgers_table(context, ledger_model_qs): return { - 'ledgers': ledger_model_qs, + 'ledger_model_qs': ledger_model_qs, 'entity_slug': context['view'].kwargs['entity_slug'], } From 188fec32057fead7269c35dd807c5e936e6302ad Mon Sep 17 00:00:00 2001 From: Miguel Sanda Date: Sat, 1 Feb 2025 16:08:25 -0500 Subject: [PATCH 23/35] Add URL generators for journal entry actions This update introduces methods to generate URLs for creating, posting, unposting, locking, and unlocking journal entries. These additions enhance the model's capabilities by centralizing URL construction for commonly used actions, improving code clarity and maintainability. --- django_ledger/models/journal_entry.py | 82 ++++++++++++++++++++++++++- 1 file changed, 81 insertions(+), 1 deletion(-) diff --git a/django_ledger/models/journal_entry.py b/django_ledger/models/journal_entry.py index 90871305..c82b9ae0 100644 --- a/django_ledger/models/journal_entry.py +++ b/django_ledger/models/journal_entry.py @@ -529,7 +529,7 @@ def can_post(self, ignore_verify: bool = True) -> bool: return all([ self.is_locked(), not self.is_posted(), - self.is_verified() if not ignore_verify else True, # avoids db queries, will be verified before saving + self.is_verified() if not ignore_verify else True, # avoids db queries, will be verified before saving not self.ledger_is_locked(), not self.is_in_locked_period() ]) @@ -1591,6 +1591,21 @@ def get_journal_entry_list_url(self) -> str: 'ledger_pk': self.ledger_id }) + def get_journal_entry_create_url(self) -> str: + """ + Constructs the URL to create a new journal entry + associated with a specific ledger and entity. + + Returns + ------- + str + The URL to create a journal entry. + """ + return reverse('django_ledger:je-create', kwargs={ + 'entity_slug': self.entity_slug, + 'ledger_pk': self.ledger_id + }) + def get_detail_txs_url(self) -> str: """ Generates the URL to view transaction details of the journal entry. @@ -1666,6 +1681,71 @@ def get_unpost_url(self) -> str: 'je_pk': self.uuid }) + # Action URLS.... + def get_action_post_url(self) -> str: + """ + Generates the URL used to mark the journal entry as posted. + + Returns + ------- + str + The generated URL for marking the journal entry as posted. + """ + return reverse('django_ledger:je-mark-as-posted', + kwargs={ + 'entity_slug': self.entity_slug, + 'ledger_pk': self.ledger_id, + 'je_pk': self.uuid + }) + + def get_action_unpost_url(self) -> str: + """ + Generates the URL used to mark the journal entry as unposted. + + Returns + ------- + str + The generated URL for marking the journal entry as unposted. + """ + return reverse('django_ledger:je-mark-as-unposted', + kwargs={ + 'entity_slug': self.entity_slug, + 'ledger_pk': self.ledger_id, + 'je_pk': self.uuid + }) + + def get_action_lock_url(self) -> str: + """ + Generates the URL used to mark the journal entry as locked. + + Returns + ------- + str + The generated URL for marking the journal entry as locked. + """ + return reverse('django_ledger:je-mark-as-locked', + kwargs={ + 'entity_slug': self.entity_slug, + 'ledger_pk': self.ledger_id, + 'je_pk': self.uuid + }) + + def get_action_unlock_url(self) -> str: + """ + Generates the URL used to mark the journal entry as unlocked. + + Returns + ------- + str + The generated URL for marking the journal entry as unlocked. + """ + return reverse('django_ledger:je-mark-as-unlocked', + kwargs={ + 'entity_slug': self.entity_slug, + 'ledger_pk': self.ledger_id, + 'je_pk': self.uuid + }) + class JournalEntryModel(JournalEntryModelAbstract): """ From 5449c3e5be026a08651324a94455e5e57cc2b724 Mon Sep 17 00:00:00 2001 From: Miguel Sanda Date: Sat, 1 Feb 2025 16:12:34 -0500 Subject: [PATCH 24/35] Refactor journal entry template variable names Updated the variable names in the journal entry table template for clarity and consistency. Replaced `je` with `journal_entry_model` and adjusted related URLs, conditionals, and references accordingly. This improves readability and aligns the template with model naming conventions. --- .../journal_entry/tags/je_table.html | 48 +++++++++---------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/django_ledger/templates/django_ledger/journal_entry/tags/je_table.html b/django_ledger/templates/django_ledger/journal_entry/tags/je_table.html index d046dc95..3dd76ac6 100644 --- a/django_ledger/templates/django_ledger/journal_entry/tags/je_table.html +++ b/django_ledger/templates/django_ledger/journal_entry/tags/je_table.html @@ -17,21 +17,21 @@ - {% for je in jes %} + {% for journal_entry_model in journal_entry_qs %} - {{ je.je_number }} - {{ je.timestamp }} - {% if je.activity %}{{ je.get_activity_display }}{% endif %} - {% if je.description %}{{ je.description }}{% endif %} + {{ journal_entry_model.je_number }} + {{ journal_entry_model.timestamp }} + {% if journal_entry_model.activity %}{{ journal_entry_model.get_activity_display }}{% endif %} + {% if journal_entry_model.description %}{{ journal_entry_model.description }}{% endif %} - {% if je.is_posted %} + {% if journal_entry_model.is_posted %} {% icon 'ant-design:check-circle-filled' 24 %} {% else %} {% icon 'maki:roadblock-11' 24 %} {% endif %} - {% if je.is_locked %} + {% if journal_entry_model.is_locked %} {% icon 'bi:lock-fill' 24 %} @@ -41,11 +41,11 @@ {% endif %} - {{ je.get_entity_unit_name }} - {{ je.txs_count }} + {{ journal_entry_model.get_entity_unit_name }} + {{ journal_entry_model.txs_count }}