Compare commits
	
		
			123 Commits
		
	
	
		
			v0.0.0
			...
			e24ed61b99
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| e24ed61b99 | |||
| 354f1ff3d8 | |||
| d8e0e30c41 | |||
| d58859bcf3 | |||
| 40e64c4d2e | |||
| 2aacb67988 | |||
| a839c5a41a | |||
| 356d10eb6e | |||
| 8dc340dbf1 | |||
| 4b5b348270 | |||
| d9585f0e53 | |||
| 5737d6cef4 | |||
| 1d61fa93d3 | |||
| b1c7bc61c4 | |||
| 708a434b5d | |||
| 8e524674a3 | |||
| 699db20308 | |||
| c3cedf714b | |||
| c67ed4471c | |||
| 2d3b9f68b8 | |||
| f82278b48a | |||
| 85480804e7 | |||
| 9e85c14431 | |||
| 31dc8fab04 | |||
| dc24af1db0 | |||
| 59795635ee | |||
| 399afe56c8 | |||
| 16e2a146db | |||
| f7ce94902f | |||
| 5cf3cb1e11 | |||
| a78057a8c3 | |||
| 0491614ae4 | |||
| fb9ff1d7ff | |||
| be10984cbb | |||
| 7b2089bdfb | |||
| be8dc21c5a | |||
| 2f8c6f6981 | |||
| cdd010427b | |||
| d78b941674 | |||
| 570c84c196 | |||
| 7873e16cc3 | |||
| 52351c52bc | |||
| 591fb4a7ab | |||
| 2a6c5de6d6 | |||
| 6b94cfb908 | |||
| eb90e83c98 | |||
| 6bf18be455 | |||
| 895bca2508 | |||
| 6af29e7df7 | |||
| 50f8f06687 | |||
| cd5b1b97fd | |||
| b7dd53d2f9 | |||
| b07b0e3be4 | |||
| e7fb2288ce | |||
| 17ba7659b6 | |||
| 2c8d5e7c8a | |||
| e2f707f696 | |||
| b5c0d0b7b3 | |||
| 7fe2bb6135 | |||
| 4d870f1dcc | |||
| 16b2eb1c93 | |||
| fd63149066 | |||
| a7a432914d | |||
| 1a44f08b90 | |||
| 3e68cfe690 | |||
| 809f2b6df3 | |||
| c286aa8b8b | |||
| 1326d9538c | |||
| b9cecf343a | |||
| 3d9e6c10da | |||
| 5090e59bb1 | |||
| 62697fb782 | |||
| 8c462e7b2c | |||
| 90a8229db9 | |||
| 8be44ccf5f | |||
| 511328a0bd | |||
| 0d8cf85ec0 | |||
| 6e212f0e33 | |||
| 2fbe137243 | |||
| f4e2c21ece | |||
| fff07a2552 | |||
| 975b00bce9 | |||
| d648538fbb | |||
| dde9c38bb8 | |||
| fecf33baa8 | |||
| cea2a44226 | |||
| b5d87d2387 | |||
| 784e7bde49 | |||
| 60280f415d | |||
| f32d268494 | |||
| 1c1be87f3e | |||
| 589da0c1c6 | |||
| 8363ce6602 | |||
| 6a83f95c9f | |||
| 7dc754174c | |||
| 5238168b2d | |||
| eeb05b8616 | |||
| 9920377266 | |||
| 9f9c40c30e | |||
| d368c5e062 | |||
| 4aed2f6ba7 | |||
| 6876fdf75e | |||
| d9624c7be6 | |||
| 8364025668 | |||
| dd3690dd6a | |||
| 3312c835fd | |||
| fce9d04896 | |||
| c68786f78a | |||
| 581e803707 | |||
| 15007ada4f | |||
| e50d6267d5 | |||
| 2359842e80 | |||
| 9497fa371e | |||
| b0ef4fb059 | |||
| 9f63db174c | |||
| 9b22331a5a | |||
| cb0dea58f1 | |||
| 241ad337c8 | |||
| 2964f206a6 | |||
| 9118b631e4 | |||
| ce6c8508df | |||
| e29b99b0a7 | |||
| e9f6b769f4 | 
							
								
								
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -37,4 +37,4 @@ excludes
 | 
				
			|||||||
*.pot
 | 
					*.pot
 | 
				
			||||||
*.mo
 | 
					*.mo
 | 
				
			||||||
zh_Hans
 | 
					zh_Hans
 | 
				
			||||||
node_modules
 | 
					test_temp.py
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -22,7 +22,7 @@ include docs/source/*
 | 
				
			|||||||
include docs/source/_static/*
 | 
					include docs/source/_static/*
 | 
				
			||||||
include docs/source/_templates/*
 | 
					include docs/source/_templates/*
 | 
				
			||||||
include tests/*
 | 
					include tests/*
 | 
				
			||||||
include tests/testsite/*
 | 
					include tests/test_site/*
 | 
				
			||||||
include tests/testsite/templates/*
 | 
					include tests/test_site/templates/*
 | 
				
			||||||
include tests/testsite/translations/*
 | 
					include tests/test_site/translations/*
 | 
				
			||||||
include tests/testsite/translations/*/LC_MESSAGES/*
 | 
					include tests/test_site/translations/*/LC_MESSAGES/*
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										53
									
								
								docs/source/accounting.account.rst
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								docs/source/accounting.account.rst
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,53 @@
 | 
				
			|||||||
 | 
					accounting.account package
 | 
				
			||||||
 | 
					==========================
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Submodules
 | 
				
			||||||
 | 
					----------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					accounting.account.commands module
 | 
				
			||||||
 | 
					----------------------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. automodule:: accounting.account.commands
 | 
				
			||||||
 | 
					   :members:
 | 
				
			||||||
 | 
					   :undoc-members:
 | 
				
			||||||
 | 
					   :show-inheritance:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					accounting.account.converters module
 | 
				
			||||||
 | 
					------------------------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. automodule:: accounting.account.converters
 | 
				
			||||||
 | 
					   :members:
 | 
				
			||||||
 | 
					   :undoc-members:
 | 
				
			||||||
 | 
					   :show-inheritance:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					accounting.account.forms module
 | 
				
			||||||
 | 
					-------------------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. automodule:: accounting.account.forms
 | 
				
			||||||
 | 
					   :members:
 | 
				
			||||||
 | 
					   :undoc-members:
 | 
				
			||||||
 | 
					   :show-inheritance:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					accounting.account.query module
 | 
				
			||||||
 | 
					-------------------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. automodule:: accounting.account.query
 | 
				
			||||||
 | 
					   :members:
 | 
				
			||||||
 | 
					   :undoc-members:
 | 
				
			||||||
 | 
					   :show-inheritance:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					accounting.account.views module
 | 
				
			||||||
 | 
					-------------------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. automodule:: accounting.account.views
 | 
				
			||||||
 | 
					   :members:
 | 
				
			||||||
 | 
					   :undoc-members:
 | 
				
			||||||
 | 
					   :show-inheritance:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Module contents
 | 
				
			||||||
 | 
					---------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. automodule:: accounting.account
 | 
				
			||||||
 | 
					   :members:
 | 
				
			||||||
 | 
					   :undoc-members:
 | 
				
			||||||
 | 
					   :show-inheritance:
 | 
				
			||||||
@@ -12,18 +12,10 @@ accounting.base\_account.commands module
 | 
				
			|||||||
   :undoc-members:
 | 
					   :undoc-members:
 | 
				
			||||||
   :show-inheritance:
 | 
					   :show-inheritance:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
accounting.base\_account.database module
 | 
					accounting.base\_account.converters module
 | 
				
			||||||
----------------------------------------
 | 
					------------------------------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.. automodule:: accounting.base_account.database
 | 
					.. automodule:: accounting.base_account.converters
 | 
				
			||||||
   :members:
 | 
					 | 
				
			||||||
   :undoc-members:
 | 
					 | 
				
			||||||
   :show-inheritance:
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
accounting.base\_account.models module
 | 
					 | 
				
			||||||
--------------------------------------
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.. automodule:: accounting.base_account.models
 | 
					 | 
				
			||||||
   :members:
 | 
					   :members:
 | 
				
			||||||
   :undoc-members:
 | 
					   :undoc-members:
 | 
				
			||||||
   :show-inheritance:
 | 
					   :show-inheritance:
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										53
									
								
								docs/source/accounting.currency.rst
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								docs/source/accounting.currency.rst
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,53 @@
 | 
				
			|||||||
 | 
					accounting.currency package
 | 
				
			||||||
 | 
					===========================
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Submodules
 | 
				
			||||||
 | 
					----------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					accounting.currency.commands module
 | 
				
			||||||
 | 
					-----------------------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. automodule:: accounting.currency.commands
 | 
				
			||||||
 | 
					   :members:
 | 
				
			||||||
 | 
					   :undoc-members:
 | 
				
			||||||
 | 
					   :show-inheritance:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					accounting.currency.converters module
 | 
				
			||||||
 | 
					-------------------------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. automodule:: accounting.currency.converters
 | 
				
			||||||
 | 
					   :members:
 | 
				
			||||||
 | 
					   :undoc-members:
 | 
				
			||||||
 | 
					   :show-inheritance:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					accounting.currency.forms module
 | 
				
			||||||
 | 
					--------------------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. automodule:: accounting.currency.forms
 | 
				
			||||||
 | 
					   :members:
 | 
				
			||||||
 | 
					   :undoc-members:
 | 
				
			||||||
 | 
					   :show-inheritance:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					accounting.currency.query module
 | 
				
			||||||
 | 
					--------------------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. automodule:: accounting.currency.query
 | 
				
			||||||
 | 
					   :members:
 | 
				
			||||||
 | 
					   :undoc-members:
 | 
				
			||||||
 | 
					   :show-inheritance:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					accounting.currency.views module
 | 
				
			||||||
 | 
					--------------------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. automodule:: accounting.currency.views
 | 
				
			||||||
 | 
					   :members:
 | 
				
			||||||
 | 
					   :undoc-members:
 | 
				
			||||||
 | 
					   :show-inheritance:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Module contents
 | 
				
			||||||
 | 
					---------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. automodule:: accounting.currency
 | 
				
			||||||
 | 
					   :members:
 | 
				
			||||||
 | 
					   :undoc-members:
 | 
				
			||||||
 | 
					   :show-inheritance:
 | 
				
			||||||
@@ -7,12 +7,22 @@ Subpackages
 | 
				
			|||||||
.. toctree::
 | 
					.. toctree::
 | 
				
			||||||
   :maxdepth: 4
 | 
					   :maxdepth: 4
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					   accounting.account
 | 
				
			||||||
   accounting.base_account
 | 
					   accounting.base_account
 | 
				
			||||||
 | 
					   accounting.currency
 | 
				
			||||||
   accounting.utils
 | 
					   accounting.utils
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Submodules
 | 
					Submodules
 | 
				
			||||||
----------
 | 
					----------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					accounting.database module
 | 
				
			||||||
 | 
					--------------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. automodule:: accounting.database
 | 
				
			||||||
 | 
					   :members:
 | 
				
			||||||
 | 
					   :undoc-members:
 | 
				
			||||||
 | 
					   :show-inheritance:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
accounting.locale module
 | 
					accounting.locale module
 | 
				
			||||||
------------------------
 | 
					------------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -21,6 +31,14 @@ accounting.locale module
 | 
				
			|||||||
   :undoc-members:
 | 
					   :undoc-members:
 | 
				
			||||||
   :show-inheritance:
 | 
					   :show-inheritance:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					accounting.models module
 | 
				
			||||||
 | 
					------------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. automodule:: accounting.models
 | 
				
			||||||
 | 
					   :members:
 | 
				
			||||||
 | 
					   :undoc-members:
 | 
				
			||||||
 | 
					   :show-inheritance:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Module contents
 | 
					Module contents
 | 
				
			||||||
---------------
 | 
					---------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -4,6 +4,14 @@ accounting.utils package
 | 
				
			|||||||
Submodules
 | 
					Submodules
 | 
				
			||||||
----------
 | 
					----------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					accounting.utils.next\_url module
 | 
				
			||||||
 | 
					---------------------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. automodule:: accounting.utils.next_url
 | 
				
			||||||
 | 
					   :members:
 | 
				
			||||||
 | 
					   :undoc-members:
 | 
				
			||||||
 | 
					   :show-inheritance:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
accounting.utils.pagination module
 | 
					accounting.utils.pagination module
 | 
				
			||||||
----------------------------------
 | 
					----------------------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -28,6 +36,30 @@ accounting.utils.query module
 | 
				
			|||||||
   :undoc-members:
 | 
					   :undoc-members:
 | 
				
			||||||
   :show-inheritance:
 | 
					   :show-inheritance:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					accounting.utils.random\_id module
 | 
				
			||||||
 | 
					----------------------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. automodule:: accounting.utils.random_id
 | 
				
			||||||
 | 
					   :members:
 | 
				
			||||||
 | 
					   :undoc-members:
 | 
				
			||||||
 | 
					   :show-inheritance:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					accounting.utils.strip\_text module
 | 
				
			||||||
 | 
					-----------------------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. automodule:: accounting.utils.strip_text
 | 
				
			||||||
 | 
					   :members:
 | 
				
			||||||
 | 
					   :undoc-members:
 | 
				
			||||||
 | 
					   :show-inheritance:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					accounting.utils.user module
 | 
				
			||||||
 | 
					----------------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. automodule:: accounting.utils.user
 | 
				
			||||||
 | 
					   :members:
 | 
				
			||||||
 | 
					   :undoc-members:
 | 
				
			||||||
 | 
					   :show-inheritance:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Module contents
 | 
					Module contents
 | 
				
			||||||
---------------
 | 
					---------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -17,7 +17,7 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
[metadata]
 | 
					[metadata]
 | 
				
			||||||
name = mia-accounting-flask
 | 
					name = mia-accounting-flask
 | 
				
			||||||
version = 0.0.0
 | 
					version = 0.2.0
 | 
				
			||||||
author = imacat
 | 
					author = imacat
 | 
				
			||||||
author_email = imacat@mail.imacat.idv.tw
 | 
					author_email = imacat@mail.imacat.idv.tw
 | 
				
			||||||
description = The Mia! Accounting Flask project.
 | 
					description = The Mia! Accounting Flask project.
 | 
				
			||||||
@@ -36,7 +36,7 @@ classifiers =
 | 
				
			|||||||
[options]
 | 
					[options]
 | 
				
			||||||
package_dir =
 | 
					package_dir =
 | 
				
			||||||
    = src
 | 
					    = src
 | 
				
			||||||
python_requires = >=3.10
 | 
					python_requires = >=3.11
 | 
				
			||||||
install_requires =
 | 
					install_requires =
 | 
				
			||||||
  flask
 | 
					  flask
 | 
				
			||||||
  Flask-SQLAlchemy
 | 
					  Flask-SQLAlchemy
 | 
				
			||||||
@@ -50,7 +50,7 @@ tests_require =
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
[options.package_data]
 | 
					[options.package_data]
 | 
				
			||||||
accounting =
 | 
					accounting =
 | 
				
			||||||
 | 
					  static/**
 | 
				
			||||||
  templates/**
 | 
					  templates/**
 | 
				
			||||||
  translations/*/LC_MESSAGES/*.mo
 | 
					  translations/*/LC_MESSAGES/*.mo
 | 
				
			||||||
accounting.base_account =
 | 
					  data/**
 | 
				
			||||||
  templates/**
 | 
					 | 
				
			||||||
 
 | 
				
			|||||||
@@ -18,16 +18,24 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
"""
 | 
					"""
 | 
				
			||||||
import typing as t
 | 
					import typing as t
 | 
				
			||||||
 | 
					from pathlib import Path
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from flask import Flask, Blueprint
 | 
					from flask import Flask, Blueprint
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from accounting.utils.user import AbstractUserUtils
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def init_app(app: Flask, url_prefix: str = "/accounting",
 | 
					data_dir: Path = Path(__file__).parent / "data"
 | 
				
			||||||
 | 
					"""The data directory."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def init_app(app: Flask, user_utils: AbstractUserUtils,
 | 
				
			||||||
 | 
					             url_prefix: str = "/accounting",
 | 
				
			||||||
             can_view_func: t.Callable[[], bool] | None = None,
 | 
					             can_view_func: t.Callable[[], bool] | None = None,
 | 
				
			||||||
             can_edit_func: t.Callable[[], bool] | None = None) -> None:
 | 
					             can_edit_func: t.Callable[[], bool] | None = None) -> None:
 | 
				
			||||||
    """Initialize the application.
 | 
					    """Initialize the application.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    :param app: The Flask application.
 | 
					    :param app: The Flask application.
 | 
				
			||||||
 | 
					    :param user_utils: The user utilities.
 | 
				
			||||||
    :param url_prefix: The URL prefix of the accounting application.
 | 
					    :param url_prefix: The URL prefix of the accounting application.
 | 
				
			||||||
    :param can_view_func: A callback that returns whether the current user can
 | 
					    :param can_view_func: A callback that returns whether the current user can
 | 
				
			||||||
        view the accounting data.
 | 
					        view the accounting data.
 | 
				
			||||||
@@ -39,6 +47,8 @@ def init_app(app: Flask, url_prefix: str = "/accounting",
 | 
				
			|||||||
    # in the application.
 | 
					    # in the application.
 | 
				
			||||||
    from .database import set_db
 | 
					    from .database import set_db
 | 
				
			||||||
    set_db(app.extensions["sqlalchemy"])
 | 
					    set_db(app.extensions["sqlalchemy"])
 | 
				
			||||||
 | 
					    from .utils.user import init_user_utils
 | 
				
			||||||
 | 
					    init_user_utils(user_utils)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    bp: Blueprint = Blueprint("accounting", __name__,
 | 
					    bp: Blueprint = Blueprint("accounting", __name__,
 | 
				
			||||||
                              url_prefix=url_prefix,
 | 
					                              url_prefix=url_prefix,
 | 
				
			||||||
@@ -49,9 +59,18 @@ def init_app(app: Flask, url_prefix: str = "/accounting",
 | 
				
			|||||||
    locale.init_app(app, bp)
 | 
					    locale.init_app(app, bp)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    from .utils import permission
 | 
					    from .utils import permission
 | 
				
			||||||
    permission.init_app(app, can_view_func, can_edit_func)
 | 
					    permission.init_app(bp, can_view_func, can_edit_func)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    from . import base_account
 | 
					    from . import base_account
 | 
				
			||||||
    base_account.init_app(app, bp)
 | 
					    base_account.init_app(app, bp)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    from . import account
 | 
				
			||||||
 | 
					    account.init_app(app, bp)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    from . import currency
 | 
				
			||||||
 | 
					    currency.init_app(app, bp)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    from .utils import next_url
 | 
				
			||||||
 | 
					    next_url.init_app(bp)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    app.register_blueprint(bp)
 | 
					    app.register_blueprint(bp)
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										37
									
								
								src/accounting/account/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								src/accounting/account/__init__.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,37 @@
 | 
				
			|||||||
 | 
					# The Mia! Accounting Flask Project.
 | 
				
			||||||
 | 
					# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#  Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					#  you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					#  You may obtain a copy of the License at
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#      http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					#  distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					#  See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					#  limitations under the License.
 | 
				
			||||||
 | 
					"""The account management.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					from flask import Flask, Blueprint
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def init_app(app: Flask, bp: Blueprint) -> None:
 | 
				
			||||||
 | 
					    """Initialize the application.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :param app: The Flask application.
 | 
				
			||||||
 | 
					    :param bp: The blueprint of the accounting application.
 | 
				
			||||||
 | 
					    :return: None.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    from .converters import AccountConverter
 | 
				
			||||||
 | 
					    app.url_map.converters["account"] = AccountConverter
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    from .views import bp as account_bp
 | 
				
			||||||
 | 
					    bp.register_blueprint(account_bp, url_prefix="/accounts")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    from .commands import init_accounts_command
 | 
				
			||||||
 | 
					    app.cli.add_command(init_accounts_command)
 | 
				
			||||||
							
								
								
									
										127
									
								
								src/accounting/account/commands.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										127
									
								
								src/accounting/account/commands.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,127 @@
 | 
				
			|||||||
 | 
					# The Mia! Accounting Flask Project.
 | 
				
			||||||
 | 
					# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#  Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					#  you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					#  You may obtain a copy of the License at
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#      http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					#  distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					#  See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					#  limitations under the License.
 | 
				
			||||||
 | 
					"""The console commands for the account management.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					import os
 | 
				
			||||||
 | 
					import re
 | 
				
			||||||
 | 
					from secrets import randbelow
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import click
 | 
				
			||||||
 | 
					from flask.cli import with_appcontext
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from accounting.database import db
 | 
				
			||||||
 | 
					from accounting.models import BaseAccount, Account, AccountL10n
 | 
				
			||||||
 | 
					from accounting.utils.user import has_user, get_user_pk
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					AccountData = tuple[int, str, int, str, str, str, bool]
 | 
				
			||||||
 | 
					"""The format of the account data, as a list of (ID, base account code, number,
 | 
				
			||||||
 | 
					English, Traditional Chinese, Simplified Chinese, is-offset-needed) tuples."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def __validate_username(ctx: click.core.Context, param: click.core.Option,
 | 
				
			||||||
 | 
					                        value: str) -> str:
 | 
				
			||||||
 | 
					    """Validates the username for the click console command.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :param ctx: The console command context.
 | 
				
			||||||
 | 
					    :param param: The console command option.
 | 
				
			||||||
 | 
					    :param value: The username.
 | 
				
			||||||
 | 
					    :raise click.BadParameter: When validation fails.
 | 
				
			||||||
 | 
					    :return: The username.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    value = value.strip()
 | 
				
			||||||
 | 
					    if value == "":
 | 
				
			||||||
 | 
					        raise click.BadParameter("Username empty.")
 | 
				
			||||||
 | 
					    if not has_user(value):
 | 
				
			||||||
 | 
					        raise click.BadParameter(f"User {value} does not exist.")
 | 
				
			||||||
 | 
					    return value
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@click.command("accounting-init-accounts")
 | 
				
			||||||
 | 
					@click.option("-u", "--username", metavar="USERNAME", prompt=True,
 | 
				
			||||||
 | 
					              help="The username.", callback=__validate_username,
 | 
				
			||||||
 | 
					              default=lambda: os.getlogin())
 | 
				
			||||||
 | 
					@with_appcontext
 | 
				
			||||||
 | 
					def init_accounts_command(username: str) -> None:
 | 
				
			||||||
 | 
					    """Initializes the accounts."""
 | 
				
			||||||
 | 
					    creator_pk: int = get_user_pk(username)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    bases: list[BaseAccount] = BaseAccount.query\
 | 
				
			||||||
 | 
					        .filter(db.func.length(BaseAccount.code) == 4)\
 | 
				
			||||||
 | 
					        .order_by(BaseAccount.code).all()
 | 
				
			||||||
 | 
					    if len(bases) == 0:
 | 
				
			||||||
 | 
					        click.echo("Please initialize the base accounts with "
 | 
				
			||||||
 | 
					                   "\"flask accounting-init-base\" first.")
 | 
				
			||||||
 | 
					        raise click.Abort
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    existing: list[Account] = Account.query.all()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    existing_base_code: set[str] = {x.base_code for x in existing}
 | 
				
			||||||
 | 
					    bases_to_add: list[BaseAccount] = [x for x in bases
 | 
				
			||||||
 | 
					                                       if x.code not in existing_base_code]
 | 
				
			||||||
 | 
					    if len(bases_to_add) == 0:
 | 
				
			||||||
 | 
					        click.echo("No more account to import.")
 | 
				
			||||||
 | 
					        return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    existing_id: set[int] = {x.id for x in existing}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_new_id() -> int:
 | 
				
			||||||
 | 
					        """Returns a new random account ID.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The newly-generated random account ID.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        while True:
 | 
				
			||||||
 | 
					            new_id: int = 100000000 + randbelow(900000000)
 | 
				
			||||||
 | 
					            if new_id not in existing_id:
 | 
				
			||||||
 | 
					                existing_id.add(new_id)
 | 
				
			||||||
 | 
					                return new_id
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    data: list[AccountData] = []
 | 
				
			||||||
 | 
					    for base in bases_to_add:
 | 
				
			||||||
 | 
					        l10n: dict[str, str] = {x.locale: x.title for x in base.l10n}
 | 
				
			||||||
 | 
					        is_offset_needed: bool = True if re.match("^[12]1[34]", base.code) \
 | 
				
			||||||
 | 
					            else False
 | 
				
			||||||
 | 
					        data.append((get_new_id(), base.code, 1, base.title_l10n,
 | 
				
			||||||
 | 
					                     l10n["zh_Hant"], l10n["zh_Hans"], is_offset_needed))
 | 
				
			||||||
 | 
					    __add_accounting_accounts(data, creator_pk)
 | 
				
			||||||
 | 
					    click.echo(F"{len(data)} added.  Accounting accounts initialized.")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def __add_accounting_accounts(data: list[AccountData], creator_pk: int)\
 | 
				
			||||||
 | 
					        -> None:
 | 
				
			||||||
 | 
					    """Adds the accounts.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :param data: A list of (base code, number, title) tuples.
 | 
				
			||||||
 | 
					    :param creator_pk: The primary key of the creator.
 | 
				
			||||||
 | 
					    :return: None.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    accounts: list[Account] = [Account(id=x[0],
 | 
				
			||||||
 | 
					                                       base_code=x[1],
 | 
				
			||||||
 | 
					                                       no=x[2],
 | 
				
			||||||
 | 
					                                       title_l10n=x[3],
 | 
				
			||||||
 | 
					                                       is_offset_needed=x[6],
 | 
				
			||||||
 | 
					                                       created_by_id=creator_pk,
 | 
				
			||||||
 | 
					                                       updated_by_id=creator_pk)
 | 
				
			||||||
 | 
					                               for x in data]
 | 
				
			||||||
 | 
					    l10n: list[AccountL10n] = [AccountL10n(account_id=x[0],
 | 
				
			||||||
 | 
					                                           locale=y[0],
 | 
				
			||||||
 | 
					                                           title=y[1])
 | 
				
			||||||
 | 
					                               for x in data
 | 
				
			||||||
 | 
					                               for y in (("zh_Hant", x[4]), ("zh_Hans", x[5]))]
 | 
				
			||||||
 | 
					    db.session.bulk_save_objects(accounts)
 | 
				
			||||||
 | 
					    db.session.bulk_save_objects(l10n)
 | 
				
			||||||
 | 
					    db.session.commit()
 | 
				
			||||||
							
								
								
									
										47
									
								
								src/accounting/account/converters.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								src/accounting/account/converters.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,47 @@
 | 
				
			|||||||
 | 
					# The Mia! Accounting Flask Project.
 | 
				
			||||||
 | 
					# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/31
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#  Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					#  you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					#  You may obtain a copy of the License at
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#      http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					#  distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					#  See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					#  limitations under the License.
 | 
				
			||||||
 | 
					"""The path converters for the account management.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					from flask import abort
 | 
				
			||||||
 | 
					from werkzeug.routing import BaseConverter
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from accounting.models import Account
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class AccountConverter(BaseConverter):
 | 
				
			||||||
 | 
					    """The account converter to convert the account code and to the
 | 
				
			||||||
 | 
					    corresponding account in the routes."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def to_python(self, value: str) -> Account:
 | 
				
			||||||
 | 
					        """Converts an account code to an account.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param value: The account code.
 | 
				
			||||||
 | 
					        :return: The corresponding account.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        account: Account | None = Account.find_by_code(value)
 | 
				
			||||||
 | 
					        if account is None:
 | 
				
			||||||
 | 
					            abort(404)
 | 
				
			||||||
 | 
					        return account
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def to_url(self, value: Account) -> str:
 | 
				
			||||||
 | 
					        """Converts an account to its code.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param value: The account.
 | 
				
			||||||
 | 
					        :return: The code.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return value.code
 | 
				
			||||||
							
								
								
									
										188
									
								
								src/accounting/account/forms.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										188
									
								
								src/accounting/account/forms.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,188 @@
 | 
				
			|||||||
 | 
					# The Mia! Accounting Flask Project.
 | 
				
			||||||
 | 
					# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#  Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					#  you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					#  You may obtain a copy of the License at
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#      http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					#  distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					#  See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					#  limitations under the License.
 | 
				
			||||||
 | 
					"""The forms for the account management.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					import sqlalchemy as sa
 | 
				
			||||||
 | 
					from flask import request
 | 
				
			||||||
 | 
					from flask_wtf import FlaskForm
 | 
				
			||||||
 | 
					from wtforms import StringField, BooleanField
 | 
				
			||||||
 | 
					from wtforms.validators import DataRequired, ValidationError
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from accounting.database import db
 | 
				
			||||||
 | 
					from accounting.locale import lazy_gettext
 | 
				
			||||||
 | 
					from accounting.models import BaseAccount, Account
 | 
				
			||||||
 | 
					from accounting.utils.random_id import new_id
 | 
				
			||||||
 | 
					from accounting.utils.strip_text import strip_text
 | 
				
			||||||
 | 
					from accounting.utils.user import get_current_user_pk
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class BaseAccountExists:
 | 
				
			||||||
 | 
					    """The validator to check if the base account exists."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __call__(self, form: FlaskForm, field: StringField) -> None:
 | 
				
			||||||
 | 
					        if field.data == "":
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        if db.session.get(BaseAccount, field.data) is None:
 | 
				
			||||||
 | 
					            raise ValidationError(lazy_gettext(
 | 
				
			||||||
 | 
					                "The base account does not exist."))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class BaseAccountAvailable:
 | 
				
			||||||
 | 
					    """The validator to check if the base account is available."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __call__(self, form: FlaskForm, field: StringField) -> None:
 | 
				
			||||||
 | 
					        if field.data == "":
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        if len(field.data) != 4:
 | 
				
			||||||
 | 
					            raise ValidationError(lazy_gettext(
 | 
				
			||||||
 | 
					                "The base account is not available."))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class AccountForm(FlaskForm):
 | 
				
			||||||
 | 
					    """The form to create or edit an account."""
 | 
				
			||||||
 | 
					    base_code = StringField(
 | 
				
			||||||
 | 
					        filters=[strip_text],
 | 
				
			||||||
 | 
					        validators=[
 | 
				
			||||||
 | 
					            DataRequired(lazy_gettext("Please select the base account.")),
 | 
				
			||||||
 | 
					            BaseAccountExists(),
 | 
				
			||||||
 | 
					            BaseAccountAvailable()])
 | 
				
			||||||
 | 
					    """The code of the base account."""
 | 
				
			||||||
 | 
					    title = StringField(
 | 
				
			||||||
 | 
					        filters=[strip_text],
 | 
				
			||||||
 | 
					        validators=[DataRequired(lazy_gettext("Please fill in the title"))])
 | 
				
			||||||
 | 
					    """The title."""
 | 
				
			||||||
 | 
					    is_offset_needed = BooleanField()
 | 
				
			||||||
 | 
					    """Whether the the entries of this account need offsets."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def populate_obj(self, obj: Account) -> None:
 | 
				
			||||||
 | 
					        """Populates the form data into an account object.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param obj: The account object.
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        is_new: bool = obj.id is None
 | 
				
			||||||
 | 
					        prev_base_code: str | None = obj.base_code
 | 
				
			||||||
 | 
					        if is_new:
 | 
				
			||||||
 | 
					            obj.id = new_id(Account)
 | 
				
			||||||
 | 
					        obj.base_code = self.base_code.data
 | 
				
			||||||
 | 
					        if prev_base_code != self.base_code.data:
 | 
				
			||||||
 | 
					            max_no: int = db.session.scalars(
 | 
				
			||||||
 | 
					                sa.select(sa.func.max(Account.no))
 | 
				
			||||||
 | 
					                .filter(Account.base_code == self.base_code.data)).one()
 | 
				
			||||||
 | 
					            obj.no = 1 if max_no is None else max_no + 1
 | 
				
			||||||
 | 
					        obj.title = self.title.data
 | 
				
			||||||
 | 
					        obj.is_offset_needed = self.is_offset_needed.data
 | 
				
			||||||
 | 
					        if is_new:
 | 
				
			||||||
 | 
					            current_user_pk: int = get_current_user_pk()
 | 
				
			||||||
 | 
					            obj.created_by_id = current_user_pk
 | 
				
			||||||
 | 
					            obj.updated_by_id = current_user_pk
 | 
				
			||||||
 | 
					        if prev_base_code is not None \
 | 
				
			||||||
 | 
					                and prev_base_code != self.base_code.data:
 | 
				
			||||||
 | 
					            setattr(self, "__post_update",
 | 
				
			||||||
 | 
					                    lambda: sort_accounts_in(prev_base_code, obj.id))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def post_update(self, obj) -> None:
 | 
				
			||||||
 | 
					        """The post-processing after the update.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: None
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        current_user_pk: int = get_current_user_pk()
 | 
				
			||||||
 | 
					        obj.updated_by_id = current_user_pk
 | 
				
			||||||
 | 
					        obj.updated_at = sa.func.now()
 | 
				
			||||||
 | 
					        if hasattr(self, "__post_update"):
 | 
				
			||||||
 | 
					            getattr(self, "__post_update")()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def selected_base(self) -> BaseAccount | None:
 | 
				
			||||||
 | 
					        """The selected base account in the form.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The selected base account in the form.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return db.session.get(BaseAccount, self.base_code.data)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def base_options(self) -> list[BaseAccount]:
 | 
				
			||||||
 | 
					        """The selectable base accounts.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The selectable base accounts.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return BaseAccount.query\
 | 
				
			||||||
 | 
					            .filter(sa.func.char_length(BaseAccount.code) == 4)\
 | 
				
			||||||
 | 
					            .order_by(BaseAccount.code).all()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def sort_accounts_in(base_code: str, exclude: int) -> None:
 | 
				
			||||||
 | 
					    """Sorts the accounts under a base account after changing the base
 | 
				
			||||||
 | 
					    account or deleting an account.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :param base_code: The code of the base account.
 | 
				
			||||||
 | 
					    :param exclude: The account ID to exclude.
 | 
				
			||||||
 | 
					    :return: None.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    accounts: list[Account] = Account.query\
 | 
				
			||||||
 | 
					        .filter(Account.base_code == base_code,
 | 
				
			||||||
 | 
					                Account.id != exclude)\
 | 
				
			||||||
 | 
					        .order_by(Account.no).all()
 | 
				
			||||||
 | 
					    for i in range(len(accounts)):
 | 
				
			||||||
 | 
					        if accounts[i].no != i + 1:
 | 
				
			||||||
 | 
					            accounts[i].no = i + 1
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class AccountReorderForm:
 | 
				
			||||||
 | 
					    """The form to reorder the accounts."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, base: BaseAccount):
 | 
				
			||||||
 | 
					        """Constructs the form to reorder the accounts under a base account.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param base: The base account.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        self.base: BaseAccount = base
 | 
				
			||||||
 | 
					        self.is_modified: bool = False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def save_order(self) -> None:
 | 
				
			||||||
 | 
					        """Saves the order of the account.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return:
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        accounts: list[Account] = self.base.accounts
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Collects the specified order.
 | 
				
			||||||
 | 
					        orders: dict[Account, int] = {}
 | 
				
			||||||
 | 
					        for account in accounts:
 | 
				
			||||||
 | 
					            if f"{account.id}-no" in request.form:
 | 
				
			||||||
 | 
					                try:
 | 
				
			||||||
 | 
					                    orders[account] = int(request.form[f"{account.id}-no"])
 | 
				
			||||||
 | 
					                except ValueError:
 | 
				
			||||||
 | 
					                    pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Missing and invalid orders are appended to the end.
 | 
				
			||||||
 | 
					        missing: list[Account] = [x for x in accounts if x not in orders]
 | 
				
			||||||
 | 
					        if len(missing) > 0:
 | 
				
			||||||
 | 
					            next_no: int = 1 if len(orders) == 0 else max(orders.values()) + 1
 | 
				
			||||||
 | 
					            for account in missing:
 | 
				
			||||||
 | 
					                orders[account] = next_no
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Sort by the specified order first, and their original order.
 | 
				
			||||||
 | 
					        accounts = sorted(accounts, key=lambda x: (orders[x], x.no, x.code))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Update the orders.
 | 
				
			||||||
 | 
					        with db.session.no_autoflush:
 | 
				
			||||||
 | 
					            for i in range(len(accounts)):
 | 
				
			||||||
 | 
					                if accounts[i].no != i + 1:
 | 
				
			||||||
 | 
					                    accounts[i].no = i + 1
 | 
				
			||||||
 | 
					                    self.is_modified = True
 | 
				
			||||||
							
								
								
									
										55
									
								
								src/accounting/account/query.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								src/accounting/account/query.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,55 @@
 | 
				
			|||||||
 | 
					# The Mia! Accounting Flask Project.
 | 
				
			||||||
 | 
					# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#  Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					#  you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					#  You may obtain a copy of the License at
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#      http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					#  distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					#  See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					#  limitations under the License.
 | 
				
			||||||
 | 
					"""The account query.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					import sqlalchemy as sa
 | 
				
			||||||
 | 
					from flask import request
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from accounting.locale import gettext
 | 
				
			||||||
 | 
					from accounting.models import Account, AccountL10n
 | 
				
			||||||
 | 
					from accounting.utils.query import parse_query_keywords
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def get_account_query() -> list[Account]:
 | 
				
			||||||
 | 
					    """Returns the accounts, optionally filtered by the query.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :return: The accounts.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    keywords: list[str] = parse_query_keywords(request.args.get("q"))
 | 
				
			||||||
 | 
					    if len(keywords) == 0:
 | 
				
			||||||
 | 
					        return Account.query.order_by(Account.base_code, Account.no).all()
 | 
				
			||||||
 | 
					    code: sa.BinaryExpression = Account.base_code + "-" \
 | 
				
			||||||
 | 
					        + sa.func.substr("000" + sa.cast(Account.no, sa.String),
 | 
				
			||||||
 | 
					                         sa.func.char_length(sa.cast(Account.no,
 | 
				
			||||||
 | 
					                                                     sa.String)) + 1)
 | 
				
			||||||
 | 
					    conditions: list[sa.BinaryExpression] = []
 | 
				
			||||||
 | 
					    for k in keywords:
 | 
				
			||||||
 | 
					        l10n: list[AccountL10n] = AccountL10n.query\
 | 
				
			||||||
 | 
					            .filter(AccountL10n.title.contains(k)).all()
 | 
				
			||||||
 | 
					        l10n_matches: set[str] = {x.account_id for x in l10n}
 | 
				
			||||||
 | 
					        sub_conditions: list[sa.BinaryExpression] \
 | 
				
			||||||
 | 
					            = [Account.base_code.contains(k),
 | 
				
			||||||
 | 
					               Account.title_l10n.contains(k),
 | 
				
			||||||
 | 
					               code.contains(k),
 | 
				
			||||||
 | 
					               Account.id.in_(l10n_matches)]
 | 
				
			||||||
 | 
					        if k in gettext("Offset needed"):
 | 
				
			||||||
 | 
					            sub_conditions.append(Account.is_offset_needed)
 | 
				
			||||||
 | 
					        conditions.append(sa.or_(*sub_conditions))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return Account.query.filter(*conditions)\
 | 
				
			||||||
 | 
					        .order_by(Account.base_code, Account.no).all()
 | 
				
			||||||
							
								
								
									
										196
									
								
								src/accounting/account/views.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										196
									
								
								src/accounting/account/views.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,196 @@
 | 
				
			|||||||
 | 
					# The Mia! Accounting Flask Project.
 | 
				
			||||||
 | 
					# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#  Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					#  you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					#  You may obtain a copy of the License at
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#      http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					#  distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					#  See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					#  limitations under the License.
 | 
				
			||||||
 | 
					"""The views for the account management.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					from urllib.parse import parse_qsl, urlencode
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from flask import Blueprint, render_template, session, redirect, flash, \
 | 
				
			||||||
 | 
					    url_for, request
 | 
				
			||||||
 | 
					from werkzeug.datastructures import ImmutableMultiDict
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from accounting.database import db
 | 
				
			||||||
 | 
					from accounting.locale import lazy_gettext
 | 
				
			||||||
 | 
					from accounting.models import Account, BaseAccount
 | 
				
			||||||
 | 
					from accounting.utils.next_url import inherit_next, or_next
 | 
				
			||||||
 | 
					from accounting.utils.pagination import Pagination
 | 
				
			||||||
 | 
					from accounting.utils.permission import can_view, has_permission, can_edit
 | 
				
			||||||
 | 
					from .forms import AccountForm, sort_accounts_in, AccountReorderForm
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					bp: Blueprint = Blueprint("account", __name__)
 | 
				
			||||||
 | 
					"""The view blueprint for the account management."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@bp.get("", endpoint="list")
 | 
				
			||||||
 | 
					@has_permission(can_view)
 | 
				
			||||||
 | 
					def list_accounts() -> str:
 | 
				
			||||||
 | 
					    """Lists the accounts.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :return: The account list.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    from .query import get_account_query
 | 
				
			||||||
 | 
					    accounts: list[BaseAccount] = get_account_query()
 | 
				
			||||||
 | 
					    pagination: Pagination = Pagination[BaseAccount](accounts)
 | 
				
			||||||
 | 
					    return render_template("accounting/account/list.html",
 | 
				
			||||||
 | 
					                           list=pagination.list, pagination=pagination)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@bp.get("/create", endpoint="create")
 | 
				
			||||||
 | 
					@has_permission(can_edit)
 | 
				
			||||||
 | 
					def show_add_account_form() -> str:
 | 
				
			||||||
 | 
					    """Shows the form to add an account.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :return: The form to add an account.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    if "form" in session:
 | 
				
			||||||
 | 
					        form = AccountForm(ImmutableMultiDict(parse_qsl(session["form"])))
 | 
				
			||||||
 | 
					        del session["form"]
 | 
				
			||||||
 | 
					        form.validate()
 | 
				
			||||||
 | 
					    else:
 | 
				
			||||||
 | 
					        form = AccountForm()
 | 
				
			||||||
 | 
					    return render_template("accounting/account/create.html",
 | 
				
			||||||
 | 
					                           form=form)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@bp.post("/store", endpoint="store")
 | 
				
			||||||
 | 
					@has_permission(can_edit)
 | 
				
			||||||
 | 
					def add_account() -> redirect:
 | 
				
			||||||
 | 
					    """Adds an account.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :return: The redirection to the account detail on success, or the account
 | 
				
			||||||
 | 
					        creation form on error.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    form = AccountForm(request.form)
 | 
				
			||||||
 | 
					    if not form.validate():
 | 
				
			||||||
 | 
					        for key in form.errors:
 | 
				
			||||||
 | 
					            for error in form.errors[key]:
 | 
				
			||||||
 | 
					                flash(error, "error")
 | 
				
			||||||
 | 
					        session["form"] = urlencode(list(request.form.items()))
 | 
				
			||||||
 | 
					        return redirect(inherit_next(url_for("accounting.account.create")))
 | 
				
			||||||
 | 
					    account: Account = Account()
 | 
				
			||||||
 | 
					    form.populate_obj(account)
 | 
				
			||||||
 | 
					    db.session.add(account)
 | 
				
			||||||
 | 
					    db.session.commit()
 | 
				
			||||||
 | 
					    flash(lazy_gettext("The account is added successfully"), "success")
 | 
				
			||||||
 | 
					    return redirect(inherit_next(url_for("accounting.account.detail",
 | 
				
			||||||
 | 
					                                         account=account)))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@bp.get("/<account:account>", endpoint="detail")
 | 
				
			||||||
 | 
					@has_permission(can_view)
 | 
				
			||||||
 | 
					def show_account_detail(account: Account) -> str:
 | 
				
			||||||
 | 
					    """Shows the account detail.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :param account: The account.
 | 
				
			||||||
 | 
					    :return: The detail.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    return render_template("accounting/account/detail.html", obj=account)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@bp.get("/<account:account>/edit", endpoint="edit")
 | 
				
			||||||
 | 
					@has_permission(can_edit)
 | 
				
			||||||
 | 
					def show_account_edit_form(account: Account) -> str:
 | 
				
			||||||
 | 
					    """Shows the form to edit an account.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :param account: The account.
 | 
				
			||||||
 | 
					    :return: The form to edit the account.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    form: AccountForm
 | 
				
			||||||
 | 
					    if "form" in session:
 | 
				
			||||||
 | 
					        form = AccountForm(ImmutableMultiDict(parse_qsl(session["form"])))
 | 
				
			||||||
 | 
					        del session["form"]
 | 
				
			||||||
 | 
					        form.validate()
 | 
				
			||||||
 | 
					    else:
 | 
				
			||||||
 | 
					        form = AccountForm(obj=account)
 | 
				
			||||||
 | 
					    return render_template("accounting/account/edit.html",
 | 
				
			||||||
 | 
					                           account=account, form=form)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@bp.post("/<account:account>/update", endpoint="update")
 | 
				
			||||||
 | 
					@has_permission(can_edit)
 | 
				
			||||||
 | 
					def update_account(account: Account) -> redirect:
 | 
				
			||||||
 | 
					    """Updates an account.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :param account: The account.
 | 
				
			||||||
 | 
					    :return: The redirection to the account detail on success, or the account
 | 
				
			||||||
 | 
					        edit form on error.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    form = AccountForm(request.form)
 | 
				
			||||||
 | 
					    if not form.validate():
 | 
				
			||||||
 | 
					        for key in form.errors:
 | 
				
			||||||
 | 
					            for error in form.errors[key]:
 | 
				
			||||||
 | 
					                flash(error, "error")
 | 
				
			||||||
 | 
					        session["form"] = urlencode(list(request.form.items()))
 | 
				
			||||||
 | 
					        return redirect(inherit_next(url_for("accounting.account.edit",
 | 
				
			||||||
 | 
					                                             account=account)))
 | 
				
			||||||
 | 
					    with db.session.no_autoflush:
 | 
				
			||||||
 | 
					        form.populate_obj(account)
 | 
				
			||||||
 | 
					    if not account.is_modified:
 | 
				
			||||||
 | 
					        flash(lazy_gettext("The account was not modified."), "success")
 | 
				
			||||||
 | 
					        return redirect(inherit_next(url_for("accounting.account.detail",
 | 
				
			||||||
 | 
					                                             account=account)))
 | 
				
			||||||
 | 
					    form.post_update(account)
 | 
				
			||||||
 | 
					    db.session.commit()
 | 
				
			||||||
 | 
					    flash(lazy_gettext("The account is updated successfully."), "success")
 | 
				
			||||||
 | 
					    return redirect(inherit_next(url_for("accounting.account.detail",
 | 
				
			||||||
 | 
					                                         account=account)))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@bp.post("/<account:account>/delete", endpoint="delete")
 | 
				
			||||||
 | 
					@has_permission(can_edit)
 | 
				
			||||||
 | 
					def delete_account(account: Account) -> redirect:
 | 
				
			||||||
 | 
					    """Deletes an account.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :param account: The account.
 | 
				
			||||||
 | 
					    :return: The redirection to the account list on success, or the account
 | 
				
			||||||
 | 
					        detail on error.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    account.delete()
 | 
				
			||||||
 | 
					    sort_accounts_in(account.base_code, account.id)
 | 
				
			||||||
 | 
					    db.session.commit()
 | 
				
			||||||
 | 
					    flash(lazy_gettext("The account is deleted successfully."), "success")
 | 
				
			||||||
 | 
					    return redirect(or_next(url_for("accounting.account.list")))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@bp.get("/bases/<baseAccount:base>", endpoint="order")
 | 
				
			||||||
 | 
					@has_permission(can_view)
 | 
				
			||||||
 | 
					def show_account_order(base: BaseAccount) -> str:
 | 
				
			||||||
 | 
					    """Shows the order of the accounts under a same base account.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :param base: The base account.
 | 
				
			||||||
 | 
					    :return: The order of the accounts under the base account.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    return render_template("accounting/account/order.html", base=base)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@bp.post("/bases/<baseAccount:base>", endpoint="sort")
 | 
				
			||||||
 | 
					@has_permission(can_edit)
 | 
				
			||||||
 | 
					def sort_accounts(base: BaseAccount) -> redirect:
 | 
				
			||||||
 | 
					    """Reorders the accounts under a base account.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :param base: The base account.
 | 
				
			||||||
 | 
					    :return: The redirection to the incoming account or the account list.  The
 | 
				
			||||||
 | 
					        reordering operation does not fail.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    form: AccountReorderForm = AccountReorderForm(base)
 | 
				
			||||||
 | 
					    form.save_order()
 | 
				
			||||||
 | 
					    if not form.is_modified:
 | 
				
			||||||
 | 
					        flash(lazy_gettext("The order was not modified."), "success")
 | 
				
			||||||
 | 
					        return redirect(or_next(url_for("accounting.account.list")))
 | 
				
			||||||
 | 
					    db.session.commit()
 | 
				
			||||||
 | 
					    flash(lazy_gettext("The order is updated successfully."), "success")
 | 
				
			||||||
 | 
					    return redirect(or_next(url_for("accounting.account.list")))
 | 
				
			||||||
@@ -23,10 +23,13 @@ from flask import Flask, Blueprint
 | 
				
			|||||||
def init_app(app: Flask, bp: Blueprint) -> None:
 | 
					def init_app(app: Flask, bp: Blueprint) -> None:
 | 
				
			||||||
    """Initialize the application.
 | 
					    """Initialize the application.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    :param bp: The blueprint of the accounting application.
 | 
					 | 
				
			||||||
    :param app: The Flask application.
 | 
					    :param app: The Flask application.
 | 
				
			||||||
 | 
					    :param bp: The blueprint of the accounting application.
 | 
				
			||||||
    :return: None.
 | 
					    :return: None.
 | 
				
			||||||
    """
 | 
					    """
 | 
				
			||||||
 | 
					    from .converters import BaseAccountConverter
 | 
				
			||||||
 | 
					    app.url_map.converters["baseAccount"] = BaseAccountConverter
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    from .views import bp as base_account_bp
 | 
					    from .views import bp as base_account_bp
 | 
				
			||||||
    bp.register_blueprint(base_account_bp, url_prefix="/base-accounts")
 | 
					    bp.register_blueprint(base_account_bp, url_prefix="/base-accounts")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -17,15 +17,14 @@
 | 
				
			|||||||
"""The console commands for the base account management.
 | 
					"""The console commands for the base account management.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
"""
 | 
					"""
 | 
				
			||||||
 | 
					import csv
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import click
 | 
					import click
 | 
				
			||||||
from flask.cli import with_appcontext
 | 
					from flask.cli import with_appcontext
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from accounting import data_dir
 | 
				
			||||||
from accounting.database import db
 | 
					from accounting.database import db
 | 
				
			||||||
from .models import BaseAccount, BaseAccountL10n
 | 
					from accounting.models import BaseAccount, BaseAccountL10n
 | 
				
			||||||
 | 
					 | 
				
			||||||
BaseAccountData = tuple[int, str, str, str]
 | 
					 | 
				
			||||||
"""The format of the base account data, as a list of (code, English,
 | 
					 | 
				
			||||||
Traditional Chinese, Simplified Chinese) tuples."""
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@click.command("accounting-init-base")
 | 
					@click.command("accounting-init-base")
 | 
				
			||||||
@@ -36,674 +35,17 @@ def init_base_accounts_command() -> None:
 | 
				
			|||||||
        click.echo("Base accounts already exist.")
 | 
					        click.echo("Base accounts already exist.")
 | 
				
			||||||
        raise click.Abort
 | 
					        raise click.Abort
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    db.session.bulk_save_objects(
 | 
					    with open(data_dir / "base_accounts.csv") as fh:
 | 
				
			||||||
        [BaseAccount(code=str(x[0]), title_l10n=x[1]) for x in DATA])
 | 
					        data: list[dict[str, str]] = [x for x in csv.DictReader(fh)]
 | 
				
			||||||
    db.session.bulk_save_objects(
 | 
					    account_data: list[dict[str, str]] = [{"code": x["code"],
 | 
				
			||||||
        [BaseAccountL10n(account_code=x[0], locale=y[0], title=y[1])
 | 
					                                           "title_l10n": x["title"]}
 | 
				
			||||||
         for x in DATA for y in (("zh_Hant", x[2]), ("zh_Hans", x[3]))])
 | 
					                                          for x in data]
 | 
				
			||||||
 | 
					    locales: list[str] = [x[5:] for x in data[0] if x.startswith("l10n-")]
 | 
				
			||||||
 | 
					    l10n_data: list[dict[str, str]] = [{"account_code": x["code"],
 | 
				
			||||||
 | 
					                                        "locale": y,
 | 
				
			||||||
 | 
					                                        "title": x[f"l10n-{y}"]}
 | 
				
			||||||
 | 
					                                       for x in data for y in locales]
 | 
				
			||||||
 | 
					    db.session.bulk_insert_mappings(BaseAccount, account_data)
 | 
				
			||||||
 | 
					    db.session.bulk_insert_mappings(BaseAccountL10n, l10n_data)
 | 
				
			||||||
    db.session.commit()
 | 
					    db.session.commit()
 | 
				
			||||||
    click.echo("Base accounts initialized.")
 | 
					    click.echo("Base accounts initialized.")
 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
DATA: list[BaseAccountData] = [
 | 
					 | 
				
			||||||
    (1, "assets", "資產", "资产"),
 | 
					 | 
				
			||||||
    (2, "liabilities", "負債", "负债"),
 | 
					 | 
				
			||||||
    (3, "owners’ equity", "業主權益", "业主权益"),
 | 
					 | 
				
			||||||
    (4, "operating revenue", "營業收入", "营业收入"),
 | 
					 | 
				
			||||||
    (5, "operating costs", "營業成本", "营业成本"),
 | 
					 | 
				
			||||||
    (6, "operating expenses", "營業費用", "营业费用"),
 | 
					 | 
				
			||||||
    (7, "non-operating revenue and expenses, other income (expense)",
 | 
					 | 
				
			||||||
     "營業外收入及費用", "营业外收入及费用"),
 | 
					 | 
				
			||||||
    (8, "income tax expense (or benefit)", "所得稅費用(或利益)",
 | 
					 | 
				
			||||||
     "所得税费用(或利益)"),
 | 
					 | 
				
			||||||
    (9, "nonrecurring gain or loss", "非經常營業損益", "非经常营业损益"),
 | 
					 | 
				
			||||||
    (11, "current assets", "流動資產", "流动资产"),
 | 
					 | 
				
			||||||
    (12, "current assets", "流動資產", "流动资产"),
 | 
					 | 
				
			||||||
    (13, "funds and long-term investments", "基金及長期投資", "基金及长期投资"),
 | 
					 | 
				
			||||||
    (14, "property , plant, and equipment", "固定資產", "固定资产"),
 | 
					 | 
				
			||||||
    (15, "property , plant, and equipment", "固定資產", "固定资产"),
 | 
					 | 
				
			||||||
    (16, "depletable assets", "遞耗資產", "递耗资产"),
 | 
					 | 
				
			||||||
    (17, "intangible assets", "無形資產", "无形资产"),
 | 
					 | 
				
			||||||
    (18, "other assets", "其他資產", "其他资产"),
 | 
					 | 
				
			||||||
    (21, "current liabilities", "流動負債", "流动负债"),
 | 
					 | 
				
			||||||
    (22, "current liabilities", "流動負債", "流动负债"),
 | 
					 | 
				
			||||||
    (23, "long-term liabilities", "長期負債", "长期负债"),
 | 
					 | 
				
			||||||
    (28, "other liabilities", "其他負債", "其他负债"),
 | 
					 | 
				
			||||||
    (31, "capital", "資本", "资本"),
 | 
					 | 
				
			||||||
    (32, "additional paid-in capital", "資本公積", "资本公积"),
 | 
					 | 
				
			||||||
    (33, "retained earnings (accumulated deficit)", "保留盈餘(或累積虧損)",
 | 
					 | 
				
			||||||
     "保留盈余(或累积亏损)"),
 | 
					 | 
				
			||||||
    (34, "equity adjustments", "權益調整", "权益调整"),
 | 
					 | 
				
			||||||
    (35, "treasury stock", "庫藏股", "库藏股"),
 | 
					 | 
				
			||||||
    (36, "minority interest", "少數股權", "少数股权"),
 | 
					 | 
				
			||||||
    (41, "sales revenue", "銷貨收入", "销货收入"),
 | 
					 | 
				
			||||||
    (46, "service revenue", "勞務收入", "劳务收入"),
 | 
					 | 
				
			||||||
    (47, "agency revenue", "業務收入", "业务收入"),
 | 
					 | 
				
			||||||
    (48, "other operating revenue", "其他營業收入", "其他营业收入"),
 | 
					 | 
				
			||||||
    (51, "cost of goods sold", "銷貨成本", "销货成本"),
 | 
					 | 
				
			||||||
    (56, "service costs", "勞務成本", "劳务成本"),
 | 
					 | 
				
			||||||
    (57, "agency costs", "業務成本", "业务成本"),
 | 
					 | 
				
			||||||
    (58, "other operating costs", "其他營業成本", "其他营业成本"),
 | 
					 | 
				
			||||||
    (61, "selling expenses", "推銷費用", "推销费用"),
 | 
					 | 
				
			||||||
    (62, "general & administrative expenses", "管理及總務費用", "管理及总务费用"),
 | 
					 | 
				
			||||||
    (63, "research and development expenses", "研究發展費用", "研究发展费用"),
 | 
					 | 
				
			||||||
    (71, "non-operating revenue", "營業外收入", "营业外收入"),
 | 
					 | 
				
			||||||
    (72, "non-operating revenue", "營業外收入", "营业外收入"),
 | 
					 | 
				
			||||||
    (73, "non-operating revenue", "營業外收入", "营业外收入"),
 | 
					 | 
				
			||||||
    (74, "non-operating revenue", "營業外收入", "营业外收入"),
 | 
					 | 
				
			||||||
    (75, "non-operating expenses", "營業外費用", "营业外费用"),
 | 
					 | 
				
			||||||
    (76, "non-operating expenses", "營業外費用", "营业外费用"),
 | 
					 | 
				
			||||||
    (77, "non-operating expenses", "營業外費用", "营业外费用"),
 | 
					 | 
				
			||||||
    (78, "non-operating expenses", "營業外費用", "营业外费用"),
 | 
					 | 
				
			||||||
    (81, "income tax expense (or benefit)", "所得稅費用(或利益)",
 | 
					 | 
				
			||||||
     "所得税费用(或利益)"),
 | 
					 | 
				
			||||||
    (91, "gain (loss) from discontinued operations", "停業部門損益",
 | 
					 | 
				
			||||||
     "停业部门损益"),
 | 
					 | 
				
			||||||
    (92, "extraordinary gain or loss", "非常損益", "非常损益"),
 | 
					 | 
				
			||||||
    (93, "cumulative effect of changes in accounting principles",
 | 
					 | 
				
			||||||
     "會計原則變動累積影響數", "会计原则变动累积影响数"),
 | 
					 | 
				
			||||||
    (94, "minority interest income", "少數股權淨利", "少数股权净利"),
 | 
					 | 
				
			||||||
    (111, "cash and cash equivalents", "現金及約當現金", "现金及约当现金"),
 | 
					 | 
				
			||||||
    (112, "short-term investments", "短期投資", "短期投资"),
 | 
					 | 
				
			||||||
    (113, "notes receivable", "應收票據", "应收票据"),
 | 
					 | 
				
			||||||
    (114, "accounts receivable", "應收帳款", "应收帐款"),
 | 
					 | 
				
			||||||
    (118, "other receivables", "其他應收款", "其他应收款"),
 | 
					 | 
				
			||||||
    (121, "inventories", "存貨", "存货"),
 | 
					 | 
				
			||||||
    (122, "inventories", "存貨", "存货"),
 | 
					 | 
				
			||||||
    (125, "prepaid expenses", "預付費用", "预付费用"),
 | 
					 | 
				
			||||||
    (126, "prepayments", "預付款項", "预付款项"),
 | 
					 | 
				
			||||||
    (128, "other current assets", "其他流動資產", "其他流动资产"),
 | 
					 | 
				
			||||||
    (129, "other current assets", "其他流動資產", "其他流动资产"),
 | 
					 | 
				
			||||||
    (131, "funds", "基金", "基金"),
 | 
					 | 
				
			||||||
    (132, "long-term investments", "長期投資", "长期投资"),
 | 
					 | 
				
			||||||
    (141, "land", "土地", "土地"),
 | 
					 | 
				
			||||||
    (142, "land improvements", "土地改良物", "土地改良物"),
 | 
					 | 
				
			||||||
    (143, "buildings", "房屋及建物", "房屋及建物"),
 | 
					 | 
				
			||||||
    (144, "machinery and equipment", "機(器)具及設備", "机(器)具及设备"),
 | 
					 | 
				
			||||||
    (145, "machinery and equipment", "機(器)具及設備", "机(器)具及设备"),
 | 
					 | 
				
			||||||
    (146, "machinery and equipment", "機(器)具及設備", "机(器)具及设备"),
 | 
					 | 
				
			||||||
    (151, "leased assets", "租賃資產", "租赁资产"),
 | 
					 | 
				
			||||||
    (152, "leasehold improvements", "租賃權益改良", "租赁权益改良"),
 | 
					 | 
				
			||||||
    (156, "construction in progress and prepayments for equipment",
 | 
					 | 
				
			||||||
     "未完工程及預付購置設備款", "未完工程及预付购置设备款"),
 | 
					 | 
				
			||||||
    (158, "miscellaneous property, plant, and equipment", "雜項固定資產",
 | 
					 | 
				
			||||||
     "杂项固定资产"),
 | 
					 | 
				
			||||||
    (161, "depletable assets", "遞耗資產", "递耗资产"),
 | 
					 | 
				
			||||||
    (171, "trademarks", "商標權", "商标权"),
 | 
					 | 
				
			||||||
    (172, "patents", "專利權", "专利权"),
 | 
					 | 
				
			||||||
    (173, "franchise", "特許權", "特许权"),
 | 
					 | 
				
			||||||
    (174, "copyright", "著作權", "著作权"),
 | 
					 | 
				
			||||||
    (175, "computer software", "電腦軟體", "电脑软体"),
 | 
					 | 
				
			||||||
    (176, "goodwill", "商譽", "商誉"),
 | 
					 | 
				
			||||||
    (177, "organization costs", "開辦費", "开办费"),
 | 
					 | 
				
			||||||
    (178, "other intangibles", "其他無形資產", "其他无形资产"),
 | 
					 | 
				
			||||||
    (181, "deferred assets", "遞延資產", "递延资产"),
 | 
					 | 
				
			||||||
    (182, "idle assets", "閒置資產", "闲置资产"),
 | 
					 | 
				
			||||||
    (184, "long-term notes , accounts and overdue receivables",
 | 
					 | 
				
			||||||
     "長期應收票據及款項與催收帳款", "长期应收票据及款项与催收帐款"),
 | 
					 | 
				
			||||||
    (185, "assets leased to others", "出租資產", "出租资产"),
 | 
					 | 
				
			||||||
    (186, "refundable deposit", "存出保證金", "存出保证金"),
 | 
					 | 
				
			||||||
    (188, "miscellaneous assets", "雜項資產", "杂项资产"),
 | 
					 | 
				
			||||||
    (211, "short-term borrowings (debt)", "短期借款", "短期借款"),
 | 
					 | 
				
			||||||
    (212, "short-term notes and bills payable", "應付短期票券", "应付短期票券"),
 | 
					 | 
				
			||||||
    (213, "notes payable", "應付票據", "应付票据"),
 | 
					 | 
				
			||||||
    (214, "accounts pay able", "應付帳款", "应付帐款"),
 | 
					 | 
				
			||||||
    (216, "income taxes payable", "應付所得稅", "应付所得税"),
 | 
					 | 
				
			||||||
    (217, "accrued expenses", "應付費用", "应付费用"),
 | 
					 | 
				
			||||||
    (218, "other payables", "其他應付款", "其他应付款"),
 | 
					 | 
				
			||||||
    (219, "other payables", "其他應付款", "其他应付款"),
 | 
					 | 
				
			||||||
    (226, "advance receipts", "預收款項", "预收款项"),
 | 
					 | 
				
			||||||
    (227, "long-term liabilities -current portion",
 | 
					 | 
				
			||||||
     "一年或一營業週期內到期長期負債", "一年或一营业周期内到期长期负债"),
 | 
					 | 
				
			||||||
    (228, "other current liabilities", "其他流動負債",
 | 
					 | 
				
			||||||
     "其他流动负债"),
 | 
					 | 
				
			||||||
    (229, "other current liabilities", "其他流動負債",
 | 
					 | 
				
			||||||
     "其他流动负债"),
 | 
					 | 
				
			||||||
    (231, "corporate bonds payable", "應付公司債", "应付公司债"),
 | 
					 | 
				
			||||||
    (232, "long-term loans payable", "長期借款", "长期借款"),
 | 
					 | 
				
			||||||
    (233, "long-term notes and accounts payable", "長期應付票據及款項",
 | 
					 | 
				
			||||||
     "长期应付票据及款项"),
 | 
					 | 
				
			||||||
    (234, "accrued liabilities for land value increment tax",
 | 
					 | 
				
			||||||
     "估計應付土地增值稅", "估计应付土地增值税"),
 | 
					 | 
				
			||||||
    (235, "accrued pension liabilities", "應計退休金負債", "应计退休金负债"),
 | 
					 | 
				
			||||||
    (238, "other long-term liabilities", "其他長期負債", "其他长期负债"),
 | 
					 | 
				
			||||||
    (281, "deferred liabilities", "遞延負債", "递延负债"),
 | 
					 | 
				
			||||||
    (286, "deposits received", "存入保證金", "存入保证金"),
 | 
					 | 
				
			||||||
    (288, "miscellaneous liabilities", "雜項負債", "杂项负债"),
 | 
					 | 
				
			||||||
    (311, "capital", "資本(或股本)", "资本(或股本)"),
 | 
					 | 
				
			||||||
    (321, "paid-in capital in excess of par", "股票溢價", "股票溢价"),
 | 
					 | 
				
			||||||
    (323, "capital surplus from assets revaluation", "資產重估增值準備",
 | 
					 | 
				
			||||||
     "资产重估增值准备"),
 | 
					 | 
				
			||||||
    (324, "capital surplus from gain on disposal of assets", "處分資產溢價公積",
 | 
					 | 
				
			||||||
     "处分资产溢价公积"),
 | 
					 | 
				
			||||||
    (325, "capital surplus from business combination", "合併公積", "合并公积"),
 | 
					 | 
				
			||||||
    (326, "donated surplus", "受贈公積", "受赠公积"),
 | 
					 | 
				
			||||||
    (328, "other additional paid-in capital", "其他資本公積", "其他资本公积"),
 | 
					 | 
				
			||||||
    (331, "legal reserve", "法定盈餘公積", "法定盈余公积"),
 | 
					 | 
				
			||||||
    (332, "special reserve", "特別盈餘公積", "特别盈余公积"),
 | 
					 | 
				
			||||||
    (335, "retained earnings-unappropriated (or accumulated deficit)",
 | 
					 | 
				
			||||||
     "未分配盈餘(或累積虧損)", "未分配盈余(或累积亏损)"),
 | 
					 | 
				
			||||||
    (341,
 | 
					 | 
				
			||||||
     "unrealized loss on market value decline of long-term equity investments",
 | 
					 | 
				
			||||||
     "長期股權投資未實現跌價損失", "长期股权投资未实现跌价损失"),
 | 
					 | 
				
			||||||
    (342, "cumulative translation adjustment", "累積換算調整數", "累积换算调整数"),
 | 
					 | 
				
			||||||
    (343, "net loss not recognized as pension cost", "未認列為退休金成本之淨損失",
 | 
					 | 
				
			||||||
     "未认列为退休金成本之净损失"),
 | 
					 | 
				
			||||||
    (351, "treasury stock", "庫藏股", "库藏股"),
 | 
					 | 
				
			||||||
    (361, "minority interest", "少數股權", "少数股权"),
 | 
					 | 
				
			||||||
    (411, "sales revenue", "銷貨收入", "销货收入"),
 | 
					 | 
				
			||||||
    (417, "sales return", "銷貨退回", "销货退回"),
 | 
					 | 
				
			||||||
    (419, "sales allowances", "銷貨折讓", "销货折让"),
 | 
					 | 
				
			||||||
    (461, "service revenue", "勞務收入", "劳务收入"),
 | 
					 | 
				
			||||||
    (471, "agency revenue", "業務收入", "业务收入"),
 | 
					 | 
				
			||||||
    (488, "other operating revenue", "其他營業收入—其他", "其他营业收入—其他"),
 | 
					 | 
				
			||||||
    (511, "cost of goods sold", "銷貨成本", "销货成本"),
 | 
					 | 
				
			||||||
    (512, "purchases", "進貨", "进货"),
 | 
					 | 
				
			||||||
    (513, "materials purchased", "進料", "进料"),
 | 
					 | 
				
			||||||
    (514, "direct labor", "直接人工", "直接人工"),
 | 
					 | 
				
			||||||
    (515, "manufacturing overhead", "製造費用", "制造费用"),
 | 
					 | 
				
			||||||
    (516, "manufacturing overhead", "製造費用", "制造费用"),
 | 
					 | 
				
			||||||
    (517, "manufacturing overhead", "製造費用", "制造费用"),
 | 
					 | 
				
			||||||
    (518, "manufacturing overhead", "製造費用", "制造费用"),
 | 
					 | 
				
			||||||
    (561, "service costs", "勞務成本", "劳务成本"),
 | 
					 | 
				
			||||||
    (571, "agency costs", "業務成本", "业务成本"),
 | 
					 | 
				
			||||||
    (588, "other operating costs-other", "其他營業成本—其他", "其他营业成本—其他"),
 | 
					 | 
				
			||||||
    (615, "selling expenses", "推銷費用", "推销费用"),
 | 
					 | 
				
			||||||
    (616, "selling expenses", "推銷費用", "推销费用"),
 | 
					 | 
				
			||||||
    (617, "selling expenses", "推銷費用", "推销费用"),
 | 
					 | 
				
			||||||
    (618, "selling expenses", "推銷費用", "推销费用"),
 | 
					 | 
				
			||||||
    (625, "general & administrative expenses", "管理及總務費用", "管理及总务费用"),
 | 
					 | 
				
			||||||
    (626, "general & administrative expenses", "管理及總務費用", "管理及总务费用"),
 | 
					 | 
				
			||||||
    (627, "general & administrative expenses", "管理及總務費用", "管理及总务费用"),
 | 
					 | 
				
			||||||
    (628, "general & administrative expenses", "管理及總務費用", "管理及总务费用"),
 | 
					 | 
				
			||||||
    (635, "research and development expenses", "研究發展費用", "研究发展费用"),
 | 
					 | 
				
			||||||
    (636, "research and development expenses", "研究發展費用", "研究发展费用"),
 | 
					 | 
				
			||||||
    (637, "research and development expenses", "研究發展費用", "研究发展费用"),
 | 
					 | 
				
			||||||
    (638, "research and development expenses", "研究發展費用", "研究发展费用"),
 | 
					 | 
				
			||||||
    (711, "interest revenue", "利息收入", "利息收入"),
 | 
					 | 
				
			||||||
    (712, "investment income", "投資收益", "投资收益"),
 | 
					 | 
				
			||||||
    (713, "foreign exchange gain", "兌換利益", "兑换利益"),
 | 
					 | 
				
			||||||
    (714, "gain on disposal of investments", "處分投資收益", "处分投资收益"),
 | 
					 | 
				
			||||||
    (715, "gain on disposal of assets", "處分資產溢價收入", "处分资产溢价收入"),
 | 
					 | 
				
			||||||
    (748, "other non-operating revenue", "其他營業外收入", "其他营业外收入"),
 | 
					 | 
				
			||||||
    (751, "interest expense", "利息費用", "利息费用"),
 | 
					 | 
				
			||||||
    (752, "investment loss", "投資損失", "投资损失"),
 | 
					 | 
				
			||||||
    (753, "foreign exchange loss", "兌換損失", "兑换损失"),
 | 
					 | 
				
			||||||
    (754, "loss on disposal of investments", "處分投資損失", "处分投资损失"),
 | 
					 | 
				
			||||||
    (755, "loss on disposal of assets", "處分資產損失", "处分资产损失"),
 | 
					 | 
				
			||||||
    (788, "other non-operating expenses", "其他營業外費用", "其他营业外费用"),
 | 
					 | 
				
			||||||
    (811, "income tax expense (or benefit)", "所得稅費用(或利益)",
 | 
					 | 
				
			||||||
     "所得税费用(或利益)"),
 | 
					 | 
				
			||||||
    (911, "income (loss) from operations of discontinued segments",
 | 
					 | 
				
			||||||
     "停業部門損益—停業前營業損益", "停业部门损益—停业前营业损益"),
 | 
					 | 
				
			||||||
    (912, "gain (loss) from disposal of discontinued segments",
 | 
					 | 
				
			||||||
     "停業部門損益—處分損益", "停业部门损益—处分损益"),
 | 
					 | 
				
			||||||
    (921, "extraordinary gain or loss", "非常損益", "非常损益"),
 | 
					 | 
				
			||||||
    (931, "cumulative effect of changes in accounting principles",
 | 
					 | 
				
			||||||
     "會計原則變動累積影響數", "会计原则变动累积影响数"),
 | 
					 | 
				
			||||||
    (941, "minority interest income", "少數股權淨利", "少数股权净利"),
 | 
					 | 
				
			||||||
    (1111, "cash on hand", "庫存現金", "库存现金"),
 | 
					 | 
				
			||||||
    (1112, "petty cash/revolving funds", "零用金/週轉金", "零用金/周转金"),
 | 
					 | 
				
			||||||
    (1113, "cash in banks", "銀行存款", "银行存款"),
 | 
					 | 
				
			||||||
    (1116, "cash in transit", "在途現金", "在途现金"),
 | 
					 | 
				
			||||||
    (1117, "cash equivalents", "約當現金", "约当现金"),
 | 
					 | 
				
			||||||
    (1118, "other cash and cash equivalents", "其他現金及約當現金",
 | 
					 | 
				
			||||||
     "其他现金及约当现金"),
 | 
					 | 
				
			||||||
    (1121, "short-term investments – stock", "短期投資—股票", "短期投资—股票"),
 | 
					 | 
				
			||||||
    (1122, "short-term investments – short-term notes and bills",
 | 
					 | 
				
			||||||
     "短期投資—短期票券", "短期投资—短期票券"),
 | 
					 | 
				
			||||||
    (1123, "short-term investments – government bonds", "短期投資—政府債券",
 | 
					 | 
				
			||||||
     "短期投资—政府债券"),
 | 
					 | 
				
			||||||
    (1124, "short-term investments – beneficiary certificates",
 | 
					 | 
				
			||||||
     "短期投資—受益憑證", "短期投资—受益凭证"),
 | 
					 | 
				
			||||||
    (1125, "short-term investments – corporate bonds", "短期投資—公司債",
 | 
					 | 
				
			||||||
     "短期投资—公司债"),
 | 
					 | 
				
			||||||
    (1128, "short-term investments – other", "短期投資—其他", "短期投资—其他"),
 | 
					 | 
				
			||||||
    (1129, "allowance for reduction of short-term investment to market",
 | 
					 | 
				
			||||||
     "備抵短期投資跌價損失", "备抵短期投资跌价损失"),
 | 
					 | 
				
			||||||
    (1131, "notes receivable", "應收票據", "应收票据"),
 | 
					 | 
				
			||||||
    (1132, "discounted notes receivable", "應收票據貼現", "应收票据贴现"),
 | 
					 | 
				
			||||||
    (1137, "notes receivable – related parties", "應收票據—關係人",
 | 
					 | 
				
			||||||
     "应收票据—关系人"),
 | 
					 | 
				
			||||||
    (1138, "other notes receivable", "其他應收票據", "其他应收票据"),
 | 
					 | 
				
			||||||
    (1139, "allowance for uncollectible accounts – notes receivable",
 | 
					 | 
				
			||||||
     "備抵呆帳-應收票據", "备抵呆帐-应收票据"),
 | 
					 | 
				
			||||||
    (1141, "accounts receivable", "應收帳款", "应收帐款"),
 | 
					 | 
				
			||||||
    (1142, "installment accounts receivable", "應收分期帳款",
 | 
					 | 
				
			||||||
     "应收分期帐款"),
 | 
					 | 
				
			||||||
    (1147, "accounts receivable – related parties", "應收帳款—關係人",
 | 
					 | 
				
			||||||
     "应收帐款—关系人"),
 | 
					 | 
				
			||||||
    (1149, "allowance for uncollectible accounts – accounts receivable",
 | 
					 | 
				
			||||||
     "備抵呆帳-應收帳款", "备抵呆帐-应收帐款"),
 | 
					 | 
				
			||||||
    (1181, "forward exchange contract receivable", "應收出售遠匯款",
 | 
					 | 
				
			||||||
     "应收出售远汇款"),
 | 
					 | 
				
			||||||
    (1182, "forward exchange contract receivable – foreign currencies",
 | 
					 | 
				
			||||||
     "應收遠匯款—外幣", "应收远汇款—外币"),
 | 
					 | 
				
			||||||
    (1183, "discount on forward ex-change contract", "買賣遠匯折價",
 | 
					 | 
				
			||||||
     "买卖远汇折价"),
 | 
					 | 
				
			||||||
    (1184, "earned revenue receivable", "應收收益", "应收收益"),
 | 
					 | 
				
			||||||
    (1185, "income tax refund receivable", "應收退稅款", "应收退税款"),
 | 
					 | 
				
			||||||
    (1187, "other receivables – related parties", "其他應收款—關係人",
 | 
					 | 
				
			||||||
     "其他应收款—关系人"),
 | 
					 | 
				
			||||||
    (1188, "other receivables – other", "其他應收款—其他", "其他应收款—其他"),
 | 
					 | 
				
			||||||
    (1189, "allowance for uncollectible accounts – other receivables",
 | 
					 | 
				
			||||||
     "備抵呆帳—其他應收款", "备抵呆帐—其他应收款"),
 | 
					 | 
				
			||||||
    (1211, "merchandise inventory", "商品存貨", "商品存货"),
 | 
					 | 
				
			||||||
    (1212, "consigned goods", "寄銷商品", "寄销商品"),
 | 
					 | 
				
			||||||
    (1213, "goods in transit", "在途商品", "在途商品"),
 | 
					 | 
				
			||||||
    (1219, "allowance for reduction of inventory to market", "備抵存貨跌價損失",
 | 
					 | 
				
			||||||
     "备抵存货跌价损失"),
 | 
					 | 
				
			||||||
    (1221, "finished goods", "製成品", "制成品"),
 | 
					 | 
				
			||||||
    (1222, "consigned finished goods", "寄銷製成品", "寄销制成品"),
 | 
					 | 
				
			||||||
    (1223, "by-products", "副產品", "副产品"),
 | 
					 | 
				
			||||||
    (1224, "work in process", "在製品", "在制品"),
 | 
					 | 
				
			||||||
    (1225, "work in process – outsourced", "委外加工", "委外加工"),
 | 
					 | 
				
			||||||
    (1226, "raw materials", "原料", "原料"),
 | 
					 | 
				
			||||||
    (1227, "supplies", "物料", "物料"),
 | 
					 | 
				
			||||||
    (1228, "materials and supplies in transit", "在途原物料", "在途原物料"),
 | 
					 | 
				
			||||||
    (1229, "allowance for reduction of inventory to market", "備抵存貨跌價損失",
 | 
					 | 
				
			||||||
     "备抵存货跌价损失"),
 | 
					 | 
				
			||||||
    (1251, "prepaid payroll", "預付薪資", "预付薪资"),
 | 
					 | 
				
			||||||
    (1252, "prepaid rents", "預付租金", "预付租金"),
 | 
					 | 
				
			||||||
    (1253, "prepaid insurance", "預付保險費", "预付保险费"),
 | 
					 | 
				
			||||||
    (1254, "office supplies", "用品盤存", "用品盘存"),
 | 
					 | 
				
			||||||
    (1255, "prepaid income tax", "預付所得稅", "预付所得税"),
 | 
					 | 
				
			||||||
    (1258, "other prepaid expenses", "其他預付費用", "其他预付费用"),
 | 
					 | 
				
			||||||
    (1261, "prepayment for purchases", "預付貨款", "预付货款"),
 | 
					 | 
				
			||||||
    (1268, "other prepayments", "其他預付款項", "其他预付款项"),
 | 
					 | 
				
			||||||
    (1281, "VAT paid ( or input tax)", "進項稅額", "进项税额"),
 | 
					 | 
				
			||||||
    (1282, "excess VAT paid (or overpaid VAT)", "留抵稅額", "留抵税额"),
 | 
					 | 
				
			||||||
    (1283, "temporary payments", "暫付款", "暂付款"),
 | 
					 | 
				
			||||||
    (1284, "payment on behalf of others", "代付款", "代付款"),
 | 
					 | 
				
			||||||
    (1285, "advances to employees", "員工借支", "员工借支"),
 | 
					 | 
				
			||||||
    (1286, "refundable deposits", "存出保證金", "存出保证金"),
 | 
					 | 
				
			||||||
    (1287, "certificate of deposit-restricted", "受限制存款", "受限制存款"),
 | 
					 | 
				
			||||||
    (1291, "deferred income tax assets", "遞延所得稅資產", "递延所得税资产"),
 | 
					 | 
				
			||||||
    (1292, "deferred foreign exchange losses", "遞延兌換損失", "递延兑换损失"),
 | 
					 | 
				
			||||||
    (1293, "owners’ (stockholders’) current account", "業主(股東)往來",
 | 
					 | 
				
			||||||
     "业主(股东)往来"),
 | 
					 | 
				
			||||||
    (1294, "current account with others", "同業往來", "同业往来"),
 | 
					 | 
				
			||||||
    (1298, "other current assets – other", "其他流動資產—其他",
 | 
					 | 
				
			||||||
     "其他流动资产—其他"),
 | 
					 | 
				
			||||||
    (1311, "redemption fund (or sinking fund)", "償債基金", "偿债基金"),
 | 
					 | 
				
			||||||
    (1312, "fund for improvement and expansion", "改良及擴充基金",
 | 
					 | 
				
			||||||
     "改良及扩充基金"),
 | 
					 | 
				
			||||||
    (1313, "contingency fund", "意外損失準備基金", "意外损失准备基金"),
 | 
					 | 
				
			||||||
    (1314, "pension fund", "退休基金", "退休基金"),
 | 
					 | 
				
			||||||
    (1318, "other funds", "其他基金", "其他基金"),
 | 
					 | 
				
			||||||
    (1321, "long-term equity investments", "長期股權投資", "长期股权投资"),
 | 
					 | 
				
			||||||
    (1322, "long-term bond investments", "長期債券投資", "长期债券投资"),
 | 
					 | 
				
			||||||
    (1323, "long-term real estate in-vestments", "長期不動產投資",
 | 
					 | 
				
			||||||
     "长期不动产投资"),
 | 
					 | 
				
			||||||
    (1324, "cash surrender value of life insurance", "人壽保險現金解約價值",
 | 
					 | 
				
			||||||
     "人寿保险现金解约价值"),
 | 
					 | 
				
			||||||
    (1328, "other long-term investments", "其他長期投資", "其他长期投资"),
 | 
					 | 
				
			||||||
    (1329,
 | 
					 | 
				
			||||||
     "allowance for excess of cost over market value of long-term investments",
 | 
					 | 
				
			||||||
     "備抵長期投資跌價損失", "备抵长期投资跌价损失"),
 | 
					 | 
				
			||||||
    (1411, "land", "土地", "土地"),
 | 
					 | 
				
			||||||
    (1418, "land – revaluation increments", "土地—重估增值", "土地—重估增值"),
 | 
					 | 
				
			||||||
    (1421, "land improvements", "土地改良物", "土地改良物"),
 | 
					 | 
				
			||||||
    (1428, "land improvements – revaluation increments", "土地改良物—重估增值",
 | 
					 | 
				
			||||||
     "土地改良物—重估增值"),
 | 
					 | 
				
			||||||
    (1429, "accumulated depreciation – land improvements", "累積折舊—土地改良物",
 | 
					 | 
				
			||||||
     "累积折旧—土地改良物"),
 | 
					 | 
				
			||||||
    (1431, "buildings", "房屋及建物", "房屋及建物"),
 | 
					 | 
				
			||||||
    (1438, "buildings –revaluation increments", "房屋及建物—重估增值",
 | 
					 | 
				
			||||||
     "房屋及建物—重估增值"),
 | 
					 | 
				
			||||||
    (1439, "accumulated depreciation – buildings", "累積折舊—房屋及建物",
 | 
					 | 
				
			||||||
     "累积折旧—房屋及建物"),
 | 
					 | 
				
			||||||
    (1441, "machinery", "機(器)具", "机(器)具"),
 | 
					 | 
				
			||||||
    (1448, "machinery – revaluation increments", "機(器)具—重估增值",
 | 
					 | 
				
			||||||
     "机(器)具—重估增值"),
 | 
					 | 
				
			||||||
    (1449, "accumulated depreciation – machinery", "累積折舊—機(器)具",
 | 
					 | 
				
			||||||
     "累积折旧—机(器)具"),
 | 
					 | 
				
			||||||
    (1511, "leased assets", "租賃資產", "租赁资产"),
 | 
					 | 
				
			||||||
    (1519, "accumulated depreciation – leased assets", "累積折舊—租賃資產",
 | 
					 | 
				
			||||||
     "累积折旧—租赁资产"),
 | 
					 | 
				
			||||||
    (1521, "leasehold improvements", "租賃權益改良", "租赁权益改良"),
 | 
					 | 
				
			||||||
    (1529, "accumulated depreciation – leasehold improvements",
 | 
					 | 
				
			||||||
     "累積折舊—租賃權益改良", "累积折旧—租赁权益改良"),
 | 
					 | 
				
			||||||
    (1561, "construction in progress", "未完工程", "未完工程"),
 | 
					 | 
				
			||||||
    (1562, "prepayment for equipment", "預付購置設備款", "预付购置设备款"),
 | 
					 | 
				
			||||||
    (1581, "miscellaneous property, plant, and equipment", "雜項固定資產",
 | 
					 | 
				
			||||||
     "杂项固定资产"),
 | 
					 | 
				
			||||||
    (1588,
 | 
					 | 
				
			||||||
     "miscellaneous property, plant, and equipment – revaluation increments",
 | 
					 | 
				
			||||||
     "雜項固定資產—重估增值", "杂项固定资产—重估增值"),
 | 
					 | 
				
			||||||
    (1589,
 | 
					 | 
				
			||||||
     "accumulated depreciation – miscellaneous property, plant, and equipment",
 | 
					 | 
				
			||||||
     "累積折舊—雜項固定資產", "累积折旧—杂项固定资产"),
 | 
					 | 
				
			||||||
    (1611, "natural resources", "天然資源", "天然资源"),
 | 
					 | 
				
			||||||
    (1618, "natural resources –revaluation increments", "天然資源—重估增值",
 | 
					 | 
				
			||||||
     "天然资源—重估增值"),
 | 
					 | 
				
			||||||
    (1619, "accumulated depletion – natural resources", "累積折耗—天然資源",
 | 
					 | 
				
			||||||
     "累积折耗—天然资源"),
 | 
					 | 
				
			||||||
    (1711, "trademarks", "商標權", "商标权"),
 | 
					 | 
				
			||||||
    (1721, "patents", "專利權", "专利权"),
 | 
					 | 
				
			||||||
    (1731, "franchise", "特許權", "特许权"),
 | 
					 | 
				
			||||||
    (1741, "copyright", "著作權", "著作权"),
 | 
					 | 
				
			||||||
    (1751, "computer software cost", "電腦軟體", "电脑软体"),
 | 
					 | 
				
			||||||
    (1761, "goodwill", "商譽", "商誉"),
 | 
					 | 
				
			||||||
    (1771, "organization costs", "開辦費", "开办费"),
 | 
					 | 
				
			||||||
    (1781, "deferred pension costs", "遞延退休金成本", "递延退休金成本"),
 | 
					 | 
				
			||||||
    (1782, "leasehold improvements", "租賃權益改良", "租赁权益改良"),
 | 
					 | 
				
			||||||
    (1788, "other intangible assets – other", "其他無形資產—其他",
 | 
					 | 
				
			||||||
     "其他无形资产—其他"),
 | 
					 | 
				
			||||||
    (1811, "deferred bond issuance costs", "債券發行成本", "债券发行成本"),
 | 
					 | 
				
			||||||
    (1812, "long-term prepaid rent", "長期預付租金", "长期预付租金"),
 | 
					 | 
				
			||||||
    (1813, "long-term prepaid insurance", "長期預付保險費", "长期预付保险费"),
 | 
					 | 
				
			||||||
    (1814, "deferred income tax assets", "遞延所得稅資產", "递延所得税资产"),
 | 
					 | 
				
			||||||
    (1815, "prepaid pension cost", "預付退休金", "预付退休金"),
 | 
					 | 
				
			||||||
    (1818, "other deferred assets", "其他遞延資產", "其他递延资产"),
 | 
					 | 
				
			||||||
    (1821, "idle assets", "閒置資產", "闲置资产"),
 | 
					 | 
				
			||||||
    (1841, "long-term notes receivable", "長期應收票據", "长期应收票据"),
 | 
					 | 
				
			||||||
    (1842, "long-term accounts receivable", "長期應收帳款", "长期应收帐款"),
 | 
					 | 
				
			||||||
    (1843, "overdue receivables", "催收帳款", "催收帐款"),
 | 
					 | 
				
			||||||
    (1847,
 | 
					 | 
				
			||||||
     "long-term notes, accounts and overdue receivables – related parties",
 | 
					 | 
				
			||||||
     "長期應收票據及款項與催收帳款—關係人", "长期应收票据及款项与催收帐款—关系人"),
 | 
					 | 
				
			||||||
    (1848, "other long-term receivables", "其他長期應收款項", "其他长期应收款项"),
 | 
					 | 
				
			||||||
    (1849,
 | 
					 | 
				
			||||||
     "allowance for uncollectible accounts – long-term notes, accounts and"
 | 
					 | 
				
			||||||
     " overdue receivables",
 | 
					 | 
				
			||||||
     "備抵呆帳—長期應收票據及款項與催收帳款", "备抵呆帐—长期应收票据及款项与催收帐款"),
 | 
					 | 
				
			||||||
    (1851, "assets leased to others", "出租資產", "出租资产"),
 | 
					 | 
				
			||||||
    (1858, "assets leased to others – incremental value from revaluation",
 | 
					 | 
				
			||||||
     "出租資產—重估增值", "出租资产—重估增值"),
 | 
					 | 
				
			||||||
    (1859, "accumulated depreciation – assets leased to others",
 | 
					 | 
				
			||||||
     "累積折舊—出租資產", "累积折旧—出租资产"),
 | 
					 | 
				
			||||||
    (1861, "refundable deposits", "存出保證金", "存出保证金"),
 | 
					 | 
				
			||||||
    (1881, "certificate of deposit – restricted", "受限制存款", "受限制存款"),
 | 
					 | 
				
			||||||
    (1888, "miscellaneous assets – other", "雜項資產—其他", "杂项资产—其他"),
 | 
					 | 
				
			||||||
    (2111, "bank overdraft", "銀行透支", "银行透支"),
 | 
					 | 
				
			||||||
    (2112, "bank loan", "銀行借款", "银行借款"),
 | 
					 | 
				
			||||||
    (2114, "short-term borrowings – owners", "短期借款—業主", "短期借款—业主"),
 | 
					 | 
				
			||||||
    (2115, "short-term borrowings – employees", "短期借款—員工", "短期借款—员工"),
 | 
					 | 
				
			||||||
    (2117, "short-term borrowings – related parties", "短期借款—關係人",
 | 
					 | 
				
			||||||
     "短期借款—关系人"),
 | 
					 | 
				
			||||||
    (2118, "short-term borrowings – other", "短期借款—其他", "短期借款—其他"),
 | 
					 | 
				
			||||||
    (2121, "commercial paper payable", "應付商業本票", "应付商业本票"),
 | 
					 | 
				
			||||||
    (2122, "bank acceptance", "銀行承兌匯票", "银行承兑汇票"),
 | 
					 | 
				
			||||||
    (2128, "other short-term notes and bills payable", "其他應付短期票券",
 | 
					 | 
				
			||||||
     "其他应付短期票券"),
 | 
					 | 
				
			||||||
    (2129, "discount on short-term notes and bills payable", "應付短期票券折價",
 | 
					 | 
				
			||||||
     "应付短期票券折价"),
 | 
					 | 
				
			||||||
    (2131, "notes payable", "應付票據", "应付票据"),
 | 
					 | 
				
			||||||
    (2137, "notes payable – related parties", "應付票據—關係人",
 | 
					 | 
				
			||||||
     "应付票据—关系人"),
 | 
					 | 
				
			||||||
    (2138, "other notes payable", "其他應付票據", "其他应付票据"),
 | 
					 | 
				
			||||||
    (2141, "accounts payable", "應付帳款", "应付帐款"),
 | 
					 | 
				
			||||||
    (2147, "accounts payable – related parties", "應付帳款—關係人",
 | 
					 | 
				
			||||||
     "应付帐款—关系人"),
 | 
					 | 
				
			||||||
    (2161, "income tax payable", "應付所得稅", "应付所得税"),
 | 
					 | 
				
			||||||
    (2171, "accrued payroll", "應付薪工", "应付薪工"),
 | 
					 | 
				
			||||||
    (2172, "accrued rent payable", "應付租金", "应付租金"),
 | 
					 | 
				
			||||||
    (2173, "accrued interest payable", "應付利息", "应付利息"),
 | 
					 | 
				
			||||||
    (2174, "accrued VAT payable", "應付營業稅", "应付营业税"),
 | 
					 | 
				
			||||||
    (2175, "accrued taxes payable – other", "應付稅捐—其他", "应付税捐—其他"),
 | 
					 | 
				
			||||||
    (2178, "other accrued expenses payable", "其他應付費用", "其他应付费用"),
 | 
					 | 
				
			||||||
    (2181, "forward exchange contract payable", "應付購入遠匯款", "应付购入远汇款"),
 | 
					 | 
				
			||||||
    (2182, "forward exchange contract payable – foreign currencies",
 | 
					 | 
				
			||||||
     "應付遠匯款—外幣", "应付远汇款—外币"),
 | 
					 | 
				
			||||||
    (2183, "premium on forward exchange contract", "買賣遠匯溢價", "买卖远汇溢价"),
 | 
					 | 
				
			||||||
    (2184, "payables on land and building purchased", "應付土地房屋款",
 | 
					 | 
				
			||||||
     "应付土地房屋款"),
 | 
					 | 
				
			||||||
    (2185, "Payables on equipment", "應付設備款", "应付设备款"),
 | 
					 | 
				
			||||||
    (2187, "other payables – related parties", "其他應付款—關係人",
 | 
					 | 
				
			||||||
     "其他应付款—关系人"),
 | 
					 | 
				
			||||||
    (2191, "dividend payable", "應付股利", "应付股利"),
 | 
					 | 
				
			||||||
    (2192, "bonus payable", "應付紅利", "应付红利"),
 | 
					 | 
				
			||||||
    (2193, "compensation payable to directors and supervisors", "應付董監事酬勞",
 | 
					 | 
				
			||||||
     "应付董监事酬劳"),
 | 
					 | 
				
			||||||
    (2198, "other payables – other", "其他應付款—其他", "其他应付款—其他"),
 | 
					 | 
				
			||||||
    (2261, "sales revenue received in advance", "預收貨款", "预收货款"),
 | 
					 | 
				
			||||||
    (2262, "revenue received in advance", "預收收入", "预收收入"),
 | 
					 | 
				
			||||||
    (2268, "other advance receipts", "其他預收款", "其他预收款"),
 | 
					 | 
				
			||||||
    (2271, "corporate bonds payable – current portion",
 | 
					 | 
				
			||||||
     "一年或一營業週期內到期公司債", "一年或一营业周期内到期公司债"),
 | 
					 | 
				
			||||||
    (2272, "long-term loans payable – current portion",
 | 
					 | 
				
			||||||
     "一年或一營業週期內到期長期借款", "一年或一营业周期内到期长期借款"),
 | 
					 | 
				
			||||||
    (2273,
 | 
					 | 
				
			||||||
     "long-term notes and accounts payable due within one year or one"
 | 
					 | 
				
			||||||
     " operating cycle",
 | 
					 | 
				
			||||||
     "一年或一營業週期內到期長期應付票據及款項",
 | 
					 | 
				
			||||||
     "一年或一营业周期内到期长期应付票据及款项"),
 | 
					 | 
				
			||||||
    (2277,
 | 
					 | 
				
			||||||
     "long-term notes and accounts payables to related parties – current"
 | 
					 | 
				
			||||||
     " portion",
 | 
					 | 
				
			||||||
     "一年或一營業週期內到期長期應付票據及款項—關係人",
 | 
					 | 
				
			||||||
     "一年或一营业周期内到期长期应付票据及款项—关系人"),
 | 
					 | 
				
			||||||
    (2278, "other long-term liabilities – current portion",
 | 
					 | 
				
			||||||
     "其他一年或一營業週期內到期長期負債", "其他一年或一营业周期内到期长期负债"),
 | 
					 | 
				
			||||||
    (2281, "VAT received (or output tax)", "銷項稅額", "销项税额"),
 | 
					 | 
				
			||||||
    (2283, "temporary receipts", "暫收款", "暂收款"),
 | 
					 | 
				
			||||||
    (2284, "receipts under custody", "代收款", "代收款"),
 | 
					 | 
				
			||||||
    (2285, "estimated warranty liabilities", "估計售後服務/保固負債",
 | 
					 | 
				
			||||||
     "估计售后服务/保固负债"),
 | 
					 | 
				
			||||||
    (2291, "deferred income tax liabilities", "遞延所得稅負債", "递延所得税负债"),
 | 
					 | 
				
			||||||
    (2292, "deferred foreign exchange gain", "遞延兌換利益", "递延兑换利益"),
 | 
					 | 
				
			||||||
    (2293, "owners’ current account", "業主(股東)往來", "业主(股东)往来"),
 | 
					 | 
				
			||||||
    (2294, "current account with others", "同業往來", "同业往来"),
 | 
					 | 
				
			||||||
    (2298, "other current liabilities – others", "其他流動負債—其他",
 | 
					 | 
				
			||||||
     "其他流动负债—其他"),
 | 
					 | 
				
			||||||
    (2311, "corporate bonds payable", "應付公司債", "应付公司债"),
 | 
					 | 
				
			||||||
    (2319, "premium (discount) on corporate bonds payable",
 | 
					 | 
				
			||||||
     "應付公司債溢(折)價", "应付公司债溢(折)价"),
 | 
					 | 
				
			||||||
    (2321, "long-term loans payable – bank", "長期銀行借款", "长期银行借款"),
 | 
					 | 
				
			||||||
    (2324, "long-term loans payable – owners", "長期借款—業主", "长期借款—业主"),
 | 
					 | 
				
			||||||
    (2325, "long-term loans payable – employees", "長期借款—員工",
 | 
					 | 
				
			||||||
     "长期借款—员工"),
 | 
					 | 
				
			||||||
    (2327, "long-term loans payable – related parties", "長期借款—關係人",
 | 
					 | 
				
			||||||
     "长期借款—关系人"),
 | 
					 | 
				
			||||||
    (2328, "long-term loans payable – other", "長期借款—其他", "长期借款—其他"),
 | 
					 | 
				
			||||||
    (2331, "long-term notes payable", "長期應付票據", "长期应付票据"),
 | 
					 | 
				
			||||||
    (2332, "long-term accounts pay-able", "長期應付帳款", "长期应付帐款"),
 | 
					 | 
				
			||||||
    (2333, "long-term capital lease liabilities", "長期應付租賃負債",
 | 
					 | 
				
			||||||
     "长期应付租赁负债"),
 | 
					 | 
				
			||||||
    (2337, "Long-term notes and accounts payable – related parties",
 | 
					 | 
				
			||||||
     "長期應付票據及款項—關係人", "长期应付票据及款项—关系人"),
 | 
					 | 
				
			||||||
    (2338, "other long-term payables", "其他長期應付款項", "其他长期应付款项"),
 | 
					 | 
				
			||||||
    (2341, "estimated accrued land value incremental tax pay-able",
 | 
					 | 
				
			||||||
     "估計應付土地增值稅", "估计应付土地增值税"),
 | 
					 | 
				
			||||||
    (2351, "accrued pension liabilities", "應計退休金負債", "应计退休金负债"),
 | 
					 | 
				
			||||||
    (2388, "other long-term liabilities – other", "其他長期負債—其他",
 | 
					 | 
				
			||||||
     "其他长期负债—其他"),
 | 
					 | 
				
			||||||
    (2811, "deferred revenue", "遞延收入", "递延收入"),
 | 
					 | 
				
			||||||
    (2814, "deferred income tax liabilities", "遞延所得稅負債", "递延所得税负债"),
 | 
					 | 
				
			||||||
    (2818, "other deferred liabilities", "其他遞延負債", "其他递延负债"),
 | 
					 | 
				
			||||||
    (2861, "guarantee deposit received", "存入保證金", "存入保证金"),
 | 
					 | 
				
			||||||
    (2888, "miscellaneous liabilities – other", "雜項負債—其他", "杂项负债—其他"),
 | 
					 | 
				
			||||||
    (3111, "capital – common stock", "普通股股本", "普通股股本"),
 | 
					 | 
				
			||||||
    (3112, "capital – preferred stock", "特別股股本", "特别股股本"),
 | 
					 | 
				
			||||||
    (3113, "capital collected in advance", "預收股本", "预收股本"),
 | 
					 | 
				
			||||||
    (3114, "stock dividends to be distributed", "待分配股票股利",
 | 
					 | 
				
			||||||
     "待分配股票股利"),
 | 
					 | 
				
			||||||
    (3115, "capital", "資本", "资本"),
 | 
					 | 
				
			||||||
    (3211, "paid-in capital in excess of par- common stock", "普通股股票溢價",
 | 
					 | 
				
			||||||
     "普通股股票溢价"),
 | 
					 | 
				
			||||||
    (3212, "paid-in capital in excess of par- preferred stock", "特別股股票溢價",
 | 
					 | 
				
			||||||
     "特别股股票溢价"),
 | 
					 | 
				
			||||||
    (3231, "capital surplus from assets revaluation", "資產重估增值準備",
 | 
					 | 
				
			||||||
     "资产重估增值准备"),
 | 
					 | 
				
			||||||
    (3241, "capital surplus from gain on disposal of assets", "處分資產溢價公積",
 | 
					 | 
				
			||||||
     "处分资产溢价公积"),
 | 
					 | 
				
			||||||
    (3251, "capital surplus from business combination", "合併公積", "合并公积"),
 | 
					 | 
				
			||||||
    (3261, "donated surplus", "受贈公積", "受赠公积"),
 | 
					 | 
				
			||||||
    (3281, "additional paid-in capital from investee under equity method",
 | 
					 | 
				
			||||||
     "權益法長期股權投資資本公積", "权益法长期股权投资资本公积"),
 | 
					 | 
				
			||||||
    (3282, "additional paid-in capital – treasury stock trans-actions",
 | 
					 | 
				
			||||||
     "資本公積—庫藏股票交易", "资本公积—库藏股票交易"),
 | 
					 | 
				
			||||||
    (3311, "legal reserve", "法定盈餘公積", "法定盈余公积"),
 | 
					 | 
				
			||||||
    (3321, "contingency reserve", "意外損失準備", "意外损失准备"),
 | 
					 | 
				
			||||||
    (3322, "improvement and expansion reserve", "改良擴充準備", "改良扩充准备"),
 | 
					 | 
				
			||||||
    (3323, "special reserve for redemption of liabilities", "償債準備",
 | 
					 | 
				
			||||||
     "偿债准备"),
 | 
					 | 
				
			||||||
    (3328, "other special reserve", "其他特別盈餘公積", "其他特别盈余公积"),
 | 
					 | 
				
			||||||
    (3351, "accumulated profit or loss", "累積盈虧", "累积盈亏"),
 | 
					 | 
				
			||||||
    (3352, "prior period adjustments", "前期損益調整", "前期损益调整"),
 | 
					 | 
				
			||||||
    (3353, "net income or loss for current period", "本期損益", "本期损益"),
 | 
					 | 
				
			||||||
    (3411,
 | 
					 | 
				
			||||||
     "unrealized loss on market value decline of long-term equity investments",
 | 
					 | 
				
			||||||
     "長期股權投資未實現跌價損失", "长期股权投资未实现跌价损失"),
 | 
					 | 
				
			||||||
    (3421, "cumulative translation adjustments", "累積換算調整數",
 | 
					 | 
				
			||||||
     "累积换算调整数"),
 | 
					 | 
				
			||||||
    (3431, "net loss not recognized as pension costs",
 | 
					 | 
				
			||||||
     "未認列為退休金成本之淨損失", "未认列为退休金成本之净损失"),
 | 
					 | 
				
			||||||
    (3511, "treasury stock", "庫藏股", "库藏股"),
 | 
					 | 
				
			||||||
    (3611, "minority interest", "少數股權", "少数股权"),
 | 
					 | 
				
			||||||
    (4111, "sales revenue", "銷貨收入", "销货收入"),
 | 
					 | 
				
			||||||
    (4112, "installment sales revenue", "分期付款銷貨收入", "分期付款销货收入"),
 | 
					 | 
				
			||||||
    (4171, "sales return", "銷貨退回", "销货退回"),
 | 
					 | 
				
			||||||
    (4191, "sales discounts and allowances", "銷貨折讓", "销货折让"),
 | 
					 | 
				
			||||||
    (4611, "service revenue", "勞務收入", "劳务收入"),
 | 
					 | 
				
			||||||
    (4711, "agency revenue", "業務收入", "业务收入"),
 | 
					 | 
				
			||||||
    (4888, "other operating revenue – other", "其他營業收入—其他",
 | 
					 | 
				
			||||||
     "其他营业收入—其他"),
 | 
					 | 
				
			||||||
    (5111, "cost of goods sold", "銷貨成本", "销货成本"),
 | 
					 | 
				
			||||||
    (5112, "installment cost of goods sold", "分期付款銷貨成本",
 | 
					 | 
				
			||||||
     "分期付款销货成本"),
 | 
					 | 
				
			||||||
    (5121, "purchases", "進貨", "进货"),
 | 
					 | 
				
			||||||
    (5122, "purchase expenses", "進貨費用", "进货费用"),
 | 
					 | 
				
			||||||
    (5123, "purchase returns", "進貨退出", "进货退出"),
 | 
					 | 
				
			||||||
    (5124, "charges on purchased merchandise", "進貨折讓", "进货折让"),
 | 
					 | 
				
			||||||
    (5131, "material purchased", "進料", "进料"),
 | 
					 | 
				
			||||||
    (5132, "charges on purchased material", "進料費用", "进料费用"),
 | 
					 | 
				
			||||||
    (5133, "material purchase returns", "進料退出", "进料退出"),
 | 
					 | 
				
			||||||
    (5134, "material purchase allowances", "進料折讓", "进料折让"),
 | 
					 | 
				
			||||||
    (5141, "direct labor", "直接人工", "直接人工"),
 | 
					 | 
				
			||||||
    (5151, "indirect labor", "間接人工", "间接人工"),
 | 
					 | 
				
			||||||
    (5152, "rent expense, rent", "租金支出", "租金支出"),
 | 
					 | 
				
			||||||
    (5153, "office supplies (expense)", "文具用品", "文具用品"),
 | 
					 | 
				
			||||||
    (5154, "travelling expense, travel", "旅費", "旅费"),
 | 
					 | 
				
			||||||
    (5155, "shipping expenses, freight", "運費", "运费"),
 | 
					 | 
				
			||||||
    (5156, "postage (expenses)", "郵電費", "邮电费"),
 | 
					 | 
				
			||||||
    (5157, "repair (s) and maintenance (expense )", "修繕費", "修缮费"),
 | 
					 | 
				
			||||||
    (5158, "packing expenses", "包裝費", "包装费"),
 | 
					 | 
				
			||||||
    (5161, "utilities (expense)", "水電瓦斯費", "水电瓦斯费"),
 | 
					 | 
				
			||||||
    (5162, "insurance (expense)", "保險費", "保险费"),
 | 
					 | 
				
			||||||
    (5163, "manufacturing overhead – outsourced", "加工費", "加工费"),
 | 
					 | 
				
			||||||
    (5166, "taxes", "稅捐", "税捐"),
 | 
					 | 
				
			||||||
    (5168, "depreciation expense", "折舊", "折旧"),
 | 
					 | 
				
			||||||
    (5169, "various amortization", "各項耗竭及攤提", "各项耗竭及摊提"),
 | 
					 | 
				
			||||||
    (5172, "meal (expenses)", "伙食費", "伙食费"),
 | 
					 | 
				
			||||||
    (5173, "employee benefits/welfare", "職工福利", "职工福利"),
 | 
					 | 
				
			||||||
    (5176, "training (expense)", "訓練費", "训练费"),
 | 
					 | 
				
			||||||
    (5177, "indirect materials", "間接材料", "间接材料"),
 | 
					 | 
				
			||||||
    (5188, "other manufacturing expenses", "其他製造費用", "其他制造费用"),
 | 
					 | 
				
			||||||
    (5611, "service costs", "勞務成本", "劳务成本"),
 | 
					 | 
				
			||||||
    (5711, "agency costs", "業務成本", "业务成本"),
 | 
					 | 
				
			||||||
    (5888, "other operating costs – other", "其他營業成本—其他",
 | 
					 | 
				
			||||||
     "其他营业成本—其他"),
 | 
					 | 
				
			||||||
    (6151, "payroll expense", "薪資支出", "薪资支出"),
 | 
					 | 
				
			||||||
    (6152, "rent expense, rent", "租金支出", "租金支出"),
 | 
					 | 
				
			||||||
    (6153, "office supplies (expense)", "文具用品", "文具用品"),
 | 
					 | 
				
			||||||
    (6154, "travelling expense, travel", "旅費", "旅费"),
 | 
					 | 
				
			||||||
    (6155, "shipping expenses, freight", "運費", "运费"),
 | 
					 | 
				
			||||||
    (6156, "postage (expenses)", "郵電費", "邮电费"),
 | 
					 | 
				
			||||||
    (6157, "repair (s) and maintenance (expense)", "修繕費", "修缮费"),
 | 
					 | 
				
			||||||
    (6159, "advertisement expense, advertisement", "廣告費", "广告费"),
 | 
					 | 
				
			||||||
    (6161, "utilities (expense)", "水電瓦斯費", "水电瓦斯费"),
 | 
					 | 
				
			||||||
    (6162, "insurance (expense)", "保險費", "保险费"),
 | 
					 | 
				
			||||||
    (6164, "entertainment (expense)", "交際費", "交际费"),
 | 
					 | 
				
			||||||
    (6165, "donation (expense)", "捐贈", "捐赠"),
 | 
					 | 
				
			||||||
    (6166, "taxes", "稅捐", "税捐"),
 | 
					 | 
				
			||||||
    (6167, "loss on uncollectible accounts", "呆帳損失", "呆帐损失"),
 | 
					 | 
				
			||||||
    (6168, "depreciation expense", "折舊", "折旧"),
 | 
					 | 
				
			||||||
    (6169, "various amortization", "各項耗竭及攤提", "各项耗竭及摊提"),
 | 
					 | 
				
			||||||
    (6172, "meal (expenses)", "伙食費", "伙食费"),
 | 
					 | 
				
			||||||
    (6173, "employee benefits/welfare", "職工福利", "职工福利"),
 | 
					 | 
				
			||||||
    (6175, "commission (expense)", "佣金支出", "佣金支出"),
 | 
					 | 
				
			||||||
    (6176, "training (expense)", "訓練費", "训练费"),
 | 
					 | 
				
			||||||
    (6188, "other selling expenses", "其他推銷費用", "其他推销费用"),
 | 
					 | 
				
			||||||
    (6251, "payroll expense", "薪資支出", "薪资支出"),
 | 
					 | 
				
			||||||
    (6252, "rent expense, rent", "租金支出", "租金支出"),
 | 
					 | 
				
			||||||
    (6253, "office supplies", "文具用品", "文具用品"),
 | 
					 | 
				
			||||||
    (6254, "travelling expense, travel", "旅費", "旅费"),
 | 
					 | 
				
			||||||
    (6255, "shipping expenses,freight", "運費", "运费"),
 | 
					 | 
				
			||||||
    (6256, "postage (expenses)", "郵電費", "邮电费"),
 | 
					 | 
				
			||||||
    (6257, "repair (s) and maintenance (expense)", "修繕費", "修缮费"),
 | 
					 | 
				
			||||||
    (6259, "advertisement expense, advertisement", "廣告費", "广告费"),
 | 
					 | 
				
			||||||
    (6261, "utilities (expense)", "水電瓦斯費", "水电瓦斯费"),
 | 
					 | 
				
			||||||
    (6262, "insurance (expense)", "保險費", "保险费"),
 | 
					 | 
				
			||||||
    (6264, "entertainment (expense)", "交際費", "交际费"),
 | 
					 | 
				
			||||||
    (6265, "donation (expense)", "捐贈", "捐赠"),
 | 
					 | 
				
			||||||
    (6266, "taxes", "稅捐", "税捐"),
 | 
					 | 
				
			||||||
    (6267, "loss on uncollectible accounts", "呆帳損失", "呆帐损失"),
 | 
					 | 
				
			||||||
    (6268, "depreciation expense", "折舊", "折旧"),
 | 
					 | 
				
			||||||
    (6269, "various amortization", "各項耗竭及攤提", "各项耗竭及摊提"),
 | 
					 | 
				
			||||||
    (6271, "loss on export sales", "外銷損失", "外销损失"),
 | 
					 | 
				
			||||||
    (6272, "meal (expenses)", "伙食費", "伙食费"),
 | 
					 | 
				
			||||||
    (6273, "employee benefits/welfare", "職工福利", "职工福利"),
 | 
					 | 
				
			||||||
    (6274, "research and development expense", "研究發展費用", "研究发展费用"),
 | 
					 | 
				
			||||||
    (6275, "commission (expense)", "佣金支出", "佣金支出"),
 | 
					 | 
				
			||||||
    (6276, "training (expense)", "訓練費", "训练费"),
 | 
					 | 
				
			||||||
    (6278, "professional service fees", "勞務費", "劳务费"),
 | 
					 | 
				
			||||||
    (6288, "other general and administrative expenses", "其他管理及總務費用",
 | 
					 | 
				
			||||||
     "其他管理及总务费用"),
 | 
					 | 
				
			||||||
    (6351, "payroll expense", "薪資支出", "薪资支出"),
 | 
					 | 
				
			||||||
    (6352, "rent expense, rent", "租金支出", "租金支出"),
 | 
					 | 
				
			||||||
    (6353, "office supplies", "文具用品", "文具用品"),
 | 
					 | 
				
			||||||
    (6354, "travelling expense, travel", "旅費", "旅费"),
 | 
					 | 
				
			||||||
    (6355, "shipping expenses, freight", "運費", "运费"),
 | 
					 | 
				
			||||||
    (6356, "postage (expenses)", "郵電費", "邮电费"),
 | 
					 | 
				
			||||||
    (6357, "repair (s) and maintenance (expense)", "修繕費", "修缮费"),
 | 
					 | 
				
			||||||
    (6361, "utilities (expense)", "水電瓦斯費", "水电瓦斯费"),
 | 
					 | 
				
			||||||
    (6362, "insurance (expense)", "保險費", "保险费"),
 | 
					 | 
				
			||||||
    (6364, "entertainment (expense)", "交際費", "交际费"),
 | 
					 | 
				
			||||||
    (6366, "taxes", "稅捐", "税捐"),
 | 
					 | 
				
			||||||
    (6368, "depreciation expense", "折舊", "折旧"),
 | 
					 | 
				
			||||||
    (6369, "various amortization", "各項耗竭及攤提", "各项耗竭及摊提"),
 | 
					 | 
				
			||||||
    (6372, "meal (expenses)", "伙食費", "伙食费"),
 | 
					 | 
				
			||||||
    (6373, "employee benefits/welfare", "職工福利", "职工福利"),
 | 
					 | 
				
			||||||
    (6376, "training (expense)", "訓練費", "训练费"),
 | 
					 | 
				
			||||||
    (6378, "other research and development expenses", "其他研究發展費用",
 | 
					 | 
				
			||||||
     "其他研究发展费用"),
 | 
					 | 
				
			||||||
    (7111, "interest revenue/income", "利息收入", "利息收入"),
 | 
					 | 
				
			||||||
    (7121, "investment income recognized under equity method",
 | 
					 | 
				
			||||||
     "權益法認列之投資收益", "权益法认列之投资收益"),
 | 
					 | 
				
			||||||
    (7122, "dividends income", "股利收入", "股利收入"),
 | 
					 | 
				
			||||||
    (7123, "gain on market price recovery of short-term investment",
 | 
					 | 
				
			||||||
     "短期投資市價回升利益", "短期投资市价回升利益"),
 | 
					 | 
				
			||||||
    (7131, "foreign exchange gain", "兌換利益", "兑换利益"),
 | 
					 | 
				
			||||||
    (7141, "gain on disposal of investments", "處分投資收益", "处分投资收益"),
 | 
					 | 
				
			||||||
    (7151, "gain on disposal of assets", "處分資產溢價收入", "处分资产溢价收入"),
 | 
					 | 
				
			||||||
    (7481, "donation income", "捐贈收入", "捐赠收入"),
 | 
					 | 
				
			||||||
    (7482, "rent revenue/income", "租金收入", "租金收入"),
 | 
					 | 
				
			||||||
    (7483, "commission revenue/income", "佣金收入", "佣金收入"),
 | 
					 | 
				
			||||||
    (7484, "revenue from sale of scraps", "出售下腳及廢料收入",
 | 
					 | 
				
			||||||
     "出售下脚及废料收入"),
 | 
					 | 
				
			||||||
    (7485, "gain on physical inventory", "存貨盤盈", "存货盘盈"),
 | 
					 | 
				
			||||||
    (7486, "gain from price recovery of inventory", "存貨跌價回升利益",
 | 
					 | 
				
			||||||
     "存货跌价回升利益"),
 | 
					 | 
				
			||||||
    (7487, "gain on reversal of bad debts", "壞帳轉回利益", "坏帐转回利益"),
 | 
					 | 
				
			||||||
    (7488, "other non-operating revenue – other items", "其他營業外收入—其他",
 | 
					 | 
				
			||||||
     "其他营业外收入—其他"),
 | 
					 | 
				
			||||||
    (7511, "interest expense", "利息費用", "利息费用"),
 | 
					 | 
				
			||||||
    (7521, "investment loss recognized under equity method",
 | 
					 | 
				
			||||||
     "權益法認列之投資損失", "权益法认列之投资损失"),
 | 
					 | 
				
			||||||
    (7523, "unrealized loss on reduction of short-term investments to market",
 | 
					 | 
				
			||||||
     "短期投資未實現跌價損失", "短期投资未实现跌价损失"),
 | 
					 | 
				
			||||||
    (7531, "foreign exchange loss", "兌換損失", "兑换损失"),
 | 
					 | 
				
			||||||
    (7541, "loss on disposal of investments", "處分投資損失", "处分投资损失"),
 | 
					 | 
				
			||||||
    (7551, "loss on disposal of assets", "處分資產損失", "处分资产损失"),
 | 
					 | 
				
			||||||
    (7881, "loss on work stoppages", "停工損失", "停工损失"),
 | 
					 | 
				
			||||||
    (7882, "casualty loss", "災害損失", "灾害损失"),
 | 
					 | 
				
			||||||
    (7885, "loss on physical inventory", "存貨盤損", "存货盘损"),
 | 
					 | 
				
			||||||
    (7886,
 | 
					 | 
				
			||||||
     "loss for market price decline and obsolete and slow-moving inventories",
 | 
					 | 
				
			||||||
     "存貨跌價及呆滯損失", "存货跌价及呆滞损失"),
 | 
					 | 
				
			||||||
    (7888, "other non-operating expenses – other", "其他營業外費用—其他",
 | 
					 | 
				
			||||||
     "其他营业外费用—其他"),
 | 
					 | 
				
			||||||
    (8111, "income tax expense ( or benefit)", "所得稅費用(或利益)",
 | 
					 | 
				
			||||||
     "所得税费用(或利益)"),
 | 
					 | 
				
			||||||
    (9111, "income (loss) from operations of discontinued segment",
 | 
					 | 
				
			||||||
     "停業部門損益—停業前營業損益", "停业部门损益—停业前营业损益"),
 | 
					 | 
				
			||||||
    (9121, "gain (loss) from disposal of discontinued segment",
 | 
					 | 
				
			||||||
     "停業部門損益—處分損益", "停业部门损益—处分损益"),
 | 
					 | 
				
			||||||
    (9211, "extraordinary gain or loss", "非常損益", "非常损益"),
 | 
					 | 
				
			||||||
    (9311, "cumulative effect of changes in accounting principles",
 | 
					 | 
				
			||||||
     "會計原則變動累積影響數", "会计原则变动累积影响数"),
 | 
					 | 
				
			||||||
    (9411, "minority interest income", "少數股權淨利", "少数股权净利"),
 | 
					 | 
				
			||||||
]
 | 
					 | 
				
			||||||
"""The base account data."""
 | 
					 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										48
									
								
								src/accounting/base_account/converters.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								src/accounting/base_account/converters.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,48 @@
 | 
				
			|||||||
 | 
					# The Mia! Accounting Flask Project.
 | 
				
			||||||
 | 
					# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#  Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					#  you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					#  You may obtain a copy of the License at
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#      http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					#  distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					#  See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					#  limitations under the License.
 | 
				
			||||||
 | 
					"""The path converters for the base account management.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					from flask import abort
 | 
				
			||||||
 | 
					from werkzeug.routing import BaseConverter
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from accounting.database import db
 | 
				
			||||||
 | 
					from accounting.models import BaseAccount
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class BaseAccountConverter(BaseConverter):
 | 
				
			||||||
 | 
					    """The account converter to convert the account code and to the
 | 
				
			||||||
 | 
					    corresponding base account in the routes."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def to_python(self, value: str) -> BaseAccount:
 | 
				
			||||||
 | 
					        """Converts an account code to a base account.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param value: The account code.
 | 
				
			||||||
 | 
					        :return: The corresponding base account.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        account: BaseAccount | None = db.session.get(BaseAccount, value)
 | 
				
			||||||
 | 
					        if account is None:
 | 
				
			||||||
 | 
					            abort(404)
 | 
				
			||||||
 | 
					        return account
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def to_url(self, value: BaseAccount) -> str:
 | 
				
			||||||
 | 
					        """Converts a base account to its code.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param value: The base account.
 | 
				
			||||||
 | 
					        :return: The code.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return value.code
 | 
				
			||||||
@@ -1,73 +0,0 @@
 | 
				
			|||||||
# The Mia! Accounting Flask Project.
 | 
					 | 
				
			||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#  Copyright (c) 2023 imacat.
 | 
					 | 
				
			||||||
#
 | 
					 | 
				
			||||||
#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
					 | 
				
			||||||
#  you may not use this file except in compliance with the License.
 | 
					 | 
				
			||||||
#  You may obtain a copy of the License at
 | 
					 | 
				
			||||||
#
 | 
					 | 
				
			||||||
#      http://www.apache.org/licenses/LICENSE-2.0
 | 
					 | 
				
			||||||
#
 | 
					 | 
				
			||||||
#  Unless required by applicable law or agreed to in writing, software
 | 
					 | 
				
			||||||
#  distributed under the License is distributed on an "AS IS" BASIS,
 | 
					 | 
				
			||||||
#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
					 | 
				
			||||||
#  See the License for the specific language governing permissions and
 | 
					 | 
				
			||||||
#  limitations under the License.
 | 
					 | 
				
			||||||
"""The data models for the base account management.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
"""
 | 
					 | 
				
			||||||
from flask import current_app
 | 
					 | 
				
			||||||
from flask_babel import get_locale
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from accounting.database import db
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class BaseAccount(db.Model):
 | 
					 | 
				
			||||||
    """A base account."""
 | 
					 | 
				
			||||||
    __tablename__ = "accounting_base_accounts"
 | 
					 | 
				
			||||||
    """The table name."""
 | 
					 | 
				
			||||||
    code = db.Column(db.String, nullable=False, primary_key=True)
 | 
					 | 
				
			||||||
    """The code."""
 | 
					 | 
				
			||||||
    title_l10n = db.Column("title", db.String, nullable=False)
 | 
					 | 
				
			||||||
    """The title."""
 | 
					 | 
				
			||||||
    l10n = db.relationship("BaseAccountL10n", back_populates="account",
 | 
					 | 
				
			||||||
                           lazy=False)
 | 
					 | 
				
			||||||
    """The localized titles."""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def __str__(self) -> str:
 | 
					 | 
				
			||||||
        """Returns the string representation of the base account.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        :return: The string representation of the base account.
 | 
					 | 
				
			||||||
        """
 | 
					 | 
				
			||||||
        return F"{self.code} {self.title}"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @property
 | 
					 | 
				
			||||||
    def title(self) -> str:
 | 
					 | 
				
			||||||
        """Returns the title in the current locale.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        :return: The title in the current locale.
 | 
					 | 
				
			||||||
        """
 | 
					 | 
				
			||||||
        current_locale = str(get_locale())
 | 
					 | 
				
			||||||
        if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
 | 
					 | 
				
			||||||
            return self.title_l10n
 | 
					 | 
				
			||||||
        for l10n in self.l10n:
 | 
					 | 
				
			||||||
            if l10n.locale == current_locale:
 | 
					 | 
				
			||||||
                return l10n.title
 | 
					 | 
				
			||||||
        return self.title_l10n
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class BaseAccountL10n(db.Model):
 | 
					 | 
				
			||||||
    """A localized base account title."""
 | 
					 | 
				
			||||||
    __tablename__ = "accounting_base_accounts_l10n"
 | 
					 | 
				
			||||||
    """The table name."""
 | 
					 | 
				
			||||||
    account_code = db.Column(db.String, db.ForeignKey(BaseAccount.code,
 | 
					 | 
				
			||||||
                                                      ondelete="CASCADE"),
 | 
					 | 
				
			||||||
                             nullable=False, primary_key=True)
 | 
					 | 
				
			||||||
    """The code of the account."""
 | 
					 | 
				
			||||||
    account = db.relationship(BaseAccount, back_populates="l10n")
 | 
					 | 
				
			||||||
    """The account."""
 | 
					 | 
				
			||||||
    locale = db.Column(db.String, nullable=False, primary_key=True)
 | 
					 | 
				
			||||||
    """The locale."""
 | 
					 | 
				
			||||||
    title = db.Column(db.String, nullable=False)
 | 
					 | 
				
			||||||
    """The localized title."""
 | 
					 | 
				
			||||||
@@ -20,8 +20,8 @@
 | 
				
			|||||||
import sqlalchemy as sa
 | 
					import sqlalchemy as sa
 | 
				
			||||||
from flask import request
 | 
					from flask import request
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from accounting.models import BaseAccount, BaseAccountL10n
 | 
				
			||||||
from accounting.utils.query import parse_query_keywords
 | 
					from accounting.utils.query import parse_query_keywords
 | 
				
			||||||
from .models import BaseAccount, BaseAccountL10n
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def get_base_account_query() -> list[BaseAccount]:
 | 
					def get_base_account_query() -> list[BaseAccount]:
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -19,6 +19,7 @@
 | 
				
			|||||||
"""
 | 
					"""
 | 
				
			||||||
from flask import Blueprint, render_template
 | 
					from flask import Blueprint, render_template
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from accounting.models import BaseAccount
 | 
				
			||||||
from accounting.utils.pagination import Pagination
 | 
					from accounting.utils.pagination import Pagination
 | 
				
			||||||
from accounting.utils.permission import has_permission, can_view
 | 
					from accounting.utils.permission import has_permission, can_view
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -33,9 +34,20 @@ def list_accounts() -> str:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    :return: The account list.
 | 
					    :return: The account list.
 | 
				
			||||||
    """
 | 
					    """
 | 
				
			||||||
    from .models import BaseAccount
 | 
					 | 
				
			||||||
    from .query import get_base_account_query
 | 
					    from .query import get_base_account_query
 | 
				
			||||||
    accounts: list[BaseAccount] = get_base_account_query()
 | 
					    accounts: list[BaseAccount] = get_base_account_query()
 | 
				
			||||||
    pagination: Pagination = Pagination[BaseAccount](accounts)
 | 
					    pagination: Pagination = Pagination[BaseAccount](accounts)
 | 
				
			||||||
    return render_template("accounting/base-account/list.html",
 | 
					    return render_template("accounting/base-account/list.html",
 | 
				
			||||||
                           list=pagination.list, pagination=pagination)
 | 
					                           list=pagination.list, pagination=pagination)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@bp.get("/<baseAccount:account>", endpoint="detail")
 | 
				
			||||||
 | 
					@has_permission(can_view)
 | 
				
			||||||
 | 
					def show_account_detail(account: BaseAccount) -> str:
 | 
				
			||||||
 | 
					    """Shows the account detail.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :param account: The account.
 | 
				
			||||||
 | 
					    :return: The detail.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    return render_template("accounting/base-account/detail.html", obj=account)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										38
									
								
								src/accounting/currency/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								src/accounting/currency/__init__.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,38 @@
 | 
				
			|||||||
 | 
					# The Mia! Accounting Flask Project.
 | 
				
			||||||
 | 
					# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#  Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					#  you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					#  You may obtain a copy of the License at
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#      http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					#  distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					#  See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					#  limitations under the License.
 | 
				
			||||||
 | 
					"""The currency management.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					from flask import Flask, Blueprint
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def init_app(app: Flask, bp: Blueprint) -> None:
 | 
				
			||||||
 | 
					    """Initialize the application.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :param app: The Flask application.
 | 
				
			||||||
 | 
					    :param bp: The blueprint of the accounting application.
 | 
				
			||||||
 | 
					    :return: None.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    from .converters import CurrencyConverter
 | 
				
			||||||
 | 
					    app.url_map.converters["currency"] = CurrencyConverter
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    from .views import bp as currency_bp, api_bp as currency_api_bp
 | 
				
			||||||
 | 
					    bp.register_blueprint(currency_bp, url_prefix="/currencies")
 | 
				
			||||||
 | 
					    bp.register_blueprint(currency_api_bp, url_prefix="/api/currencies")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    from .commands import init_currencies_command
 | 
				
			||||||
 | 
					    app.cli.add_command(init_currencies_command)
 | 
				
			||||||
							
								
								
									
										85
									
								
								src/accounting/currency/commands.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										85
									
								
								src/accounting/currency/commands.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,85 @@
 | 
				
			|||||||
 | 
					# The Mia! Accounting Flask Project.
 | 
				
			||||||
 | 
					# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#  Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					#  you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					#  You may obtain a copy of the License at
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#      http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					#  distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					#  See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					#  limitations under the License.
 | 
				
			||||||
 | 
					"""The console commands for the currency management.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					import csv
 | 
				
			||||||
 | 
					import os
 | 
				
			||||||
 | 
					import typing as t
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import click
 | 
				
			||||||
 | 
					from flask.cli import with_appcontext
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from accounting import data_dir
 | 
				
			||||||
 | 
					from accounting.database import db
 | 
				
			||||||
 | 
					from accounting.models import Currency, CurrencyL10n
 | 
				
			||||||
 | 
					from accounting.utils.user import has_user, get_user_pk
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					CurrencyData = tuple[str, str, str, str]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def __validate_username(ctx: click.core.Context, param: click.core.Option,
 | 
				
			||||||
 | 
					                        value: str) -> str:
 | 
				
			||||||
 | 
					    """Validates the username for the click console command.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :param ctx: The console command context.
 | 
				
			||||||
 | 
					    :param param: The console command option.
 | 
				
			||||||
 | 
					    :param value: The username.
 | 
				
			||||||
 | 
					    :raise click.BadParameter: When validation fails.
 | 
				
			||||||
 | 
					    :return: The username.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    value = value.strip()
 | 
				
			||||||
 | 
					    if value == "":
 | 
				
			||||||
 | 
					        raise click.BadParameter("Username empty.")
 | 
				
			||||||
 | 
					    if not has_user(value):
 | 
				
			||||||
 | 
					        raise click.BadParameter(f"User {value} does not exist.")
 | 
				
			||||||
 | 
					    return value
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@click.command("accounting-init-currencies")
 | 
				
			||||||
 | 
					@click.option("-u", "--username", metavar="USERNAME", prompt=True,
 | 
				
			||||||
 | 
					              help="The username.", callback=__validate_username,
 | 
				
			||||||
 | 
					              default=lambda: os.getlogin())
 | 
				
			||||||
 | 
					@with_appcontext
 | 
				
			||||||
 | 
					def init_currencies_command(username: str) -> None:
 | 
				
			||||||
 | 
					    """Initializes the currencies."""
 | 
				
			||||||
 | 
					    existing_codes: set[str] = {x.code for x in Currency.query.all()}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    with open(data_dir / "currencies.csv") as fh:
 | 
				
			||||||
 | 
					        data: list[dict[str, str]] = [x for x in csv.DictReader(fh)]
 | 
				
			||||||
 | 
					    to_add: list[dict[str, str]] = [x for x in data
 | 
				
			||||||
 | 
					                                    if x["code"] not in existing_codes]
 | 
				
			||||||
 | 
					    if len(to_add) == 0:
 | 
				
			||||||
 | 
					        click.echo("No more currency to add.")
 | 
				
			||||||
 | 
					        return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    creator_pk: int = get_user_pk(username)
 | 
				
			||||||
 | 
					    currency_data: list[dict[str, t.Any]] = [{"code": x["code"],
 | 
				
			||||||
 | 
					                                              "name_l10n": x["name"],
 | 
				
			||||||
 | 
					                                              "created_by_id": creator_pk,
 | 
				
			||||||
 | 
					                                              "updated_by_id": creator_pk}
 | 
				
			||||||
 | 
					                                             for x in to_add]
 | 
				
			||||||
 | 
					    locales: list[str] = [x[5:] for x in to_add[0] if x.startswith("l10n-")]
 | 
				
			||||||
 | 
					    l10n_data: list[dict[str, str]] = [{"currency_code": x["code"],
 | 
				
			||||||
 | 
					                                        "locale": y,
 | 
				
			||||||
 | 
					                                        "name": x[f"l10n-{y}"]}
 | 
				
			||||||
 | 
					                                       for x in to_add for y in locales]
 | 
				
			||||||
 | 
					    db.session.bulk_insert_mappings(Currency, currency_data)
 | 
				
			||||||
 | 
					    db.session.bulk_insert_mappings(CurrencyL10n, l10n_data)
 | 
				
			||||||
 | 
					    db.session.commit()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    click.echo(F"{len(to_add)} added.  Currencies initialized.")
 | 
				
			||||||
							
								
								
									
										48
									
								
								src/accounting/currency/converters.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								src/accounting/currency/converters.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,48 @@
 | 
				
			|||||||
 | 
					# The Mia! Accounting Flask Project.
 | 
				
			||||||
 | 
					# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#  Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					#  you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					#  You may obtain a copy of the License at
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#      http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					#  distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					#  See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					#  limitations under the License.
 | 
				
			||||||
 | 
					"""The path converters for the currency management.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					from flask import abort
 | 
				
			||||||
 | 
					from werkzeug.routing import BaseConverter
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from accounting.database import db
 | 
				
			||||||
 | 
					from accounting.models import Currency
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class CurrencyConverter(BaseConverter):
 | 
				
			||||||
 | 
					    """The currency converter to convert the currency code and to the
 | 
				
			||||||
 | 
					    corresponding currency in the routes."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def to_python(self, value: str) -> Currency:
 | 
				
			||||||
 | 
					        """Converts a currency code to a currency.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param value: The currency code.
 | 
				
			||||||
 | 
					        :return: The corresponding currency.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        currency: Currency | None = db.session.get(Currency, value)
 | 
				
			||||||
 | 
					        if currency is None:
 | 
				
			||||||
 | 
					            abort(404)
 | 
				
			||||||
 | 
					        return currency
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def to_url(self, value: Currency) -> str:
 | 
				
			||||||
 | 
					        """Converts a currency to its code.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param value: The currency.
 | 
				
			||||||
 | 
					        :return: The code.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return value.code
 | 
				
			||||||
							
								
								
									
										93
									
								
								src/accounting/currency/forms.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										93
									
								
								src/accounting/currency/forms.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,93 @@
 | 
				
			|||||||
 | 
					# The Mia! Accounting Flask Project.
 | 
				
			||||||
 | 
					# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#  Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					#  you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					#  You may obtain a copy of the License at
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#      http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					#  distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					#  See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					#  limitations under the License.
 | 
				
			||||||
 | 
					"""The forms for the currency management.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					from __future__ import annotations
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import sqlalchemy as sa
 | 
				
			||||||
 | 
					from flask_wtf import FlaskForm
 | 
				
			||||||
 | 
					from wtforms import StringField, ValidationError
 | 
				
			||||||
 | 
					from wtforms.validators import DataRequired, Regexp, NoneOf
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from accounting.database import db
 | 
				
			||||||
 | 
					from accounting.locale import lazy_gettext
 | 
				
			||||||
 | 
					from accounting.models import Currency
 | 
				
			||||||
 | 
					from accounting.utils.strip_text import strip_text
 | 
				
			||||||
 | 
					from accounting.utils.user import get_current_user_pk
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class CurrencyForm(FlaskForm):
 | 
				
			||||||
 | 
					    """The form to create or edit a currency."""
 | 
				
			||||||
 | 
					    CODE_BLOCKLIST: list[str] = ["create", "store", "exists-code"]
 | 
				
			||||||
 | 
					    """The reserved codes that are not available."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    class CodeUnique:
 | 
				
			||||||
 | 
					        """The validator to check if the code is unique."""
 | 
				
			||||||
 | 
					        def __call__(self, form: CurrencyForm, field: StringField) -> None:
 | 
				
			||||||
 | 
					            if field.data == "":
 | 
				
			||||||
 | 
					                return
 | 
				
			||||||
 | 
					            if form.obj_code is not None and form.obj_code == field.data:
 | 
				
			||||||
 | 
					                return
 | 
				
			||||||
 | 
					            if db.session.get(Currency, field.data) is not None:
 | 
				
			||||||
 | 
					                raise ValidationError(lazy_gettext(
 | 
				
			||||||
 | 
					                    "Code conflicts with another currency."))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    code = StringField(
 | 
				
			||||||
 | 
					        filters=[strip_text],
 | 
				
			||||||
 | 
					        validators=[DataRequired(lazy_gettext("Please fill in the code.")),
 | 
				
			||||||
 | 
					                    Regexp(r"^[A-Z]{3}$",
 | 
				
			||||||
 | 
					                           message=lazy_gettext(
 | 
				
			||||||
 | 
					                               "Code can only be composed of 3 upper-cased"
 | 
				
			||||||
 | 
					                               " letters.")),
 | 
				
			||||||
 | 
					                    NoneOf(CODE_BLOCKLIST, message=lazy_gettext(
 | 
				
			||||||
 | 
					                        "This code is not available.")),
 | 
				
			||||||
 | 
					                    CodeUnique()])
 | 
				
			||||||
 | 
					    """The code.  It may not conflict with another currency."""
 | 
				
			||||||
 | 
					    name = StringField(
 | 
				
			||||||
 | 
					        filters=[strip_text],
 | 
				
			||||||
 | 
					        validators=[DataRequired(lazy_gettext("Please fill in the name."))])
 | 
				
			||||||
 | 
					    """The name."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, *args, **kwargs):
 | 
				
			||||||
 | 
					        super().__init__(*args, **kwargs)
 | 
				
			||||||
 | 
					        self.obj_code: str | None = None
 | 
				
			||||||
 | 
					        """The current code of the currency, or None when adding a new
 | 
				
			||||||
 | 
					        currency."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def populate_obj(self, obj: Currency) -> None:
 | 
				
			||||||
 | 
					        """Populates the form data into a currency object.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param obj: The currency object.
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        is_new: bool = obj.code is None
 | 
				
			||||||
 | 
					        obj.code = self.code.data
 | 
				
			||||||
 | 
					        obj.name = self.name.data
 | 
				
			||||||
 | 
					        if is_new:
 | 
				
			||||||
 | 
					            current_user_pk: int = get_current_user_pk()
 | 
				
			||||||
 | 
					            obj.created_by_id = current_user_pk
 | 
				
			||||||
 | 
					            obj.updated_by_id = current_user_pk
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def post_update(self, obj) -> None:
 | 
				
			||||||
 | 
					        """The post-processing after the update.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: None
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        current_user_pk: int = get_current_user_pk()
 | 
				
			||||||
 | 
					        obj.updated_by_id = current_user_pk
 | 
				
			||||||
 | 
					        obj.updated_at = sa.func.now()
 | 
				
			||||||
							
								
								
									
										44
									
								
								src/accounting/currency/query.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								src/accounting/currency/query.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,44 @@
 | 
				
			|||||||
 | 
					# The Mia! Accounting Flask Project.
 | 
				
			||||||
 | 
					# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#  Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					#  you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					#  You may obtain a copy of the License at
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#      http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					#  distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					#  See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					#  limitations under the License.
 | 
				
			||||||
 | 
					"""The currency query.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					import sqlalchemy as sa
 | 
				
			||||||
 | 
					from flask import request
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from accounting.models import Currency, CurrencyL10n
 | 
				
			||||||
 | 
					from accounting.utils.query import parse_query_keywords
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def get_currency_query() -> list[Currency]:
 | 
				
			||||||
 | 
					    """Returns the base accounts, optionally filtered by the query.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :return: The base accounts.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    keywords: list[str] = parse_query_keywords(request.args.get("q"))
 | 
				
			||||||
 | 
					    if len(keywords) == 0:
 | 
				
			||||||
 | 
					        return Currency.query.order_by(Currency.code).all()
 | 
				
			||||||
 | 
					    conditions: list[sa.BinaryExpression] = []
 | 
				
			||||||
 | 
					    for k in keywords:
 | 
				
			||||||
 | 
					        l10n: list[CurrencyL10n] = CurrencyL10n.query\
 | 
				
			||||||
 | 
					            .filter(CurrencyL10n.name.contains(k)).all()
 | 
				
			||||||
 | 
					        l10n_matches: set[str] = {x.account_code for x in l10n}
 | 
				
			||||||
 | 
					        conditions.append(sa.or_(Currency.code.contains(k),
 | 
				
			||||||
 | 
					                                 Currency.name_l10n.contains(k),
 | 
				
			||||||
 | 
					                                 Currency.code.in_(l10n_matches)))
 | 
				
			||||||
 | 
					    return Currency.query.filter(*conditions)\
 | 
				
			||||||
 | 
					        .order_by(Currency.code).all()
 | 
				
			||||||
							
								
								
									
										178
									
								
								src/accounting/currency/views.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										178
									
								
								src/accounting/currency/views.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,178 @@
 | 
				
			|||||||
 | 
					# The Mia! Accounting Flask Project.
 | 
				
			||||||
 | 
					# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#  Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					#  you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					#  You may obtain a copy of the License at
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#      http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					#  distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					#  See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					#  limitations under the License.
 | 
				
			||||||
 | 
					"""The views for the currency management.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					from urllib.parse import urlencode, parse_qsl
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from flask import Blueprint, render_template, redirect, session, request, \
 | 
				
			||||||
 | 
					    flash, url_for
 | 
				
			||||||
 | 
					from werkzeug.datastructures import ImmutableMultiDict
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from accounting.database import db
 | 
				
			||||||
 | 
					from accounting.locale import lazy_gettext
 | 
				
			||||||
 | 
					from accounting.models import Currency
 | 
				
			||||||
 | 
					from accounting.utils.next_url import inherit_next, or_next
 | 
				
			||||||
 | 
					from accounting.utils.pagination import Pagination
 | 
				
			||||||
 | 
					from accounting.utils.permission import has_permission, can_view, can_edit
 | 
				
			||||||
 | 
					from .forms import CurrencyForm
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					bp: Blueprint = Blueprint("currency", __name__)
 | 
				
			||||||
 | 
					"""The view blueprint for the currency management."""
 | 
				
			||||||
 | 
					api_bp: Blueprint = Blueprint("currency-api", __name__)
 | 
				
			||||||
 | 
					"""The view blueprint for the currency management API."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@bp.get("", endpoint="list")
 | 
				
			||||||
 | 
					@has_permission(can_view)
 | 
				
			||||||
 | 
					def list_currencies() -> str:
 | 
				
			||||||
 | 
					    """Lists the currencies.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :return: The currency list.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    from .query import get_currency_query
 | 
				
			||||||
 | 
					    currencies: list[Currency] = get_currency_query()
 | 
				
			||||||
 | 
					    pagination: Pagination = Pagination[Currency](currencies)
 | 
				
			||||||
 | 
					    return render_template("accounting/currency/list.html",
 | 
				
			||||||
 | 
					                           list=pagination.list, pagination=pagination)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@bp.get("/create", endpoint="create")
 | 
				
			||||||
 | 
					@has_permission(can_edit)
 | 
				
			||||||
 | 
					def show_add_currency_form() -> str:
 | 
				
			||||||
 | 
					    """Shows the form to add a currency.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :return: The form to add a currency.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    if "form" in session:
 | 
				
			||||||
 | 
					        form = CurrencyForm(ImmutableMultiDict(parse_qsl(session["form"])))
 | 
				
			||||||
 | 
					        del session["form"]
 | 
				
			||||||
 | 
					        form.validate()
 | 
				
			||||||
 | 
					    else:
 | 
				
			||||||
 | 
					        form = CurrencyForm()
 | 
				
			||||||
 | 
					    return render_template("accounting/currency/create.html",
 | 
				
			||||||
 | 
					                           form=form)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@bp.post("/store", endpoint="store")
 | 
				
			||||||
 | 
					@has_permission(can_edit)
 | 
				
			||||||
 | 
					def add_currency() -> redirect:
 | 
				
			||||||
 | 
					    """Adds a currency.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :return: The redirection to the currency detail on success, or the currency
 | 
				
			||||||
 | 
					        creation form on error.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    form = CurrencyForm(request.form)
 | 
				
			||||||
 | 
					    if not form.validate():
 | 
				
			||||||
 | 
					        for key in form.errors:
 | 
				
			||||||
 | 
					            for error in form.errors[key]:
 | 
				
			||||||
 | 
					                flash(error, "error")
 | 
				
			||||||
 | 
					        session["form"] = urlencode(list(request.form.items()))
 | 
				
			||||||
 | 
					        return redirect(inherit_next(url_for("accounting.currency.create")))
 | 
				
			||||||
 | 
					    currency: Currency = Currency()
 | 
				
			||||||
 | 
					    form.populate_obj(currency)
 | 
				
			||||||
 | 
					    db.session.add(currency)
 | 
				
			||||||
 | 
					    db.session.commit()
 | 
				
			||||||
 | 
					    flash(lazy_gettext("The currency is added successfully"), "success")
 | 
				
			||||||
 | 
					    return redirect(inherit_next(url_for("accounting.currency.detail",
 | 
				
			||||||
 | 
					                                         currency=currency)))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@bp.get("/<currency:currency>", endpoint="detail")
 | 
				
			||||||
 | 
					@has_permission(can_view)
 | 
				
			||||||
 | 
					def show_currency_detail(currency: Currency) -> str:
 | 
				
			||||||
 | 
					    """Shows the currency detail.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :param currency: The currency.
 | 
				
			||||||
 | 
					    :return: The detail.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    return render_template("accounting/currency/detail.html", obj=currency)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@bp.get("/<currency:currency>/edit", endpoint="edit")
 | 
				
			||||||
 | 
					@has_permission(can_edit)
 | 
				
			||||||
 | 
					def show_currency_edit_form(currency: Currency) -> str:
 | 
				
			||||||
 | 
					    """Shows the form to edit a currency.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :param currency: The currency.
 | 
				
			||||||
 | 
					    :return: The form to edit the currency.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    form: CurrencyForm
 | 
				
			||||||
 | 
					    if "form" in session:
 | 
				
			||||||
 | 
					        form = CurrencyForm(ImmutableMultiDict(parse_qsl(session["form"])))
 | 
				
			||||||
 | 
					        del session["form"]
 | 
				
			||||||
 | 
					        form.validate()
 | 
				
			||||||
 | 
					    else:
 | 
				
			||||||
 | 
					        form = CurrencyForm(obj=currency)
 | 
				
			||||||
 | 
					    return render_template("accounting/currency/edit.html",
 | 
				
			||||||
 | 
					                           currency=currency, form=form)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@bp.post("/<currency:currency>/update", endpoint="update")
 | 
				
			||||||
 | 
					@has_permission(can_edit)
 | 
				
			||||||
 | 
					def update_currency(currency: Currency) -> redirect:
 | 
				
			||||||
 | 
					    """Updates a currency.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :param currency: The currency.
 | 
				
			||||||
 | 
					    :return: The redirection to the currency detail on success, or the currency
 | 
				
			||||||
 | 
					        edit form on error.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    form = CurrencyForm(request.form)
 | 
				
			||||||
 | 
					    form.obj_code = currency.code
 | 
				
			||||||
 | 
					    if not form.validate():
 | 
				
			||||||
 | 
					        for key in form.errors:
 | 
				
			||||||
 | 
					            for error in form.errors[key]:
 | 
				
			||||||
 | 
					                flash(error, "error")
 | 
				
			||||||
 | 
					        session["form"] = urlencode(list(request.form.items()))
 | 
				
			||||||
 | 
					        return redirect(inherit_next(url_for("accounting.currency.edit",
 | 
				
			||||||
 | 
					                                             currency=currency)))
 | 
				
			||||||
 | 
					    with db.session.no_autoflush:
 | 
				
			||||||
 | 
					        form.populate_obj(currency)
 | 
				
			||||||
 | 
					    if not currency.is_modified:
 | 
				
			||||||
 | 
					        flash(lazy_gettext("The currency was not modified."), "success")
 | 
				
			||||||
 | 
					        return redirect(inherit_next(url_for("accounting.currency.detail",
 | 
				
			||||||
 | 
					                                             currency=currency)))
 | 
				
			||||||
 | 
					    form.post_update(currency)
 | 
				
			||||||
 | 
					    db.session.commit()
 | 
				
			||||||
 | 
					    flash(lazy_gettext("The currency is updated successfully."), "success")
 | 
				
			||||||
 | 
					    return redirect(inherit_next(url_for("accounting.currency.detail",
 | 
				
			||||||
 | 
					                                         currency=currency)))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@bp.post("/<currency:currency>/delete", endpoint="delete")
 | 
				
			||||||
 | 
					@has_permission(can_edit)
 | 
				
			||||||
 | 
					def delete_currency(currency: Currency) -> redirect:
 | 
				
			||||||
 | 
					    """Deletes a currency.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :param currency: The currency.
 | 
				
			||||||
 | 
					    :return: The redirection to the currency list on success, or the currency
 | 
				
			||||||
 | 
					        detail on error.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    currency.delete()
 | 
				
			||||||
 | 
					    db.session.commit()
 | 
				
			||||||
 | 
					    flash(lazy_gettext("The currency is deleted successfully."), "success")
 | 
				
			||||||
 | 
					    return redirect(or_next(url_for("accounting.currency.list")))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@api_bp.get("/exists-code", endpoint="exists")
 | 
				
			||||||
 | 
					@has_permission(can_edit)
 | 
				
			||||||
 | 
					def exists_code() -> dict[str, bool]:
 | 
				
			||||||
 | 
					    """Validates whether a currency code exists.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :return: Whether the currency code exists.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    return {"exists": db.session.get(Currency, request.args["q"]) is not None}
 | 
				
			||||||
							
								
								
									
										528
									
								
								src/accounting/data/base_accounts.csv
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										528
									
								
								src/accounting/data/base_accounts.csv
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,528 @@
 | 
				
			|||||||
 | 
					code,title,l10n-zh_Hant,l10n-zh_Hans
 | 
				
			||||||
 | 
					1,assets,資產,资产
 | 
				
			||||||
 | 
					2,liabilities,負債,负债
 | 
				
			||||||
 | 
					3,owners’ equity,業主權益,业主权益
 | 
				
			||||||
 | 
					4,operating revenue,營業收入,营业收入
 | 
				
			||||||
 | 
					5,operating costs,營業成本,营业成本
 | 
				
			||||||
 | 
					6,operating expenses,營業費用,营业费用
 | 
				
			||||||
 | 
					7,"non-operating revenue and expenses, other income (expense)",營業外收入及費用,营业外收入及费用
 | 
				
			||||||
 | 
					8,income tax expense (or benefit),所得稅費用(或利益),所得税费用(或利益)
 | 
				
			||||||
 | 
					9,nonrecurring gain or loss,非經常營業損益,非经常营业损益
 | 
				
			||||||
 | 
					11,current assets,流動資產,流动资产
 | 
				
			||||||
 | 
					12,current assets,流動資產,流动资产
 | 
				
			||||||
 | 
					13,funds and long-term investments,基金及長期投資,基金及长期投资
 | 
				
			||||||
 | 
					14,"property , plant, and equipment",固定資產,固定资产
 | 
				
			||||||
 | 
					15,"property , plant, and equipment",固定資產,固定资产
 | 
				
			||||||
 | 
					16,depletable assets,遞耗資產,递耗资产
 | 
				
			||||||
 | 
					17,intangible assets,無形資產,无形资产
 | 
				
			||||||
 | 
					18,other assets,其他資產,其他资产
 | 
				
			||||||
 | 
					21,current liabilities,流動負債,流动负债
 | 
				
			||||||
 | 
					22,current liabilities,流動負債,流动负债
 | 
				
			||||||
 | 
					23,long-term liabilities,長期負債,长期负债
 | 
				
			||||||
 | 
					28,other liabilities,其他負債,其他负债
 | 
				
			||||||
 | 
					31,capital,資本,资本
 | 
				
			||||||
 | 
					32,additional paid-in capital,資本公積,资本公积
 | 
				
			||||||
 | 
					33,retained earnings (accumulated deficit),保留盈餘(或累積虧損),保留盈余(或累积亏损)
 | 
				
			||||||
 | 
					34,equity adjustments,權益調整,权益调整
 | 
				
			||||||
 | 
					35,treasury stock,庫藏股,库藏股
 | 
				
			||||||
 | 
					36,minority interest,少數股權,少数股权
 | 
				
			||||||
 | 
					41,sales revenue,銷貨收入,销货收入
 | 
				
			||||||
 | 
					46,service revenue,勞務收入,劳务收入
 | 
				
			||||||
 | 
					47,agency revenue,業務收入,业务收入
 | 
				
			||||||
 | 
					48,other operating revenue,其他營業收入,其他营业收入
 | 
				
			||||||
 | 
					51,cost of goods sold,銷貨成本,销货成本
 | 
				
			||||||
 | 
					56,service costs,勞務成本,劳务成本
 | 
				
			||||||
 | 
					57,agency costs,業務成本,业务成本
 | 
				
			||||||
 | 
					58,other operating costs,其他營業成本,其他营业成本
 | 
				
			||||||
 | 
					61,selling expenses,推銷費用,推销费用
 | 
				
			||||||
 | 
					62,general & administrative expenses,管理及總務費用,管理及总务费用
 | 
				
			||||||
 | 
					63,research and development expenses,研究發展費用,研究发展费用
 | 
				
			||||||
 | 
					71,non-operating revenue,營業外收入,营业外收入
 | 
				
			||||||
 | 
					72,non-operating revenue,營業外收入,营业外收入
 | 
				
			||||||
 | 
					73,non-operating revenue,營業外收入,营业外收入
 | 
				
			||||||
 | 
					74,non-operating revenue,營業外收入,营业外收入
 | 
				
			||||||
 | 
					75,non-operating expenses,營業外費用,营业外费用
 | 
				
			||||||
 | 
					76,non-operating expenses,營業外費用,营业外费用
 | 
				
			||||||
 | 
					77,non-operating expenses,營業外費用,营业外费用
 | 
				
			||||||
 | 
					78,non-operating expenses,營業外費用,营业外费用
 | 
				
			||||||
 | 
					81,income tax expense (or benefit),所得稅費用(或利益),所得税费用(或利益)
 | 
				
			||||||
 | 
					91,gain (loss) from discontinued operations,停業部門損益,停业部门损益
 | 
				
			||||||
 | 
					92,extraordinary gain or loss,非常損益,非常损益
 | 
				
			||||||
 | 
					93,cumulative effect of changes in accounting principles,會計原則變動累積影響數,会计原则变动累积影响数
 | 
				
			||||||
 | 
					94,minority interest income,少數股權淨利,少数股权净利
 | 
				
			||||||
 | 
					111,cash and cash equivalents,現金及約當現金,现金及约当现金
 | 
				
			||||||
 | 
					112,short-term investments,短期投資,短期投资
 | 
				
			||||||
 | 
					113,notes receivable,應收票據,应收票据
 | 
				
			||||||
 | 
					114,accounts receivable,應收帳款,应收帐款
 | 
				
			||||||
 | 
					118,other receivables,其他應收款,其他应收款
 | 
				
			||||||
 | 
					121,inventories,存貨,存货
 | 
				
			||||||
 | 
					122,inventories,存貨,存货
 | 
				
			||||||
 | 
					125,prepaid expenses,預付費用,预付费用
 | 
				
			||||||
 | 
					126,prepayments,預付款項,预付款项
 | 
				
			||||||
 | 
					128,other current assets,其他流動資產,其他流动资产
 | 
				
			||||||
 | 
					129,other current assets,其他流動資產,其他流动资产
 | 
				
			||||||
 | 
					131,funds,基金,基金
 | 
				
			||||||
 | 
					132,long-term investments,長期投資,长期投资
 | 
				
			||||||
 | 
					141,land,土地,土地
 | 
				
			||||||
 | 
					142,land improvements,土地改良物,土地改良物
 | 
				
			||||||
 | 
					143,buildings,房屋及建物,房屋及建物
 | 
				
			||||||
 | 
					144,machinery and equipment,機(器)具及設備,机(器)具及设备
 | 
				
			||||||
 | 
					145,machinery and equipment,機(器)具及設備,机(器)具及设备
 | 
				
			||||||
 | 
					146,machinery and equipment,機(器)具及設備,机(器)具及设备
 | 
				
			||||||
 | 
					151,leased assets,租賃資產,租赁资产
 | 
				
			||||||
 | 
					152,leasehold improvements,租賃權益改良,租赁权益改良
 | 
				
			||||||
 | 
					156,construction in progress and prepayments for equipment,未完工程及預付購置設備款,未完工程及预付购置设备款
 | 
				
			||||||
 | 
					158,"miscellaneous property, plant, and equipment",雜項固定資產,杂项固定资产
 | 
				
			||||||
 | 
					161,depletable assets,遞耗資產,递耗资产
 | 
				
			||||||
 | 
					171,trademarks,商標權,商标权
 | 
				
			||||||
 | 
					172,patents,專利權,专利权
 | 
				
			||||||
 | 
					173,franchise,特許權,特许权
 | 
				
			||||||
 | 
					174,copyright,著作權,著作权
 | 
				
			||||||
 | 
					175,computer software,電腦軟體,电脑软体
 | 
				
			||||||
 | 
					176,goodwill,商譽,商誉
 | 
				
			||||||
 | 
					177,organization costs,開辦費,开办费
 | 
				
			||||||
 | 
					178,other intangibles,其他無形資產,其他无形资产
 | 
				
			||||||
 | 
					181,deferred assets,遞延資產,递延资产
 | 
				
			||||||
 | 
					182,idle assets,閒置資產,闲置资产
 | 
				
			||||||
 | 
					184,"long-term notes , accounts and overdue receivables",長期應收票據及款項與催收帳款,长期应收票据及款项与催收帐款
 | 
				
			||||||
 | 
					185,assets leased to others,出租資產,出租资产
 | 
				
			||||||
 | 
					186,refundable deposit,存出保證金,存出保证金
 | 
				
			||||||
 | 
					188,miscellaneous assets,雜項資產,杂项资产
 | 
				
			||||||
 | 
					211,short-term borrowings (debt),短期借款,短期借款
 | 
				
			||||||
 | 
					212,short-term notes and bills payable,應付短期票券,应付短期票券
 | 
				
			||||||
 | 
					213,notes payable,應付票據,应付票据
 | 
				
			||||||
 | 
					214,accounts pay able,應付帳款,应付帐款
 | 
				
			||||||
 | 
					216,income taxes payable,應付所得稅,应付所得税
 | 
				
			||||||
 | 
					217,accrued expenses,應付費用,应付费用
 | 
				
			||||||
 | 
					218,other payables,其他應付款,其他应付款
 | 
				
			||||||
 | 
					219,other payables,其他應付款,其他应付款
 | 
				
			||||||
 | 
					226,advance receipts,預收款項,预收款项
 | 
				
			||||||
 | 
					227,long-term liabilities -current portion,一年或一營業週期內到期長期負債,一年或一营业周期内到期长期负债
 | 
				
			||||||
 | 
					228,other current liabilities,其他流動負債,其他流动负债
 | 
				
			||||||
 | 
					229,other current liabilities,其他流動負債,其他流动负债
 | 
				
			||||||
 | 
					231,corporate bonds payable,應付公司債,应付公司债
 | 
				
			||||||
 | 
					232,long-term loans payable,長期借款,长期借款
 | 
				
			||||||
 | 
					233,long-term notes and accounts payable,長期應付票據及款項,长期应付票据及款项
 | 
				
			||||||
 | 
					234,accrued liabilities for land value increment tax,估計應付土地增值稅,估计应付土地增值税
 | 
				
			||||||
 | 
					235,accrued pension liabilities,應計退休金負債,应计退休金负债
 | 
				
			||||||
 | 
					238,other long-term liabilities,其他長期負債,其他长期负债
 | 
				
			||||||
 | 
					281,deferred liabilities,遞延負債,递延负债
 | 
				
			||||||
 | 
					286,deposits received,存入保證金,存入保证金
 | 
				
			||||||
 | 
					288,miscellaneous liabilities,雜項負債,杂项负债
 | 
				
			||||||
 | 
					311,capital,資本(或股本),资本(或股本)
 | 
				
			||||||
 | 
					321,paid-in capital in excess of par,股票溢價,股票溢价
 | 
				
			||||||
 | 
					323,capital surplus from assets revaluation,資產重估增值準備,资产重估增值准备
 | 
				
			||||||
 | 
					324,capital surplus from gain on disposal of assets,處分資產溢價公積,处分资产溢价公积
 | 
				
			||||||
 | 
					325,capital surplus from business combination,合併公積,合并公积
 | 
				
			||||||
 | 
					326,donated surplus,受贈公積,受赠公积
 | 
				
			||||||
 | 
					328,other additional paid-in capital,其他資本公積,其他资本公积
 | 
				
			||||||
 | 
					331,legal reserve,法定盈餘公積,法定盈余公积
 | 
				
			||||||
 | 
					332,special reserve,特別盈餘公積,特别盈余公积
 | 
				
			||||||
 | 
					335,retained earnings-unappropriated (or accumulated deficit),未分配盈餘(或累積虧損),未分配盈余(或累积亏损)
 | 
				
			||||||
 | 
					341,unrealized loss on market value decline of long-term equity investments,長期股權投資未實現跌價損失,长期股权投资未实现跌价损失
 | 
				
			||||||
 | 
					342,cumulative translation adjustment,累積換算調整數,累积换算调整数
 | 
				
			||||||
 | 
					343,net loss not recognized as pension cost,未認列為退休金成本之淨損失,未认列为退休金成本之净损失
 | 
				
			||||||
 | 
					351,treasury stock,庫藏股,库藏股
 | 
				
			||||||
 | 
					361,minority interest,少數股權,少数股权
 | 
				
			||||||
 | 
					411,sales revenue,銷貨收入,销货收入
 | 
				
			||||||
 | 
					417,sales return,銷貨退回,销货退回
 | 
				
			||||||
 | 
					419,sales allowances,銷貨折讓,销货折让
 | 
				
			||||||
 | 
					461,service revenue,勞務收入,劳务收入
 | 
				
			||||||
 | 
					471,agency revenue,業務收入,业务收入
 | 
				
			||||||
 | 
					488,other operating revenue,其他營業收入—其他,其他营业收入—其他
 | 
				
			||||||
 | 
					511,cost of goods sold,銷貨成本,销货成本
 | 
				
			||||||
 | 
					512,purchases,進貨,进货
 | 
				
			||||||
 | 
					513,materials purchased,進料,进料
 | 
				
			||||||
 | 
					514,direct labor,直接人工,直接人工
 | 
				
			||||||
 | 
					515,manufacturing overhead,製造費用,制造费用
 | 
				
			||||||
 | 
					516,manufacturing overhead,製造費用,制造费用
 | 
				
			||||||
 | 
					517,manufacturing overhead,製造費用,制造费用
 | 
				
			||||||
 | 
					518,manufacturing overhead,製造費用,制造费用
 | 
				
			||||||
 | 
					561,service costs,勞務成本,劳务成本
 | 
				
			||||||
 | 
					571,agency costs,業務成本,业务成本
 | 
				
			||||||
 | 
					588,other operating costs-other,其他營業成本—其他,其他营业成本—其他
 | 
				
			||||||
 | 
					615,selling expenses,推銷費用,推销费用
 | 
				
			||||||
 | 
					616,selling expenses,推銷費用,推销费用
 | 
				
			||||||
 | 
					617,selling expenses,推銷費用,推销费用
 | 
				
			||||||
 | 
					618,selling expenses,推銷費用,推销费用
 | 
				
			||||||
 | 
					625,general & administrative expenses,管理及總務費用,管理及总务费用
 | 
				
			||||||
 | 
					626,general & administrative expenses,管理及總務費用,管理及总务费用
 | 
				
			||||||
 | 
					627,general & administrative expenses,管理及總務費用,管理及总务费用
 | 
				
			||||||
 | 
					628,general & administrative expenses,管理及總務費用,管理及总务费用
 | 
				
			||||||
 | 
					635,research and development expenses,研究發展費用,研究发展费用
 | 
				
			||||||
 | 
					636,research and development expenses,研究發展費用,研究发展费用
 | 
				
			||||||
 | 
					637,research and development expenses,研究發展費用,研究发展费用
 | 
				
			||||||
 | 
					638,research and development expenses,研究發展費用,研究发展费用
 | 
				
			||||||
 | 
					711,interest revenue,利息收入,利息收入
 | 
				
			||||||
 | 
					712,investment income,投資收益,投资收益
 | 
				
			||||||
 | 
					713,foreign exchange gain,兌換利益,兑换利益
 | 
				
			||||||
 | 
					714,gain on disposal of investments,處分投資收益,处分投资收益
 | 
				
			||||||
 | 
					715,gain on disposal of assets,處分資產溢價收入,处分资产溢价收入
 | 
				
			||||||
 | 
					748,other non-operating revenue,其他營業外收入,其他营业外收入
 | 
				
			||||||
 | 
					751,interest expense,利息費用,利息费用
 | 
				
			||||||
 | 
					752,investment loss,投資損失,投资损失
 | 
				
			||||||
 | 
					753,foreign exchange loss,兌換損失,兑换损失
 | 
				
			||||||
 | 
					754,loss on disposal of investments,處分投資損失,处分投资损失
 | 
				
			||||||
 | 
					755,loss on disposal of assets,處分資產損失,处分资产损失
 | 
				
			||||||
 | 
					788,other non-operating expenses,其他營業外費用,其他营业外费用
 | 
				
			||||||
 | 
					811,income tax expense (or benefit),所得稅費用(或利益),所得税费用(或利益)
 | 
				
			||||||
 | 
					911,income (loss) from operations of discontinued segments,停業部門損益—停業前營業損益,停业部门损益—停业前营业损益
 | 
				
			||||||
 | 
					912,gain (loss) from disposal of discontinued segments,停業部門損益—處分損益,停业部门损益—处分损益
 | 
				
			||||||
 | 
					921,extraordinary gain or loss,非常損益,非常损益
 | 
				
			||||||
 | 
					931,cumulative effect of changes in accounting principles,會計原則變動累積影響數,会计原则变动累积影响数
 | 
				
			||||||
 | 
					941,minority interest income,少數股權淨利,少数股权净利
 | 
				
			||||||
 | 
					1111,cash on hand,庫存現金,库存现金
 | 
				
			||||||
 | 
					1112,petty cash/revolving funds,零用金/週轉金,零用金/周转金
 | 
				
			||||||
 | 
					1113,cash in banks,銀行存款,银行存款
 | 
				
			||||||
 | 
					1116,cash in transit,在途現金,在途现金
 | 
				
			||||||
 | 
					1117,cash equivalents,約當現金,约当现金
 | 
				
			||||||
 | 
					1118,other cash and cash equivalents,其他現金及約當現金,其他现金及约当现金
 | 
				
			||||||
 | 
					1121,short-term investments – stock,短期投資—股票,短期投资—股票
 | 
				
			||||||
 | 
					1122,short-term investments – short-term notes and bills,短期投資—短期票券,短期投资—短期票券
 | 
				
			||||||
 | 
					1123,short-term investments – government bonds,短期投資—政府債券,短期投资—政府债券
 | 
				
			||||||
 | 
					1124,short-term investments – beneficiary certificates,短期投資—受益憑證,短期投资—受益凭证
 | 
				
			||||||
 | 
					1125,short-term investments – corporate bonds,短期投資—公司債,短期投资—公司债
 | 
				
			||||||
 | 
					1128,short-term investments – other,短期投資—其他,短期投资—其他
 | 
				
			||||||
 | 
					1129,allowance for reduction of short-term investment to market,備抵短期投資跌價損失,备抵短期投资跌价损失
 | 
				
			||||||
 | 
					1131,notes receivable,應收票據,应收票据
 | 
				
			||||||
 | 
					1132,discounted notes receivable,應收票據貼現,应收票据贴现
 | 
				
			||||||
 | 
					1137,notes receivable – related parties,應收票據—關係人,应收票据—关系人
 | 
				
			||||||
 | 
					1138,other notes receivable,其他應收票據,其他应收票据
 | 
				
			||||||
 | 
					1139,allowance for uncollectible accounts – notes receivable,備抵呆帳-應收票據,备抵呆帐-应收票据
 | 
				
			||||||
 | 
					1141,accounts receivable,應收帳款,应收帐款
 | 
				
			||||||
 | 
					1142,installment accounts receivable,應收分期帳款,应收分期帐款
 | 
				
			||||||
 | 
					1147,accounts receivable – related parties,應收帳款—關係人,应收帐款—关系人
 | 
				
			||||||
 | 
					1149,allowance for uncollectible accounts – accounts receivable,備抵呆帳-應收帳款,备抵呆帐-应收帐款
 | 
				
			||||||
 | 
					1181,forward exchange contract receivable,應收出售遠匯款,应收出售远汇款
 | 
				
			||||||
 | 
					1182,forward exchange contract receivable – foreign currencies,應收遠匯款—外幣,应收远汇款—外币
 | 
				
			||||||
 | 
					1183,discount on forward ex-change contract,買賣遠匯折價,买卖远汇折价
 | 
				
			||||||
 | 
					1184,earned revenue receivable,應收收益,应收收益
 | 
				
			||||||
 | 
					1185,income tax refund receivable,應收退稅款,应收退税款
 | 
				
			||||||
 | 
					1187,other receivables – related parties,其他應收款—關係人,其他应收款—关系人
 | 
				
			||||||
 | 
					1188,other receivables – other,其他應收款—其他,其他应收款—其他
 | 
				
			||||||
 | 
					1189,allowance for uncollectible accounts – other receivables,備抵呆帳—其他應收款,备抵呆帐—其他应收款
 | 
				
			||||||
 | 
					1211,merchandise inventory,商品存貨,商品存货
 | 
				
			||||||
 | 
					1212,consigned goods,寄銷商品,寄销商品
 | 
				
			||||||
 | 
					1213,goods in transit,在途商品,在途商品
 | 
				
			||||||
 | 
					1219,allowance for reduction of inventory to market,備抵存貨跌價損失,备抵存货跌价损失
 | 
				
			||||||
 | 
					1221,finished goods,製成品,制成品
 | 
				
			||||||
 | 
					1222,consigned finished goods,寄銷製成品,寄销制成品
 | 
				
			||||||
 | 
					1223,by-products,副產品,副产品
 | 
				
			||||||
 | 
					1224,work in process,在製品,在制品
 | 
				
			||||||
 | 
					1225,work in process – outsourced,委外加工,委外加工
 | 
				
			||||||
 | 
					1226,raw materials,原料,原料
 | 
				
			||||||
 | 
					1227,supplies,物料,物料
 | 
				
			||||||
 | 
					1228,materials and supplies in transit,在途原物料,在途原物料
 | 
				
			||||||
 | 
					1229,allowance for reduction of inventory to market,備抵存貨跌價損失,备抵存货跌价损失
 | 
				
			||||||
 | 
					1251,prepaid payroll,預付薪資,预付薪资
 | 
				
			||||||
 | 
					1252,prepaid rents,預付租金,预付租金
 | 
				
			||||||
 | 
					1253,prepaid insurance,預付保險費,预付保险费
 | 
				
			||||||
 | 
					1254,office supplies,用品盤存,用品盘存
 | 
				
			||||||
 | 
					1255,prepaid income tax,預付所得稅,预付所得税
 | 
				
			||||||
 | 
					1258,other prepaid expenses,其他預付費用,其他预付费用
 | 
				
			||||||
 | 
					1261,prepayment for purchases,預付貨款,预付货款
 | 
				
			||||||
 | 
					1268,other prepayments,其他預付款項,其他预付款项
 | 
				
			||||||
 | 
					1281,VAT paid ( or input tax),進項稅額,进项税额
 | 
				
			||||||
 | 
					1282,excess VAT paid (or overpaid VAT),留抵稅額,留抵税额
 | 
				
			||||||
 | 
					1283,temporary payments,暫付款,暂付款
 | 
				
			||||||
 | 
					1284,payment on behalf of others,代付款,代付款
 | 
				
			||||||
 | 
					1285,advances to employees,員工借支,员工借支
 | 
				
			||||||
 | 
					1286,refundable deposits,存出保證金,存出保证金
 | 
				
			||||||
 | 
					1287,certificate of deposit-restricted,受限制存款,受限制存款
 | 
				
			||||||
 | 
					1291,deferred income tax assets,遞延所得稅資產,递延所得税资产
 | 
				
			||||||
 | 
					1292,deferred foreign exchange losses,遞延兌換損失,递延兑换损失
 | 
				
			||||||
 | 
					1293,owners’ (stockholders’) current account,業主(股東)往來,业主(股东)往来
 | 
				
			||||||
 | 
					1294,current account with others,同業往來,同业往来
 | 
				
			||||||
 | 
					1298,other current assets – other,其他流動資產—其他,其他流动资产—其他
 | 
				
			||||||
 | 
					1311,redemption fund (or sinking fund),償債基金,偿债基金
 | 
				
			||||||
 | 
					1312,fund for improvement and expansion,改良及擴充基金,改良及扩充基金
 | 
				
			||||||
 | 
					1313,contingency fund,意外損失準備基金,意外损失准备基金
 | 
				
			||||||
 | 
					1314,pension fund,退休基金,退休基金
 | 
				
			||||||
 | 
					1318,other funds,其他基金,其他基金
 | 
				
			||||||
 | 
					1321,long-term equity investments,長期股權投資,长期股权投资
 | 
				
			||||||
 | 
					1322,long-term bond investments,長期債券投資,长期债券投资
 | 
				
			||||||
 | 
					1323,long-term real estate in-vestments,長期不動產投資,长期不动产投资
 | 
				
			||||||
 | 
					1324,cash surrender value of life insurance,人壽保險現金解約價值,人寿保险现金解约价值
 | 
				
			||||||
 | 
					1328,other long-term investments,其他長期投資,其他长期投资
 | 
				
			||||||
 | 
					1329,allowance for excess of cost over market value of long-term investments,備抵長期投資跌價損失,备抵长期投资跌价损失
 | 
				
			||||||
 | 
					1411,land,土地,土地
 | 
				
			||||||
 | 
					1418,land – revaluation increments,土地—重估增值,土地—重估增值
 | 
				
			||||||
 | 
					1421,land improvements,土地改良物,土地改良物
 | 
				
			||||||
 | 
					1428,land improvements – revaluation increments,土地改良物—重估增值,土地改良物—重估增值
 | 
				
			||||||
 | 
					1429,accumulated depreciation – land improvements,累積折舊—土地改良物,累积折旧—土地改良物
 | 
				
			||||||
 | 
					1431,buildings,房屋及建物,房屋及建物
 | 
				
			||||||
 | 
					1438,buildings –revaluation increments,房屋及建物—重估增值,房屋及建物—重估增值
 | 
				
			||||||
 | 
					1439,accumulated depreciation – buildings,累積折舊—房屋及建物,累积折旧—房屋及建物
 | 
				
			||||||
 | 
					1441,machinery,機(器)具,机(器)具
 | 
				
			||||||
 | 
					1448,machinery – revaluation increments,機(器)具—重估增值,机(器)具—重估增值
 | 
				
			||||||
 | 
					1449,accumulated depreciation – machinery,累積折舊—機(器)具,累积折旧—机(器)具
 | 
				
			||||||
 | 
					1511,leased assets,租賃資產,租赁资产
 | 
				
			||||||
 | 
					1519,accumulated depreciation – leased assets,累積折舊—租賃資產,累积折旧—租赁资产
 | 
				
			||||||
 | 
					1521,leasehold improvements,租賃權益改良,租赁权益改良
 | 
				
			||||||
 | 
					1529,accumulated depreciation – leasehold improvements,累積折舊—租賃權益改良,累积折旧—租赁权益改良
 | 
				
			||||||
 | 
					1561,construction in progress,未完工程,未完工程
 | 
				
			||||||
 | 
					1562,prepayment for equipment,預付購置設備款,预付购置设备款
 | 
				
			||||||
 | 
					1581,"miscellaneous property, plant, and equipment",雜項固定資產,杂项固定资产
 | 
				
			||||||
 | 
					1588,"miscellaneous property, plant, and equipment – revaluation increments",雜項固定資產—重估增值,杂项固定资产—重估增值
 | 
				
			||||||
 | 
					1589,"accumulated depreciation – miscellaneous property, plant, and equipment",累積折舊—雜項固定資產,累积折旧—杂项固定资产
 | 
				
			||||||
 | 
					1611,natural resources,天然資源,天然资源
 | 
				
			||||||
 | 
					1618,natural resources –revaluation increments,天然資源—重估增值,天然资源—重估增值
 | 
				
			||||||
 | 
					1619,accumulated depletion – natural resources,累積折耗—天然資源,累积折耗—天然资源
 | 
				
			||||||
 | 
					1711,trademarks,商標權,商标权
 | 
				
			||||||
 | 
					1721,patents,專利權,专利权
 | 
				
			||||||
 | 
					1731,franchise,特許權,特许权
 | 
				
			||||||
 | 
					1741,copyright,著作權,著作权
 | 
				
			||||||
 | 
					1751,computer software cost,電腦軟體,电脑软体
 | 
				
			||||||
 | 
					1761,goodwill,商譽,商誉
 | 
				
			||||||
 | 
					1771,organization costs,開辦費,开办费
 | 
				
			||||||
 | 
					1781,deferred pension costs,遞延退休金成本,递延退休金成本
 | 
				
			||||||
 | 
					1782,leasehold improvements,租賃權益改良,租赁权益改良
 | 
				
			||||||
 | 
					1788,other intangible assets – other,其他無形資產—其他,其他无形资产—其他
 | 
				
			||||||
 | 
					1811,deferred bond issuance costs,債券發行成本,债券发行成本
 | 
				
			||||||
 | 
					1812,long-term prepaid rent,長期預付租金,长期预付租金
 | 
				
			||||||
 | 
					1813,long-term prepaid insurance,長期預付保險費,长期预付保险费
 | 
				
			||||||
 | 
					1814,deferred income tax assets,遞延所得稅資產,递延所得税资产
 | 
				
			||||||
 | 
					1815,prepaid pension cost,預付退休金,预付退休金
 | 
				
			||||||
 | 
					1818,other deferred assets,其他遞延資產,其他递延资产
 | 
				
			||||||
 | 
					1821,idle assets,閒置資產,闲置资产
 | 
				
			||||||
 | 
					1841,long-term notes receivable,長期應收票據,长期应收票据
 | 
				
			||||||
 | 
					1842,long-term accounts receivable,長期應收帳款,长期应收帐款
 | 
				
			||||||
 | 
					1843,overdue receivables,催收帳款,催收帐款
 | 
				
			||||||
 | 
					1847,"long-term notes, accounts and overdue receivables – related parties",長期應收票據及款項與催收帳款—關係人,长期应收票据及款项与催收帐款—关系人
 | 
				
			||||||
 | 
					1848,other long-term receivables,其他長期應收款項,其他长期应收款项
 | 
				
			||||||
 | 
					1849,"allowance for uncollectible accounts – long-term notes, accounts and overdue receivables",備抵呆帳—長期應收票據及款項與催收帳款,备抵呆帐—长期应收票据及款项与催收帐款
 | 
				
			||||||
 | 
					1851,assets leased to others,出租資產,出租资产
 | 
				
			||||||
 | 
					1858,assets leased to others – incremental value from revaluation,出租資產—重估增值,出租资产—重估增值
 | 
				
			||||||
 | 
					1859,accumulated depreciation – assets leased to others,累積折舊—出租資產,累积折旧—出租资产
 | 
				
			||||||
 | 
					1861,refundable deposits,存出保證金,存出保证金
 | 
				
			||||||
 | 
					1881,certificate of deposit – restricted,受限制存款,受限制存款
 | 
				
			||||||
 | 
					1888,miscellaneous assets – other,雜項資產—其他,杂项资产—其他
 | 
				
			||||||
 | 
					2111,bank overdraft,銀行透支,银行透支
 | 
				
			||||||
 | 
					2112,bank loan,銀行借款,银行借款
 | 
				
			||||||
 | 
					2114,short-term borrowings – owners,短期借款—業主,短期借款—业主
 | 
				
			||||||
 | 
					2115,short-term borrowings – employees,短期借款—員工,短期借款—员工
 | 
				
			||||||
 | 
					2117,short-term borrowings – related parties,短期借款—關係人,短期借款—关系人
 | 
				
			||||||
 | 
					2118,short-term borrowings – other,短期借款—其他,短期借款—其他
 | 
				
			||||||
 | 
					2121,commercial paper payable,應付商業本票,应付商业本票
 | 
				
			||||||
 | 
					2122,bank acceptance,銀行承兌匯票,银行承兑汇票
 | 
				
			||||||
 | 
					2128,other short-term notes and bills payable,其他應付短期票券,其他应付短期票券
 | 
				
			||||||
 | 
					2129,discount on short-term notes and bills payable,應付短期票券折價,应付短期票券折价
 | 
				
			||||||
 | 
					2131,notes payable,應付票據,应付票据
 | 
				
			||||||
 | 
					2137,notes payable – related parties,應付票據—關係人,应付票据—关系人
 | 
				
			||||||
 | 
					2138,other notes payable,其他應付票據,其他应付票据
 | 
				
			||||||
 | 
					2141,accounts payable,應付帳款,应付帐款
 | 
				
			||||||
 | 
					2147,accounts payable – related parties,應付帳款—關係人,应付帐款—关系人
 | 
				
			||||||
 | 
					2161,income tax payable,應付所得稅,应付所得税
 | 
				
			||||||
 | 
					2171,accrued payroll,應付薪工,应付薪工
 | 
				
			||||||
 | 
					2172,accrued rent payable,應付租金,应付租金
 | 
				
			||||||
 | 
					2173,accrued interest payable,應付利息,应付利息
 | 
				
			||||||
 | 
					2174,accrued VAT payable,應付營業稅,应付营业税
 | 
				
			||||||
 | 
					2175,accrued taxes payable – other,應付稅捐—其他,应付税捐—其他
 | 
				
			||||||
 | 
					2178,other accrued expenses payable,其他應付費用,其他应付费用
 | 
				
			||||||
 | 
					2181,forward exchange contract payable,應付購入遠匯款,应付购入远汇款
 | 
				
			||||||
 | 
					2182,forward exchange contract payable – foreign currencies,應付遠匯款—外幣,应付远汇款—外币
 | 
				
			||||||
 | 
					2183,premium on forward exchange contract,買賣遠匯溢價,买卖远汇溢价
 | 
				
			||||||
 | 
					2184,payables on land and building purchased,應付土地房屋款,应付土地房屋款
 | 
				
			||||||
 | 
					2185,Payables on equipment,應付設備款,应付设备款
 | 
				
			||||||
 | 
					2187,other payables – related parties,其他應付款—關係人,其他应付款—关系人
 | 
				
			||||||
 | 
					2191,dividend payable,應付股利,应付股利
 | 
				
			||||||
 | 
					2192,bonus payable,應付紅利,应付红利
 | 
				
			||||||
 | 
					2193,compensation payable to directors and supervisors,應付董監事酬勞,应付董监事酬劳
 | 
				
			||||||
 | 
					2198,other payables – other,其他應付款—其他,其他应付款—其他
 | 
				
			||||||
 | 
					2261,sales revenue received in advance,預收貨款,预收货款
 | 
				
			||||||
 | 
					2262,revenue received in advance,預收收入,预收收入
 | 
				
			||||||
 | 
					2268,other advance receipts,其他預收款,其他预收款
 | 
				
			||||||
 | 
					2271,corporate bonds payable – current portion,一年或一營業週期內到期公司債,一年或一营业周期内到期公司债
 | 
				
			||||||
 | 
					2272,long-term loans payable – current portion,一年或一營業週期內到期長期借款,一年或一营业周期内到期长期借款
 | 
				
			||||||
 | 
					2273,long-term notes and accounts payable due within one year or one operating cycle,一年或一營業週期內到期長期應付票據及款項,一年或一营业周期内到期长期应付票据及款项
 | 
				
			||||||
 | 
					2277,long-term notes and accounts payables to related parties – current portion,一年或一營業週期內到期長期應付票據及款項—關係人,一年或一营业周期内到期长期应付票据及款项—关系人
 | 
				
			||||||
 | 
					2278,other long-term liabilities – current portion,其他一年或一營業週期內到期長期負債,其他一年或一营业周期内到期长期负债
 | 
				
			||||||
 | 
					2281,VAT received (or output tax),銷項稅額,销项税额
 | 
				
			||||||
 | 
					2283,temporary receipts,暫收款,暂收款
 | 
				
			||||||
 | 
					2284,receipts under custody,代收款,代收款
 | 
				
			||||||
 | 
					2285,estimated warranty liabilities,估計售後服務/保固負債,估计售后服务/保固负债
 | 
				
			||||||
 | 
					2291,deferred income tax liabilities,遞延所得稅負債,递延所得税负债
 | 
				
			||||||
 | 
					2292,deferred foreign exchange gain,遞延兌換利益,递延兑换利益
 | 
				
			||||||
 | 
					2293,owners’ current account,業主(股東)往來,业主(股东)往来
 | 
				
			||||||
 | 
					2294,current account with others,同業往來,同业往来
 | 
				
			||||||
 | 
					2298,other current liabilities – others,其他流動負債—其他,其他流动负债—其他
 | 
				
			||||||
 | 
					2311,corporate bonds payable,應付公司債,应付公司债
 | 
				
			||||||
 | 
					2319,premium (discount) on corporate bonds payable,應付公司債溢(折)價,应付公司债溢(折)价
 | 
				
			||||||
 | 
					2321,long-term loans payable – bank,長期銀行借款,长期银行借款
 | 
				
			||||||
 | 
					2324,long-term loans payable – owners,長期借款—業主,长期借款—业主
 | 
				
			||||||
 | 
					2325,long-term loans payable – employees,長期借款—員工,长期借款—员工
 | 
				
			||||||
 | 
					2327,long-term loans payable – related parties,長期借款—關係人,长期借款—关系人
 | 
				
			||||||
 | 
					2328,long-term loans payable – other,長期借款—其他,长期借款—其他
 | 
				
			||||||
 | 
					2331,long-term notes payable,長期應付票據,长期应付票据
 | 
				
			||||||
 | 
					2332,long-term accounts pay-able,長期應付帳款,长期应付帐款
 | 
				
			||||||
 | 
					2333,long-term capital lease liabilities,長期應付租賃負債,长期应付租赁负债
 | 
				
			||||||
 | 
					2337,Long-term notes and accounts payable – related parties,長期應付票據及款項—關係人,长期应付票据及款项—关系人
 | 
				
			||||||
 | 
					2338,other long-term payables,其他長期應付款項,其他长期应付款项
 | 
				
			||||||
 | 
					2341,estimated accrued land value incremental tax pay-able,估計應付土地增值稅,估计应付土地增值税
 | 
				
			||||||
 | 
					2351,accrued pension liabilities,應計退休金負債,应计退休金负债
 | 
				
			||||||
 | 
					2388,other long-term liabilities – other,其他長期負債—其他,其他长期负债—其他
 | 
				
			||||||
 | 
					2811,deferred revenue,遞延收入,递延收入
 | 
				
			||||||
 | 
					2814,deferred income tax liabilities,遞延所得稅負債,递延所得税负债
 | 
				
			||||||
 | 
					2818,other deferred liabilities,其他遞延負債,其他递延负债
 | 
				
			||||||
 | 
					2861,guarantee deposit received,存入保證金,存入保证金
 | 
				
			||||||
 | 
					2888,miscellaneous liabilities – other,雜項負債—其他,杂项负债—其他
 | 
				
			||||||
 | 
					3111,capital – common stock,普通股股本,普通股股本
 | 
				
			||||||
 | 
					3112,capital – preferred stock,特別股股本,特别股股本
 | 
				
			||||||
 | 
					3113,capital collected in advance,預收股本,预收股本
 | 
				
			||||||
 | 
					3114,stock dividends to be distributed,待分配股票股利,待分配股票股利
 | 
				
			||||||
 | 
					3115,capital,資本,资本
 | 
				
			||||||
 | 
					3211,paid-in capital in excess of par- common stock,普通股股票溢價,普通股股票溢价
 | 
				
			||||||
 | 
					3212,paid-in capital in excess of par- preferred stock,特別股股票溢價,特别股股票溢价
 | 
				
			||||||
 | 
					3231,capital surplus from assets revaluation,資產重估增值準備,资产重估增值准备
 | 
				
			||||||
 | 
					3241,capital surplus from gain on disposal of assets,處分資產溢價公積,处分资产溢价公积
 | 
				
			||||||
 | 
					3251,capital surplus from business combination,合併公積,合并公积
 | 
				
			||||||
 | 
					3261,donated surplus,受贈公積,受赠公积
 | 
				
			||||||
 | 
					3281,additional paid-in capital from investee under equity method,權益法長期股權投資資本公積,权益法长期股权投资资本公积
 | 
				
			||||||
 | 
					3282,additional paid-in capital – treasury stock trans-actions,資本公積—庫藏股票交易,资本公积—库藏股票交易
 | 
				
			||||||
 | 
					3311,legal reserve,法定盈餘公積,法定盈余公积
 | 
				
			||||||
 | 
					3321,contingency reserve,意外損失準備,意外损失准备
 | 
				
			||||||
 | 
					3322,improvement and expansion reserve,改良擴充準備,改良扩充准备
 | 
				
			||||||
 | 
					3323,special reserve for redemption of liabilities,償債準備,偿债准备
 | 
				
			||||||
 | 
					3328,other special reserve,其他特別盈餘公積,其他特别盈余公积
 | 
				
			||||||
 | 
					3351,accumulated profit or loss,累積盈虧,累积盈亏
 | 
				
			||||||
 | 
					3352,prior period adjustments,前期損益調整,前期损益调整
 | 
				
			||||||
 | 
					3353,net income or loss for current period,本期損益,本期损益
 | 
				
			||||||
 | 
					3411,unrealized loss on market value decline of long-term equity investments,長期股權投資未實現跌價損失,长期股权投资未实现跌价损失
 | 
				
			||||||
 | 
					3421,cumulative translation adjustments,累積換算調整數,累积换算调整数
 | 
				
			||||||
 | 
					3431,net loss not recognized as pension costs,未認列為退休金成本之淨損失,未认列为退休金成本之净损失
 | 
				
			||||||
 | 
					3511,treasury stock,庫藏股,库藏股
 | 
				
			||||||
 | 
					3611,minority interest,少數股權,少数股权
 | 
				
			||||||
 | 
					4111,sales revenue,銷貨收入,销货收入
 | 
				
			||||||
 | 
					4112,installment sales revenue,分期付款銷貨收入,分期付款销货收入
 | 
				
			||||||
 | 
					4171,sales return,銷貨退回,销货退回
 | 
				
			||||||
 | 
					4191,sales discounts and allowances,銷貨折讓,销货折让
 | 
				
			||||||
 | 
					4611,service revenue,勞務收入,劳务收入
 | 
				
			||||||
 | 
					4711,agency revenue,業務收入,业务收入
 | 
				
			||||||
 | 
					4888,other operating revenue – other,其他營業收入—其他,其他营业收入—其他
 | 
				
			||||||
 | 
					5111,cost of goods sold,銷貨成本,销货成本
 | 
				
			||||||
 | 
					5112,installment cost of goods sold,分期付款銷貨成本,分期付款销货成本
 | 
				
			||||||
 | 
					5121,purchases,進貨,进货
 | 
				
			||||||
 | 
					5122,purchase expenses,進貨費用,进货费用
 | 
				
			||||||
 | 
					5123,purchase returns,進貨退出,进货退出
 | 
				
			||||||
 | 
					5124,charges on purchased merchandise,進貨折讓,进货折让
 | 
				
			||||||
 | 
					5131,material purchased,進料,进料
 | 
				
			||||||
 | 
					5132,charges on purchased material,進料費用,进料费用
 | 
				
			||||||
 | 
					5133,material purchase returns,進料退出,进料退出
 | 
				
			||||||
 | 
					5134,material purchase allowances,進料折讓,进料折让
 | 
				
			||||||
 | 
					5141,direct labor,直接人工,直接人工
 | 
				
			||||||
 | 
					5151,indirect labor,間接人工,间接人工
 | 
				
			||||||
 | 
					5152,"rent expense, rent",租金支出,租金支出
 | 
				
			||||||
 | 
					5153,office supplies (expense),文具用品,文具用品
 | 
				
			||||||
 | 
					5154,"travelling expense, travel",旅費,旅费
 | 
				
			||||||
 | 
					5155,"shipping expenses, freight",運費,运费
 | 
				
			||||||
 | 
					5156,postage (expenses),郵電費,邮电费
 | 
				
			||||||
 | 
					5157,repair (s) and maintenance (expense ),修繕費,修缮费
 | 
				
			||||||
 | 
					5158,packing expenses,包裝費,包装费
 | 
				
			||||||
 | 
					5161,utilities (expense),水電瓦斯費,水电瓦斯费
 | 
				
			||||||
 | 
					5162,insurance (expense),保險費,保险费
 | 
				
			||||||
 | 
					5163,manufacturing overhead – outsourced,加工費,加工费
 | 
				
			||||||
 | 
					5166,taxes,稅捐,税捐
 | 
				
			||||||
 | 
					5168,depreciation expense,折舊,折旧
 | 
				
			||||||
 | 
					5169,various amortization,各項耗竭及攤提,各项耗竭及摊提
 | 
				
			||||||
 | 
					5172,meal (expenses),伙食費,伙食费
 | 
				
			||||||
 | 
					5173,employee benefits/welfare,職工福利,职工福利
 | 
				
			||||||
 | 
					5176,training (expense),訓練費,训练费
 | 
				
			||||||
 | 
					5177,indirect materials,間接材料,间接材料
 | 
				
			||||||
 | 
					5188,other manufacturing expenses,其他製造費用,其他制造费用
 | 
				
			||||||
 | 
					5611,service costs,勞務成本,劳务成本
 | 
				
			||||||
 | 
					5711,agency costs,業務成本,业务成本
 | 
				
			||||||
 | 
					5888,other operating costs – other,其他營業成本—其他,其他营业成本—其他
 | 
				
			||||||
 | 
					6151,payroll expense,薪資支出,薪资支出
 | 
				
			||||||
 | 
					6152,"rent expense, rent",租金支出,租金支出
 | 
				
			||||||
 | 
					6153,office supplies (expense),文具用品,文具用品
 | 
				
			||||||
 | 
					6154,"travelling expense, travel",旅費,旅费
 | 
				
			||||||
 | 
					6155,"shipping expenses, freight",運費,运费
 | 
				
			||||||
 | 
					6156,postage (expenses),郵電費,邮电费
 | 
				
			||||||
 | 
					6157,repair (s) and maintenance (expense),修繕費,修缮费
 | 
				
			||||||
 | 
					6159,"advertisement expense, advertisement",廣告費,广告费
 | 
				
			||||||
 | 
					6161,utilities (expense),水電瓦斯費,水电瓦斯费
 | 
				
			||||||
 | 
					6162,insurance (expense),保險費,保险费
 | 
				
			||||||
 | 
					6164,entertainment (expense),交際費,交际费
 | 
				
			||||||
 | 
					6165,donation (expense),捐贈,捐赠
 | 
				
			||||||
 | 
					6166,taxes,稅捐,税捐
 | 
				
			||||||
 | 
					6167,loss on uncollectible accounts,呆帳損失,呆帐损失
 | 
				
			||||||
 | 
					6168,depreciation expense,折舊,折旧
 | 
				
			||||||
 | 
					6169,various amortization,各項耗竭及攤提,各项耗竭及摊提
 | 
				
			||||||
 | 
					6172,meal (expenses),伙食費,伙食费
 | 
				
			||||||
 | 
					6173,employee benefits/welfare,職工福利,职工福利
 | 
				
			||||||
 | 
					6175,commission (expense),佣金支出,佣金支出
 | 
				
			||||||
 | 
					6176,training (expense),訓練費,训练费
 | 
				
			||||||
 | 
					6188,other selling expenses,其他推銷費用,其他推销费用
 | 
				
			||||||
 | 
					6251,payroll expense,薪資支出,薪资支出
 | 
				
			||||||
 | 
					6252,"rent expense, rent",租金支出,租金支出
 | 
				
			||||||
 | 
					6253,office supplies,文具用品,文具用品
 | 
				
			||||||
 | 
					6254,"travelling expense, travel",旅費,旅费
 | 
				
			||||||
 | 
					6255,"shipping expenses,freight",運費,运费
 | 
				
			||||||
 | 
					6256,postage (expenses),郵電費,邮电费
 | 
				
			||||||
 | 
					6257,repair (s) and maintenance (expense),修繕費,修缮费
 | 
				
			||||||
 | 
					6259,"advertisement expense, advertisement",廣告費,广告费
 | 
				
			||||||
 | 
					6261,utilities (expense),水電瓦斯費,水电瓦斯费
 | 
				
			||||||
 | 
					6262,insurance (expense),保險費,保险费
 | 
				
			||||||
 | 
					6264,entertainment (expense),交際費,交际费
 | 
				
			||||||
 | 
					6265,donation (expense),捐贈,捐赠
 | 
				
			||||||
 | 
					6266,taxes,稅捐,税捐
 | 
				
			||||||
 | 
					6267,loss on uncollectible accounts,呆帳損失,呆帐损失
 | 
				
			||||||
 | 
					6268,depreciation expense,折舊,折旧
 | 
				
			||||||
 | 
					6269,various amortization,各項耗竭及攤提,各项耗竭及摊提
 | 
				
			||||||
 | 
					6271,loss on export sales,外銷損失,外销损失
 | 
				
			||||||
 | 
					6272,meal (expenses),伙食費,伙食费
 | 
				
			||||||
 | 
					6273,employee benefits/welfare,職工福利,职工福利
 | 
				
			||||||
 | 
					6274,research and development expense,研究發展費用,研究发展费用
 | 
				
			||||||
 | 
					6275,commission (expense),佣金支出,佣金支出
 | 
				
			||||||
 | 
					6276,training (expense),訓練費,训练费
 | 
				
			||||||
 | 
					6278,professional service fees,勞務費,劳务费
 | 
				
			||||||
 | 
					6288,other general and administrative expenses,其他管理及總務費用,其他管理及总务费用
 | 
				
			||||||
 | 
					6351,payroll expense,薪資支出,薪资支出
 | 
				
			||||||
 | 
					6352,"rent expense, rent",租金支出,租金支出
 | 
				
			||||||
 | 
					6353,office supplies,文具用品,文具用品
 | 
				
			||||||
 | 
					6354,"travelling expense, travel",旅費,旅费
 | 
				
			||||||
 | 
					6355,"shipping expenses, freight",運費,运费
 | 
				
			||||||
 | 
					6356,postage (expenses),郵電費,邮电费
 | 
				
			||||||
 | 
					6357,repair (s) and maintenance (expense),修繕費,修缮费
 | 
				
			||||||
 | 
					6361,utilities (expense),水電瓦斯費,水电瓦斯费
 | 
				
			||||||
 | 
					6362,insurance (expense),保險費,保险费
 | 
				
			||||||
 | 
					6364,entertainment (expense),交際費,交际费
 | 
				
			||||||
 | 
					6366,taxes,稅捐,税捐
 | 
				
			||||||
 | 
					6368,depreciation expense,折舊,折旧
 | 
				
			||||||
 | 
					6369,various amortization,各項耗竭及攤提,各项耗竭及摊提
 | 
				
			||||||
 | 
					6372,meal (expenses),伙食費,伙食费
 | 
				
			||||||
 | 
					6373,employee benefits/welfare,職工福利,职工福利
 | 
				
			||||||
 | 
					6376,training (expense),訓練費,训练费
 | 
				
			||||||
 | 
					6378,other research and development expenses,其他研究發展費用,其他研究发展费用
 | 
				
			||||||
 | 
					7111,interest revenue/income,利息收入,利息收入
 | 
				
			||||||
 | 
					7121,investment income recognized under equity method,權益法認列之投資收益,权益法认列之投资收益
 | 
				
			||||||
 | 
					7122,dividends income,股利收入,股利收入
 | 
				
			||||||
 | 
					7123,gain on market price recovery of short-term investment,短期投資市價回升利益,短期投资市价回升利益
 | 
				
			||||||
 | 
					7131,foreign exchange gain,兌換利益,兑换利益
 | 
				
			||||||
 | 
					7141,gain on disposal of investments,處分投資收益,处分投资收益
 | 
				
			||||||
 | 
					7151,gain on disposal of assets,處分資產溢價收入,处分资产溢价收入
 | 
				
			||||||
 | 
					7481,donation income,捐贈收入,捐赠收入
 | 
				
			||||||
 | 
					7482,rent revenue/income,租金收入,租金收入
 | 
				
			||||||
 | 
					7483,commission revenue/income,佣金收入,佣金收入
 | 
				
			||||||
 | 
					7484,revenue from sale of scraps,出售下腳及廢料收入,出售下脚及废料收入
 | 
				
			||||||
 | 
					7485,gain on physical inventory,存貨盤盈,存货盘盈
 | 
				
			||||||
 | 
					7486,gain from price recovery of inventory,存貨跌價回升利益,存货跌价回升利益
 | 
				
			||||||
 | 
					7487,gain on reversal of bad debts,壞帳轉回利益,坏帐转回利益
 | 
				
			||||||
 | 
					7488,other non-operating revenue – other items,其他營業外收入—其他,其他营业外收入—其他
 | 
				
			||||||
 | 
					7511,interest expense,利息費用,利息费用
 | 
				
			||||||
 | 
					7521,investment loss recognized under equity method,權益法認列之投資損失,权益法认列之投资损失
 | 
				
			||||||
 | 
					7523,unrealized loss on reduction of short-term investments to market,短期投資未實現跌價損失,短期投资未实现跌价损失
 | 
				
			||||||
 | 
					7531,foreign exchange loss,兌換損失,兑换损失
 | 
				
			||||||
 | 
					7541,loss on disposal of investments,處分投資損失,处分投资损失
 | 
				
			||||||
 | 
					7551,loss on disposal of assets,處分資產損失,处分资产损失
 | 
				
			||||||
 | 
					7881,loss on work stoppages,停工損失,停工损失
 | 
				
			||||||
 | 
					7882,casualty loss,災害損失,灾害损失
 | 
				
			||||||
 | 
					7885,loss on physical inventory,存貨盤損,存货盘损
 | 
				
			||||||
 | 
					7886,loss for market price decline and obsolete and slow-moving inventories,存貨跌價及呆滯損失,存货跌价及呆滞损失
 | 
				
			||||||
 | 
					7888,other non-operating expenses – other,其他營業外費用—其他,其他营业外费用—其他
 | 
				
			||||||
 | 
					8111,income tax expense ( or benefit),所得稅費用(或利益),所得税费用(或利益)
 | 
				
			||||||
 | 
					9111,income (loss) from operations of discontinued segment,停業部門損益—停業前營業損益,停业部门损益—停业前营业损益
 | 
				
			||||||
 | 
					9121,gain (loss) from disposal of discontinued segment,停業部門損益—處分損益,停业部门损益—处分损益
 | 
				
			||||||
 | 
					9211,extraordinary gain or loss,非常損益,非常损益
 | 
				
			||||||
 | 
					9311,cumulative effect of changes in accounting principles,會計原則變動累積影響數,会计原则变动累积影响数
 | 
				
			||||||
 | 
					9411,minority interest income,少數股權淨利,少数股权净利
 | 
				
			||||||
		
		
			
  | 
							
								
								
									
										10
									
								
								src/accounting/data/currencies.csv
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/accounting/data/currencies.csv
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,10 @@
 | 
				
			|||||||
 | 
					code,name,l10n-zh_Hant,l10n-zh_Hans
 | 
				
			||||||
 | 
					TWD,New Taiwan dollar,新臺幣,新台币
 | 
				
			||||||
 | 
					USD,United States dollar,美元,美元
 | 
				
			||||||
 | 
					JPY,Japanese yen,日圓,日圆
 | 
				
			||||||
 | 
					CNY,Renminbi,人民幣,人民币
 | 
				
			||||||
 | 
					HKD,Hong Kong dollar,港元,港元
 | 
				
			||||||
 | 
					EUR,Euro,歐元,欧元
 | 
				
			||||||
 | 
					MOP,Macanese pataca,澳門元,澳门元
 | 
				
			||||||
 | 
					AUD,Australian dollar,澳洲元,澳大利亚元
 | 
				
			||||||
 | 
					ETH,Ethereum,以太坊,以太坊
 | 
				
			||||||
		
		
			
  | 
@@ -22,9 +22,10 @@ initialized at compile time, but as a submodule it is only available at run
 | 
				
			|||||||
time.
 | 
					time.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
"""
 | 
					"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from flask_sqlalchemy import SQLAlchemy
 | 
					from flask_sqlalchemy import SQLAlchemy
 | 
				
			||||||
 | 
					
 | 
				
			||||||
db: SQLAlchemy
 | 
					db: SQLAlchemy = SQLAlchemy()
 | 
				
			||||||
"""The database instance."""
 | 
					"""The database instance."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -39,6 +39,17 @@ def gettext(string, **variables) -> str:
 | 
				
			|||||||
    return domain.gettext(string, **variables)
 | 
					    return domain.gettext(string, **variables)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def pgettext(context, string, **variables) -> str:
 | 
				
			||||||
 | 
					    """A replacement of the Babel gettext() function..
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :param context: The context.
 | 
				
			||||||
 | 
					    :param string: The message to translate.
 | 
				
			||||||
 | 
					    :param variables: The variable substitution.
 | 
				
			||||||
 | 
					    :return: The translated message.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    return domain.pgettext(context, string, **variables)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def lazy_gettext(string, **variables) -> LazyString:
 | 
					def lazy_gettext(string, **variables) -> LazyString:
 | 
				
			||||||
    """A replacement of the Babel lazy_gettext() function..
 | 
					    """A replacement of the Babel lazy_gettext() function..
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -105,8 +116,8 @@ def __babel_js_catalog_view() -> Response:
 | 
				
			|||||||
def init_app(app: Flask, bp: Blueprint) -> None:
 | 
					def init_app(app: Flask, bp: Blueprint) -> None:
 | 
				
			||||||
    """Initializes the application.
 | 
					    """Initializes the application.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    :param bp: The blueprint of the accounting application.
 | 
					 | 
				
			||||||
    :param app: The Flask application.
 | 
					    :param app: The Flask application.
 | 
				
			||||||
 | 
					    :param bp: The blueprint of the accounting application.
 | 
				
			||||||
    :return: None.
 | 
					    :return: None.
 | 
				
			||||||
    """
 | 
					    """
 | 
				
			||||||
    bp.add_url_rule("/_jstrans.js", "babel_catalog",
 | 
					    bp.add_url_rule("/_jstrans.js", "babel_catalog",
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										452
									
								
								src/accounting/models.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										452
									
								
								src/accounting/models.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,452 @@
 | 
				
			|||||||
 | 
					# The Mia! Accounting Flask Project.
 | 
				
			||||||
 | 
					# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#  Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					#  you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					#  You may obtain a copy of the License at
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#      http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					#  distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					#  See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					#  limitations under the License.
 | 
				
			||||||
 | 
					"""The data models.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					import re
 | 
				
			||||||
 | 
					import typing as t
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import sqlalchemy as sa
 | 
				
			||||||
 | 
					from flask import current_app
 | 
				
			||||||
 | 
					from flask_babel import get_locale
 | 
				
			||||||
 | 
					from sqlalchemy import text
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from accounting.database import db
 | 
				
			||||||
 | 
					from accounting.utils.user import user_cls, user_pk_column
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class BaseAccount(db.Model):
 | 
				
			||||||
 | 
					    """A base account."""
 | 
				
			||||||
 | 
					    __tablename__ = "accounting_base_accounts"
 | 
				
			||||||
 | 
					    """The table name."""
 | 
				
			||||||
 | 
					    code = db.Column(db.String, nullable=False, primary_key=True)
 | 
				
			||||||
 | 
					    """The code."""
 | 
				
			||||||
 | 
					    title_l10n = db.Column("title", db.String, nullable=False)
 | 
				
			||||||
 | 
					    """The title."""
 | 
				
			||||||
 | 
					    l10n = db.relationship("BaseAccountL10n", back_populates="account",
 | 
				
			||||||
 | 
					                           lazy=False)
 | 
				
			||||||
 | 
					    """The localized titles."""
 | 
				
			||||||
 | 
					    accounts = db.relationship("Account", back_populates="base")
 | 
				
			||||||
 | 
					    """The descendant accounts under the base account."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __str__(self) -> str:
 | 
				
			||||||
 | 
					        """Returns the string representation of the base account.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The string representation of the base account.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return F"{self.code} {self.title}"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def title(self) -> str:
 | 
				
			||||||
 | 
					        """Returns the title in the current locale.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The title in the current locale.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        current_locale = str(get_locale())
 | 
				
			||||||
 | 
					        if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
 | 
				
			||||||
 | 
					            return self.title_l10n
 | 
				
			||||||
 | 
					        for l10n in self.l10n:
 | 
				
			||||||
 | 
					            if l10n.locale == current_locale:
 | 
				
			||||||
 | 
					                return l10n.title
 | 
				
			||||||
 | 
					        return self.title_l10n
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def query_values(self) -> list[str]:
 | 
				
			||||||
 | 
					        """Returns the values to be queried.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The values to be queried.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return [self.code, self.title_l10n] + [x.title for x in self.l10n]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class BaseAccountL10n(db.Model):
 | 
				
			||||||
 | 
					    """A localized base account title."""
 | 
				
			||||||
 | 
					    __tablename__ = "accounting_base_accounts_l10n"
 | 
				
			||||||
 | 
					    """The table name."""
 | 
				
			||||||
 | 
					    account_code = db.Column(db.String,
 | 
				
			||||||
 | 
					                             db.ForeignKey(BaseAccount.code,
 | 
				
			||||||
 | 
					                                           onupdate="CASCADE",
 | 
				
			||||||
 | 
					                                           ondelete="CASCADE"),
 | 
				
			||||||
 | 
					                             nullable=False, primary_key=True)
 | 
				
			||||||
 | 
					    """The code of the account."""
 | 
				
			||||||
 | 
					    account = db.relationship(BaseAccount, back_populates="l10n")
 | 
				
			||||||
 | 
					    """The account."""
 | 
				
			||||||
 | 
					    locale = db.Column(db.String, nullable=False, primary_key=True)
 | 
				
			||||||
 | 
					    """The locale."""
 | 
				
			||||||
 | 
					    title = db.Column(db.String, nullable=False)
 | 
				
			||||||
 | 
					    """The localized title."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Account(db.Model):
 | 
				
			||||||
 | 
					    """An account."""
 | 
				
			||||||
 | 
					    __tablename__ = "accounting_accounts"
 | 
				
			||||||
 | 
					    """The table name."""
 | 
				
			||||||
 | 
					    id = db.Column(db.Integer, nullable=False, primary_key=True,
 | 
				
			||||||
 | 
					                   autoincrement=False)
 | 
				
			||||||
 | 
					    """The account ID."""
 | 
				
			||||||
 | 
					    base_code = db.Column(db.String,
 | 
				
			||||||
 | 
					                          db.ForeignKey(BaseAccount.code, onupdate="CASCADE",
 | 
				
			||||||
 | 
					                                        ondelete="CASCADE"),
 | 
				
			||||||
 | 
					                          nullable=False)
 | 
				
			||||||
 | 
					    """The code of the base account."""
 | 
				
			||||||
 | 
					    base = db.relationship(BaseAccount, back_populates="accounts")
 | 
				
			||||||
 | 
					    """The base account."""
 | 
				
			||||||
 | 
					    no = db.Column(db.Integer, nullable=False, default=text("1"))
 | 
				
			||||||
 | 
					    """The account number under the base account."""
 | 
				
			||||||
 | 
					    title_l10n = db.Column("title", db.String, nullable=False)
 | 
				
			||||||
 | 
					    """The title."""
 | 
				
			||||||
 | 
					    is_offset_needed = db.Column(db.Boolean, nullable=False, default=False)
 | 
				
			||||||
 | 
					    """Whether the entries of this account need offsets."""
 | 
				
			||||||
 | 
					    created_at = db.Column(db.DateTime(timezone=True), nullable=False,
 | 
				
			||||||
 | 
					                           server_default=db.func.now())
 | 
				
			||||||
 | 
					    """The time of creation."""
 | 
				
			||||||
 | 
					    created_by_id = db.Column(db.Integer,
 | 
				
			||||||
 | 
					                              db.ForeignKey(user_pk_column,
 | 
				
			||||||
 | 
					                                            onupdate="CASCADE"),
 | 
				
			||||||
 | 
					                              nullable=False)
 | 
				
			||||||
 | 
					    """The ID of the creator."""
 | 
				
			||||||
 | 
					    created_by = db.relationship(user_cls, foreign_keys=created_by_id)
 | 
				
			||||||
 | 
					    """The creator."""
 | 
				
			||||||
 | 
					    updated_at = db.Column(db.DateTime(timezone=True), nullable=False,
 | 
				
			||||||
 | 
					                           server_default=db.func.now())
 | 
				
			||||||
 | 
					    """The time of last update."""
 | 
				
			||||||
 | 
					    updated_by_id = db.Column(db.Integer,
 | 
				
			||||||
 | 
					                              db.ForeignKey(user_pk_column,
 | 
				
			||||||
 | 
					                                            onupdate="CASCADE"),
 | 
				
			||||||
 | 
					                              nullable=False)
 | 
				
			||||||
 | 
					    """The ID of the updator."""
 | 
				
			||||||
 | 
					    updated_by = db.relationship(user_cls, foreign_keys=updated_by_id)
 | 
				
			||||||
 | 
					    """The updator."""
 | 
				
			||||||
 | 
					    l10n = db.relationship("AccountL10n", back_populates="account",
 | 
				
			||||||
 | 
					                           lazy=False)
 | 
				
			||||||
 | 
					    """The localized titles."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    __CASH = "1111-001"
 | 
				
			||||||
 | 
					    """The code of the cash account,"""
 | 
				
			||||||
 | 
					    __RECEIVABLE = "1141-001"
 | 
				
			||||||
 | 
					    """The code of the receivable account,"""
 | 
				
			||||||
 | 
					    __PAYABLE = "2141-001"
 | 
				
			||||||
 | 
					    """The code of the payable account,"""
 | 
				
			||||||
 | 
					    __ACCUMULATED_CHANGE = "3351-001"
 | 
				
			||||||
 | 
					    """The code of the accumulated-change account,"""
 | 
				
			||||||
 | 
					    __BROUGHT_FORWARD = "3352-001"
 | 
				
			||||||
 | 
					    """The code of the brought-forward account,"""
 | 
				
			||||||
 | 
					    __NET_CHANGE = "3353-001"
 | 
				
			||||||
 | 
					    """The code of the net-change account,"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __str__(self) -> str:
 | 
				
			||||||
 | 
					        """Returns the string representation of this account.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The string representation of this account.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return F"{self.base_code}-{self.no:03d} {self.title}"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def code(self) -> str:
 | 
				
			||||||
 | 
					        """Returns the code.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The code.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return F"{self.base_code}-{self.no:03d}"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def title(self) -> str:
 | 
				
			||||||
 | 
					        """Returns the title in the current locale.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The title in the current locale.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        current_locale = str(get_locale())
 | 
				
			||||||
 | 
					        if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
 | 
				
			||||||
 | 
					            return self.title_l10n
 | 
				
			||||||
 | 
					        for l10n in self.l10n:
 | 
				
			||||||
 | 
					            if l10n.locale == current_locale:
 | 
				
			||||||
 | 
					                return l10n.title
 | 
				
			||||||
 | 
					        return self.title_l10n
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @title.setter
 | 
				
			||||||
 | 
					    def title(self, value: str) -> None:
 | 
				
			||||||
 | 
					        """Sets the title in the current locale.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param value: The new title.
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        if self.title_l10n is None:
 | 
				
			||||||
 | 
					            self.title_l10n = value
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        current_locale = str(get_locale())
 | 
				
			||||||
 | 
					        if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
 | 
				
			||||||
 | 
					            self.title_l10n = value
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        for l10n in self.l10n:
 | 
				
			||||||
 | 
					            if l10n.locale == current_locale:
 | 
				
			||||||
 | 
					                l10n.title = value
 | 
				
			||||||
 | 
					                return
 | 
				
			||||||
 | 
					        self.l10n.append(AccountL10n(locale=current_locale, title=value))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @classmethod
 | 
				
			||||||
 | 
					    def find_by_code(cls, code: str) -> t.Self | None:
 | 
				
			||||||
 | 
					        """Finds an account by its code.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param code: The code.
 | 
				
			||||||
 | 
					        :return: The account, or None if this account does not exist.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        m = re.match("^([1-9]{4})-([0-9]{3})$", code)
 | 
				
			||||||
 | 
					        if m is None:
 | 
				
			||||||
 | 
					            return None
 | 
				
			||||||
 | 
					        return cls.query.filter(cls.base_code == m.group(1),
 | 
				
			||||||
 | 
					                                cls.no == int(m.group(2))).first()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @classmethod
 | 
				
			||||||
 | 
					    def debit(cls) -> list[t.Self]:
 | 
				
			||||||
 | 
					        """Returns the debit accounts.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The debit accounts.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return cls.query.filter(sa.or_(cls.base_code.startswith("1"),
 | 
				
			||||||
 | 
					                                       cls.base_code.startswith("2"),
 | 
				
			||||||
 | 
					                                       cls.base_code.startswith("3"),
 | 
				
			||||||
 | 
					                                       cls.base_code.startswith("5"),
 | 
				
			||||||
 | 
					                                       cls.base_code.startswith("6"),
 | 
				
			||||||
 | 
					                                       cls.base_code.startswith("75"),
 | 
				
			||||||
 | 
					                                       cls.base_code.startswith("76"),
 | 
				
			||||||
 | 
					                                       cls.base_code.startswith("77"),
 | 
				
			||||||
 | 
					                                       cls.base_code.startswith("78"),
 | 
				
			||||||
 | 
					                                       cls.base_code.startswith("8"),
 | 
				
			||||||
 | 
					                                       cls.base_code.startswith("9")),
 | 
				
			||||||
 | 
					                                cls.base_code != "3351",
 | 
				
			||||||
 | 
					                                cls.base_code != "3353")\
 | 
				
			||||||
 | 
					            .order_by(cls.base_code, cls.no).all()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @classmethod
 | 
				
			||||||
 | 
					    def credit(cls) -> list[t.Self]:
 | 
				
			||||||
 | 
					        """Returns the debit accounts.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The debit accounts.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return cls.query.filter(sa.or_(cls.base_code.startswith("1"),
 | 
				
			||||||
 | 
					                                       cls.base_code.startswith("2"),
 | 
				
			||||||
 | 
					                                       cls.base_code.startswith("3"),
 | 
				
			||||||
 | 
					                                       cls.base_code.startswith("4"),
 | 
				
			||||||
 | 
					                                       cls.base_code.startswith("71"),
 | 
				
			||||||
 | 
					                                       cls.base_code.startswith("72"),
 | 
				
			||||||
 | 
					                                       cls.base_code.startswith("73"),
 | 
				
			||||||
 | 
					                                       cls.base_code.startswith("74"),
 | 
				
			||||||
 | 
					                                       cls.base_code.startswith("8"),
 | 
				
			||||||
 | 
					                                       cls.base_code.startswith("9")),
 | 
				
			||||||
 | 
					                                cls.base_code != "3351",
 | 
				
			||||||
 | 
					                                cls.base_code != "3353")\
 | 
				
			||||||
 | 
					            .order_by(cls.base_code, cls.no).all()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @classmethod
 | 
				
			||||||
 | 
					    def cash(cls) -> t.Self:
 | 
				
			||||||
 | 
					        """Returns the cash account.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The cash account
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return cls.find_by_code(cls.__CASH)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @classmethod
 | 
				
			||||||
 | 
					    def receivable(cls) -> t.Self:
 | 
				
			||||||
 | 
					        """Returns the receivable account.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The receivable account
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return cls.find_by_code(cls.__RECEIVABLE)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @classmethod
 | 
				
			||||||
 | 
					    def payable(cls) -> t.Self:
 | 
				
			||||||
 | 
					        """Returns the payable account.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The payable account
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return cls.find_by_code(cls.__PAYABLE)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @classmethod
 | 
				
			||||||
 | 
					    def accumulated_change(cls) -> t.Self:
 | 
				
			||||||
 | 
					        """Returns the accumulated-change account.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The accumulated-change account
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return cls.find_by_code(cls.__ACCUMULATED_CHANGE)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @classmethod
 | 
				
			||||||
 | 
					    def brought_forward(cls) -> t.Self:
 | 
				
			||||||
 | 
					        """Returns the brought-forward account.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The brought-forward account
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return cls.find_by_code(cls.__BROUGHT_FORWARD)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @classmethod
 | 
				
			||||||
 | 
					    def net_change(cls) -> t.Self:
 | 
				
			||||||
 | 
					        """Returns the net-change account.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The net-change account
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return cls.find_by_code(cls.__NET_CHANGE)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def is_modified(self) -> bool:
 | 
				
			||||||
 | 
					        """Returns whether a product account was modified.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: True if modified, or False otherwise.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        if db.session.is_modified(self):
 | 
				
			||||||
 | 
					            return True
 | 
				
			||||||
 | 
					        for l10n in self.l10n:
 | 
				
			||||||
 | 
					            if db.session.is_modified(l10n):
 | 
				
			||||||
 | 
					                return True
 | 
				
			||||||
 | 
					        return False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def delete(self) -> None:
 | 
				
			||||||
 | 
					        """Deletes this account.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        AccountL10n.query.filter(AccountL10n.account == self).delete()
 | 
				
			||||||
 | 
					        cls: t.Type[t.Self] = self.__class__
 | 
				
			||||||
 | 
					        cls.query.filter(cls.id == self.id).delete()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class AccountL10n(db.Model):
 | 
				
			||||||
 | 
					    """A localized account title."""
 | 
				
			||||||
 | 
					    __tablename__ = "accounting_accounts_l10n"
 | 
				
			||||||
 | 
					    """The table name."""
 | 
				
			||||||
 | 
					    account_id = db.Column(db.Integer,
 | 
				
			||||||
 | 
					                           db.ForeignKey(Account.id, onupdate="CASCADE",
 | 
				
			||||||
 | 
					                                         ondelete="CASCADE"),
 | 
				
			||||||
 | 
					                           nullable=False, primary_key=True)
 | 
				
			||||||
 | 
					    """The account ID."""
 | 
				
			||||||
 | 
					    account = db.relationship(Account, back_populates="l10n")
 | 
				
			||||||
 | 
					    """The account."""
 | 
				
			||||||
 | 
					    locale = db.Column(db.String, nullable=False, primary_key=True)
 | 
				
			||||||
 | 
					    """The locale."""
 | 
				
			||||||
 | 
					    title = db.Column(db.String, nullable=False)
 | 
				
			||||||
 | 
					    """The localized title."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Currency(db.Model):
 | 
				
			||||||
 | 
					    """A currency."""
 | 
				
			||||||
 | 
					    __tablename__ = "accounting_currencies"
 | 
				
			||||||
 | 
					    """The table name."""
 | 
				
			||||||
 | 
					    code = db.Column(db.String, nullable=False, primary_key=True)
 | 
				
			||||||
 | 
					    """The code."""
 | 
				
			||||||
 | 
					    name_l10n = db.Column("name", db.String, nullable=False)
 | 
				
			||||||
 | 
					    """The name."""
 | 
				
			||||||
 | 
					    created_at = db.Column(db.DateTime(timezone=True), nullable=False,
 | 
				
			||||||
 | 
					                           server_default=db.func.now())
 | 
				
			||||||
 | 
					    """The time of creation."""
 | 
				
			||||||
 | 
					    created_by_id = db.Column(db.Integer,
 | 
				
			||||||
 | 
					                              db.ForeignKey(user_pk_column,
 | 
				
			||||||
 | 
					                                            onupdate="CASCADE"),
 | 
				
			||||||
 | 
					                              nullable=False)
 | 
				
			||||||
 | 
					    """The ID of the creator."""
 | 
				
			||||||
 | 
					    created_by = db.relationship(user_cls, foreign_keys=created_by_id)
 | 
				
			||||||
 | 
					    """The creator."""
 | 
				
			||||||
 | 
					    updated_at = db.Column(db.DateTime(timezone=True), nullable=False,
 | 
				
			||||||
 | 
					                           server_default=db.func.now())
 | 
				
			||||||
 | 
					    """The time of last update."""
 | 
				
			||||||
 | 
					    updated_by_id = db.Column(db.Integer,
 | 
				
			||||||
 | 
					                              db.ForeignKey(user_pk_column,
 | 
				
			||||||
 | 
					                                            onupdate="CASCADE"),
 | 
				
			||||||
 | 
					                              nullable=False)
 | 
				
			||||||
 | 
					    """The ID of the updator."""
 | 
				
			||||||
 | 
					    updated_by = db.relationship(user_cls, foreign_keys=updated_by_id)
 | 
				
			||||||
 | 
					    """The updator."""
 | 
				
			||||||
 | 
					    l10n = db.relationship("CurrencyL10n", back_populates="currency",
 | 
				
			||||||
 | 
					                           lazy=False)
 | 
				
			||||||
 | 
					    """The localized names."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __str__(self) -> str:
 | 
				
			||||||
 | 
					        """Returns the string representation of the currency.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The string representation of the currency.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return F"{self.name} ({self.code})"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def name(self) -> str:
 | 
				
			||||||
 | 
					        """Returns the name in the current locale.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The name in the current locale.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        current_locale = str(get_locale())
 | 
				
			||||||
 | 
					        if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
 | 
				
			||||||
 | 
					            return self.name_l10n
 | 
				
			||||||
 | 
					        for l10n in self.l10n:
 | 
				
			||||||
 | 
					            if l10n.locale == current_locale:
 | 
				
			||||||
 | 
					                return l10n.name
 | 
				
			||||||
 | 
					        return self.name_l10n
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @name.setter
 | 
				
			||||||
 | 
					    def name(self, value: str) -> None:
 | 
				
			||||||
 | 
					        """Sets the name in the current locale.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param value: The new name.
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        if self.name_l10n is None:
 | 
				
			||||||
 | 
					            self.name_l10n = value
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        current_locale = str(get_locale())
 | 
				
			||||||
 | 
					        if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
 | 
				
			||||||
 | 
					            self.name_l10n = value
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        for l10n in self.l10n:
 | 
				
			||||||
 | 
					            if l10n.locale == current_locale:
 | 
				
			||||||
 | 
					                l10n.name = value
 | 
				
			||||||
 | 
					                return
 | 
				
			||||||
 | 
					        self.l10n.append(CurrencyL10n(locale=current_locale, name=value))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def is_modified(self) -> bool:
 | 
				
			||||||
 | 
					        """Returns whether a product account was modified.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: True if modified, or False otherwise.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        if db.session.is_modified(self):
 | 
				
			||||||
 | 
					            return True
 | 
				
			||||||
 | 
					        for l10n in self.l10n:
 | 
				
			||||||
 | 
					            if db.session.is_modified(l10n):
 | 
				
			||||||
 | 
					                return True
 | 
				
			||||||
 | 
					        return False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def delete(self) -> None:
 | 
				
			||||||
 | 
					        """Deletes the currency.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        CurrencyL10n.query.filter(CurrencyL10n.currency == self).delete()
 | 
				
			||||||
 | 
					        cls: t.Type[t.Self] = self.__class__
 | 
				
			||||||
 | 
					        cls.query.filter(cls.code == self.code).delete()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class CurrencyL10n(db.Model):
 | 
				
			||||||
 | 
					    """A localized currency name."""
 | 
				
			||||||
 | 
					    __tablename__ = "accounting_currencies_l10n"
 | 
				
			||||||
 | 
					    """The table name."""
 | 
				
			||||||
 | 
					    currency_code = db.Column(db.String,
 | 
				
			||||||
 | 
					                              db.ForeignKey(Currency.code, onupdate="CASCADE",
 | 
				
			||||||
 | 
					                                            ondelete="CASCADE"),
 | 
				
			||||||
 | 
					                              nullable=False, primary_key=True)
 | 
				
			||||||
 | 
					    """The currency code."""
 | 
				
			||||||
 | 
					    currency = db.relationship(Currency, back_populates="l10n")
 | 
				
			||||||
 | 
					    """The currency."""
 | 
				
			||||||
 | 
					    locale = db.Column(db.String, nullable=False, primary_key=True)
 | 
				
			||||||
 | 
					    """The locale."""
 | 
				
			||||||
 | 
					    name = db.Column(db.String, nullable=False)
 | 
				
			||||||
 | 
					    """The localized name."""
 | 
				
			||||||
							
								
								
									
										108
									
								
								src/accounting/static/css/style.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										108
									
								
								src/accounting/static/css/style.css
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,108 @@
 | 
				
			|||||||
 | 
					/* The Mia! Accounting Flask Project
 | 
				
			||||||
 | 
					 * style.css: The style sheet for the accounting application.
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/*  Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					 *
 | 
				
			||||||
 | 
					 *  Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					 *  you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					 *  You may obtain a copy of the License at
 | 
				
			||||||
 | 
					 *
 | 
				
			||||||
 | 
					 *      http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					 *
 | 
				
			||||||
 | 
					 *  Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					 *  distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					 *  See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					 *  limitations under the License.
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Author: imacat@mail.imacat.idv.tw (imacat)
 | 
				
			||||||
 | 
					 * First written: 2023/2/1
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.accounting-clickable {
 | 
				
			||||||
 | 
					    cursor: pointer;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					.btn-group .btn .accounting-search-input {
 | 
				
			||||||
 | 
					    min-height: calc(1em + .5rem + 2px);
 | 
				
			||||||
 | 
					    padding: 0 0.5rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					.btn-group .btn .accounting-search-label button {
 | 
				
			||||||
 | 
					    border: none;
 | 
				
			||||||
 | 
					    background-color: transparent;
 | 
				
			||||||
 | 
					    color: inherit;
 | 
				
			||||||
 | 
					    padding-right: 0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/** The card layout */
 | 
				
			||||||
 | 
					.accounting-card {
 | 
				
			||||||
 | 
					    padding: 2em 1.5em;
 | 
				
			||||||
 | 
					    margin: 1em;
 | 
				
			||||||
 | 
					    background-color: #E9ECEF;
 | 
				
			||||||
 | 
					    border-radius: 0.3em;
 | 
				
			||||||
 | 
					    box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					.accounting-card-title {
 | 
				
			||||||
 | 
					    font-size: 1.8rem;
 | 
				
			||||||
 | 
					    font-weight: bolder;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					.accounting-card-code {
 | 
				
			||||||
 | 
					    font-size: 1.4rem;
 | 
				
			||||||
 | 
					    color: #373b3e;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/** The option selector */
 | 
				
			||||||
 | 
					.accounting-selector-list {
 | 
				
			||||||
 | 
					    height: 20rem;
 | 
				
			||||||
 | 
					    overflow-y: scroll;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* The Material Design text field (floating form control in Bootstrap) */
 | 
				
			||||||
 | 
					.accounting-material-text-field {
 | 
				
			||||||
 | 
					    position: relative;
 | 
				
			||||||
 | 
					    min-height: calc(3.5rem + 2px);
 | 
				
			||||||
 | 
					    padding-top: 1.625rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					.accounting-material-text-field > .form-label {
 | 
				
			||||||
 | 
					    position: absolute;
 | 
				
			||||||
 | 
					    top: 0;
 | 
				
			||||||
 | 
					    left: 0;
 | 
				
			||||||
 | 
					    height: calc(3.5rem + 2px);
 | 
				
			||||||
 | 
					    padding: 1rem 0.75rem;
 | 
				
			||||||
 | 
					    transform-origin: 0 0;
 | 
				
			||||||
 | 
					    transition: opacity .1s ease-in-out,transform .1s ease-in-out;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					.accounting-material-text-field.accounting-not-empty > .form-label {
 | 
				
			||||||
 | 
					    opacity: 0.65;
 | 
				
			||||||
 | 
					    transform: scale(.85) translateY(-.5rem) translateX(.15rem);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* The Material Design floating action buttons */
 | 
				
			||||||
 | 
					.accounting-material-fab {
 | 
				
			||||||
 | 
					    position: fixed;
 | 
				
			||||||
 | 
					    right: 2rem;
 | 
				
			||||||
 | 
					    bottom: 1rem;
 | 
				
			||||||
 | 
					    z-index: 10;
 | 
				
			||||||
 | 
					    flex-direction: column-reverse;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					.accounting-material-fab .btn {
 | 
				
			||||||
 | 
					    border-radius: 50%;
 | 
				
			||||||
 | 
					    transform: scale(1.5);
 | 
				
			||||||
 | 
					    box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.2), 0 6px 10px 0 rgba(0, 0, 0, 0.14), 0 1px 18px 0 rgba(0,0,0,.12);
 | 
				
			||||||
 | 
					    display: block;
 | 
				
			||||||
 | 
					    margin-top: 2.5rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					.accounting-material-fab .btn:hover, .accounting-material-fab .btn:focus {
 | 
				
			||||||
 | 
					    box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2), 0 8px 10px 1px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0,0,0,.12);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* The Material Design form switch */
 | 
				
			||||||
 | 
					@media(max-width:767px) {
 | 
				
			||||||
 | 
					    .form-switch {
 | 
				
			||||||
 | 
					        display: flex;
 | 
				
			||||||
 | 
					        flex-direction: row-reverse;
 | 
				
			||||||
 | 
					        justify-content: space-between;
 | 
				
			||||||
 | 
					        padding-left: 0;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										184
									
								
								src/accounting/static/js/account-form.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										184
									
								
								src/accounting/static/js/account-form.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,184 @@
 | 
				
			|||||||
 | 
					/* The Mia! Accounting Flask Project
 | 
				
			||||||
 | 
					 * account-form.js: The JavaScript for the account form
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/*  Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					 *
 | 
				
			||||||
 | 
					 *  Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					 *  you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					 *  You may obtain a copy of the License at
 | 
				
			||||||
 | 
					 *
 | 
				
			||||||
 | 
					 *      http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					 *
 | 
				
			||||||
 | 
					 *  Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					 *  distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					 *  See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					 *  limitations under the License.
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Author: imacat@mail.imacat.idv.tw (imacat)
 | 
				
			||||||
 | 
					 * First written: 2023/2/1
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Initializes the page JavaScript.
 | 
				
			||||||
 | 
					document.addEventListener("DOMContentLoaded", function () {
 | 
				
			||||||
 | 
					    initializeBaseAccountSelector();
 | 
				
			||||||
 | 
					    document.getElementById("accounting-base-code")
 | 
				
			||||||
 | 
					        .onchange = validateBase;
 | 
				
			||||||
 | 
					    document.getElementById("accounting-title")
 | 
				
			||||||
 | 
					        .onchange = validateTitle;
 | 
				
			||||||
 | 
					    document.getElementById("accounting-form")
 | 
				
			||||||
 | 
					        .onsubmit = validateForm;
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Initializes the base account selector.
 | 
				
			||||||
 | 
					 *
 | 
				
			||||||
 | 
					 * @private
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					function initializeBaseAccountSelector() {
 | 
				
			||||||
 | 
					    const selector = document.getElementById("accounting-base-selector-model");
 | 
				
			||||||
 | 
					    const base = document.getElementById("accounting-base");
 | 
				
			||||||
 | 
					    const baseCode = document.getElementById("accounting-base-code");
 | 
				
			||||||
 | 
					    const baseContent = document.getElementById("accounting-base-content");
 | 
				
			||||||
 | 
					    const options = Array.from(document.getElementsByClassName("accounting-base-option"));
 | 
				
			||||||
 | 
					    const btnClear = document.getElementById("accounting-btn-clear-base");
 | 
				
			||||||
 | 
					    selector.addEventListener("show.bs.modal", function () {
 | 
				
			||||||
 | 
					        base.classList.add("accounting-not-empty");
 | 
				
			||||||
 | 
					        options.forEach(function (item) {
 | 
				
			||||||
 | 
					            item.classList.remove("active");
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					        const selected = document.getElementById("accounting-base-option-" + baseCode.value);
 | 
				
			||||||
 | 
					        if (selected !== null) {
 | 
				
			||||||
 | 
					            selected.classList.add("active");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					    selector.addEventListener("hidden.bs.modal", function () {
 | 
				
			||||||
 | 
					        if (baseCode.value === "") {
 | 
				
			||||||
 | 
					            base.classList.remove("accounting-not-empty");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					    options.forEach(function (option) {
 | 
				
			||||||
 | 
					        option.onclick = function () {
 | 
				
			||||||
 | 
					            baseCode.value = option.dataset.code;
 | 
				
			||||||
 | 
					            baseContent.innerText = option.dataset.content;
 | 
				
			||||||
 | 
					            btnClear.classList.add("btn-danger");
 | 
				
			||||||
 | 
					            btnClear.classList.remove("btn-secondary")
 | 
				
			||||||
 | 
					            btnClear.disabled = false;
 | 
				
			||||||
 | 
					            validateBase();
 | 
				
			||||||
 | 
					            bootstrap.Modal.getInstance(selector).hide();
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					    btnClear.onclick = function () {
 | 
				
			||||||
 | 
					        baseCode.value = "";
 | 
				
			||||||
 | 
					        baseContent.innerText = "";
 | 
				
			||||||
 | 
					        btnClear.classList.add("btn-secondary")
 | 
				
			||||||
 | 
					        btnClear.classList.remove("btn-danger");
 | 
				
			||||||
 | 
					        btnClear.disabled = true;
 | 
				
			||||||
 | 
					        validateBase();
 | 
				
			||||||
 | 
					        bootstrap.Modal.getInstance(selector).hide();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    initializeBaseAccountQuery();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Initializes the query on the base account options.
 | 
				
			||||||
 | 
					 *
 | 
				
			||||||
 | 
					 * @private
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					function initializeBaseAccountQuery() {
 | 
				
			||||||
 | 
					    const query = document.getElementById("accounting-base-selector-query");
 | 
				
			||||||
 | 
					    const optionList = document.getElementById("accounting-base-option-list");
 | 
				
			||||||
 | 
					    const options = Array.from(document.getElementsByClassName("accounting-base-option"));
 | 
				
			||||||
 | 
					    const queryNoResult = document.getElementById("accounting-base-option-no-result");
 | 
				
			||||||
 | 
					    query.addEventListener("input", function () {
 | 
				
			||||||
 | 
					        console.log(query.value);
 | 
				
			||||||
 | 
					        if (query.value === "") {
 | 
				
			||||||
 | 
					            options.forEach(function (option) {
 | 
				
			||||||
 | 
					                option.classList.remove("d-none");
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					            optionList.classList.remove("d-none");
 | 
				
			||||||
 | 
					            queryNoResult.classList.add("d-none");
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        let hasAnyMatched = false;
 | 
				
			||||||
 | 
					        options.forEach(function (option) {
 | 
				
			||||||
 | 
					            const queryValues = JSON.parse(option.dataset.queryValues);
 | 
				
			||||||
 | 
					            let isMatched = false;
 | 
				
			||||||
 | 
					            for (let i = 0; i < queryValues.length; i++) {
 | 
				
			||||||
 | 
					                if (queryValues[i].includes(query.value)) {
 | 
				
			||||||
 | 
					                    isMatched = true;
 | 
				
			||||||
 | 
					                    break;
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            if (isMatched) {
 | 
				
			||||||
 | 
					                option.classList.remove("d-none");
 | 
				
			||||||
 | 
					                hasAnyMatched = true;
 | 
				
			||||||
 | 
					            } else {
 | 
				
			||||||
 | 
					                option.classList.add("d-none");
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					        if (!hasAnyMatched) {
 | 
				
			||||||
 | 
					            optionList.classList.add("d-none");
 | 
				
			||||||
 | 
					            queryNoResult.classList.remove("d-none");
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					            optionList.classList.remove("d-none");
 | 
				
			||||||
 | 
					            queryNoResult.classList.add("d-none");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Validates the form.
 | 
				
			||||||
 | 
					 *
 | 
				
			||||||
 | 
					 * @returns {boolean} true if valid, or false otherwise
 | 
				
			||||||
 | 
					 * @private
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					function validateForm() {
 | 
				
			||||||
 | 
					    let isValid = true;
 | 
				
			||||||
 | 
					    isValid = validateBase() && isValid;
 | 
				
			||||||
 | 
					    isValid = validateTitle() && isValid;
 | 
				
			||||||
 | 
					    return isValid;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Validates the base account.
 | 
				
			||||||
 | 
					 *
 | 
				
			||||||
 | 
					 * @returns {boolean} true if valid, or false otherwise
 | 
				
			||||||
 | 
					 * @private
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					function validateBase() {
 | 
				
			||||||
 | 
					    const field = document.getElementById("accounting-base-code");
 | 
				
			||||||
 | 
					    const error = document.getElementById("accounting-base-code-error");
 | 
				
			||||||
 | 
					    const displayField = document.getElementById("accounting-base");
 | 
				
			||||||
 | 
					    field.value = field.value.trim();
 | 
				
			||||||
 | 
					    if (field.value === "") {
 | 
				
			||||||
 | 
					        displayField.classList.add("is-invalid");
 | 
				
			||||||
 | 
					        error.innerText = A_("Please select the base account.");
 | 
				
			||||||
 | 
					        return false;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    displayField.classList.remove("is-invalid");
 | 
				
			||||||
 | 
					    error.innerText = "";
 | 
				
			||||||
 | 
					    return true;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Validates the title.
 | 
				
			||||||
 | 
					 *
 | 
				
			||||||
 | 
					 * @returns {boolean} true if valid, or false otherwise
 | 
				
			||||||
 | 
					 * @private
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					function validateTitle() {
 | 
				
			||||||
 | 
					    const field = document.getElementById("accounting-title");
 | 
				
			||||||
 | 
					    const error = document.getElementById("accounting-title-error");
 | 
				
			||||||
 | 
					    field.value = field.value.trim();
 | 
				
			||||||
 | 
					    if (field.value === "") {
 | 
				
			||||||
 | 
					        field.classList.add("is-invalid");
 | 
				
			||||||
 | 
					        error.innerText = A_("Please fill in the title.");
 | 
				
			||||||
 | 
					        return false;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    field.classList.remove("is-invalid");
 | 
				
			||||||
 | 
					    error.innerText = "";
 | 
				
			||||||
 | 
					    return true;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										39
									
								
								src/accounting/static/js/account-order.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								src/accounting/static/js/account-order.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,39 @@
 | 
				
			|||||||
 | 
					/* The Mia! Accounting Flask Project
 | 
				
			||||||
 | 
					 * account-order.js: The JavaScript for the account order
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/*  Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					 *
 | 
				
			||||||
 | 
					 *  Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					 *  you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					 *  You may obtain a copy of the License at
 | 
				
			||||||
 | 
					 *
 | 
				
			||||||
 | 
					 *      http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					 *
 | 
				
			||||||
 | 
					 *  Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					 *  distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					 *  See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					 *  limitations under the License.
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Author: imacat@mail.imacat.idv.tw (imacat)
 | 
				
			||||||
 | 
					 * First written: 2023/2/2
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Initializes the page JavaScript.
 | 
				
			||||||
 | 
					document.addEventListener("DOMContentLoaded", function () {
 | 
				
			||||||
 | 
					    const list = document.getElementById("accounting-order-list");
 | 
				
			||||||
 | 
					    if (list !== null) {
 | 
				
			||||||
 | 
					        const onReorder = function () {
 | 
				
			||||||
 | 
					            const accounts = Array.from(list.children);
 | 
				
			||||||
 | 
					            for (let i = 0; i < accounts.length; i++) {
 | 
				
			||||||
 | 
					                const no = document.getElementById("accounting-order-" + accounts[i].dataset.id + "-no");
 | 
				
			||||||
 | 
					                const code = document.getElementById("accounting-order-" + accounts[i].dataset.id + "-code");
 | 
				
			||||||
 | 
					                no.value = String(i + 1);
 | 
				
			||||||
 | 
					                code.innerText = list.dataset.baseCode + "-" + ("000" + (i + 1)).slice(-3);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					        initializeDragAndDropReordering(list, onReorder);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
							
								
								
									
										174
									
								
								src/accounting/static/js/currency-form.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										174
									
								
								src/accounting/static/js/currency-form.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,174 @@
 | 
				
			|||||||
 | 
					/* The Mia! Accounting Flask Project
 | 
				
			||||||
 | 
					 * currency-form.js: The JavaScript for the currency form
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/*  Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					 *
 | 
				
			||||||
 | 
					 *  Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					 *  you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					 *  You may obtain a copy of the License at
 | 
				
			||||||
 | 
					 *
 | 
				
			||||||
 | 
					 *      http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					 *
 | 
				
			||||||
 | 
					 *  Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					 *  distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					 *  See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					 *  limitations under the License.
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Author: imacat@mail.imacat.idv.tw (imacat)
 | 
				
			||||||
 | 
					 * First written: 2023/2/6
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Initializes the page JavaScript.
 | 
				
			||||||
 | 
					document.addEventListener("DOMContentLoaded", function () {
 | 
				
			||||||
 | 
					    document.getElementById("accounting-code")
 | 
				
			||||||
 | 
					        .onchange = validateCode;
 | 
				
			||||||
 | 
					    document.getElementById("accounting-name")
 | 
				
			||||||
 | 
					        .onchange = validateName;
 | 
				
			||||||
 | 
					    document.getElementById("accounting-form")
 | 
				
			||||||
 | 
					        .onsubmit = validateForm;
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * The asynchronous validation result
 | 
				
			||||||
 | 
					 * @type {object}
 | 
				
			||||||
 | 
					 * @private
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					let isAsyncValid = {};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Validates the form.
 | 
				
			||||||
 | 
					 *
 | 
				
			||||||
 | 
					 * @returns {boolean} true if valid, or false otherwise
 | 
				
			||||||
 | 
					 * @private
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					function validateForm() {
 | 
				
			||||||
 | 
					    isAsyncValid = {
 | 
				
			||||||
 | 
					        "code": false,
 | 
				
			||||||
 | 
					        "_sync": false,
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					    let isValid = true;
 | 
				
			||||||
 | 
					    isValid = validateCode() && isValid;
 | 
				
			||||||
 | 
					    isValid = validateName() && isValid;
 | 
				
			||||||
 | 
					    isAsyncValid["_sync"] = isValid;
 | 
				
			||||||
 | 
					    submitFormIfAllAsyncValid();
 | 
				
			||||||
 | 
					    return false;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Submits the form if the whole form passed the asynchronous
 | 
				
			||||||
 | 
					 * validations.
 | 
				
			||||||
 | 
					 *
 | 
				
			||||||
 | 
					 * @private
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					function submitFormIfAllAsyncValid() {
 | 
				
			||||||
 | 
					    let isValid = true;
 | 
				
			||||||
 | 
					    Object.keys(isAsyncValid).forEach(function (key) {
 | 
				
			||||||
 | 
					        isValid = isAsyncValid[key] && isValid;
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					    if (isValid) {
 | 
				
			||||||
 | 
					        document.getElementById("accounting-form").submit()
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Validates the code.
 | 
				
			||||||
 | 
					 *
 | 
				
			||||||
 | 
					 * @param changeEvent {Event} the change event, if invoked from onchange
 | 
				
			||||||
 | 
					 * @returns {boolean} true if valid, or false otherwise
 | 
				
			||||||
 | 
					 * @private
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					function validateCode(changeEvent = null) {
 | 
				
			||||||
 | 
					    const key = "code";
 | 
				
			||||||
 | 
					    const isSubmission = changeEvent === null;
 | 
				
			||||||
 | 
					    let hasAsyncValidation = false;
 | 
				
			||||||
 | 
					    const field = document.getElementById("accounting-code");
 | 
				
			||||||
 | 
					    const error = document.getElementById("accounting-code-error");
 | 
				
			||||||
 | 
					    field.value = field.value.trim();
 | 
				
			||||||
 | 
					    if (field.value === "") {
 | 
				
			||||||
 | 
					        field.classList.add("is-invalid");
 | 
				
			||||||
 | 
					        error.innerText = A_("Please fill in the code.");
 | 
				
			||||||
 | 
					        return false;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    const blocklist = JSON.parse(field.dataset.blocklist);
 | 
				
			||||||
 | 
					    if (blocklist.includes(field.value)) {
 | 
				
			||||||
 | 
					        field.classList.add("is-invalid");
 | 
				
			||||||
 | 
					        error.innerText = A_("This code is not available.");
 | 
				
			||||||
 | 
					        return false;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    if (!field.value.match(/^[A-Z]{3}$/)) {
 | 
				
			||||||
 | 
					        field.classList.add("is-invalid");
 | 
				
			||||||
 | 
					        error.innerText = A_("Code can only be composed of 3 upper-cased letters.");
 | 
				
			||||||
 | 
					        return false;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    const original = field.dataset.original;
 | 
				
			||||||
 | 
					    if (original === "" || field.value !== original) {
 | 
				
			||||||
 | 
					        hasAsyncValidation = true;
 | 
				
			||||||
 | 
					        validateAsyncCodeIsDuplicated(isSubmission, key);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    if (!hasAsyncValidation) {
 | 
				
			||||||
 | 
					        isAsyncValid[key] = true;
 | 
				
			||||||
 | 
					        field.classList.remove("is-invalid");
 | 
				
			||||||
 | 
					        error.innerText = "";
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    return true;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Validates asynchronously whether the code is duplicated.
 | 
				
			||||||
 | 
					 * The boolean validation result is stored in isAsyncValid[key].
 | 
				
			||||||
 | 
					 *
 | 
				
			||||||
 | 
					 * @param isSubmission {boolean} whether this is invoked from a form submission
 | 
				
			||||||
 | 
					 * @param key {string} the key to store the result in isAsyncValid
 | 
				
			||||||
 | 
					 * @private
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					function validateAsyncCodeIsDuplicated(isSubmission, key) {
 | 
				
			||||||
 | 
					    const field = document.getElementById("accounting-code");
 | 
				
			||||||
 | 
					    const error = document.getElementById("accounting-code-error");
 | 
				
			||||||
 | 
					    const url = field.dataset.existsUrl;
 | 
				
			||||||
 | 
					    const onLoad = function () {
 | 
				
			||||||
 | 
					        if (this.status === 200) {
 | 
				
			||||||
 | 
					            const result = JSON.parse(this.responseText);
 | 
				
			||||||
 | 
					            if (result["exists"]) {
 | 
				
			||||||
 | 
					                field.classList.add("is-invalid");
 | 
				
			||||||
 | 
					                error.innerText = A_("Code conflicts with another currency.");
 | 
				
			||||||
 | 
					                if (isSubmission) {
 | 
				
			||||||
 | 
					                    isAsyncValid[key] = false;
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					                return;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            field.classList.remove("is-invalid");
 | 
				
			||||||
 | 
					            error.innerText = "";
 | 
				
			||||||
 | 
					            if (isSubmission) {
 | 
				
			||||||
 | 
					                isAsyncValid[key] = true;
 | 
				
			||||||
 | 
					                submitFormIfAllAsyncValid();
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					    const request = new XMLHttpRequest();
 | 
				
			||||||
 | 
					    request.onload = onLoad;
 | 
				
			||||||
 | 
					    request.open("GET", url + "?q=" + encodeURIComponent(field.value));
 | 
				
			||||||
 | 
					    request.send();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Validates the name.
 | 
				
			||||||
 | 
					 *
 | 
				
			||||||
 | 
					 * @returns {boolean} true if valid, or false otherwise
 | 
				
			||||||
 | 
					 * @private
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					function validateName() {
 | 
				
			||||||
 | 
					    const field = document.getElementById("accounting-name");
 | 
				
			||||||
 | 
					    const error = document.getElementById("accounting-name-error");
 | 
				
			||||||
 | 
					    field.value = field.value.trim();
 | 
				
			||||||
 | 
					    if (field.value === "") {
 | 
				
			||||||
 | 
					        field.classList.add("is-invalid");
 | 
				
			||||||
 | 
					        error.innerText = A_("Please fill in the name.");
 | 
				
			||||||
 | 
					        return false;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    field.classList.remove("is-invalid");
 | 
				
			||||||
 | 
					    error.innerText = "";
 | 
				
			||||||
 | 
					    return true;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										108
									
								
								src/accounting/static/js/drag-and-drop-reorder.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										108
									
								
								src/accounting/static/js/drag-and-drop-reorder.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,108 @@
 | 
				
			|||||||
 | 
					/* The Mia! Accounting Flask Project
 | 
				
			||||||
 | 
					 * drag-and-drop-reorder.js: The JavaScript for the reorder a list with drag-and-drop
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/*  Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					 *
 | 
				
			||||||
 | 
					 *  Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					 *  you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					 *  You may obtain a copy of the License at
 | 
				
			||||||
 | 
					 *
 | 
				
			||||||
 | 
					 *      http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					 *
 | 
				
			||||||
 | 
					 *  Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					 *  distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					 *  See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					 *  limitations under the License.
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Author: imacat@mail.imacat.idv.tw (imacat)
 | 
				
			||||||
 | 
					 * First written: 2023/2/3
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Initializes the drag-and-drop reordering on a list.
 | 
				
			||||||
 | 
					 *
 | 
				
			||||||
 | 
					 * @param list {HTMLElement} the list to be reordered
 | 
				
			||||||
 | 
					 * @param onReorder {(function())|*} The callback to reorder the items
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					function initializeDragAndDropReordering(list, onReorder) {
 | 
				
			||||||
 | 
					    initializeMouseDragAndDropReordering(list, onReorder);
 | 
				
			||||||
 | 
					    initializeTouchDragAndDropReordering(list, onReorder);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Initializes the drag-and-drop reordering with mouse.
 | 
				
			||||||
 | 
					 *
 | 
				
			||||||
 | 
					 * @param list {HTMLElement} the list to be reordered
 | 
				
			||||||
 | 
					 * @param onReorder {(function())|*} The callback to reorder the items
 | 
				
			||||||
 | 
					 * @private
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					function initializeMouseDragAndDropReordering(list, onReorder) {
 | 
				
			||||||
 | 
					    const items = Array.from(list.children);
 | 
				
			||||||
 | 
					    let dragged = null;
 | 
				
			||||||
 | 
					    items.forEach(function (item) {
 | 
				
			||||||
 | 
					        item.draggable = true;
 | 
				
			||||||
 | 
					        item.addEventListener("dragstart", function () {
 | 
				
			||||||
 | 
					            dragged = item;
 | 
				
			||||||
 | 
					            dragged.classList.add("list-group-item-dark");
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					        item.addEventListener("dragover", function () {
 | 
				
			||||||
 | 
					            onDragOver(dragged, item);
 | 
				
			||||||
 | 
					            onReorder();
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					        item.addEventListener("dragend", function () {
 | 
				
			||||||
 | 
					            dragged.classList.remove("list-group-item-dark");
 | 
				
			||||||
 | 
					            dragged = null;
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Initializes the drag-and-drop reordering with touch devices.
 | 
				
			||||||
 | 
					 *
 | 
				
			||||||
 | 
					 * @param list {HTMLElement} the list to be reordered
 | 
				
			||||||
 | 
					 * @param onReorder {(function())|*} The callback to reorder the items
 | 
				
			||||||
 | 
					 * @private
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					function initializeTouchDragAndDropReordering(list, onReorder) {
 | 
				
			||||||
 | 
					    const items = Array.from(list.children);
 | 
				
			||||||
 | 
					    items.forEach(function (item) {
 | 
				
			||||||
 | 
					        item.addEventListener("touchstart", function () {
 | 
				
			||||||
 | 
					            item.classList.add("list-group-item-dark");
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					        item.addEventListener("touchmove", function (event) {
 | 
				
			||||||
 | 
					            const touch = event.targetTouches[0];
 | 
				
			||||||
 | 
					            const target = document.elementFromPoint(touch.pageX, touch.pageY);
 | 
				
			||||||
 | 
					            onDragOver(item, target);
 | 
				
			||||||
 | 
					            onReorder();
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					        item.addEventListener("touchend", function () {
 | 
				
			||||||
 | 
					            item.classList.remove("list-group-item-dark");
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Handles when an item is dragged over the other item.
 | 
				
			||||||
 | 
					 *
 | 
				
			||||||
 | 
					 * @param dragged {Element} the item that was dragged
 | 
				
			||||||
 | 
					 * @param target {Element} the other item that was dragged over
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					function onDragOver(dragged, target) {
 | 
				
			||||||
 | 
					    if (target.parentElement !== dragged.parentElement || target === dragged) {
 | 
				
			||||||
 | 
					        return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    let isBefore = false;
 | 
				
			||||||
 | 
					    for (let p = target; p !== null; p = p.previousSibling) {
 | 
				
			||||||
 | 
					        if (p === dragged) {
 | 
				
			||||||
 | 
					            isBefore = true;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    if (isBefore) {
 | 
				
			||||||
 | 
					        target.parentElement.insertBefore(dragged, target.nextSibling);
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					        target.parentElement.insertBefore(dragged, target);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										28
									
								
								src/accounting/templates/accounting/account/create.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								src/accounting/templates/accounting/account/create.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,28 @@
 | 
				
			|||||||
 | 
					{#
 | 
				
			||||||
 | 
					The Mia! Accounting Flask Project
 | 
				
			||||||
 | 
					create.html: The account creation form
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					 you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					 You may obtain a copy of the License at
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					     http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					 distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					 See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					 limitations under the License.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Author: imacat@mail.imacat.idv.tw (imacat)
 | 
				
			||||||
 | 
					First written: 2023/2/1
 | 
				
			||||||
 | 
					#}
 | 
				
			||||||
 | 
					{% extends "accounting/account/include/form.html" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block header %}{% block title %}{{ A_("Add a New Account") }}{% endblock %}{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block back_url %}{{ request.args.get("next") or url_for("accounting.account.list") }}{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block action_url %}{{ url_for("accounting.account.store") }}{% endblock %}
 | 
				
			||||||
							
								
								
									
										99
									
								
								src/accounting/templates/accounting/account/detail.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								src/accounting/templates/accounting/account/detail.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,99 @@
 | 
				
			|||||||
 | 
					{#
 | 
				
			||||||
 | 
					The Mia! Accounting Flask Project
 | 
				
			||||||
 | 
					detail.html: The account detail
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					 you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					 You may obtain a copy of the License at
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					     http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					 distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					 See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					 limitations under the License.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Author: imacat@mail.imacat.idv.tw (imacat)
 | 
				
			||||||
 | 
					First written: 2023/1/31
 | 
				
			||||||
 | 
					#}
 | 
				
			||||||
 | 
					{% extends "accounting/base.html" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block header %}{% block title %}{{ obj }}{% endblock %}{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block content %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<div class="btn-group mb-3">
 | 
				
			||||||
 | 
					  <a class="btn btn-primary" href="{{ url_for("accounting.account.list")|accounting_or_next }}">
 | 
				
			||||||
 | 
					    <i class="fa-solid fa-circle-chevron-left"></i>
 | 
				
			||||||
 | 
					    {{ A_("Back") }}
 | 
				
			||||||
 | 
					  </a>
 | 
				
			||||||
 | 
					  {% if accounting_can_edit() %}
 | 
				
			||||||
 | 
					    <a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.account.edit", account=obj)|accounting_inherit_next }}">
 | 
				
			||||||
 | 
					      <i class="fa-solid fa-gear"></i>
 | 
				
			||||||
 | 
					      {{ A_("Settings") }}
 | 
				
			||||||
 | 
					    </a>
 | 
				
			||||||
 | 
					  {% endif %}
 | 
				
			||||||
 | 
					  <a class="btn btn-primary" href="{{ url_for("accounting.account.order", base=obj.base)|accounting_append_next }}">
 | 
				
			||||||
 | 
					    <i class="fa-solid fa-bars-staggered"></i>
 | 
				
			||||||
 | 
					    {{ A_("Order") }}
 | 
				
			||||||
 | 
					  </a>
 | 
				
			||||||
 | 
					  {% if accounting_can_edit() %}
 | 
				
			||||||
 | 
					    <button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal">
 | 
				
			||||||
 | 
					      <i class="fa-solid fa-trash"></i>
 | 
				
			||||||
 | 
					      {{ A_("Delete") }}
 | 
				
			||||||
 | 
					    </button>
 | 
				
			||||||
 | 
					  {% endif %}
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% if accounting_can_edit() %}
 | 
				
			||||||
 | 
					  <div class="d-md-none accounting-material-fab">
 | 
				
			||||||
 | 
					    <a class="btn btn-primary" href="{{ url_for("accounting.account.edit", account=obj)|accounting_inherit_next }}">
 | 
				
			||||||
 | 
					      <i class="fa-solid fa-pen-to-square"></i>
 | 
				
			||||||
 | 
					    </a>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					{% endif %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% if accounting_can_edit() %}
 | 
				
			||||||
 | 
					  <form action="{{ url_for("accounting.account.delete", account=obj) }}" method="post">
 | 
				
			||||||
 | 
					    <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
 | 
				
			||||||
 | 
					    {% if "next" in request.args %}
 | 
				
			||||||
 | 
					      <input type="hidden" name="next" value="{{ request.args["next"] }}">
 | 
				
			||||||
 | 
					    {% endif %}
 | 
				
			||||||
 | 
					    <div class="modal fade" id="accounting-delete-modal" tabindex="-1" aria-labelledby="accounting-delete-model-label" aria-hidden="true">
 | 
				
			||||||
 | 
					      <div class="modal-dialog">
 | 
				
			||||||
 | 
					        <div class="modal-content">
 | 
				
			||||||
 | 
					          <div class="modal-header">
 | 
				
			||||||
 | 
					            <h1 class="modal-title fs-5" id="accounting-delete-model-label">{{ A_("Delete Account Confirmation") }}</h1>
 | 
				
			||||||
 | 
					            <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					          <div class="modal-body">
 | 
				
			||||||
 | 
					            {{ A_("Do you really want to delete this account?") }}
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					          <div class="modal-footer">
 | 
				
			||||||
 | 
					            <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
 | 
				
			||||||
 | 
					            <button type="submit" class="btn btn-danger">{{ A_("Confirm") }}</button>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </form>
 | 
				
			||||||
 | 
					{% endif %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<div class="accounting-card col-sm-6">
 | 
				
			||||||
 | 
					  <div class="accounting-card-title">{{ obj.title }}</div>
 | 
				
			||||||
 | 
					  <div class="accounting-card-code">{{ obj.code }}</div>
 | 
				
			||||||
 | 
					  {% if obj.is_offset_needed %}
 | 
				
			||||||
 | 
					    <div>
 | 
				
			||||||
 | 
					      <span class="badge rounded-pill bg-info">{{ A_("Offset needed") }}</span>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  {% endif %}
 | 
				
			||||||
 | 
					  <div class="small text-secondary fst-italic">
 | 
				
			||||||
 | 
					    <div>{{ A_("Created") }} {{ obj.created_at }} {{ obj.created_by }}</div>
 | 
				
			||||||
 | 
					    <div>{{ A_("Updated") }} {{ obj.updated_at }} {{ obj.updated_by }}</div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
							
								
								
									
										28
									
								
								src/accounting/templates/accounting/account/edit.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								src/accounting/templates/accounting/account/edit.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,28 @@
 | 
				
			|||||||
 | 
					{#
 | 
				
			||||||
 | 
					The Mia! Accounting Flask Project
 | 
				
			||||||
 | 
					edit.html: The account edit form
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					 you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					 You may obtain a copy of the License at
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					     http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					 distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					 See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					 limitations under the License.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Author: imacat@mail.imacat.idv.tw (imacat)
 | 
				
			||||||
 | 
					First written: 2023/2/1
 | 
				
			||||||
 | 
					#}
 | 
				
			||||||
 | 
					{% extends "accounting/account/include/form.html" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block header %}{% block title %}{{ A_("%(account)s Settings", account=account) }}{% endblock %}{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block back_url %}{{ url_for("accounting.account.detail", account=account)|accounting_inherit_next }}{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block action_url %}{{ url_for("accounting.account.update", account=account) }}{% endblock %}
 | 
				
			||||||
							
								
								
									
										123
									
								
								src/accounting/templates/accounting/account/include/form.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										123
									
								
								src/accounting/templates/accounting/account/include/form.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,123 @@
 | 
				
			|||||||
 | 
					{#
 | 
				
			||||||
 | 
					The Mia! Accounting Flask Project
 | 
				
			||||||
 | 
					form.html: The account form
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					 you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					 You may obtain a copy of the License at
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					     http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					 distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					 See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					 limitations under the License.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Author: imacat@mail.imacat.idv.tw (imacat)
 | 
				
			||||||
 | 
					First written: 2023/2/1
 | 
				
			||||||
 | 
					#}
 | 
				
			||||||
 | 
					{% extends "accounting/base.html" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block accounting_scripts %}
 | 
				
			||||||
 | 
					  <script src="{{ url_for("accounting.static", filename="js/account-form.js") }}"></script>
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block content %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<div class="btn-group btn-actions mb-3">
 | 
				
			||||||
 | 
					  <a class="btn btn-primary" role="button" href="{% block back_url %}{% endblock %}">
 | 
				
			||||||
 | 
					    <i class="fa-solid fa-circle-chevron-left"></i>
 | 
				
			||||||
 | 
					    {{ A_("Back") }}
 | 
				
			||||||
 | 
					  </a>
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<form id="accounting-form" action="{% block action_url %}{% endblock %}" method="post">
 | 
				
			||||||
 | 
					  {{ form.csrf_token }}
 | 
				
			||||||
 | 
					  {% if "next" in request.args %}
 | 
				
			||||||
 | 
					    <input type="hidden" name="next" value="{{ request.args["next"] }}">
 | 
				
			||||||
 | 
					  {% endif %}
 | 
				
			||||||
 | 
					  <div class="form-floating mb-3">
 | 
				
			||||||
 | 
					    <input id="accounting-base-code" type="hidden" name="base_code" value="{{ "" if form.base_code.data is none else form.base_code.data }}">
 | 
				
			||||||
 | 
					    <div id="accounting-base" class="form-control accounting-clickable accounting-material-text-field {% if form.base_code.data %} accounting-not-empty {% endif %} {% if form.base_code.errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#accounting-base-selector-model">
 | 
				
			||||||
 | 
					      <label class="form-label" for="accounting-base">{{ A_("Base account") }}</label>
 | 
				
			||||||
 | 
					      <div id="accounting-base-content">
 | 
				
			||||||
 | 
					        {% if form.base_code.data %}
 | 
				
			||||||
 | 
					          {% if form.base_code.errors %}
 | 
				
			||||||
 | 
					            {{ A_("(Unknown)") }}
 | 
				
			||||||
 | 
					          {% else %}
 | 
				
			||||||
 | 
					            {{ form.selected_base }}
 | 
				
			||||||
 | 
					          {% endif %}
 | 
				
			||||||
 | 
					        {% endif %}
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					    <div id="accounting-base-code-error" class="invalid-feedback">{% if form.base_code.errors %}{{ form.base_code.errors[0] }}{% endif %}</div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  <div class="form-floating mb-3">
 | 
				
			||||||
 | 
					    <input id="accounting-title" class="form-control {% if form.title.errors %} is-invalid {% endif %}" type="text" name="title" value="{{ "" if form.title.data is none else form.title.data }}" placeholder=" " required="required">
 | 
				
			||||||
 | 
					    <label class="form-label" for="accounting-title">{{ A_("Title") }}</label>
 | 
				
			||||||
 | 
					    <div id="accounting-title-error" class="invalid-feedback">{% if form.title.errors %}{{ form.title.errors[0] }}{% endif %}</div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  <div class="form-check form-switch mb-3">
 | 
				
			||||||
 | 
					    <input id="accounting-is-offset-needed" class="form-check-input" type="checkbox" name="is_offset_needed" value="1" {% if form.is_offset_needed.data %} checked="checked" {% endif %}>
 | 
				
			||||||
 | 
					    <label class="form-check-label" for="accounting-is-offset-needed">
 | 
				
			||||||
 | 
					      {{ A_("The entries in the account need offsets.") }}
 | 
				
			||||||
 | 
					    </label>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  <div class="d-none d-md-block">
 | 
				
			||||||
 | 
					    <button class="btn btn-primary" type="submit">
 | 
				
			||||||
 | 
					      <i class="fa-solid fa-floppy-disk"></i>
 | 
				
			||||||
 | 
					      {{ A_("Save") }}
 | 
				
			||||||
 | 
					    </button>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  <div class="d-md-none accounting-material-fab">
 | 
				
			||||||
 | 
					    <button class="btn btn-primary" type="submit">
 | 
				
			||||||
 | 
					      <i class="fa-solid fa-floppy-disk"></i>
 | 
				
			||||||
 | 
					    </button>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</form>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<div class="modal fade" id="accounting-base-selector-model" tabindex="-1" aria-labelledby="accounting-base-selector-model-label" aria-hidden="true">
 | 
				
			||||||
 | 
					  <div class="modal-dialog">
 | 
				
			||||||
 | 
					    <div class="modal-content">
 | 
				
			||||||
 | 
					      <div class="modal-header">
 | 
				
			||||||
 | 
					        <h1 class="modal-title fs-5" id="accounting-base-selector-model-label">{{ A_("Select Base Account") }}</h1>
 | 
				
			||||||
 | 
					        <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					      <div class="modal-body">
 | 
				
			||||||
 | 
					        <div class="input-group mb-2">
 | 
				
			||||||
 | 
					          <input id="accounting-base-selector-query" class="form-control form-control-sm" type="search" placeholder=" " required="required" aria-label="Search">
 | 
				
			||||||
 | 
					          <label class="input-group-text" for="accounting-base-selector-query">
 | 
				
			||||||
 | 
					            <i class="fa-solid fa-magnifying-glass"></i>
 | 
				
			||||||
 | 
					            {{ A_("Search") }}
 | 
				
			||||||
 | 
					          </label>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <ul id="accounting-base-option-list" class="list-group accounting-selector-list">
 | 
				
			||||||
 | 
					          {% for base in form.base_options %}
 | 
				
			||||||
 | 
					          <li id="accounting-base-option-{{ base.code }}" class="list-group-item accounting-base-option accounting-clickable" data-code="{{ base.code }}" data-content="{{ base }}" data-query-values="{{ base.query_values|tojson|forceescape }}">
 | 
				
			||||||
 | 
					            {{ base }}
 | 
				
			||||||
 | 
					          </li>
 | 
				
			||||||
 | 
					          {% endfor %}
 | 
				
			||||||
 | 
					        </ul>
 | 
				
			||||||
 | 
					        <p id="accounting-base-option-no-result" class="d-none">{{ A_("There is no data.") }}</p>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					      <div class="modal-footer">
 | 
				
			||||||
 | 
					        <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
 | 
				
			||||||
 | 
					        {% if form.base_code.data %}
 | 
				
			||||||
 | 
					          <button id="accounting-btn-clear-base" type="button" class="btn btn-danger">{{ A_("Clear") }}</button>
 | 
				
			||||||
 | 
					        {% else %}
 | 
				
			||||||
 | 
					          <button id="accounting-btn-clear-base" type="button" class="btn btn-secondary" disabled="disabled">{{ A_("Clear") }}</button>
 | 
				
			||||||
 | 
					        {% endif %}
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
							
								
								
									
										71
									
								
								src/accounting/templates/accounting/account/list.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								src/accounting/templates/accounting/account/list.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,71 @@
 | 
				
			|||||||
 | 
					{#
 | 
				
			||||||
 | 
					The Mia! Accounting Flask Project
 | 
				
			||||||
 | 
					list.html: The account list
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					 you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					 You may obtain a copy of the License at
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					     http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					 distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					 See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					 limitations under the License.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Author: imacat@mail.imacat.idv.tw (imacat)
 | 
				
			||||||
 | 
					First written: 2023/1/30
 | 
				
			||||||
 | 
					#}
 | 
				
			||||||
 | 
					{% extends "accounting/base.html" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block header %}{% block title %}{% if "q" in request.args %}{{ A_("Search Result for \"%(query)s\"", query=request.args["q"]) }}{% else %}{{ A_("Account Management") }}{% endif %}{% endblock %}{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block content %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<div class="btn-group mb-2">
 | 
				
			||||||
 | 
					  {% if accounting_can_edit() %}
 | 
				
			||||||
 | 
					    <a class="btn btn-primary text-nowrap d-none d-md-block" href="{{ url_for("accounting.account.create")|accounting_append_next }}">
 | 
				
			||||||
 | 
					      <i class="fa-solid fa-plus"></i>
 | 
				
			||||||
 | 
					      {{ A_("New") }}
 | 
				
			||||||
 | 
					    </a>
 | 
				
			||||||
 | 
					  {% endif %}
 | 
				
			||||||
 | 
					  <form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.account.list") }}" method="get" role="search">
 | 
				
			||||||
 | 
					    <input id="accounting-search" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required" aria-label="Search">
 | 
				
			||||||
 | 
					    <label for="accounting-search" class="accounting-search-label">
 | 
				
			||||||
 | 
					      <button type="submit">
 | 
				
			||||||
 | 
					        <i class="fa-solid fa-magnifying-glass"></i>
 | 
				
			||||||
 | 
					        {{ A_("Search") }}
 | 
				
			||||||
 | 
					      </button>
 | 
				
			||||||
 | 
					    </label>
 | 
				
			||||||
 | 
					  </form>
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% if accounting_can_edit() %}
 | 
				
			||||||
 | 
					  <div class="d-md-none accounting-material-fab">
 | 
				
			||||||
 | 
					    <a class="btn btn-primary" href="{{ url_for("accounting.account.create")|accounting_append_next }}">
 | 
				
			||||||
 | 
					      <i class="fa-solid fa-plus"></i>
 | 
				
			||||||
 | 
					    </a>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					{% endif %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% if list %}
 | 
				
			||||||
 | 
					  {% include "accounting/include/pagination.html" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  <div class="list-group">
 | 
				
			||||||
 | 
					  {% for item in list %}
 | 
				
			||||||
 | 
					    <a class="list-group-item list-group-item-action" href="{{ url_for("accounting.account.detail", account=item)|accounting_append_next }}">
 | 
				
			||||||
 | 
					      {{ item }}
 | 
				
			||||||
 | 
					      {% if item.is_offset_needed %}
 | 
				
			||||||
 | 
					        <span class="badge rounded-pill bg-info">{{ A_("Offset needed") }}</span>
 | 
				
			||||||
 | 
					      {% endif %}
 | 
				
			||||||
 | 
					    </a>
 | 
				
			||||||
 | 
					  {% endfor %}
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					{% else %}
 | 
				
			||||||
 | 
					  <p>{{ A_("There is no data.") }}</p>
 | 
				
			||||||
 | 
					{% endif %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
							
								
								
									
										84
									
								
								src/accounting/templates/accounting/account/order.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								src/accounting/templates/accounting/account/order.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,84 @@
 | 
				
			|||||||
 | 
					{#
 | 
				
			||||||
 | 
					The Mia! Accounting Flask Project
 | 
				
			||||||
 | 
					order.html: The order of the accounts under a same base account
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					 you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					 You may obtain a copy of the License at
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					     http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					 distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					 See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					 limitations under the License.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Author: imacat@mail.imacat.idv.tw (imacat)
 | 
				
			||||||
 | 
					First written: 2023/2/2
 | 
				
			||||||
 | 
					#}
 | 
				
			||||||
 | 
					{% extends "accounting/account/include/form.html" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block accounting_scripts %}
 | 
				
			||||||
 | 
					  <script src="{{ url_for("accounting.static", filename="js/drag-and-drop-reorder.js") }}"></script>
 | 
				
			||||||
 | 
					  <script src="{{ url_for("accounting.static", filename="js/account-order.js") }}"></script>
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block header %}{% block title %}{{ A_("The Accounts of %(base)s", base=base) }}{% endblock %}{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block content %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<div class="btn-group mb-3">
 | 
				
			||||||
 | 
					  <a class="btn btn-primary" href="{{ url_for("accounting.account.list")|accounting_or_next }}">
 | 
				
			||||||
 | 
					    <i class="fa-solid fa-circle-chevron-left"></i>
 | 
				
			||||||
 | 
					    {{ A_("Back") }}
 | 
				
			||||||
 | 
					  </a>
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% if base.accounts|length > 1 and accounting_can_edit() %}
 | 
				
			||||||
 | 
					  <form action="{{ url_for("accounting.account.sort", base=base) }}" method="post">
 | 
				
			||||||
 | 
					    <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
 | 
				
			||||||
 | 
					    {% if "next" in request.args %}
 | 
				
			||||||
 | 
					      <input type="hidden" name="next" value="{{ request.args["next"] }}">
 | 
				
			||||||
 | 
					    {% endif %}
 | 
				
			||||||
 | 
					    <ul id="accounting-order-list" class="list-group mb-3" data-base-code="{{ base.code }}">
 | 
				
			||||||
 | 
					      {% for account in base.accounts|sort(attribute="no") %}
 | 
				
			||||||
 | 
					        <li class="list-group-item d-flex justify-content-between" data-id="{{ account.id }}">
 | 
				
			||||||
 | 
					          <input id="accounting-order-{{ account.id }}-no" type="hidden" name="{{ account.id }}-no" value="{{ loop.index }}">
 | 
				
			||||||
 | 
					          <div>
 | 
				
			||||||
 | 
					            <span id="accounting-order-{{ account.id }}-code">{{ account.code }}</span>
 | 
				
			||||||
 | 
					            {{ account.title }}
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					          <i class="fa-solid fa-bars"></i>
 | 
				
			||||||
 | 
					        </li>
 | 
				
			||||||
 | 
					      {% endfor %}
 | 
				
			||||||
 | 
					    </ul>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <div class="d-none d-md-block">
 | 
				
			||||||
 | 
					      <button class="btn btn-primary" type="submit">
 | 
				
			||||||
 | 
					        <i class="fa-solid fa-floppy-disk"></i>
 | 
				
			||||||
 | 
					        {{ A_("Save") }}
 | 
				
			||||||
 | 
					      </button>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <div class="d-md-none accounting-material-fab">
 | 
				
			||||||
 | 
					      <button class="btn btn-primary" type="submit">
 | 
				
			||||||
 | 
					        <i class="fa-solid fa-floppy-disk"></i>
 | 
				
			||||||
 | 
					      </button>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </form>
 | 
				
			||||||
 | 
					{% elif base.accounts %}
 | 
				
			||||||
 | 
					  <ul class="list-group mb-3">
 | 
				
			||||||
 | 
					    {% for account in base.accounts|sort(attribute="no") %}
 | 
				
			||||||
 | 
					      <li class="list-group-item">
 | 
				
			||||||
 | 
					        {{ account }}
 | 
				
			||||||
 | 
					      </li>
 | 
				
			||||||
 | 
					    {% endfor %}
 | 
				
			||||||
 | 
					  </ul>
 | 
				
			||||||
 | 
					{% else %}
 | 
				
			||||||
 | 
					  <p>{{ A_("There is no data.") }}</p>
 | 
				
			||||||
 | 
					{% endif %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
							
								
								
									
										49
									
								
								src/accounting/templates/accounting/base-account/detail.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								src/accounting/templates/accounting/base-account/detail.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,49 @@
 | 
				
			|||||||
 | 
					{#
 | 
				
			||||||
 | 
					The Mia! Accounting Flask Project
 | 
				
			||||||
 | 
					detail.html: The base account detail
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					 you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					 You may obtain a copy of the License at
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					     http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					 distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					 See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					 limitations under the License.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Author: imacat@mail.imacat.idv.tw (imacat)
 | 
				
			||||||
 | 
					First written: 2023/2/1
 | 
				
			||||||
 | 
					#}
 | 
				
			||||||
 | 
					{% extends "accounting/base.html" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block header %}{% block title %}{{ obj }}{% endblock %}{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block content %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<div class="btn-group mb-3">
 | 
				
			||||||
 | 
					  <a class="btn btn-primary" href="{{ url_for("accounting.account.list")|accounting_or_next }}">
 | 
				
			||||||
 | 
					    <i class="fa-solid fa-circle-chevron-left"></i>
 | 
				
			||||||
 | 
					    {{ A_("Back") }}
 | 
				
			||||||
 | 
					  </a>
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<div class="accounting-card col-sm-6">
 | 
				
			||||||
 | 
					  <div class="accounting-card-title">{{ obj.title }}</div>
 | 
				
			||||||
 | 
					  <div class="accounting-card-code">{{ obj.code }}</div>
 | 
				
			||||||
 | 
					  {% if obj.accounts %}
 | 
				
			||||||
 | 
					    <div>
 | 
				
			||||||
 | 
					    {% for account in obj.accounts %}
 | 
				
			||||||
 | 
					      <a class="btn btn-primary" href="{{ url_for("accounting.account.detail", account=account)|accounting_append_next }}">
 | 
				
			||||||
 | 
					        {{ account }}
 | 
				
			||||||
 | 
					      </a>
 | 
				
			||||||
 | 
					    {% endfor %}
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  {% endif %}
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
@@ -21,36 +21,32 @@ First written: 2023/1/26
 | 
				
			|||||||
#}
 | 
					#}
 | 
				
			||||||
{% extends "accounting/base.html" %}
 | 
					{% extends "accounting/base.html" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block header %}{% block title %}{{ A_("Base Accounts") }}{% endblock %}{% endblock %}
 | 
					{% block header %}{% block title %}{% if "q" in request.args %}{{ A_("Search Result for \"%(query)s\"", query=request.args["q"]) }}{% else %}{{ A_("Base Account Managements") }}{% endif %}{% endblock %}{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block content %}
 | 
					{% block content %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<form action="{{ url_for("accounting.base-account.list") }}" method="get" role="search">
 | 
					<div class="btn-group mb-2">
 | 
				
			||||||
  <div class="row">
 | 
					  <form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.base-account.list") }}" method="get" role="search">
 | 
				
			||||||
    <div class="col-sm-3">
 | 
					    <input id="accounting-search" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required" aria-label="Search">
 | 
				
			||||||
      <div class="input-group mb-2">
 | 
					    <label for="accounting-search" class="accounting-search-label">
 | 
				
			||||||
        <input id="query" class="form-control form-control-sm" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required" aria-label="Search">
 | 
					      <button type="submit">
 | 
				
			||||||
        <button class="input-group-text" type="submit">
 | 
					        <i class="fa-solid fa-magnifying-glass"></i>
 | 
				
			||||||
          <label for="query">
 | 
					        {{ A_("Search") }}
 | 
				
			||||||
            <i class="fa-solid fa-magnifying-glass"></i>
 | 
					      </button>
 | 
				
			||||||
            {{ A_("Search") }}
 | 
					    </label>
 | 
				
			||||||
          </label>
 | 
					  </form>
 | 
				
			||||||
        </button>
 | 
					</div>
 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
  </div>
 | 
					 | 
				
			||||||
</form>
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% if list %}
 | 
					{% if list %}
 | 
				
			||||||
  {% include "accounting/include/pagination.html" %}
 | 
					  {% include "accounting/include/pagination.html" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  <ul class="list-group">
 | 
					  <div class="list-group">
 | 
				
			||||||
  {% for item in list %}
 | 
					    {% for item in list %}
 | 
				
			||||||
    <li class="list-group-item list-group-item-action">
 | 
					      <a class="list-group-item list-group-item-action" href="{{ url_for("accounting.base-account.detail", account=item)|accounting_append_next }}">
 | 
				
			||||||
      {{ item }}
 | 
					        {{ item }}
 | 
				
			||||||
    </li>
 | 
					      </a>
 | 
				
			||||||
  {% endfor %}
 | 
					    {% endfor %}
 | 
				
			||||||
  </ul>
 | 
					  </div>
 | 
				
			||||||
{% else %}
 | 
					{% else %}
 | 
				
			||||||
  <p>{{ A_("There is no data.") }}</p>
 | 
					  <p>{{ A_("There is no data.") }}</p>
 | 
				
			||||||
{% endif %}
 | 
					{% endif %}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -21,6 +21,10 @@ First written: 2023/1/27
 | 
				
			|||||||
#}
 | 
					#}
 | 
				
			||||||
{% extends "base.html" %}
 | 
					{% extends "base.html" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block styles %}
 | 
				
			||||||
 | 
					  <link rel="stylesheet" type="text/css" href="{{ url_for("accounting.static", filename="css/style.css") }}">
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block scripts %}
 | 
					{% block scripts %}
 | 
				
			||||||
  <script src="{{ url_for("accounting.babel_catalog") }}"></script>
 | 
					  <script src="{{ url_for("accounting.babel_catalog") }}"></script>
 | 
				
			||||||
  {% block accounting_scripts %}{% endblock %}
 | 
					  {% block accounting_scripts %}{% endblock %}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										28
									
								
								src/accounting/templates/accounting/currency/create.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								src/accounting/templates/accounting/currency/create.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,28 @@
 | 
				
			|||||||
 | 
					{#
 | 
				
			||||||
 | 
					The Mia! Accounting Flask Project
 | 
				
			||||||
 | 
					create.html: The currency creation form
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					 you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					 You may obtain a copy of the License at
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					     http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					 distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					 See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					 limitations under the License.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Author: imacat@mail.imacat.idv.tw (imacat)
 | 
				
			||||||
 | 
					First written: 2023/2/6
 | 
				
			||||||
 | 
					#}
 | 
				
			||||||
 | 
					{% extends "accounting/currency/include/form.html" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block header %}{% block title %}{{ A_("Add a New Currency") }}{% endblock %}{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block back_url %}{{ request.args.get("next") or url_for("accounting.currency.list") }}{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block action_url %}{{ url_for("accounting.currency.store") }}{% endblock %}
 | 
				
			||||||
							
								
								
									
										90
									
								
								src/accounting/templates/accounting/currency/detail.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										90
									
								
								src/accounting/templates/accounting/currency/detail.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,90 @@
 | 
				
			|||||||
 | 
					{#
 | 
				
			||||||
 | 
					The Mia! Accounting Flask Project
 | 
				
			||||||
 | 
					detail.html: The currency detail
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					 you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					 You may obtain a copy of the License at
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					     http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					 distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					 See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					 limitations under the License.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Author: imacat@mail.imacat.idv.tw (imacat)
 | 
				
			||||||
 | 
					First written: 2023/2/6
 | 
				
			||||||
 | 
					#}
 | 
				
			||||||
 | 
					{% extends "accounting/base.html" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block header %}{% block title %}{{ obj }}{% endblock %}{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block content %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<div class="btn-group mb-3">
 | 
				
			||||||
 | 
					  <a class="btn btn-primary" href="{{ url_for("accounting.currency.list")|accounting_or_next }}">
 | 
				
			||||||
 | 
					    <i class="fa-solid fa-circle-chevron-left"></i>
 | 
				
			||||||
 | 
					    {{ A_("Back") }}
 | 
				
			||||||
 | 
					  </a>
 | 
				
			||||||
 | 
					  {% if accounting_can_edit() %}
 | 
				
			||||||
 | 
					    <a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.currency.edit", currency=obj)|accounting_inherit_next }}">
 | 
				
			||||||
 | 
					      <i class="fa-solid fa-gear"></i>
 | 
				
			||||||
 | 
					      {{ A_("Settings") }}
 | 
				
			||||||
 | 
					    </a>
 | 
				
			||||||
 | 
					  {% endif %}
 | 
				
			||||||
 | 
					  {% if accounting_can_edit() %}
 | 
				
			||||||
 | 
					    <button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal">
 | 
				
			||||||
 | 
					      <i class="fa-solid fa-trash"></i>
 | 
				
			||||||
 | 
					      {{ A_("Delete") }}
 | 
				
			||||||
 | 
					    </button>
 | 
				
			||||||
 | 
					  {% endif %}
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% if accounting_can_edit() %}
 | 
				
			||||||
 | 
					  <div class="d-md-none accounting-material-fab">
 | 
				
			||||||
 | 
					    <a class="btn btn-primary" href="{{ url_for("accounting.currency.edit", currency=obj)|accounting_inherit_next }}">
 | 
				
			||||||
 | 
					      <i class="fa-solid fa-pen-to-square"></i>
 | 
				
			||||||
 | 
					    </a>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					{% endif %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% if accounting_can_edit() %}
 | 
				
			||||||
 | 
					  <form action="{{ url_for("accounting.currency.delete", currency=obj) }}" method="post">
 | 
				
			||||||
 | 
					    <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
 | 
				
			||||||
 | 
					    {% if "next" in request.args %}
 | 
				
			||||||
 | 
					      <input type="hidden" name="next" value="{{ request.args["next"] }}">
 | 
				
			||||||
 | 
					    {% endif %}
 | 
				
			||||||
 | 
					    <div class="modal fade" id="accounting-delete-modal" tabindex="-1" aria-labelledby="accounting-delete-model-label" aria-hidden="true">
 | 
				
			||||||
 | 
					      <div class="modal-dialog">
 | 
				
			||||||
 | 
					        <div class="modal-content">
 | 
				
			||||||
 | 
					          <div class="modal-header">
 | 
				
			||||||
 | 
					            <h1 class="modal-title fs-5" id="accounting-delete-model-label">{{ A_("Delete Currency Confirmation") }}</h1>
 | 
				
			||||||
 | 
					            <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					          <div class="modal-body">
 | 
				
			||||||
 | 
					            {{ A_("Do you really want to delete this currency?") }}
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					          <div class="modal-footer">
 | 
				
			||||||
 | 
					            <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
 | 
				
			||||||
 | 
					            <button type="submit" class="btn btn-danger">{{ A_("Confirm") }}</button>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </form>
 | 
				
			||||||
 | 
					{% endif %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<div class="accounting-card col-sm-6">
 | 
				
			||||||
 | 
					  <div class="accounting-card-title">{{ obj.name }}</div>
 | 
				
			||||||
 | 
					  <div class="accounting-card-code">{{ obj.code }}</div>
 | 
				
			||||||
 | 
					  <div class="small text-secondary fst-italic">
 | 
				
			||||||
 | 
					    <div>{{ A_("Created") }} {{ obj.created_at }} {{ obj.created_by }}</div>
 | 
				
			||||||
 | 
					    <div>{{ A_("Updated") }} {{ obj.updated_at }} {{ obj.updated_by }}</div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
							
								
								
									
										30
									
								
								src/accounting/templates/accounting/currency/edit.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								src/accounting/templates/accounting/currency/edit.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,30 @@
 | 
				
			|||||||
 | 
					{#
 | 
				
			||||||
 | 
					The Mia! Accounting Flask Project
 | 
				
			||||||
 | 
					edit.html: The currency edit form
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					 you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					 You may obtain a copy of the License at
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					     http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					 distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					 See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					 limitations under the License.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Author: imacat@mail.imacat.idv.tw (imacat)
 | 
				
			||||||
 | 
					First written: 2023/2/6
 | 
				
			||||||
 | 
					#}
 | 
				
			||||||
 | 
					{% extends "accounting/currency/include/form.html" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block header %}{% block title %}{{ A_("%(currency)s Settings", currency=currency) }}{% endblock %}{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block back_url %}{{ url_for("accounting.currency.detail", currency=currency)|accounting_inherit_next }}{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block action_url %}{{ url_for("accounting.currency.update", currency=currency) }}{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block original_code %}{{ currency.code }}{% endblock %}
 | 
				
			||||||
@@ -0,0 +1,68 @@
 | 
				
			|||||||
 | 
					{#
 | 
				
			||||||
 | 
					The Mia! Accounting Flask Project
 | 
				
			||||||
 | 
					form.html: The currency form
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					 you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					 You may obtain a copy of the License at
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					     http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					 distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					 See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					 limitations under the License.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Author: imacat@mail.imacat.idv.tw (imacat)
 | 
				
			||||||
 | 
					First written: 2023/2/6
 | 
				
			||||||
 | 
					#}
 | 
				
			||||||
 | 
					{% extends "accounting/base.html" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block accounting_scripts %}
 | 
				
			||||||
 | 
					  <script src="{{ url_for("accounting.static", filename="js/currency-form.js") }}"></script>
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block content %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<div class="btn-group btn-actions mb-3">
 | 
				
			||||||
 | 
					  <a class="btn btn-primary" role="button" href="{% block back_url %}{% endblock %}">
 | 
				
			||||||
 | 
					    <i class="fa-solid fa-circle-chevron-left"></i>
 | 
				
			||||||
 | 
					    {{ A_("Back") }}
 | 
				
			||||||
 | 
					  </a>
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<form id="accounting-form" action="{% block action_url %}{% endblock %}" method="post">
 | 
				
			||||||
 | 
					  {{ form.csrf_token }}
 | 
				
			||||||
 | 
					  {% if "next" in request.args %}
 | 
				
			||||||
 | 
					    <input type="hidden" name="next" value="{{ request.args["next"] }}">
 | 
				
			||||||
 | 
					  {% endif %}
 | 
				
			||||||
 | 
					  <div class="form-floating mb-3">
 | 
				
			||||||
 | 
					    <input id="accounting-code" class="form-control {% if form.code.errors %} is-invalid {% endif %}" type="text" name="code" value="{{ "" if form.code.data is none else form.code.data }}" placeholder=" " required="required" data-exists-url="{{ url_for("accounting.currency-api.exists") }}" data-original="{% block original_code %}{% endblock %}" data-blocklist="{{ form.CODE_BLOCKLIST|tojson|forceescape }}">
 | 
				
			||||||
 | 
					    <label class="form-label" for="accounting-code">{{ A_("Code") }}</label>
 | 
				
			||||||
 | 
					    <div id="accounting-code-error" class="invalid-feedback">{% if form.code.errors %}{{ form.code.errors[0] }}{% endif %}</div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  <div class="form-floating mb-3">
 | 
				
			||||||
 | 
					    <input id="accounting-name" class="form-control {% if form.name.errors %} is-invalid {% endif %}" type="text" name="name" value="{{ "" if form.name.data is none else form.name.data }}" placeholder=" " required="required">
 | 
				
			||||||
 | 
					    <label class="form-label" for="accounting-name">{{ A_("Name") }}</label>
 | 
				
			||||||
 | 
					    <div id="accounting-name-error" class="invalid-feedback">{% if form.name.errors %}{{ form.name.errors[0] }}{% endif %}</div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  <div class="d-none d-md-block">
 | 
				
			||||||
 | 
					    <button class="btn btn-primary" type="submit">
 | 
				
			||||||
 | 
					      <i class="fa-solid fa-floppy-disk"></i>
 | 
				
			||||||
 | 
					      {{ A_("Save") }}
 | 
				
			||||||
 | 
					    </button>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  <div class="d-md-none accounting-material-fab">
 | 
				
			||||||
 | 
					    <button class="btn btn-primary" type="submit">
 | 
				
			||||||
 | 
					      <i class="fa-solid fa-floppy-disk"></i>
 | 
				
			||||||
 | 
					    </button>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</form>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
							
								
								
									
										68
									
								
								src/accounting/templates/accounting/currency/list.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								src/accounting/templates/accounting/currency/list.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,68 @@
 | 
				
			|||||||
 | 
					{#
 | 
				
			||||||
 | 
					The Mia! Accounting Flask Project
 | 
				
			||||||
 | 
					list.html: The currency list
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					 you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					 You may obtain a copy of the License at
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					     http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					 distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					 See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					 limitations under the License.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Author: imacat@mail.imacat.idv.tw (imacat)
 | 
				
			||||||
 | 
					First written: 2023/2/6
 | 
				
			||||||
 | 
					#}
 | 
				
			||||||
 | 
					{% extends "accounting/base.html" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block header %}{% block title %}{% if "q" in request.args %}{{ A_("Search Result for \"%(query)s\"", query=request.args["q"]) }}{% else %}{{ A_("Currency Management") }}{% endif %}{% endblock %}{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block content %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<div class="btn-group mb-2">
 | 
				
			||||||
 | 
					  {% if accounting_can_edit() %}
 | 
				
			||||||
 | 
					    <a class="btn btn-primary text-nowrap d-none d-md-block" href="{{ url_for("accounting.currency.create")|accounting_append_next }}">
 | 
				
			||||||
 | 
					      <i class="fa-solid fa-plus"></i>
 | 
				
			||||||
 | 
					      {{ A_("New") }}
 | 
				
			||||||
 | 
					    </a>
 | 
				
			||||||
 | 
					  {% endif %}
 | 
				
			||||||
 | 
					  <form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.currency.list") }}" method="get" role="search">
 | 
				
			||||||
 | 
					    <input id="accounting-search" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required" aria-label="Search">
 | 
				
			||||||
 | 
					    <label for="accounting-search" class="accounting-search-label">
 | 
				
			||||||
 | 
					      <button type="submit">
 | 
				
			||||||
 | 
					        <i class="fa-solid fa-magnifying-glass"></i>
 | 
				
			||||||
 | 
					        {{ A_("Search") }}
 | 
				
			||||||
 | 
					      </button>
 | 
				
			||||||
 | 
					    </label>
 | 
				
			||||||
 | 
					  </form>
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% if accounting_can_edit() %}
 | 
				
			||||||
 | 
					  <div class="d-md-none accounting-material-fab">
 | 
				
			||||||
 | 
					    <a class="btn btn-primary" href="{{ url_for("accounting.currency.create")|accounting_append_next }}">
 | 
				
			||||||
 | 
					      <i class="fa-solid fa-plus"></i>
 | 
				
			||||||
 | 
					    </a>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					{% endif %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% if list %}
 | 
				
			||||||
 | 
					  {% include "accounting/include/pagination.html" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  <div class="list-group">
 | 
				
			||||||
 | 
					  {% for item in list %}
 | 
				
			||||||
 | 
					    <a class="list-group-item list-group-item-action" href="{{ url_for("accounting.currency.detail", currency=item)|accounting_append_next }}">
 | 
				
			||||||
 | 
					      {{ item }}
 | 
				
			||||||
 | 
					    </a>
 | 
				
			||||||
 | 
					  {% endfor %}
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					{% else %}
 | 
				
			||||||
 | 
					  <p>{{ A_("There is no data.") }}</p>
 | 
				
			||||||
 | 
					{% endif %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
@@ -19,19 +19,31 @@ nav.html: The navigation menu for the accounting application.
 | 
				
			|||||||
Author: imacat@mail.imacat.idv.tw (imacat)
 | 
					Author: imacat@mail.imacat.idv.tw (imacat)
 | 
				
			||||||
First written: 2023/1/26
 | 
					First written: 2023/1/26
 | 
				
			||||||
#}
 | 
					#}
 | 
				
			||||||
{% if can_view_accounting() %}
 | 
					{% if accounting_can_view() %}
 | 
				
			||||||
  <li class="nav-item dropdown">
 | 
					  <li class="nav-item dropdown">
 | 
				
			||||||
    <span class="nav-link dropdown-toggle" data-bs-toggle="dropdown">
 | 
					    <span class="nav-link dropdown-toggle" data-bs-toggle="dropdown">
 | 
				
			||||||
      <i class="fa-solid fa-gear"></i>
 | 
					      <i class="fa-solid fa-gear"></i>
 | 
				
			||||||
      {{ A_("Accounting") }}
 | 
					      {{ A_("Accounting") }}
 | 
				
			||||||
    </span>
 | 
					    </span>
 | 
				
			||||||
    <ul class="dropdown-menu">
 | 
					    <ul class="dropdown-menu">
 | 
				
			||||||
 | 
					      <li>
 | 
				
			||||||
 | 
					        <a class="dropdown-item {% if request.endpoint.startswith("accounting.account.") %} active {% endif %}" href="{{ url_for("accounting.account.list") }}">
 | 
				
			||||||
 | 
					          <i class="fa-solid fa-list"></i>
 | 
				
			||||||
 | 
					          {{ A_("Accounts") }}
 | 
				
			||||||
 | 
					        </a>
 | 
				
			||||||
 | 
					      </li>
 | 
				
			||||||
      <li>
 | 
					      <li>
 | 
				
			||||||
        <a class="dropdown-item {% if request.endpoint.startswith("accounting.base-account.") %} active {% endif %}" href="{{ url_for("accounting.base-account.list") }}">
 | 
					        <a class="dropdown-item {% if request.endpoint.startswith("accounting.base-account.") %} active {% endif %}" href="{{ url_for("accounting.base-account.list") }}">
 | 
				
			||||||
          <i class="fa-solid fa-list"></i>
 | 
					          <i class="fa-solid fa-list"></i>
 | 
				
			||||||
          {{ A_("Base Accounts") }}
 | 
					          {{ A_("Base Accounts") }}
 | 
				
			||||||
        </a>
 | 
					        </a>
 | 
				
			||||||
      </li>
 | 
					      </li>
 | 
				
			||||||
 | 
					      <li>
 | 
				
			||||||
 | 
					        <a class="dropdown-item {% if request.endpoint.startswith("accounting.currency.") %} active {% endif %}" href="{{ url_for("accounting.currency.list") }}">
 | 
				
			||||||
 | 
					          <i class="fa-solid fa-money-bill-wave"></i>
 | 
				
			||||||
 | 
					          {{ A_("Currencies") }}
 | 
				
			||||||
 | 
					        </a>
 | 
				
			||||||
 | 
					      </li>
 | 
				
			||||||
    </ul>
 | 
					    </ul>
 | 
				
			||||||
  </li>
 | 
					  </li>
 | 
				
			||||||
{% endif %}
 | 
					{% endif %}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -19,10 +19,10 @@ pagination.html: The pagination navigation bar.
 | 
				
			|||||||
Author: imacat@mail.imacat.idv.tw (imacat)
 | 
					Author: imacat@mail.imacat.idv.tw (imacat)
 | 
				
			||||||
First written: 2023/1/26
 | 
					First written: 2023/1/26
 | 
				
			||||||
#}
 | 
					#}
 | 
				
			||||||
{% if pagination.is_needed %}
 | 
					{% if pagination.is_paged %}
 | 
				
			||||||
  <nav aria-label="Page navigation">
 | 
					  <nav aria-label="Page navigation">
 | 
				
			||||||
    <ul class="pagination">
 | 
					    <ul class="pagination">
 | 
				
			||||||
      {% for link in pagination.page_links %}
 | 
					      {% for link in pagination.pages %}
 | 
				
			||||||
        {% if link.uri is none %}
 | 
					        {% if link.uri is none %}
 | 
				
			||||||
          <li class="page-item disabled {% if not link.is_for_mobile %} d-none d-md-inline {% endif %}">
 | 
					          <li class="page-item disabled {% if not link.is_for_mobile %} d-none d-md-inline {% endif %}">
 | 
				
			||||||
            <span class="page-link">
 | 
					            <span class="page-link">
 | 
				
			||||||
@@ -42,7 +42,7 @@ First written: 2023/1/26
 | 
				
			|||||||
          {{ pagination.page_size }}
 | 
					          {{ pagination.page_size }}
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
        <ul class="dropdown-menu">
 | 
					        <ul class="dropdown-menu">
 | 
				
			||||||
          {% for link in pagination.page_sizes %}
 | 
					          {% for link in pagination.page_size_options %}
 | 
				
			||||||
            <li>
 | 
					            <li>
 | 
				
			||||||
              <a class="dropdown-item {% if link.is_current %} active {% endif %}" href="{{ link.uri }}">
 | 
					              <a class="dropdown-item {% if link.is_current %} active {% endif %}" href="{{ link.uri }}">
 | 
				
			||||||
                {{ link.text }}
 | 
					                {{ link.text }}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -8,8 +8,8 @@ msgid ""
 | 
				
			|||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
"Project-Id-Version: Mia! Accounting Flask 0.0.0\n"
 | 
					"Project-Id-Version: Mia! Accounting Flask 0.0.0\n"
 | 
				
			||||||
"Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n"
 | 
					"Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n"
 | 
				
			||||||
"POT-Creation-Date: 2023-01-28 13:37+0800\n"
 | 
					"POT-Creation-Date: 2023-02-07 16:22+0800\n"
 | 
				
			||||||
"PO-Revision-Date: 2023-01-28 13:37+0800\n"
 | 
					"PO-Revision-Date: 2023-02-07 18:04+0800\n"
 | 
				
			||||||
"Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n"
 | 
					"Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n"
 | 
				
			||||||
"Language: zh_Hant\n"
 | 
					"Language: zh_Hant\n"
 | 
				
			||||||
"Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n"
 | 
					"Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n"
 | 
				
			||||||
@@ -19,28 +19,281 @@ msgstr ""
 | 
				
			|||||||
"Content-Transfer-Encoding: 8bit\n"
 | 
					"Content-Transfer-Encoding: 8bit\n"
 | 
				
			||||||
"Generated-By: Babel 2.11.0\n"
 | 
					"Generated-By: Babel 2.11.0\n"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#: src/accounting/base_account/templates/accounting/base-account/list.html:24
 | 
					#: src/accounting/account/forms.py:41
 | 
				
			||||||
#: src/accounting/templates/accounting/include/nav.html:32
 | 
					msgid "The base account does not exist."
 | 
				
			||||||
msgid "Base Accounts"
 | 
					msgstr "沒有這個基本科目。"
 | 
				
			||||||
msgstr "基本科目"
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
#: src/accounting/base_account/templates/accounting/base-account/list.html:35
 | 
					#: src/accounting/account/forms.py:52
 | 
				
			||||||
 | 
					msgid "The base account is not available."
 | 
				
			||||||
 | 
					msgstr "不能選這個基本科目。"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: src/accounting/account/forms.py:61
 | 
				
			||||||
 | 
					#: src/accounting/static/js/account-form.js:110
 | 
				
			||||||
 | 
					msgid "Please select the base account."
 | 
				
			||||||
 | 
					msgstr "請選擇基本科目。"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: src/accounting/account/forms.py:67
 | 
				
			||||||
 | 
					msgid "Please fill in the title"
 | 
				
			||||||
 | 
					msgstr "請填上標題。"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: src/accounting/account/query.py:50
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/account/detail.html:90
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/account/list.html:62
 | 
				
			||||||
 | 
					msgid "Offset needed"
 | 
				
			||||||
 | 
					msgstr "逐筆核銷"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: src/accounting/account/views.py:88
 | 
				
			||||||
 | 
					msgid "The account is added successfully"
 | 
				
			||||||
 | 
					msgstr "科目加好了。"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: src/accounting/account/views.py:143
 | 
				
			||||||
 | 
					msgid "The account was not modified."
 | 
				
			||||||
 | 
					msgstr "科目未異動。"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: src/accounting/account/views.py:148
 | 
				
			||||||
 | 
					msgid "The account is updated successfully."
 | 
				
			||||||
 | 
					msgstr "科目存好了。"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: src/accounting/account/views.py:165
 | 
				
			||||||
 | 
					msgid "The account is deleted successfully."
 | 
				
			||||||
 | 
					msgstr "科目刪掉了"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: src/accounting/account/views.py:192
 | 
				
			||||||
 | 
					msgid "The order was not modified."
 | 
				
			||||||
 | 
					msgstr "順序未異動。"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: src/accounting/account/views.py:195
 | 
				
			||||||
 | 
					msgid "The order is updated successfully."
 | 
				
			||||||
 | 
					msgstr "順序存好了。"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: src/accounting/currency/forms.py:47
 | 
				
			||||||
 | 
					#: src/accounting/static/js/currency-form.js:136
 | 
				
			||||||
 | 
					msgid "Code conflicts with another currency."
 | 
				
			||||||
 | 
					msgstr "代碼與其它貨幣重複。"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: src/accounting/currency/forms.py:52
 | 
				
			||||||
 | 
					#: src/accounting/static/js/currency-form.js:92
 | 
				
			||||||
 | 
					msgid "Please fill in the code."
 | 
				
			||||||
 | 
					msgstr "請填上代碼。"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: src/accounting/currency/forms.py:54
 | 
				
			||||||
 | 
					#: src/accounting/static/js/currency-form.js:103
 | 
				
			||||||
 | 
					msgid "Code can only be composed of 3 upper-cased letters."
 | 
				
			||||||
 | 
					msgstr "代碼限為三個大寫英文字母。"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: src/accounting/currency/forms.py:57
 | 
				
			||||||
 | 
					#: src/accounting/static/js/currency-form.js:98
 | 
				
			||||||
 | 
					msgid "This code is not available."
 | 
				
			||||||
 | 
					msgstr "不能用這個代碼。"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: src/accounting/currency/forms.py:63
 | 
				
			||||||
 | 
					#: src/accounting/static/js/currency-form.js:168
 | 
				
			||||||
 | 
					msgid "Please fill in the name."
 | 
				
			||||||
 | 
					msgstr "請填上名稱。"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: src/accounting/currency/views.py:90
 | 
				
			||||||
 | 
					msgid "The currency is added successfully"
 | 
				
			||||||
 | 
					msgstr "貨幣加好了。"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: src/accounting/currency/views.py:146
 | 
				
			||||||
 | 
					msgid "The currency was not modified."
 | 
				
			||||||
 | 
					msgstr "貨幣未異動。"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: src/accounting/currency/views.py:151
 | 
				
			||||||
 | 
					msgid "The currency is updated successfully."
 | 
				
			||||||
 | 
					msgstr "貨幣存好了。"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: src/accounting/currency/views.py:167
 | 
				
			||||||
 | 
					msgid "The currency is deleted successfully."
 | 
				
			||||||
 | 
					msgstr "貨幣刪掉了"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: src/accounting/static/js/account-form.js:130
 | 
				
			||||||
 | 
					msgid "Please fill in the title."
 | 
				
			||||||
 | 
					msgstr "請填上標題。"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/account/create.html:24
 | 
				
			||||||
 | 
					msgid "Add a New Account"
 | 
				
			||||||
 | 
					msgstr "新增科目"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/account/detail.html:31
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/account/include/form.html:33
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/account/order.html:36
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/base-account/detail.html:31
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/currency/detail.html:31
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/currency/include/form.html:33
 | 
				
			||||||
 | 
					msgid "Back"
 | 
				
			||||||
 | 
					msgstr "回上頁"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/account/detail.html:36
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/currency/detail.html:36
 | 
				
			||||||
 | 
					msgid "Settings"
 | 
				
			||||||
 | 
					msgstr "設定"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/account/detail.html:41
 | 
				
			||||||
 | 
					msgid "Order"
 | 
				
			||||||
 | 
					msgstr "次序"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/account/detail.html:46
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/currency/detail.html:42
 | 
				
			||||||
 | 
					msgid "Delete"
 | 
				
			||||||
 | 
					msgstr "刪除"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/account/detail.html:69
 | 
				
			||||||
 | 
					msgid "Delete Account Confirmation"
 | 
				
			||||||
 | 
					msgstr "科目刪除確認"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/account/detail.html:73
 | 
				
			||||||
 | 
					msgid "Do you really want to delete this account?"
 | 
				
			||||||
 | 
					msgstr "你確定要刪掉這個科目嗎?"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/account/detail.html:76
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/account/include/form.html:111
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/currency/detail.html:72
 | 
				
			||||||
 | 
					msgid "Cancel"
 | 
				
			||||||
 | 
					msgstr "取消"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/account/detail.html:77
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/currency/detail.html:73
 | 
				
			||||||
 | 
					msgid "Confirm"
 | 
				
			||||||
 | 
					msgstr "確定"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/account/detail.html:94
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/currency/detail.html:85
 | 
				
			||||||
 | 
					msgid "Created"
 | 
				
			||||||
 | 
					msgstr "建檔"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/account/detail.html:95
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/currency/detail.html:86
 | 
				
			||||||
 | 
					msgid "Updated"
 | 
				
			||||||
 | 
					msgstr "更新"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/account/edit.html:24
 | 
				
			||||||
 | 
					#, python-format
 | 
				
			||||||
 | 
					msgid "%(account)s Settings"
 | 
				
			||||||
 | 
					msgstr "%(account)s設定"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/account/list.html:24
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/base-account/list.html:24
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/currency/list.html:24
 | 
				
			||||||
 | 
					#, python-format
 | 
				
			||||||
 | 
					msgid "Search Result for \"%(query)s\""
 | 
				
			||||||
 | 
					msgstr "「%(query)s」搜尋結果"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/account/list.html:24
 | 
				
			||||||
 | 
					msgid "Account Management"
 | 
				
			||||||
 | 
					msgstr "科目管理"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/account/list.html:32
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/currency/list.html:32
 | 
				
			||||||
 | 
					msgid "New"
 | 
				
			||||||
 | 
					msgstr "新增"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/account/include/form.html:98
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/account/list.html:40
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/base-account/list.html:34
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/currency/list.html:40
 | 
				
			||||||
msgid "Search"
 | 
					msgid "Search"
 | 
				
			||||||
msgstr "搜尋"
 | 
					msgstr "搜尋"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#: src/accounting/base_account/templates/accounting/base-account/list.html:53
 | 
					#: src/accounting/templates/accounting/account/list.html:68
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/account/order.html:81
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/base-account/list.html:51
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/currency/list.html:57
 | 
				
			||||||
msgid "There is no data."
 | 
					msgid "There is no data."
 | 
				
			||||||
msgstr "沒有資料。"
 | 
					msgstr "沒有資料。"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/account/order.html:29
 | 
				
			||||||
 | 
					#, python-format
 | 
				
			||||||
 | 
					msgid "The Accounts of %(base)s"
 | 
				
			||||||
 | 
					msgstr "%(base)s下的科目"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/account/include/form.html:75
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/account/order.html:62
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/currency/include/form.html:57
 | 
				
			||||||
 | 
					msgid "Save"
 | 
				
			||||||
 | 
					msgstr "儲存"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/account/include/form.html:45
 | 
				
			||||||
 | 
					msgid "Base account"
 | 
				
			||||||
 | 
					msgstr "基本科目"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/account/include/form.html:49
 | 
				
			||||||
 | 
					msgid "(Unknown)"
 | 
				
			||||||
 | 
					msgstr "(不明)"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/account/include/form.html:61
 | 
				
			||||||
 | 
					msgid "Title"
 | 
				
			||||||
 | 
					msgstr "標題"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/account/include/form.html:68
 | 
				
			||||||
 | 
					msgid "The entries in the account need offsets."
 | 
				
			||||||
 | 
					msgstr "帳目要逐筆核銷。"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/account/include/form.html:90
 | 
				
			||||||
 | 
					msgid "Select Base Account"
 | 
				
			||||||
 | 
					msgstr "選擇基本科目"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/account/include/form.html:113
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/account/include/form.html:115
 | 
				
			||||||
 | 
					msgid "Clear"
 | 
				
			||||||
 | 
					msgstr "清除"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/base-account/list.html:24
 | 
				
			||||||
 | 
					msgid "Base Account Managements"
 | 
				
			||||||
 | 
					msgstr "基本科目管理"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/currency/create.html:24
 | 
				
			||||||
 | 
					msgid "Add a New Currency"
 | 
				
			||||||
 | 
					msgstr "新增貨幣"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/currency/detail.html:65
 | 
				
			||||||
 | 
					msgid "Delete Currency Confirmation"
 | 
				
			||||||
 | 
					msgstr "貨幣刪除確認"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/currency/detail.html:69
 | 
				
			||||||
 | 
					msgid "Do you really want to delete this currency?"
 | 
				
			||||||
 | 
					msgstr "你確定要刪掉這個貨幣嗎?"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/currency/edit.html:24
 | 
				
			||||||
 | 
					#, python-format
 | 
				
			||||||
 | 
					msgid "%(currency)s Settings"
 | 
				
			||||||
 | 
					msgstr "%(currency)s設定"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/currency/list.html:24
 | 
				
			||||||
 | 
					msgid "Currency Management"
 | 
				
			||||||
 | 
					msgstr "貨幣管理"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/currency/include/form.html:44
 | 
				
			||||||
 | 
					msgid "Code"
 | 
				
			||||||
 | 
					msgstr "代碼"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/currency/include/form.html:50
 | 
				
			||||||
 | 
					msgid "Name"
 | 
				
			||||||
 | 
					msgstr "名稱"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#: src/accounting/templates/accounting/include/nav.html:26
 | 
					#: src/accounting/templates/accounting/include/nav.html:26
 | 
				
			||||||
msgid "Accounting"
 | 
					msgid "Accounting"
 | 
				
			||||||
msgstr "記帳"
 | 
					msgstr "記帳"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#: src/accounting/utils/pagination.py:146
 | 
					#: src/accounting/templates/accounting/include/nav.html:32
 | 
				
			||||||
msgid "Previous"
 | 
					msgid "Accounts"
 | 
				
			||||||
msgstr "前一頁"
 | 
					msgstr "科目"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#: src/accounting/utils/pagination.py:194
 | 
					#: src/accounting/templates/accounting/include/nav.html:38
 | 
				
			||||||
 | 
					msgid "Base Accounts"
 | 
				
			||||||
 | 
					msgstr "基本科目"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: src/accounting/templates/accounting/include/nav.html:44
 | 
				
			||||||
 | 
					msgid "Currencies"
 | 
				
			||||||
 | 
					msgstr "貨幣"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: src/accounting/utils/pagination.py:206
 | 
				
			||||||
 | 
					msgctxt "Pagination|"
 | 
				
			||||||
 | 
					msgid "Previous"
 | 
				
			||||||
 | 
					msgstr "上一頁"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: src/accounting/utils/pagination.py:255
 | 
				
			||||||
 | 
					msgctxt "Pagination|"
 | 
				
			||||||
msgid "Next"
 | 
					msgid "Next"
 | 
				
			||||||
msgstr "下一頁"
 | 
					msgstr "下一頁"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										86
									
								
								src/accounting/utils/next_url.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										86
									
								
								src/accounting/utils/next_url.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,86 @@
 | 
				
			|||||||
 | 
					# The Mia! Accounting Flask Project.
 | 
				
			||||||
 | 
					# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#  Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					#  you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					#  You may obtain a copy of the License at
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#      http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					#  distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					#  See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					#  limitations under the License.
 | 
				
			||||||
 | 
					"""The utilities to handle the next URL.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					This module should not import any other module from the application.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					from urllib.parse import urlparse, parse_qsl, ParseResult, urlencode, \
 | 
				
			||||||
 | 
					    urlunparse
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from flask import request, Blueprint
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def append_next(uri: str) -> str:
 | 
				
			||||||
 | 
					    """Appends the current URI as the next URI to the query argument.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :param uri: The URI.
 | 
				
			||||||
 | 
					    :return: The URI with the current URI appended as the next URI.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    next_uri: str = request.full_path if request.query_string else request.path
 | 
				
			||||||
 | 
					    return __set_next(uri, next_uri)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def inherit_next(uri: str) -> str:
 | 
				
			||||||
 | 
					    """Inherits the current next URI to the query argument, if exists.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :param uri: The URI.
 | 
				
			||||||
 | 
					    :return: The URI with the current next URI added at the query argument.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    next_uri: str | None = request.form.get("next") \
 | 
				
			||||||
 | 
					        if request.method == "POST" else request.args.get("next")
 | 
				
			||||||
 | 
					    if next_uri is None:
 | 
				
			||||||
 | 
					        return uri
 | 
				
			||||||
 | 
					    return __set_next(uri, next_uri)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def or_next(uri: str) -> str:
 | 
				
			||||||
 | 
					    """Returns the next URI, if exists, or the supplied URI.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :param uri: The URI.
 | 
				
			||||||
 | 
					    :return: The next URI or the supplied URI.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    next_uri: str | None = request.form.get("next") \
 | 
				
			||||||
 | 
					        if request.method == "POST" else request.args.get("next")
 | 
				
			||||||
 | 
					    return uri if next_uri is None else next_uri
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def __set_next(uri: str, next_uri: str) -> str:
 | 
				
			||||||
 | 
					    """Sets the next URI to the query arguments.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :param uri: The URI.
 | 
				
			||||||
 | 
					    :param next_uri: The next URI.
 | 
				
			||||||
 | 
					    :return: The URI with the next URI set.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    uri_p: ParseResult = urlparse(uri)
 | 
				
			||||||
 | 
					    params: list[tuple[str, str]] = parse_qsl(uri_p.query)
 | 
				
			||||||
 | 
					    params = [x for x in params if x[0] == "next"]
 | 
				
			||||||
 | 
					    params.append(("next", next_uri))
 | 
				
			||||||
 | 
					    parts: list[str] = list(uri_p)
 | 
				
			||||||
 | 
					    parts[4] = urlencode(params)
 | 
				
			||||||
 | 
					    return urlunparse(parts)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def init_app(bp: Blueprint) -> None:
 | 
				
			||||||
 | 
					    """Initializes the application.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :param bp: The blueprint of the accounting application.
 | 
				
			||||||
 | 
					    :return: None.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    bp.add_app_template_filter(append_next, "accounting_append_next")
 | 
				
			||||||
 | 
					    bp.add_app_template_filter(inherit_next, "accounting_inherit_next")
 | 
				
			||||||
 | 
					    bp.add_app_template_filter(or_next, "accounting_or_next")
 | 
				
			||||||
@@ -24,12 +24,13 @@ from urllib.parse import urlparse, parse_qsl, urlencode, urlunparse, \
 | 
				
			|||||||
    ParseResult
 | 
					    ParseResult
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from flask import request
 | 
					from flask import request
 | 
				
			||||||
 | 
					from werkzeug.routing import RequestRedirect
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from accounting.locale import gettext
 | 
					from accounting.locale import gettext, pgettext
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class PageLink:
 | 
					class Link:
 | 
				
			||||||
    """A link in the pagination."""
 | 
					    """A link."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __init__(self, text: str, uri: str | None = None,
 | 
					    def __init__(self, text: str, uri: str | None = None,
 | 
				
			||||||
                 is_current: bool = False, is_for_mobile: bool = False):
 | 
					                 is_current: bool = False, is_for_mobile: bool = False):
 | 
				
			||||||
@@ -52,15 +53,20 @@ class PageLink:
 | 
				
			|||||||
        """Whether the link should be shown on mobile screens."""
 | 
					        """Whether the link should be shown on mobile screens."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Redirection(RequestRedirect):
 | 
				
			||||||
 | 
					    """The redirection."""
 | 
				
			||||||
 | 
					    code = 302
 | 
				
			||||||
 | 
					    """The HTTP code."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					DEFAULT_PAGE_SIZE: int = 10
 | 
				
			||||||
 | 
					"""The default page size."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
T = t.TypeVar("T")
 | 
					T = t.TypeVar("T")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class Pagination(t.Generic[T]):
 | 
					class Pagination(t.Generic[T]):
 | 
				
			||||||
    """The pagination utilities"""
 | 
					    """The pagination utility."""
 | 
				
			||||||
    AVAILABLE_PAGE_SIZES: list[int] = [10, 100, 200]
 | 
					 | 
				
			||||||
    """The available page sizes."""
 | 
					 | 
				
			||||||
    DEFAULT_PAGE_SIZE: int = 10
 | 
					 | 
				
			||||||
    """The default page size."""
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __init__(self, items: list[T], is_reversed: bool = False):
 | 
					    def __init__(self, items: list[T], is_reversed: bool = False):
 | 
				
			||||||
        """Constructs the pagination.
 | 
					        """Constructs the pagination.
 | 
				
			||||||
@@ -68,130 +74,186 @@ class Pagination(t.Generic[T]):
 | 
				
			|||||||
        :param items: The items.
 | 
					        :param items: The items.
 | 
				
			||||||
        :param is_reversed: True if the default page is the last page, or False
 | 
					        :param is_reversed: True if the default page is the last page, or False
 | 
				
			||||||
            otherwise.
 | 
					            otherwise.
 | 
				
			||||||
 | 
					        :raise Redirection: When the pagination parameters are malformed.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        self.__items: list[T] = items
 | 
					        pagination: AbstractPagination[T] = EmptyPagination[T]() \
 | 
				
			||||||
        """All the items."""
 | 
					            if len(items) == 0 \
 | 
				
			||||||
        self.__is_reversed: bool = is_reversed
 | 
					            else NonEmptyPagination[T](items, is_reversed)
 | 
				
			||||||
        """Whether the default page is the last page."""
 | 
					        self.is_paged: bool = pagination.is_paged
 | 
				
			||||||
        self.page_size: int = int(request.args.get("page-size",
 | 
					        """Whether there should be pagination."""
 | 
				
			||||||
                                                   self.DEFAULT_PAGE_SIZE))
 | 
					        self.list: list[T] = pagination.list
 | 
				
			||||||
        """The number of items in a page."""
 | 
					        """The items shown in the list"""
 | 
				
			||||||
        self.__total_pages: int = 0 if len(items) == 0 \
 | 
					        self.pages: list[Link] = pagination.pages
 | 
				
			||||||
            else int((len(items) - 1) / self.page_size) + 1
 | 
					        """The pages."""
 | 
				
			||||||
        """The total number of pages."""
 | 
					        self.page_size: int = pagination.page_size
 | 
				
			||||||
        self.is_needed: bool = self.__total_pages > 1
 | 
					        """The number of items in a page."""
 | 
				
			||||||
 | 
					        self.page_size_options: list[Link] = pagination.page_size_options
 | 
				
			||||||
 | 
					        """The options to the number of items in a page."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class AbstractPagination(t.Generic[T]):
 | 
				
			||||||
 | 
					    """An abstract pagination."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self):
 | 
				
			||||||
 | 
					        """Constructs an empty pagination."""
 | 
				
			||||||
 | 
					        self.page_size: int = DEFAULT_PAGE_SIZE
 | 
				
			||||||
 | 
					        """The number of items in a page."""
 | 
				
			||||||
 | 
					        self.is_paged: bool = False
 | 
				
			||||||
        """Whether there should be pagination."""
 | 
					        """Whether there should be pagination."""
 | 
				
			||||||
        self.__default_page_no: int = 0
 | 
					 | 
				
			||||||
        """The default page number."""
 | 
					 | 
				
			||||||
        self.page_no: int = 0
 | 
					 | 
				
			||||||
        """The current page number."""
 | 
					 | 
				
			||||||
        self.list: list[T] = []
 | 
					        self.list: list[T] = []
 | 
				
			||||||
        """The items shown in the list"""
 | 
					        """The items shown in the list"""
 | 
				
			||||||
        if self.__total_pages > 0:
 | 
					        self.pages: list[Link] = []
 | 
				
			||||||
            self.__set_list()
 | 
					        """The pages."""
 | 
				
			||||||
 | 
					        self.page_size_options: list[Link] = []
 | 
				
			||||||
 | 
					        """The options to the number of items in a page."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class EmptyPagination(AbstractPagination[T]):
 | 
				
			||||||
 | 
					    """The pagination from empty data."""
 | 
				
			||||||
 | 
					    pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class NonEmptyPagination(AbstractPagination[T]):
 | 
				
			||||||
 | 
					    """The pagination with real data."""
 | 
				
			||||||
 | 
					    PAGE_SIZE_OPTIONS: list[int] = [10, 100, 200]
 | 
				
			||||||
 | 
					    """The page size options."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, items: list[T], is_reversed: bool = False):
 | 
				
			||||||
 | 
					        """Constructs the pagination.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param items: The items.
 | 
				
			||||||
 | 
					        :param is_reversed: True if the default page is the last page, or False
 | 
				
			||||||
 | 
					            otherwise.
 | 
				
			||||||
 | 
					        :raise Redirection: When the pagination parameters are malformed.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        super().__init__()
 | 
				
			||||||
        self.__current_uri: str = request.full_path if request.query_string \
 | 
					        self.__current_uri: str = request.full_path if request.query_string \
 | 
				
			||||||
            else request.path
 | 
					            else request.path
 | 
				
			||||||
        """The current URI."""
 | 
					        """The current URI."""
 | 
				
			||||||
        self.__base_uri_params: tuple[list[str], list[tuple[str, str]]] \
 | 
					        self.__is_reversed: bool = is_reversed
 | 
				
			||||||
            = self.__get_base_uri_params()
 | 
					        """Whether the default page is the last page."""
 | 
				
			||||||
        """The base URI parameters."""
 | 
					        self.page_size = self.__get_page_size()
 | 
				
			||||||
        self.page_links: list[PageLink] = self.__get_page_links()
 | 
					        self.__total_pages: int = int((len(items) - 1) / self.page_size) + 1
 | 
				
			||||||
        """The pagination links."""
 | 
					        """The total number of pages."""
 | 
				
			||||||
        self.page_sizes: list[PageLink] = self.__get_page_sizes()
 | 
					        self.is_paged = self.__total_pages > 1
 | 
				
			||||||
        """The links to switch the number of items in a page."""
 | 
					        self.__default_page_no: int = self.__total_pages \
 | 
				
			||||||
 | 
					            if self.__is_reversed else 1
 | 
				
			||||||
    def __set_list(self) -> None:
 | 
					        """The default page number."""
 | 
				
			||||||
        """Sets the items to show in the list.
 | 
					        self.__page_no: int = self.__get_page_no()
 | 
				
			||||||
 | 
					        """The current page number."""
 | 
				
			||||||
        :return: None.
 | 
					        lower_bound: int = (self.__page_no - 1) * self.page_size
 | 
				
			||||||
        """
 | 
					 | 
				
			||||||
        self.__default_page_no = self.__total_pages if self.__is_reversed \
 | 
					 | 
				
			||||||
            else 1
 | 
					 | 
				
			||||||
        self.page_no = int(request.args.get("page-no",
 | 
					 | 
				
			||||||
                                            self.__default_page_no))
 | 
					 | 
				
			||||||
        if self.page_no < 1:
 | 
					 | 
				
			||||||
            self.page_no = 1
 | 
					 | 
				
			||||||
        if self.page_no > self.__total_pages:
 | 
					 | 
				
			||||||
            self.page_no = self.__total_pages
 | 
					 | 
				
			||||||
        lower_bound: int = (self.page_no - 1) * self.page_size
 | 
					 | 
				
			||||||
        upper_bound: int = lower_bound + self.page_size
 | 
					        upper_bound: int = lower_bound + self.page_size
 | 
				
			||||||
        if upper_bound > len(self.__items):
 | 
					        if upper_bound > len(items):
 | 
				
			||||||
            upper_bound = len(self.__items)
 | 
					            upper_bound = len(items)
 | 
				
			||||||
        self.list = self.__items[lower_bound:upper_bound]
 | 
					        self.list = items[lower_bound:upper_bound]
 | 
				
			||||||
 | 
					        self.pages = self.__get_pages()
 | 
				
			||||||
 | 
					        self.page_size_options = self.__get_page_size_options()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __get_base_uri_params(self) -> tuple[list[str], list[tuple[str, str]]]:
 | 
					    def __get_page_size(self) -> int:
 | 
				
			||||||
        """Returns the base URI and its parameters, with the "page-no" and
 | 
					        """Returns the page size.
 | 
				
			||||||
        "page-size" parameters removed.
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :return: The URI parts and the cleaned-up query parameters.
 | 
					        :return: The page size.
 | 
				
			||||||
 | 
					        :raise Redirection: When the page size is malformed.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        uri_p: ParseResult = urlparse(self.__current_uri)
 | 
					        if "page-size" not in request.args:
 | 
				
			||||||
        params: list[tuple[str, str]] = parse_qsl(uri_p.query)
 | 
					            return DEFAULT_PAGE_SIZE
 | 
				
			||||||
        params = [x for x in params if x[0] not in ["page-no", "page-size"]]
 | 
					        try:
 | 
				
			||||||
        parts: list[str] = list(uri_p)
 | 
					            page_size: int = int(request.args["page-size"])
 | 
				
			||||||
        return parts, params
 | 
					        except ValueError:
 | 
				
			||||||
 | 
					            raise Redirection(self.__uri_set("page-size", None))
 | 
				
			||||||
 | 
					        if page_size == DEFAULT_PAGE_SIZE or page_size < 1:
 | 
				
			||||||
 | 
					            raise Redirection(self.__uri_set("page-size", None))
 | 
				
			||||||
 | 
					        return page_size
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __get_page_links(self) -> list[PageLink]:
 | 
					    def __get_page_no(self) -> int:
 | 
				
			||||||
 | 
					        """Returns the page number.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The page number.
 | 
				
			||||||
 | 
					        :raise Redirection: When the page number is malformed.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        if "page-no" not in request.args:
 | 
				
			||||||
 | 
					            return self.__default_page_no
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            page_no: int = int(request.args["page-no"])
 | 
				
			||||||
 | 
					        except ValueError:
 | 
				
			||||||
 | 
					            raise Redirection(self.__uri_set("page-no", None))
 | 
				
			||||||
 | 
					        if page_no == self.__default_page_no:
 | 
				
			||||||
 | 
					            raise Redirection(self.__uri_set("page-no", None))
 | 
				
			||||||
 | 
					        if page_no < 1:
 | 
				
			||||||
 | 
					            if not self.__is_reversed:
 | 
				
			||||||
 | 
					                raise Redirection(self.__uri_set("page-no", None))
 | 
				
			||||||
 | 
					            raise Redirection(self.__uri_set("page-no", "1"))
 | 
				
			||||||
 | 
					        if page_no > self.__total_pages:
 | 
				
			||||||
 | 
					            if self.__is_reversed:
 | 
				
			||||||
 | 
					                raise Redirection(self.__uri_set("page-no", None))
 | 
				
			||||||
 | 
					            raise Redirection(self.__uri_set("page-no",
 | 
				
			||||||
 | 
					                                             str(self.__total_pages)))
 | 
				
			||||||
 | 
					        return page_no
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __get_pages(self) -> list[Link]:
 | 
				
			||||||
        """Returns the page links in the pagination navigation.
 | 
					        """Returns the page links in the pagination navigation.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :return: The page links in the pagination navigation.
 | 
					        :return: The page links in the pagination navigation.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        if self.__total_pages < 2:
 | 
					        if not self.is_paged:
 | 
				
			||||||
            return []
 | 
					            return []
 | 
				
			||||||
        uri: str | None
 | 
					        uri: str | None
 | 
				
			||||||
        links: list[PageLink] = []
 | 
					        links: list[Link] = []
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # The previous page.
 | 
					        # The previous page.
 | 
				
			||||||
        uri = None if self.page_no == 1 else self.__uri_page(self.page_no - 1)
 | 
					        uri = None if self.__page_no == 1 \
 | 
				
			||||||
        links.append(PageLink(gettext("Previous"), uri, is_for_mobile=True))
 | 
					            else self.__uri_page(self.__page_no - 1)
 | 
				
			||||||
 | 
					        links.append(Link(pgettext("Pagination|", "Previous"), uri,
 | 
				
			||||||
 | 
					                          is_for_mobile=True))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # The first page.
 | 
					        # The first page.
 | 
				
			||||||
        if self.page_no > 1:
 | 
					        if self.__page_no > 1:
 | 
				
			||||||
            links.append(PageLink("1", self.__uri_page(1)))
 | 
					            links.append(Link("1", self.__uri_page(1)))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # The eclipse of the previous pages.
 | 
					        # The eclipse of the previous pages.
 | 
				
			||||||
        if self.page_no - 3 == 2:
 | 
					        if self.__page_no - 3 == 2:
 | 
				
			||||||
            links.append(PageLink(str(self.page_no - 3),
 | 
					            links.append(Link(str(self.__page_no - 3),
 | 
				
			||||||
                                  self.__uri_page(self.page_no - 3)))
 | 
					                              self.__uri_page(self.__page_no - 3)))
 | 
				
			||||||
        elif self.page_no - 3 > 2:
 | 
					        elif self.__page_no - 3 > 2:
 | 
				
			||||||
            links.append(PageLink("…"))
 | 
					            links.append(Link("…"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # The previous two pages.
 | 
					        # The previous two pages.
 | 
				
			||||||
        if self.page_no - 2 > 1:
 | 
					        if self.__page_no - 2 > 1:
 | 
				
			||||||
            links.append(PageLink(str(self.page_no - 2),
 | 
					            links.append(Link(str(self.__page_no - 2),
 | 
				
			||||||
                                  self.__uri_page(self.page_no - 2)))
 | 
					                              self.__uri_page(self.__page_no - 2)))
 | 
				
			||||||
        if self.page_no - 1 > 1:
 | 
					        if self.__page_no - 1 > 1:
 | 
				
			||||||
            links.append(PageLink(str(self.page_no - 1),
 | 
					            links.append(Link(str(self.__page_no - 1),
 | 
				
			||||||
                                  self.__uri_page(self.page_no - 1)))
 | 
					                              self.__uri_page(self.__page_no - 1)))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # The current page.
 | 
					        # The current page.
 | 
				
			||||||
        links.append(PageLink(str(self.page_no), self.__uri_page(self.page_no),
 | 
					        links.append(Link(str(self.__page_no), self.__uri_page(self.__page_no),
 | 
				
			||||||
                              is_current=True))
 | 
					                          is_current=True))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # The next two pages.
 | 
					        # The next two pages.
 | 
				
			||||||
        if self.page_no + 1 < self.__total_pages:
 | 
					        if self.__page_no + 1 < self.__total_pages:
 | 
				
			||||||
            links.append(PageLink(str(self.page_no + 1),
 | 
					            links.append(Link(str(self.__page_no + 1),
 | 
				
			||||||
                                  self.__uri_page(self.page_no + 1)))
 | 
					                              self.__uri_page(self.__page_no + 1)))
 | 
				
			||||||
        if self.page_no + 2 < self.__total_pages:
 | 
					        if self.__page_no + 2 < self.__total_pages:
 | 
				
			||||||
            links.append(PageLink(str(self.page_no + 2),
 | 
					            links.append(Link(str(self.__page_no + 2),
 | 
				
			||||||
                                  self.__uri_page(self.page_no + 2)))
 | 
					                              self.__uri_page(self.__page_no + 2)))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # The eclipse of the next pages.
 | 
					        # The eclipse of the next pages.
 | 
				
			||||||
        if self.page_no + 3 == self.__total_pages - 1:
 | 
					        if self.__page_no + 3 == self.__total_pages - 1:
 | 
				
			||||||
            links.append(PageLink(str(self.page_no + 3),
 | 
					            links.append(Link(str(self.__page_no + 3),
 | 
				
			||||||
                                  self.__uri_page(self.page_no + 3)))
 | 
					                              self.__uri_page(self.__page_no + 3)))
 | 
				
			||||||
        elif self.page_no + 3 < self.__total_pages - 1:
 | 
					        elif self.__page_no + 3 < self.__total_pages - 1:
 | 
				
			||||||
            links.append(PageLink("…"))
 | 
					            links.append(Link("…"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # The last page.
 | 
					        # The last page.
 | 
				
			||||||
        if self.page_no < self.__total_pages:
 | 
					        if self.__page_no < self.__total_pages:
 | 
				
			||||||
            links.append(PageLink(str(self.__total_pages),
 | 
					            links.append(Link(str(self.__total_pages),
 | 
				
			||||||
                                  self.__uri_page(self.__total_pages)))
 | 
					                              self.__uri_page(self.__total_pages)))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # The next page.
 | 
					        # The next page.
 | 
				
			||||||
        uri = None if self.page_no == self.__total_pages \
 | 
					        uri = None if self.__page_no == self.__total_pages \
 | 
				
			||||||
            else self.__uri_page(self.page_no + 1)
 | 
					            else self.__uri_page(self.__page_no + 1)
 | 
				
			||||||
        links.append(PageLink(gettext("Next"), uri, is_for_mobile=True))
 | 
					        links.append(Link(pgettext("Pagination|", "Next"), uri,
 | 
				
			||||||
 | 
					                          is_for_mobile=True))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return links
 | 
					        return links
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -201,21 +263,22 @@ class Pagination(t.Generic[T]):
 | 
				
			|||||||
        :param page_no: The page number.
 | 
					        :param page_no: The page number.
 | 
				
			||||||
        :return: The URI of the page.
 | 
					        :return: The URI of the page.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        params: list[tuple[str, str]] = []
 | 
					        if page_no == self.__page_no:
 | 
				
			||||||
        if page_no != self.__default_page_no:
 | 
					            return self.__current_uri
 | 
				
			||||||
            params.append(("page-no", str(page_no)))
 | 
					        if page_no == self.__default_page_no:
 | 
				
			||||||
        if self.page_size != self.DEFAULT_PAGE_SIZE:
 | 
					            return self.__uri_set("page-no", None)
 | 
				
			||||||
            params.append(("page-size", str(self.page_size)))
 | 
					        return self.__uri_set("page-no", str(page_no))
 | 
				
			||||||
        return self.__uri_set_params(params)
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __get_page_sizes(self) -> list[PageLink]:
 | 
					    def __get_page_size_options(self) -> list[Link]:
 | 
				
			||||||
        """Returns the available page sizes.
 | 
					        """Returns the page size options.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :return: The available page sizes.
 | 
					        :return: The page size options.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        return [PageLink(str(x), self.__uri_size(x),
 | 
					        if not self.is_paged:
 | 
				
			||||||
                         is_current=x == self.page_size)
 | 
					            return []
 | 
				
			||||||
                for x in self.AVAILABLE_PAGE_SIZES]
 | 
					        return [Link(str(x), self.__uri_size(x),
 | 
				
			||||||
 | 
					                     is_current=x == self.page_size)
 | 
				
			||||||
 | 
					                for x in self.PAGE_SIZE_OPTIONS]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __uri_size(self, page_size: int) -> str:
 | 
					    def __uri_size(self, page_size: int) -> str:
 | 
				
			||||||
        """Returns the URI of a page size.
 | 
					        """Returns the URI of a page size.
 | 
				
			||||||
@@ -225,16 +288,34 @@ class Pagination(t.Generic[T]):
 | 
				
			|||||||
        """
 | 
					        """
 | 
				
			||||||
        if page_size == self.page_size:
 | 
					        if page_size == self.page_size:
 | 
				
			||||||
            return self.__current_uri
 | 
					            return self.__current_uri
 | 
				
			||||||
        return self.__uri_set_params([("page-size", str(page_size))])
 | 
					        if page_size == DEFAULT_PAGE_SIZE:
 | 
				
			||||||
 | 
					            return self.__uri_set("page-size", None)
 | 
				
			||||||
 | 
					        return self.__uri_set("page-size", str(page_size))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __uri_set_params(self, params: list[tuple[str, str]]) -> str:
 | 
					    def __uri_set(self, name: str, value: str | None) -> str:
 | 
				
			||||||
        """Returns the URI with the query parameters set.
 | 
					        """Raises current URI with a parameter set.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :param params: The query parameters.
 | 
					        :param name: The name of the parameter.
 | 
				
			||||||
        :return: The URI with the query parameters set.
 | 
					        :param value: The value, or None to remove the parameter.
 | 
				
			||||||
 | 
					        :return: The URI with the parameter set.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        cur_params: list[tuple[str, str]] = self.__base_uri_params[1].copy()
 | 
					        uri_p: ParseResult = urlparse(self.__current_uri)
 | 
				
			||||||
        cur_params.extend(params)
 | 
					        params: list[tuple[str, str]] = parse_qsl(uri_p.query)
 | 
				
			||||||
        parts: list[str] = self.__base_uri_params[0].copy()
 | 
					
 | 
				
			||||||
        parts[4] = urlencode(cur_params)
 | 
					        # Try to keep the position of the parameter.
 | 
				
			||||||
 | 
					        i: int = 0
 | 
				
			||||||
 | 
					        is_found: bool = False
 | 
				
			||||||
 | 
					        while i < len(params):
 | 
				
			||||||
 | 
					            if params[i][0] == name:
 | 
				
			||||||
 | 
					                if is_found or value is None:
 | 
				
			||||||
 | 
					                    params = params[:i] + params[i + 1:]
 | 
				
			||||||
 | 
					                    continue
 | 
				
			||||||
 | 
					                params[i] = (name, value)
 | 
				
			||||||
 | 
					                is_found = True
 | 
				
			||||||
 | 
					            i = i + 1
 | 
				
			||||||
 | 
					        if not is_found and value is not None:
 | 
				
			||||||
 | 
					            params.append((name, value))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        parts: list[str] = list(uri_p)
 | 
				
			||||||
 | 
					        parts[4] = urlencode(params)
 | 
				
			||||||
        return urlunparse(parts)
 | 
					        return urlunparse(parts)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -21,7 +21,9 @@ This module should not import any other module from the application.
 | 
				
			|||||||
"""
 | 
					"""
 | 
				
			||||||
import typing as t
 | 
					import typing as t
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from flask import Flask, abort
 | 
					from flask import abort, Blueprint
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from accounting.utils.user import get_current_user
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def has_permission(rule: t.Callable[[], bool]) -> t.Callable:
 | 
					def has_permission(rule: t.Callable[[], bool]) -> t.Callable:
 | 
				
			||||||
@@ -75,17 +77,22 @@ def can_view() -> bool:
 | 
				
			|||||||
def can_edit() -> bool:
 | 
					def can_edit() -> bool:
 | 
				
			||||||
    """Returns whether the current user can edit the account data.
 | 
					    """Returns whether the current user can edit the account data.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    The user has to log in.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    :return: True if the current user can edit the accounting data, or False
 | 
					    :return: True if the current user can edit the accounting data, or False
 | 
				
			||||||
        otherwise.
 | 
					        otherwise.
 | 
				
			||||||
    """
 | 
					    """
 | 
				
			||||||
 | 
					    if get_current_user() is None:
 | 
				
			||||||
 | 
					        return False
 | 
				
			||||||
    return __can_edit_func()
 | 
					    return __can_edit_func()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def init_app(app: Flask, can_view_func: t.Callable[[], bool] | None = None,
 | 
					def init_app(bp: Blueprint,
 | 
				
			||||||
 | 
					             can_view_func: t.Callable[[], bool] | None = None,
 | 
				
			||||||
             can_edit_func: t.Callable[[], bool] | None = None) -> None:
 | 
					             can_edit_func: t.Callable[[], bool] | None = None) -> None:
 | 
				
			||||||
    """Initializes the application.
 | 
					    """Initializes the application.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    :param app: The Flask application.
 | 
					    :param bp: The blueprint of the accounting application.
 | 
				
			||||||
    :param can_view_func: A callback that returns whether the current user can
 | 
					    :param can_view_func: A callback that returns whether the current user can
 | 
				
			||||||
        view the accounting data.
 | 
					        view the accounting data.
 | 
				
			||||||
    :param can_edit_func: A callback that returns whether the current user can
 | 
					    :param can_edit_func: A callback that returns whether the current user can
 | 
				
			||||||
@@ -97,4 +104,5 @@ def init_app(app: Flask, can_view_func: t.Callable[[], bool] | None = None,
 | 
				
			|||||||
        __can_view_func = can_view_func
 | 
					        __can_view_func = can_view_func
 | 
				
			||||||
    if can_edit_func is not None:
 | 
					    if can_edit_func is not None:
 | 
				
			||||||
        __can_edit_func = can_edit_func
 | 
					        __can_edit_func = can_edit_func
 | 
				
			||||||
    app.jinja_env.globals["can_view_accounting"] = __can_view_func
 | 
					    bp.add_app_template_global(can_view, "accounting_can_view")
 | 
				
			||||||
 | 
					    bp.add_app_template_global(can_edit, "accounting_can_edit")
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -34,11 +34,22 @@ def parse_query_keywords(q: str | None) -> list[str]:
 | 
				
			|||||||
    if q == "":
 | 
					    if q == "":
 | 
				
			||||||
        return []
 | 
					        return []
 | 
				
			||||||
    keywords: list[str] = []
 | 
					    keywords: list[str] = []
 | 
				
			||||||
    while q is not None:
 | 
					    while True:
 | 
				
			||||||
        m: re.Match = re.match(r"(?:\"([^\"]+)\"|(\S+))(?:\s+(.+)|)$", q)
 | 
					        m: re.Match
 | 
				
			||||||
        if m.group(1) is not None:
 | 
					        m = re.match(r"\"([^\"]+)\"\s+(.+)$", q)
 | 
				
			||||||
 | 
					        if m is not None:
 | 
				
			||||||
            keywords.append(m.group(1))
 | 
					            keywords.append(m.group(1))
 | 
				
			||||||
        else:
 | 
					            q = m.group(2)
 | 
				
			||||||
            keywords.append(m.group(2))
 | 
					            continue
 | 
				
			||||||
        q = m.group(3)
 | 
					        m = re.match(r"\"([^\"]+)\"?$", q)
 | 
				
			||||||
 | 
					        if m is not None:
 | 
				
			||||||
 | 
					            keywords.append(m.group(1))
 | 
				
			||||||
 | 
					            break
 | 
				
			||||||
 | 
					        m = re.match(r"(\S+)\s+(.+)$", q)
 | 
				
			||||||
 | 
					        if m is not None:
 | 
				
			||||||
 | 
					            keywords.append(m.group(1))
 | 
				
			||||||
 | 
					            q = m.group(2)
 | 
				
			||||||
 | 
					            continue
 | 
				
			||||||
 | 
					        keywords.append(q)
 | 
				
			||||||
 | 
					        break
 | 
				
			||||||
    return keywords
 | 
					    return keywords
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										37
									
								
								src/accounting/utils/random_id.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								src/accounting/utils/random_id.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,37 @@
 | 
				
			|||||||
 | 
					# The Mia! Accounting Flask Project.
 | 
				
			||||||
 | 
					# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#  Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					#  you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					#  You may obtain a copy of the License at
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#      http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					#  distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					#  See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					#  limitations under the License.
 | 
				
			||||||
 | 
					"""The random ID mixin for the data models.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					This module should not import any other module from the application.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					import typing as t
 | 
				
			||||||
 | 
					from secrets import randbelow
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from accounting.database import db
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def new_id(cls: t.Type):
 | 
				
			||||||
 | 
					    """Returns a new random ID for the data model.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :param cls: The data model.
 | 
				
			||||||
 | 
					    :return: The generated new random ID.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    while True:
 | 
				
			||||||
 | 
					        obj_id: int = 100000000 + randbelow(900000000)
 | 
				
			||||||
 | 
					        if db.session.get(cls, obj_id) is None:
 | 
				
			||||||
 | 
					            return obj_id
 | 
				
			||||||
							
								
								
									
										32
									
								
								src/accounting/utils/strip_text.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								src/accounting/utils/strip_text.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,32 @@
 | 
				
			|||||||
 | 
					# The Mia! Accounting Flask Project.
 | 
				
			||||||
 | 
					# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#  Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					#  you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					#  You may obtain a copy of the License at
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#      http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					#  distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					#  See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					#  limitations under the License.
 | 
				
			||||||
 | 
					"""The text stripper for the form fields.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					This module should not import any other module from the application.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def strip_text(s: str | None) -> str | None:
 | 
				
			||||||
 | 
					    """The filter to strip the leading and trailing white spaces of text.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :param s: The text input string.
 | 
				
			||||||
 | 
					    :return: The filtered string.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    if s is None:
 | 
				
			||||||
 | 
					        return None
 | 
				
			||||||
 | 
					    return s.strip()
 | 
				
			||||||
							
								
								
									
										129
									
								
								src/accounting/utils/user.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										129
									
								
								src/accounting/utils/user.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,129 @@
 | 
				
			|||||||
 | 
					# The Mia! Accounting Flask Project.
 | 
				
			||||||
 | 
					# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#  Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					#  you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					#  You may obtain a copy of the License at
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#      http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					#  distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					#  See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					#  limitations under the License.
 | 
				
			||||||
 | 
					"""The user utilities.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					This module should not import any other module from the application.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					import typing as t
 | 
				
			||||||
 | 
					from abc import ABC, abstractmethod
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import sqlalchemy as sa
 | 
				
			||||||
 | 
					from flask import g
 | 
				
			||||||
 | 
					from flask_sqlalchemy.model import Model
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					T = t.TypeVar("T", bound=Model)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class AbstractUserUtils(t.Generic[T], ABC):
 | 
				
			||||||
 | 
					    """The abstract user utilities."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    @abstractmethod
 | 
				
			||||||
 | 
					    def cls(self) -> t.Type[T]:
 | 
				
			||||||
 | 
					        """Returns the user class.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The user class.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    @abstractmethod
 | 
				
			||||||
 | 
					    def pk_column(self) -> sa.Column:
 | 
				
			||||||
 | 
					        """Returns the primary key column.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The primary key column.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    @abstractmethod
 | 
				
			||||||
 | 
					    def current_user(self) -> T | None:
 | 
				
			||||||
 | 
					        """Returns the currently logged-in user.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The currently logged-in user, or None if the user has not
 | 
				
			||||||
 | 
					            logged in
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @abstractmethod
 | 
				
			||||||
 | 
					    def get_by_username(self, username: str) -> T | None:
 | 
				
			||||||
 | 
					        """Returns the user by her username.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The user by her username, or None if the user was not found.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @abstractmethod
 | 
				
			||||||
 | 
					    def get_pk(self, user: T) -> int:
 | 
				
			||||||
 | 
					        """Returns the primary key of the user.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The primary key of the user.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					__user_utils: AbstractUserUtils
 | 
				
			||||||
 | 
					"""The user utilities."""
 | 
				
			||||||
 | 
					user_cls: t.Type[Model] = Model
 | 
				
			||||||
 | 
					"""The user class."""
 | 
				
			||||||
 | 
					user_pk_column: sa.Column = sa.Column(sa.Integer)
 | 
				
			||||||
 | 
					"""The primary key column of the user class."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def init_user_utils(utils: AbstractUserUtils) -> None:
 | 
				
			||||||
 | 
					    """Initializes the user utilities.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :param utils: The user utilities.
 | 
				
			||||||
 | 
					    :return: None.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    global __user_utils, user_cls, user_pk_column
 | 
				
			||||||
 | 
					    __user_utils = utils
 | 
				
			||||||
 | 
					    user_cls = utils.cls
 | 
				
			||||||
 | 
					    user_pk_column = utils.pk_column
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def get_current_user_pk() -> int:
 | 
				
			||||||
 | 
					    """Returns the primary key value of the currently logged-in user.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :return: The primary key value of the currently logged-in user.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    return __user_utils.get_pk(get_current_user())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def has_user(username: str) -> bool:
 | 
				
			||||||
 | 
					    """Returns whether a user by the username exists.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :param username: The username.
 | 
				
			||||||
 | 
					    :return: True if the user by the username exists, or False otherwise.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    return __user_utils.get_by_username(username) is not None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def get_user_pk(username: str) -> int:
 | 
				
			||||||
 | 
					    """Returns the primary key value of the user by the username.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :param username: The username.
 | 
				
			||||||
 | 
					    :return: The primary key value of the user by the username.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    return __user_utils.get_pk(__user_utils.get_by_username(username))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def get_current_user() -> user_cls | None:
 | 
				
			||||||
 | 
					    """Returns the currently logged-in user.  The result is cached in the
 | 
				
			||||||
 | 
					    current request.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :return: The currently logged-in user.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    if not hasattr(g, "_accounting_user"):
 | 
				
			||||||
 | 
					        setattr(g, "_accounting_user", __user_utils.current_user)
 | 
				
			||||||
 | 
					    return getattr(g, "_accounting_user")
 | 
				
			||||||
@@ -28,7 +28,7 @@ from babel.messages.frontend import CommandLineInterface
 | 
				
			|||||||
from opencc import OpenCC
 | 
					from opencc import OpenCC
 | 
				
			||||||
 | 
					
 | 
				
			||||||
root_dir: Path = Path(__file__).parent.parent
 | 
					root_dir: Path = Path(__file__).parent.parent
 | 
				
			||||||
translation_dir: Path = root_dir / "tests" / "testsite" / "translations"
 | 
					translation_dir: Path = root_dir / "tests" / "test_site" / "translations"
 | 
				
			||||||
domain: str = "messages"
 | 
					domain: str = "messages"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -49,7 +49,7 @@ def babel_extract() -> None:
 | 
				
			|||||||
        / f"{domain}.po"
 | 
					        / f"{domain}.po"
 | 
				
			||||||
    CommandLineInterface().run([
 | 
					    CommandLineInterface().run([
 | 
				
			||||||
        "pybabel", "extract", "-F", str(cfg), "-k", "lazy_gettext", "-k", "A_",
 | 
					        "pybabel", "extract", "-F", str(cfg), "-k", "lazy_gettext", "-k", "A_",
 | 
				
			||||||
        "-o", str(pot), str(Path("tests") / "testsite")])
 | 
					        "-o", str(pot), str(Path("tests") / "test_site")])
 | 
				
			||||||
    if not zh_hant.exists():
 | 
					    if not zh_hant.exists():
 | 
				
			||||||
        zh_hant.touch()
 | 
					        zh_hant.touch()
 | 
				
			||||||
    if not zh_hans.exists():
 | 
					    if not zh_hans.exists():
 | 
				
			||||||
							
								
								
									
										713
									
								
								tests/test_account.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										713
									
								
								tests/test_account.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,713 @@
 | 
				
			|||||||
 | 
					# The Mia! Accounting Flask Project.
 | 
				
			||||||
 | 
					# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#  Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					#  you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					#  You may obtain a copy of the License at
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#      http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					#  distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					#  See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					#  limitations under the License.
 | 
				
			||||||
 | 
					"""The test for the account management.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					import time
 | 
				
			||||||
 | 
					import unittest
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import httpx
 | 
				
			||||||
 | 
					import sqlalchemy as sa
 | 
				
			||||||
 | 
					from click.testing import Result
 | 
				
			||||||
 | 
					from flask import Flask
 | 
				
			||||||
 | 
					from flask.testing import FlaskCliRunner
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from test_site import create_app
 | 
				
			||||||
 | 
					from testlib import get_client, set_locale
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class AccountData:
 | 
				
			||||||
 | 
					    """The account data."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, base_code: str, no: int, title: str):
 | 
				
			||||||
 | 
					        """Constructs the account data.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param base_code: The base code.
 | 
				
			||||||
 | 
					        :param no: The number.
 | 
				
			||||||
 | 
					        :param title: The title.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        self.base_code: str = base_code
 | 
				
			||||||
 | 
					        """The base code."""
 | 
				
			||||||
 | 
					        self.no: int = no
 | 
				
			||||||
 | 
					        """The number."""
 | 
				
			||||||
 | 
					        self.title: str = title
 | 
				
			||||||
 | 
					        """The title."""
 | 
				
			||||||
 | 
					        self.code: str = f"{self.base_code}-{self.no:03d}"
 | 
				
			||||||
 | 
					        """The code."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					cash: AccountData = AccountData("1111", 1, "Cash")
 | 
				
			||||||
 | 
					"""The cash account."""
 | 
				
			||||||
 | 
					bank: AccountData = AccountData("1113", 1, "Bank")
 | 
				
			||||||
 | 
					"""The bank account."""
 | 
				
			||||||
 | 
					stock: AccountData = AccountData("1121", 1, "Stock")
 | 
				
			||||||
 | 
					"""The stock account."""
 | 
				
			||||||
 | 
					loan: AccountData = AccountData("2112", 1, "Loan")
 | 
				
			||||||
 | 
					"""The loan account."""
 | 
				
			||||||
 | 
					PREFIX: str = "/accounting/accounts"
 | 
				
			||||||
 | 
					"""The URL prefix of the currency management."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class AccountCommandTestCase(unittest.TestCase):
 | 
				
			||||||
 | 
					    """The account console command test case."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def setUp(self) -> None:
 | 
				
			||||||
 | 
					        """Sets up the test.
 | 
				
			||||||
 | 
					        This is run once per test.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        self.app: Flask = create_app(is_testing=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        runner: FlaskCliRunner = self.app.test_cli_runner()
 | 
				
			||||||
 | 
					        with self.app.app_context():
 | 
				
			||||||
 | 
					            from accounting.database import db
 | 
				
			||||||
 | 
					            from accounting.models import BaseAccount, Account, AccountL10n
 | 
				
			||||||
 | 
					            result: Result
 | 
				
			||||||
 | 
					            result = runner.invoke(args="init-db")
 | 
				
			||||||
 | 
					            self.assertEqual(result.exit_code, 0)
 | 
				
			||||||
 | 
					            if BaseAccount.query.first() is None:
 | 
				
			||||||
 | 
					                result = runner.invoke(args="accounting-init-base")
 | 
				
			||||||
 | 
					                self.assertEqual(result.exit_code, 0)
 | 
				
			||||||
 | 
					            AccountL10n.query.delete()
 | 
				
			||||||
 | 
					            Account.query.delete()
 | 
				
			||||||
 | 
					            db.session.commit()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_init(self) -> None:
 | 
				
			||||||
 | 
					        """Tests the "accounting-init-account" console command.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        from accounting.models import BaseAccount, Account, AccountL10n
 | 
				
			||||||
 | 
					        runner: FlaskCliRunner = self.app.test_cli_runner()
 | 
				
			||||||
 | 
					        with self.app.app_context():
 | 
				
			||||||
 | 
					            result: Result = runner.invoke(args=["accounting-init-accounts",
 | 
				
			||||||
 | 
					                                                 "-u", "editor"])
 | 
				
			||||||
 | 
					            self.assertEqual(result.exit_code, 0)
 | 
				
			||||||
 | 
					        with self.app.app_context():
 | 
				
			||||||
 | 
					            bases: list[BaseAccount] = BaseAccount.query\
 | 
				
			||||||
 | 
					                .filter(sa.func.char_length(BaseAccount.code) == 4).all()
 | 
				
			||||||
 | 
					            accounts: list[Account] = Account.query.all()
 | 
				
			||||||
 | 
					            l10n: list[AccountL10n] = AccountL10n.query.all()
 | 
				
			||||||
 | 
					        self.assertEqual({x.code for x in bases},
 | 
				
			||||||
 | 
					                         {x.base_code for x in accounts})
 | 
				
			||||||
 | 
					        self.assertEqual(len(accounts), len(bases))
 | 
				
			||||||
 | 
					        self.assertEqual(len(l10n), len(bases) * 2)
 | 
				
			||||||
 | 
					        base_dict: dict[str, BaseAccount] = {x.code: x for x in bases}
 | 
				
			||||||
 | 
					        for account in accounts:
 | 
				
			||||||
 | 
					            base: BaseAccount = base_dict[account.base_code]
 | 
				
			||||||
 | 
					            self.assertEqual(account.no, 1)
 | 
				
			||||||
 | 
					            self.assertEqual(account.title_l10n, base.title_l10n)
 | 
				
			||||||
 | 
					            self.assertEqual({x.locale: x.title for x in account.l10n},
 | 
				
			||||||
 | 
					                             {x.locale: x.title for x in base.l10n})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class AccountTestCase(unittest.TestCase):
 | 
				
			||||||
 | 
					    """The account test case."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def setUp(self) -> None:
 | 
				
			||||||
 | 
					        """Sets up the test.
 | 
				
			||||||
 | 
					        This is run once per test.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        self.app: Flask = create_app(is_testing=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        runner: FlaskCliRunner = self.app.test_cli_runner()
 | 
				
			||||||
 | 
					        with self.app.app_context():
 | 
				
			||||||
 | 
					            from accounting.database import db
 | 
				
			||||||
 | 
					            from accounting.models import BaseAccount, Account, AccountL10n
 | 
				
			||||||
 | 
					            result: Result
 | 
				
			||||||
 | 
					            result = runner.invoke(args="init-db")
 | 
				
			||||||
 | 
					            self.assertEqual(result.exit_code, 0)
 | 
				
			||||||
 | 
					            if BaseAccount.query.first() is None:
 | 
				
			||||||
 | 
					                result = runner.invoke(args="accounting-init-base")
 | 
				
			||||||
 | 
					                self.assertEqual(result.exit_code, 0)
 | 
				
			||||||
 | 
					            AccountL10n.query.delete()
 | 
				
			||||||
 | 
					            Account.query.delete()
 | 
				
			||||||
 | 
					            db.session.commit()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.client, self.csrf_token = get_client(self, self.app, "editor")
 | 
				
			||||||
 | 
					        response: httpx.Response
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.client.post(f"{PREFIX}/store",
 | 
				
			||||||
 | 
					                                    data={"csrf_token": self.csrf_token,
 | 
				
			||||||
 | 
					                                          "base_code": cash.base_code,
 | 
				
			||||||
 | 
					                                          "title": cash.title})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					        self.assertEqual(response.headers["Location"],
 | 
				
			||||||
 | 
					                         f"{PREFIX}/{cash.code}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.client.post(f"{PREFIX}/store",
 | 
				
			||||||
 | 
					                                    data={"csrf_token": self.csrf_token,
 | 
				
			||||||
 | 
					                                          "base_code": bank.base_code,
 | 
				
			||||||
 | 
					                                          "title": bank.title})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					        self.assertEqual(response.headers["Location"],
 | 
				
			||||||
 | 
					                         f"{PREFIX}/{bank.code}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_nobody(self) -> None:
 | 
				
			||||||
 | 
					        """Test the permission as nobody.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        from accounting.models import Account
 | 
				
			||||||
 | 
					        client, csrf_token = get_client(self, self.app, "nobody")
 | 
				
			||||||
 | 
					        response: httpx.Response
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = client.get(PREFIX)
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 403)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = client.get(f"{PREFIX}/{cash.code}")
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 403)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = client.get(f"{PREFIX}/create")
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 403)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = client.post(f"{PREFIX}/store",
 | 
				
			||||||
 | 
					                               data={"csrf_token": csrf_token,
 | 
				
			||||||
 | 
					                                     "base_code": stock.base_code,
 | 
				
			||||||
 | 
					                                     "title": stock.title})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 403)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = client.get(f"{PREFIX}/{cash.code}/edit")
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 403)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = client.post(f"{PREFIX}/{cash.code}/update",
 | 
				
			||||||
 | 
					                               data={"csrf_token": csrf_token,
 | 
				
			||||||
 | 
					                                     "base_code": cash.base_code,
 | 
				
			||||||
 | 
					                                     "title": f"{cash.title}-2"})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 403)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = client.post(f"{PREFIX}/{cash.code}/delete",
 | 
				
			||||||
 | 
					                               data={"csrf_token": csrf_token})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 403)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = client.get(f"{PREFIX}/bases/{cash.base_code}")
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 403)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        with self.app.app_context():
 | 
				
			||||||
 | 
					            bank_id: int = Account.find_by_code(bank.code).id
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = client.post(f"{PREFIX}/bases/{bank.base_code}",
 | 
				
			||||||
 | 
					                               data={"csrf_token": csrf_token,
 | 
				
			||||||
 | 
					                                     "next": "/next",
 | 
				
			||||||
 | 
					                                     f"{bank_id}-no": "5"})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 403)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_viewer(self) -> None:
 | 
				
			||||||
 | 
					        """Test the permission as viewer.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        from accounting.models import Account
 | 
				
			||||||
 | 
					        client, csrf_token = get_client(self, self.app, "viewer")
 | 
				
			||||||
 | 
					        response: httpx.Response
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = client.get(PREFIX)
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 200)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = client.get(f"{PREFIX}/{cash.code}")
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 200)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = client.get(f"{PREFIX}/create")
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 403)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = client.post(f"{PREFIX}/store",
 | 
				
			||||||
 | 
					                               data={"csrf_token": csrf_token,
 | 
				
			||||||
 | 
					                                     "base_code": stock.base_code,
 | 
				
			||||||
 | 
					                                     "title": stock.title})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 403)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = client.get(f"{PREFIX}/{cash.code}/edit")
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 403)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = client.post(f"{PREFIX}/{cash.code}/update",
 | 
				
			||||||
 | 
					                               data={"csrf_token": csrf_token,
 | 
				
			||||||
 | 
					                                     "base_code": cash.base_code,
 | 
				
			||||||
 | 
					                                     "title": f"{cash.title}-2"})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 403)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = client.post(f"{PREFIX}/{cash.code}/delete",
 | 
				
			||||||
 | 
					                               data={"csrf_token": csrf_token})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 403)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = client.get(f"{PREFIX}/bases/{cash.base_code}")
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 200)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        with self.app.app_context():
 | 
				
			||||||
 | 
					            bank_id: int = Account.find_by_code(bank.code).id
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = client.post(f"{PREFIX}/bases/{bank.base_code}",
 | 
				
			||||||
 | 
					                               data={"csrf_token": csrf_token,
 | 
				
			||||||
 | 
					                                     "next": "/next",
 | 
				
			||||||
 | 
					                                     f"{bank_id}-no": "5"})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 403)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_editor(self) -> None:
 | 
				
			||||||
 | 
					        """Test the permission as editor.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        from accounting.models import Account
 | 
				
			||||||
 | 
					        response: httpx.Response
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.client.get(PREFIX)
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 200)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.client.get(f"{PREFIX}/{cash.code}")
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 200)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.client.get(f"{PREFIX}/create")
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 200)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.client.post(f"{PREFIX}/store",
 | 
				
			||||||
 | 
					                                    data={"csrf_token": self.csrf_token,
 | 
				
			||||||
 | 
					                                          "base_code": stock.base_code,
 | 
				
			||||||
 | 
					                                          "title": stock.title})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					        self.assertEqual(response.headers["Location"],
 | 
				
			||||||
 | 
					                         f"{PREFIX}/{stock.code}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.client.get(f"{PREFIX}/{cash.code}/edit")
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 200)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.client.post(f"{PREFIX}/{cash.code}/update",
 | 
				
			||||||
 | 
					                                    data={"csrf_token": self.csrf_token,
 | 
				
			||||||
 | 
					                                          "base_code": cash.base_code,
 | 
				
			||||||
 | 
					                                          "title": f"{cash.title}-2"})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					        self.assertEqual(response.headers["Location"], f"{PREFIX}/{cash.code}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.client.post(f"{PREFIX}/{cash.code}/delete",
 | 
				
			||||||
 | 
					                                    data={"csrf_token": self.csrf_token})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					        self.assertEqual(response.headers["Location"], PREFIX)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.client.get(f"{PREFIX}/bases/{cash.base_code}")
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 200)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        with self.app.app_context():
 | 
				
			||||||
 | 
					            bank_id: int = Account.find_by_code(bank.code).id
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.client.post(f"{PREFIX}/bases/{bank.base_code}",
 | 
				
			||||||
 | 
					                                    data={"csrf_token": self.csrf_token,
 | 
				
			||||||
 | 
					                                          "next": "/next",
 | 
				
			||||||
 | 
					                                          f"{bank_id}-no": "5"})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					        self.assertEqual(response.headers["Location"], "/next")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_add(self) -> None:
 | 
				
			||||||
 | 
					        """Tests to add the currencies.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        from accounting.database import db
 | 
				
			||||||
 | 
					        from accounting.models import Account
 | 
				
			||||||
 | 
					        create_uri: str = f"{PREFIX}/create"
 | 
				
			||||||
 | 
					        store_uri: str = f"{PREFIX}/store"
 | 
				
			||||||
 | 
					        detail_uri: str = f"{PREFIX}/{stock.code}"
 | 
				
			||||||
 | 
					        response: httpx.Response
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        with self.app.app_context():
 | 
				
			||||||
 | 
					            self.assertEqual({x.code for x in Account.query.all()},
 | 
				
			||||||
 | 
					                             {cash.code, bank.code})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Missing CSRF token
 | 
				
			||||||
 | 
					        response = self.client.post(store_uri,
 | 
				
			||||||
 | 
					                                    data={"base_code": stock.base_code,
 | 
				
			||||||
 | 
					                                          "title": stock.title})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 400)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # CSRF token mismatch
 | 
				
			||||||
 | 
					        response = self.client.post(store_uri,
 | 
				
			||||||
 | 
					                                    data={"csrf_token": f"{self.csrf_token}-2",
 | 
				
			||||||
 | 
					                                          "base_code": stock.base_code,
 | 
				
			||||||
 | 
					                                          "title": stock.title})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 400)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Empty base account code
 | 
				
			||||||
 | 
					        response = self.client.post(store_uri,
 | 
				
			||||||
 | 
					                                    data={"csrf_token": self.csrf_token,
 | 
				
			||||||
 | 
					                                          "base_code": " ",
 | 
				
			||||||
 | 
					                                          "title": stock.title})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					        self.assertEqual(response.headers["Location"], create_uri)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Non-existing base account
 | 
				
			||||||
 | 
					        response = self.client.post(store_uri,
 | 
				
			||||||
 | 
					                                    data={"csrf_token": self.csrf_token,
 | 
				
			||||||
 | 
					                                          "base_code": "9999",
 | 
				
			||||||
 | 
					                                          "title": stock.title})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					        self.assertEqual(response.headers["Location"], create_uri)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Unavailable base account
 | 
				
			||||||
 | 
					        response = self.client.post(store_uri,
 | 
				
			||||||
 | 
					                                    data={"csrf_token": self.csrf_token,
 | 
				
			||||||
 | 
					                                          "base_code": "1",
 | 
				
			||||||
 | 
					                                          "title": stock.title})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					        self.assertEqual(response.headers["Location"], create_uri)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Empty name
 | 
				
			||||||
 | 
					        response = self.client.post(store_uri,
 | 
				
			||||||
 | 
					                                    data={"csrf_token": self.csrf_token,
 | 
				
			||||||
 | 
					                                          "base_code": stock.base_code,
 | 
				
			||||||
 | 
					                                          "title": " "})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					        self.assertEqual(response.headers["Location"], create_uri)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Success, with spaces to be stripped
 | 
				
			||||||
 | 
					        response = self.client.post(store_uri,
 | 
				
			||||||
 | 
					                                    data={"csrf_token": self.csrf_token,
 | 
				
			||||||
 | 
					                                          "base_code": f" {stock.base_code} ",
 | 
				
			||||||
 | 
					                                          "title": f" {stock.title} "})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					        self.assertEqual(response.headers["Location"], detail_uri)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Success under the same base
 | 
				
			||||||
 | 
					        response = self.client.post(store_uri,
 | 
				
			||||||
 | 
					                                    data={"csrf_token": self.csrf_token,
 | 
				
			||||||
 | 
					                                          "base_code": stock.base_code,
 | 
				
			||||||
 | 
					                                          "title": stock.title})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					        self.assertEqual(response.headers["Location"],
 | 
				
			||||||
 | 
					                         f"{PREFIX}/{stock.base_code}-002")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Success under the same base, with order in a mess.
 | 
				
			||||||
 | 
					        with self.app.app_context():
 | 
				
			||||||
 | 
					            stock_2: Account = Account.find_by_code(f"{stock.base_code}-002")
 | 
				
			||||||
 | 
					            stock_2.no = 66
 | 
				
			||||||
 | 
					            db.session.commit()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.client.post(store_uri,
 | 
				
			||||||
 | 
					                                    data={"csrf_token": self.csrf_token,
 | 
				
			||||||
 | 
					                                          "base_code": stock.base_code,
 | 
				
			||||||
 | 
					                                          "title": stock.title})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					        self.assertEqual(response.headers["Location"],
 | 
				
			||||||
 | 
					                         f"{PREFIX}/{stock.base_code}-067")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        with self.app.app_context():
 | 
				
			||||||
 | 
					            self.assertEqual({x.code for x in Account.query.all()},
 | 
				
			||||||
 | 
					                             {cash.code, bank.code, stock.code,
 | 
				
			||||||
 | 
					                              f"{stock.base_code}-066",
 | 
				
			||||||
 | 
					                              f"{stock.base_code}-067"})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            stock_account: Account = Account.find_by_code(stock.code)
 | 
				
			||||||
 | 
					            self.assertEqual(stock_account.base_code, stock.base_code)
 | 
				
			||||||
 | 
					            self.assertEqual(stock_account.title_l10n, stock.title)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_basic_update(self) -> None:
 | 
				
			||||||
 | 
					        """Tests the basic rules to update a user.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        from accounting.models import Account
 | 
				
			||||||
 | 
					        detail_uri: str = f"{PREFIX}/{cash.code}"
 | 
				
			||||||
 | 
					        edit_uri: str = f"{PREFIX}/{cash.code}/edit"
 | 
				
			||||||
 | 
					        update_uri: str = f"{PREFIX}/{cash.code}/update"
 | 
				
			||||||
 | 
					        detail_c_uri: str = f"{PREFIX}/{stock.code}"
 | 
				
			||||||
 | 
					        response: httpx.Response
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Success, with spaces to be stripped
 | 
				
			||||||
 | 
					        response = self.client.post(update_uri,
 | 
				
			||||||
 | 
					                                    data={"csrf_token": self.csrf_token,
 | 
				
			||||||
 | 
					                                          "base_code": f" {cash.base_code} ",
 | 
				
			||||||
 | 
					                                          "title": f" {cash.title}-1 "})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					        self.assertEqual(response.headers["Location"], detail_uri)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        with self.app.app_context():
 | 
				
			||||||
 | 
					            cash_account: Account = Account.find_by_code(cash.code)
 | 
				
			||||||
 | 
					            self.assertEqual(cash_account.base_code, cash.base_code)
 | 
				
			||||||
 | 
					            self.assertEqual(cash_account.title_l10n, f"{cash.title}-1")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Empty base account code
 | 
				
			||||||
 | 
					        response = self.client.post(update_uri,
 | 
				
			||||||
 | 
					                                    data={"csrf_token": self.csrf_token,
 | 
				
			||||||
 | 
					                                          "base_code": " ",
 | 
				
			||||||
 | 
					                                          "title": stock.title})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					        self.assertEqual(response.headers["Location"], edit_uri)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Non-existing base account
 | 
				
			||||||
 | 
					        response = self.client.post(update_uri,
 | 
				
			||||||
 | 
					                                    data={"csrf_token": self.csrf_token,
 | 
				
			||||||
 | 
					                                          "base_code": "9999",
 | 
				
			||||||
 | 
					                                          "title": stock.title})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					        self.assertEqual(response.headers["Location"], edit_uri)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Unavailable base account
 | 
				
			||||||
 | 
					        response = self.client.post(update_uri,
 | 
				
			||||||
 | 
					                                    data={"csrf_token": self.csrf_token,
 | 
				
			||||||
 | 
					                                          "base_code": "1",
 | 
				
			||||||
 | 
					                                          "title": stock.title})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					        self.assertEqual(response.headers["Location"], edit_uri)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Empty name
 | 
				
			||||||
 | 
					        response = self.client.post(update_uri,
 | 
				
			||||||
 | 
					                                    data={"csrf_token": self.csrf_token,
 | 
				
			||||||
 | 
					                                          "base_code": stock.base_code,
 | 
				
			||||||
 | 
					                                          "title": " "})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					        self.assertEqual(response.headers["Location"], edit_uri)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Change the base account
 | 
				
			||||||
 | 
					        response = self.client.post(update_uri,
 | 
				
			||||||
 | 
					                                    data={"csrf_token": self.csrf_token,
 | 
				
			||||||
 | 
					                                          "base_code": stock.base_code,
 | 
				
			||||||
 | 
					                                          "title": stock.title})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					        self.assertEqual(response.headers["Location"], detail_c_uri)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.client.get(detail_uri)
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 404)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.client.get(detail_c_uri)
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 200)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_update_not_modified(self) -> None:
 | 
				
			||||||
 | 
					        """Tests that the data is not modified.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        from accounting.models import Account
 | 
				
			||||||
 | 
					        detail_uri: str = f"{PREFIX}/{cash.code}"
 | 
				
			||||||
 | 
					        update_uri: str = f"{PREFIX}/{cash.code}/update"
 | 
				
			||||||
 | 
					        response: httpx.Response
 | 
				
			||||||
 | 
					        time.sleep(1)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.client.post(update_uri,
 | 
				
			||||||
 | 
					                                    data={"csrf_token": self.csrf_token,
 | 
				
			||||||
 | 
					                                          "base_code": f" {cash.base_code} ",
 | 
				
			||||||
 | 
					                                          "title": f" {cash.title} "})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					        self.assertEqual(response.headers["Location"], detail_uri)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        with self.app.app_context():
 | 
				
			||||||
 | 
					            cash_account: Account = Account.find_by_code(cash.code)
 | 
				
			||||||
 | 
					            self.assertIsNotNone(cash_account)
 | 
				
			||||||
 | 
					            self.assertEqual(cash_account.created_at, cash_account.updated_at)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.client.post(update_uri,
 | 
				
			||||||
 | 
					                                    data={"csrf_token": self.csrf_token,
 | 
				
			||||||
 | 
					                                          "base_code": cash.base_code,
 | 
				
			||||||
 | 
					                                          "title": stock.title})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					        self.assertEqual(response.headers["Location"], detail_uri)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        with self.app.app_context():
 | 
				
			||||||
 | 
					            cash_account: Account = Account.find_by_code(cash.code)
 | 
				
			||||||
 | 
					            self.assertIsNotNone(cash_account)
 | 
				
			||||||
 | 
					            self.assertNotEqual(cash_account.created_at,
 | 
				
			||||||
 | 
					                                cash_account.updated_at)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_created_updated_by(self) -> None:
 | 
				
			||||||
 | 
					        """Tests the created-by and updated-by record.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        from accounting.models import Account
 | 
				
			||||||
 | 
					        editor_username, editor2_username = "editor", "editor2"
 | 
				
			||||||
 | 
					        client, csrf_token = get_client(self, self.app, editor2_username)
 | 
				
			||||||
 | 
					        detail_uri: str = f"{PREFIX}/{cash.code}"
 | 
				
			||||||
 | 
					        update_uri: str = f"{PREFIX}/{cash.code}/update"
 | 
				
			||||||
 | 
					        response: httpx.Response
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        with self.app.app_context():
 | 
				
			||||||
 | 
					            cash_account: Account = Account.find_by_code(cash.code)
 | 
				
			||||||
 | 
					            self.assertEqual(cash_account.created_by.username, editor_username)
 | 
				
			||||||
 | 
					            self.assertEqual(cash_account.updated_by.username, editor_username)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = client.post(update_uri,
 | 
				
			||||||
 | 
					                               data={"csrf_token": csrf_token,
 | 
				
			||||||
 | 
					                                     "base_code": cash.base_code,
 | 
				
			||||||
 | 
					                                     "title": f"{cash.title}-2"})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					        self.assertEqual(response.headers["Location"], detail_uri)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        with self.app.app_context():
 | 
				
			||||||
 | 
					            cash_account: Account = Account.find_by_code(cash.code)
 | 
				
			||||||
 | 
					            self.assertEqual(cash_account.created_by.username,
 | 
				
			||||||
 | 
					                             editor_username)
 | 
				
			||||||
 | 
					            self.assertEqual(cash_account.updated_by.username,
 | 
				
			||||||
 | 
					                             editor2_username)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_l10n(self) -> None:
 | 
				
			||||||
 | 
					        """Tests the localization.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: None
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        from accounting.models import Account
 | 
				
			||||||
 | 
					        detail_uri: str = f"{PREFIX}/{cash.code}"
 | 
				
			||||||
 | 
					        update_uri: str = f"{PREFIX}/{cash.code}/update"
 | 
				
			||||||
 | 
					        response: httpx.Response
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        with self.app.app_context():
 | 
				
			||||||
 | 
					            cash_account: Account = Account.find_by_code(cash.code)
 | 
				
			||||||
 | 
					            self.assertEqual(cash_account.title_l10n, cash.title)
 | 
				
			||||||
 | 
					            self.assertEqual(cash_account.l10n, [])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        set_locale(self, self.client, self.csrf_token, "zh_Hant")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.client.post(update_uri,
 | 
				
			||||||
 | 
					                                    data={"csrf_token": self.csrf_token,
 | 
				
			||||||
 | 
					                                          "base_code": cash.base_code,
 | 
				
			||||||
 | 
					                                          "title": f"{cash.title}-zh_Hant"})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					        self.assertEqual(response.headers["Location"], detail_uri)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        with self.app.app_context():
 | 
				
			||||||
 | 
					            cash_account: Account = Account.find_by_code(cash.code)
 | 
				
			||||||
 | 
					            self.assertEqual(cash_account.title_l10n, cash.title)
 | 
				
			||||||
 | 
					            self.assertEqual({(x.locale, x.title) for x in cash_account.l10n},
 | 
				
			||||||
 | 
					                             {("zh_Hant", f"{cash.title}-zh_Hant")})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        set_locale(self, self.client, self.csrf_token, "en")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.client.post(update_uri,
 | 
				
			||||||
 | 
					                                    data={"csrf_token": self.csrf_token,
 | 
				
			||||||
 | 
					                                          "base_code": cash.base_code,
 | 
				
			||||||
 | 
					                                          "title": f"{cash.title}-2"})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					        self.assertEqual(response.headers["Location"], detail_uri)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        with self.app.app_context():
 | 
				
			||||||
 | 
					            cash_account: Account = Account.find_by_code(cash.code)
 | 
				
			||||||
 | 
					            self.assertEqual(cash_account.title_l10n, f"{cash.title}-2")
 | 
				
			||||||
 | 
					            self.assertEqual({(x.locale, x.title) for x in cash_account.l10n},
 | 
				
			||||||
 | 
					                             {("zh_Hant", f"{cash.title}-zh_Hant")})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        set_locale(self, self.client, self.csrf_token, "zh_Hant")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.client.post(update_uri,
 | 
				
			||||||
 | 
					                                    data={"csrf_token": self.csrf_token,
 | 
				
			||||||
 | 
					                                          "base_code": cash.base_code,
 | 
				
			||||||
 | 
					                                          "title": f"{cash.title}-zh_Hant-2"})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					        self.assertEqual(response.headers["Location"], detail_uri)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        with self.app.app_context():
 | 
				
			||||||
 | 
					            cash_account: Account = Account.find_by_code(cash.code)
 | 
				
			||||||
 | 
					            self.assertEqual(cash_account.title_l10n, f"{cash.title}-2")
 | 
				
			||||||
 | 
					            self.assertEqual({(x.locale, x.title) for x in cash_account.l10n},
 | 
				
			||||||
 | 
					                             {("zh_Hant", f"{cash.title}-zh_Hant-2")})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_delete(self) -> None:
 | 
				
			||||||
 | 
					        """Tests to delete a currency.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        from accounting.models import Account
 | 
				
			||||||
 | 
					        detail_uri: str = f"{PREFIX}/{cash.code}"
 | 
				
			||||||
 | 
					        delete_uri: str = f"{PREFIX}/{cash.code}/delete"
 | 
				
			||||||
 | 
					        list_uri: str = PREFIX
 | 
				
			||||||
 | 
					        response: httpx.Response
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        with self.app.app_context():
 | 
				
			||||||
 | 
					            self.assertEqual({x.code for x in Account.query.all()},
 | 
				
			||||||
 | 
					                             {cash.code, bank.code})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.client.get(detail_uri)
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 200)
 | 
				
			||||||
 | 
					        response = self.client.post(delete_uri,
 | 
				
			||||||
 | 
					                                    data={"csrf_token": self.csrf_token})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					        self.assertEqual(response.headers["Location"], list_uri)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        with self.app.app_context():
 | 
				
			||||||
 | 
					            self.assertEqual({x.code for x in Account.query.all()},
 | 
				
			||||||
 | 
					                             {bank.code})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.client.get(detail_uri)
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 404)
 | 
				
			||||||
 | 
					        response = self.client.post(delete_uri,
 | 
				
			||||||
 | 
					                                    data={"csrf_token": self.csrf_token})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 404)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_reorder(self) -> None:
 | 
				
			||||||
 | 
					        """Tests to reorder the accounts under a same base account.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        from accounting.database import db
 | 
				
			||||||
 | 
					        from accounting.models import Account
 | 
				
			||||||
 | 
					        response: httpx.Response
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        for i in range(2, 6):
 | 
				
			||||||
 | 
					            response = self.client.post(f"{PREFIX}/store",
 | 
				
			||||||
 | 
					                                        data={"csrf_token": self.csrf_token,
 | 
				
			||||||
 | 
					                                              "base_code": "1111",
 | 
				
			||||||
 | 
					                                              "title": "Title"})
 | 
				
			||||||
 | 
					            self.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					            self.assertEqual(response.headers["Location"],
 | 
				
			||||||
 | 
					                             f"{PREFIX}/1111-00{i}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Normal reorder
 | 
				
			||||||
 | 
					        with self.app.app_context():
 | 
				
			||||||
 | 
					            id_1: int = Account.find_by_code("1111-001").id
 | 
				
			||||||
 | 
					            id_2: int = Account.find_by_code("1111-002").id
 | 
				
			||||||
 | 
					            id_3: int = Account.find_by_code("1111-003").id
 | 
				
			||||||
 | 
					            id_4: int = Account.find_by_code("1111-004").id
 | 
				
			||||||
 | 
					            id_5: int = Account.find_by_code("1111-005").id
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.client.post(f"{PREFIX}/bases/1111",
 | 
				
			||||||
 | 
					                                    data={"csrf_token": self.csrf_token,
 | 
				
			||||||
 | 
					                                          "next": "/next",
 | 
				
			||||||
 | 
					                                          f"{id_1}-no": "4",
 | 
				
			||||||
 | 
					                                          f"{id_2}-no": "1",
 | 
				
			||||||
 | 
					                                          f"{id_3}-no": "5",
 | 
				
			||||||
 | 
					                                          f"{id_4}-no": "2",
 | 
				
			||||||
 | 
					                                          f"{id_5}-no": "3"})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					        self.assertEqual(response.headers["Location"], f"/next")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        with self.app.app_context():
 | 
				
			||||||
 | 
					            self.assertEqual(db.session.get(Account, id_1).code, "1111-004")
 | 
				
			||||||
 | 
					            self.assertEqual(db.session.get(Account, id_2).code, "1111-001")
 | 
				
			||||||
 | 
					            self.assertEqual(db.session.get(Account, id_3).code, "1111-005")
 | 
				
			||||||
 | 
					            self.assertEqual(db.session.get(Account, id_4).code, "1111-002")
 | 
				
			||||||
 | 
					            self.assertEqual(db.session.get(Account, id_5).code, "1111-003")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Malformed orders
 | 
				
			||||||
 | 
					        with self.app.app_context():
 | 
				
			||||||
 | 
					            db.session.get(Account, id_1).no = 3
 | 
				
			||||||
 | 
					            db.session.get(Account, id_2).no = 4
 | 
				
			||||||
 | 
					            db.session.get(Account, id_3).no = 6
 | 
				
			||||||
 | 
					            db.session.get(Account, id_4).no = 8
 | 
				
			||||||
 | 
					            db.session.get(Account, id_5).no = 9
 | 
				
			||||||
 | 
					            db.session.commit()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.client.post(f"{PREFIX}/bases/1111",
 | 
				
			||||||
 | 
					                                    data={"csrf_token": self.csrf_token,
 | 
				
			||||||
 | 
					                                          "next": "/next",
 | 
				
			||||||
 | 
					                                          f"{id_2}-no": "3a",
 | 
				
			||||||
 | 
					                                          f"{id_3}-no": "5",
 | 
				
			||||||
 | 
					                                          f"{id_4}-no": "2"})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					        self.assertEqual(response.headers["Location"], f"/next")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        with self.app.app_context():
 | 
				
			||||||
 | 
					            self.assertEqual(db.session.get(Account, id_1).code, "1111-003")
 | 
				
			||||||
 | 
					            self.assertEqual(db.session.get(Account, id_2).code, "1111-004")
 | 
				
			||||||
 | 
					            self.assertEqual(db.session.get(Account, id_3).code, "1111-002")
 | 
				
			||||||
 | 
					            self.assertEqual(db.session.get(Account, id_4).code, "1111-001")
 | 
				
			||||||
 | 
					            self.assertEqual(db.session.get(Account, id_5).code, "1111-005")
 | 
				
			||||||
@@ -14,10 +14,11 @@
 | 
				
			|||||||
#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
					#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
#  See the License for the specific language governing permissions and
 | 
					#  See the License for the specific language governing permissions and
 | 
				
			||||||
#  limitations under the License.
 | 
					#  limitations under the License.
 | 
				
			||||||
 | 
					 | 
				
			||||||
"""The test for the base account management.
 | 
					"""The test for the base account management.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
"""
 | 
					"""
 | 
				
			||||||
 | 
					import csv
 | 
				
			||||||
 | 
					import typing as t
 | 
				
			||||||
import unittest
 | 
					import unittest
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import httpx
 | 
					import httpx
 | 
				
			||||||
@@ -25,8 +26,61 @@ from click.testing import Result
 | 
				
			|||||||
from flask import Flask
 | 
					from flask import Flask
 | 
				
			||||||
from flask.testing import FlaskCliRunner
 | 
					from flask.testing import FlaskCliRunner
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from testlib import get_csrf_token
 | 
					from test_site import create_app
 | 
				
			||||||
from testsite import create_app
 | 
					from testlib import get_client
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class BaseAccountCommandTestCase(unittest.TestCase):
 | 
				
			||||||
 | 
					    """The base account console command test case."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def setUp(self) -> None:
 | 
				
			||||||
 | 
					        """Sets up the test.
 | 
				
			||||||
 | 
					        This is run once per test.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        from accounting.models import BaseAccount, BaseAccountL10n
 | 
				
			||||||
 | 
					        self.app: Flask = create_app(is_testing=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        runner: FlaskCliRunner = self.app.test_cli_runner()
 | 
				
			||||||
 | 
					        with self.app.app_context():
 | 
				
			||||||
 | 
					            result: Result = runner.invoke(args="init-db")
 | 
				
			||||||
 | 
					            self.assertEqual(result.exit_code, 0)
 | 
				
			||||||
 | 
					            BaseAccountL10n.query.delete()
 | 
				
			||||||
 | 
					            BaseAccount.query.delete()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_init(self) -> None:
 | 
				
			||||||
 | 
					        """Tests the "accounting-init-base" console command.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        from accounting import data_dir
 | 
				
			||||||
 | 
					        from accounting.models import BaseAccount
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        with open(data_dir / "base_accounts.csv") as fh:
 | 
				
			||||||
 | 
					            data: dict[dict[str, t.Any]] \
 | 
				
			||||||
 | 
					                = {x["code"]: {"code": x["code"],
 | 
				
			||||||
 | 
					                               "title": x["title"],
 | 
				
			||||||
 | 
					                               "l10n": {y[5:]: x[y]
 | 
				
			||||||
 | 
					                                        for y in x if y.startswith("l10n-")}}
 | 
				
			||||||
 | 
					                   for x in csv.DictReader(fh)}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        runner: FlaskCliRunner = self.app.test_cli_runner()
 | 
				
			||||||
 | 
					        result: Result = runner.invoke(args="accounting-init-base")
 | 
				
			||||||
 | 
					        self.assertEqual(result.exit_code, 0)
 | 
				
			||||||
 | 
					        with self.app.app_context():
 | 
				
			||||||
 | 
					            accounts: list[BaseAccount] = BaseAccount.query.all()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.assertEqual(len(accounts), len(data))
 | 
				
			||||||
 | 
					        for account in accounts:
 | 
				
			||||||
 | 
					            self.assertIn(account.code, data)
 | 
				
			||||||
 | 
					            self.assertEqual(account.title_l10n, data[account.code]["title"])
 | 
				
			||||||
 | 
					            l10n: dict[str, str] = {x.locale: x.title for x in account.l10n}
 | 
				
			||||||
 | 
					            self.assertEqual(len(l10n), len(data[account.code]["l10n"]))
 | 
				
			||||||
 | 
					            for locale in l10n:
 | 
				
			||||||
 | 
					                self.assertIn(locale, data[account.code]["l10n"])
 | 
				
			||||||
 | 
					                self.assertEqual(l10n[locale],
 | 
				
			||||||
 | 
					                                 data[account.code]["l10n"][locale])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class BaseAccountTestCase(unittest.TestCase):
 | 
					class BaseAccountTestCase(unittest.TestCase):
 | 
				
			||||||
@@ -38,76 +92,55 @@ class BaseAccountTestCase(unittest.TestCase):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        :return: None.
 | 
					        :return: None.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
 | 
					        from accounting.models import BaseAccount
 | 
				
			||||||
        self.app: Flask = create_app(is_testing=True)
 | 
					        self.app: Flask = create_app(is_testing=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        runner: FlaskCliRunner = self.app.test_cli_runner()
 | 
					        runner: FlaskCliRunner = self.app.test_cli_runner()
 | 
				
			||||||
        with self.app.app_context():
 | 
					        with self.app.app_context():
 | 
				
			||||||
            result: Result = runner.invoke(args="init-db")
 | 
					            result: Result = runner.invoke(args="init-db")
 | 
				
			||||||
            self.assertEqual(result.exit_code, 0)
 | 
					            self.assertEqual(result.exit_code, 0)
 | 
				
			||||||
        self.client: httpx.Client = httpx.Client(app=self.app,
 | 
					            if BaseAccount.query.first() is None:
 | 
				
			||||||
                                                 base_url="https://testserver")
 | 
					                result = runner.invoke(args="accounting-init-base")
 | 
				
			||||||
        self.client.headers["Referer"] = "https://testserver"
 | 
					                self.assertEqual(result.exit_code, 0)
 | 
				
			||||||
        self.csrf_token: str = get_csrf_token(self, self.client, "/login")
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_init(self) -> None:
 | 
					    def test_nobody(self) -> None:
 | 
				
			||||||
        """Tests the "accounting-init-base" console command.
 | 
					        """Test the permission as nobody.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :return: None.
 | 
					        :return: None.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        from accounting.base_account.models import BaseAccount, BaseAccountL10n
 | 
					        client, csrf_token = get_client(self, self.app, "nobody")
 | 
				
			||||||
        runner: FlaskCliRunner = self.app.test_cli_runner()
 | 
					 | 
				
			||||||
        result: Result = runner.invoke(args="accounting-init-base")
 | 
					 | 
				
			||||||
        self.assertEqual(result.exit_code, 0)
 | 
					 | 
				
			||||||
        with self.app.app_context():
 | 
					 | 
				
			||||||
            accounts: list[BaseAccount] = BaseAccount.query.all()
 | 
					 | 
				
			||||||
            l10n: list[BaseAccountL10n] = BaseAccountL10n.query.all()
 | 
					 | 
				
			||||||
        self.assertEqual(len(accounts), 527)
 | 
					 | 
				
			||||||
        self.assertEqual(len(l10n), 527 * 2)
 | 
					 | 
				
			||||||
        l10n_keys: set[str] = {f"{x.account_code}-{x.locale}" for x in l10n}
 | 
					 | 
				
			||||||
        for account in accounts:
 | 
					 | 
				
			||||||
            self.assertIn(f"{account.code}-zh_Hant", l10n_keys)
 | 
					 | 
				
			||||||
            self.assertIn(f"{account.code}-zh_Hant", l10n_keys)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        list_uri: str = "/accounting/base-accounts"
 | 
					 | 
				
			||||||
        response: httpx.Response
 | 
					        response: httpx.Response
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        self.__logout()
 | 
					        response = client.get("/accounting/base-accounts")
 | 
				
			||||||
        response = self.client.get(list_uri)
 | 
					 | 
				
			||||||
        self.assertEqual(response.status_code, 403)
 | 
					        self.assertEqual(response.status_code, 403)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        self.__logout()
 | 
					        response = client.get("/accounting/base-accounts/1111")
 | 
				
			||||||
        self.__login_as("viewer")
 | 
					 | 
				
			||||||
        response = self.client.get(list_uri)
 | 
					 | 
				
			||||||
        self.assertEqual(response.status_code, 200)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        self.__logout()
 | 
					 | 
				
			||||||
        self.__login_as("editor")
 | 
					 | 
				
			||||||
        response = self.client.get(list_uri)
 | 
					 | 
				
			||||||
        self.assertEqual(response.status_code, 200)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        self.__logout()
 | 
					 | 
				
			||||||
        self.__login_as("nobody")
 | 
					 | 
				
			||||||
        response = self.client.get(list_uri)
 | 
					 | 
				
			||||||
        self.assertEqual(response.status_code, 403)
 | 
					        self.assertEqual(response.status_code, 403)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __logout(self) -> None:
 | 
					    def test_viewer(self) -> None:
 | 
				
			||||||
        """Logs out the currently logged-in user.
 | 
					        """Test the permission as viewer.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :return: None.
 | 
					        :return: None.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        response: httpx.Response = self.client.post(
 | 
					        client, csrf_token = get_client(self, self.app, "viewer")
 | 
				
			||||||
            "/logout", data={"csrf_token": self.csrf_token})
 | 
					        response: httpx.Response
 | 
				
			||||||
        self.assertEqual(response.status_code, 302)
 | 
					 | 
				
			||||||
        self.assertEqual(response.headers["Location"], "/")
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __login_as(self, username: str) -> None:
 | 
					        response = client.get("/accounting/base-accounts")
 | 
				
			||||||
        """Logs in as a specific user.
 | 
					        self.assertEqual(response.status_code, 200)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = client.get("/accounting/base-accounts/1111")
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 200)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_editor(self) -> None:
 | 
				
			||||||
 | 
					        """Test the permission as editor.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        :param username: The username.
 | 
					 | 
				
			||||||
        :return: None.
 | 
					        :return: None.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        response: httpx.Response = self.client.post(
 | 
					        client, csrf_token = get_client(self, self.app, "editor")
 | 
				
			||||||
            "/login", data={"csrf_token": self.csrf_token,
 | 
					        response: httpx.Response
 | 
				
			||||||
                            "username": username})
 | 
					
 | 
				
			||||||
        self.assertEqual(response.status_code, 302)
 | 
					        response = client.get("/accounting/base-accounts")
 | 
				
			||||||
        self.assertEqual(response.headers["Location"], "/")
 | 
					        self.assertEqual(response.status_code, 200)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = client.get("/accounting/base-accounts/1111")
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 200)
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										611
									
								
								tests/test_currency.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										611
									
								
								tests/test_currency.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,611 @@
 | 
				
			|||||||
 | 
					# The Mia! Accounting Flask Project.
 | 
				
			||||||
 | 
					# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#  Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					#  you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					#  You may obtain a copy of the License at
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#      http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					#  distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					#  See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					#  limitations under the License.
 | 
				
			||||||
 | 
					"""The test for the currency management.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					import csv
 | 
				
			||||||
 | 
					import time
 | 
				
			||||||
 | 
					import typing as t
 | 
				
			||||||
 | 
					import unittest
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import httpx
 | 
				
			||||||
 | 
					from click.testing import Result
 | 
				
			||||||
 | 
					from flask import Flask
 | 
				
			||||||
 | 
					from flask.testing import FlaskCliRunner
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from test_site import create_app
 | 
				
			||||||
 | 
					from testlib import get_client, set_locale
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class CurrencyData:
 | 
				
			||||||
 | 
					    """The currency data."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, code: str, name: str):
 | 
				
			||||||
 | 
					        """Constructs the currency data.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param code: The code.
 | 
				
			||||||
 | 
					        :param name: The name.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        self.code: str = code
 | 
				
			||||||
 | 
					        """The code."""
 | 
				
			||||||
 | 
					        self.name: str = name
 | 
				
			||||||
 | 
					        """The name."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					zza: CurrencyData = CurrencyData("ZZA", "Testing Dollar #A")
 | 
				
			||||||
 | 
					"""The first test currency."""
 | 
				
			||||||
 | 
					zzb: CurrencyData = CurrencyData("ZZB", "Testing Dollar #B")
 | 
				
			||||||
 | 
					"""The second test currency."""
 | 
				
			||||||
 | 
					zzc: CurrencyData = CurrencyData("ZZC", "Testing Dollar #C")
 | 
				
			||||||
 | 
					"""The third test currency."""
 | 
				
			||||||
 | 
					zzd: CurrencyData = CurrencyData("ZZD", "Testing Dollar #D")
 | 
				
			||||||
 | 
					"""The fourth test currency."""
 | 
				
			||||||
 | 
					PREFIX: str = "/accounting/currencies"
 | 
				
			||||||
 | 
					"""The URL prefix of the currency management."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class CurrencyCommandTestCase(unittest.TestCase):
 | 
				
			||||||
 | 
					    """The account console command test case."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def setUp(self) -> None:
 | 
				
			||||||
 | 
					        """Sets up the test.
 | 
				
			||||||
 | 
					        This is run once per test.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        self.app: Flask = create_app(is_testing=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        runner: FlaskCliRunner = self.app.test_cli_runner()
 | 
				
			||||||
 | 
					        with self.app.app_context():
 | 
				
			||||||
 | 
					            from accounting.database import db
 | 
				
			||||||
 | 
					            from accounting.models import Currency, CurrencyL10n
 | 
				
			||||||
 | 
					            result: Result
 | 
				
			||||||
 | 
					            result = runner.invoke(args="init-db")
 | 
				
			||||||
 | 
					            self.assertEqual(result.exit_code, 0)
 | 
				
			||||||
 | 
					            CurrencyL10n.query.delete()
 | 
				
			||||||
 | 
					            Currency.query.delete()
 | 
				
			||||||
 | 
					            db.session.commit()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_init(self) -> None:
 | 
				
			||||||
 | 
					        """Tests the "accounting-init-currencies" console command.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        from accounting import data_dir
 | 
				
			||||||
 | 
					        from accounting.models import Currency
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        with open(data_dir / "currencies.csv") as fh:
 | 
				
			||||||
 | 
					            data: dict[dict[str, t.Any]] \
 | 
				
			||||||
 | 
					                = {x["code"]: {"code": x["code"],
 | 
				
			||||||
 | 
					                               "name": x["name"],
 | 
				
			||||||
 | 
					                               "l10n": {y[5:]: x[y]
 | 
				
			||||||
 | 
					                                        for y in x if y.startswith("l10n-")}}
 | 
				
			||||||
 | 
					                   for x in csv.DictReader(fh)}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        runner: FlaskCliRunner = self.app.test_cli_runner()
 | 
				
			||||||
 | 
					        with self.app.app_context():
 | 
				
			||||||
 | 
					            result: Result = runner.invoke(
 | 
				
			||||||
 | 
					                args=["accounting-init-currencies", "-u", "editor"])
 | 
				
			||||||
 | 
					            self.assertEqual(result.exit_code, 0)
 | 
				
			||||||
 | 
					            currencies: list[Currency] = Currency.query.all()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.assertEqual(len(currencies), len(data))
 | 
				
			||||||
 | 
					        for currency in currencies:
 | 
				
			||||||
 | 
					            self.assertIn(currency.code, data)
 | 
				
			||||||
 | 
					            self.assertEqual(currency.name_l10n, data[currency.code]["name"])
 | 
				
			||||||
 | 
					            l10n: dict[str, str] = {x.locale: x.name for x in currency.l10n}
 | 
				
			||||||
 | 
					            self.assertEqual(len(l10n), len(data[currency.code]["l10n"]))
 | 
				
			||||||
 | 
					            for locale in l10n:
 | 
				
			||||||
 | 
					                self.assertIn(locale, data[currency.code]["l10n"])
 | 
				
			||||||
 | 
					                self.assertEqual(l10n[locale],
 | 
				
			||||||
 | 
					                                 data[currency.code]["l10n"][locale])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class CurrencyTestCase(unittest.TestCase):
 | 
				
			||||||
 | 
					    """The currency test case."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def setUp(self) -> None:
 | 
				
			||||||
 | 
					        """Sets up the test.
 | 
				
			||||||
 | 
					        This is run once per test.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        self.app: Flask = create_app(is_testing=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        runner: FlaskCliRunner = self.app.test_cli_runner()
 | 
				
			||||||
 | 
					        with self.app.app_context():
 | 
				
			||||||
 | 
					            from accounting.database import db
 | 
				
			||||||
 | 
					            from accounting.models import Currency, CurrencyL10n
 | 
				
			||||||
 | 
					            result: Result
 | 
				
			||||||
 | 
					            result = runner.invoke(args="init-db")
 | 
				
			||||||
 | 
					            self.assertEqual(result.exit_code, 0)
 | 
				
			||||||
 | 
					            CurrencyL10n.query.delete()
 | 
				
			||||||
 | 
					            Currency.query.delete()
 | 
				
			||||||
 | 
					            db.session.commit()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.client, self.csrf_token = get_client(self, self.app, "editor")
 | 
				
			||||||
 | 
					        response: httpx.Response
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.client.post(f"{PREFIX}/store",
 | 
				
			||||||
 | 
					                                    data={"csrf_token": self.csrf_token,
 | 
				
			||||||
 | 
					                                          "code": zza.code,
 | 
				
			||||||
 | 
					                                          "name": zza.name})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					        self.assertEqual(response.headers["Location"], f"{PREFIX}/{zza.code}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.client.post(f"{PREFIX}/store",
 | 
				
			||||||
 | 
					                                    data={"csrf_token": self.csrf_token,
 | 
				
			||||||
 | 
					                                          "code": zzb.code,
 | 
				
			||||||
 | 
					                                          "name": zzb.name})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					        self.assertEqual(response.headers["Location"], f"{PREFIX}/{zzb.code}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_nobody(self) -> None:
 | 
				
			||||||
 | 
					        """Test the permission as nobody.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        client, csrf_token = get_client(self, self.app, "nobody")
 | 
				
			||||||
 | 
					        response: httpx.Response
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = client.get(PREFIX)
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 403)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = client.get(f"{PREFIX}/{zza.code}")
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 403)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = client.get(f"{PREFIX}/create")
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 403)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = client.post(f"{PREFIX}/store",
 | 
				
			||||||
 | 
					                               data={"csrf_token": csrf_token,
 | 
				
			||||||
 | 
					                                     "code": zzc.code,
 | 
				
			||||||
 | 
					                                     "name": zzc.name})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 403)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = client.get(f"{PREFIX}/{zza.code}/edit")
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 403)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = client.post(f"{PREFIX}/{zza.code}/update",
 | 
				
			||||||
 | 
					                               data={"csrf_token": csrf_token,
 | 
				
			||||||
 | 
					                                     "code": zzd.code,
 | 
				
			||||||
 | 
					                                     "name": zzd.name})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 403)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = client.post(f"{PREFIX}/{zzb.code}/delete",
 | 
				
			||||||
 | 
					                               data={"csrf_token": csrf_token})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 403)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_viewer(self) -> None:
 | 
				
			||||||
 | 
					        """Test the permission as viewer.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        client, csrf_token = get_client(self, self.app, "viewer")
 | 
				
			||||||
 | 
					        response: httpx.Response
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = client.get(PREFIX)
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 200)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = client.get(f"{PREFIX}/{zza.code}")
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 200)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = client.get(f"{PREFIX}/create")
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 403)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = client.post(f"{PREFIX}/store",
 | 
				
			||||||
 | 
					                               data={"csrf_token": csrf_token,
 | 
				
			||||||
 | 
					                                     "code": zzc.code,
 | 
				
			||||||
 | 
					                                     "name": zzc.name})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 403)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = client.get(f"{PREFIX}/{zza.code}/edit")
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 403)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = client.post(f"{PREFIX}/{zza.code}/update",
 | 
				
			||||||
 | 
					                               data={"csrf_token": csrf_token,
 | 
				
			||||||
 | 
					                                     "code": zzd.code,
 | 
				
			||||||
 | 
					                                     "name": zzd.name})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 403)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = client.post(f"{PREFIX}/{zzb.code}/delete",
 | 
				
			||||||
 | 
					                               data={"csrf_token": csrf_token})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 403)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_editor(self) -> None:
 | 
				
			||||||
 | 
					        """Test the permission as editor.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        response: httpx.Response
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.client.get(PREFIX)
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 200)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.client.get(f"{PREFIX}/{zza.code}")
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 200)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.client.get(f"{PREFIX}/create")
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 200)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.client.post(f"{PREFIX}/store",
 | 
				
			||||||
 | 
					                                    data={"csrf_token": self.csrf_token,
 | 
				
			||||||
 | 
					                                          "code": zzc.code,
 | 
				
			||||||
 | 
					                                          "name": zzc.name})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					        self.assertEqual(response.headers["Location"], f"{PREFIX}/{zzc.code}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.client.get(f"{PREFIX}/{zza.code}/edit")
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 200)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.client.post(f"{PREFIX}/{zza.code}/update",
 | 
				
			||||||
 | 
					                                    data={"csrf_token": self.csrf_token,
 | 
				
			||||||
 | 
					                                          "code": zzd.code,
 | 
				
			||||||
 | 
					                                          "name": zzd.name})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					        self.assertEqual(response.headers["Location"], f"{PREFIX}/{zzd.code}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.client.post(f"{PREFIX}/{zzb.code}/delete",
 | 
				
			||||||
 | 
					                                    data={"csrf_token": self.csrf_token})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					        self.assertEqual(response.headers["Location"], PREFIX)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_add(self) -> None:
 | 
				
			||||||
 | 
					        """Tests to add the currencies.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        from accounting.models import Currency
 | 
				
			||||||
 | 
					        from test_site import db
 | 
				
			||||||
 | 
					        create_uri: str = f"{PREFIX}/create"
 | 
				
			||||||
 | 
					        store_uri: str = f"{PREFIX}/store"
 | 
				
			||||||
 | 
					        detail_uri: str = f"{PREFIX}/{zzc.code}"
 | 
				
			||||||
 | 
					        response: httpx.Response
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        with self.app.app_context():
 | 
				
			||||||
 | 
					            self.assertEqual({x.code for x in Currency.query.all()},
 | 
				
			||||||
 | 
					                             {zza.code, zzb.code})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Missing CSRF token
 | 
				
			||||||
 | 
					        response = self.client.post(store_uri,
 | 
				
			||||||
 | 
					                                    data={"code": zzc.code,
 | 
				
			||||||
 | 
					                                          "name": zzc.name})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 400)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # CSRF token mismatch
 | 
				
			||||||
 | 
					        response = self.client.post(store_uri,
 | 
				
			||||||
 | 
					                                    data={"csrf_token": f"{self.csrf_token}-2",
 | 
				
			||||||
 | 
					                                          "code": zzc.code,
 | 
				
			||||||
 | 
					                                          "name": zzc.name})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 400)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Empty code
 | 
				
			||||||
 | 
					        response = self.client.post(store_uri,
 | 
				
			||||||
 | 
					                                    data={"csrf_token": self.csrf_token,
 | 
				
			||||||
 | 
					                                          "code": " ",
 | 
				
			||||||
 | 
					                                          "name": zzc.name})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					        self.assertEqual(response.headers["Location"], create_uri)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Blocked code, with spaces to be stripped
 | 
				
			||||||
 | 
					        response = self.client.post(store_uri,
 | 
				
			||||||
 | 
					                                    data={"csrf_token": self.csrf_token,
 | 
				
			||||||
 | 
					                                          "code": " create ",
 | 
				
			||||||
 | 
					                                          "name": zzc.name})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					        self.assertEqual(response.headers["Location"], create_uri)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Bad code
 | 
				
			||||||
 | 
					        response = self.client.post(store_uri,
 | 
				
			||||||
 | 
					                                    data={"csrf_token": self.csrf_token,
 | 
				
			||||||
 | 
					                                          "code": " zzc ",
 | 
				
			||||||
 | 
					                                          "name": zzc.name})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					        self.assertEqual(response.headers["Location"], create_uri)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Empty name
 | 
				
			||||||
 | 
					        response = self.client.post(store_uri,
 | 
				
			||||||
 | 
					                                    data={"csrf_token": self.csrf_token,
 | 
				
			||||||
 | 
					                                          "code": zzc.code,
 | 
				
			||||||
 | 
					                                          "name": " "})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					        self.assertEqual(response.headers["Location"], create_uri)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Success, with spaces to be stripped
 | 
				
			||||||
 | 
					        response = self.client.post(store_uri,
 | 
				
			||||||
 | 
					                                    data={"csrf_token": self.csrf_token,
 | 
				
			||||||
 | 
					                                          "code": f" {zzc.code} ",
 | 
				
			||||||
 | 
					                                          "name": f" {zzc.name} "})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					        self.assertEqual(response.headers["Location"], detail_uri)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Duplicated code
 | 
				
			||||||
 | 
					        response = self.client.post(store_uri,
 | 
				
			||||||
 | 
					                                    data={"csrf_token": self.csrf_token,
 | 
				
			||||||
 | 
					                                          "code": zzc.code,
 | 
				
			||||||
 | 
					                                          "name": zzc.name})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					        self.assertEqual(response.headers["Location"], create_uri)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        with self.app.app_context():
 | 
				
			||||||
 | 
					            self.assertEqual({x.code for x in Currency.query.all()},
 | 
				
			||||||
 | 
					                             {zza.code, zzb.code, zzc.code})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            zzc_currency: Currency = db.session.get(Currency, zzc.code)
 | 
				
			||||||
 | 
					            self.assertEqual(zzc_currency.code, zzc.code)
 | 
				
			||||||
 | 
					            self.assertEqual(zzc_currency.name_l10n, zzc.name)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_basic_update(self) -> None:
 | 
				
			||||||
 | 
					        """Tests the basic rules to update a user.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        from accounting.models import Currency
 | 
				
			||||||
 | 
					        from test_site import db
 | 
				
			||||||
 | 
					        detail_uri: str = f"{PREFIX}/{zza.code}"
 | 
				
			||||||
 | 
					        edit_uri: str = f"{PREFIX}/{zza.code}/edit"
 | 
				
			||||||
 | 
					        update_uri: str = f"{PREFIX}/{zza.code}/update"
 | 
				
			||||||
 | 
					        detail_c_uri: str = f"{PREFIX}/{zzc.code}"
 | 
				
			||||||
 | 
					        response: httpx.Response
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Success, with spaces to be stripped
 | 
				
			||||||
 | 
					        response = self.client.post(update_uri,
 | 
				
			||||||
 | 
					                                    data={"csrf_token": self.csrf_token,
 | 
				
			||||||
 | 
					                                          "code": f" {zza.code} ",
 | 
				
			||||||
 | 
					                                          "name": f" {zza.name}-1 "})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					        self.assertEqual(response.headers["Location"], detail_uri)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        with self.app.app_context():
 | 
				
			||||||
 | 
					            zza_currency: Currency = db.session.get(Currency, zza.code)
 | 
				
			||||||
 | 
					            self.assertEqual(zza_currency.code, zza.code)
 | 
				
			||||||
 | 
					            self.assertEqual(zza_currency.name_l10n, f"{zza.name}-1")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Empty code
 | 
				
			||||||
 | 
					        response = self.client.post(update_uri,
 | 
				
			||||||
 | 
					                                    data={"csrf_token": self.csrf_token,
 | 
				
			||||||
 | 
					                                          "code": " ",
 | 
				
			||||||
 | 
					                                          "name": zzc.name})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					        self.assertEqual(response.headers["Location"], edit_uri)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Blocked code, with spaces to be stripped
 | 
				
			||||||
 | 
					        response = self.client.post(update_uri,
 | 
				
			||||||
 | 
					                                    data={"csrf_token": self.csrf_token,
 | 
				
			||||||
 | 
					                                          "code": " create ",
 | 
				
			||||||
 | 
					                                          "name": zzc.name})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					        self.assertEqual(response.headers["Location"], edit_uri)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Bad code
 | 
				
			||||||
 | 
					        response = self.client.post(update_uri,
 | 
				
			||||||
 | 
					                                    data={"csrf_token": self.csrf_token,
 | 
				
			||||||
 | 
					                                          "code": "abc/def",
 | 
				
			||||||
 | 
					                                          "name": zzc.name})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					        self.assertEqual(response.headers["Location"], edit_uri)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Empty name
 | 
				
			||||||
 | 
					        response = self.client.post(update_uri,
 | 
				
			||||||
 | 
					                                    data={"csrf_token": self.csrf_token,
 | 
				
			||||||
 | 
					                                          "code": zzc.code,
 | 
				
			||||||
 | 
					                                          "name": " "})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					        self.assertEqual(response.headers["Location"], edit_uri)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Duplicated code
 | 
				
			||||||
 | 
					        response = self.client.post(update_uri,
 | 
				
			||||||
 | 
					                                    data={"csrf_token": self.csrf_token,
 | 
				
			||||||
 | 
					                                          "code": zzb.code,
 | 
				
			||||||
 | 
					                                          "name": zzc.name})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					        self.assertEqual(response.headers["Location"], edit_uri)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Change code
 | 
				
			||||||
 | 
					        response = self.client.post(update_uri,
 | 
				
			||||||
 | 
					                                    data={"csrf_token": self.csrf_token,
 | 
				
			||||||
 | 
					                                          "code": zzc.code,
 | 
				
			||||||
 | 
					                                          "name": zzc.name})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					        self.assertEqual(response.headers["Location"], detail_c_uri)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.client.get(detail_uri)
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 404)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.client.get(detail_c_uri)
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 200)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_update_not_modified(self) -> None:
 | 
				
			||||||
 | 
					        """Tests that the data is not modified.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        from accounting.models import Currency
 | 
				
			||||||
 | 
					        from test_site import db
 | 
				
			||||||
 | 
					        detail_uri: str = f"{PREFIX}/{zza.code}"
 | 
				
			||||||
 | 
					        update_uri: str = f"{PREFIX}/{zza.code}/update"
 | 
				
			||||||
 | 
					        response: httpx.Response
 | 
				
			||||||
 | 
					        time.sleep(1)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.client.post(update_uri,
 | 
				
			||||||
 | 
					                                    data={"csrf_token": self.csrf_token,
 | 
				
			||||||
 | 
					                                          "code": f" {zza.code} ",
 | 
				
			||||||
 | 
					                                          "name": f" {zza.name} "})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					        self.assertEqual(response.headers["Location"], detail_uri)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        with self.app.app_context():
 | 
				
			||||||
 | 
					            zza_currency: Currency = db.session.get(Currency, zza.code)
 | 
				
			||||||
 | 
					            self.assertIsNotNone(zza_currency)
 | 
				
			||||||
 | 
					            self.assertEqual(zza_currency.created_at, zza_currency.updated_at)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.client.post(update_uri,
 | 
				
			||||||
 | 
					                                    data={"csrf_token": self.csrf_token,
 | 
				
			||||||
 | 
					                                          "code": zza.code,
 | 
				
			||||||
 | 
					                                          "name": zzc.name})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					        self.assertEqual(response.headers["Location"], detail_uri)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        with self.app.app_context():
 | 
				
			||||||
 | 
					            zza_currency: Currency = db.session.get(Currency, zza.code)
 | 
				
			||||||
 | 
					            self.assertIsNotNone(zza_currency)
 | 
				
			||||||
 | 
					            self.assertNotEqual(zza_currency.created_at,
 | 
				
			||||||
 | 
					                                zza_currency.updated_at)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_created_updated_by(self) -> None:
 | 
				
			||||||
 | 
					        """Tests the created-by and updated-by record.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        from accounting.models import Currency
 | 
				
			||||||
 | 
					        from test_site import db
 | 
				
			||||||
 | 
					        editor_username, editor2_username = "editor", "editor2"
 | 
				
			||||||
 | 
					        client, csrf_token = get_client(self, self.app, editor2_username)
 | 
				
			||||||
 | 
					        detail_uri: str = f"{PREFIX}/{zza.code}"
 | 
				
			||||||
 | 
					        update_uri: str = f"{PREFIX}/{zza.code}/update"
 | 
				
			||||||
 | 
					        response: httpx.Response
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        with self.app.app_context():
 | 
				
			||||||
 | 
					            zza_currency: Currency = db.session.get(Currency, zza.code)
 | 
				
			||||||
 | 
					            self.assertEqual(zza_currency.created_by.username, editor_username)
 | 
				
			||||||
 | 
					            self.assertEqual(zza_currency.updated_by.username, editor_username)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = client.post(update_uri,
 | 
				
			||||||
 | 
					                               data={"csrf_token": csrf_token,
 | 
				
			||||||
 | 
					                                     "code": zza.code,
 | 
				
			||||||
 | 
					                                     "name": f"{zza.name}-2"})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					        self.assertEqual(response.headers["Location"], detail_uri)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        with self.app.app_context():
 | 
				
			||||||
 | 
					            zza_currency: Currency = db.session.get(Currency, zza.code)
 | 
				
			||||||
 | 
					            self.assertEqual(zza_currency.created_by.username, editor_username)
 | 
				
			||||||
 | 
					            self.assertEqual(zza_currency.updated_by.username, editor2_username)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_api_exists(self) -> None:
 | 
				
			||||||
 | 
					        """Tests the API to check if a code exists.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        response: httpx.Response
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.client.get(
 | 
				
			||||||
 | 
					            f"/accounting/api/currencies/exists-code?q={zza.code}")
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 200)
 | 
				
			||||||
 | 
					        data = response.json()
 | 
				
			||||||
 | 
					        self.assertEqual(set(data.keys()), {"exists"})
 | 
				
			||||||
 | 
					        self.assertTrue(data["exists"])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.client.get(
 | 
				
			||||||
 | 
					            f"/accounting/api/currencies/exists-code?q={zza.code}-1")
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 200)
 | 
				
			||||||
 | 
					        data = response.json()
 | 
				
			||||||
 | 
					        self.assertEqual(set(data.keys()), {"exists"})
 | 
				
			||||||
 | 
					        self.assertFalse(data["exists"])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_l10n(self) -> None:
 | 
				
			||||||
 | 
					        """Tests the localization.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: None
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        from accounting.models import Currency
 | 
				
			||||||
 | 
					        from test_site import db
 | 
				
			||||||
 | 
					        detail_uri: str = f"{PREFIX}/{zza.code}"
 | 
				
			||||||
 | 
					        update_uri: str = f"{PREFIX}/{zza.code}/update"
 | 
				
			||||||
 | 
					        response: httpx.Response
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        with self.app.app_context():
 | 
				
			||||||
 | 
					            zza_currency: Currency = db.session.get(Currency, zza.code)
 | 
				
			||||||
 | 
					            self.assertEqual(zza_currency.name_l10n, zza.name)
 | 
				
			||||||
 | 
					            self.assertEqual(zza_currency.l10n, [])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        set_locale(self, self.client, self.csrf_token, "zh_Hant")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.client.post(update_uri,
 | 
				
			||||||
 | 
					                                    data={"csrf_token": self.csrf_token,
 | 
				
			||||||
 | 
					                                          "code": zza.code,
 | 
				
			||||||
 | 
					                                          "name": f"{zza.name}-zh_Hant"})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					        self.assertEqual(response.headers["Location"], detail_uri)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        with self.app.app_context():
 | 
				
			||||||
 | 
					            zza_currency: Currency = db.session.get(Currency, zza.code)
 | 
				
			||||||
 | 
					            self.assertEqual(zza_currency.name_l10n, zza.name)
 | 
				
			||||||
 | 
					            self.assertEqual({(x.locale, x.name) for x in zza_currency.l10n},
 | 
				
			||||||
 | 
					                             {("zh_Hant", f"{zza.name}-zh_Hant")})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        set_locale(self, self.client, self.csrf_token, "en")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.client.post(update_uri,
 | 
				
			||||||
 | 
					                                    data={"csrf_token": self.csrf_token,
 | 
				
			||||||
 | 
					                                          "code": zza.code,
 | 
				
			||||||
 | 
					                                          "name": f"{zza.name}-2"})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					        self.assertEqual(response.headers["Location"], detail_uri)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        with self.app.app_context():
 | 
				
			||||||
 | 
					            zza_currency: Currency = db.session.get(Currency, zza.code)
 | 
				
			||||||
 | 
					            self.assertEqual(zza_currency.name_l10n, f"{zza.name}-2")
 | 
				
			||||||
 | 
					            self.assertEqual({(x.locale, x.name) for x in zza_currency.l10n},
 | 
				
			||||||
 | 
					                             {("zh_Hant", f"{zza.name}-zh_Hant")})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        set_locale(self, self.client, self.csrf_token, "zh_Hant")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.client.post(update_uri,
 | 
				
			||||||
 | 
					                                    data={"csrf_token": self.csrf_token,
 | 
				
			||||||
 | 
					                                          "code": zza.code,
 | 
				
			||||||
 | 
					                                          "name": f"{zza.name}-zh_Hant-2"})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					        self.assertEqual(response.headers["Location"], detail_uri)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        with self.app.app_context():
 | 
				
			||||||
 | 
					            zza_currency: Currency = db.session.get(Currency, zza.code)
 | 
				
			||||||
 | 
					            self.assertEqual(zza_currency.name_l10n, f"{zza.name}-2")
 | 
				
			||||||
 | 
					            self.assertEqual({(x.locale, x.name) for x in zza_currency.l10n},
 | 
				
			||||||
 | 
					                             {("zh_Hant", f"{zza.name}-zh_Hant-2")})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_delete(self) -> None:
 | 
				
			||||||
 | 
					        """Tests to delete a currency.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        from accounting.models import Currency
 | 
				
			||||||
 | 
					        detail_uri: str = f"{PREFIX}/{zza.code}"
 | 
				
			||||||
 | 
					        delete_uri: str = f"{PREFIX}/{zza.code}/delete"
 | 
				
			||||||
 | 
					        list_uri: str = PREFIX
 | 
				
			||||||
 | 
					        response: httpx.Response
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        with self.app.app_context():
 | 
				
			||||||
 | 
					            self.assertEqual({x.code for x in Currency.query.all()},
 | 
				
			||||||
 | 
					                             {zza.code, zzb.code})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.client.get(detail_uri)
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 200)
 | 
				
			||||||
 | 
					        response = self.client.post(delete_uri,
 | 
				
			||||||
 | 
					                                    data={"csrf_token": self.csrf_token})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					        self.assertEqual(response.headers["Location"], list_uri)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        with self.app.app_context():
 | 
				
			||||||
 | 
					            self.assertEqual({x.code for x in Currency.query.all()},
 | 
				
			||||||
 | 
					                             {zzb.code})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.client.get(detail_uri)
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 404)
 | 
				
			||||||
 | 
					        response = self.client.post(delete_uri,
 | 
				
			||||||
 | 
					                                    data={"csrf_token": self.csrf_token})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 404)
 | 
				
			||||||
@@ -17,6 +17,7 @@
 | 
				
			|||||||
"""The Mia! Accounting Flask demonstration website.
 | 
					"""The Mia! Accounting Flask demonstration website.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
"""
 | 
					"""
 | 
				
			||||||
 | 
					import os
 | 
				
			||||||
import typing as t
 | 
					import typing as t
 | 
				
			||||||
from secrets import token_urlsafe
 | 
					from secrets import token_urlsafe
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -26,6 +27,9 @@ from flask.cli import with_appcontext
 | 
				
			|||||||
from flask_babel_js import BabelJS
 | 
					from flask_babel_js import BabelJS
 | 
				
			||||||
from flask_sqlalchemy import SQLAlchemy
 | 
					from flask_sqlalchemy import SQLAlchemy
 | 
				
			||||||
from flask_wtf import CSRFProtect
 | 
					from flask_wtf import CSRFProtect
 | 
				
			||||||
 | 
					from sqlalchemy import Column
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import accounting.utils.user
 | 
				
			||||||
 | 
					
 | 
				
			||||||
bp: Blueprint = Blueprint("home", __name__)
 | 
					bp: Blueprint = Blueprint("home", __name__)
 | 
				
			||||||
babel_js: BabelJS = BabelJS()
 | 
					babel_js: BabelJS = BabelJS()
 | 
				
			||||||
@@ -44,7 +48,7 @@ def create_app(is_testing: bool = False) -> Flask:
 | 
				
			|||||||
    app: Flask = Flask(__name__)
 | 
					    app: Flask = Flask(__name__)
 | 
				
			||||||
    db_uri: str = "sqlite:///" if is_testing else "sqlite:///local.sqlite"
 | 
					    db_uri: str = "sqlite:///" if is_testing else "sqlite:///local.sqlite"
 | 
				
			||||||
    app.config.from_mapping({
 | 
					    app.config.from_mapping({
 | 
				
			||||||
        "SECRET_KEY": token_urlsafe(32),
 | 
					        "SECRET_KEY": os.environ.get("SECRET_KEY", token_urlsafe(32)),
 | 
				
			||||||
        "SQLALCHEMY_DATABASE_URI": db_uri,
 | 
					        "SQLALCHEMY_DATABASE_URI": db_uri,
 | 
				
			||||||
        "BABEL_DEFAULT_LOCALE": "en",
 | 
					        "BABEL_DEFAULT_LOCALE": "en",
 | 
				
			||||||
        "ALL_LINGUAS": "zh_Hant|正體中文,en|English,zh_Hans|简体中文",
 | 
					        "ALL_LINGUAS": "zh_Hant|正體中文,en|English,zh_Hans|简体中文",
 | 
				
			||||||
@@ -65,11 +69,33 @@ def create_app(is_testing: bool = False) -> Flask:
 | 
				
			|||||||
    from . import auth
 | 
					    from . import auth
 | 
				
			||||||
    auth.init_app(app)
 | 
					    auth.init_app(app)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    class UserUtils(accounting.utils.user.AbstractUserUtils[auth.User]):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        @property
 | 
				
			||||||
 | 
					        def cls(self) -> t.Type[auth.User]:
 | 
				
			||||||
 | 
					            return auth.User
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        @property
 | 
				
			||||||
 | 
					        def pk_column(self) -> Column:
 | 
				
			||||||
 | 
					            return auth.User.id
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        @property
 | 
				
			||||||
 | 
					        def current_user(self) -> auth.User | None:
 | 
				
			||||||
 | 
					            return auth.current_user()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        def get_by_username(self, username: str) -> auth.User | None:
 | 
				
			||||||
 | 
					            return auth.User.query\
 | 
				
			||||||
 | 
					                .filter(auth.User.username == username).first()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        def get_pk(self, user: auth.User) -> int:
 | 
				
			||||||
 | 
					            return user.id
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    can_view: t.Callable[[], bool] = lambda: auth.current_user() is not None \
 | 
					    can_view: t.Callable[[], bool] = lambda: auth.current_user() is not None \
 | 
				
			||||||
        and auth.current_user().username in ["viewer", "editor"]
 | 
					        and auth.current_user().username in ["viewer", "editor", "editor2"]
 | 
				
			||||||
    can_edit: t.Callable[[], bool] = lambda: auth.current_user() is not None \
 | 
					    can_edit: t.Callable[[], bool] = lambda: auth.current_user() is not None \
 | 
				
			||||||
        and auth.current_user().username == "editor"
 | 
					        and auth.current_user().username in ["editor", "editor2"]
 | 
				
			||||||
    accounting.init_app(app, can_view_func=can_view, can_edit_func=can_edit)
 | 
					    accounting.init_app(app, user_utils=UserUtils(),
 | 
				
			||||||
 | 
					                        can_view_func=can_view, can_edit_func=can_edit)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return app
 | 
					    return app
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -80,7 +106,7 @@ def init_db_command() -> None:
 | 
				
			|||||||
    """Initializes the database."""
 | 
					    """Initializes the database."""
 | 
				
			||||||
    db.create_all()
 | 
					    db.create_all()
 | 
				
			||||||
    from .auth import User
 | 
					    from .auth import User
 | 
				
			||||||
    for username in ["viewer", "editor", "nobody"]:
 | 
					    for username in ["viewer", "editor", "editor2", "nobody"]:
 | 
				
			||||||
        if User.query.filter(User.username == username).first() is None:
 | 
					        if User.query.filter(User.username == username).first() is None:
 | 
				
			||||||
            db.session.add(User(username=username))
 | 
					            db.session.add(User(username=username))
 | 
				
			||||||
    db.session.commit()
 | 
					    db.session.commit()
 | 
				
			||||||
@@ -35,6 +35,13 @@ class User(db.Model):
 | 
				
			|||||||
    username = db.Column(db.String, nullable=False, unique=True)
 | 
					    username = db.Column(db.String, nullable=False, unique=True)
 | 
				
			||||||
    """The username."""
 | 
					    """The username."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __str__(self) -> str:
 | 
				
			||||||
 | 
					        """Returns the string representation of the user.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: The string representation of the user.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        return self.username
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@bp.get("login", endpoint="login-form")
 | 
					@bp.get("login", endpoint="login-form")
 | 
				
			||||||
def show_login_form() -> str:
 | 
					def show_login_form() -> str:
 | 
				
			||||||
@@ -51,7 +58,8 @@ def login() -> redirect:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    :return: The redirection to the home page.
 | 
					    :return: The redirection to the home page.
 | 
				
			||||||
    """
 | 
					    """
 | 
				
			||||||
    if request.form.get("username") not in ["viewer", "editor", "nobody"]:
 | 
					    if request.form.get("username") not in ["viewer", "editor", "editor2",
 | 
				
			||||||
 | 
					                                            "nobody"]:
 | 
				
			||||||
        return redirect(url_for("auth.login"))
 | 
					        return redirect(url_for("auth.login"))
 | 
				
			||||||
    session["user"] = request.form.get("username")
 | 
					    session["user"] = request.form.get("username")
 | 
				
			||||||
    return redirect(url_for("home.home"))
 | 
					    return redirect(url_for("home.home"))
 | 
				
			||||||
@@ -29,6 +29,7 @@ First written: 2023/1/27
 | 
				
			|||||||
  <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
 | 
					  <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
 | 
				
			||||||
  <button class="btn btn-primary" type="submit" name="username" value="viewer">{{ _("Viewer") }}</button>
 | 
					  <button class="btn btn-primary" type="submit" name="username" value="viewer">{{ _("Viewer") }}</button>
 | 
				
			||||||
  <button class="btn btn-primary" type="submit" name="username" value="editor">{{ _("Editor") }}</button>
 | 
					  <button class="btn btn-primary" type="submit" name="username" value="editor">{{ _("Editor") }}</button>
 | 
				
			||||||
 | 
					  <button class="btn btn-primary" type="submit" name="username" value="editor2">{{ _("Editor2") }}</button>
 | 
				
			||||||
  <button class="btn btn-primary" type="submit" name="username" value="nobody">{{ _("Nobody") }}</button>
 | 
					  <button class="btn btn-primary" type="submit" name="username" value="nobody">{{ _("Nobody") }}</button>
 | 
				
			||||||
</form>
 | 
					</form>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -9,8 +9,8 @@ msgid ""
 | 
				
			|||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
"Project-Id-Version: Mia! Accounting Flask Demonstration 0.0.0\n"
 | 
					"Project-Id-Version: Mia! Accounting Flask Demonstration 0.0.0\n"
 | 
				
			||||||
"Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n"
 | 
					"Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n"
 | 
				
			||||||
"POT-Creation-Date: 2023-01-28 13:42+0800\n"
 | 
					"POT-Creation-Date: 2023-02-06 23:25+0800\n"
 | 
				
			||||||
"PO-Revision-Date: 2023-01-28 13:42+0800\n"
 | 
					"PO-Revision-Date: 2023-02-06 23:26+0800\n"
 | 
				
			||||||
"Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n"
 | 
					"Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n"
 | 
				
			||||||
"Language: zh_Hant\n"
 | 
					"Language: zh_Hant\n"
 | 
				
			||||||
"Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n"
 | 
					"Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n"
 | 
				
			||||||
@@ -20,35 +20,41 @@ msgstr ""
 | 
				
			|||||||
"Content-Transfer-Encoding: 8bit\n"
 | 
					"Content-Transfer-Encoding: 8bit\n"
 | 
				
			||||||
"Generated-By: Babel 2.11.0\n"
 | 
					"Generated-By: Babel 2.11.0\n"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#: tests/testsite/templates/base.html:23
 | 
					#: tests/test_site/templates/base.html:23
 | 
				
			||||||
msgid "en"
 | 
					msgid "en"
 | 
				
			||||||
msgstr "zh-Hant"
 | 
					msgstr "zh-Hant"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#: tests/testsite/templates/base.html:43 tests/testsite/templates/home.html:24
 | 
					#: tests/test_site/templates/base.html:43
 | 
				
			||||||
 | 
					#: tests/test_site/templates/home.html:24
 | 
				
			||||||
msgid "Home"
 | 
					msgid "Home"
 | 
				
			||||||
msgstr "首頁"
 | 
					msgstr "首頁"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#: tests/testsite/templates/base.html:68
 | 
					#: tests/test_site/templates/base.html:68
 | 
				
			||||||
msgid "Log Out"
 | 
					msgid "Log Out"
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#: tests/testsite/templates/base.html:78 tests/testsite/templates/login.html:24
 | 
					#: tests/test_site/templates/base.html:78
 | 
				
			||||||
 | 
					#: tests/test_site/templates/login.html:24
 | 
				
			||||||
msgid "Log In"
 | 
					msgid "Log In"
 | 
				
			||||||
msgstr "登入"
 | 
					msgstr "登入"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#: tests/testsite/templates/base.html:119
 | 
					#: tests/test_site/templates/base.html:119
 | 
				
			||||||
msgid "Error:"
 | 
					msgid "Error:"
 | 
				
			||||||
msgstr "錯誤:"
 | 
					msgstr "錯誤:"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#: tests/testsite/templates/login.html:30
 | 
					#: tests/test_site/templates/login.html:30
 | 
				
			||||||
msgid "Viewer"
 | 
					msgid "Viewer"
 | 
				
			||||||
msgstr "讀報表者"
 | 
					msgstr "讀報表者"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#: tests/testsite/templates/login.html:31
 | 
					#: tests/test_site/templates/login.html:31
 | 
				
			||||||
msgid "Editor"
 | 
					msgid "Editor"
 | 
				
			||||||
msgstr "記帳者"
 | 
					msgstr "記帳者"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#: tests/testsite/templates/login.html:32
 | 
					#: tests/test_site/templates/login.html:32
 | 
				
			||||||
 | 
					msgid "Editor2"
 | 
				
			||||||
 | 
					msgstr "記帳者2"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: tests/test_site/templates/login.html:33
 | 
				
			||||||
msgid "Nobody"
 | 
					msgid "Nobody"
 | 
				
			||||||
msgstr "沒有權限者"
 | 
					msgstr "沒有權限者"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										310
									
								
								tests/test_utils.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										310
									
								
								tests/test_utils.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,310 @@
 | 
				
			|||||||
 | 
					# The Mia! Accounting Flask Project.
 | 
				
			||||||
 | 
					# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/3
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#  Copyright (c) 2023 imacat.
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
				
			||||||
 | 
					#  you may not use this file except in compliance with the License.
 | 
				
			||||||
 | 
					#  You may obtain a copy of the License at
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#      http://www.apache.org/licenses/LICENSE-2.0
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					#  Unless required by applicable law or agreed to in writing, software
 | 
				
			||||||
 | 
					#  distributed under the License is distributed on an "AS IS" BASIS,
 | 
				
			||||||
 | 
					#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
				
			||||||
 | 
					#  See the License for the specific language governing permissions and
 | 
				
			||||||
 | 
					#  limitations under the License.
 | 
				
			||||||
 | 
					"""The test for the independent utilities.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					import unittest
 | 
				
			||||||
 | 
					from urllib.parse import quote_plus
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import httpx
 | 
				
			||||||
 | 
					from flask import Flask, request
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from accounting.utils.next_url import append_next, inherit_next, or_next
 | 
				
			||||||
 | 
					from accounting.utils.pagination import Pagination, DEFAULT_PAGE_SIZE
 | 
				
			||||||
 | 
					from accounting.utils.query import parse_query_keywords
 | 
				
			||||||
 | 
					from test_site import create_app, csrf
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class NextUriTestCase(unittest.TestCase):
 | 
				
			||||||
 | 
					    """The test case for the next URI utilities."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_next_uri(self) -> None:
 | 
				
			||||||
 | 
					        """Tests the next URI utilities.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        app: Flask = create_app(is_testing=True)
 | 
				
			||||||
 | 
					        target: str = "/target"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        @app.route("/test-next", methods=["GET", "POST"])
 | 
				
			||||||
 | 
					        @csrf.exempt
 | 
				
			||||||
 | 
					        def test_next_view() -> str:
 | 
				
			||||||
 | 
					            """The test view with the next URI."""
 | 
				
			||||||
 | 
					            current_uri: str = request.full_path if request.query_string \
 | 
				
			||||||
 | 
					                else request.path
 | 
				
			||||||
 | 
					            self.assertEqual(append_next(target),
 | 
				
			||||||
 | 
					                             f"{target}?next={quote_plus(current_uri)}")
 | 
				
			||||||
 | 
					            next_uri: str = request.form["next"] if request.method == "POST" \
 | 
				
			||||||
 | 
					                else request.args["next"]
 | 
				
			||||||
 | 
					            self.assertEqual(inherit_next(target),
 | 
				
			||||||
 | 
					                             f"{target}?next={quote_plus(next_uri)}")
 | 
				
			||||||
 | 
					            self.assertEqual(or_next(target), next_uri)
 | 
				
			||||||
 | 
					            return ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        @app.route("/test-no-next", methods=["GET", "POST"])
 | 
				
			||||||
 | 
					        @csrf.exempt
 | 
				
			||||||
 | 
					        def test_no_next_view() -> str:
 | 
				
			||||||
 | 
					            """The test view without the next URI."""
 | 
				
			||||||
 | 
					            current_uri: str = request.full_path if request.query_string \
 | 
				
			||||||
 | 
					                else request.path
 | 
				
			||||||
 | 
					            self.assertEqual(append_next(target),
 | 
				
			||||||
 | 
					                             f"{target}?next={quote_plus(current_uri)}")
 | 
				
			||||||
 | 
					            self.assertEqual(inherit_next(target), target)
 | 
				
			||||||
 | 
					            self.assertEqual(or_next(target), target)
 | 
				
			||||||
 | 
					            return ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        client: httpx.Client = httpx.Client(app=app,
 | 
				
			||||||
 | 
					                                            base_url="https://testserver")
 | 
				
			||||||
 | 
					        client.headers["Referer"] = "https://testserver"
 | 
				
			||||||
 | 
					        response: httpx.Response
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # With the next URI
 | 
				
			||||||
 | 
					        response = client.get("/test-next?next=/next&q=abc&page-no=4")
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 200)
 | 
				
			||||||
 | 
					        response = client.post("/test-next", data={"next": "/next",
 | 
				
			||||||
 | 
					                                                   "name": "viewer"})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 200)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Without the next URI
 | 
				
			||||||
 | 
					        response = client.get("/test-no-next?q=abc&page-no=4")
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 200)
 | 
				
			||||||
 | 
					        response = client.post("/test-no-next", data={"name": "viewer"})
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 200)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class QueryKeywordParserTestCase(unittest.TestCase):
 | 
				
			||||||
 | 
					    """The test case for the query keyword parser."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_default(self) -> None:
 | 
				
			||||||
 | 
					        """Tests the query keyword parser.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        self.assertEqual(parse_query_keywords("coffee"), ["coffee"])
 | 
				
			||||||
 | 
					        self.assertEqual(parse_query_keywords("coffee tea"), ["coffee", "tea"])
 | 
				
			||||||
 | 
					        self.assertEqual(parse_query_keywords("\"coffee\" \"tea cake\""),
 | 
				
			||||||
 | 
					                         ["coffee", "tea cake"])
 | 
				
			||||||
 | 
					        self.assertEqual(parse_query_keywords("\"coffee tea\" cheese "
 | 
				
			||||||
 | 
					                                              "\"cake candy\" sugar"),
 | 
				
			||||||
 | 
					                         ["coffee tea", "cheese", "cake candy", "sugar"])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_malformed(self) -> None:
 | 
				
			||||||
 | 
					        """Tests the malformed query.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        self.assertEqual(parse_query_keywords("coffee \"tea cake"),
 | 
				
			||||||
 | 
					                         ["coffee", "tea cake"])
 | 
				
			||||||
 | 
					        self.assertEqual(parse_query_keywords("coffee te\"a ca\"ke"),
 | 
				
			||||||
 | 
					                         ["coffee", "te\"a", "ca\"ke"])
 | 
				
			||||||
 | 
					        self.assertEqual(parse_query_keywords("coffee\" tea cake\""),
 | 
				
			||||||
 | 
					                         ["coffee\"", "tea", "cake\""])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_empty(self) -> None:
 | 
				
			||||||
 | 
					        """Tests the empty query.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        self.assertEqual(parse_query_keywords(None), [])
 | 
				
			||||||
 | 
					        self.assertEqual(parse_query_keywords(""), [])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class PaginationTestCase(unittest.TestCase):
 | 
				
			||||||
 | 
					    """The test case for pagination."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    class Params:
 | 
				
			||||||
 | 
					        """The testing parameters."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        def __init__(self, items: list[int], is_reversed: bool | None,
 | 
				
			||||||
 | 
					                     result: list[int], is_paged: bool):
 | 
				
			||||||
 | 
					            """Constructs the expected pagination.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            :param items: All the items in the list.
 | 
				
			||||||
 | 
					            :param is_reversed: Whether the default page is the last page.
 | 
				
			||||||
 | 
					            :param result: The expected items on the page.
 | 
				
			||||||
 | 
					            :param is_paged: Whether the pagination is needed.
 | 
				
			||||||
 | 
					            """
 | 
				
			||||||
 | 
					            self.items: list[int] = items
 | 
				
			||||||
 | 
					            self.is_reversed: bool | None = is_reversed
 | 
				
			||||||
 | 
					            self.result: list[int] = result
 | 
				
			||||||
 | 
					            self.is_paged: bool = is_paged
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def setUp(self) -> None:
 | 
				
			||||||
 | 
					        """Sets up the test.
 | 
				
			||||||
 | 
					        This is run once per test.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        self.app: Flask = create_app(is_testing=True)
 | 
				
			||||||
 | 
					        self.params = self.Params([], None, [], True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        @self.app.get("/test-pagination")
 | 
				
			||||||
 | 
					        def test_pagination_view() -> str:
 | 
				
			||||||
 | 
					            """The test view with the pagination."""
 | 
				
			||||||
 | 
					            pagination: Pagination
 | 
				
			||||||
 | 
					            if self.params.is_reversed is not None:
 | 
				
			||||||
 | 
					                pagination = Pagination[int](
 | 
				
			||||||
 | 
					                    self.params.items, is_reversed=self.params.is_reversed)
 | 
				
			||||||
 | 
					            else:
 | 
				
			||||||
 | 
					                pagination = Pagination[int](self.params.items)
 | 
				
			||||||
 | 
					            self.assertEqual(pagination.is_paged, self.params.is_paged)
 | 
				
			||||||
 | 
					            self.assertEqual(pagination.list, self.params.result)
 | 
				
			||||||
 | 
					            return ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.client = httpx.Client(app=self.app, base_url="https://testserver")
 | 
				
			||||||
 | 
					        self.client.headers["Referer"] = "https://testserver"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __test_success(self, query: str, items: range,
 | 
				
			||||||
 | 
					                       result: range, is_paged: bool = True,
 | 
				
			||||||
 | 
					                       is_reversed: bool | None = None) -> None:
 | 
				
			||||||
 | 
					        """Tests the pagination.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param query: The query string.
 | 
				
			||||||
 | 
					        :param items: The original items.
 | 
				
			||||||
 | 
					        :param result: The expected page content.
 | 
				
			||||||
 | 
					        :param is_paged: Whether the pagination is needed.
 | 
				
			||||||
 | 
					        :param is_reversed: Whether the list is reversed.
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        target: str = "/test-pagination"
 | 
				
			||||||
 | 
					        if query != "":
 | 
				
			||||||
 | 
					            target = f"{target}?{query}"
 | 
				
			||||||
 | 
					        self.params = self.Params(list(items), is_reversed,
 | 
				
			||||||
 | 
					                                  list(result), is_paged)
 | 
				
			||||||
 | 
					        response: httpx.Response = self.client.get(target)
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 200)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __test_malformed(self, query: str, items: range, redirect_to: str,
 | 
				
			||||||
 | 
					                         is_reversed: bool | None = None) -> None:
 | 
				
			||||||
 | 
					        """Tests the pagination.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param query: The query string.
 | 
				
			||||||
 | 
					        :param items: The original items.
 | 
				
			||||||
 | 
					        :param redirect_to: The expected target query of the redirection.
 | 
				
			||||||
 | 
					        :param is_reversed: Whether the list is reversed.
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        target: str = "/test-pagination"
 | 
				
			||||||
 | 
					        self.params = self.Params(list(items), is_reversed, [], True)
 | 
				
			||||||
 | 
					        response: httpx.Response = self.client.get(f"{target}?{query}")
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					        self.assertEqual(response.headers["Location"],
 | 
				
			||||||
 | 
					                         f"{target}?{redirect_to}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_default(self) -> None:
 | 
				
			||||||
 | 
					        """Tests the default pagination.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        # The default first page
 | 
				
			||||||
 | 
					        self.__test_success("", range(1, 687), range(1, 11))
 | 
				
			||||||
 | 
					        # Some page in the middle
 | 
				
			||||||
 | 
					        self.__test_success("page-no=37", range(1, 687), range(361, 371))
 | 
				
			||||||
 | 
					        # The last page
 | 
				
			||||||
 | 
					        self.__test_success("page-no=69", range(1, 687), range(681, 687))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_page_size(self) -> None:
 | 
				
			||||||
 | 
					        """Tests the pagination with a different page size.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        # The default page with a different page size
 | 
				
			||||||
 | 
					        self.__test_success("page-size=15", range(1, 687), range(1, 16))
 | 
				
			||||||
 | 
					        # Some page with a different page size
 | 
				
			||||||
 | 
					        self.__test_success("page-no=37&page-size=15", range(1, 687),
 | 
				
			||||||
 | 
					                            range(541, 556))
 | 
				
			||||||
 | 
					        # The last page with a different page size.
 | 
				
			||||||
 | 
					        self.__test_success("page-no=46&page-size=15", range(1, 687),
 | 
				
			||||||
 | 
					                            range(676, 687))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_not_needed(self) -> None:
 | 
				
			||||||
 | 
					        """Tests the pagination that is not needed.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        # Empty list
 | 
				
			||||||
 | 
					        self.__test_success("", range(0, 0), range(0, 0), is_paged=False)
 | 
				
			||||||
 | 
					        # A list that fits in one page
 | 
				
			||||||
 | 
					        self.__test_success("", range(1, 4), range(1, 4), is_paged=False)
 | 
				
			||||||
 | 
					        # A large page size that fits in everything
 | 
				
			||||||
 | 
					        self.__test_success("page-size=1000", range(1, 687), range(1, 687),
 | 
				
			||||||
 | 
					                            is_paged=False)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_reversed(self) -> None:
 | 
				
			||||||
 | 
					        """Tests the default page on a reversed list.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        # The default page
 | 
				
			||||||
 | 
					        self.__test_success("", range(1, 687), range(681, 687),
 | 
				
			||||||
 | 
					                            is_reversed=True)
 | 
				
			||||||
 | 
					        # The default page with a different page size
 | 
				
			||||||
 | 
					        self.__test_success("page-size=15", range(1, 687), range(676, 687),
 | 
				
			||||||
 | 
					                            is_reversed=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_last_page(self) -> None:
 | 
				
			||||||
 | 
					        """Tests the calculation of the items on the last page.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        # The last page that fits in one page
 | 
				
			||||||
 | 
					        self.__test_success("page-no=69", range(1, 691), range(681, 691))
 | 
				
			||||||
 | 
					        # A danging item in the last page
 | 
				
			||||||
 | 
					        self.__test_success("page-no=70", range(1, 692), range(691, 692))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_malformed(self) -> None:
 | 
				
			||||||
 | 
					        """Tests the malformed pagination parameters.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: None.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        # A malformed page size
 | 
				
			||||||
 | 
					        self.__test_malformed("q=word&page-size=100a&page-no=37&next=%2F",
 | 
				
			||||||
 | 
					                              range(1, 691), "q=word&page-no=37&next=%2F")
 | 
				
			||||||
 | 
					        # A default page size
 | 
				
			||||||
 | 
					        self.__test_malformed(f"q=word&page-size={DEFAULT_PAGE_SIZE}"
 | 
				
			||||||
 | 
					                              "&page-no=37&next=%2F",
 | 
				
			||||||
 | 
					                              range(1, 691), "q=word&page-no=37&next=%2F")
 | 
				
			||||||
 | 
					        # An invalid page size
 | 
				
			||||||
 | 
					        self.__test_malformed("q=word&page-size=0&page-no=37&next=%2F",
 | 
				
			||||||
 | 
					                              range(1, 691), "q=word&page-no=37&next=%2F")
 | 
				
			||||||
 | 
					        # A malformed page number
 | 
				
			||||||
 | 
					        self.__test_malformed("q=word&page-size=15&page-no=37a&next=%2F",
 | 
				
			||||||
 | 
					                              range(1, 691), "q=word&page-size=15&next=%2F")
 | 
				
			||||||
 | 
					        # A default page number
 | 
				
			||||||
 | 
					        self.__test_malformed("q=word&page-size=15&page-no=1&next=%2F",
 | 
				
			||||||
 | 
					                              range(1, 691), "q=word&page-size=15&next=%2F")
 | 
				
			||||||
 | 
					        # A default page number, on a reversed list
 | 
				
			||||||
 | 
					        self.__test_malformed("q=word&page-size=15&page-no=46&next=%2F",
 | 
				
			||||||
 | 
					                              range(1, 691), "q=word&page-size=15&next=%2F",
 | 
				
			||||||
 | 
					                              is_reversed=True)
 | 
				
			||||||
 | 
					        # A page number beyond the last page
 | 
				
			||||||
 | 
					        self.__test_malformed("q=word&page-size=15&page-no=100&next=%2F",
 | 
				
			||||||
 | 
					                              range(1, 691),
 | 
				
			||||||
 | 
					                              "q=word&page-size=15&page-no=46&next=%2F")
 | 
				
			||||||
 | 
					        # A page number beyond the last page, on a reversed list
 | 
				
			||||||
 | 
					        self.__test_malformed("q=word&page-size=15&page-no=100&next=%2F",
 | 
				
			||||||
 | 
					                              range(1, 691),
 | 
				
			||||||
 | 
					                              "q=word&page-size=15&next=%2F", is_reversed=True)
 | 
				
			||||||
 | 
					        # A page number before the first page
 | 
				
			||||||
 | 
					        self.__test_malformed("q=word&page-size=15&page-no=0&next=%2F",
 | 
				
			||||||
 | 
					                              range(1, 691),
 | 
				
			||||||
 | 
					                              "q=word&page-size=15&next=%2F")
 | 
				
			||||||
 | 
					        # A page number before the first page, on a reversed list
 | 
				
			||||||
 | 
					        self.__test_malformed("q=word&page-size=15&page-no=0&next=%2F",
 | 
				
			||||||
 | 
					                              range(1, 691),
 | 
				
			||||||
 | 
					                              "q=word&page-size=15&page-no=1&next=%2F",
 | 
				
			||||||
 | 
					                              is_reversed=True)
 | 
				
			||||||
@@ -17,10 +17,32 @@
 | 
				
			|||||||
"""The common test libraries.
 | 
					"""The common test libraries.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
"""
 | 
					"""
 | 
				
			||||||
 | 
					import typing as t
 | 
				
			||||||
from html.parser import HTMLParser
 | 
					from html.parser import HTMLParser
 | 
				
			||||||
from unittest import TestCase
 | 
					from unittest import TestCase
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import httpx
 | 
					import httpx
 | 
				
			||||||
 | 
					from flask import Flask
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def get_client(test_case: TestCase, app: Flask, username: str) \
 | 
				
			||||||
 | 
					        -> tuple[httpx.Client, str]:
 | 
				
			||||||
 | 
					    """Returns a user client.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :param test_case: The test case.
 | 
				
			||||||
 | 
					    :param app: The Flask application.
 | 
				
			||||||
 | 
					    :param username: The username.
 | 
				
			||||||
 | 
					    :return: A tuple of the client and the CSRF token.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    client: httpx.Client = httpx.Client(app=app, base_url="https://testserver")
 | 
				
			||||||
 | 
					    client.headers["Referer"] = "https://testserver"
 | 
				
			||||||
 | 
					    csrf_token: str = get_csrf_token(test_case, client, "/login")
 | 
				
			||||||
 | 
					    response: httpx.Response = client.post("/login",
 | 
				
			||||||
 | 
					                                           data={"csrf_token": csrf_token,
 | 
				
			||||||
 | 
					                                                 "username": username})
 | 
				
			||||||
 | 
					    test_case.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					    test_case.assertEqual(response.headers["Location"], "/")
 | 
				
			||||||
 | 
					    return client, csrf_token
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def get_csrf_token(test_case: TestCase, client: httpx.Client, uri: str) -> str:
 | 
					def get_csrf_token(test_case: TestCase, client: httpx.Client, uri: str) -> str:
 | 
				
			||||||
@@ -54,3 +76,21 @@ def get_csrf_token(test_case: TestCase, client: httpx.Client, uri: str) -> str:
 | 
				
			|||||||
    parser.feed(response.text)
 | 
					    parser.feed(response.text)
 | 
				
			||||||
    test_case.assertIsNotNone(parser.csrf_token)
 | 
					    test_case.assertIsNotNone(parser.csrf_token)
 | 
				
			||||||
    return parser.csrf_token
 | 
					    return parser.csrf_token
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def set_locale(test_case: TestCase, client: httpx.Client, csrf_token: str,
 | 
				
			||||||
 | 
					               locale: t.Literal["en", "zh_Hant", "zh_Hans"]) -> None:
 | 
				
			||||||
 | 
					    """Sets the current locale.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    :param test_case: The test case.
 | 
				
			||||||
 | 
					    :param client: The test client.
 | 
				
			||||||
 | 
					    :param csrf_token: The CSRF token.
 | 
				
			||||||
 | 
					    :param locale: The locale.
 | 
				
			||||||
 | 
					    :return: None.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    response: httpx.Response = client.post("/locale",
 | 
				
			||||||
 | 
					                                           data={"csrf_token": csrf_token,
 | 
				
			||||||
 | 
					                                                 "locale": locale,
 | 
				
			||||||
 | 
					                                                 "next": "/next"})
 | 
				
			||||||
 | 
					    test_case.assertEqual(response.status_code, 302)
 | 
				
			||||||
 | 
					    test_case.assertEqual(response.headers["Location"], "/next")
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user