Added the initial application with the main account list, the pagination, the query, the permission, the localization, the documentation, the test case, and a test demonstration site.
This commit is contained in:
133
tests/babel-utils-testsite.py
Executable file
133
tests/babel-utils-testsite.py
Executable file
@ -0,0 +1,133 @@
|
||||
#! env python3
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/28
|
||||
|
||||
# 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 translation management utilities for the test site.
|
||||
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from time import strftime
|
||||
|
||||
import click
|
||||
from babel.messages.frontend import CommandLineInterface
|
||||
from opencc import OpenCC
|
||||
|
||||
root_dir: Path = Path(__file__).parent.parent
|
||||
translation_dir: Path = root_dir / "tests" / "testsite" / "translations"
|
||||
domain: str = "messages"
|
||||
|
||||
|
||||
@click.group()
|
||||
def main() -> None:
|
||||
"""Manages the message translation."""
|
||||
|
||||
|
||||
@click.command("extract")
|
||||
def babel_extract() -> None:
|
||||
"""Extracts the messages for translation."""
|
||||
os.chdir(root_dir)
|
||||
cfg: Path = translation_dir / "babel.cfg"
|
||||
pot: Path = translation_dir / f"{domain}.pot"
|
||||
zh_hant: Path = translation_dir / "zh_Hant" / "LC_MESSAGES"\
|
||||
/ f"{domain}.po"
|
||||
zh_hans: Path = translation_dir / "zh_Hans" / "LC_MESSAGES"\
|
||||
/ f"{domain}.po"
|
||||
CommandLineInterface().run([
|
||||
"pybabel", "extract", "-F", str(cfg), "-k", "lazy_gettext", "-k", "A_",
|
||||
"-o", str(pot), str(Path("tests") / "testsite")])
|
||||
if not zh_hant.exists():
|
||||
zh_hant.touch()
|
||||
if not zh_hans.exists():
|
||||
zh_hans.touch()
|
||||
CommandLineInterface().run([
|
||||
"pybabel", "update", "-i", str(pot), "-D", domain,
|
||||
"-d", translation_dir])
|
||||
|
||||
|
||||
@click.command("compile")
|
||||
def babel_compile() -> None:
|
||||
"""Compiles the translated messages."""
|
||||
__convert_chinese()
|
||||
__update_rev_date()
|
||||
CommandLineInterface().run([
|
||||
"pybabel", "compile", "-D", domain, "-d", translation_dir])
|
||||
|
||||
|
||||
def __convert_chinese() -> None:
|
||||
"""Updates the Simplified Chinese translation according to the Traditional
|
||||
Chinese translation.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
cc: OpenCC = OpenCC("tw2sp")
|
||||
zh_hant: Path = translation_dir / "zh_Hant" / "LC_MESSAGES"\
|
||||
/ f"{domain}.po"
|
||||
zh_hans: Path = translation_dir / "zh_Hans" / "LC_MESSAGES"\
|
||||
/ f"{domain}.po"
|
||||
now: str = strftime("%Y-%m-%d %H:%M%z")
|
||||
with open(zh_hant, "r") as f:
|
||||
content: str = f.read()
|
||||
content = cc.convert(content)
|
||||
content = re.sub(r"^# Chinese \\(Traditional\\) translations ",
|
||||
"# Chinese (Simplified) translations ", content)
|
||||
content = re.sub(r"\n\"PO-Revision-Date: [^\n]*\"\n",
|
||||
f"\n\"PO-Revision-Date: {now}\\\\n\"\n",
|
||||
content)
|
||||
content = content.replace("\n\"Language-Team: zh_Hant",
|
||||
"\n\"Language-Team: zh_Hans")
|
||||
content = content.replace("\n\"Language: zh_Hant\\n\"\n",
|
||||
"\n\"Language: zh_Hans\\n\"\n")
|
||||
content = content.replace("\nmsgstr \"zh-Hant\"\n",
|
||||
"\nmsgstr \"zh-Hans\"\n")
|
||||
zh_hans.parent.mkdir(exist_ok=True)
|
||||
with open(zh_hans, "w") as f:
|
||||
f.write(content)
|
||||
|
||||
|
||||
def __update_rev_date() -> None:
|
||||
"""Updates the revision dates in the PO files.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
for language_dir in translation_dir.glob("*"):
|
||||
po_file: Path = language_dir / "LC_MESSAGES" / f"{domain}.po"
|
||||
if po_file.is_file():
|
||||
__update_file_rev_date(po_file)
|
||||
|
||||
|
||||
def __update_file_rev_date(file: Path) -> None:
|
||||
"""Updates the revision date of a PO file
|
||||
|
||||
:param file: The PO file.
|
||||
:return: None.
|
||||
"""
|
||||
now = strftime("%Y-%m-%d %H:%M%z")
|
||||
with open(file, "r+") as f:
|
||||
content = f.read()
|
||||
content = re.sub(r"\n\"PO-Revision-Date: [^\n]*\"\n",
|
||||
f"\n\"PO-Revision-Date: {now}\\\\n\"\n",
|
||||
content)
|
||||
f.seek(0)
|
||||
f.write(content)
|
||||
|
||||
|
||||
main.add_command(babel_extract)
|
||||
main.add_command(babel_compile)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
133
tests/babel-utils.py
Executable file
133
tests/babel-utils.py
Executable file
@ -0,0 +1,133 @@
|
||||
#! env python3
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/28
|
||||
|
||||
# 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 translation management utilities.
|
||||
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from time import strftime
|
||||
|
||||
import click
|
||||
from babel.messages.frontend import CommandLineInterface
|
||||
from opencc import OpenCC
|
||||
|
||||
root_dir: Path = Path(__file__).parent.parent
|
||||
translation_dir: Path = root_dir / "src" / "accounting" / "translations"
|
||||
domain: str = "accounting"
|
||||
|
||||
|
||||
@click.group()
|
||||
def main() -> None:
|
||||
"""Manages the message translation."""
|
||||
|
||||
|
||||
@click.command("extract")
|
||||
def babel_extract() -> None:
|
||||
"""Extracts the messages for translation."""
|
||||
os.chdir(root_dir)
|
||||
cfg: Path = translation_dir / "babel.cfg"
|
||||
pot: Path = translation_dir / f"{domain}.pot"
|
||||
zh_hant: Path = translation_dir / "zh_Hant" / "LC_MESSAGES"\
|
||||
/ f"{domain}.po"
|
||||
zh_hans: Path = translation_dir / "zh_Hans" / "LC_MESSAGES"\
|
||||
/ f"{domain}.po"
|
||||
CommandLineInterface().run([
|
||||
"pybabel", "extract", "-F", str(cfg), "-k", "lazy_gettext", "-k", "A_",
|
||||
"-o", str(pot), "src"])
|
||||
if not zh_hant.exists():
|
||||
zh_hant.touch()
|
||||
if not zh_hans.exists():
|
||||
zh_hans.touch()
|
||||
CommandLineInterface().run([
|
||||
"pybabel", "update", "-i", str(pot), "-D", domain,
|
||||
"-d", translation_dir])
|
||||
|
||||
|
||||
@click.command("compile")
|
||||
def babel_compile() -> None:
|
||||
"""Compiles the translated messages."""
|
||||
__convert_chinese()
|
||||
__update_rev_date()
|
||||
CommandLineInterface().run([
|
||||
"pybabel", "compile", "-D", domain, "-d", translation_dir])
|
||||
|
||||
|
||||
def __convert_chinese() -> None:
|
||||
"""Updates the Simplified Chinese translation according to the Traditional
|
||||
Chinese translation.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
cc: OpenCC = OpenCC("tw2sp")
|
||||
zh_hant: Path = translation_dir / "zh_Hant" / "LC_MESSAGES"\
|
||||
/ f"{domain}.po"
|
||||
zh_hans: Path = translation_dir / "zh_Hans" / "LC_MESSAGES"\
|
||||
/ f"{domain}.po"
|
||||
now: str = strftime("%Y-%m-%d %H:%M%z")
|
||||
with open(zh_hant, "r") as f:
|
||||
content: str = f.read()
|
||||
content = cc.convert(content)
|
||||
content = re.sub(r"^# Chinese \\(Traditional\\) translations ",
|
||||
"# Chinese (Simplified) translations ", content)
|
||||
content = re.sub(r"\n\"PO-Revision-Date: [^\n]*\"\n",
|
||||
f"\n\"PO-Revision-Date: {now}\\\\n\"\n",
|
||||
content)
|
||||
content = content.replace("\n\"Language-Team: zh_Hant",
|
||||
"\n\"Language-Team: zh_Hans")
|
||||
content = content.replace("\n\"Language: zh_Hant\\n\"\n",
|
||||
"\n\"Language: zh_Hans\\n\"\n")
|
||||
content = content.replace("\nmsgstr \"zh-Hant\"\n",
|
||||
"\nmsgstr \"zh-Hans\"\n")
|
||||
zh_hans.parent.mkdir(exist_ok=True)
|
||||
with open(zh_hans, "w") as f:
|
||||
f.write(content)
|
||||
|
||||
|
||||
def __update_rev_date() -> None:
|
||||
"""Updates the revision dates in the PO files.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
for language_dir in translation_dir.glob("*"):
|
||||
po_file: Path = language_dir / "LC_MESSAGES" / f"{domain}.po"
|
||||
if po_file.is_file():
|
||||
__update_file_rev_date(po_file)
|
||||
|
||||
|
||||
def __update_file_rev_date(file: Path) -> None:
|
||||
"""Updates the revision date of a PO file
|
||||
|
||||
:param file: The PO file.
|
||||
:return: None.
|
||||
"""
|
||||
now = strftime("%Y-%m-%d %H:%M%z")
|
||||
with open(file, "r+") as f:
|
||||
content = f.read()
|
||||
content = re.sub(r"\n\"PO-Revision-Date: [^\n]*\"\n",
|
||||
f"\n\"PO-Revision-Date: {now}\\\\n\"\n",
|
||||
content)
|
||||
f.seek(0)
|
||||
f.write(content)
|
||||
|
||||
|
||||
main.add_command(babel_extract)
|
||||
main.add_command(babel_compile)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
113
tests/test_base_account.py
Normal file
113
tests/test_base_account.py
Normal file
@ -0,0 +1,113 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/26
|
||||
|
||||
# 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 base account management.
|
||||
|
||||
"""
|
||||
import unittest
|
||||
|
||||
import httpx
|
||||
from click.testing import Result
|
||||
from flask import Flask
|
||||
from flask.testing import FlaskCliRunner
|
||||
|
||||
from testlib import get_csrf_token
|
||||
from testsite import create_app
|
||||
|
||||
|
||||
class BaseAccountTestCase(unittest.TestCase):
|
||||
"""The base 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():
|
||||
result: Result = runner.invoke(args="init-db")
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
self.client: httpx.Client = httpx.Client(app=self.app,
|
||||
base_url="https://testserver")
|
||||
self.client.headers["Referer"] = "https://testserver"
|
||||
self.csrf_token: str = get_csrf_token(self, self.client, "/login")
|
||||
|
||||
def test_init(self) -> None:
|
||||
"""Tests the "accounting-init-base" console command.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
from accounting.base_account.models import BaseAccount, BaseAccountL10n
|
||||
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
|
||||
|
||||
self.__logout()
|
||||
response = self.client.get(list_uri)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
self.__logout()
|
||||
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)
|
||||
|
||||
def __logout(self) -> None:
|
||||
"""Logs out the currently logged-in user.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
response: httpx.Response = self.client.post(
|
||||
"/logout", data={"csrf_token": self.csrf_token})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], "/")
|
||||
|
||||
def __login_as(self, username: str) -> None:
|
||||
"""Logs in as a specific user.
|
||||
|
||||
:param username: The username.
|
||||
:return: None.
|
||||
"""
|
||||
response: httpx.Response = self.client.post(
|
||||
"/login", data={"csrf_token": self.csrf_token,
|
||||
"username": username})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], "/")
|
56
tests/testlib.py
Normal file
56
tests/testlib.py
Normal file
@ -0,0 +1,56 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/27
|
||||
|
||||
# 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 common test libraries.
|
||||
|
||||
"""
|
||||
from html.parser import HTMLParser
|
||||
from unittest import TestCase
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
def get_csrf_token(test_case: TestCase, client: httpx.Client, uri: str) -> str:
|
||||
"""Returns the CSRF token from a form in a URI.
|
||||
|
||||
:param test_case: The test case.
|
||||
:param client: The httpx client.
|
||||
:param uri: The URI.
|
||||
:return: The CSRF token.
|
||||
"""
|
||||
|
||||
class CsrfParser(HTMLParser):
|
||||
"""The CSRF token parser."""
|
||||
|
||||
def __init__(self):
|
||||
"""Constructs the CSRF token parser."""
|
||||
super().__init__()
|
||||
self.csrf_token: str | None = None
|
||||
"""The CSRF token."""
|
||||
|
||||
def handle_starttag(self, tag: str,
|
||||
attrs: list[tuple[str, str | None]]) -> None:
|
||||
"""Handles when a start tag is found."""
|
||||
attrs_dict: dict[str, str] = dict(attrs)
|
||||
if attrs_dict.get("name") == "csrf_token":
|
||||
self.csrf_token = attrs_dict["value"]
|
||||
|
||||
response: httpx.Response = client.get(uri)
|
||||
test_case.assertEqual(response.status_code, 200)
|
||||
parser: CsrfParser = CsrfParser()
|
||||
parser.feed(response.text)
|
||||
test_case.assertIsNotNone(parser.csrf_token)
|
||||
return parser.csrf_token
|
96
tests/testsite/__init__.py
Normal file
96
tests/testsite/__init__.py
Normal file
@ -0,0 +1,96 @@
|
||||
# The Mia! Accounting Flask Demonstration Website.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/27
|
||||
|
||||
# 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 Mia! Accounting Flask demonstration website.
|
||||
|
||||
"""
|
||||
import typing as t
|
||||
from secrets import token_urlsafe
|
||||
|
||||
import click
|
||||
from flask import Flask, Blueprint, render_template
|
||||
from flask.cli import with_appcontext
|
||||
from flask_babel_js import BabelJS
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_wtf import CSRFProtect
|
||||
|
||||
bp: Blueprint = Blueprint("home", __name__)
|
||||
babel_js: BabelJS = BabelJS()
|
||||
csrf: CSRFProtect = CSRFProtect()
|
||||
db: SQLAlchemy = SQLAlchemy()
|
||||
|
||||
|
||||
def create_app(is_testing: bool = False) -> Flask:
|
||||
"""Create and configure the application.
|
||||
|
||||
:param is_testing: True if we are running for testing, or False otherwise.
|
||||
:return: The application.
|
||||
"""
|
||||
import accounting
|
||||
|
||||
app: Flask = Flask(__name__)
|
||||
db_uri: str = "sqlite:///" if is_testing else "sqlite:///local.sqlite"
|
||||
app.config.from_mapping({
|
||||
"SECRET_KEY": token_urlsafe(32),
|
||||
"SQLALCHEMY_DATABASE_URI": db_uri,
|
||||
"BABEL_DEFAULT_LOCALE": "en",
|
||||
"ALL_LINGUAS": "zh_Hant|正體中文,en|English,zh_Hans|简体中文",
|
||||
})
|
||||
if is_testing:
|
||||
app.config["TESTING"] = True
|
||||
|
||||
babel_js.init_app(app)
|
||||
csrf.init_app(app)
|
||||
db.init_app(app)
|
||||
|
||||
app.register_blueprint(bp, url_prefix="/")
|
||||
app.cli.add_command(init_db_command)
|
||||
|
||||
from . import locale
|
||||
locale.init_app(app)
|
||||
|
||||
from . import auth
|
||||
auth.init_app(app)
|
||||
|
||||
can_view: t.Callable[[], bool] = lambda: auth.current_user() is not None \
|
||||
and auth.current_user().username in ["viewer", "editor"]
|
||||
can_edit: t.Callable[[], bool] = lambda: auth.current_user() is not None \
|
||||
and auth.current_user().username == "editor"
|
||||
accounting.init_app(app, can_view_func=can_view, can_edit_func=can_edit)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@click.command("init-db")
|
||||
@with_appcontext
|
||||
def init_db_command() -> None:
|
||||
"""Initializes the database."""
|
||||
db.create_all()
|
||||
from .auth import User
|
||||
for username in ["viewer", "editor", "nobody"]:
|
||||
if User.query.filter(User.username == username).first() is None:
|
||||
db.session.add(User(username=username))
|
||||
db.session.commit()
|
||||
click.echo("Database initialized successfully.")
|
||||
|
||||
|
||||
@bp.get("/", endpoint="home")
|
||||
def get_home() -> str:
|
||||
"""Returns the home page.
|
||||
|
||||
:return: The home page.
|
||||
"""
|
||||
return render_template("home.html")
|
92
tests/testsite/auth.py
Normal file
92
tests/testsite/auth.py
Normal file
@ -0,0 +1,92 @@
|
||||
# The Mia! Accounting Flask Demonstration Website.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/27
|
||||
|
||||
# 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 authentication for the Mia! Accounting Flask demonstration website.
|
||||
|
||||
"""
|
||||
from flask import Blueprint, render_template, Flask, redirect, url_for, \
|
||||
session, request, g
|
||||
|
||||
from . import db
|
||||
|
||||
bp: Blueprint = Blueprint("auth", __name__, url_prefix="/")
|
||||
|
||||
|
||||
class User(db.Model):
|
||||
"""A user."""
|
||||
__tablename__ = "users"
|
||||
"""The table name."""
|
||||
id = db.Column(db.Integer, nullable=False, primary_key=True,
|
||||
autoincrement=True)
|
||||
"""The ID"""
|
||||
username = db.Column(db.String, nullable=False, unique=True)
|
||||
"""The username."""
|
||||
|
||||
|
||||
@bp.get("login", endpoint="login-form")
|
||||
def show_login_form() -> str:
|
||||
"""Shows the login form.
|
||||
|
||||
:return: The login form.
|
||||
"""
|
||||
return render_template("login.html")
|
||||
|
||||
|
||||
@bp.post("login", endpoint="login")
|
||||
def login() -> redirect:
|
||||
"""Logs in the user.
|
||||
|
||||
:return: The redirection to the home page.
|
||||
"""
|
||||
if request.form.get("username") not in ["viewer", "editor", "nobody"]:
|
||||
return redirect(url_for("auth.login"))
|
||||
session["user"] = request.form.get("username")
|
||||
return redirect(url_for("home.home"))
|
||||
|
||||
|
||||
@bp.post("logout", endpoint="logout")
|
||||
def logout() -> redirect:
|
||||
"""Logs out the user.
|
||||
|
||||
:return: The redirection to the home page.
|
||||
"""
|
||||
if "user" in session:
|
||||
del session["user"]
|
||||
return redirect(url_for("home.home"))
|
||||
|
||||
|
||||
def current_user() -> User | None:
|
||||
"""Returns the current user.
|
||||
|
||||
:return: The current user, or None if the user did not log in.
|
||||
"""
|
||||
if not hasattr(g, "user"):
|
||||
if "user" not in session:
|
||||
g.user = None
|
||||
else:
|
||||
g.user = User.query.filter(
|
||||
User.username == session["user"]).first()
|
||||
return g.user
|
||||
|
||||
|
||||
def init_app(app: Flask) -> None:
|
||||
"""Initialize the localization.
|
||||
|
||||
:param app: The Flask application.
|
||||
:return: None.
|
||||
"""
|
||||
app.register_blueprint(bp)
|
||||
app.jinja_env.globals["current_user"] = current_user
|
97
tests/testsite/locale.py
Normal file
97
tests/testsite/locale.py
Normal file
@ -0,0 +1,97 @@
|
||||
# The Mia! Accounting Flask Demonstration Website.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/2
|
||||
|
||||
# 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 localization for the Mia! Accounting Flask demonstration website.
|
||||
|
||||
"""
|
||||
from babel import Locale
|
||||
from flask import request, session, current_app, Blueprint, Response, \
|
||||
redirect, url_for, Flask
|
||||
from flask_babel import Babel
|
||||
from werkzeug.datastructures import LanguageAccept
|
||||
|
||||
bp: Blueprint = Blueprint("locale", __name__, url_prefix="/")
|
||||
|
||||
|
||||
def get_locale():
|
||||
"""Returns the locale of the user
|
||||
|
||||
:return: The locale of the user.
|
||||
"""
|
||||
all_linguas: dict[str, str] = get_all_linguas()
|
||||
if "locale" in session and session["locale"] in all_linguas:
|
||||
return session["locale"]
|
||||
return __fix_accept_language(request.accept_languages)\
|
||||
.best_match(all_linguas.keys())
|
||||
|
||||
|
||||
def __fix_accept_language(accept: LanguageAccept) -> LanguageAccept:
|
||||
"""Fixes the accept-language so that territory variants may be matched to
|
||||
script variants. For example, zh_TW, zh_HK to zh_Hant, and zh_CN, zh_SG to
|
||||
zh_Hans. This is to solve the issue that Flask only recognizes the script
|
||||
variants, like zh_Hant and zh_Hans.
|
||||
|
||||
:param accept: The original HTTP accept languages.
|
||||
:return: The fixed HTTP accept languages
|
||||
"""
|
||||
accept_list: list[tuple[str, float]] = list(accept)
|
||||
to_add: list[tuple[str, float]] = []
|
||||
for pair in accept_list:
|
||||
locale: Locale = Locale.parse(pair[0].replace("-", "_"))
|
||||
if locale.script is not None:
|
||||
tag: str = f"{locale.language}-{locale.script}"
|
||||
if tag not in accept:
|
||||
to_add.append((tag, pair[1]))
|
||||
accept_list.extend(to_add)
|
||||
return LanguageAccept(accept_list)
|
||||
|
||||
|
||||
@bp.post("/locale", endpoint="set-locale")
|
||||
def set_locale() -> Response:
|
||||
"""Sets the locale for the user.
|
||||
|
||||
:return: The response.
|
||||
"""
|
||||
all_linguas: dict[str, str] = get_all_linguas()
|
||||
if "locale" in request.form and request.form["locale"] in all_linguas:
|
||||
session["locale"] = request.form["locale"]
|
||||
if "next" in request.form:
|
||||
return redirect(request.form["next"])
|
||||
return redirect(url_for("home.home"))
|
||||
|
||||
|
||||
def get_all_linguas() -> dict[str, str]:
|
||||
"""Returns all the available languages.
|
||||
|
||||
:return: All the available languages, as a dictionary of the language code
|
||||
and their local names.
|
||||
"""
|
||||
return {y[0]: y[1] for y in
|
||||
[x.split("|") for x in
|
||||
current_app.config["ALL_LINGUAS"].split(",")]}
|
||||
|
||||
|
||||
def init_app(app: Flask) -> None:
|
||||
"""Initialize the localization.
|
||||
|
||||
:param app: The Flask application.
|
||||
:return: None.
|
||||
"""
|
||||
babel = Babel()
|
||||
babel.init_app(app, locale_selector=get_locale)
|
||||
app.register_blueprint(bp)
|
||||
app.jinja_env.globals["get_locale"] = get_locale
|
||||
app.jinja_env.globals["get_all_linguas"] = get_all_linguas
|
134
tests/testsite/templates/base.html
Normal file
134
tests/testsite/templates/base.html
Normal file
@ -0,0 +1,134 @@
|
||||
{#
|
||||
The Mia! Accounting Flask Demonstration Website
|
||||
base.html: The side-wide layout template
|
||||
|
||||
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/27
|
||||
#}
|
||||
<!doctype html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" lang="{{ _("en") }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="author" content="{{ "imacat" }}" />
|
||||
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.2.1/css/all.min.css">
|
||||
{% block styles %}{% endblock %}
|
||||
<script src="{{ url_for("babel_catalog") }}"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/decimal.js/9.0.0/decimal.min.js"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
<title>{% block title %}{% endblock %}</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="navbar navbar-expand-lg bg-body-tertiary bg-dark navbar-dark">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="{{ url_for("home.home") }}">
|
||||
<i class="fa-solid fa-house"></i>
|
||||
{{ _("Home") }}
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#collapsible-navbar" aria-controls="collapsible-navbar" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
||||
<div id="collapsible-navbar" class="collapse navbar-collapse">
|
||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||
{% include "/accounting/include/nav.html" %}
|
||||
</ul>
|
||||
|
||||
<!-- The right side -->
|
||||
<ul class="navbar-nav d-flex">
|
||||
{% if current_user() is not none %}
|
||||
<li class="nav-item dropdown">
|
||||
<span class="nav-link dropdown-toggle" data-bs-toggle="dropdown">
|
||||
<i class="fa-solid fa-user"></i>
|
||||
{{ current_user().username }}
|
||||
</span>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li>
|
||||
<form action="{{ url_for("auth.logout") }}" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="btn dropdown-item" type="submit">
|
||||
<i class="fa-solid fa-right-from-bracket"></i>
|
||||
{{ _("Log Out") }}
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for("auth.login") }}">
|
||||
<i class="fa-solid fa-right-to-bracket"></i>
|
||||
{{ _("Log In") }}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="nav-item dropdown">
|
||||
<span class="nav-link dropdown-toggle" data-bs-toggle="dropdown">
|
||||
<i class="fa-solid fa-language"></i>
|
||||
</span>
|
||||
<form action="{{ url_for("locale.set-locale") }}" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="next" value="{{ request.full_path if request.query_string else request.path }}">
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
{% for locale_code, locale_name in get_all_linguas().items() %}
|
||||
<li>
|
||||
<button class="dropdown-item {% if locale_code == get_locale() %} active {% endif %}" type="submit" name="locale" value="{{ locale_code }}">
|
||||
{{ locale_name }}
|
||||
</button>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container">
|
||||
|
||||
<h1>{% block header %}{% endblock %}</h1>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
{% if category == "success" %}
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% elif category == "error" %}
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<strong>{{ _("Error:") }}</strong> {{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<main class="pb-5">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
24
tests/testsite/templates/home.html
Normal file
24
tests/testsite/templates/home.html
Normal file
@ -0,0 +1,24 @@
|
||||
{#
|
||||
The Mia! Accounting Flask Demonstration Website
|
||||
home.html: The home page.
|
||||
|
||||
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/27
|
||||
#}
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block header %}{% block title %}{{ _("Home") }}{% endblock %}{% endblock %}
|
35
tests/testsite/templates/login.html
Normal file
35
tests/testsite/templates/login.html
Normal file
@ -0,0 +1,35 @@
|
||||
{#
|
||||
The Mia! Accounting Flask Demonstration Website
|
||||
login.html: The login page.
|
||||
|
||||
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/27
|
||||
#}
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block header %}{% block title %}{{ _("Log In") }}{% endblock %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<form action="{{ url_for("auth.login") }}" method="post">
|
||||
<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="editor">{{ _("Editor") }}</button>
|
||||
<button class="btn btn-primary" type="submit" name="username" value="nobody">{{ _("Nobody") }}</button>
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
3
tests/testsite/translations/babel.cfg
Normal file
3
tests/testsite/translations/babel.cfg
Normal file
@ -0,0 +1,3 @@
|
||||
[python: **.py]
|
||||
[jinja2: **/templates/**.html]
|
||||
[javascript: **/static/js/**.js]
|
54
tests/testsite/translations/zh_Hant/LC_MESSAGES/messages.po
Normal file
54
tests/testsite/translations/zh_Hant/LC_MESSAGES/messages.po
Normal file
@ -0,0 +1,54 @@
|
||||
# Chinese (Traditional) translations for the Mia! Accounting Flask
|
||||
# Demonstration website.
|
||||
# Copyright (C) 2023 imacat
|
||||
# This file is distributed under the same license as the Mia! Accounting
|
||||
# Flask Demonstration project.
|
||||
# imacat <imacat@mail.imacat.idv.tw>, 2023.
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Mia! Accounting Flask Demonstration 0.0.0\n"
|
||||
"Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n"
|
||||
"POT-Creation-Date: 2023-01-28 13:42+0800\n"
|
||||
"PO-Revision-Date: 2023-01-28 13:42+0800\n"
|
||||
"Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n"
|
||||
"Language: zh_Hant\n"
|
||||
"Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n"
|
||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.11.0\n"
|
||||
|
||||
#: tests/testsite/templates/base.html:23
|
||||
msgid "en"
|
||||
msgstr "zh-Hant"
|
||||
|
||||
#: tests/testsite/templates/base.html:43 tests/testsite/templates/home.html:24
|
||||
msgid "Home"
|
||||
msgstr "首頁"
|
||||
|
||||
#: tests/testsite/templates/base.html:68
|
||||
msgid "Log Out"
|
||||
msgstr ""
|
||||
|
||||
#: tests/testsite/templates/base.html:78 tests/testsite/templates/login.html:24
|
||||
msgid "Log In"
|
||||
msgstr "登入"
|
||||
|
||||
#: tests/testsite/templates/base.html:119
|
||||
msgid "Error:"
|
||||
msgstr "錯誤:"
|
||||
|
||||
#: tests/testsite/templates/login.html:30
|
||||
msgid "Viewer"
|
||||
msgstr "讀報表者"
|
||||
|
||||
#: tests/testsite/templates/login.html:31
|
||||
msgid "Editor"
|
||||
msgstr "記帳者"
|
||||
|
||||
#: tests/testsite/templates/login.html:32
|
||||
msgid "Nobody"
|
||||
msgstr "沒有權限者"
|
||||
|
Reference in New Issue
Block a user