Compare commits

..

512 commits

Author SHA1 Message Date
fc68368f3f
Fix entrypoint script path and update permissions for execution
All checks were successful
Build and push Docker image / build (push) Successful in 1m36s
2025-05-08 21:07:28 +02:00
100773c958
Remove version tag from Nummi image in Docker Compose configuration
All checks were successful
Build and push Docker image / build (push) Successful in 1m35s
2025-05-08 21:03:28 +02:00
530e6eaf47
Refactor static file settings to use configuration for STATIC_ROOT 2025-05-08 21:03:19 +02:00
0e0da7f658
Update Docker build workflow to include latest tag in image push 2025-05-08 20:59:08 +02:00
8a86ff9a4b
Update build workflow to trigger on version tags instead of main branch
All checks were successful
Build and push Docker image / build (push) Successful in 2m0s
2025-05-08 20:57:26 +02:00
b12f151b8d
Add Docker support with Dockerfile and docker-compose; implement entrypoint script for application startup 2025-05-08 20:56:55 +02:00
2c0e7b7699
Move to UV and Docker
All checks were successful
Build and push Docker image / build (push) Successful in 1m39s
2025-05-06 08:55:04 +02:00
08732b6ad4
Fix add transaction to empty statement bug 2025-02-06 18:01:52 +01:00
6563260b34
Add metadata and tags fields to Statement model; enhance search results to include statements
Close #44
2025-01-05 18:59:24 +01:00
4974c30397
Enhance invoice display with improved CSS styling and update invoice links
Fix #47
2025-01-05 18:51:59 +01:00
a277b37526
Refactor invoice handling to remove unused formset and improve metadata extraction from PDF files
Fix #46
2025-01-05 18:42:58 +01:00
7194039706
Fix history_plot function with regard to category and account checks
Fix #31
2025-01-05 17:12:29 +01:00
c153000d3d
Add invoice model with metadata and tags; update search and templates for invoice handling
Progress #44
2025-01-05 16:01:26 +01:00
608da4be55
Refactor category_plot tag to remove budget parameter
Fix #45
2025-01-05 15:15:35 +01:00
d246843be0
Enhance category plot template with improved URL handling for transactions based on year, month, statement, and account 2025-01-05 11:47:40 +01:00
e5ae5caf2a
Add history URL routing and templates for transaction history views 2025-01-05 11:38:28 +01:00
71f7a82f60
Refactor transaction archive templates
Fix #42
2025-01-05 09:43:29 +01:00
d5292911c2
Refactor models to inherit from NummiModel and implement custom query sets for enhanced search functionality 2025-01-04 21:57:21 +01:00
d44407d9ab
Refactor transaction URL handling and enhance filter form functionality
Close #43
2025-01-04 21:16:10 +01:00
02c53c7dab
Fix template variable references in transaction detail view 2025-01-04 20:28:32 +01:00
e42410863d
Update French translations 2025-01-04 18:57:13 +01:00
a238401f13
Add AccountSelect widget and update account field in forms
- Introduced AccountSelect widget for improved account selection in forms.
- Updated AccountForm, StatementForm, and TransactionFiltersForm to use AccountSelect.
- Modified account model options to include 'archived' in ordering.
- Added new migration for account model options change.
2025-01-04 18:45:36 +01:00
ccbf433ec0
Add filter icon 2025-01-04 18:33:04 +01:00
b848bf8d65
Implement filters and sorting for transactions
Close #5
2025-01-04 18:28:37 +01:00
7851e8afbb
Improve form buttons styling
Close #34
2025-01-04 14:34:23 +01:00
6a17a3e333
Add pre-commit hook to prevent commits to main 2025-01-04 14:21:59 +01:00
e1f29f90c6
Move transaction link
Close #35
Progress #38
2025-01-04 14:17:48 +01:00
805c7d3dc0
Enable file drag and drop on transaction details page
Close #37
2025-01-04 12:37:09 +01:00
cfb2ceb2c3
Fix #39 2025-01-03 22:46:53 +01:00
cb1882ef8a
Default statement transaction date
Fix #40
2025-01-03 21:53:01 +01:00
d2d101fe34
Resize block on textarea
Fix #41
2025-01-03 21:49:47 +01:00
ec5c0e2fef
Fix update_sum left in transaction models 2025-01-03 19:16:03 +01:00
0c5ba6899b
Fix no autocomplete on statement transaction creation
Fix #36
2025-01-03 19:04:40 +01:00
ff519ea7d1
Auto-fill transaction form
Close #27
2025-01-03 18:50:50 +01:00
0cb4a681f1
Refactor datalist input usage 2025-01-03 16:52:02 +01:00
f203d1db46
Fix transaction table (fix #33) 2025-01-03 16:46:36 +01:00
38ab298094
Improve transaction form
Default to no statement (fix #28)
Add link to statement on statement transaction creation
Fix textarea height
2025-01-03 16:25:49 +01:00
7478404d8a
Improve statement details page
Fix #29
2025-01-03 15:55:58 +01:00
d8961c8198
Improve forms
Form errors displayed correctly (fix #30)
Fix #26
Close #20
2025-01-03 15:21:06 +01:00
5b49836bad
Fix history plot
Fix #31
2025-01-03 14:55:16 +01:00
c14e075cad
Add VS Code debug config 2025-01-03 14:54:47 +01:00
a6fc7f5a92
Move statement sum and diff to properties instead of model fields
Fix #32
2025-01-03 14:25:40 +01:00
26f97dd179
Use only budgeted categories in history plot 2025-01-03 13:05:46 +01:00
2cba5c6d83
Hide archived accounts in select for new statements 2025-01-03 12:46:19 +01:00
c6c67b9f93
Fix invoices not being shown on transactions 2025-01-03 12:33:33 +01:00
1f22ac7042
JS bugfix 2025-01-02 17:36:45 +01:00
57b315c842
Fix bug with js for archived accounts 2025-01-02 17:33:25 +01:00
224f55a8b1
Add icon field, closes #24 2025-01-02 17:30:59 +01:00
b4654ec445
Add automatic icons to category select (#24) 2025-01-02 16:40:27 +01:00
348ac494e9
Use templatetag for transaction_table 2025-01-02 15:19:12 +01:00
229033aac0
Use templatetag for history_plot 2025-01-02 15:03:10 +01:00
b1fddd0dd6
Use templatetag for statement_table 2025-01-02 14:41:53 +01:00
786d7c2130
Use templatetags for statement_table 2025-01-02 14:19:06 +01:00
090f1a3a5c
Fix #21 2025-01-02 14:05:08 +01:00
bac3b59358
Fix #22 2025-01-02 14:02:01 +01:00
cb939df81e
Fix #23, Fix #25 2025-01-02 13:58:28 +01:00
e8050fadb9
Fix statement list, gaps 2025-01-02 10:16:05 +01:00
57d5330d75
Create account list view 2025-01-02 10:07:01 +01:00
8575f43475
Fix transaction table with no account 2025-01-02 09:37:47 +01:00
79f87779dd
Fix display on index page 2025-01-01 19:05:34 +01:00
2991786444
Improve statement form 2025-01-01 19:01:35 +01:00
08551a865d
Improve category style 2025-01-01 18:53:56 +01:00
744ea277f5
Fix forms 2025-01-01 18:44:14 +01:00
cfe5389658
Fix login form buttons 2025-01-01 17:11:45 +01:00
f2f6335ad5
Fix forms 2025-01-01 17:10:15 +01:00
75ec967c40
Fix fileinput 2025-01-01 16:46:06 +01:00
c0aa8d17b6
Improve fieldsets 2025-01-01 16:21:31 +01:00
eeb5f4f04f
Improve forms 2025-01-01 15:39:18 +01:00
221dbb196e
Improve form style 2025-01-01 14:56:29 +01:00
804b2b38e0
Order datalist items by count 2025-01-01 14:14:11 +01:00
f98990d0a8
Update djlint settings 2025-01-01 13:59:31 +01:00
898c92df23
Fix orphan transaction detail page 2025-01-01 13:51:24 +01:00
c7994114a1
Add autocomplete for transaction fields
Needs work on ordering by -count
2025-01-01 09:23:19 +01:00
951f157de9
Fix invoice list (edit, bold title) 2025-01-01 08:40:52 +01:00
c8b58a18ac
Improve invoice list 2025-01-01 08:38:04 +01:00
275a1f6bc7
Fix transaction details on mobile 2025-01-01 08:20:16 +01:00
fe519bd4f0
Create transaction detail view 2024-12-31 19:39:55 +01:00
a8ff27e245
Add edit icon to category edit 2024-12-31 17:21:32 +01:00
093ca475d8
Fix icons 2024-12-31 17:19:52 +01:00
fe59869a1d
Fix show archived button 2024-12-31 17:13:33 +01:00
94d1907f9a
Add statement summary to details page 2024-12-31 17:04:34 +01:00
7d4dbdc0df
Create statement detail view separate from edit view 2024-12-31 16:47:36 +01:00
fd140a9314
Hide expected total when it is correct 2024-12-31 16:19:37 +01:00
f9e489218d
Add icons to messages 2024-12-31 16:14:24 +01:00
b20ef58e18
Reduce statement size to allow for better mobile experience 2024-12-31 15:58:06 +01:00
86a354f353
Fix category plot on statement page 2024-12-31 15:44:33 +01:00
cb08cb3d46
Improve account list 2024-12-31 15:41:02 +01:00
412cf94f93
Add last statements to home page 2024-12-31 15:31:07 +01:00
c754e869fc
Include icons in links 2024-12-31 15:14:42 +01:00
fac26bc224
Merge branch 'main' into dev 2024-12-31 15:04:35 +01:00
63f1cf4e4d
Update statement display (cards instead of table) 2024-12-31 15:03:01 +01:00
7109142b4e
Fix bug in history 2024-12-30 19:20:53 +01:00
886e682650
Fix history plot on mobile 2024-12-30 18:14:10 +01:00
5d6bd9ea2b
Improve history view using tenth centile times 125% for min max 2024-12-30 17:56:55 +01:00
28ac9c8ef7
Improve calendar table 2024-12-30 17:29:42 +01:00
a3e598acb6
Improve history view with outliers 2024-12-30 16:57:00 +01:00
46ea394422
Allow orphan transactions
Closes #18
2024-12-30 16:35:03 +01:00
93c4b43fa3
Improve account list
Closes #19
2024-12-30 16:11:43 +01:00
cf25fd1826
Enable archiving accounts 2024-12-29 10:38:25 +01:00
50ae922a99
Add total line in account list on index page 2024-03-23 11:51:37 +01:00
18a58783c8
Fix form table header background (fix #15) 2024-03-23 10:56:22 +01:00
e93f6f5d2b
Fix empty accounts producing error 500 2024-03-23 10:52:28 +01:00
ee3ec21527
Update styling on invoice table 2024-01-05 09:11:38 +01:00
ad020e476b
Add icon to log in link in navbar 2024-01-05 09:00:08 +01:00
0ff191e0a4
Fix account table display on small devices 2024-01-05 08:56:16 +01:00
a6e84fbc13
Fix back link styling on year page 2024-01-04 18:45:42 +01:00
7369b36ab1
Move connected as item in navbar 2024-01-04 18:45:17 +01:00
63258147ee
Add icons to navbar 2024-01-04 18:33:56 +01:00
67e71b717b
Fix back link 2024-01-04 17:16:13 +01:00
e41b989862
Add creation links to statement and transaction list pages 2024-01-04 17:04:37 +01:00
ca7cd790b5
Update check icons in statement table 2024-01-04 16:58:47 +01:00
9d50dc7154
Separate detail and edit view for category, update statement page 2024-01-04 16:51:48 +01:00
218a6aca6f
Separate detail and edit view for account 2024-01-04 16:05:38 +01:00
6bd83feafe
Update up-down icons 2024-01-04 15:38:53 +01:00
b0716a65b7
Add icons on calendar plot 2024-01-03 19:02:08 +01:00
8db2720f75
Remove create links from navbar; Add a link to create categories in the category list on the homepage 2024-01-03 18:49:24 +01:00
954ee9ce17
Update big links styling 2024-01-03 18:35:31 +01:00
08b234a070
Add colors back to history 2024-01-03 18:29:14 +01:00
bf1d15574d
Fix previous commit 2024-01-03 16:14:54 +01:00
ef90d52807
Fix history regarding category__budget 2024-01-03 16:07:10 +01:00
e4169bd1c3
Split columns for income and expenses 2024-01-03 15:52:36 +01:00
e2f7a1dcc3
Fix history calendar 2024-01-03 15:37:48 +01:00
cc551c536c
Change date format in transaction table on month and year page 2024-01-03 15:35:06 +01:00
b3c9642adc
Update year history (remove year in plot lines) 2024-01-03 15:20:45 +01:00
b478286f47
Fix page layout 2024-01-03 15:13:54 +01:00
6d14602dd2
Fix homepage on small screens 2024-01-03 14:59:08 +01:00
0f1aba45ea
Add class value to balance in account table 2024-01-03 14:54:24 +01:00
05b7418162
Reshape home page 2024-01-03 14:51:40 +01:00
d48818e455
Create table for accounts 2024-01-03 14:40:48 +01:00
9927cbbab4
Move new and more links to tables 2024-01-03 14:25:25 +01:00
e44e3a51f0
Fix contrast issues with table icons 2024-01-02 17:03:55 +01:00
65d97f523c
Add title attribute to calendar plot for accessibility 2024-01-02 16:58:24 +01:00
6b08113f60
Add back link on month and year page 2024-01-02 16:04:04 +01:00
e5cdf19fa3
Add transaction list on year view 2024-01-02 15:42:51 +01:00
b4c7f88b3d
Add time elements for accessibility 2024-01-02 15:28:24 +01:00
87c12f47e9
Fix pagination on small devices 2024-01-02 15:15:57 +01:00
8ebb940f5b
Add missing section block in month page 2024-01-02 15:09:50 +01:00
5ccae7f4a3
Fix spacing with pagination 2024-01-02 15:07:42 +01:00
3fd87ff370
Update month page template 2024-01-02 15:03:45 +01:00
f8c0f9dced
Change date format in month pagination 2024-01-02 15:00:27 +01:00
2ba21fbd10
Refactor pagination for month/year 2024-01-02 14:56:13 +01:00
35b26f2d10
Add year link on month view 2024-01-02 14:36:36 +01:00
57279b1cda
Simplify duplicated code (archive views) 2024-01-02 14:21:58 +01:00
47c4de8fbb
Fix calendar scroll 2024-01-02 14:18:57 +01:00
1137cfc1cc
Fix top-gap in main 2024-01-02 12:21:36 +01:00
2f32c2b80f
Fix year urls on account pages 2024-01-02 12:13:57 +01:00
60f84fe20d
Add pagination to year archive view 2024-01-02 12:06:20 +01:00
8b9af8e1a4
Add links to category in year category plot 2024-01-02 11:59:44 +01:00
309281f5e1
Fix row colors in history plot 2024-01-02 11:55:54 +01:00
75df57f42a
Update year view (add link) 2024-01-02 11:52:04 +01:00
3dbc31eebc
Fix row colors in category plot 2024-01-02 11:47:58 +01:00
caa113859d
Add year archive view 2024-01-02 11:45:06 +01:00
45b47dd1ba
Move templatetags to history templatetags 2024-01-02 11:11:05 +01:00
003902b43f
Minor style updates 2024-01-02 11:00:56 +01:00
0940904cd8
Refactor History plot 2024-01-02 10:58:30 +01:00
6b50de5e35
Fix opacity calculation on chrome 2023-12-31 18:41:54 +01:00
e4e0f56be0
Use css nesting 2023-12-31 09:37:29 +01:00
ee043d3956
Fix back link in transation form (fix #12) 2023-12-31 09:03:49 +01:00
fe95222474
Fix bugs in html templates 2023-12-30 11:12:38 +01:00
47d8ff0382
Create tag for css imports 2023-12-30 10:54:01 +01:00
7f3b9c7b5c
Structure page content with sections 2023-12-30 10:38:30 +01:00
0e5b8ea85d
Use :is css pseudo-class 2023-12-30 10:24:24 +01:00
715822e48f
Remove unused spaceless tags 2023-12-30 10:18:17 +01:00
ee4e6b7ceb
Improve history plot with empty segments 2023-12-30 10:10:53 +01:00
55923e7709
Fix form fields on alternating background 2023-12-30 09:42:58 +01:00
14755df86b
Fix pagination on month archive, keep account 2023-12-30 09:40:11 +01:00
a0bc1a6608
Update pagination on montharchive to keep category 2023-12-30 09:35:05 +01:00
68ff2ae8ad
Set table borders to 1px 2023-12-30 09:17:47 +01:00
17fe61c9c0
Improve accessibility for links 2023-12-30 09:14:07 +01:00
aeb22521f3
Add alternating row colors 2023-12-30 09:12:03 +01:00
5013df903f
Add pagination to month archive view (fix #13) 2023-12-30 09:04:52 +01:00
c3ba376793
Fix month offset (missing first month in history) 2023-12-29 09:53:59 +01:00
4bb682fc88
Fix HTML for index page 2023-12-29 09:50:08 +01:00
8652eb0b57
Remove borders in calendar table 2023-12-29 09:23:51 +01:00
6f631607b8
Reduce computations in history generation 2023-12-28 16:31:13 +01:00
9dbbd3d48e
Add monthly year chart 2023-12-28 15:46:52 +01:00
4bbb5de3c5
Update translations 2023-11-25 12:07:01 +01:00
ffc0fa3ce1
Update messages styling 2023-11-25 11:56:14 +01:00
8dd29be8bf
Add success message on object creation with link to object 2023-11-25 11:28:20 +01:00
76ac6bc7fb
Use tomllib instead of toml 2023-11-25 10:44:57 +01:00
3c1e4ac45d
Fix broken negative bars when total is negative (fixes #10) 2023-05-04 22:02:33 +02:00
02bd1853d5
Fix wrong total values (#10) 2023-05-04 22:00:36 +02:00
a5dd41b533
In category_plot templatetag, replace filter budget with exclude not budget 2023-05-03 12:56:00 +02:00
eabdaf2516
Fix urls in category plot on month page 2023-05-03 12:20:19 +02:00
574d1d2875
Add total line to category plot
Closes #9
2023-05-03 12:18:20 +02:00
278d252cfd
Merge branch 'main' of ssh://git.edgarpierre.fr:39529/edpibu/nummi 2023-05-03 12:01:59 +02:00
b9e129e80a
Move category plot to templatetag, hide non-budget categories 2023-05-03 12:01:25 +02:00
fcdfd0e9ad
Update PKGBUILD to v0.9.1 2023-04-24 09:12:22 +02:00
819bd20fdf
Fix file uploads (borken by missing enctype on form) 2023-04-24 09:07:30 +02:00
3d9424a1d5
Remove LocaleMiddleware 2023-04-23 17:14:56 +02:00
3980f2230c
Add colors to logo 2023-04-23 16:54:28 +02:00
09c8da0650
Update PKGBUILD with version 2023-04-23 14:07:54 +02:00
9a666b6d22
Fix .gitignore 2023-04-23 08:10:30 +02:00
a0872b65c4
Use get_absolute_url to get model urls 2023-04-23 08:08:43 +02:00
1bad7d7291
Fix icon margin 2023-04-22 19:47:58 +02:00
b9cd7a8460
Fix icon margin 2023-04-22 19:43:31 +02:00
517dd28b2e
Fix navbar scrolling 2023-04-22 19:41:03 +02:00
b6326bdc8f
Fix category plot on month page 2023-04-22 15:57:36 +02:00
f7564eb282
Add links to category in month category plot 2023-04-22 15:52:32 +02:00
b828324220
Add category plot on history pages 2023-04-22 15:44:27 +02:00
681651109a
Fix translations 2023-04-22 15:24:12 +02:00
068dab59d8
Log LOCALE_PATHS 2023-04-22 15:11:51 +02:00
d478f038ba
Log BASE_DIR 2023-04-22 15:10:34 +02:00
5abb9a3b30
Update settings for locale 2023-04-22 15:08:21 +02:00
13b014e16e
Update translations 2023-04-22 14:46:34 +02:00
a77cfbe339
Fix urls 2023-04-22 14:33:07 +02:00
2d7957b813
Empty tables are now visible 2023-04-22 14:28:49 +02:00
a3f28631df
Create base template for lists 2023-04-22 14:06:24 +02:00
2e3d76ad19
Remove unused templates 2023-04-22 13:59:39 +02:00
210268928b
Move pagination file up 2023-04-22 13:58:30 +02:00
2d5f209c41
Move history to separate app 2023-04-22 13:53:45 +02:00
f0a232f366
Moved views to adequate apps 2023-04-22 13:47:43 +02:00
62f360e77b
Hide connected as when not logged in 2023-04-22 13:33:12 +02:00
2d45fef975
Move config.toml to pkgbuild dir 2023-04-22 13:31:12 +02:00
06c8cab4bf
Move PKGBUILD to pkgbuild dir 2023-04-22 13:27:58 +02:00
ee25223e73
Implemented frontend for transactions and invoices 2023-04-22 12:44:34 +02:00
a98b073eea
Implemented frontend for statements 2023-04-22 12:30:08 +02:00
c9d1496e00
Implemented frontend for search 2023-04-22 12:20:03 +02:00
f6e8b583cc
Implemented frontend for category 2023-04-22 12:15:48 +02:00
719436f9ad
Implemented frontend for account 2023-04-22 12:14:42 +02:00
bb4a8c5db1
Fixed migrations 2023-04-22 11:40:12 +02:00
b05c3e6760
Split backend in applications 2023-04-22 11:16:42 +02:00
a0d0b5d594
Move media handling to new app 2023-04-22 09:58:38 +02:00
ad18226974
Remove tests.py 2023-04-22 09:49:17 +02:00
82c7a03965
Move adding property to filters 2023-04-22 09:48:11 +02:00
88f4f9099b
Add user name in navbar 2023-04-22 09:42:21 +02:00
b18088f9d6
Remove useless config 2023-04-22 09:29:44 +02:00
4c75cb460d
Remove admin modification of models 2023-04-22 09:27:57 +02:00
0e1c1be189
Fix monthly view for category and account 2023-04-21 18:58:46 +02:00
17e150032e
Add monthly view 2023-04-21 17:55:46 +02:00
966100536e
Update translations 2023-04-21 15:42:18 +02:00
44e20fbbcf
Manually squashed migration 2023-04-20 18:14:10 +02:00
2972b3f741
Remove squashed migration 2023-04-20 17:36:11 +02:00
d063f35e2b
Change migration name 2023-04-20 17:34:51 +02:00
4738dffc82
Transition squashed migration to normal migration 2023-04-20 17:27:06 +02:00
f3b355d76b
Add flake8 linter 2023-04-20 17:24:01 +02:00
5a76d58894
Update title style 2023-04-20 16:59:39 +02:00
8d68a7091f
Update title and paragraph styles 2023-04-20 16:14:31 +02:00
d9e03df878
Update title style 2023-04-20 16:06:29 +02:00
ea7d8faecd
Style title on create 2023-04-20 15:58:37 +02:00
dfe89fb716
Remove last New ... 2023-04-20 15:53:55 +02:00
442b36055a
Remove None fallback in get 2023-04-20 15:51:26 +02:00
d595d73417
Disable fixed fields in forms 2023-04-20 15:50:40 +02:00
42c44c8c11
Make links full-width 2023-04-20 15:32:30 +02:00
51d57564c4
Fix no-income / no-expense snapshots 2023-04-20 15:22:04 +02:00
ae39b4870b
Add back link to create forms linked to accounts, snapshots 2023-04-20 15:15:00 +02:00
7521cc236f
Remove get_create_url, redirect createform to same page using hidden form field 2023-04-20 14:41:16 +02:00
c744c87baa
Use different urls for create pages 2023-04-20 12:22:45 +02:00
b673950e99
Fix translation error 2023-04-20 12:15:06 +02:00
d7d9a44894
Change create titles (again) 2023-04-20 12:14:25 +02:00
e87893ae0c
Capitalize links to creation form 2023-04-20 12:06:01 +02:00
45d12d0b07
Remove lowercase from creation form title 2023-04-20 12:04:14 +02:00
df19c5586d
Change save button to create on creation form 2023-04-20 12:03:30 +02:00
37bb725f23
Use form template 2023-04-20 12:01:22 +02:00
110a699f1a
Show title as New on forms 2023-04-20 11:28:06 +02:00
9263af0d40
Create view returns to create another ; remove checkbox 2023-04-20 11:11:06 +02:00
3eb40cd5ed
Remove addmore checkbox from update forms 2023-04-20 11:07:17 +02:00
e3ddf4cbaa
Right-align whole tfoot in forms 2023-04-20 10:22:01 +02:00
a1d48ca246
Remove empty value fill-ins 2023-04-20 10:21:18 +02:00
c77b88c2f7
Fix add more checkbox in login and search form 2023-04-20 10:17:40 +02:00
751ddba463
Fix empty account bug 2023-04-20 10:11:14 +02:00
4a1d7778df
Add backend for Add more checkbox 2023-04-20 10:07:53 +02:00
821d0d290a
Update translations 2023-04-20 09:40:39 +02:00
306a1e06c2
Add checkbox 'Add more' to frontend 2023-04-20 09:39:40 +02:00
3ca027afc5
Update invoice form 2023-04-20 09:39:24 +02:00
492e6e77d1
Update __str__ functions 2023-04-20 09:23:30 +02:00
4792388ccd
Move function definition to utils 2023-04-20 09:19:15 +02:00
5a0958d52c
Fill empty months in history 2023-04-19 21:43:43 +02:00
917531aa97
Add history function to view 2023-04-19 18:59:37 +02:00
5534a4352d
Add trend to history chart 2023-04-19 18:35:57 +02:00
9db123457f
Change skip link font weight 2023-04-19 17:27:12 +02:00
b98743d88b
Remove footer 2023-04-19 17:25:33 +02:00
9deba5b28f
Show file extension 2023-04-19 17:19:01 +02:00
06b51a87ff
Update PKGBUILD 2023-04-19 17:09:00 +02:00
2c95379870
Raise skip link opacity 2023-04-19 17:08:03 +02:00
f4077456af
Fix snapshot page 2023-04-19 16:58:40 +02:00
0ec3fa74b2
Set icon field length to 24 2023-04-19 15:44:05 +02:00
787427beec
Switch to remixicon 2023-04-19 15:32:01 +02:00
10ba4f118e
Use remixicon + emoji uicons in frontend 2023-04-19 14:54:32 +02:00
df0005ebe2
Set uicon length to 2 for composite emojis 2023-04-19 10:11:21 +02:00
93b31dabee
Fix uicon field type 2023-04-19 10:08:41 +02:00
629c993072
Replace icon field with uicon in backend 2023-04-19 10:03:22 +02:00
71269377b4
Add prettier pre-commit hook 2023-04-18 19:51:15 +02:00
044a7f1c06
Add djlint pre-commit hook 2023-04-18 19:01:41 +02:00
d0cfc2d729
Add pre-commit hooks 2023-04-18 18:55:23 +02:00
6ed05ad33d
Update translations 2023-04-18 18:31:48 +02:00
8a2cb5b974
Add footer 2023-04-18 17:36:36 +02:00
2b9180f25a
Enable form lateral scrolling for mobile devices 2023-04-18 17:19:17 +02:00
9b05d41eec
Do not take budget into account for history view on account page 2023-04-18 17:15:48 +02:00
a21f655f18
Improve mobile design 2023-04-18 16:57:05 +02:00
ae5e5f1104
Improve design for small screen use 2023-04-18 16:53:08 +02:00
a578c5c2a7
Fix background on file input 2023-04-18 16:16:00 +02:00
7e34c1f149
Add background to form headers 2023-04-18 16:11:27 +02:00
8b0c0d245c
Update pagination 2023-04-18 16:03:12 +02:00
b2bbbb405f
Fix confirm_delete page 2023-04-18 15:40:20 +02:00
5e9fc9c0e7
Fix padding on file input 2023-04-18 15:35:35 +02:00
5116133a52
Moved form templates 2023-04-18 15:33:25 +02:00
c210e91fff
Change tables with more items to show 2023-04-18 15:27:14 +02:00
64b5b22def
Fix file input 2023-04-18 15:14:52 +02:00
74c95c7d16
Fix snapshot list on account page 2023-04-18 14:37:51 +02:00
ab3f7f4e40
Update snapshot page 2023-04-18 14:35:32 +02:00
a14f73779b
Update translations 2023-04-18 14:27:24 +02:00
fecf8caccf
Round numbers instead of truncating 2023-04-18 14:23:24 +02:00
9a2bbcd5c3
Add history chart in accounts 2023-04-18 14:18:20 +02:00
3f92a6d2ba
Add history chart in categories 2023-04-18 14:15:09 +02:00
9fd41851bc
Sticky all nav 2023-04-18 14:06:56 +02:00
c912343442
Add skip link, add sticky navigation 2023-04-18 14:05:13 +02:00
057e0ecf74
Update design 2023-04-18 13:46:07 +02:00
58c18195a6
Update login form 2023-04-18 13:38:29 +02:00
d0447e0c8e
Update form style 2023-04-18 12:15:48 +02:00
6381263786
Updage paginate_by to 96 2023-04-18 11:27:36 +02:00
5b34e41021
Update links 2023-04-18 11:25:58 +02:00
c7f5311c02
Fix chart width 2023-04-18 11:01:49 +02:00
e298ec88f1
Fix empty invoice table 2023-04-18 10:55:35 +02:00
1b7cc543f0
Update invoice table 2023-04-18 10:48:29 +02:00
a364b38651
Update snapshot table 2023-04-18 10:35:12 +02:00
6ca8ca6493
Use html tables, update design 2023-04-18 10:20:45 +02:00
fdc9214b10
Add background to thead 2023-04-18 09:39:39 +02:00
de9b73ed3a
Add borders 2023-04-18 09:38:07 +02:00
8cf48cfdf5
Update chart on snapshot page 2023-04-18 09:31:11 +02:00
93a800bf79
Update dates 2023-04-17 18:22:16 +02:00
65df9aae18
Update translations for history 2023-04-17 18:16:50 +02:00
656449db9f
HTML/CSS only plot implemented for history on homepage 2023-04-17 17:25:11 +02:00
ee7e6e60a7
Update page titles 2023-04-09 08:58:03 +02:00
60c875c2a5
Add missing data for snapshot html plot 2023-01-03 18:36:02 +01:00
344a1558af
Update search form
Fixes #6, fixes #8
2023-01-01 11:05:54 +01:00
09fe3f6835
Update navbar layout 2023-01-01 10:39:33 +01:00
63253526fa
Update tables to hide redundant fields
Closes #7
2023-01-01 10:36:40 +01:00
695e1d4d52
Update snapshot table to hide account on account page (#7) 2023-01-01 10:27:08 +01:00
39b620135a
Update sorting 2023-01-01 10:17:06 +01:00
4c34716992
Update translations 2023-01-01 10:03:10 +01:00
d767ef1a7a
Fix invoice delete 2023-01-01 09:18:01 +01:00
01dcd561d6
Fix styling of new invoice line in invoice table 2023-01-01 09:02:25 +01:00
08227d3af3
Add default account field
Fixes #3 again
2022-12-31 19:08:09 +01:00
d9304db43d
Default fields
Fixes #3
2022-12-31 18:59:29 +01:00
748f9e5fb6
Fix migration; fix invoice saving 2022-12-31 18:44:03 +01:00
2550c52a61
Add file migration 2022-12-31 18:17:40 +01:00
06704aaa77
Limit access to files to user
No database migration is made
2022-12-31 17:28:15 +01:00
de06312a2a
Update translations 2022-12-31 12:14:28 +01:00
2a0fc642b6
Update link visibility depending on item number in table 2022-12-31 12:12:15 +01:00
65a35b31f0
Add links to create snapshot in account, transaction in snapshot 2022-12-31 11:59:33 +01:00
6b54cc7546
Hide tables on account form when empty 2022-12-31 11:39:39 +01:00
1536e7f674
Code cleanup
Use mixins for listviews
2022-12-31 11:38:39 +01:00
94671b9ac4
Add back link to list pages 2022-12-31 11:34:25 +01:00
39896129a6
Update account snapshot list 2022-12-31 11:28:39 +01:00
7f4b6a0415
Update all transaction lists 2022-12-31 11:25:46 +01:00
2a898c706e
Only show all transactions line when number is strictly higher than showed 2022-12-31 11:13:46 +01:00
7c5e804aba
Update templates with new snapshot and transaction tables
Add link on homepage to see all transactions and snapshots
2022-12-31 11:07:11 +01:00
057734afd3
Add snapshot list view, general and per account
Closes #4
2022-12-31 10:27:35 +01:00
acd83704af
Add transaction list views for snapshots and accounts (#4) 2022-12-31 10:13:15 +01:00
4e6dd25c99
Use get parameters as initial form values (#3) 2022-12-31 09:59:47 +01:00
34d5ce647c
Order form fields - closes #2 2022-12-31 09:57:34 +01:00
ce28f6d0b6
Update fields icon to slugfield 2022-12-31 09:53:30 +01:00
25e9751de2
Update translations 2022-12-31 09:51:33 +01:00
f066cf1092
Update to use lazy translations - Close #1 2022-12-31 09:49:20 +01:00
bae4fed8d6
Fix create snapshot bug 2022-12-30 18:24:49 +01:00
9ea0839ba8
Add account to statement __str__ 2022-12-30 18:05:00 +01:00
a8bd70fab1
Update PKGBUILD backup with correct config 2022-12-30 17:45:23 +01:00
411d258cd7
Fix updating of snapshot values 2022-12-30 17:37:11 +01:00
0184cba30d
Forms update
Add transactions to snapshot
Fix snapshot delete
2022-12-30 17:09:02 +01:00
ea9cd1b9b8
Updated migrations to enable transition from earlier db versions
User action is needed to cleanup everything afterwards
2022-12-30 14:10:33 +01:00
e438cfd78a
Import sorting 2022-12-29 22:18:14 +01:00
0d9ff3b771
Migration 2022-12-29 22:18:03 +01:00
fd15e60085
Validate icon slugs 2022-12-29 22:17:55 +01:00
a1c3563348
Updated translations 2022-12-29 22:12:29 +01:00
ab894867f4
Split models.py into models and forms 2022-12-29 22:08:20 +01:00
2bd2eca26e
Fix order of saving transactions
Snapshot value was not calculated correctly
2022-12-29 22:00:50 +01:00
cb253d4305
Remove useless db operation 2022-12-29 21:59:31 +01:00
c083fff07e
Update snapshots with sum db columns 2022-12-29 21:58:26 +01:00
02eb781492
Updated snapshots -- major incompatibility with previous migrations, needs manual intervention 2022-12-29 21:41:57 +01:00
b9e3ee059e
Change default icon for accounts 2022-12-29 21:06:23 +01:00
0e0b9fc927
Fix categories and accounts being visible to all users 2022-12-29 21:04:40 +01:00
e26f552aff
User settings.AUTH_USER_MODEL instead of User in models 2022-12-29 20:05:25 +01:00
7bb6b2f671
Reformat migrations 2022-12-29 20:04:55 +01:00
7356d02ada
Add account column to tables 2022-12-29 19:54:15 +01:00
f2df88d091
Add account creation and selection 2022-12-29 19:45:07 +01:00
667c6d3da1
Reformat blank lines 2022-12-29 19:23:59 +01:00
7d0ed1dd5a
Fix unique date field on snapshots 2022-12-29 19:22:38 +01:00
2ac316aaca
Add User field to models
Support for multiple users !
2022-12-29 19:20:52 +01:00
df2f2e4c37
Remove useless imports 2022-12-29 18:49:14 +01:00
963c91594a
Add local media to gitignore 2022-12-29 18:44:58 +01:00
7f6c00c0a2
Reformat settings 2022-12-29 18:44:44 +01:00
077c39adde
Fix alignment of icons on invoice list 2022-12-29 18:39:05 +01:00
49834e10e8
Add missing dependency in PKGBUILD 2022-12-29 18:21:01 +01:00
22bd94f3be
Fix permissions in PKGBUILD 2022-12-29 18:19:22 +01:00
1aaafc01e2
Use toml python package to support python3.10 2022-12-29 18:14:55 +01:00
21da8a41ec
Updated PKGBUILD and systemd service for new config 2022-12-29 17:48:02 +01:00
a06f49c2a5
Updated configuration using config.toml file instead of environment variables 2022-12-29 17:26:54 +01:00
bcbf33984b
Switched index view to class-based 2022-12-28 12:38:20 +01:00
a722165ee2
Black reformat on migration 2022-12-28 12:34:33 +01:00
0d37594f0b
Updated search view to process post via class-based view 2022-12-28 12:33:18 +01:00
a6805212fc
Updated migrations 2022-12-28 12:21:14 +01:00
ecf8962562
Updated snapshot views as class-based views 2022-12-28 12:18:20 +01:00
a43ed6c039
Updated category views as class-based views 2022-12-28 12:04:09 +01:00
033698b38a
Added views for invoices
Added class-based views for all operations
Added corresponding urls
Updated transaction page to use new views
2022-12-28 11:52:24 +01:00
d8c95fac25
Added delete view for transaction 2022-12-28 10:17:30 +01:00
a48e34281f
Removed now unused code 2022-12-28 09:56:35 +01:00
e911cdcf6b
Fix tabulation issue in template 2022-12-28 09:56:23 +01:00
60631b77b7
Update styling for textarea 2022-12-28 09:55:57 +01:00
757ce1f45f
Update views using class based views
Updated views for transaction
Invoice form temporarily removed
2022-12-28 09:54:39 +01:00
41499f1329
Update search to use listview
Enables pagination
2022-12-26 18:50:02 +01:00
c58ef42bd0
Fix translation error 2022-12-26 18:19:14 +01:00
b611085467
Update __str__ of snapshots 2022-12-26 18:11:09 +01:00
8b859651e8
Improve accessibility
Replace trash-can icon with text
2022-12-26 18:04:16 +01:00
0676b52613
Improve accessibility 2022-12-26 18:00:03 +01:00
01710a9476
Fix whitespace in navbar 2022-12-26 17:54:14 +01:00
542ddbc781
Update theme
Set background color of table header to theme color
2022-12-26 17:50:03 +01:00
47b9d6a3fb
Update categories layout on homepage 2022-12-25 16:33:41 +01:00
16dba43810
Reformat template files with djLint 2022-12-25 16:24:40 +01:00
3a31eec326
Update invoice list layout 2022-12-25 16:10:39 +01:00
93d827dea3
Update translations 2022-12-21 17:31:49 +01:00
5953d80ff2
Improve search
Search in trader
Combine ranked and trigram
2022-12-21 17:18:02 +01:00
4b4c5f827d
Add search 2022-12-21 17:05:23 +01:00
d8b00d91e8
Redirect on valid form input 2022-12-21 15:49:27 +01:00
6d0fb174eb
Organise imports using isort --profile black 2022-12-21 15:42:30 +01:00
364ad42c05
Only show budgeted categories on snapshot page 2022-12-21 15:37:57 +01:00
08b3c3dd18
Use new media server for invoices 2022-12-21 15:32:51 +01:00
ef3e3ad03e
Merge branch 'main' of ssh://git.edgarpierre.fr:39529/edpibu/nummi 2022-12-21 15:19:26 +01:00
b77c5b6064
Fix style on login page 2022-12-21 15:18:52 +01:00
98fea25615
Merge branch 'main' of ssh://git.edgarpierre.fr:39529/edpibu/nummi 2022-12-21 15:14:36 +01:00
cf23e3f79d
Try to fix permission issues with nginx 2022-12-21 15:13:21 +01:00
ee26d0d9a8
Update media file server for debugging 2022-12-21 14:56:28 +01:00
100dc9f508
Update checksums 2022-12-21 14:48:55 +01:00
4a41f998d7
Use path converter in media url 2022-12-21 14:43:01 +01:00
4a1bd56fd9
Update nginx proxy of media files 2022-12-21 14:39:56 +01:00
55021db557
Updated checksums 2022-12-21 14:28:29 +01:00
b995b9a0f5
Add missing sysusers config 2022-12-21 14:24:38 +01:00
043f5e7112
Add private media server using nginx
Use internal nginx root with X-Accel-Redirect header
2022-12-21 14:23:02 +01:00
65af7105b2
Merge pkgbuild
Add environment file, nginx config, systemd service, tmpfiles and PKGBUILD
2022-12-21 14:15:43 +01:00
ba07636a85
Add display for snapshot files on homepage 2022-12-21 14:09:38 +01:00
4efc421f04
Fix file selector styling 2022-12-21 14:05:15 +01:00
e11749f187
Add file selector to snapshots 2022-12-21 14:02:15 +01:00
996f6a9f18
Add visibility on invoices
Add links to invoices on transaction tables
Added invoices and has_invoice properties to transactions
2022-12-21 09:38:17 +01:00
7895210aea
Add default date on snapshots 2022-12-21 09:21:17 +01:00
495c64fcdc
Update layout
Modified navbar content
Moved categories up on homepage
Load all snapshots on homepage
Load 64 transactions per page
2022-12-21 09:18:47 +01:00
b090902648
Update plot style 2022-12-20 16:31:03 +01:00
d04fc756d4
Add timeline graph to category page 2022-12-20 16:20:44 +01:00
bbcaf1c1d3
Add timeline per category 2022-12-20 16:14:06 +01:00
d88ba21869
Update style for checkbox compatibility 2022-12-20 15:45:23 +01:00
60bdff43e7
Remove non-budget categories for plot 2022-12-20 15:32:34 +01:00
396ef9ead5
Add budget option to categories 2022-12-20 15:28:56 +01:00
19490e62b2
Add category plot 2022-12-19 14:36:02 +01:00
e095d8d35f
Add first plot 2022-12-19 14:18:25 +01:00
3cf3cb649e
Default to fr-fr 2022-12-19 12:09:02 +01:00
a7e3f92469
Add form errors 2022-12-19 12:03:37 +01:00
0e2fd6e74f
Update style, fix contrast issues 2022-12-19 11:57:16 +01:00
b844947cfd
Updated text and improved button contrast 2022-12-19 11:42:48 +01:00
e6e7fa328a
Updated translations 2022-12-19 11:19:22 +01:00
edf6ae0584
Update design 2022-12-18 09:58:40 +01:00
e026445ec3
Update gitignore 2022-12-18 09:49:52 +01:00
1f40f6bdc6
Fix hidden inputs 2022-08-11 08:41:50 +02:00
178f82d36a
Start translation to fr-fr 2022-05-30 19:21:40 +02:00
0131146c55
Add styling to password field 2022-05-30 15:10:28 +02:00
78907323a6
Add accesskeys to navbar 2022-05-30 15:08:54 +02:00
984dadf7e6
Update form buttons for accessibility & nojs behavior 2022-05-30 15:04:30 +02:00
f21e2a1b2e
Fix bug when snapshot only has negative or positive transactions 2022-05-30 14:55:52 +02:00
f359426997
Fix bug when adding earliest snapshot 2022-05-30 14:48:28 +02:00
7fc60d83a4
Fix categories links on index 2022-05-26 08:06:10 +02:00
a10e798c8c
Add templating for form buttons 2022-05-25 08:14:50 +02:00
7269fcbc6b
Add empty alt text to logo 2022-05-24 20:53:44 +02:00
5850aa95c3
Update form style 2022-05-24 20:52:53 +02:00
63c7ab497e
Fix add snapshot page 2022-05-24 20:46:05 +02:00
a911b324b5
Add logo 2022-05-24 20:15:38 +02:00
d7558cfa0c
Force grouping on value filter 2022-05-24 18:14:22 +02:00
d2d27caf41
Fix number filters 2022-05-24 18:10:01 +02:00
408e605c7b
Add sum on chart 2022-05-24 17:31:45 +02:00
f6dc54f87d
Update border-radius 2022-05-24 17:16:03 +02:00
5337f62621
Add border-radius to chart bars 2022-05-24 17:13:50 +02:00
73ce1a298e
Update graphics 2022-05-24 17:09:16 +02:00
d9abdf798f
Update plot 2022-05-24 15:37:58 +02:00
0437814725
Switch to horizontal bar plot 2022-05-24 15:32:00 +02:00
b1b3a5afa2
Add snapshot plot 2022-05-24 15:28:52 +02:00
14fa4b38d0
Add prefix to invoice form 2022-05-23 14:07:12 +02:00
c3c328fdd6
Show form errors: view rewriting with methods 2022-05-22 18:52:57 +02:00
11c215e4d8
Use form template 2022-05-22 18:14:11 +02:00
c5ac2d83c7
Update form theme 2022-05-22 18:07:04 +02:00
30ccc7b6eb
Update theme 2022-05-22 17:55:01 +02:00
e37e0c2183
Fix typo in base.html nav 2022-05-22 17:12:20 +02:00
7a365212f5
Add css to paginator 2022-05-22 17:09:22 +02:00
fdcdff7bcc
Add paginated transaction view 2022-05-22 17:07:57 +02:00
e4f70c67e3
Darken value column in tables 2022-05-22 14:52:25 +02:00
d96450aae2
Fix date range for snapshot transactions 2022-05-22 14:41:15 +02:00
5c01b8cd61
Update css for index categories 2022-05-22 14:17:26 +02:00
b4e218f418
Remove restrictions on category names 2022-05-22 13:57:53 +02:00
b45150d4ff
Added payment field to transaction 2022-05-22 13:52:09 +02:00
215787e204
Add real date field to transactions 2022-05-22 13:36:43 +02:00
83aefe1f84
Add delete button to transaction form 2022-05-22 13:27:57 +02:00
df0882bd88
Fix bugs and update templating 2022-05-22 12:32:26 +02:00
5d6f47b852
Fix snapshot diff update issue 2022-05-22 11:14:05 +02:00
0e60dfba27
Fix snapshot updating issues 2022-05-22 11:10:52 +02:00
02b0fae3f7
Fix bugs with snapshots: opening earliest snapshot, deleting latest 2022-05-22 10:39:29 +02:00
57ace376d8
Updat transaction list in category with table 2022-05-22 10:35:22 +02:00
f3d0d3d746
Update theme colors 2022-05-22 10:28:53 +02:00
43aabf051b
Add transactions to snapshot page 2022-05-22 10:15:42 +02:00
cb458cf472
Add snapshot form 2022-05-22 10:06:44 +02:00
4b784a553b
Update index 2022-05-22 09:27:48 +02:00
15a317dc0f
Snapshot table 2022-05-22 09:21:30 +02:00
a3797bb7fb
Add snapshot list 2022-05-22 08:37:52 +02:00
29484e3c02
Add snapshots 2022-05-21 21:00:26 +02:00
52eade1159
Update .gitignore 2022-05-21 20:23:41 +02:00
ee494e0a9d
Force + on transaction value 2022-05-21 19:17:44 +02:00
8cd1427418
Add LICENSE 2022-05-21 19:11:11 +02:00
a6afe9fef7
Move README to root 2022-05-21 19:09:42 +02:00
27d6977518
Add README 2022-05-21 19:09:09 +02:00
83203d4e20
Move static for main app to main folder 2022-05-21 19:03:32 +02:00
97f4c70116
Fix heading alignment for value 2022-05-21 19:01:33 +02:00
ccc7ef1499
Fix heading centering on index 2022-05-21 18:51:04 +02:00
1300132d69
Add environment config 2022-05-21 18:45:53 +02:00
ad537e737b
Fix CSRF trust 2022-05-21 18:09:11 +02:00
0eeb345003
Add STATIC_ROOT 2022-05-21 17:53:44 +02:00
568c607231
Set to PROD settings 2022-05-21 16:12:39 +02:00
50b21d62ab
Move system files to branch pkgbuild 2022-05-21 16:11:00 +02:00
174 changed files with 7568 additions and 56 deletions

View file

@ -0,0 +1,29 @@
name: Build and push Docker image
on:
push:
tags:
- 'v*'
jobs:
build:
runs-on: ubuntu-latest
steps:
-
name: Login to Docker Hub
uses: docker/login-action@v3
with:
registry: code.edgarpierre.fr
username: ${{ vars.DOCKER_PUSH_USERNAME }}
password: ${{ secrets.DOCKER_PUSH_PASSWORD }}
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
-
name: Build and push
uses: docker/build-push-action@v6
with:
push: true
tags: |
code.edgarpierre.fr/${{ github.repository }}:${{ github.ref_name }}
code.edgarpierre.fr/${{ github.repository }}:latest

7
.gitignore vendored
View file

@ -1,4 +1,3 @@
*.pkg.tar.zst /env*
/nummi-git/ __pycache__
/pkg/ /media
/src/

31
.pre-commit-config.yaml Normal file
View file

@ -0,0 +1,31 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: check-added-large-files
- id: no-commit-to-branch
args: ["--branch", "main"]
- repo: https://github.com/PyCQA/isort
rev: 5.12.0
hooks:
- id: isort
args: ["--profile", "black"]
- repo: https://github.com/psf/black
rev: 23.3.0
hooks:
- id: black
- repo: https://github.com/PyCQA/flake8
rev: "6.0.0"
hooks:
- id: flake8
args: ["--max-line-length=88", "--extend-ignore=E203"]
- repo: https://github.com/Riverside-Healthcare/djLint
rev: v1.23.3
hooks:
- id: djlint-django
args: ["--reformat", "--lint", "--quiet"]
- repo: https://github.com/pre-commit/mirrors-prettier
rev: "v3.0.0-alpha.6"
hooks:
- id: prettier
types_or: ["css", "javascript", "svg"]

1
.prettierrc.toml Normal file
View file

@ -0,0 +1 @@
endOfLine = "auto"

1
.python-version Normal file
View file

@ -0,0 +1 @@
3.12

22
.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,22 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Python Debugger: Django",
"type": "debugpy",
"request": "launch",
"program": "${workspaceFolder}\\nummi\\manage.py",
"args": [
"runserver"
],
"env": {
"NUMMI_CONFIG": "${workspaceFolder}\\env\\config.toml"
},
"django": true
}
]
}

17
Dockerfile Normal file
View file

@ -0,0 +1,17 @@
FROM ghcr.io/astral-sh/uv:debian-slim
ADD . /app
WORKDIR /app
RUN useradd -m -r nummi && \
chown -R nummi /app
USER nummi
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV NUMMI_CONFIG=/nummi/config.toml
RUN uv sync --locked
CMD ["/app/entrypoint.sh"]

661
LICENSE Normal file
View file

@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.

View file

@ -1,45 +0,0 @@
pkgname=nummi-git
pkgver=r80.73ce1a2
pkgrel=1
pkgdesc="Web-based accounting interface"
arch=("any")
url="https://git.edgarpierre.fr/edpibu/nummi"
license=('AGPL3')
depends=(
"gunicorn"
"python-django"
"python-matplotlib"
)
makedepends=("git")
optdepends=("postgresql: database")
backup=("etc/${pkgname%-git}.env")
source=(
"${pkgname}::git+ssh://gitea@git.edgarpierre.fr:39529/edpibu/nummi.git"
"${pkgname%-git}.service"
"${pkgname%-git}.tmpfiles"
"${pkgname%-git}.sysusers"
"${pkgname%-git}.nginx"
"${pkgname%-git}.env"
)
b2sums=('SKIP'
'9e9b62a141bb4594dc203978d5c56dd397ed6c095e6f83fdea07ded2fa46dd2e052aa8f80729a3b612e3773aa487660fcca791079ad4885b825c9c831df61b45'
'2be5a55e8582a680a17c133c28b43f0a27800acc855c90ae670486b3847b91a47216d15a029670cdc3c55c193371b060cfe3b01bf4f858a64b64da9711067188'
'3d31754c644e05ec01bc07ebfb89749d563260abb4507297aceaaeebe2f057517ccc3e1c61dbb2eb864779260cd3c7aad01939775525b6bf6461267587dea156'
'b86c46f17b5445b5631a3725ed3d4cd3bf1e864c68f8db8cecba1f8ae554e1e4872d8a86bfe6ab5172507f8315ebeccf78c9fbe3bb9eeb71c21113450e0df4d5'
'5bdc097ab3fa7e8e128d194bfb6c0fefb5528903e2b8765557a82033f8925c1174dbe73007a595e32c5e415cf6f19957c35bbe463c50d2813894c20c8a28a88d')
pkgver() {
cd "$pkgname"
printf "r%s.%s" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
}
package() {
install -Dm644 ${pkgname%-git}.service -t "${pkgdir}"/usr/lib/systemd/system/
install -Dm644 ${pkgname%-git}.tmpfiles "${pkgdir}"/usr/lib/tmpfiles.d/${pkgname%-git}.conf
install -Dm644 ${pkgname%-git}.sysusers "${pkgdir}"/usr/lib/sysusers.d/${pkgname%-git}.conf
install -Dm644 ${pkgname%-git}.nginx "${pkgdir}"/etc/nginx/sites-enabled/${pkgname%-git}.conf
install -Dm750 ${pkgname%-git}.env -t "${pkgdir}"/etc/
cd ${pkgname}/${pkgname%-git}
find * -type f -exec install -Dm0644 "{}" "${pkgdir}/usr/share/webapps/${pkgname%-git}/{}" \;
}

4
README.md Normal file
View file

@ -0,0 +1,4 @@
# Nummi
Nummi is a web-based accounting software.
Nummi uses Django.

23
compose.yaml Normal file
View file

@ -0,0 +1,23 @@
services:
nummi:
image: code.edgarpierre.fr/edpibu/nummi
container_name: nummi
restart: unless-stopped
ports:
- 33001:8000
volumes:
- /docker/nummi/config:/nummi
- /docker/nummi/static:/app/static
- /docker/nummi/media:/app/media
depends_on:
- postgres
postgres:
image: postgres:17-alpine
container_name: nummi_postgres
restart: unless-stopped
environment:
POSTGRES_USER: nummi
POSTGRES_PASSWORD:
volumes:
- /docker/nummi/postgres:/var/lib/postgresql/data

5
entrypoint.sh Executable file
View file

@ -0,0 +1,5 @@
#!/usr/bin/env sh
cd /app/nummi
uv run manage.py collectstatic --noinput
uv run manage.py migrate --noinput
uv run gunicorn --bind :8000 --workers 2 nummi.wsgi:application

View file

@ -1,4 +0,0 @@
NUMMI_MEDIA_ROOT="/var/lib/nummi"
NUMMI_SECRET="xxxxxx"
#NUMMI_DEBUG="True"
NUMMI_HOST="nummi.edgarpierre.fr"

View file

@ -1,2 +0,0 @@
d /var/lib/nummi 0750 nummi nummi
d /srv/nummi 0755 nummi nummi

View file

6
nummi/account/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class AccountConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "account"

22
nummi/account/forms.py Normal file
View file

@ -0,0 +1,22 @@
from django.forms.widgets import Select
from main.forms import IconInput, NummiForm
from .models import Account
class AccountForm(NummiForm):
class Meta:
model = Account
fields = [
"name",
"icon",
"default",
"archived",
]
widgets = {
"icon": IconInput(),
}
class AccountSelect(Select):
template_name = "account/forms/widgets/account.html"

Binary file not shown.

View file

@ -0,0 +1,75 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-01-04 18:51+0100\n"
"PO-Revision-Date: 2023-04-22 15:17+0200\n"
"Last-Translator: Edgar P. Burkhart <traduction@edgarpierre.fr>\n"
"Language-Team: \n"
"Language: fr_FR\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: Poedit 3.2.2\n"
#: .\account\models.py:12 .\account\models.py:45 .\account\models.py:53
#: .\account\templates\account\account_list.html:9
msgid "Account"
msgstr "Compte"
#: .\account\models.py:12
msgid "Name"
msgstr "Nom"
#: .\account\models.py:16
msgid "Icon"
msgstr "Icône"
#: .\account\models.py:18
msgid "Default"
msgstr "Défaut"
#: .\account\models.py:19
msgid "Archived"
msgstr "Archivé"
#: .\account\models.py:46 .\account\templates\account\account_list.html:12
msgid "Accounts"
msgstr "Comptes"
#: .\account\templates\account\account_detail.html:15
msgid "Edit account"
msgstr "Modifier le compte"
#: .\account\templates\account\account_detail.html:18
msgid "Statements"
msgstr "Relevés"
#: .\account\templates\account\account_detail.html:24
msgid "History"
msgstr "Historique"
#: .\account\templates\account\account_form.html:5
#: .\account\templates\account\account_table.html:35
msgid "Create account"
msgstr "Créer un compte"
#: .\account\templates\account\account_form.html:8
msgid "New account"
msgstr "Nouveau compte"
#: .\account\templates\account\account_table.html:16
msgid "All accounts"
msgstr "Tous les comptes"
#: .\account\templates\account\account_table.html:26
msgid "Show archived"
msgstr "Afficher archivés"
#~ msgid "Transactions"
#~ msgstr "Transactions"

View file

@ -0,0 +1,65 @@
# Generated by Django 4.1.4 on 2023-04-22 09:01
import uuid
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("main", "0002_segmentation"),
]
state_operations = [
migrations.CreateModel(
name="Account",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
(
"name",
models.CharField(
default="Account", max_length=64, verbose_name="Name"
),
),
(
"icon",
models.SlugField(
default="bank", max_length=24, verbose_name="Icon"
),
),
("default", models.BooleanField(default=False, verbose_name="Default")),
(
"user",
models.ForeignKey(
editable=False,
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
verbose_name="User",
),
),
],
options={
"verbose_name": "Account",
"verbose_name_plural": "Accounts",
"ordering": ["-default", "name"],
"db_table": "account_account",
},
),
]
operations = [
migrations.SeparateDatabaseAndState(state_operations=state_operations)
]

View file

@ -0,0 +1,16 @@
# Generated by Django 4.1.4 on 2023-04-22 09:28
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("account", "0001_initial"),
]
operations = [
migrations.AlterModelTable(
name="account",
table=None,
),
]

View file

@ -0,0 +1,17 @@
# Generated by Django 4.2 on 2024-12-29 09:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("account", "0002_alter_account_table"),
]
operations = [
migrations.AddField(
model_name="account",
name="archived",
field=models.BooleanField(default=False, verbose_name="Archived"),
),
]

View file

@ -0,0 +1,20 @@
# Generated by Django 4.2.7 on 2025-01-04 17:44
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("account", "0003_account_archived"),
]
operations = [
migrations.AlterModelOptions(
name="account",
options={
"ordering": ["-default", "archived", "name"],
"verbose_name": "Account",
"verbose_name_plural": "Accounts",
},
),
]

View file

57
nummi/account/models.py Normal file
View file

@ -0,0 +1,57 @@
from uuid import uuid4
from django.apps import apps
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from main.models import NummiModel
class Account(NummiModel):
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
name = models.CharField(max_length=64, default=_("Account"), verbose_name=_("Name"))
icon = models.SlugField(
max_length=24,
default="bank",
verbose_name=_("Icon"),
)
default = models.BooleanField(default=False, verbose_name=_("Default"))
archived = models.BooleanField(default=False, verbose_name=_("Archived"))
def save(self, *args, **kwargs):
if self.default:
for ac in Account.objects.filter(user=self.user, default=True):
ac.default = False
ac.save()
super().save(*args, **kwargs)
def __str__(self):
return str(self.name)
def get_absolute_url(self):
return reverse("account", args=(self.pk,))
def get_delete_url(self):
return reverse("del_account", args=(self.pk,))
@property
def transactions(self):
return apps.get_model("transaction", "Transaction").objects.filter(
statement__account=self
)
class Meta:
ordering = ["-default", "archived", "name"]
verbose_name = _("Account")
verbose_name_plural = _("Accounts")
class AccountModel(NummiModel):
account = models.ForeignKey(
Account,
on_delete=models.CASCADE,
verbose_name=_("Account"),
)
class Meta:
abstract = True

View file

@ -0,0 +1,27 @@
{% extends "main/base.html" %}
{% load main_extras history_extras statement_extras %}
{% load i18n %}
{% block title %}
{{ account }} {{ block.super }}
{% endblock title %}
{% block link %}
{{ block.super }}
{% css "main/css/table.css" %}
{% css "main/css/plot.css" %}
{% endblock link %}
{% block body %}
<h2>{{ account.icon|remix }}{{ account }}</h2>
<p>
<a href="{% url "edit_account" account.pk %}">{{ "edit"|remix }}{% translate "Edit account" %}</a>
</p>
<section>
<h3>{% translate "Statements" %}</h3>
{% url "new_statement" account=account.pk as ns_url %}
{% url "account_statements" account=account.pk as s_url %}
{% statement_table account.statement_set.all statements_url=s_url new_statement_url=ns_url n_max=6 %}
</section>
<section>
<h3>{% translate "History" %}</h3>
{% history_plot account.transactions account=account %}
</section>
{% endblock body %}

View file

@ -0,0 +1,10 @@
{% extends "main/form/base.html" %}
{% load main_extras %}
{% load i18n %}
{% block title_new %}
{% translate "Create account" %}
{% endblock %}
{% block h2_new %}
{% translate "New account" %}
{% endblock %}
{% block h2 %}{{ form.instance.icon|remix }}{{ form.instance }}{% endblock %}

View file

@ -0,0 +1,16 @@
{% extends "main/list.html" %}
{% load i18n main_extras account_extras %}
{% block link %}
{{ block.super }}
{% css "main/css/table.css" %}
{% css "main/css/plot.css" %}
{% endblock link %}
{% block name %}
{% translate "Account" %}
{% endblock name %}
{% block h2 %}
{% translate "Accounts" %}
{% endblock h2 %}
{% block table %}
<div class="split">{% account_table accounts %}</div>
{% endblock table %}

View file

@ -0,0 +1,39 @@
{% load i18n main_extras %}
<dl class="accounts">
{% for acc in accounts %}
<div class="account {% if not search and acc.archived %}archived{% endif %}">
<dt>
<a href="{{ acc.get_absolute_url }}">{{ acc.icon|remix }}{{ acc }}</a>
</dt>
<dd class="value">
{% if acc.statement_set.first %}{{ acc.statement_set.first.value|value }}{% endif %}
</dd>
</div>
{% endfor %}
{% if index %}
<div class="more account">
<dt>
<a href="{% url "accounts" %}">{{ "gallery-view"|remixnl }}{% translate "All accounts" %}</a>
</dt>
<dd class="value">
{{ accounts|balance|value }}
</dd>
</div>
{% elif not search %}
<div class="more account">
<dt>
<label class="wi" for="show-archived-accounts">
{{ "archive"|remix }}{% translate "Show archived" %}
</label>
</dt>
<dd>
<input type="checkbox" class="show-archived" id="show-archived-accounts" />
</dd>
</div>
<div class="new account">
<dt>
<a href="{% url "new_account" %}">{{ "add-box"|remix }}{% translate "Create account" %}</a>
</dt>
</div>
{% endif %}
</dl>

View file

@ -0,0 +1,5 @@
{% load main_extras %}
<span class="ico-input account-select">
{{ "bank"|remix }}
{% include "django/forms/widgets/select.html" %}
</span>

View file

View file

@ -0,0 +1,10 @@
from django import template
register = template.Library()
@register.inclusion_tag("account/account_table.html")
def account_table(accounts, **kwargs):
return kwargs | {
"accounts": accounts,
}

31
nummi/account/urls.py Normal file
View file

@ -0,0 +1,31 @@
from django.urls import path
from statement.views import StatementCreateView
from . import views
urlpatterns = [
path("list", views.AccountListView.as_view(), name="accounts"),
path("new", views.AccountCreateView.as_view(), name="new_account"),
path("<account>", views.AccountDetailView.as_view(), name="account"),
path("<account>/edit", views.AccountUpdateView.as_view(), name="edit_account"),
path(
"<account>/transactions",
views.AccountTListView.as_view(),
name="account_transactions",
),
path(
"<account>/statements",
views.AccountSListView.as_view(),
name="account_statements",
),
path(
"<account>/statement",
StatementCreateView.as_view(),
name="new_statement",
),
path(
"<account>/delete",
views.AccountDeleteView.as_view(),
name="del_account",
),
]

60
nummi/account/views.py Normal file
View file

@ -0,0 +1,60 @@
from django.shortcuts import get_object_or_404
from main.views import (
NummiCreateView,
NummiDeleteView,
NummiDetailView,
NummiListView,
NummiUpdateView,
)
from statement.views import StatementListView
from transaction.views import TransactionListView
from .forms import AccountForm
from .models import Account
class AccountCreateView(NummiCreateView):
model = Account
form_class = AccountForm
class AccountUpdateView(NummiUpdateView):
model = Account
form_class = AccountForm
pk_url_kwarg = "account"
class AccountDeleteView(NummiDeleteView):
model = Account
pk_url_kwarg = "account"
class AccountDetailView(NummiDetailView):
model = Account
pk_url_kwarg = "account"
context_object_name = "account"
class AccountMixin:
def get_queryset(self):
self.account = get_object_or_404(
Account.objects.filter(user=self.request.user),
pk=self.kwargs.get("account"),
)
return super().get_queryset().filter(account=self.account)
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {"account": self.account}
class AccountListView(NummiListView):
model = Account
context_object_name = "accounts"
class AccountTListView(AccountMixin, TransactionListView):
pass
class AccountSListView(AccountMixin, StatementListView):
pass

View file

6
nummi/category/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class CategoryConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "category"

21
nummi/category/forms.py Normal file
View file

@ -0,0 +1,21 @@
from django.forms.widgets import Select
from main.forms import IconInput, NummiForm
from .models import Category
class CategoryForm(NummiForm):
class Meta:
model = Category
fields = [
"name",
"icon",
"budget",
]
widgets = {
"icon": IconInput,
}
class CategorySelect(Select):
template_name = "category/forms/widgets/category.html"

Binary file not shown.

View file

@ -0,0 +1,83 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-01-04 18:51+0100\n"
"PO-Revision-Date: 2023-04-22 15:18+0200\n"
"Last-Translator: Edgar P. Burkhart <traduction@edgarpierre.fr>\n"
"Language-Team: \n"
"Language: fr_FR\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: Poedit 3.2.2\n"
#: .\category\models.py:12 .\category\models.py:32
#: .\category\templates\category\category_plot.html:13
msgid "Category"
msgstr "Catégorie"
#: .\category\models.py:12
msgid "Name"
msgstr "Nom"
#: .\category\models.py:17
msgid "Icon"
msgstr "Icône"
#: .\category\models.py:19
msgid "Budget"
msgstr "Budget"
#: .\category\models.py:33
msgid "Categories"
msgstr "Catégories"
#: .\category\templates\category\category_detail.html:14
msgid "Edit category"
msgstr "Modifier la catégorie"
#: .\category\templates\category\category_detail.html:17
msgid "Transactions"
msgstr "Transactions"
#: .\category\templates\category\category_detail.html:20
msgid "View all transactions"
msgstr "Voir toutes les transactions"
#: .\category\templates\category\category_detail.html:25
msgid "History"
msgstr "Historique"
#: .\category\templates\category\category_form.html:5
msgid "Create category"
msgstr "Créer une catégorie"
#: .\category\templates\category\category_form.html:8
msgid "New category"
msgstr "Nouvelle catégorie"
#: .\category\templates\category\category_plot.html:14
msgid "Expenses"
msgstr "Dépenses"
#: .\category\templates\category\category_plot.html:15
msgid "Income"
msgstr "Revenus"
#: .\category\templates\category\category_plot.html:58
msgid "No transaction"
msgstr "Aucune transaction"
#: .\category\templates\category\category_plot.html:66
msgid "Total"
msgstr "Total"
#: .\category\templates\category\category_plot.html:89
msgid "Expected total"
msgstr "Total attendu"

View file

@ -0,0 +1,64 @@
# Generated by Django 4.1.4 on 2023-04-22 09:01
import uuid
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
state_operations = [
migrations.CreateModel(
name="Category",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
(
"name",
models.CharField(
default="Category", max_length=64, verbose_name="Name"
),
),
(
"icon",
models.SlugField(
default="folder", max_length=24, verbose_name="Icon"
),
),
("budget", models.BooleanField(default=True, verbose_name="Budget")),
(
"user",
models.ForeignKey(
editable=False,
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
verbose_name="User",
),
),
],
options={
"verbose_name": "Category",
"verbose_name_plural": "Categories",
"ordering": ["name"],
"db_table": "category_category",
},
),
]
operations = [
migrations.SeparateDatabaseAndState(state_operations=state_operations)
]

View file

@ -0,0 +1,16 @@
# Generated by Django 4.1.4 on 2023-04-22 09:28
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("category", "0001_initial"),
]
operations = [
migrations.AlterModelTable(
name="category",
table=None,
),
]

View file

33
nummi/category/models.py Normal file
View file

@ -0,0 +1,33 @@
from uuid import uuid4
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from main.models import NummiModel
class Category(NummiModel):
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
name = models.CharField(
max_length=64, default=_("Category"), verbose_name=_("Name")
)
icon = models.SlugField(
max_length=24,
default="folder",
verbose_name=_("Icon"),
)
budget = models.BooleanField(default=True, verbose_name=_("Budget"))
def __str__(self):
return str(self.name)
def get_absolute_url(self):
return reverse("category", args=(self.pk,))
def get_delete_url(self):
return reverse("del_category", args=(self.pk,))
class Meta:
ordering = ["name"]
verbose_name = _("Category")
verbose_name_plural = _("Categories")

View file

@ -0,0 +1,28 @@
{% extends "main/base.html" %}
{% load i18n main_extras history_extras transaction_extras %}
{% block title %}
{{ category }} {{ block.super }}
{% endblock title %}
{% block link %}
{{ block.super }}
{% css "main/css/table.css" %}
{% css "main/css/plot.css" %}
{% endblock link %}
{% block body %}
<h2>{{ category.icon|remix }}{{ category }}</h2>
<p>
<a href="{% url "edit_category" category.pk %}">{{ "edit"|remix }}{% translate "Edit category" %}</a>
</p>
<section>
<h3>{% translate "Transactions" %}</h3>
{% url_get "transactions" category=category.id as t_url %}
<p>
<a class="big-link" href="{{ t_url }}">{{ "list-check"|remixnl }}{% translate "View all transactions" %}</a>
</p>
{% transaction_table category.transaction_set.all n_max=8 transactions_url=t_url %}
</section>
<section>
<h3>{% translate "History" %}</h3>
{% history_plot category.transaction_set category=category %}
</section>
{% endblock body %}

View file

@ -0,0 +1,10 @@
{% extends "main/form/base.html" %}
{% load main_extras %}
{% load i18n %}
{% block title_new %}
{% translate "Create category" %}
{% endblock %}
{% block h2_new %}
{% translate "New category" %}
{% endblock %}
{% block h2 %}{{ form.instance.icon|remix }}{{ form.instance }}{% endblock %}

View file

@ -0,0 +1,114 @@
{% load main_extras statement_extras history_extras %}
{% load i18n %}
<div class="plot">
<table class="full-width">
<colgroup>
<col class="desc">
<col class="value">
<col span="2" class="bar">
<col class="value">
</colgroup>
<thead>
<tr>
<th scope="col">{% translate "Category" %}</th>
<th scope="col" colspan="2">{% translate "Expenses" %}</th>
<th scope="col" colspan="2">{% translate "Income" %}</th>
</tr>
</thead>
<tbody>
{% spaceless %}
{% for cat in categories %}
<tr>
<th scope="row" class="l wi">
{% if cat.category %}
{% if year %}
<a href="{% history_url year=year category=cat.category account=account.id %}">{{ cat.category__icon|remix }}{{ cat.category__name }}</a>
{% elif month %}
<a href="{% url_get "transactions" start_date=month end_date=month|end_of_month account=account.id category=cat.category %}">{{ cat.category__icon|remix }}{{ cat.category__name }}</a>
{% elif statement %}
<a href="{% url_get "transactions" account=statement.account.id statement=statement.id category=cat.category %}">{{ cat.category_.icon|remix }}{{ cat.category__name }}</a>
{% elif account %}
<a href="{% url_get "transactions" account=account.id category=cat.category %}">{{ cat.category__icon|remix }}{{ cat.category__name }}</a>
{% else %}
{{ cat.category__icon|remix }}{{ cat.category__name }}
{% endif %}
{% endif %}
</th>
<td class="value">{{ cat.sum_m|pmvalue }}</td>
<td class="bar m">
{% if cat.sum_m %}
<div style="width: {% widthratio cat.sum_m max -100 %}%"></div>
{% endif %}
{% if cat.sum < 0 %}
<div class="tot" style="width:{% widthratio cat.sum max -100 %}%">
<span>{{ cat.sum|pmvalue }}</span>
</div>
{% endif %}
</td>
<td class="bar p">
{% if cat.sum_p %}
<div style="width: {% widthratio cat.sum_p max 100 %}%"></div>
{% endif %}
{% if cat.sum > 0 %}
<div class="tot" style="width:{% widthratio cat.sum max 100 %}%">
<span>{{ cat.sum|pmvalue }}</span>
</div>
{% endif %}
</td>
<td class="value">{{ cat.sum_p|pmvalue }}</td>
</tr>
{% empty %}
<tr>
<td class="empty" colspan="5">{% translate "No transaction" %}</td>
</tr>
{% endfor %}
{% endspaceless %}
</tbody>
<tfoot>
{% if categories %}
<tr>
<th scope="row" class="l">{% translate "Total" %}</th>
<td class="value">{{ total_m|pmvalue }}</td>
<td class="bar m">
<div style="width: {% widthratio total_m max -100 %}%"></div>
{% if total < 0 %}
<div class="tot" style="width:{% widthratio total max -100 %}%">
<span>{{ total|pmvalue }}</span>
</div>
{% endif %}
</td>
<td class="bar p">
<div style="width: {% widthratio total_p max 100 %}%"></div>
{% if total > 0 %}
<div class="tot" style="width:{% widthratio total max 100 %}%">
<span>{{ total|pmvalue }}</span>
</div>
{% endif %}
</td>
<td class="value">{{ total_p|pmvalue }}</td>
</tr>
{% endif %}
{% if statement and statement.diff != statement.sum %}
<tr>
<th scope="row" class="l">{% translate "Expected total" %}</th>
<td class="c">{{ total|check:statement.diff }}</td>
<td class="bar m">
{% if statement.diff < 0 %}
<div class="tot" style="width:{% widthratio statement.diff max -100 %}%">
<span>{{ statement.diff|pmvalue }}</span>
</div>
{% endif %}
</td>
<td class="bar p">
{% if statement.diff >= 0 %}
<div class="tot" style="width:{% widthratio statement.diff max 100 %}%">
<span>{{ statement.diff|pmvalue }}</span>
</div>
{% endif %}
</td>
<td></td>
</tr>
{% endif %}
</tfoot>
</table>
</div>

View file

@ -0,0 +1,5 @@
{% load main_extras %}
<span class="ico-input category-select">
{{ "folder"|remix }}
{% include "django/forms/widgets/select.html" %}
</span>

View file

View file

@ -0,0 +1,34 @@
from django import template
from django.db import models
from django.db.models.functions import Greatest
register = template.Library()
@register.inclusion_tag("category/category_plot.html", takes_context=True)
def category_plot(context, transactions, **kwargs):
kwargs.setdefault("account", context.get("account"))
if not kwargs.get("account"):
transactions = transactions.exclude(category__budget=False)
categories = (
transactions.values("category", "category__name", "category__icon")
.annotate(
sum=models.Sum("value"),
sum_m=models.Sum("value", filter=models.Q(value__lt=0)),
sum_p=models.Sum("value", filter=models.Q(value__gt=0)),
)
.order_by("-sum")
)
return (
kwargs
| {
"categories": categories,
}
| categories.aggregate(
max=Greatest(-models.Sum("sum_m"), models.Sum("sum_p")),
total_m=models.Sum("sum_m"),
total_p=models.Sum("sum_p"),
total=models.Sum("sum"),
)
)

15
nummi/category/urls.py Normal file
View file

@ -0,0 +1,15 @@
from django.urls import path
from . import views
urlpatterns = [
path("new", views.CategoryCreateView.as_view(), name="new_category"),
path("<category>", views.CategoryDetailView.as_view(), name="category"),
path("<category>/edit", views.CategoryUpdateView.as_view(), name="edit_category"),
path(
"<category>/transactions",
views.CategoryTListView.as_view(),
name="category_transactions",
),
path("<category>/delete", views.CategoryDeleteView.as_view(), name="del_category"),
]

49
nummi/category/views.py Normal file
View file

@ -0,0 +1,49 @@
from django.shortcuts import get_object_or_404
from main.views import (
NummiCreateView,
NummiDeleteView,
NummiDetailView,
NummiUpdateView,
)
from transaction.views import TransactionListView
from .forms import CategoryForm
from .models import Category
class CategoryCreateView(NummiCreateView):
model = Category
form_class = CategoryForm
class CategoryUpdateView(NummiUpdateView):
model = Category
form_class = CategoryForm
pk_url_kwarg = "category"
class CategoryDetailView(NummiDetailView):
model = Category
pk_url_kwarg = "category"
context_object_name = "category"
class CategoryDeleteView(NummiDeleteView):
model = Category
pk_url_kwarg = "category"
class CategoryMixin:
def get_queryset(self):
self.category = get_object_or_404(
Category.objects.filter(user=self.request.user),
pk=self.kwargs.get("category"),
)
return super().get_queryset().filter(category=self.category)
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {"category": self.category}
class CategoryTListView(CategoryMixin, TransactionListView):
pass

View file

6
nummi/history/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class HistoryConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "history"

Binary file not shown.

View file

@ -0,0 +1,38 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-01-04 18:51+0100\n"
"PO-Revision-Date: 2023-04-22 15:18+0200\n"
"Last-Translator: Edgar P. Burkhart <traduction@edgarpierre.fr>\n"
"Language-Team: \n"
"Language: fr_FR\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: Poedit 3.2.2\n"
#: .\history\templates\history\plot.html:10
msgid "Year"
msgstr "Année"
#: .\history\templates\history\plot.html:13
msgid "Total"
msgstr "Total"
#: .\history\templates\history\plot.html:56
msgid "Month"
msgstr "Mois"
#: .\history\templates\history\plot.html:57
msgid "Expenses"
msgstr "Dépenses"
#: .\history\templates\history\plot.html:58
msgid "Income"
msgstr "Revenus"

View file

View file

@ -0,0 +1,25 @@
{% load history_extras %}
{% if month %}
<p class="pagination">
<a href="{% history_url year=month %}">{{ month.year }}</a>
</p>
{% endif %}
<p class="pagination n3">
{% if month %}
{% if previous_month %}
<a href="{% history_url month=previous_month %}">{{ previous_month|date:"F Y"|capfirst }}</a>
{% endif %}
<a class="cur" href="{% history_url month=month %}">{{ month|date:"F Y"|capfirst }}</a>
{% if next_month %}
<a href="{% history_url month=next_month %}">{{ next_month|date:"F Y"|capfirst }}</a>
{% endif %}
{% elif year %}
{% if previous_year %}
<a href="{% history_url year=previous_year %}">{{ previous_year|date:"Y" }}</a>
{% endif %}
<a class="cur" href="{% history_url year=year %}">{{ year|date:"Y" }}</a>
{% if next_year %}
<a href="{% history_url year=next_year %}">{{ next_year|date:"Y" }}</a>
{% endif %}
{% endif %}
</p>

View file

@ -0,0 +1,92 @@
{% load main_extras %}
{% load history_extras %}
{% load transaction_extras %}
{% load i18n %}
<div class="calendar">
<table>
<thead>
<tr>
{% if not year %}
<th scope="col">{% translate "Year" %}</th>
{% endif %}
{% calendar_head %}
<th scope="col">{% translate "Total" %}</th>
</tr>
</thead>
<tbody>
{% regroup history.data by month.year as years_list %}
{% for y, y_data in years_list reversed %}
<tr>
{% if not year %}
<th class="date" scope="row">
<a href="{% history_url year=y account=account.id category=category.id %}">{{ y }}</a>
</th>
{% endif %}
{% for m in y_data %}
{% if forloop.parentloop.last and forloop.first %}
{% empty_calendar_cells_start m.month.month %}
{% endif %}
{% if m %}
<td class="month {% if m.sum > 0 %}p{% else %}m{% endif %}"
style="--opacity: {% calendar_opacity m.sum history.max.sum %}"
title="{{ m.sum|pmrvalue }}">{% up_down_icon m.sum %}</td>
{% else %}
<td class="month"></td>
{% endif %}
{% if forloop.parentloop.first and forloop.last %}
{% empty_calendar_cells_end m.month.month %}
{% endif %}
{% endfor %}
<td class="total">{{ y_data|sum_year|pmrvalue }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="history plot">
<table class="full-width">
<colgroup>
<col class="icon">
<col class="desc">
<col class="value">
<col span="2" class="bar">
<col class="value">
</colgroup>
<thead>
<tr>
<th scope="col">{{ "expand-up-down"|remix }}</th>
<th scope="col">{% translate "Month" %}</th>
<th scope="col" colspan="2">{% translate "Expenses" %}</th>
<th scope="col" colspan="2">{% translate "Income" %}</th>
</tr>
</thead>
<tbody>
{% for date in history.data reversed %}
{% ifchanged %}
{% if date.sum_m or date.sum_p %}
<tr {% if not date.month.month|divisibleby:"2" %}class="even"{% endif %}>
<td class="icon">{% up_down_icon date.sum %}</td>
<th class="date" scope="row">
<a href="{% history_url year=date.month.year month=date.month.month account=account.id category=category.id %}">
{% if year %}
{{ date.month|date:"F"|capfirst }}
{% else %}
{{ date.month|date:"Y-m" }}
{% endif %}
</a>
</th>
<td class="value">{{ date.sum_m|pmrvalue }}</td>
<td class="bar m">{% plot_bar date.sum date.sum_m history.max.pm %}</td>
<td class="bar p">{% plot_bar date.sum date.sum_p history.max.pm %}</td>
<td class="value">{{ date.sum_p|pmrvalue }}</td>
</tr>
{% else %}
<tr class="empty">
<td colspan="6" class="empty"></td>
</tr>
{% endif %}
{% endifchanged %}
{% endfor %}
</tbody>
</table>
</div>

View file

@ -0,0 +1,51 @@
{% extends "main/base.html" %}
{% load i18n static main_extras transaction_extras category history_extras %}
{% block link %}
{{ block.super }}
{% css "main/css/plot.css" %}
{% css "main/css/table.css" %}
{% endblock link %}
{% block body %}
<h2>
{% block h2 %}
{% endblock h2 %}
</h2>
{% history_pagination %}
{% if account or category %}
<p class="back">
<a class="big-link"
href="{% history_url year=year month=month clear=True %}">{{ "arrow-go-back"|remix }}{% translate "Back" %}</a>
{% if account %}
<a class="big-link" href="{% url "account" account.id %}">{{ account.icon|remix }}{{ account }}</a>
{% endif %}
{% if category %}
<a class="big-link" href="{% url "category" category.id %}">{{ category.icon|remix }}{{ category }}</a>
{% endif %}
</p>
{% endif %}
{% if history %}
<section>
<h3>{% translate "History" %}</h3>
{% include "history/plot.html" %}
</section>
{% endif %}
{% if not category %}
<section>
<h3>{% translate "Categories" %}</h3>
{% category_plot transactions month=month year=year %}
</section>
{% endif %}
<section>
<h3>{% translate "Transactions" %}</h3>
{% if month %}
{% url_get "transactions" start_date=month end_date=month|end_of_month category=category.id account=account.id as t_url %}
{% elif year %}
{% url_get "transactions" start_date=year end_date=year|end_of_year category=category.id account=account.id as t_url %}
{% endif %}
<p>
<a class="big-link" href="{{ t_url }}">{{ "list-check"|remixnl }}{% translate "View all transactions" %}</a>
</p>
{% transaction_table transactions n_max=8 transactions_url=t_url %}
</section>
{% history_pagination %}
{% endblock body %}

View file

@ -0,0 +1,8 @@
{% extends "history/transaction_archive.html" %}
{% load i18n static main_extras transaction_extras category %}
{% block title %}
{{ month|date:"F Y"|capfirst }} {{ block.super }}
{% endblock title %}
{% block h2 %}
{{ month|date:"F Y"|capfirst }}
{% endblock h2 %}

View file

@ -0,0 +1,8 @@
{% extends "history/transaction_archive.html" %}
{% load i18n static main_extras transaction_extras category %}
{% block title %}
{{ year|date:"Y" }} {{ block.super }}
{% endblock title %}
{% block h2 %}
{{ year|date:"Y" }}
{% endblock h2 %}

View file

@ -0,0 +1,119 @@
import datetime
import math
from urllib import parse
from django import template
from django.urls import reverse
from django.utils.safestring import mark_safe
from history.utils import history
from main.templatetags.main_extras import pmrvalue, remix
register = template.Library()
@register.inclusion_tag("history/plot.html", takes_context=True)
def history_plot(context, transactions, **kwargs):
kwargs.setdefault("account", context.get("account"))
kwargs.setdefault("category", context.get("category"))
if kwargs.get("category") or kwargs.get("account"):
kwargs["history"] = history(transactions.all())
else:
kwargs["history"] = history(transactions.exclude(category__budget=False))
return kwargs
@register.simple_tag
def calendar_opacity(v, vmax):
if v is None:
return "0%"
return f"{math.sin(min(1, math.fabs(v/vmax))*math.pi/2): .0%}"
@register.simple_tag
def empty_calendar_cells(n):
return mark_safe(n * "<td></td>")
@register.simple_tag
def empty_calendar_cells_start(n):
return empty_calendar_cells(n - 1)
@register.simple_tag
def empty_calendar_cells_end(n):
return empty_calendar_cells(12 - n)
@register.simple_tag
def up_down_icon(val):
if val is None:
return ""
if val > 0:
return remix("arrow-up-s", "green")
elif val < 0:
return remix("arrow-down-s", "red")
return remix("equal", "white")
@register.simple_tag
def plot_bar(s, sum_pm, s_max):
_res = ""
if s_max:
if sum_pm:
_w = abs(sum_pm / s_max)
_res += f"""<div style="width: {_w: .1%}"></div>"""
if sum_pm is not None and s * sum_pm > 0:
_w = abs(s / s_max)
_res += (
f"""<div class="tot" style="width: {_w: .1%}">"""
f"""<span>{pmrvalue(s)}</span></div>"""
)
else:
_res += "<div></div>"
return mark_safe(_res)
@register.simple_tag
def calendar_head():
months = range(1, 13)
th = (f"""<th>{month: 02d}</th>""" for month in months)
return mark_safe("".join(th))
@register.filter
def sum_year(y_data):
return sum(y["sum"] or 0 for y in y_data)
@register.inclusion_tag("history/pagination.html", takes_context=True)
def history_pagination(context):
return context
@register.simple_tag(takes_context=True)
def history_url(context, month=None, year=None, clear=False, **kwargs):
if not clear:
kwargs.setdefault("account", getattr(context.get("account"), "id", None))
kwargs.setdefault("category", getattr(context.get("category"), "id", None))
if month:
if isinstance(month, datetime.date):
year = month.year
month = month.month
url = reverse("history:month", kwargs={"year": year, "month": month})
elif year:
if isinstance(year, datetime.date):
year = year.year
url = reverse("history:year", kwargs={"year": year})
kwargs = {k: v for k, v in kwargs.items() if v}
if kwargs:
return f"{url}?{parse.urlencode(kwargs)}"
return url

13
nummi/history/urls.py Normal file
View file

@ -0,0 +1,13 @@
from django.urls import path
from . import views
app_name = "history"
urlpatterns = [
path(
"month/<int:year>/<int:month>",
views.TransactionMonthView.as_view(),
name="month",
),
path("year/<int:year>", views.TransactionYearView.as_view(), name="year"),
]

54
nummi/history/utils.py Normal file
View file

@ -0,0 +1,54 @@
import datetime
from django.db.models import Q, Sum
from django.db.models.functions import Abs, Greatest, TruncMonth
def history(transaction_set):
if not transaction_set.exists():
return None
_transaction_month = transaction_set.values(month=TruncMonth("date")).order_by(
"-date"
)
_first_month = _transaction_month.last()["month"]
_last_month = _transaction_month.first()["month"]
_history = (
_transaction_month.annotate(
sum_p=Sum("value", filter=Q(value__gt=0), default=0),
sum_m=Sum("value", filter=Q(value__lt=0), default=0),
sum=Sum("value"),
)
.annotate(max_sum=Greatest("sum_p", Abs("sum_m")))
.order_by("-month")
)
_data = [
_history.filter(month=datetime.date(y, m + 1, 1)).first()
or {"month": datetime.date(y, m + 1, 1), "sum": None}
for y in range(
_first_month.year,
_last_month.year + 1,
)
for m in range(
_first_month.month - 1 if _first_month.year == y else 0,
_last_month.month if _last_month.year == y else 12,
)
]
return {
"data": _data,
"max": {
"pm": 125
* _history.order_by("-max_sum")[len(_history.exclude(max_sum=0)) // 10][
"max_sum"
]
/ 100,
"sum": 125
* _history.annotate(abs_sum=Abs("sum")).order_by("-abs_sum")[
len(_history) // 10
]["abs_sum"]
/ 100,
},
}

54
nummi/history/views.py Normal file
View file

@ -0,0 +1,54 @@
from django.shortcuts import get_object_or_404
from django.views.generic.dates import MonthArchiveView, YearArchiveView
from history.utils import history
from main.views import UserMixin
from transaction.models import Transaction
class ACFilterMixin:
def get_queryset(self):
queryset = super().get_queryset()
if account := self.request.GET.get("account"):
queryset = queryset.filter(statement__account=account)
if category := self.request.GET.get("category"):
queryset = queryset.filter(category=category)
return queryset
def get_context_data(self, **kwargs):
context_data = super().get_context_data(**kwargs)
if account := self.request.GET.get("account"):
context_data["account"] = get_object_or_404(
self.request.user.account_set, pk=account
)
if category := self.request.GET.get("category"):
context_data["category"] = get_object_or_404(
self.request.user.category_set, pk=category
)
return context_data
class TransactionMonthView(UserMixin, ACFilterMixin, MonthArchiveView):
model = Transaction
date_field = "date"
context_object_name = "transactions"
month_format = "%m"
template_name = "history/transaction_month.html"
class TransactionYearView(UserMixin, ACFilterMixin, YearArchiveView):
model = Transaction
date_field = "date"
context_object_name = "transactions"
make_object_list = True
template_name = "history/transaction_year.html"
def get_context_data(self, **kwargs):
context_data = super().get_context_data(**kwargs)
h_data = context_data.get("transactions")
if not (context_data.get("account") or context_data.get("category")):
h_data = h_data.exclude(category__budget=False)
context_data["history"] = history(h_data)
return context_data

0
nummi/main/__init__.py Normal file
View file

5
nummi/main/apps.py Normal file
View file

@ -0,0 +1,5 @@
from django.apps import AppConfig
class MainConfig(AppConfig):
name = "main"

51
nummi/main/forms.py Normal file
View file

@ -0,0 +1,51 @@
from django import forms
from django.forms.widgets import TextInput
from .utils import get_icons
class NummiFileInput(forms.ClearableFileInput):
template_name = "main/form/fileinput.html"
class NummiForm(forms.ModelForm):
template_name = "main/form/form_base.html"
meta_fieldsets = []
def __init__(self, *args, **kwargs):
kwargs.pop("user", None)
super().__init__(*args, **kwargs)
@property
def fieldsets(self):
if self.meta_fieldsets:
for group in self.meta_fieldsets:
yield ((self[f] for f in fieldset) for fieldset in group)
else:
yield ([f] for f in self)
class DatalistInput(TextInput):
template_name = "main/forms/widgets/datalist.html"
def __init__(self, *args, options=[]):
self.options = options
super().__init__(*args)
def get_context(self, *args):
context = super().get_context(*args)
name = context["widget"]["name"]
context["widget"]["attrs"]["list"] = f"{name}-list"
context["widget"]["attrs"]["autocomplete"] = "off"
context["widget"]["options"] = self.options
return context
class IconInput(DatalistInput):
template_name = "main/forms/widgets/icon.html"
icon_list = get_icons()
def get_context(self, *args):
context = super().get_context(*args)
context["widget"]["options"] = self.icon_list
return context

Binary file not shown.

View file

@ -0,0 +1,134 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-01-04 18:51+0100\n"
"PO-Revision-Date: 2023-04-23 08:03+0200\n"
"Last-Translator: Edgar P. Burkhart <traduction@edgarpierre.fr>\n"
"Language-Team: \n"
"Language: fr_FR\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: Poedit 3.2.2\n"
#: .\main\models.py:10
msgid "User"
msgstr "Utilisateur"
#: .\main\templates\main\base.html:28
msgid "Skip to main content"
msgstr "Aller au contenu principal"
#: .\main\templates\main\base.html:35
msgid "Home"
msgstr "Accueil"
#: .\main\templates\main\base.html:42 .\main\templates\main\index.html:17
msgid "Statements"
msgstr "Relevés"
#: .\main\templates\main\base.html:49
msgid "Transactions"
msgstr "Transactions"
#: .\main\templates\main\base.html:57 .\main\templates\main\list.html:10
#: .\main\templates\main\list.html:37
msgid "Search"
msgstr "Rechercher"
#: .\main\templates\main\base.html:62
#, python-format
msgid "Logged in as <strong>%(user)s</strong>"
msgstr "Connecté en tant que <strong>%(user)s</strong>"
#: .\main\templates\main\base.html:66
msgid "Log out"
msgstr "Se déconnecter"
#: .\main\templates\main\base.html:74 .\main\templates\main\form\login.html:5
#: .\main\templates\main\login.html:11
msgid "Log in"
msgstr "Se connecter"
#: .\main\templates\main\confirm_delete.html:15
#, python-format
msgid "Are you sure you want do delete <strong>%(object)s</strong> ?"
msgstr "Êtes-vous sûr de vouloir supprimer <strong>%(object)s</strong> ?"
#: .\main\templates\main\confirm_delete.html:20
msgid "Cancel"
msgstr "Annuler"
#: .\main\templates\main\confirm_delete.html:21
msgid "Confirm"
msgstr "Confirmer"
#: .\main\templates\main\form\fileinput.html:6
msgid "File"
msgstr "Fichier"
#: .\main\templates\main\form\form_base.html:46
msgid "Create"
msgstr "Créer"
#: .\main\templates\main\form\form_base.html:48
msgid "Save"
msgstr "Enregistrer"
#: .\main\templates\main\form\form_base.html:50
msgid "Reset"
msgstr "Réinitialiser"
#: .\main\templates\main\form\form_base.html:52
msgid "Delete"
msgstr "Supprimer"
#: .\main\templates\main\index.html:13
msgid "Accounts"
msgstr "Comptes"
#: .\main\templates\main\index.html:23
msgid "Categories"
msgstr "Catégories"
#: .\main\templates\main\index.html:29
msgid "Create category"
msgstr "Créer une catégorie"
#: .\main\templates\main\index.html:34
msgid "History"
msgstr "Historique"
#: .\main\views.py:54
msgid "was created successfully"
msgstr "a été créé avec succès"
#~ msgid "Account"
#~ msgstr "Compte"
#~ msgid "Balance"
#~ msgstr "Solde"
#~ msgid "Edit"
#~ msgstr "Modifier"
#~ msgid "No account"
#~ msgstr "Aucun compte"
#~ msgid "Create account"
#~ msgstr "Créer un compte"
#~ msgid "Create statement"
#~ msgstr "Créer un relevé"
#~ msgid "Create transaction"
#~ msgstr "Créer une transaction"
#~ msgid "No category"
#~ msgstr "Aucune catégorie"

View file

@ -0,0 +1,349 @@
# Generated by Django 4.1.4 on 2023-04-20 15:32
import datetime
import uuid
import django.contrib.postgres.operations
import django.core.validators
import django.db.models.deletion
import media.utils
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
django.contrib.postgres.operations.TrigramExtension(),
django.contrib.postgres.operations.UnaccentExtension(),
migrations.CreateModel(
name="Category",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
(
"name",
models.CharField(
default="Category", max_length=64, verbose_name="Name"
),
),
("budget", models.BooleanField(default=True, verbose_name="Budget")),
(
"user",
models.ForeignKey(
editable=False,
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
verbose_name="User",
),
),
(
"icon",
models.SlugField(
default="folder", max_length=24, verbose_name="Icon"
),
),
],
options={
"ordering": ["name"],
"verbose_name": "Category",
"verbose_name_plural": "Categories",
},
),
migrations.CreateModel(
name="Account",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
(
"name",
models.CharField(
default="Account", max_length=64, verbose_name="Name"
),
),
(
"user",
models.ForeignKey(
editable=False,
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
verbose_name="User",
),
),
("default", models.BooleanField(default=False, verbose_name="Default")),
(
"icon",
models.SlugField(
default="bank", max_length=24, verbose_name="Icon"
),
),
],
options={
"verbose_name": "Account",
"verbose_name_plural": "Accounts",
"ordering": ["-default", "name"],
},
),
migrations.CreateModel(
name="Snapshot",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
(
"date",
models.DateField(
default=datetime.date.today, verbose_name="End date"
),
),
(
"value",
models.DecimalField(
decimal_places=2,
default=0,
max_digits=12,
verbose_name="End value",
),
),
(
"file",
models.FileField(
blank=True,
default="",
max_length=256,
upload_to=media.utils.get_path,
validators=[
django.core.validators.FileExtensionValidator(["pdf"])
],
verbose_name="File",
),
),
(
"user",
models.ForeignKey(
editable=False,
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
verbose_name="User",
),
),
(
"account",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="main.account",
verbose_name="Account",
),
),
(
"start_date",
models.DateField(
default=datetime.date.today, verbose_name="Start date"
),
),
(
"start_value",
models.DecimalField(
decimal_places=2,
default=0,
max_digits=12,
verbose_name="Start value",
),
),
(
"diff",
models.DecimalField(
decimal_places=2,
default=0,
editable=False,
max_digits=12,
verbose_name="Difference",
),
),
(
"sum",
models.DecimalField(
decimal_places=2,
default=0,
editable=False,
max_digits=12,
verbose_name="Transaction difference",
),
),
],
options={
"ordering": ["-date", "account"],
"verbose_name": "Statement",
"verbose_name_plural": "Statements",
},
),
migrations.CreateModel(
name="Transaction",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
(
"name",
models.CharField(
default="Transaction", max_length=256, verbose_name="Name"
),
),
(
"description",
models.TextField(blank=True, null=True, verbose_name="Description"),
),
(
"value",
models.DecimalField(
decimal_places=2, default=0, max_digits=12, verbose_name="Value"
),
),
(
"date",
models.DateField(default=datetime.date.today, verbose_name="Date"),
),
(
"trader",
models.CharField(
blank=True, max_length=128, null=True, verbose_name="Trader"
),
),
(
"category",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="main.category",
verbose_name="Category",
),
),
(
"real_date",
models.DateField(blank=True, null=True, verbose_name="Real date"),
),
(
"payment",
models.CharField(
blank=True, max_length=128, null=True, verbose_name="Payment"
),
),
(
"account",
models.ForeignKey(
editable=False,
on_delete=django.db.models.deletion.CASCADE,
to="main.account",
verbose_name="Account",
),
),
(
"snapshot",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="main.snapshot",
verbose_name="Statement",
),
),
(
"user",
models.ForeignKey(
editable=False,
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
verbose_name="User",
),
),
],
options={
"ordering": ["-date", "snapshot"],
"verbose_name": "Transaction",
"verbose_name_plural": "Transactions",
},
),
migrations.CreateModel(
name="Invoice",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
(
"name",
models.CharField(
default="Invoice", max_length=256, verbose_name="Name"
),
),
(
"file",
models.FileField(
max_length=128,
upload_to=media.utils.get_path,
validators=[
django.core.validators.FileExtensionValidator(["pdf"])
],
verbose_name="File",
),
),
(
"transaction",
models.ForeignKey(
editable=False,
on_delete=django.db.models.deletion.CASCADE,
to="main.transaction",
),
),
(
"user",
models.ForeignKey(
editable=False,
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
verbose_name="User",
),
),
],
options={
"ordering": ["transaction", "name"],
"verbose_name": "Invoice",
"verbose_name_plural": "Invoices",
},
),
]

View file

@ -0,0 +1,33 @@
# Generated by Django 4.1.4 on 2023-04-22 09:01
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("main", "0001_v1"),
]
database_operations = [
migrations.AlterModelTable("Account", "account_account"),
migrations.AlterModelTable("Category", "category_category"),
migrations.AlterModelTable("Snapshot", "statement_statement"),
migrations.AlterModelTable("Transaction", "transaction_transaction"),
migrations.AlterModelTable("Invoice", "transaction_invoice"),
]
state_operations = [
migrations.DeleteModel("Account"),
migrations.DeleteModel("Category"),
migrations.DeleteModel("Snapshot"),
migrations.DeleteModel("Transaction"),
migrations.DeleteModel("Invoice"),
]
operations = [
migrations.RenameField("Transaction", "snapshot", "statement"),
migrations.SeparateDatabaseAndState(
database_operations=database_operations,
state_operations=state_operations,
),
]

View file

46
nummi/main/models.py Normal file
View file

@ -0,0 +1,46 @@
from django.conf import settings
from django.contrib.postgres.search import (
SearchQuery,
SearchRank,
SearchVector,
TrigramSimilarity,
)
from django.db import models
from django.utils.translation import gettext_lazy as _
class NummiQuerySet(models.QuerySet):
main_field = "name"
fields = dict()
def search(self, search):
return (
self.annotate(
rank=SearchRank(
sum(
(
SearchVector(field, weight=weight)
for field, weight in self.fields.items()
),
start=SearchVector(self.main_field, weight="A"),
),
SearchQuery(search, search_type="websearch"),
),
similarity=TrigramSimilarity(self.main_field, search),
)
.filter(models.Q(rank__gte=0.1) | models.Q(similarity__gte=0.3))
.order_by("-rank")
)
class NummiModel(models.Model):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
verbose_name=_("User"),
editable=False,
)
objects = NummiQuerySet.as_manager()
class Meta:
abstract = True

View file

@ -0,0 +1,217 @@
.drop-zone {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
grid-template-columns: 1fr;
grid-template-rows: 1fr;
align-items: center;
text-align: center;
color: transparent;
display: grid;
transition-property: backdrop-filter;
transition-duration: 750ms;
z-index: -1;
> span {
font-weight: 650;
font-size: 2rem;
transition-property: color;
transition-duration: inherit;
}
main.highlight > & {
z-index: 100;
backdrop-filter: blur(0.1rem);
> span {
color: var(--green);
}
}
}
form {
display: grid;
gap: 0.5rem;
grid-template-columns: repeat(auto-fill, 32rem);
@media (width < 1024px) {
grid-template-columns: 1fr;
}
&.hidden {
display: none;
}
.column {
display: grid;
gap: 0.5rem;
border: 1px solid var(--gray);
padding: var(--gap);
block-size: min-content;
}
.fieldset {
display: grid;
grid-auto-columns: 1fr;
grid-auto-flow: column;
gap: inherit;
padding: 0;
margin: 0;
border: none;
}
ul.errorlist {
color: var(--red);
font-weight: 550;
list-style: none;
padding: 0;
margin: 0;
}
.field {
display: grid;
grid-auto-rows: min-content;
align-items: center;
column-gap: 0.5rem;
ul.errorlist {
font-size: 0.8rem;
}
&:has(> textarea) {
grid-template-rows: min-content 1fr;
textarea {
resize: block;
}
}
&:has(> input[type="checkbox"]) {
grid-template-columns: min-content 1fr;
> label {
font-size: inherit;
grid-row: 1;
grid-column: 2;
padding: 0.5rem;
line-height: initial;
}
> input {
grid-row: 1;
grid-column: 1;
margin: 0.5rem;
}
&:has(> :focus) {
background: var(--bg-1);
}
}
> label {
font-size: 0.8rem;
line-height: 0.8rem;
z-index: 10;
}
> a {
padding: 0.5rem;
}
input,
select,
textarea {
font: inherit;
line-height: initial;
border: none;
border: 1px solid transparent;
border-bottom: 1px solid var(--gray);
background: none;
z-index: 1;
padding: 0.5rem;
&:has(~ ul.errorlist) {
border-color: var(--red);
}
&.autocompleted:not(.mod) {
border-bottom-color: var(--green);
}
&:not([type="checkbox"]) {
width: 100%;
margin: 0;
}
&[name*="value"] {
text-align: right;
font-feature-settings: var(--num);
}
&[name*="date"] {
font-feature-settings: var(--num);
}
&:focus {
outline: none;
background: var(--bg-1);
}
}
> .file-input {
display: grid;
> .current {
display: grid;
grid-template-columns: 1fr;
grid-auto-columns: max-content;
grid-auto-flow: column;
a {
padding: 0.5rem;
}
}
input[type="file"] {
&::file-selector-button {
display: none;
}
}
}
> .ico-input {
display: grid;
grid-template-columns: min-content 1fr;
column-gap: 0.5rem;
align-items: center;
span[class|="ri"] {
padding: 0.5rem;
}
&:has(> :focus) {
background: var(--bg-1);
}
}
}
.buttons {
grid-column: 1 / -1;
line-height: 2rem;
input,
a {
font: inherit;
cursor: pointer;
padding: 0 var(--gap);
border: var(--gray) 1px solid;
margin-right: 0.5rem;
margin-bottom: 0.5rem;
color: inherit;
&:hover {
text-decoration: underline;
}
}
input[type="submit"] {
background: var(--green-1);
border-color: var(--green);
}
input[type="reset"] {
background: var(--bg-1);
}
a.del {
color: var(--red);
border-color: var(--red);
border-style: dashed;
}
}
}

View file

@ -0,0 +1,572 @@
@import "https://rsms.me/inter/inter.css";
@import "https://cdn.jsdelivr.net/npm/remixicon@4.5.0/fonts/remixicon.css";
*,
*::before,
*::after {
box-sizing: border-box;
}
:root {
--theme: #66cc66;
--theme-0: #338033;
--theme-1: #99ff99;
--theme-2: var(--theme);
--theme-3: #802653;
--theme-4: #cc6699;
--gray: #dedede;
--text-theme: var(--text);
--bg: #ffffff;
--text: #000000de;
--bg-inv: var(--theme-1);
--text-inv: #ffffffde;
--bg-1: #f0f0f0;
--text-green: #296629;
--text-link: var(--text-green);
--gap: 1rem;
--red: var(--theme-3);
--green: var(--theme-0);
--red-1: var(--theme-4);
--green-1: var(--theme);
--border: 0.5em;
--radius: 0.25em;
--default-ffs: "dlig", "ss01", "ss04";
--num: var(--default-ffs), "tnum", "case";
}
body {
font-family: "Inter var experimental", "Inter var", "Inter", sans-serif;
margin: 0;
background: var(--bg);
color: var(--text);
display: grid;
grid-template-columns: max-content 1fr;
font-feature-settings: var(--default-ffs);
}
p {
line-height: 2em;
}
a {
color: var(--text-link);
text-decoration: none;
display: inline-block;
&:is(:hover, :focus) {
text-decoration: underline;
}
}
.red {
color: var(--red);
fill: var(--red);
}
.green {
color: var(--green);
fill: var(--green);
}
main,
nav,
footer {
padding: 2rem 1rem;
@media (width > 720px) {
padding: 2rem;
}
background: var(--bg);
}
main {
position: relative;
grid-column: 2;
grid-row: 1;
overflow-x: hidden;
h2.new {
opacity: 0.8;
}
.split {
display: grid;
gap: var(--gap);
grid-template-columns: 100%;
@media (width > 720px) {
grid-template-columns: minmax(20rem, max-content) 1fr;
}
& > section > :first-child {
margin-top: 0;
}
}
}
nav {
grid-column: 1;
grid-row: 1;
height: 100vh;
position: sticky;
top: 0;
overflow-y: auto;
background: var(--bg-1);
line-height: 2rem;
h1 img {
height: 1cap;
}
ul {
list-style: none;
padding: 0;
margin: 0;
position: relative;
}
a {
&.skip-link {
font-weight: 300;
&:is(:active, :focus) {
font-weight: 500;
}
}
display: grid;
grid-template-columns: 1fr max-content;
align-items: baseline;
[class^="ri-"] {
height: 1.5em;
width: 1.5em;
line-height: 1.5em;
border-radius: var(--radius);
}
&.cur {
font-weight: 550;
[class^="ri-"] {
background: var(--text-link);
color: var(--bg);
}
}
}
}
:is(nav, main) > :first-child,
main > section:first-child > :first-child {
margin-top: 0;
}
footer {
text-align: center;
grid-column: 2;
font-weight: 250;
}
.pagination {
text-align: center;
font-feature-settings: var(--num);
a {
min-width: 1rem;
padding: 0 0.5rem;
&.cur {
font-weight: 650;
text-decoration: underline dotted;
&:is(:hover, :focus) {
text-decoration: underline;
}
}
}
@media (width > 720px) {
&.n3 {
display: grid;
grid-template-columns: repeat(3, 1fr);
width: max-content;
margin: 0.5rem auto;
.prev {
grid-column: 1;
}
.cur {
grid-column: 2;
}
.next {
grid-column: 3;
}
}
}
& + section :first-child {
margin-top: 0;
}
}
@media (width < 1024px) {
body {
grid-template-columns: 1fr;
}
nav,
main,
footer {
grid-column: 1;
grid-row: auto;
position: relative;
height: initial;
}
}
[class^="ri-"] {
display: inline-block;
text-align: center;
font-weight: normal;
&.green,
&.red,
&.white {
&.green {
background: var(--green);
color: var(--bg);
}
&.red {
background: var(--red);
color: var(--bg);
}
&.white {
background: var(--bg-1);
}
border-radius: var(--radius);
height: 1.5em;
width: 1.5em;
line-height: 1.5em;
}
a:not(.i) &,
.wi &,
h2 & {
&:first-child::after {
content: "\2002";
}
}
}
h1,
h2,
h3 {
font-weight: 300;
margin-top: 1em;
margin-bottom: 0.5em;
line-height: 1cap;
}
h1 {
font-size: 3rem;
}
h2 {
font-size: 2rem;
}
h3 {
font-size: 1.5rem;
}
p {
margin: 0.5em 0;
}
ul.messages {
font-weight: 550;
list-style-type: none;
margin: 0;
margin-bottom: var(--gap);
background: var(--bg-1);
padding: 0;
li {
--message-color: var(--text);
padding: calc(var(--gap) / 2) var(--gap);
border-left: var(--message-color) solid var(--border);
[class^="ri-"] {
height: 1.5em;
width: 1.5em;
line-height: 1.5em;
border-radius: var(--radius);
background: var(--message-color);
color: var(--bg);
margin-right: 0.5rem;
}
&.msg-level-20 {
--message-color: var(--green);
}
&.msg-level-25 {
--message-color: var(--green-1);
}
&.msg-level-30 {
--message-color: var(--red-1);
}
&.msg-level-40 {
--message-color: var(--red);
}
}
}
.backlinks {
display: grid;
grid-template-columns: repeat(2, 1fr);
p {
grid-column: 1;
&.back {
grid-column: 2;
text-align: right;
a {
margin-right: 0;
margin-left: 1em;
[class^="ri-"] {
margin-right: 0em;
margin-left: 0.5em;
}
}
}
}
}
dl.accounts {
margin: 0;
dt,
dd {
margin: 0;
}
.account {
padding: 0.5rem;
margin-bottom: 0.5rem;
border: 1px solid var(--gray);
display: grid;
grid-template-columns: 1fr min-content;
&.new,
&.more {
border-style: dashed;
}
&.more label {
display: block;
}
}
&:not(.show-archive) .account.archived {
display: none;
}
}
ul.statements,
ul.invoices {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
grid-auto-rows: 1fr;
gap: 0.5rem;
list-style: none;
padding: 0;
margin: 0;
li {
display: grid;
gap: 0.5rem;
padding: var(--gap);
border: var(--gray) 1px solid;
text-align: right;
align-items: center;
> * {
&.title {
font-weight: 650;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
&.new,
&.more {
border-style: dashed;
}
}
}
ul.statements {
li {
> * {
display: grid;
align-items: center;
&.date :first-child {
font-size: 2rem;
}
&.value :first-child {
font-weight: 650;
}
&.icon {
span {
margin: auto;
}
}
&.account a {
overflow: hidden;
text-overflow: ".";
white-space: nowrap;
}
}
}
}
ul.invoices {
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
}
.statement-details {
display: grid;
@media (width > 720px) {
grid-template-columns: repeat(3, min-content);
}
gap: var(--gap);
align-items: center;
> span.evolution {
display: grid;
grid-auto-rows: min-content;
> span[class^="ri-"] {
font-size: 2rem;
}
> span.value {
text-align: right;
}
}
> span.start,
> span.end,
> span.file,
> span.incons {
display: grid;
border: var(--gray) 1px solid;
padding: var(--gap);
&.file,
&.incons {
grid-column: 1 / -1;
}
&.incons {
border-color: var(--red);
grid-template-columns: min-content 1fr;
grid-auto-flow: column;
align-items: center;
gap: 0.5rem;
> .ds {
display: grid;
grid-auto-rows: min-content;
}
.value {
text-align: right;
}
.diff {
font-weight: 650;
}
.sum {
text-decoration: line-through;
}
.links {
grid-column: 1 / -1;
grid-row: 2;
display: grid;
gap: inherit;
a {
color: var(--red);
}
}
}
> .date {
text-align: right;
}
> .value {
font-size: 2rem;
}
}
}
.multilink {
display: grid;
grid-auto-columns: max-content;
}
.transaction-details {
display: grid;
grid-auto-columns: minmax(1fr, max-content);
grid-auto-rows: min-content;
max-width: 32rem;
ul {
list-style: none;
display: grid;
li.value {
font-size: 1.5rem;
text-align: right;
}
}
p.description,
ul {
border: var(--gray) 1px solid;
padding: var(--gap);
}
}
.category,
.big-link {
padding: 0 var(--gap);
border: var(--gray) 1px solid;
margin-right: 0.5rem;
margin-bottom: 0.5rem;
&.add {
border-style: dashed;
}
}
.date,
.value {
font-feature-settings: var(--num);
}
details {
border: var(--gray) 1px solid;
margin-bottom: var(--gap);
summary {
font-weight: 650;
cursor: pointer;
padding: var(--gap);
&::marker {
content: "\ed27\2002";
font-family: remixicon;
font-weight: initial;
}
}
&[open] summary {
background: var(--bg-1);
}
form {
padding: var(--gap);
}
}

View file

@ -0,0 +1,123 @@
table.full-width col.bar {
width: auto;
}
.plot {
overflow-x: auto;
table {
min-width: 40rem;
}
td.bar {
position: relative;
padding: 0;
overflow: hidden;
div {
position: absolute;
height: 0.5rem;
top: 0;
&:not(.tot) {
width: 0;
box-sizing: border-box;
z-index: 1;
display: inline-block;
}
&.tot {
z-index: 10;
height: 0.5rem;
span {
position: absolute;
display: inline-block;
white-space: nowrap;
margin: 0 var(--gap);
font-weight: 650;
top: 0.5rem;
line-height: 1.5rem;
height: 1.5rem;
font-feature-settings: var(--num);
}
}
}
&.p div {
left: 0;
border-radius: 0 var(--radius) var(--radius) 0;
background: var(--green-1);
&.tot {
background: var(--green);
span {
left: 0;
}
}
}
&.m div {
right: 0;
border-radius: var(--radius) 0 0 var(--radius);
background: var(--red-1);
&.tot {
background: var(--red);
span {
right: 0;
}
}
}
}
&.history tbody tr {
background: initial;
}
tbody tr {
&.empty {
height: 0.5rem;
}
&.even {
background: #eeeeff;
}
}
}
.calendar {
overflow-x: auto;
margin-bottom: var(--gap);
font-feature-settings: var(--num);
table {
tbody tr {
background: initial;
&:not(:last-child) {
border-bottom: none;
}
&:not(:first-child) {
border-top: none;
}
td.month {
text-align: center;
background-color: color-mix(
in hsl,
var(--td-bg, var(--bg)) var(--opacity),
var(--bg)
);
padding: 0;
width: 4rem;
height: 4rem;
&.p {
--td-bg: var(--green);
}
&.m {
--td-bg: var(--red);
}
}
td.total {
text-align: right;
font-weight: 650;
font-feature-settings: var(--num);
}
}
}
}

View file

@ -0,0 +1,59 @@
.table {
overflow-x: auto;
width: 100%;
}
table {
border-collapse: collapse;
&.full-width {
width: 100%;
col {
width: 8rem;
}
}
col.icon {
width: 1ch;
}
thead tr:not(.new) {
background: var(--bg-1);
}
tr {
border: 1px solid var(--gray);
height: 2rem;
line-height: 2rem;
tbody &:where(:nth-of-type(even)) {
background: #eeeeff;
}
&.more,
&.new {
text-align: center;
border-style: dashed;
}
}
td,
th {
padding: 0 var(--gap);
position: relative;
white-space: nowrap;
text-overflow: ellipsis;
&.empty {
text-align: center;
opacity: 0.8;
font-weight: 300;
}
}
.l {
text-align: left;
}
.r,
.value {
text-align: right;
}
.c,
.date {
text-align: center;
}
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,181 @@
const beforeUnloadHandler = (event) => {
event.preventDefault();
};
const forms = document.querySelectorAll("form");
for (let form of forms) {
let inputs = form.querySelectorAll("input");
for (input of inputs) {
input.addEventListener("input", (event) => {
window.addEventListener("beforeunload", beforeUnloadHandler);
});
}
form.addEventListener("submit", (event) => {
window.removeEventListener("beforeunload", beforeUnloadHandler);
});
form.addEventListener("reset", (event) => {
window.removeEventListener("beforeunload", beforeUnloadHandler);
});
let categorySelect = form.querySelector(".category-select");
if (categorySelect) {
let input = categorySelect.querySelector("select");
let icon = categorySelect.querySelector("span");
let icons = JSON.parse(input.dataset.icons);
function setIcon(event) {
icon.className = `ri-${icons[input.value] || "folder"}-line`;
}
setIcon();
input.addEventListener("input", setIcon);
form.addEventListener("reset", (event) => {
setTimeout(setIcon, 0);
});
}
let accountSelect = form.querySelector(".account-select");
if (accountSelect) {
let input = accountSelect.querySelector("select");
let icon = accountSelect.querySelector("span");
let icons = JSON.parse(input.dataset.icons);
function setIcon(event) {
icon.className = `ri-${icons[input.value] || "bank"}-line`;
}
setIcon();
input.addEventListener("input", setIcon);
form.addEventListener("reset", (event) => {
setTimeout(setIcon, 0);
});
}
let iconSelect = form.querySelector(".icon-select");
if (iconSelect) {
let input = iconSelect.querySelector("input");
let icon = iconSelect.querySelector("span");
let icons = Array.from(iconSelect.querySelector("datalist").options).map(
(opt) => opt.value,
);
function setIcon(event) {
icon.className = `ri-${
icons.includes(input.value) ? input.value : "square"
}-line`;
}
setIcon();
input.addEventListener("input", setIcon);
form.addEventListener("reset", (event) => {
setTimeout(setIcon, 0);
});
}
let ac_json = form.querySelector("#autocomplete");
if (ac_json) {
let autocomplete_data = JSON.parse(ac_json.textContent);
let fields = form.querySelectorAll("[name]");
function clearMod(event) {
event.target.classList.add("mod");
event.target.removeEventListener("input", clearMod);
}
function setMod() {
for (let f of fields) {
f.addEventListener("input", clearMod);
}
}
setMod();
form.addEventListener("reset", (event) => {
for (let f of fields) {
f.classList.remove("mod", "autocompleted");
}
setMod();
});
let old_values = [];
let field = form.querySelector(`[name="${autocomplete_data.field}"]`);
field.addEventListener("change", (event) => {
if (field.value in autocomplete_data.data) {
old_values = [];
for (let comp of autocomplete_data.data[field.value]) {
let f = form.querySelector(`[name="${comp.field}"]`);
if (!f.classList.contains("mod")) {
old_values.push({ field: f, value: f.value });
f.value = comp.value;
f.dispatchEvent(new Event("input"));
f.animate([{ borderColor: "var(--green)" }, {}], 750);
f.classList.remove("mod");
f.classList.add("autocompleted");
f.addEventListener("input", clearMod);
function ccAutocomplete(event) {
document.removeEventListener("keyup", cancelAutocomplete);
for (comp of old_values) {
comp.field.removeEventListener("input", ccAutocomplete);
}
}
f.addEventListener("input", ccAutocomplete);
}
}
function cancelAutocomplete(event) {
if (event.code == "Escape") {
for (comp of old_values) {
let f = comp.field;
f.value = comp.value;
f.dispatchEvent(new Event("input"));
f.animate([{ borderColor: "var(--red)" }, {}], 750);
f.classList.remove("mod");
f.classList.remove("autocompleted");
f.addEventListener("input", clearMod);
}
document.removeEventListener("keyup", cancelAutocomplete);
}
}
document.addEventListener("keyup", cancelAutocomplete);
form.addEventListener("reset", (event) => {
document.removeEventListener("keyup", cancelAutocomplete);
});
}
});
}
}
let accounts = document.querySelector("dl.accounts");
if (accounts) {
let toggle = accounts.querySelector("input#show-archived-accounts");
if (toggle) {
accounts.classList.toggle("show-archive", toggle.checked);
toggle.addEventListener("change", (event) => {
accounts.classList.toggle("show-archive", toggle.checked);
});
}
}
const filterForm = document.querySelector("form.filter");
if (filterForm) {
const accountSelect = filterForm.querySelector("[name='account']");
const statementSelect = filterForm.querySelector("[name='statement']");
if (!statementSelect.disabled) {
accountSelect.addEventListener("input", (event) => {
statementSelect.value = "";
statementSelect.disabled = true;
});
filterForm.addEventListener("reset", (event) => {
statementSelect.disabled = false;
});
}
let disableStatement = false;
filterForm.addEventListener("submit", (event) => {
for (element of filterForm.elements) {
if (
element.value == "" ||
(disableStatement && element.name == "statement")
) {
if (element.name == "account") {
disableStatement = true;
}
element.disabled = true;
}
}
});
}

View file

@ -0,0 +1,26 @@
<svg version="1.1"
width="100"
height="100"
xmlns="http://www.w3.org/2000/svg">
<defs>
<rect id="basemask" x="5" y="-5" width="90" height="110" />
<rect id="line" x="45" y="-10" width="10" height="120" />
<linearGradient id="grad" gradientTransform="rotate(90)">
<stop offset="5%" stop-color="#66cc66" />
<stop offset="95%" stop-color="#cc6699" />
</linearGradient>
</defs>
<mask id="mask">
<use href="#basemask" fill="white" />
<use href="#line" fill="black" />
<use href="#line" transform="translate(20)" fill="black" />
</mask>
<circle cx="50" cy="50" r="50"
fill="url('#grad')"
mask="url('#mask')"
transform-origin="center center" transform="rotate(10)" />
</svg>

After

Width:  |  Height:  |  Size: 707 B

View file

@ -0,0 +1,96 @@
{% load static %}
{% load i18n %}
{% load main_extras %}
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>
{% block title %}Nummi{% endblock %}
</title>
{% block link %}
<link rel="icon" href="{% static "main/svg/logo.svg" %}" type="image/svg+xml">
{% css "main/css/main.css" %}
{% js "main/js/base.js" %}
{% endblock %}
</head>
<body>
{% block nav %}
{% spaceless %}
<nav>
<h1>
<img src="{% static "main/svg/logo.svg" %}" alt="">
Nummi
</h1>
<ul>
<li>
<a class="skip-link" href="#main">{% translate "Skip to main content" %}</a>
</li>
{% if user.is_authenticated %}
<li>
<a href="{% url "index" %}"
class="home{% if request.resolver_match.url_name == "index" %} cur{% endif %}"
accesskey="h">
<span>{% translate "Home" %}</span>
{{ "home"|remix }}
</a>
</li>
<li>
<a href="{% url "statements" %}"
class="{% if request.resolver_match.url_name == "statements" %}cur{% endif %}">
<span>{% translate "Statements" %}</span>
{{ "file"|remix }}
</a>
</li>
<li>
<a href="{% url "transactions" %}"
class="{% if request.resolver_match.url_name == "transactions" %}cur{% endif %}">
<span>{% translate "Transactions" %}</span>
{{ "receipt"|remix }}
</a>
</li>
<li>
<a href="{% url "search" %}"
class="{% if request.resolver_match.url_name == "search" %}cur{% endif %}"
accesskey="r">
<span>{% translate "Search" %}</span>
{{ "search"|remix }}
</a>
</li>
<li>
{% blocktranslate %}Logged in as <strong>{{ user }}</strong>{% endblocktranslate %}
</li>
<li>
<a href="{% url "logout" %}" accesskey="l">
<span>{% translate "Log out" %}</span>
{{ "close-circle"|remix }}
</a>
</li>
{% else %}
<li>
<a {% if request.resolver_match.url_name == "login" %}class="cur"{% endif %}
href="{% url "login" %}">
<span>{% translate "Log in" %}</span>
{{ "user"|remix }}
</a>
</li>
{% endif %}
</ul>
</nav>
{% endspaceless %}
{% endblock %}
<main id="main">
{% if messages %}
<ul class="messages">
{% for message in messages %}
<li class="msg-level-{{ message.level }}">
{{ message.level|messageicon }}{{ message }}
</li>
{% endfor %}
</ul>
{% endif %}
{% block body %}{% endblock %}
</main>
</body>
</html>

View file

@ -0,0 +1,25 @@
{% extends "main/base.html" %}
{% load static %}
{% load main_extras %}
{% load i18n %}
{% block link %}
{{ block.super }}
{% css "main/css/form.css" %}
{% css "main/css/table.css" %}
{% endblock %}
{% block body %}
{% spaceless %}
<form method="post">
{% csrf_token %}
<p>
{% blocktranslate %}Are you sure you want do delete <strong>{{ object }}</strong> ?{% endblocktranslate %}
</p>
{% block additionalinfo %}{% endblock %}
{{ form }}
<div class="buttons">
<a href="{{ object.get_absolute_url }}">{% translate "Cancel" %}</a>
<input class="del" type="submit" value="{% translate "Confirm" %}" />
</div>
</form>
{% endspaceless %}
{% endblock %}

View file

@ -0,0 +1,40 @@
{% extends "main/base.html" %}
{% load static %}
{% load main_extras %}
{% load i18n %}
{% block title %}
{% if form.instance|adding %}
{% block title_new %}{% endblock %}
{% else %}
{{ form.instance }}
{% endif %}
Nummi
{% endblock %}
{% block link %}
{{ block.super }}
{% css "main/css/form.css" %}
{% css "main/css/table.css" %}
{% css "main/css/plot.css" %}
{% endblock %}
{% block body %}
{% with instance=form.instance %}
{% if instance|adding %}
<h2 class="new">
{% block h2_new %}{% endblock %}
</h2>
{% else %}
<h2>
{% block h2 %}{{ instance }}{% endblock %}
</h2>
{% endif %}
{% block pre %}{% endblock %}
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{% if instance|adding %}
<input hidden name="next" value="{{ request.path }}">
{% endif %}
{{ form }}
</form>
{% block tables %}{% endblock %}
{% endwith %}
{% endblock %}

View file

@ -0,0 +1,21 @@
{% load i18n %}
{% load main_extras %}
<div class="file-input">
{% if widget.is_initial %}
<div class="current">
<a href="{{ widget.value.url }}" target="_blank">{{ "file"|remix }}{% translate "File" %} [{{ widget.value|extension }}]</a>
{% if not widget.required %}
<span class="field checkbox">
<label for="{{ widget.checkbox_id }}">{{ widget.clear_checkbox_label }}</label>
<input type="checkbox"
name="{{ widget.checkbox_name }}"
id="{{ widget.checkbox_id }}"
{% if widget.attrs.disabled %}disabled{% endif %}>
</span>
{% endif %}
</div>
{% endif %}
<input type="{{ widget.type }}"
name="{{ widget.name }}"
{% include "django/forms/widgets/attrs.html" %}>
</div>

View file

@ -0,0 +1,56 @@
{% load i18n %}
{% load main_extras %}
{% block fields %}
{% if form.autocomplete %}{{ form.autocomplete|json_script:"autocomplete" }}{% endif %}
{% if form.non_field_errors %}
<ul class="errorlist">
{% for error in form.non_field_errors %}<li>{{ error }}</li>{% endfor %}
</ul>
{% endif %}
{% if form.fieldsets %}
{% for group in form.fieldsets %}
<div class="column">
{% for fieldset in group %}
<div class="fieldset">
{% for field in fieldset %}
<div class="field">
<label for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
{% if field.errors %}
<ul class="errorlist">
{% for error in field.errors %}
<li class="wi">{{ "error-warning"|remix }}{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% endfor %}
</div>
{% endfor %}
</div>
{% endfor %}
{% else %}
<div class="column">
{% for field in form %}
{% if field.errors %}<p class="error">{{ field.errors }}</p>{% endif %}
<div class="field">
<label for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
</div>
{% endfor %}
</div>
{% endif %}
<div class="buttons">
{% block buttons %}
{% if form.instance|adding %}
<input type="submit" value="{% translate "Create" %}">
{% else %}
<input type="submit" value="{% translate "Save" %}">
{% endif %}
<input type="reset" value="{% translate "Reset" %}">
{% if not form.instance|adding %}
<a class="del" href="{{ form.instance.get_delete_url }}">{% translate "Delete" %}</a>
{% endif %}
{% endblock buttons %}
</div>
{% endblock fields %}

View file

@ -0,0 +1,7 @@
{% extends "main/form/form_base.html" %}
{% load i18n %}
{% block buttons %}
<input hidden value="{{ next }}" name="next">
<input type="submit" value="{% translate "Log in" %}">
<input type="reset">
{% endblock buttons %}

View file

@ -0,0 +1,4 @@
{% include "django/forms/widgets/text.html" %}
<datalist id="{{ widget.attrs.list }}">
{% for option in widget.options %}<option value="{{ option }}"></option>{% endfor %}
</datalist>

View file

@ -0,0 +1,5 @@
{% load main_extras %}
<span class="ico-input icon-select">
{{ "square"|remix }}
{% include "main/forms/widgets/datalist.html" %}
</span>

View file

@ -0,0 +1,37 @@
{% extends "main/base.html" %}
{% load static %}
{% load main_extras account_extras history_extras statement_extras %}
{% load i18n %}
{% block link %}
{{ block.super }}
{% css "main/css/table.css" %}
{% css "main/css/plot.css" %}
{% endblock link %}
{% block body %}
<div class="split">
<section>
<h2>{% translate "Accounts" %}</h2>
{% account_table user.account_set.all index=True %}
</section>
<section>
<h2>{% translate "Statements" %}</h2>
{% url "statements" as s_url %}
{% statement_table statements statements_url=s_url %}
</section>
</div>
<section>
<h2>{% translate "Categories" %}</h2>
{% spaceless %}
<p>
{% for cat in user.category_set.all %}
<a class="category" href="{{ cat.get_absolute_url }}">{{ cat.icon|remix }}{{ cat }}</a>
{% endfor %}
<a class="category add" href="{% url "new_category" %}">{{ "add"|remix }}{% translate "Create category" %}</a>
</p>
{% endspaceless %}
</section>
<section>
<h2>{% translate "History" %}</h2>
{% history_plot user.transaction_set.all %}
</section>
{% endblock body %}

View file

@ -0,0 +1,47 @@
{% extends "main/base.html" %}
{% load static %}
{% load main_extras %}
{% load i18n %}
{% block title %}
{% block name %}{% endblock %}
{% if account %} {{ account }}{% endif %}
{% if category %} {{ category }}{% endif %}
{% if search %}
{% translate "Search" %}
{% endif %}
Nummi
{% endblock %}
{% block link %}
{{ block.super }}
{% css "main/css/table.css" %}
{% endblock %}
{% block body %}
<h2>
{% block h2 %}{% endblock %}
</h2>
{% if account or category or search %}
<div class="backlinks">
{% block backlinks %}
{% if account %}
<p>
<a class="big-link" href="{{ account.get_absolute_url }}">{{ account.icon|remix }}{{ account }}</a>
</p>
{% endif %}
{% if category %}
<p>
<a class="big-link" href="{{ category.get_absolute_url }}">{{ category.icon|remix }}{{ category }}</a>
</p>
{% endif %}
{% if search %}
<p>
<a href="{% url "search" %}">{% translate "Search" %}</a>
</p>
{% endif %}
{% endblock %}
</div>
{% endif %}
{% include "main/pagination.html" %}
{% block table %}{% endblock %}
{% include "main/pagination.html" %}
{% block body_more %}{% endblock %}
{% endblock %}

View file

@ -0,0 +1,16 @@
{% extends "main/base.html" %}
{% load main_extras %}
{% load static %}
{% load i18n %}
{% block link %}
{{ block.super }}
{% css "main/css/table.css" %}
{% css "main/css/form.css" %}
{% endblock %}
{% block body %}
<h2>{% translate "Log in" %}</h2>
<form action="{% url 'login' %}" method="post">
{% csrf_token %}
{% include "main/form/login.html" %}
</form>
{% endblock %}

View file

@ -0,0 +1,4 @@
{% load i18n main_extras transaction_extras %}
{% if page_obj %}
<p class="pagination">{% pagination_links page_obj %}</p>
{% endif %}

View file

@ -0,0 +1,13 @@
{% load i18n main_extras %}
{% if first.show %}
<a href="?{% page_url 1 %}" class="first">1</a>
{% if first.dots %}<span></span>{% endif %}
{% endif %}
{% for page in pages %}
<a href="?{% page_url page.number %}"
{% if page.current %}class="cur"{% endif %}>{{ page.number }}</a>
{% endfor %}
{% if last.show %}
{% if last.dots %}<span></span>{% endif %}
<a href="?{% page_url last.number %}" class="last">{{ last.number }}</a>
{% endif %}

View file

View file

@ -0,0 +1,140 @@
from urllib import parse
from dateutil.relativedelta import relativedelta
from django import template
from django.templatetags.static import static
from django.urls import reverse
from django.utils import formats
from django.utils.safestring import mark_safe
register = template.Library()
@register.filter
def value(val, pm=False, r=2):
if val is None:
return ""
_prefix = ""
_suffix = "&nbsp;€"
_val = formats.number_format(val, decimal_pos=r, use_l10n=True, force_grouping=True)
if val > 0:
if pm:
_prefix += "&plus;&nbsp;"
elif val < 0:
_val = _val[1:]
_prefix += "&minus;&nbsp;"
return mark_safe(f"{_prefix}{_val}{_suffix}")
@register.filter
def pmvalue(val):
return value(val, True)
@register.filter
def pmrvalue(val):
return value(val, True, r=0)
@register.filter
def remix(icon, *args):
return remixnl(f"{icon}-line", *args)
@register.filter
def remixnl(icon, cls=""):
return mark_safe(f"""<span class="ri-{icon} {cls}"></span>""")
@register.filter
def messageicon(level):
ico = {
10: "bug",
20: "information",
25: "check",
30: "alert",
40: "error-warning",
}
return remix(ico.get(level, "question"))
@register.filter
def extension(file):
return file.name.split(".", 1)[1].upper()
@register.filter
def verbose_name(obj):
return obj._meta.verbose_name
@register.filter
def adding(obj):
return obj._state.adding
@register.simple_tag
def css(href):
return mark_safe(
f"""<link rel="stylesheet" href="{static(href)}" type="text/css">"""
)
@register.simple_tag
def js(href):
return mark_safe(f"""<script src="{static(href)}" defer></script>""")
@register.filter
def balance(accounts):
return sum(
statement.value for acc in accounts if (statement := acc.statement_set.first())
)
@register.inclusion_tag("main/pagination_links.html", takes_context=True)
def pagination_links(context, page_obj):
_n = 3
return {
"request": context["request"],
"pages": [
{"number": p, "current": p == page_obj.number}
for p in page_obj.paginator.page_range
if abs(p - page_obj.number) < _n
],
"first": {
"show": page_obj.number > _n,
"dots": page_obj.number > _n + 1,
},
"last": {
"show": page_obj.number <= page_obj.paginator.num_pages - _n,
"dots": page_obj.number < page_obj.paginator.num_pages - _n,
"number": page_obj.paginator.num_pages,
},
}
@register.simple_tag(takes_context=True)
def page_url(context, page):
query = context["request"].GET.copy()
query["page"] = page
return query.urlencode()
@register.simple_tag
def url_get(name, **kwargs):
kwargs = {k: v for k, v in kwargs.items() if v}
return f"{reverse(name)}?{parse.urlencode(kwargs)}"
@register.filter
def end_of_month(month):
return month + relativedelta(months=1, days=-1)
@register.filter
def end_of_year(year):
return year + relativedelta(years=1, days=-1)

15
nummi/main/urls.py Normal file
View file

@ -0,0 +1,15 @@
from django.urls import include, path
from . import views
urlpatterns = [
path("", views.IndexView.as_view(), name="index"),
path("login", views.LoginView.as_view(), name="login"),
path("logout", views.LogoutView.as_view(), name="logout"),
path("account/", include("account.urls")),
path("category/", include("category.urls")),
path("statement/", include("statement.urls")),
path("transaction/", include("transaction.urls")),
path("history/", include("history.urls")),
path("search/", include("search.urls")),
]

18
nummi/main/utils.py Normal file
View file

@ -0,0 +1,18 @@
import json
import pathlib
from django.contrib.staticfiles import finders
def get_icons():
with open(finders.find("main/icons/remixicon.glyph.json"), "r") as f:
data = json.load(f)
return [i.removesuffix("-line") for i in data.keys() if i.endswith("-line")]
def pdf_outline_to_str(outline):
return " ".join(
(
dest.title if not isinstance(dest, list) else pdf_outline_to_str(dest)
for dest in outline
)
)

89
nummi/main/views.py Normal file
View file

@ -0,0 +1,89 @@
from django.contrib import messages
from django.contrib.auth import views as auth_views
from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse_lazy
from django.utils.html import format_html
from django.utils.translation import gettext as _
from django.views.generic import (
CreateView,
DeleteView,
DetailView,
ListView,
TemplateView,
UpdateView,
)
class IndexView(LoginRequiredMixin, TemplateView):
template_name = "main/index.html"
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
"statements": (
self.request.user.statement_set.exclude(account__archived=True)
.order_by("account__id", "-date")
.distinct("account__id")
)
}
class UserMixin(LoginRequiredMixin):
def get_queryset(self, **kwargs):
return super().get_queryset().filter(user=self.request.user)
def get_form_kwargs(self):
return super().get_form_kwargs() | {
"user": self.request.user,
}
class NummiCreateView(UserMixin, CreateView):
def form_valid(self, form):
form.instance.user = self.request.user
self.next = form.data.get("next")
return super().form_valid(form)
def get_success_url(self):
surl = super().get_success_url()
messages.success(
self.request,
format_html(
"<a href='{surl}'>{name}</a> {msg}",
surl=surl,
name=self.object,
msg=_("was created successfully"),
),
)
return self.next or surl
class NummiUpdateView(UserMixin, UpdateView):
pass
class NummiDetailView(UserMixin, DetailView):
pass
class NummiDeleteView(UserMixin, DeleteView):
template_name = "main/confirm_delete.html"
success_url = reverse_lazy("index")
def get_form_kwargs(self):
_res = super().get_form_kwargs()
_res.pop("user")
return _res
class LoginView(auth_views.LoginView):
template_name = "main/login.html"
next_page = "index"
class LogoutView(auth_views.LogoutView):
next_page = "login"
class NummiListView(UserMixin, ListView):
pass

22
nummi/manage.py Executable file
View file

@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "nummi.settings")
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()

Some files were not shown because too many files have changed in this diff Show more