├── app
├── static
│ └── .gitkeep
├── constants
│ ├── __init__.py
│ ├── storage.py
│ ├── source.py
│ ├── message.py
│ ├── role.py
│ ├── locale.py
│ ├── output.py
│ ├── v_code.py
│ ├── project.py
│ ├── base.py
│ └── file.py
├── services
│ ├── __init__.py
│ └── google_storage.py
├── decorators
│ ├── __init__.py
│ ├── auth.py
│ ├── file.py
│ └── url.py
├── utils
│ ├── datetime.py
│ ├── type.py
│ ├── str.py
│ ├── hash.py
│ ├── file.py
│ ├── __init__.py
│ ├── mongo.py
│ ├── labelplus.py
│ └── logging.py
├── validators
│ ├── custom_message.py
│ ├── target.py
│ ├── custom_schema.py
│ ├── __init__.py
│ ├── member.py
│ ├── admin.py
│ ├── translation.py
│ ├── site_setting.py
│ ├── avatar.py
│ ├── file.py
│ ├── role.py
│ ├── term.py
│ ├── source.py
│ ├── join_process.py
│ ├── team.py
│ └── v_code.py
├── templates
│ └── email
│ │ ├── reset_password.txt
│ │ ├── reset_email.txt
│ │ ├── confirm_email.txt
│ │ ├── reset_password.html
│ │ ├── reset_email.html
│ │ └── confirm_email.html
├── core
│ ├── views.py
│ └── responses.py
├── models
│ ├── __init__.py
│ ├── site_setting.py
│ ├── message.py
│ ├── target.py
│ ├── invitation.py
│ └── output.py
├── regexs
│ └── __init__.py
├── exceptions
│ ├── message.py
│ ├── __init__.py
│ ├── term.py
│ ├── output.py
│ ├── language.py
│ ├── v_code.py
│ ├── team.py
│ ├── file.py
│ ├── auth.py
│ ├── base.py
│ └── join_process.py
├── apis
│ ├── language.py
│ ├── target.py
│ ├── __init__.py
│ ├── index.py
│ ├── group.py
│ ├── site_setting.py
│ ├── avatar.py
│ ├── type.py
│ ├── project_set.py
│ ├── manga_image_translator.py
│ ├── member.py
│ ├── role.py
│ └── translation.py
├── scripts
│ ├── fill_zh_translations.py
│ └── fill_en_translations.py
├── __init__.py
├── tasks
│ ├── __init__.py
│ ├── output_team_projects.py
│ ├── thumbnail.py
│ ├── import_from_labelplus.py
│ └── email.py
└── translations
│ └── __init__.py
├── tests
├── api
│ ├── __init__.py
│ ├── test_admin_file_api.py
│ ├── test_admin_api.py
│ └── test_site_setting_api.py
├── base
│ ├── __init__.py
│ ├── test_test_database.py
│ ├── test_not_exist_error.py
│ ├── test_error_code.py
│ ├── test_logging.py
│ ├── test_default_admin.py
│ ├── test_test_utils.py
│ └── test_regex.py
├── model
│ ├── __init__.py
│ ├── test_language_model.py
│ └── test_file_storage_model.py
├── other
│ └── __init__.py
└── deps.yaml
├── babel.cfg
├── docs
├── user_stories.md
└── models.md
├── files
├── test
│ ├── revisionA.txt
│ ├── revisionB.txt
│ ├── term.txt
│ ├── revisionC.txt
│ ├── 2kb.png
│ ├── 3kb.png
│ ├── README.txt
│ ├── 1kbA.txt
│ ├── 1kbB.txt
│ └── 3kbA.txt
├── fonts
│ ├── captcha.ttf
│ ├── README.txt
│ └── OFL.txt
└── ps_script
│ └── ps_script_res
│ ├── en.psd
│ ├── zh.psd
│ ├── README.md
│ └── CHANGELOG.md
├── .ruff.toml
├── apidoc.json
├── CONTRIBUTION.md
├── Dockerfile
├── pytest.ini
├── CHANGELOG.md
├── README.md
├── apidoc_header.md
├── LICENSE.md
├── .github
└── workflows
│ ├── check-pr.yml
│ └── build-image.yml
├── Makefile
├── deps-top.txt
├── README_zh.md
├── .gitignore
├── .dockerignore
├── .env.test.sample
├── manage.py
├── .env.sample
└── requirements.txt
/app/static/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/api/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/base/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/model/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/other/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/constants/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/services/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/babel.cfg:
--------------------------------------------------------------------------------
1 | [python: **.py]
--------------------------------------------------------------------------------
/docs/user_stories.md:
--------------------------------------------------------------------------------
1 | # user stories
2 |
3 | TODO
4 |
--------------------------------------------------------------------------------
/app/decorators/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | 各个模块所使用的装饰器
3 | """
4 |
--------------------------------------------------------------------------------
/files/test/revisionA.txt:
--------------------------------------------------------------------------------
1 | 这个文本用于测试版本操作
2 | 一
3 | 二
4 | 三
5 |
--------------------------------------------------------------------------------
/files/test/revisionB.txt:
--------------------------------------------------------------------------------
1 | 这个文本用于测试版本操作
2 | 一
3 |
4 | 三
5 |
6 | 五
7 | 六
--------------------------------------------------------------------------------
/files/test/term.txt:
--------------------------------------------------------------------------------
1 | 这个文本用于测试术语库
2 | 这是第一行,有一个术语Hello。
3 | 这是第二行,有两个术语你好和Hello。
--------------------------------------------------------------------------------
/files/test/revisionC.txt:
--------------------------------------------------------------------------------
1 | 这个文本用于测试版本操作
2 | 一
3 |
4 | 三
5 | 四
6 | 哈哈哈哈
7 | 五
8 | 一
--------------------------------------------------------------------------------
/files/test/2kb.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/moeflow-com/moeflow-backend/HEAD/files/test/2kb.png
--------------------------------------------------------------------------------
/files/test/3kb.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/moeflow-com/moeflow-backend/HEAD/files/test/3kb.png
--------------------------------------------------------------------------------
/files/fonts/captcha.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/moeflow-com/moeflow-backend/HEAD/files/fonts/captcha.ttf
--------------------------------------------------------------------------------
/files/fonts/README.txt:
--------------------------------------------------------------------------------
1 | captcha.ttf 裁剪自 OpenSans-Regular.ttf 用于生成验证码
2 | see: https://fonts.google.com/specimen/Open+Sans
--------------------------------------------------------------------------------
/files/ps_script/ps_script_res/en.psd:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/moeflow-com/moeflow-backend/HEAD/files/ps_script/ps_script_res/en.psd
--------------------------------------------------------------------------------
/files/ps_script/ps_script_res/zh.psd:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/moeflow-com/moeflow-backend/HEAD/files/ps_script/ps_script_res/zh.psd
--------------------------------------------------------------------------------
/app/constants/storage.py:
--------------------------------------------------------------------------------
1 | from app.constants.base import StrType
2 |
3 |
4 | class StorageType(StrType):
5 | OSS = "OSS"
6 | LOCAL_STORAGE = "LOCAL_STORAGE"
7 |
--------------------------------------------------------------------------------
/app/utils/datetime.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 |
4 | def utcnow() -> datetime.datetime:
5 | return datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc)
6 |
--------------------------------------------------------------------------------
/.ruff.toml:
--------------------------------------------------------------------------------
1 | target-version = "py311"
2 |
3 | line-length = 88
4 | indent-width = 4
5 |
6 | lint.external = ['F403']
7 | extend-exclude = [
8 | "./tests"
9 | ]
10 |
--------------------------------------------------------------------------------
/app/constants/source.py:
--------------------------------------------------------------------------------
1 | from app.constants.base import IntType
2 |
3 |
4 | class SourcePositionType(IntType):
5 | """原文(标记)位置类型"""
6 |
7 | IN = 1 # 框内标记
8 | OUT = 2 # 框外标记
9 |
--------------------------------------------------------------------------------
/files/test/README.txt:
--------------------------------------------------------------------------------
1 | 这个文件夹存放测试需要用到的文件
2 | 请勿修改文件内容,除非同步修改测试用例
3 | 1kbA.txt 里面有1024个A
4 | 3kbA.txt 里面有1024*3个A
5 | 1kbB.txt 里面有1024个B
6 | 2kb.png 是2kb大小的图片
7 | 3kb.png 是3kb大小的图片
8 | term.txt 用于测试术语库
--------------------------------------------------------------------------------
/app/utils/type.py:
--------------------------------------------------------------------------------
1 | def is_number(string: str):
2 | """判断字符串是否是纯数字"""
3 | try:
4 | int(string)
5 | except Exception:
6 | return False
7 | else:
8 | return True
9 |
--------------------------------------------------------------------------------
/app/validators/custom_message.py:
--------------------------------------------------------------------------------
1 | from flask_babel import lazy_gettext
2 |
3 | required_message = {"required": lazy_gettext("必填")}
4 | email_invalid_message = {"invalid": lazy_gettext("邮箱格式不正确")}
5 |
--------------------------------------------------------------------------------
/app/constants/message.py:
--------------------------------------------------------------------------------
1 | from app.constants.base import IntType
2 |
3 |
4 | class MessageType(IntType):
5 | USER = 0 # 用户
6 | SYSTEM = 1 # 系统
7 | INVITE = 2 # 邀请
8 | APPLY = 3 # 申请
9 |
--------------------------------------------------------------------------------
/app/validators/target.py:
--------------------------------------------------------------------------------
1 | from marshmallow import fields
2 |
3 | from app.validators.custom_schema import DefaultSchema
4 |
5 |
6 | class TargetSearchSchema(DefaultSchema):
7 | word = fields.Str(missing=None)
8 |
--------------------------------------------------------------------------------
/app/constants/role.py:
--------------------------------------------------------------------------------
1 | from app.constants.base import IntType
2 |
3 |
4 | class RoleType(IntType):
5 | """角色类型,用于获取角色"""
6 |
7 | ALL = 0 # 所有角色
8 | SYSTEM = 1 # 系统角色
9 | CUSTOM = 2 # 定制角色
10 |
--------------------------------------------------------------------------------
/apidoc.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "萌翻 API 文档",
3 | "version": "1.0.0",
4 | "description": "",
5 | "header": {
6 | "title": "API约定",
7 | "filename": "apidoc_header.md"
8 | },
9 | "title": "MoeFlow API Docs",
10 | "url": "http://localhost:8000"
11 | }
--------------------------------------------------------------------------------
/app/templates/email/reset_password.txt:
--------------------------------------------------------------------------------
1 | {{_('您申请重置密码!')}}
2 | {{_('1、如果您未曾申请重置密码,请忽略此邮件。')}}
3 | {{_('2、如果是您申请的,您的重置密码验证码如下:')}}
4 | {{code}}
5 | {{_('敬礼')}}
6 | {{ _('{site_name}').format(site_name=site_name) }}
7 | {{ _('本站地址:') }}
8 | {{ site_url }}
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/app/templates/email/reset_email.txt:
--------------------------------------------------------------------------------
1 | {{_('您申请解除安全邮箱!')}}
2 | {{_('1、如果您未曾申请过解除安全邮箱,很可能您的密码已经泄露,请立即修改密码!')}}
3 | {{_('2、如果是您申请的,您的解除安全邮箱验证码如下:')}}
4 | {{code}}
5 | {{_('敬礼')}}
6 | {{ _('{site_name}').format(site_name=site_name) }}
7 | {{ _('本站地址:') }}
8 | {{ site_url }}
9 |
10 |
11 |
--------------------------------------------------------------------------------
/app/validators/custom_schema.py:
--------------------------------------------------------------------------------
1 | from marshmallow import Schema
2 |
3 |
4 | class DefaultSchema(Schema):
5 | # marshmallow的默认配置
6 | class Meta:
7 | unknown = (
8 | "EXCLUDE" # required to ignore unknown fields, since marshmallow 3.0.0rc9
9 | )
10 |
--------------------------------------------------------------------------------
/app/core/views.py:
--------------------------------------------------------------------------------
1 | from typing import Optional
2 |
3 | from flask import g
4 | from flask_apikit.views import APIView
5 | from app.models.user import User
6 |
7 |
8 | class MoeAPIView(APIView):
9 | @property
10 | def current_user(self) -> Optional[User]:
11 | return g.get("current_user")
12 |
--------------------------------------------------------------------------------
/app/templates/email/confirm_email.txt:
--------------------------------------------------------------------------------
1 | {{ _('欢迎来到{site_name}!').format(site_name=site_name) }}
2 | {{ _('以下是核验您安全邮箱的验证码:') }}
3 | {{ code }}
4 | {{ _('如您没有注册{site_name}账号,请忽略此邮件。').format(site_name=site_name) }}
5 | {{ _('敬礼') }}
6 | {{ _('{site_name}').format(site_name=site_name) }}
7 | {{ _('本站地址:') }}
8 | {{ site_url }}
--------------------------------------------------------------------------------
/app/validators/__init__.py:
--------------------------------------------------------------------------------
1 | from .auth import RegisterSchema, LoginSchema, ChangeInfoSchema
2 | from .v_code import ConfirmEmailVCodeSchema, ResetPasswordVCodeSchema
3 |
4 | __all__ = [
5 | "RegisterSchema",
6 | "LoginSchema",
7 | "ChangeInfoSchema",
8 | "ConfirmEmailVCodeSchema",
9 | "ResetPasswordVCodeSchema",
10 | ]
11 |
--------------------------------------------------------------------------------
/CONTRIBUTION.md:
--------------------------------------------------------------------------------
1 | # CONTRIBUTION
2 |
3 | ## Structure
4 |
5 |
6 | ## HOWTOs
7 |
8 | ### init venv for development
9 |
10 | ```
11 | $ make create-venv deps
12 | $ venv/bin/pip install -r requirements.txt
13 | ```
14 |
15 | ### lint + format code
16 |
17 | ```
18 | $ venv/bin/ruff .
19 | ```
20 |
21 |
22 | ### run within docker
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM python:3.11
2 |
3 | LABEL project="moeflow-backend"
4 |
5 | COPY ./requirements.txt /tmp/requirements.txt
6 | RUN pip install -r /tmp/requirements.txt
7 |
8 | ARG MOEFLOW_BUILD_ID=unknown
9 | ENV MOEFLOW_BUILD_ID=${MOEFLOW_BUILD_ID}
10 |
11 | COPY . /app
12 | WORKDIR /app
13 |
14 | EXPOSE 5000
15 |
16 | RUN BIN_PREFIX=/usr/local/bin make babel-update-mo
17 |
--------------------------------------------------------------------------------
/app/models/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | 模型
3 | """
4 |
5 | import logging
6 | from mongoengine import connect
7 |
8 | logger = logging.getLogger(__name__)
9 | logger.setLevel(logging.INFO)
10 |
11 |
12 | def connect_db(config):
13 | logger.info("Connect mongodb")
14 | uri = config["DB_URI"]
15 | logger.debug(" - $DB_URI: {}".format(uri))
16 | return connect(host=uri)
17 |
18 |
19 | # TODO 为所有模型添加索引
20 |
--------------------------------------------------------------------------------
/app/utils/str.py:
--------------------------------------------------------------------------------
1 | import re
2 |
3 |
4 | def to_underscore(data: str) -> str:
5 | """将ProjectGreatSet转换成project_great_set的形式"""
6 | pattern = re.compile(r"[A-Z]")
7 | # 在所有大写字母前增加下划线
8 | data = re.sub(pattern, lambda res: f"_{res.group()}", data)
9 | # 尝试去掉第一个下划线
10 | if data.startswith("_"):
11 | data = data[1:]
12 | # 全部小写
13 | data = data.lower()
14 | return data
15 |
--------------------------------------------------------------------------------
/tests/base/test_test_database.py:
--------------------------------------------------------------------------------
1 | from app.models.user import Team, User
2 | from tests import DEFAULT_TEAMS_COUNT, DEFAULT_USERS_COUNT, MoeTestCase
3 |
4 |
5 | class TestDatabaseTestCase(MoeTestCase):
6 | def test_is_clear1(self):
7 | """测试测试的每个新方法都会调用setUp清库"""
8 | self.assertEqual(User.objects.count(), DEFAULT_USERS_COUNT + 0)
9 | self.assertEqual(Team.objects.count(), DEFAULT_TEAMS_COUNT + 0)
10 |
--------------------------------------------------------------------------------
/app/utils/hash.py:
--------------------------------------------------------------------------------
1 | import hashlib
2 | from io import BufferedReader
3 |
4 |
5 | def md5(src):
6 | """获取字符串的md5"""
7 | m = hashlib.md5()
8 | m.update(src.encode("UTF-8"))
9 | return m.hexdigest()
10 |
11 |
12 | def get_file_md5(file):
13 | """获取文件的md5"""
14 | m = hashlib.md5()
15 | m.update(file.read())
16 | # 如果是文件,则还原指针
17 | if isinstance(file, BufferedReader):
18 | file.seek(0)
19 | return m.hexdigest()
20 |
--------------------------------------------------------------------------------
/app/regexs/__init__.py:
--------------------------------------------------------------------------------
1 | """正则表达式"""
2 |
3 | USER_NAME_REGEX = r"^[\u2E80-\u2FDF\u3040-\u318F\u31A0-\u31BF\u31F0-\u31FF\u3400-\u4DB5\u4E00-\u9FFF\uA960-\uA97F\uAC00-\uD7FFa-zA-Z0-9_]+$" # noqa: E501
4 | TEAM_NAME_REGEX = r"^[\u2E80-\u2FDF\u3040-\u318F\u31A0-\u31BF\u31F0-\u31FF\u3400-\u4DB5\u4E00-\u9FFF\uA960-\uA97F\uAC00-\uD7FFa-zA-Z0-9_]+$" # noqa: E501
5 | EMAIL_REGEX = r"^[^@ ]+@[^.@ ]+(\.[^.@ ]+)*(\.[^.@ ]{2,})$"
6 | SAFE_FILENAME_REGEX = r"[/\\?%*:|\"<>\x7F\x00-\x1F]"
7 |
--------------------------------------------------------------------------------
/app/validators/member.py:
--------------------------------------------------------------------------------
1 | from marshmallow import fields
2 |
3 | from app.validators.custom_message import required_message
4 | from app.validators.custom_schema import DefaultSchema
5 | from app.validators.custom_validate import object_id
6 |
7 |
8 | class ChangeMemberSchema(DefaultSchema):
9 | """修改团队用户验证器"""
10 |
11 | role = fields.Str(
12 | required=True,
13 | validate=[object_id],
14 | error_messages={**required_message},
15 | )
16 |
--------------------------------------------------------------------------------
/pytest.ini:
--------------------------------------------------------------------------------
1 | [pytest]
2 | generate_report_on_test = True
3 | ; 覆盖率配置,需要时开启(可配合VSCode的Coverage Gutters使用)
4 | addopts = --log-level=DEBUG --capture=no --cov=app --cov-report=term --cov-report=xml:cov.xml --html=report.html --self-contained-html
5 | ; 忽略第三方库Warining
6 | filterwarnings =
7 | ignore:count is deprecated. Use Collection.count_documents instead.:DeprecationWarning
8 | ignore:Using or importing the ABCs .* is deprecated.*:DeprecationWarning
9 | env_files = .env.test
10 |
--------------------------------------------------------------------------------
/app/exceptions/message.py:
--------------------------------------------------------------------------------
1 | from flask_babel import lazy_gettext
2 |
3 | from .base import MoeError
4 |
5 |
6 | class MessageRootError(MoeError):
7 | """
8 | @apiDefine MessageRootError
9 | @apiError 7000 站内信异常
10 | """
11 |
12 | code = 7000
13 | message = lazy_gettext("站内信异常")
14 |
15 |
16 | class MessageTypeError(MessageRootError):
17 | """
18 | @apiDefine MessageTypeError
19 | @apiError 7001 站内信类型错误
20 | """
21 |
22 | code = 7001
23 | message = lazy_gettext("站内信类型错误")
24 |
--------------------------------------------------------------------------------
/app/core/responses.py:
--------------------------------------------------------------------------------
1 | from flask_apikit.responses import Pagination
2 |
3 |
4 | class MoePagination(Pagination):
5 | def set_objects(
6 | self, objects, /, *, func: str = "to_api", func_kwargs: dict = None
7 | ) -> "MoePagination":
8 | """传入 MongoEngine 的 Query,使用 func 同名方法自动解析数据,获取总个数后 set_data 到分页对象"""
9 | if func_kwargs is None:
10 | func_kwargs = {}
11 | data = [getattr(o, func)(**func_kwargs) for o in objects]
12 | return self.set_data(data=data, count=objects.count())
13 |
--------------------------------------------------------------------------------
/app/constants/locale.py:
--------------------------------------------------------------------------------
1 | from flask_babel import lazy_gettext
2 |
3 | from app.constants.base import StrType
4 |
5 |
6 | class Locale(StrType):
7 | """站点可选语言"""
8 |
9 | AUTO = "auto"
10 | ZH_CN = "zh_CN"
11 | ZH_TW = "zh_TW"
12 | EN = "en"
13 |
14 | details = {
15 | "AUTO": {"name": lazy_gettext("自动"), "intro": lazy_gettext("遵循浏览器设置")},
16 | "ZH_CN": {"name": lazy_gettext("中文(简体)")},
17 | "ZH_TW": {"name": lazy_gettext("中文(繁体)")},
18 | "EN": {"name": lazy_gettext("英文")},
19 | }
20 |
--------------------------------------------------------------------------------
/tests/base/test_not_exist_error.py:
--------------------------------------------------------------------------------
1 | from app.exceptions import NotExistError, TeamNotExistError
2 | from tests import MoeTestCase
3 |
4 |
5 | class NotExistTestCase(MoeTestCase):
6 | def test_not_exist_error(self):
7 | with self.app.test_request_context():
8 | self.assertEqual(NotExistError("Team").code, 3001)
9 | self.assertEqual(NotExistError("Project").code, 4001)
10 | self.assertEqual(NotExistError("Nothing").code, 104)
11 | self.assertEqual(NotExistError("Team"), TeamNotExistError)
12 |
--------------------------------------------------------------------------------
/app/exceptions/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | 所有API异常定义在此
3 | """
4 |
5 | from .base import * # 100-999 # noqa: F403
6 | from .auth import * # 1xxx # noqa: F403
7 | from .v_code import * # 2xxx # noqa: F403
8 | from .team import * # 3xxx # noqa: F403
9 | from .project import * # 4xxx # noqa: F403
10 | from .join_process import * # 5xxx # noqa: F403
11 | from .language import * # 6xxx # noqa: F403
12 | from .term import * # 7xxx # noqa: F403
13 | from .file import * # 8xxx # noqa: F403
14 | from .output import * # 9xxx # noqa: F403
15 |
16 | self_vars = vars()
17 |
--------------------------------------------------------------------------------
/app/validators/admin.py:
--------------------------------------------------------------------------------
1 | from marshmallow import fields
2 |
3 | from app.validators.custom_message import required_message
4 | from app.validators.custom_validate import object_id
5 | from app.validators.custom_schema import DefaultSchema
6 |
7 |
8 | class AdminStatusSchema(DefaultSchema):
9 | user_id = fields.Str(
10 | required=True,
11 | validate=[object_id],
12 | error_messages={**required_message},
13 | )
14 | status = fields.Bool(
15 | required=True,
16 | error_messages={**required_message},
17 | )
18 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 |
2 | ## Version 1.1.2
3 |
4 | https://github.com/moeflow-com/moeflow-backend/releases/tag/v1.1.2
5 |
6 | - upgrade python and deps
7 | - ruff / CI
8 | - move DB migration to manage.py
9 |
10 | ### Version.1.0.1
11 |
12 | 1. 修改部分没做本地化的位置(例如:首页、邮件),方便修改网站名称、标题、域名等信息。
13 | 2. 调整 config.py 中的配置格式,部分配置有默认值可选。
14 | 3. 调整阿里云 OSS 相关域名输出格式,私有读写模式下缩略图、下载等位置正常显示
15 | 4. 调整输出的翻译文本格式为 `utf-8`
16 | 5. 调整创建项目、创建团队时的部分参数,减少前端需配置的默认值。
17 | 6. 修改后端首页模版、增加 404 跳转到首页的代码。方便将前后端项目进行合并。(相关操作说明请参考前端帮助文件中对应段落!)
18 |
19 |
20 | ### Version 1.0.0
21 |
22 | 萌翻前后端开源的首个版本
23 |
24 |
--------------------------------------------------------------------------------
/app/apis/language.py:
--------------------------------------------------------------------------------
1 | from app.models.language import Language
2 | from app.core.views import MoeAPIView
3 |
4 |
5 | class LanguageListAPI(MoeAPIView):
6 | def get(self):
7 | """
8 | @api {get} /v1/languages 获取所有语言
9 | @apiVersion 1.0.0
10 | @apiName get_language_list
11 | @apiGroup Language
12 | @apiUse APIHeader
13 | @apiUse TokenHeader
14 |
15 | @apiSuccessExample {json} 返回示例
16 | {
17 |
18 | }
19 | """
20 | languages = Language.get()
21 | return [language.to_api() for language in languages]
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # moeflow-backend
2 |
3 | API + storage server for [moeflow](https://github.com/moeflow-com)
4 |
5 | [](https://codecov.io/gh/moeflow-com/moeflow-backend) [](https://sonarcloud.io/summary/new_code?id=moeflow-com_moeflow-backend)
6 |
7 | ## How to deploy
8 |
9 | Please refer to instruction in https://github.com/moeflow-com/moeflow-deploy
10 |
11 | ## How to develop
12 |
13 |
14 | ## model
15 |
--------------------------------------------------------------------------------
/app/templates/email/reset_password.html:
--------------------------------------------------------------------------------
1 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/app/utils/file.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 |
4 | def get_file_size(file, unit="kb") -> int:
5 | """获取文件大小,默认返回kb为单位的数值"""
6 | file.seek(0, os.SEEK_END) # 移动到文件尾部
7 | size = file.tell() # 获取文件大小,单位是Byte
8 | file.seek(0) # 将指针移回开头
9 | if unit.lower() == "kb":
10 | size = size / 1024
11 | elif unit.lower() == "mb":
12 | size = size / 1024 / 1024
13 | elif unit.lower() == "gb":
14 | size = size / 1024 / 1024 / 1024
15 | elif unit.lower() == "tb":
16 | size = size / 1024 / 1024 / 1024 / 1024
17 | elif unit.lower() == "bit":
18 | size = size * 8
19 | return size
20 |
--------------------------------------------------------------------------------
/app/templates/email/reset_email.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
{{ _('您申请解除安全邮箱') }}
4 |
5 |
6 | {{ _('1、如果您未曾申请过解除安全邮箱,很可能您的密码已经泄露,请立即修改密码!') }}
7 |
8 | {{ _('2、如果是您申请的,您的解除安全邮箱验证码如下:') }}
9 |
10 | {{ code }}
11 |
12 |
13 |
{{ _('敬礼') }}
14 |
15 |
16 |
{{ _('{site_name}').format(site_name=site_name) }}
17 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/app/utils/__init__.py:
--------------------------------------------------------------------------------
1 | def default(val, default_val=None, attr_name=None, func=None):
2 | """
3 | 如果 val 为 None, 则使用 default_val
4 | :param val: 值
5 | :param default_val: 默认值,默认为None
6 | :param attr_name: 如果有则调用相应的方法/属性
7 | :param func: 使用一个函数对val进行处理
8 | :return:
9 | """
10 | if val is None:
11 | val = default_val
12 | else:
13 | if attr_name:
14 | if callable(getattr(val, attr_name)):
15 | val = getattr(val, attr_name)()
16 | else:
17 | val = getattr(val, attr_name)
18 | if func:
19 | val = func(val)
20 | return val
21 |
--------------------------------------------------------------------------------
/app/templates/email/confirm_email.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
{{ _('欢迎来到{site_name}!').format(site_name=site_name) }}
4 |
5 |
6 | {{ _('以下是核验您安全邮箱的验证码:') }}
7 |
8 | {{ code }}
9 |
10 |
11 | {{ _('如您没有注册{site_name}账号,请忽略此邮件。').format(site_name=site_name) }}
12 |
{{ _('敬礼') }}
13 |
14 |
15 |
16 |
{{ _('{site_name}').format(site_name=site_name) }}
17 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/app/exceptions/term.py:
--------------------------------------------------------------------------------
1 | from flask_babel import lazy_gettext
2 |
3 | from .base import MoeError
4 |
5 |
6 | class TermRootError(MoeError):
7 | """
8 | @apiDefine TermRootError
9 | @apiError 7000 术语库异常
10 | """
11 |
12 | code = 7000
13 | message = lazy_gettext("术语库异常")
14 |
15 |
16 | class TermBankNotExistError(TermRootError):
17 | """
18 | @apiDefine TermBankNotExistError
19 | @apiError 7001 术语库不存在
20 | """
21 |
22 | code = 7001
23 | message = lazy_gettext("术语库不存在")
24 |
25 |
26 | class TermNotExistError(TermRootError):
27 | """
28 | @apiDefine TermNotExistError
29 | @apiError 7002 术语不存在
30 | """
31 |
32 | code = 7002
33 | message = lazy_gettext("术语不存在")
34 |
--------------------------------------------------------------------------------
/app/utils/mongo.py:
--------------------------------------------------------------------------------
1 | from typing import TypeVar, List
2 |
3 | T = TypeVar("T")
4 |
5 |
6 | def mongo_order(objects: List[T], order_by, default_order_by) -> List[T]:
7 | """处理排序"""
8 | # 设置排序默认值
9 | if order_by is None or order_by == []:
10 | order_by = default_order_by
11 | # 如果是字符串的话,则转为数组
12 | if isinstance(order_by, str):
13 | order_by = [order_by]
14 | # 排序
15 | if order_by:
16 | objects = objects.order_by(*order_by)
17 | return objects
18 |
19 |
20 | def mongo_slice(objects: List[T], skip, limit) -> List[T]:
21 | """切片处理"""
22 | if skip:
23 | objects = objects.skip(skip)
24 | if limit:
25 | objects = objects.limit(limit)
26 | return objects
27 |
--------------------------------------------------------------------------------
/apidoc_header.md:
--------------------------------------------------------------------------------
1 | ## HTTP status code 约定
2 |
3 | |status code| 内容
4 | |---|---
5 | |200-299|成功的请求
6 | |400-499|失败的请求,body将包含json格式的错误详细内容
7 | |500-599|服务器错误
8 |
9 | ## 错误格式约定
10 |
11 | **一般错误格式**
12 |
13 | |名称 |内容
14 | |--- |---
15 | |code |错误的具体代码
16 | |error |错误的类名
17 | |message|内容支持i18n,一般可以直接返回给用户
18 |
19 | ```json
20 | {
21 | "code": 2,
22 | "error": "NoPermissionError",
23 | "message": "抱歉,您没有权限。"
24 | }
25 | ```
26 | **ValidateError(#2)错误格式**
27 |
28 | 错误信息包含于字段名的数组内
29 |
30 | ```json
31 | {
32 | "code": 2,
33 | "error": "ValidateError",
34 | "message": {
35 | "email": [
36 | "此邮箱未注册"
37 | ],
38 | "password": [
39 | "必填"
40 | ]
41 | }
42 | }
43 | ```
44 |
45 |
--------------------------------------------------------------------------------
/app/exceptions/output.py:
--------------------------------------------------------------------------------
1 | from flask_babel import lazy_gettext
2 |
3 | from .base import MoeError
4 |
5 |
6 | class OutputRootError(MoeError):
7 | """
8 | @apiDefine ProjectRootError
9 | @apiError 9000 导出异常
10 | """
11 |
12 | code = 9000
13 | message = lazy_gettext("导出异常")
14 |
15 |
16 | class OutputNotExistError(OutputRootError):
17 | """
18 | @apiDefine OutputNotExistError
19 | @apiError 9001 导出导出文件不存在
20 | """
21 |
22 | code = 9001
23 | message = lazy_gettext("导出导出文件不存在")
24 |
25 |
26 | class OutputTooFastError(OutputRootError):
27 | """
28 | @apiDefine OutputTooFastError
29 | @apiError 9002 导出过于频繁,请稍后再试
30 | """
31 |
32 | code = 9002
33 | message = lazy_gettext("导出过于频繁,请稍后再试")
34 |
--------------------------------------------------------------------------------
/app/constants/output.py:
--------------------------------------------------------------------------------
1 | from flask_babel import lazy_gettext
2 |
3 | from app.constants.base import IntType
4 |
5 |
6 | class OutputStatus(IntType):
7 | """项目导出状态"""
8 |
9 | QUEUING = 0 # 排队中
10 | DOWNLOADING = 1 # 源文件整理中
11 | TRANSLATION_OUTPUTING = 2 # 翻译整理中
12 | ZIPING = 3 # 压缩中
13 | SUCCEEDED = 4 # 已完成
14 | ERROR = 5 # 导出错误,请重试
15 |
16 | details = {
17 | "QUEUING": {"name": lazy_gettext("排队中")},
18 | "TRANSLATION_OUTPUTING": {"name": lazy_gettext("翻译整理中")},
19 | "DOWNLOADING": {"name": lazy_gettext("源文件整理中")},
20 | "ZIPING": {"name": lazy_gettext("压缩中")},
21 | "SUCCEEDED": {"name": lazy_gettext("已完成")},
22 | "ERROR": {"name": lazy_gettext("导出错误,请重试")},
23 | }
24 |
25 |
26 | class OutputTypes(IntType):
27 | """项目导出类型"""
28 |
29 | ALL = 0 # 所有内容
30 | ONLY_TEXT = 1 # 仅文本
31 |
--------------------------------------------------------------------------------
/app/constants/v_code.py:
--------------------------------------------------------------------------------
1 | from typing import Literal
2 |
3 | from app.constants.base import IntType
4 |
5 |
6 | class VCodeType(IntType):
7 | CAPTCHA = 1 # 人机验证码
8 | CONFIRM_EMAIL = 2 # 验证邮箱
9 | RESET_EMAIL = 3 # 重设邮箱
10 | RESET_PASSWORD = 4 # 重置密码
11 | CONFIRM_PHONE = 5 # 验证手机
12 | RESET_PHONE = 6 # 重设手机
13 |
14 |
15 | VCodeTypeIntro = {
16 | VCodeType.CAPTCHA: "人机验证码",
17 | VCodeType.CONFIRM_EMAIL: "验证邮箱",
18 | VCodeType.RESET_EMAIL: "重设邮箱",
19 | VCodeType.RESET_PASSWORD: "重置密码",
20 | VCodeType.CONFIRM_PHONE: "验证手机",
21 | VCodeType.RESET_PHONE: "重设手机",
22 | }
23 | VCodeTypes = Literal[1, 2, 3, 4, 5, 6]
24 |
25 |
26 | class VCodeContentType(IntType):
27 | NUMBER = 1 # 纯数字
28 | LETTER = 2 # 纯字幕
29 | NUMBER_AND_LETTER = 3 # 数字字幕混合
30 |
31 |
32 | VCodeContentTypes = Literal[1, 2, 3]
33 | VCodeAddressTypes = Literal["email", "sms"]
34 |
--------------------------------------------------------------------------------
/tests/base/test_error_code.py:
--------------------------------------------------------------------------------
1 | from app.exceptions import MoeError, self_vars
2 | from tests import MoeTestCase
3 |
4 |
5 | class ErrorCodeTestCase(MoeTestCase):
6 | def test_error_code(self):
7 | """测试错误码是否重复"""
8 | error_codes = []
9 | # 取出所有MoeError的子类
10 | for k, v in self_vars.items():
11 | if type(v) == type:
12 | if issubclass(v, MoeError):
13 | error_codes.append(v.code)
14 | # 找出重复的错误代码
15 | not_unique_error_codes = []
16 | for code in error_codes:
17 | if error_codes.count(code) > 1:
18 | if code not in not_unique_error_codes:
19 | not_unique_error_codes.append(code)
20 | # 发现重复的错误代码,则抛出警告
21 | if len(not_unique_error_codes) > 0:
22 | raise UserWarning(
23 | "MoeError code not unique: {}".format(not_unique_error_codes)
24 | )
25 |
--------------------------------------------------------------------------------
/app/validators/translation.py:
--------------------------------------------------------------------------------
1 | from flask_babel import gettext
2 | from marshmallow import fields, validates_schema
3 |
4 | from app.validators.custom_message import required_message
5 | from app.validators.custom_schema import DefaultSchema
6 | from app.validators.custom_validate import object_id
7 | from flask_apikit.exceptions import ValidateError
8 |
9 |
10 | class CreateTranslationSchema(DefaultSchema):
11 | content = fields.Str(required=True, error_messages={**required_message})
12 | target_id = fields.Str(
13 | required=True,
14 | validate=[object_id],
15 | error_messages={**required_message},
16 | )
17 |
18 |
19 | class EditTranslationSchema(DefaultSchema):
20 | content = fields.Str()
21 | proofread_content = fields.Str()
22 | selected = fields.Bool()
23 |
24 | @validates_schema
25 | def verify_empty(self, data):
26 | if len(data) == 0:
27 | raise ValidateError(gettext("没有有效参数"))
28 |
--------------------------------------------------------------------------------
/app/validators/site_setting.py:
--------------------------------------------------------------------------------
1 | from marshmallow import fields
2 |
3 | from app.validators.custom_message import required_message
4 | from app.validators.custom_schema import DefaultSchema
5 | from app.validators.custom_validate import object_id
6 |
7 |
8 | class SiteSettingSchema(DefaultSchema):
9 | enable_whitelist = fields.Boolean(
10 | required=True, error_messages={**required_message}
11 | )
12 | whitelist_emails = fields.List(
13 | fields.Email(),
14 | required=True,
15 | error_messages={**required_message},
16 | )
17 | only_allow_admin_create_team = fields.Boolean(
18 | required=True, error_messages={**required_message}
19 | )
20 | auto_join_team_ids = fields.List(
21 | fields.Str(
22 | validate=[object_id],
23 | ),
24 | required=True,
25 | error_messages={**required_message},
26 | )
27 | homepage_html = fields.Str()
28 | homepage_css = fields.Str()
29 |
--------------------------------------------------------------------------------
/app/validators/avatar.py:
--------------------------------------------------------------------------------
1 | from app.exceptions.base import RequestDataWrongError
2 | from app.validators.custom_message import required_message
3 | from app.validators.custom_validate import object_id
4 | from app.validators.custom_schema import DefaultSchema
5 | from flask_babel import lazy_gettext
6 | from marshmallow import fields, validates_schema
7 |
8 |
9 | class EditAvatarSchema(DefaultSchema):
10 | type = fields.Str(
11 | required=True,
12 | error_messages={**required_message},
13 | )
14 | id = fields.Str(
15 | missing=None,
16 | validate=[object_id],
17 | error_messages={**required_message},
18 | )
19 |
20 | @validates_schema
21 | def verify_v_code(self, data):
22 | if data["type"] not in ["user", "team"]:
23 | raise RequestDataWrongError(lazy_gettext("不支持的头像类型"))
24 | if data["type"] != "user" and data["id"] is None:
25 | raise RequestDataWrongError(lazy_gettext("缺少id"))
26 |
--------------------------------------------------------------------------------
/files/ps_script/ps_script_res/README.md:
--------------------------------------------------------------------------------
1 | # LabelPlus PS-Script
2 |
3 | 
4 |
5 | ## 概述
6 |
7 | LabelPlus是一个用于图片翻译的工具包,本工程是其中的Photoshop文本导入工具,它读入翻译文本,并将文本逐条添加到PSD档中。
8 |
9 | 脚本用到的开源项目:
10 | * [xtools(BSD license)](http://ps-scripts.sourceforge.net/xtools.html)中部分工具函数及UI框架
11 | * [JSON Action Manager](http://www.tonton-pixel.com/json-photoshop-scripting/json-action-manager/index.html)中的JSON解析库
12 |
13 | 功能一览:
14 |
15 | * 解析LabelPlus文本 创建对应文本图层
16 | * 允许选择性导入部分文件、分组
17 | * 允许更换图源:可使用不同尺寸、可根据顺序自动匹配文件名(图片顺序、数量必须相同)、可替换图源后缀名
18 | * 自定义自动替换文本规则(如自动将`!?`替换为`!?`)
19 | * 格式设置:字体、字号、行距、文本方向
20 | * 可设置自定义动作:每导入一段文字后,执行动作;打开、关闭文档时执行动作
21 | * 根据标号位置自动涂白(实验功能)
22 |
23 | ## 开发方法
24 |
25 | ### requirement
26 | * typescript
27 | * python
28 |
29 | ```
30 | $ sudo apt install python nodejs
31 | $ sudo npm install -g typescript yarn
32 | ```
33 |
34 | ### build
35 |
36 | ```
37 | $ cd PS-Script
38 | $ yarn install
39 | $ ./build.sh
40 | ```
41 |
--------------------------------------------------------------------------------
/app/validators/file.py:
--------------------------------------------------------------------------------
1 | from marshmallow import fields
2 |
3 | from app.models.file import File
4 | from app.validators.custom_validate import indexes_in, object_id
5 | from app.validators.custom_schema import DefaultSchema
6 |
7 |
8 | class FileSearchSchema(DefaultSchema):
9 | word = fields.Str(missing=None)
10 | parent_id = fields.Str(missing=None, validate=[object_id])
11 | only_folder = fields.Bool(missing=False)
12 | only_file = fields.Bool(missing=False)
13 | order_by = fields.List(fields.Str(), missing=None, validate=[indexes_in(File)])
14 | target = fields.Str(missing=None, validate=[object_id])
15 |
16 |
17 | class FileGetSchema(DefaultSchema):
18 | target = fields.Str(missing=None, validate=[object_id])
19 |
20 |
21 | class FileUploadSchema(DefaultSchema):
22 | parent_id = fields.Str(missing=None, validate=[object_id])
23 |
24 |
25 | class AdminFileSearchSchema(DefaultSchema):
26 | safe_status = fields.List(fields.Int(), missing=[])
27 |
--------------------------------------------------------------------------------
/docs/models.md:
--------------------------------------------------------------------------------
1 | # models
2 |
3 | Persistent models in MongoDB.
4 |
5 | ## Core
6 |
7 |
8 | ### Global
9 |
10 | - `Language`
11 | - `VCode`
12 | - `Captcha`
13 | - `SiteSetting`
14 |
15 | ### User
16 |
17 | - `User`
18 | - `Message`
19 |
20 | ### Team
21 |
22 | - `Team`
23 | - `TeamPermission`
24 | - `TeamUserRelation`
25 | - `TeamRole`
26 | - `Application`
27 | - `ApplicationStatus`
28 | - `Invitation`
29 | - `InvitationStatus`
30 |
31 | ### Unused? Terms
32 |
33 | - `TermBank`
34 | - `TermGroup`
35 | - `Term`
36 |
37 | ### Project level
38 |
39 | - `Project`
40 | - `Target[]`
41 | - `File[]`
42 | - `ProjectSet`
43 | - `ProjectRole`
44 | - `ProjectUserRelation`
45 | - `ProjectAllowApplyType`
46 | - `ProjectPermission`
47 | - `Output`
48 |
49 | ### Inside project
50 |
51 | - `File`
52 | - `FileTargetCache`
53 | - `Source`: `(rank, x, y, content)`
54 | - `Translation`: `(User, Source, Target) -> (content, proof_content)`
55 | - `Tip`: `(User, Source, Target) -> (content)`
56 |
--------------------------------------------------------------------------------
/files/test/1kbA.txt:
--------------------------------------------------------------------------------
1 | AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
--------------------------------------------------------------------------------
/files/test/1kbB.txt:
--------------------------------------------------------------------------------
1 | BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
--------------------------------------------------------------------------------
/app/services/google_storage.py:
--------------------------------------------------------------------------------
1 | from google.cloud import storage
2 |
3 |
4 | class GoogleStorage:
5 | def __init__(self, config=None):
6 | if config:
7 | self.init(config)
8 | else:
9 | self.client = None
10 | self.bucket = None
11 |
12 | def init(self, config):
13 | """配置初始化"""
14 | self.client = storage.Client.from_service_account_json(
15 | config["GOOGLE_STORAGE_MOEFLOW_VISION_TMP"]["JSON"]
16 | )
17 | self.bucket = self.client.bucket(
18 | config["GOOGLE_STORAGE_MOEFLOW_VISION_TMP"]["BUCKET_NAME"]
19 | )
20 |
21 | def upload_from_string(self, path, filename, file):
22 | """上传文件"""
23 | blob = self.bucket.blob(path + filename)
24 | blob.upload_from_string(file)
25 | return blob
26 |
27 | def upload(self, path, filename, file):
28 | """上传文件"""
29 | blob = self.bucket.blob(path + filename)
30 | blob.upload_from_file(file)
31 | return blob
32 |
--------------------------------------------------------------------------------
/app/apis/target.py:
--------------------------------------------------------------------------------
1 | from app.core.views import MoeAPIView
2 | from app.decorators.auth import token_required
3 | from app.decorators.url import fetch_model
4 | from app.models.target import Target
5 | from app.models.project import ProjectPermission
6 | from app.exceptions import NoPermissionError
7 | from flask_babel import gettext
8 |
9 |
10 | class TargetAPI(MoeAPIView):
11 | @token_required
12 | @fetch_model(Target)
13 | def delete(self, target: Target):
14 | """
15 | @api {get} /v1/targets/ 删除翻译目标
16 | @apiVersion 1.0.0
17 | @apiName deleteTargetAPI
18 | @apiGroup Project
19 | @apiUse APIHeader
20 | @apiUse TokenHeader
21 |
22 | @apiSuccessExample {json} 返回示例
23 | {
24 |
25 | }
26 | """
27 | if not self.current_user.can(target.project, ProjectPermission.DELETE_TARGET):
28 | raise NoPermissionError
29 | target.clear()
30 | return {
31 | "message": gettext("删除成功"),
32 | }
33 |
--------------------------------------------------------------------------------
/tests/base/test_logging.py:
--------------------------------------------------------------------------------
1 | from app import flask_app, create_app
2 | import app.utils.logging as app_logging
3 | import logging
4 |
5 | # app_logging.configure_root_logger()
6 | logger = logging.getLogger(__name__)
7 | logger.setLevel(logging.DEBUG)
8 |
9 |
10 | def test_logging():
11 | assert 1 is 1
12 | logger.log(0, "notset?")
13 | logger.debug("debug")
14 | logger.info("info")
15 | logger.warning("warn")
16 | logger.error("local logger %d / %d", logger.level, logger.getEffectiveLevel())
17 | app_logging.logger.debug("debug to global logger")
18 | app_logging.logger.info("info to global logger")
19 | app_logging.logger.warning("warn to global logger")
20 | app_logging.logger.error(
21 | "global logger %d / %d",
22 | app_logging.logger.level,
23 | app_logging.logger.getEffectiveLevel(),
24 | )
25 | root_logger = logging.getLogger("root")
26 | logging.error(
27 | "root logger %d / %d", root_logger.level, root_logger.getEffectiveLevel()
28 | )
29 | create_app()
30 |
--------------------------------------------------------------------------------
/app/scripts/fill_zh_translations.py:
--------------------------------------------------------------------------------
1 | from babel.messages.pofile import read_po, write_po
2 | import logging
3 |
4 | logger = logging.getLogger(__name__)
5 |
6 |
7 | def fill_msg_with_msg_id(file_path: str):
8 | # Read the .po file
9 | with open(file_path, "rb") as po_file:
10 | catalog = read_po(po_file)
11 |
12 | for message in catalog:
13 | if message.id and not message.string:
14 | message.string = message.id
15 | elif message.id != message.string:
16 | logger.warning(
17 | "%s L%s: MISMATCH message id %s / message string %s",
18 | file_path,
19 | message.lineno,
20 | message.id,
21 | message.string,
22 | )
23 |
24 | # Write the updated catalog back to the .po file
25 | with open(file_path, "wb") as po_file:
26 | write_po(po_file, catalog)
27 |
28 |
29 | if __name__ == "__main__":
30 | po_file_path = "app/translations/zh/LC_MESSAGES/messages.po"
31 | fill_msg_with_msg_id(po_file_path)
32 |
--------------------------------------------------------------------------------
/tests/api/test_admin_file_api.py:
--------------------------------------------------------------------------------
1 | from app.exceptions import (
2 | NeedTokenError,
3 | NoPermissionError,
4 | )
5 | from app.models.user import User
6 | from tests import MoeAPITestCase
7 |
8 |
9 | class AdminFileAPITestCase(MoeAPITestCase):
10 | def test_get_files1(self):
11 | """非管理员用户不能访问接口"""
12 | user = User.create(email="u1", name="u1", password="123123")
13 | token = user.generate_token()
14 | data = self.get("/v1/admin/files", token=token)
15 | self.assertErrorEqual(data, NoPermissionError)
16 |
17 | def test_get_files2(self):
18 | """管理员用户可以访问接口"""
19 | user = User.create(email="u1", name="u1", password="123123")
20 | token = user.generate_token()
21 | user.admin = True
22 | user.save()
23 | data = self.get("/v1/admin/files", token=token)
24 | self.assertErrorEqual(data)
25 |
26 | def test_get_files3(self):
27 | """未登录不能访问接口"""
28 | data = self.get("/v1/admin/files")
29 | self.assertErrorEqual(data, NeedTokenError)
30 |
--------------------------------------------------------------------------------
/app/validators/role.py:
--------------------------------------------------------------------------------
1 | from marshmallow import fields, validates_schema
2 |
3 | from app.validators.custom_message import required_message
4 | from app.validators.custom_schema import DefaultSchema
5 | from app.validators.custom_validate import RoleValidate
6 |
7 |
8 | class RoleSchema(DefaultSchema):
9 | name = fields.Str(
10 | required=True,
11 | validate=[RoleValidate.name_length],
12 | error_messages={**required_message},
13 | )
14 | level = fields.Int(required=True, error_messages={**required_message})
15 | permissions = fields.List(
16 | fields.Int(), required=True, error_messages={**required_message}
17 | )
18 | intro = fields.Str(
19 | required=True,
20 | validate=[RoleValidate.intro_length],
21 | error_messages={**required_message},
22 | )
23 |
24 | @validates_schema
25 | def verify_level(self, data):
26 | # 等级不能高于当前用户角色等级
27 | RoleValidate.valid_level(
28 | data["level"],
29 | max=self.context["current_user_role"].level,
30 | field_name="level",
31 | )
32 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022-present kozzzx
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/app/decorators/auth.py:
--------------------------------------------------------------------------------
1 | from functools import wraps
2 |
3 | from flask import g, request
4 |
5 | from app.exceptions import NeedTokenError, UserBannedError, NoPermissionError
6 | from app.models.user import User
7 |
8 |
9 | def token_required(func):
10 | @wraps(func)
11 | def wrapper(*args, **kwargs):
12 | token = request.headers.get("Authorization")
13 | if token is None:
14 | raise NeedTokenError
15 | current_user = User.verify_token(token)
16 | # 检查用户状态
17 | if current_user.banned:
18 | raise UserBannedError
19 | # 赋值到g对象
20 | g.current_user = current_user
21 | return func(*args, **kwargs)
22 |
23 | return wrapper
24 |
25 |
26 | def admin_required(func):
27 | @wraps(func)
28 | def wrapper(*args, **kwargs):
29 | token = request.headers.get("Authorization")
30 | if token is None:
31 | raise NeedTokenError
32 | current_user = User.verify_token(token)
33 | # 检查用户状态
34 | if not current_user.admin_can():
35 | raise NoPermissionError
36 | # 赋值到g对象
37 | g.current_user = current_user
38 | return func(*args, **kwargs)
39 |
40 | return wrapper
41 |
--------------------------------------------------------------------------------
/app/apis/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | 所有的API编写在此
3 | """
4 |
5 | import logging
6 |
7 | from flask import Blueprint, Flask
8 |
9 | logger = logging.getLogger(__name__)
10 | # logger.setLevel(logging.DEBUG)
11 |
12 | """
13 | @apiDefine TokenHeader
14 | @apiHeader {String} Authorization
15 | 身份验证token.
16 |
17 | 格式为 `Bearer xxx`
18 | """
19 | """
20 | @apiDefine APIHeader
21 | @apiHeader {String} Content-Type
22 | 数据类型
23 |
24 | 必须为 `application/json`
25 | @apiHeader {String} [Accept-Language]
26 | 请求语言 *暂仅支持 `zh-CN`*
27 |
28 | 格式为 `<语言代号>;q=<喜好程度[0-1]>`, 多个语言使用逗号分割
29 |
30 | 示例 `zh-TW;q=0.8,zh;q=0.6`
31 | """
32 | """
33 | @apiDefine 204
34 | @apiSuccess (Success 204) null 响应成功,无返回值
35 | """
36 |
37 |
38 | def register_apis(app: Flask):
39 | """
40 | 自动注册蓝本
41 |
42 | :param app:
43 | :return:
44 | """
45 | logger.info("Register route blueprints")
46 | # 获取urls中所有蓝本
47 | from . import urls
48 |
49 | blueprints = [v for k, v in vars(urls).items() if isinstance(v, Blueprint)]
50 | for blueprint in blueprints:
51 | prefix = "/" if blueprint.url_prefix is None else blueprint.url_prefix
52 | logger.debug(" - {}: {}".format(blueprint.name, prefix))
53 | app.register_blueprint(blueprint)
54 |
--------------------------------------------------------------------------------
/tests/deps.yaml:
--------------------------------------------------------------------------------
1 | # genearted from moeflow-deploy repo like
2 | # docker-compose -f docker-compose.yml -f docker-compose.dev.yml config moeflow-mongodb moeflow-rabbitmq
3 | version: '3.3'
4 | # name: moeflow-backend-test-deps
5 | services:
6 | moeflow-mongodb:
7 | environment:
8 | MONGO_INITDB_ROOT_PASSWORD: CHANGE_ME
9 | MONGO_INITDB_ROOT_USERNAME: moeflow
10 | healthcheck:
11 | test:
12 | - CMD
13 | - mongo
14 | - --eval
15 | - db.adminCommand('ping')
16 | timeout: 5s
17 | interval: 15s
18 | start_period: 10s
19 | image: docker.io/mongo:4.4.1
20 | ports:
21 | - 127.0.0.1:27017:27017
22 | restart: unless-stopped
23 | moeflow-rabbitmq:
24 | environment:
25 | RABBITMQ_DEFAULT_PASS: CHANGE_ME
26 | RABBITMQ_DEFAULT_USER: moeflow
27 | RABBITMQ_DEFAULT_VHOST: moeflow
28 | healthcheck:
29 | test:
30 | - CMD-SHELL
31 | - rabbitmq-diagnostics -q ping
32 | timeout: 5s
33 | interval: 5s
34 | start_period: 10s
35 | image: docker.io/rabbitmq:3.8.9-management
36 | ports:
37 | - 127.0.0.1:5672:5672 # AMQP
38 | - 127.0.0.1:15672:15672 # management UI
39 | restart: unless-stopped
40 |
--------------------------------------------------------------------------------
/app/exceptions/language.py:
--------------------------------------------------------------------------------
1 | from flask_babel import lazy_gettext
2 |
3 | from .base import MoeError
4 |
5 |
6 | class LanguageRootError(MoeError):
7 | """
8 | @apiDefine LanguageRootError
9 | @apiError 6000 语言异常
10 | """
11 |
12 | code = 6000
13 | message = lazy_gettext("语言异常")
14 |
15 |
16 | class LanguageNotExistError(LanguageRootError):
17 | """
18 | @apiDefine LanguageNotExistError
19 | @apiError 6001 语言不存在
20 | """
21 |
22 | code = 6001
23 | message = lazy_gettext("语言不存在")
24 |
25 |
26 | class TargetAndSourceLanguageSameError(LanguageRootError):
27 | """
28 | @apiDefine TargetAndSourceLanguageSameError
29 | @apiError 6002 原语言和目标语言不能相同
30 | """
31 |
32 | code = 6002
33 | message = lazy_gettext("原语言和目标语言不能相同")
34 |
35 |
36 | class NeedTargetLanguagesError(LanguageRootError):
37 | """
38 | @apiDefine NeedTargetLanguagesError
39 | @apiError 6003 需要设置目标语言
40 | """
41 |
42 | code = 6003
43 | message = lazy_gettext("需要设置目标语言")
44 |
45 |
46 | class SameTargetLanguageError(LanguageRootError):
47 | """
48 | @apiDefine SameTargetLanguageError
49 | @apiError 6004 已有此目标语言
50 | """
51 |
52 | code = 6004
53 | message = lazy_gettext("已有此目标语言")
54 |
--------------------------------------------------------------------------------
/app/exceptions/v_code.py:
--------------------------------------------------------------------------------
1 | from flask_babel import lazy_gettext
2 |
3 | from .base import MoeError
4 |
5 |
6 | class VCodeRootError(MoeError):
7 | """
8 | @apiDefine VCodeRootError
9 | @apiError 2000 验证码异常
10 | """
11 |
12 | code = 2000
13 | message = lazy_gettext("验证码异常")
14 |
15 |
16 | class VCodeExpiredError(VCodeRootError):
17 | """
18 | @apiDefine VCodeExpiredError
19 | @apiError 2001 验证码过期,请重新输入
20 | """
21 |
22 | code = 2001
23 | message = lazy_gettext("验证码过期,请重新输入")
24 |
25 |
26 | class VCodeWrongError(VCodeRootError):
27 | """
28 | @apiDefine VCodeWrongError
29 | @apiError 2002 验证码错误
30 | """
31 |
32 | code = 2002
33 | message = lazy_gettext("验证码错误")
34 |
35 |
36 | class VCodeNotExistError(VCodeRootError):
37 | """
38 | @apiDefine VCodeNotExistError
39 | @apiError 2003 验证码不存在或已失效
40 | """
41 |
42 | code = 2003
43 | message = lazy_gettext("验证码失效,请重新获取")
44 |
45 |
46 | class VCodeCoolingError(VCodeRootError):
47 | """
48 | @apiDefine VCodeCoolingError
49 | @apiError 2004 验证码冷却中,请稍后再试
50 | """
51 |
52 | code = 2004
53 |
54 | def __init__(self, seconds):
55 | self.message = {
56 | "wait": seconds,
57 | "tip": lazy_gettext("请等候{seconds}秒后再试").format(seconds=seconds),
58 | }
59 |
--------------------------------------------------------------------------------
/app/__init__.py:
--------------------------------------------------------------------------------
1 | import os
2 | import logging
3 |
4 | from flask import Flask
5 |
6 | from .factory import (
7 | app_config,
8 | create_celery,
9 | create_flask_app,
10 | init_flask_app,
11 | oss,
12 | gs_vision,
13 | )
14 |
15 | from app.utils.logging import configure_root_logger, configure_extra_logs
16 |
17 | configure_root_logger()
18 | logger = logging.getLogger(__name__)
19 | logger.setLevel(logging.INFO)
20 |
21 | # 基本路径
22 | APP_PATH = os.path.abspath(os.path.dirname(__file__))
23 | FILE_PATH = os.path.abspath(os.path.join(APP_PATH, "..", "files")) # 一般文件
24 | TMP_PATH = os.path.abspath(os.path.join(FILE_PATH, "tmp")) # 临时文件存放地址
25 | STORAGE_PATH = os.path.abspath(os.path.join(APP_PATH, "..", "storage")) # 储存地址
26 |
27 | # Singletons
28 | flask_app = create_flask_app(
29 | Flask(
30 | __name__,
31 | **{
32 | "static_url_path": "/storage",
33 | "static_folder": STORAGE_PATH,
34 | }
35 | if app_config["STORAGE_TYPE"] == "LOCAL_STORAGE"
36 | else {},
37 | )
38 | )
39 | configure_extra_logs(flask_app)
40 | celery = create_celery(flask_app)
41 | init_flask_app(flask_app)
42 |
43 |
44 | def create_app():
45 | return flask_app
46 |
47 |
48 | __all__ = [
49 | "oss",
50 | "gs_vision",
51 | "flask_app",
52 | "app_config",
53 | "celery",
54 | "APP_PATH",
55 | "STORAGE_PATH",
56 | "TMP_PATH",
57 | "FILE_PATH",
58 | ]
59 |
--------------------------------------------------------------------------------
/.github/workflows/check-pr.yml:
--------------------------------------------------------------------------------
1 | name: PR checks
2 |
3 | on:
4 | pull_request:
5 | workflow_dispatch:
6 |
7 | jobs:
8 | static-check:
9 | runs-on: ubuntu-24.04
10 |
11 | steps:
12 | - uses: actions/checkout@v4
13 | - uses: actions/setup-python@v5
14 | with:
15 | python-version: '3.11'
16 | cache: 'pip'
17 | - run: pip install ruff
18 | - run: ruff check .
19 | - run: ruff format --diff .
20 |
21 | test:
22 | runs-on: ubuntu-24.04
23 | steps:
24 | - uses: actions/checkout@v4
25 | - uses: hoverkraft-tech/compose-action@v2.0.1
26 | with:
27 | compose-file: tests/deps.yaml
28 | - uses: actions/setup-python@v5
29 | with:
30 | python-version: '3.11'
31 | cache: 'pip'
32 | - run: |
33 | sudo apt-get install --yes libjpeg-dev libz-dev python3-dev libfreetype-dev build-essential
34 | - run: pip install -r requirements.txt
35 | - run: cp -rv .env.test.sample .env.test
36 | - uses: pavelzw/pytest-action@v2
37 | with:
38 | job-summary: true
39 | verbose: true
40 | custom-arguments: '-n=2'
41 | emoji: false
42 | - uses: codecov/codecov-action@v4.5.0
43 | if: always()
44 | with:
45 | fail_ci_if_error: true
46 | use_oidc: true
47 | - name: save test report
48 | uses: actions/upload-artifact@v4
49 | if: always()
50 | with:
51 | name: report.html
52 | path: report.html
53 |
--------------------------------------------------------------------------------
/tests/api/test_admin_api.py:
--------------------------------------------------------------------------------
1 | from app.exceptions.base import NoPermissionError
2 | from tests import MoeAPITestCase
3 |
4 |
5 | class AdminAPITestCase(MoeAPITestCase):
6 | def test_admin_admin_status_api(self):
7 | """管理员用户可以修改管理员状态"""
8 | admin_user = self.create_user("admin")
9 | admin_user.admin = True
10 | admin_user.save()
11 | admin_token = admin_user.generate_token()
12 | # Admin can enable admin status
13 | user = self.create_user("user")
14 | token = user.generate_token()
15 | self.assertFalse(user.admin)
16 | data = self.put(
17 | "/v1/admin/admin-status",
18 | json={"user_id": str(user.id), "status": True},
19 | token=admin_token,
20 | )
21 | self.assertErrorEqual(data)
22 | user.reload()
23 | self.assertTrue(user.admin)
24 | # Admin can disable admin status
25 | data = self.put(
26 | "/v1/admin/admin-status",
27 | json={"user_id": str(user.id), "status": False},
28 | token=admin_token,
29 | )
30 | self.assertErrorEqual(data)
31 | user.reload()
32 | self.assertFalse(user.admin)
33 | # Non-admin cannot edit admin status
34 | data = self.put(
35 | "/v1/admin/admin-status",
36 | json={"user_id": str(user.id), "status": True},
37 | token=token,
38 | )
39 | self.assertErrorEqual(data, NoPermissionError)
40 |
--------------------------------------------------------------------------------
/app/exceptions/team.py:
--------------------------------------------------------------------------------
1 | from flask_babel import lazy_gettext
2 |
3 | from .base import MoeError
4 |
5 |
6 | class TeamRootError(MoeError):
7 | """
8 | @apiDefine TeamRootError
9 | @apiError 3000 团队异常
10 | """
11 |
12 | code = 3000
13 | message = lazy_gettext("团队异常")
14 |
15 |
16 | class TeamNotExistError(TeamRootError):
17 | """
18 | @apiDefine TeamNotExistError
19 | @apiError 3001 团队不存在
20 | """
21 |
22 | code = 3001
23 | message = lazy_gettext("团队不存在")
24 |
25 |
26 | """错误码 3002 可以使用"""
27 | """错误码 3003 可以使用"""
28 | """错误码 3004 可以使用"""
29 |
30 |
31 | class TeamNameRegexError(TeamRootError):
32 | """
33 | @apiDefine TeamNameRegexError
34 | @apiError 3005 仅可使用中文/日文/韩文/英文/数字/_
35 | """
36 |
37 | code = 3005
38 | message = lazy_gettext("仅可使用中文/日文/韩文/英文/数字/_")
39 |
40 |
41 | class TeamNameRegisteredError(TeamRootError):
42 | """
43 | @apiDefine TeamNameRegisteredError
44 | @apiError 3006 此团队名已被使用,请尝试其他昵称
45 | """
46 |
47 | code = 3006
48 | message = lazy_gettext("此团队名已被使用")
49 |
50 |
51 | class TeamNameLengthError(TeamRootError):
52 | """
53 | @apiDefine TeamNameLengthError
54 | @apiError 3007 长度为2到18个字符
55 | """
56 |
57 | code = 3007
58 | message = lazy_gettext("长度为2到18个字符")
59 |
60 |
61 | class OnlyAllowAdminCreateTeamError(TeamRootError):
62 | """
63 | @apiDefine OnlyAllowAdminCreateTeamError
64 | @apiError 3008 仅站点管理员可创建团队
65 | """
66 |
67 | code = 3008
68 | message = lazy_gettext("仅站点管理员可创建团队")
69 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | PYTEST_COV_ARGS =
2 |
3 | BIN_PREFIX ?= venv/bin
4 |
5 | PYTHON_BIN = python3.11
6 |
7 | FORCE: ;
8 |
9 | create-venv:
10 | $(PYTHON_BIN) -mvenv venv
11 |
12 | deps:
13 | venv/bin/pip install -r requirements.txt
14 |
15 | remove-venv: FORCE
16 | rm -rf venv
17 |
18 | recreate-venv: remove-venv create-venv
19 |
20 | lint:
21 | venv/bin/ruff check
22 |
23 | lint-fix:
24 | venv/bin/ruff --fix
25 |
26 | format:
27 | venv/bin/ruff format
28 |
29 | requirements.txt: deps-top.txt
30 | rm -rf venv-rebuild-deps
31 | $(PYTHON_BIN) -mvenv venv-rebuild-deps
32 | venv-rebuild-deps/bin/pip install -r deps-top.txt
33 | echo '# GENERATED: run make requirements.txt to recreate lock file' > requirements.txt
34 | venv-rebuild-deps/bin/pipdeptree --freeze >> requirements.txt
35 | rm -rf venv-rebuild-deps
36 |
37 | test: test_all
38 |
39 | test_all:
40 | venv/bin/pytest
41 |
42 | test_all_parallel:
43 | # TODO: fix this
44 | venv/bin/pytest -n 8
45 |
46 | test_single:
47 | venv/bin/pytest tests/api/test_file_api.py
48 |
49 | test_logging:
50 | #--capture=no
51 | venv/bin/pytest --capture=sys --log-cli-level=DEBUG tests/base/test_logging.py
52 |
53 | babel-update-po:
54 | $(BIN_PREFIX)/pybabel extract -F babel.cfg -k lazy_gettext -k hardcode_text -o messages.pot app
55 | $(BIN_PREFIX)/pybabel update -i messages.pot -d app/translations
56 |
57 | babel-update-mo: babel-update-po
58 | $(BIN_PREFIX)/pybabel compile -d app/translations
59 |
60 | babel-translate-po:
61 | venv/bin/python app/scripts/fill_zh_translations.py
62 | venv/bin/python app/scripts/fill_en_translations.py
63 |
--------------------------------------------------------------------------------
/app/apis/index.py:
--------------------------------------------------------------------------------
1 | import os
2 | from html import escape
3 | from urllib.parse import unquote
4 |
5 | from flask import (
6 | current_app,
7 | send_from_directory,
8 | url_for,
9 | )
10 |
11 | from app.core.views import MoeAPIView
12 |
13 |
14 | class DocsAPI(MoeAPIView):
15 | def get(self, path):
16 | # Debug模式下,当访问主页时候,自动重新生成文档
17 | if path.startswith("index.html") and current_app.config.get("DEBUG"):
18 | os.system("apidoc -i app/ -o docs/")
19 | return send_from_directory("../docs", path)
20 |
21 |
22 | class PingAPI(MoeAPIView):
23 | def get(self):
24 | return "pong"
25 |
26 |
27 | class UrlListAPI(MoeAPIView):
28 | def get(self):
29 | output = ""
30 | for rule in current_app.url_map.iter_rules():
31 | options = {}
32 | for arg in sorted(rule.arguments):
33 | options[arg] = "<{0}>".format(arg)
34 |
35 | methods = ",".join(rule.methods)
36 | url = escape(unquote(url_for(rule.endpoint, **options)))
37 | line = "| {} | {} | {} |
".format(
38 | rule.endpoint, methods, url
39 | )
40 | output += line
41 | output += "
"
42 | return output
43 |
44 |
45 | class ErrorAPI(MoeAPIView):
46 | def get(self):
47 | """用于测试异常的报错"""
48 | int("1.2") # ValueError: invalid literal for int() with base 10: '1.2'
49 |
50 |
51 | class WarningAPI(MoeAPIView):
52 | def get(self):
53 | """用于测试异常的报错"""
54 | raise Warning("test warning")
55 |
--------------------------------------------------------------------------------
/deps-top.txt:
--------------------------------------------------------------------------------
1 | # THIS FILE is for leaf packages or version overrides
2 | # requirements.txt contains all package versions. It should be updated with `make requirements.txt`
3 | #
4 | python-dotenv==1.0.1
5 | Flask==2.2.5
6 | # Jinja2==3.0.3
7 | itsdangerous==2.0.1 # MUST NOT upgrade this, we still use TimedJSONWebSignatureSerializer
8 | Werkzeug==3.0.6 # MUST NOT upgrade this, our flask version requires it
9 | flask-apikit==0.0.7
10 | gunicorn==20.0.4 # 生产环境服务器
11 | pytest==8.2.1 # 测试框架
12 | pytest-cov==5.0.0 # 测试覆盖率
13 | pytest-xdist==3.6.1 # 并发测试支持
14 | pytest-dotenv==0.5.2
15 | pytest-html==4.1.1
16 | pytest-md==0.2.0
17 |
18 | flask-babel==4.0.0 # i18n
19 | mongoengine==0.20.0 # Mongo数据库
20 | mongomock==4.1.2
21 | Pillow==9.5.0 # MUST NOT upgrade this, v10 requires code change
22 | marshmallow==3.0.0b20 # 字段验证 (flask-apikit需要)
23 | requests==2.22.0 # HTTP请求
24 | oss2==2.7.0 # 阿里云OSS
25 | celery==5.3.6 # 任务调度
26 | celery[redis]==5.3.6
27 | flower==2.0.1 # web ui for celery
28 | redis==5.0.3 # Redis数据库
29 | chardet==3.0.4 # 文件编码预测
30 | aliyun-python-sdk-core-v3==2.13.3 # 用于签发阿里云pip
31 | click==8.1.3 # 命令行工具
32 | # aliyun-python-sdk-green==3.2.0 # 阿里云内容检测(暂时用不到)
33 | google-cloud-storage==1.33.0
34 | pipdeptree==2.13.2 # 將pip包 freeze成樹狀txt 方便查看
35 | ruff
36 | asgiref==3.7.2
37 |
--------------------------------------------------------------------------------
/app/tasks/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | Celery 通过以下语句启动(在项目根目录):
3 |
4 | # STEP 1:设置环境变量 并 启动CELERY
5 | export CONFIG_PATH="../configs/dev.py" && celery -A app.celery worker --loglevel=info
6 | # STEP 2:开启监控程序
7 | flower --port=5555 --broker=redis://localhost:6379/1
8 | """
9 |
10 | import asyncio
11 | import datetime
12 | from typing import Any
13 | from celery import Task
14 | from celery.result import AsyncResult
15 | from celery.exceptions import TimeoutError as CeleryTimeoutError
16 | from app import celery as celery_app
17 | from asgiref.sync import async_to_sync
18 |
19 |
20 | _FORCE_SYNC_TASK: bool = celery_app.conf["app_config"].get("TESTING", False)
21 |
22 |
23 | class SyncResult:
24 | """和celery的delay异步返回类似的结果,用于同步、异步切换"""
25 |
26 | task_id = "sync"
27 |
28 |
29 | def queue_task(task: Task, *args, **kwargs) -> str:
30 | result = task.delay(*args, **kwargs)
31 | result.forget()
32 | return result.id
33 |
34 |
35 | def wait_result_sync(task_id: str, timeout: int = 10) -> Any:
36 | result = AsyncResult(id=task_id, app=celery_app)
37 | try:
38 | return result.get(timeout=timeout)
39 | except CeleryTimeoutError:
40 | raise TimeoutError
41 |
42 |
43 | @async_to_sync
44 | async def wait_result(task_id: str, timeout: int = 10) -> Any:
45 | start = datetime.datetime.now().timestamp()
46 | result = AsyncResult(id=task_id, app=celery_app)
47 | while not result.ready():
48 | if (datetime.datetime.now().timestamp() - start) > timeout:
49 | result.forget()
50 | raise TimeoutError
51 | await asyncio.sleep(0.5e3)
52 | return result.get() # type: ignore
53 |
--------------------------------------------------------------------------------
/app/apis/group.py:
--------------------------------------------------------------------------------
1 | from app.models.project import Project, ProjectAllowApplyType
2 | from app.core.rbac import AllowApplyType
3 | from app.core.views import MoeAPIView
4 | from app.decorators.auth import token_required
5 | from app.decorators.url import fetch_group
6 | from app.exceptions.join_process import GroupNotOpenError
7 |
8 |
9 | class GroupPublicInfoAPI(MoeAPIView):
10 | @token_required
11 | @fetch_group
12 | def get(self, group):
13 | """
14 | @api {get} /v1///public-info 获取收到的申请
15 | @apiVersion 1.0.0
16 | @apiName getGroupPublicInfo
17 | @apiGroup Group
18 | @apiUse APIHeader
19 | @apiUse TokenHeader
20 |
21 | @apiParam {String} group_type 团体类型,支持 “team”、“project”
22 | @apiParam {String} group_id 团队 ID
23 | """
24 | relation = self.current_user.get_relation(group)
25 | if relation is None:
26 | if group.allow_apply_type == AllowApplyType.NONE:
27 | raise GroupNotOpenError
28 | if (
29 | isinstance(group, Project)
30 | and group.allow_apply_type == ProjectAllowApplyType.TEAM_USER
31 | ):
32 | team_relation = self.current_user.get_relation(group.team)
33 | if team_relation is None:
34 | raise GroupNotOpenError
35 | return {
36 | "id": str(group.id),
37 | "name": group.name,
38 | "joined": True if relation else False,
39 | "user_count": group.user_count,
40 | "application_check_type": group.application_check_type,
41 | }
42 |
--------------------------------------------------------------------------------
/README_zh.md:
--------------------------------------------------------------------------------
1 | # 萌翻[MoeFlow]后端项目
2 |
3 | 由于此版本调整了部分 API 接口, **请配合萌翻前端 Version.1.0.1 版本使用!** 直接使用旧版可能在修改(创建)团队和项目时报错。
4 |
5 | 此版本需配置 **阿里云 OSS** 作为文件存储。如果需要使用其他文件存储方式,可以选择使用以下的分支版本:
6 |
7 | - **本地硬盘存储** [`scanlation/moetran-local-backend`](https://github.com/scanlation/moetran-local-backend)
8 |
9 | ## 安装步骤
10 |
11 | 1. 安装 Python 3.10 版本
12 | 2. 依赖环境 MangoDB、Erlang、RabbitMQ
13 | 3. `pip install -r requirements.txt` (这一步如果 Windows 有报错,请在环境变量里面加 `PYTHONUTF8=1` )
14 | 4. 以 `/config.py` 为模板创建 `/configs/dev.py` 用于开发(此目录已被 git ignore)
15 | 5. 开发时,请直接在 `/configs/dev.py` 文件里面修改必填的配置
16 | 6. 运行前注意配置环境变量 `CONFIG_PATH=../configs/dev.py`
17 | 7. 运行主进程: `python manage.py run`
18 | 8. 在 `DEBUG` 开启的情况下,注册等验证码信息,直接看命令行输出的日志信息。
19 | 9. _(可选)_ 导入、导出等功能需要依赖两个 celery worker 进程,调试时可按另附的步骤启动。
20 |
21 | ## 配置 Celery
22 |
23 | 1. 如果使用 Windows 跑 Celery Worker,需要先安装 `eventlet` 并修改参数,否则会提示: `not enough values to unpack (expected 3, got 0)`
24 | 2. _(可选)_ Windows 安装 `eventlet` 请执行: `pip install eventlet`
25 | 3. 两个 worker 需要启动两个命令行(**这里的方案使用 Windows 的 Powershell 举例**),运行前需配置环境变量:`CONFIG_PATH=../configs/dev.py`
26 | 4. 启动主要 Celery Worker (发送邮件、分析术语),请执行:`celery -A app.celery worker -n default -P eventlet --loglevel=info`
27 | 5. 启动输出用 Celery Worker (导入项目、生成缩略图、导出翻译、导出项目),请执行:`celery -A app.celery worker -Q output -n output -P eventlet --loglevel=info`
28 | 6. 非 Windows 环境如果有报错,请去掉命令中的 `-P eventlet` 一段。
29 |
30 | ## 如何测试
31 |
32 | 1. 配置测试 `test.py`
33 | 1. DEBUG = True 和 TESTING = True
34 | 2. DB_URI 协议名使用 `mongomock://` 并将数据库名以 `_test` 结尾
35 | 3. 将 `CONFIRM_EMAIL_WAIT_SECONDS` `RESET_EMAIL_WAIT_SECONDS` `RESET_PASSWORD_WAIT_SECONDS` 设置为 `1`,以免过多等待
36 | 2. 执行 `export CONFIG_PATH=/path/to/configs/test.py && pytest -n auto` 开始并行测试
37 |
--------------------------------------------------------------------------------
/tests/base/test_default_admin.py:
--------------------------------------------------------------------------------
1 | from app import create_app
2 | from app.factory import init_db
3 | from app.models.user import User
4 | from tests import MoeTestCase
5 |
6 |
7 | class TestDefaultAdmin(MoeTestCase):
8 | def test_create_default_admin_by_config(self):
9 | """测试自动创建默认管理员"""
10 | admin_user = User.objects.first()
11 | self.assertEqual(admin_user.email, self.app.config["ADMIN_EMAIL"])
12 | self.assertEqual(admin_user.admin, True)
13 |
14 | def test_reset_default_admin(self):
15 | """测试重启应用时,重置默认默认管理员权限"""
16 | admin_user = User.objects.first()
17 | user = self.create_user("user")
18 | self.assertEqual(admin_user.email, self.app.config["ADMIN_EMAIL"])
19 | self.assertEqual(admin_user.admin, True)
20 | admin_user.admin = False
21 | admin_user.save()
22 | admin_user.reload()
23 | self.assertEqual(admin_user.admin, False)
24 | init_db(create_app())
25 | admin_user.reload()
26 | self.assertEqual(admin_user.admin, True)
27 | # 测试其他用户权限不受影响
28 | user.reload()
29 | self.assertEqual(user.admin, False)
30 |
31 | def test_reset_default_admin_when_true(self):
32 | """测试重启应用时,但默认管理员已经是管理员,不会影响默认管理员权限"""
33 | admin_user = User.objects.first()
34 | user = self.create_user("user")
35 | self.assertEqual(admin_user.email, self.app.config["ADMIN_EMAIL"])
36 | self.assertEqual(admin_user.admin, True)
37 | self.assertEqual(user.admin, False)
38 | init_db(create_app())
39 | admin_user.reload()
40 | self.assertEqual(admin_user.admin, True)
41 | # 测试其他用户权限不受影响
42 | user.reload()
43 | self.assertEqual(user.admin, False)
44 |
--------------------------------------------------------------------------------
/app/decorators/file.py:
--------------------------------------------------------------------------------
1 | from functools import wraps
2 | from typing import TYPE_CHECKING
3 |
4 | from app.exceptions import (
5 | FileNotActivatedError,
6 | FileTypeNotSupportError,
7 | gettext,
8 | )
9 | from app.constants.file import FileType
10 |
11 | if TYPE_CHECKING:
12 | from app.models.file import File
13 |
14 |
15 | def need_activated(func):
16 | """必须激活的File才能进行操作"""
17 |
18 | @wraps(func)
19 | def wrapper(self: "File", *args, **kwargs):
20 | if not self.activated:
21 | raise FileNotActivatedError
22 | return func(self, *args, **kwargs)
23 |
24 | return wrapper
25 |
26 |
27 | def only(file_type: int):
28 | def decorator(func):
29 | """只允许某类型使用的函数,供File模型使用"""
30 |
31 | @wraps(func)
32 | def wrapper(self: "File", *args, **kwargs):
33 | if self.type != file_type:
34 | raise FileTypeNotSupportError(str(func))
35 | return func(self, *args, **kwargs)
36 |
37 | return wrapper
38 |
39 | return decorator
40 |
41 |
42 | def only_file(func):
43 | """只允许非FOLDER使用的函数,供File模型使用"""
44 |
45 | @wraps(func)
46 | def wrapper(self: "File", *args, **kwargs):
47 | if self.type == FileType.FOLDER:
48 | raise FileTypeNotSupportError(gettext("不能对文件夹执行 ") + func.__name__)
49 | return func(self, *args, **kwargs)
50 |
51 | return wrapper
52 |
53 |
54 | def only_folder(func):
55 | """只允许FOLDER使用的函数,供File模型使用"""
56 |
57 | @wraps(func)
58 | def wrapper(self: "File", *args, **kwargs):
59 | if self.type != FileType.FOLDER:
60 | raise FileTypeNotSupportError(gettext("不能对文件执行 ") + str(func))
61 | return func(self, *args, **kwargs)
62 |
63 | return wrapper
64 |
--------------------------------------------------------------------------------
/app/constants/project.py:
--------------------------------------------------------------------------------
1 | from flask_babel import lazy_gettext
2 |
3 | from app.constants.base import IntType
4 |
5 |
6 | class ProjectStatus(IntType):
7 | """项目状态"""
8 |
9 | WORKING = 0 # 进行中
10 | FINISHED = 1 # 已完结
11 | PLAN_FINISH = 2 # 处于完结计划(准备删除这个状态)
12 | PLAN_DELETE = 3 # 处于销毁计划(准备删除这个状态)
13 | DELETED = 4 # 已删除(标记删除功能还未实现)
14 |
15 | details = {
16 | "WORKING": {"name": lazy_gettext("进行中")},
17 | "FINISHED": {"name": lazy_gettext("已完结")},
18 | "PLAN_FINISH": {"name": lazy_gettext("等待完结")},
19 | "PLAN_DELETE": {"name": lazy_gettext("等待销毁")},
20 | "DELETED": {"name": lazy_gettext("已删除")},
21 | }
22 |
23 |
24 | class ImportFromLabelplusStatus(IntType):
25 | PENDING = 0 # 排队中
26 | RUNNING = 1 # 进行中
27 | SUCCEEDED = 2 # 成功
28 | ERROR = 3 # 错误
29 |
30 | details = {
31 | "PENDING": {"name": lazy_gettext("排队中")},
32 | "RUNNING": {"name": lazy_gettext("进行中")},
33 | "SUCCEEDED": {"name": lazy_gettext("成功")},
34 | "ERROR": {"name": lazy_gettext("错误")},
35 | }
36 |
37 |
38 | class ImportFromLabelplusErrorType(IntType):
39 | UNKNOWN = 0 # 未知
40 | NO_TARGET = 1 # 运行时,没有的翻译目标
41 | NO_CREATOR = 2 # 项目没有创建人
42 | PARSE_FAILED = 3 # 解析失败
43 |
44 | details = {
45 | "UNKNOWN": {
46 | "name": lazy_gettext(
47 | "从 Labelplus 文本导入中断,请重试,如仍出现同样错误,请联系开发团队"
48 | )
49 | },
50 | "NO_TARGET": {
51 | "name": lazy_gettext("从 Labelplus 文本导入时,没有有效的翻译目标语言")
52 | },
53 | "NO_CREATOR": {"name": lazy_gettext("从 Labelplus 文本导入时,项目没有创建人")},
54 | "PARSE_FAILED": {
55 | "name": lazy_gettext("Labelplus 文本解析失败,请联系开发团队")
56 | },
57 | }
58 |
--------------------------------------------------------------------------------
/app/models/site_setting.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from mongoengine import (
3 | Document,
4 | ListField,
5 | BooleanField,
6 | StringField,
7 | ObjectIdField,
8 | )
9 |
10 | logger = logging.getLogger(__name__)
11 | logger.setLevel(logging.INFO)
12 |
13 |
14 | class SiteSetting(Document):
15 | """
16 | This document only have one document, of which the type is 'site'.
17 | """
18 |
19 | type = StringField(db_field="n", required=True, unique=True)
20 | enable_whitelist = BooleanField(db_field="ew", default=True)
21 | whitelist_emails = ListField(StringField(), db_field="we", default=list)
22 | only_allow_admin_create_team = BooleanField(db_field="oacg", default=True)
23 | auto_join_team_ids = ListField(ObjectIdField(), db_field="ajt", default=list)
24 | homepage_html = StringField(db_field="h", default="")
25 | homepage_css = StringField(db_field="hc", default="")
26 |
27 | meta = {
28 | "indexes": [
29 | "type",
30 | ]
31 | }
32 |
33 | @classmethod
34 | def init_site_setting(cls):
35 | if cls.objects(type="site").count() > 0:
36 | logger.debug("已有站点设置,跳过初始化")
37 | else:
38 | logger.debug("初始化站点设置")
39 | cls(type="site").save()
40 |
41 | @classmethod
42 | def get(cls) -> "SiteSetting":
43 | return cls.objects(type="site").first()
44 |
45 | def to_api(self):
46 | return {
47 | "enable_whitelist": self.enable_whitelist,
48 | "whitelist_emails": self.whitelist_emails,
49 | "only_allow_admin_create_team": self.only_allow_admin_create_team,
50 | "auto_join_team_ids": [str(id) for id in self.auto_join_team_ids],
51 | "homepage_html": self.homepage_html,
52 | "homepage_css": self.homepage_css,
53 | }
54 |
--------------------------------------------------------------------------------
/app/utils/labelplus.py:
--------------------------------------------------------------------------------
1 | import re
2 | from typing import List, TypedDict
3 |
4 |
5 | class LPLabel(TypedDict):
6 | x: float
7 | y: float
8 | position_type: int
9 | translation: str
10 |
11 |
12 | class LPFile(TypedDict):
13 | file_name: str
14 | labels: List[LPLabel]
15 |
16 |
17 | def load_from_labelplus(labelplus: str) -> List[LPFile]:
18 | files: List[LPFile] = []
19 | lines = labelplus.splitlines()
20 | file_index = -1
21 | label_index = -1
22 | for line in lines:
23 | # 检测文件行
24 | file_match = re.match(r".+>>>\[(.+)\]<<<.+", line)
25 | if file_match and file_match.group(1):
26 | file_name = file_match.group(1)
27 | files.append({"file_name": file_name, "labels": []})
28 | file_index += 1
29 | label_index = -1
30 | continue
31 | # 检测标签行
32 | label_match = re.match(r".+---\[.+\]---.+\[(.+),(.+),(.+)\]", line)
33 | if label_match and label_match.group(1):
34 | label_x = label_match.group(1)
35 | label_y = label_match.group(2)
36 | position_type = label_match.group(3)
37 | files[file_index]["labels"].append(
38 | {
39 | "x": float(label_x),
40 | "y": float(label_y),
41 | "position_type": int(position_type),
42 | "translation": "",
43 | }
44 | )
45 | label_index += 1
46 | continue
47 | if file_index > -1 and label_index > -1:
48 | files[file_index]["labels"][label_index]["translation"] += line + "\n"
49 | for file in files:
50 | for label in file["labels"]:
51 | if label["translation"].endswith("\n"):
52 | label["translation"] = label["translation"][0:-1]
53 | return files
54 |
--------------------------------------------------------------------------------
/.github/workflows/build-image.yml:
--------------------------------------------------------------------------------
1 | name: Build Docker image
2 |
3 | on:
4 | workflow_dispatch:
5 | pull_request:
6 | push:
7 | tags:
8 | - "v**"
9 | branches:
10 | - main
11 |
12 | env:
13 | REGISTRY: ghcr.io
14 | IMAGE_NAME: ${{ github.repository }}
15 |
16 | jobs:
17 | build-and-push-image:
18 | runs-on: ubuntu-latest
19 |
20 | permissions:
21 | contents: read
22 | packages: write
23 |
24 | steps:
25 | - name: Checkout repository
26 | uses: actions/checkout@v3
27 |
28 | - name: Log in to the Container registry
29 | uses: docker/login-action@v2.0.0
30 | with:
31 | registry: ${{ env.REGISTRY }}
32 | username: ${{ github.actor }}
33 | password: ${{ secrets.GITHUB_TOKEN }}
34 |
35 | - name: Extract metadata (tags, labels) for Docker
36 | id: meta
37 | uses: docker/metadata-action@v5.5.1
38 | with:
39 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
40 | tags: | # almost same as action default
41 | type=ref,event=pr
42 | type=ref,event=tag,pattern={{raw}}
43 | # type=ref,event=workflow_dispatch
44 | type=sha,event=workflow_dispatch
45 |
46 | - name: Setup docker buildx
47 | uses: docker/setup-buildx-action@v2
48 |
49 | - name: Build and push Docker image
50 | uses: docker/build-push-action@v6.9.0
51 | with:
52 | context: .
53 | push: ${{ github.event_name != 'pull_request' }}
54 | build-args:
55 | MOEFLOW_BUILD_ID=${{ github.repository }}:${{ github.ref }}:${{ github.sha }}
56 | cache-from:
57 | type=gha
58 | cache-to:
59 | type=gha
60 | platforms: linux/arm64,linux/amd64
61 | tags: ${{ steps.meta.outputs.tags }}
62 | labels: ${{ steps.meta.outputs.labels }}
63 |
--------------------------------------------------------------------------------
/app/models/message.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | from flask_babel import gettext
4 | from mongoengine import (
5 | Document,
6 | StringField,
7 | ReferenceField,
8 | DateTimeField,
9 | IntField,
10 | BooleanField,
11 | )
12 |
13 | from app.exceptions.message import MessageTypeError
14 | from app.constants.message import MessageType
15 | from typing import TYPE_CHECKING
16 |
17 | if TYPE_CHECKING:
18 | from app.models.user import User
19 |
20 |
21 | class Message(Document):
22 | sender: "User" = ReferenceField("User", db_field="s", required=True) # 发送者
23 | receiver: "User" = ReferenceField("User", db_field="r", required=True) # 接收人
24 | content = StringField(db_field="c", default="") # 内容
25 | type = IntField(
26 | db_field="t", required=True
27 | ) # 站内信类型,根据不同类型,前端显示不同
28 | read = BooleanField(db_field="rd", default=False) # 是否已读
29 | create_time = DateTimeField(db_field="ct", default=datetime.datetime.utcnow)
30 |
31 | @classmethod
32 | def send_system_message(
33 | cls, sender: "User", receiver: "User", content, message_type
34 | ):
35 | """发送系统消息"""
36 | if message_type not in [
37 | MessageType.SYSTEM,
38 | MessageType.INVITE,
39 | MessageType.APPLY,
40 | ]:
41 | raise MessageTypeError(gettext("非系统消息类型"))
42 | message = cls(
43 | sender=sender,
44 | receiver=receiver,
45 | content=content,
46 | type=message_type,
47 | ).save()
48 | return message
49 |
50 | @classmethod
51 | def send_user_message(cls, sender: "User", receiver: "User", content: str):
52 | """发送用户消息"""
53 | message = cls(
54 | sender=sender,
55 | receiver=receiver,
56 | content=content,
57 | type=MessageType.USER,
58 | ).save()
59 | return message
60 |
--------------------------------------------------------------------------------
/app/validators/term.py:
--------------------------------------------------------------------------------
1 | from marshmallow import fields, post_load
2 |
3 | from app.models.language import Language
4 | from app.validators.custom_message import required_message
5 | from app.validators.custom_schema import DefaultSchema
6 | from app.validators.custom_validate import (
7 | TermBankValidate,
8 | TermValidate,
9 | object_id,
10 | )
11 |
12 |
13 | class TermBankSchema(DefaultSchema):
14 | name = fields.Str(
15 | required=True,
16 | validate=[TermBankValidate.name_length],
17 | error_messages={**required_message},
18 | )
19 | tip = fields.Str(
20 | required=True,
21 | validate=[TermBankValidate.tip_length],
22 | error_messages={**required_message},
23 | )
24 | source_language_id = fields.Str(
25 | required=True,
26 | validate=[object_id],
27 | error_messages={**required_message},
28 | )
29 | target_language_id = fields.Str(
30 | required=True,
31 | validate=[object_id],
32 | error_messages={**required_message},
33 | )
34 |
35 | @post_load
36 | def to_model(self, in_data):
37 | """通过id获取模型,以供直接使用"""
38 | # 获取语言模型对象
39 | in_data["source_language"] = Language.by_id(in_data["source_language_id"])
40 | in_data["target_language"] = Language.by_id(in_data["target_language_id"])
41 | return in_data
42 |
43 |
44 | class TermSchema(DefaultSchema):
45 | source = fields.Str(
46 | required=True,
47 | validate=[TermValidate.source_length],
48 | error_messages={**required_message},
49 | )
50 | target = fields.Str(
51 | required=True,
52 | validate=[TermValidate.target_length],
53 | error_messages={**required_message},
54 | )
55 | tip = fields.Str(
56 | required=True,
57 | validate=[TermValidate.tip_length],
58 | error_messages={**required_message},
59 | )
60 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | env/
12 | build/
13 | develop-eggs/
14 | dist/
15 | downloads/
16 | eggs/
17 | .eggs/
18 | lib/
19 | lib64/
20 | parts/
21 | sdist/
22 | var/
23 | wheels/
24 | *.egg-info/
25 | .installed.cfg
26 | *.egg
27 |
28 | # PyInstaller
29 | # Usually these files are written by a python script from a template
30 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
31 | *.manifest
32 | *.spec
33 |
34 | # Installer logs
35 | pip-log.txt
36 | pip-delete-this-directory.txt
37 |
38 | # Unit test / coverage reports
39 | /report.html
40 | htmlcov/
41 | .tox/
42 | .coverage
43 | .coverage.*
44 | .cache
45 | nosetests.xml
46 | coverage.xml
47 | *,cover
48 | .hypothesis/
49 |
50 | # Translations
51 | *.mo
52 | *.pot
53 |
54 | # Django stuff:
55 | *.log
56 | local_settings.py
57 |
58 | # Flask stuff:
59 | instance/
60 | .webassets-cache
61 |
62 | # Scrapy stuff:
63 | .scrapy
64 |
65 | # Sphinx documentation
66 | docs/_build/
67 |
68 | # PyBuilder
69 | target/
70 |
71 | # Jupyter Notebook
72 | .ipynb_checkpoints
73 |
74 | # pyenv
75 | .python-version
76 |
77 | # celery beat schedule file
78 | celerybeat-schedule
79 |
80 | # SageMath parsed files
81 | *.sage.py
82 |
83 | # dotenv
84 | .env*
85 | !.*.sample
86 |
87 | # virtualenv
88 | .venv
89 | venv/
90 | ENV/
91 |
92 | # Spyder project settings
93 | .spyderproject
94 |
95 | # Rope project settings
96 | .ropeproject
97 |
98 | # IntelliJ project files
99 | .idea
100 | *.iml
101 | out
102 | gen
103 | /logs/log.txt
104 | /file/tmp/*
105 |
106 | # VSCode project files
107 | /.vscode
108 | /.pytest_cache
109 | /cov.xml
110 |
111 | # macOS
112 | .DS_Store
113 |
114 | # 配置文件
115 | /configs/
116 |
117 | # 临时文件夹
118 | /files/tmp/
119 |
120 | # 储存文件
121 | /storage/*
122 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | **/__pycache__/
3 | **/*.py[cod]
4 | **/*$py.class
5 |
6 | # C extensions
7 | **/*.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | env/
12 | build/
13 | develop-eggs/
14 | dist/
15 | downloads/
16 | eggs/
17 | .eggs/
18 | lib/
19 | lib64/
20 | parts/
21 | sdist/
22 | var/
23 | wheels/
24 | *.egg-info/
25 | .installed.cfg
26 | *.egg
27 |
28 | # PyInstaller
29 | # Usually these files are written by a python script from a template
30 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
31 | *.manifest
32 | *.spec
33 |
34 | # Installer logs
35 | pip-log.txt
36 | pip-delete-this-directory.txt
37 |
38 | # Unit test / coverage reports
39 | htmlcov/
40 | .tox/
41 | .coverage
42 | .coverage.*
43 | .cache
44 | nosetests.xml
45 | coverage.xml
46 | *,cover
47 | .hypothesis/
48 |
49 | # Translations
50 | *.mo
51 | *.pot
52 |
53 | # Django stuff:
54 | *.log
55 | local_settings.py
56 |
57 | # Flask stuff:
58 | instance/
59 | .webassets-cache
60 |
61 | # Scrapy stuff:
62 | .scrapy
63 |
64 | # Sphinx documentation
65 | docs/_build/
66 |
67 | # PyBuilder
68 | target/
69 |
70 | # Jupyter Notebook
71 | **/.ipynb_checkpoints
72 |
73 | # pyenv
74 | **/.python-version
75 |
76 | # celery beat schedule file
77 | celerybeat-schedule
78 |
79 | # SageMath parsed files
80 | **/*.sage.py
81 |
82 | # dotenv
83 | .env
84 |
85 | # virtualenv
86 | .venv
87 | venv/
88 | ENV/
89 |
90 | # Spyder project settings
91 | .spyderproject
92 |
93 | # Rope project settings
94 | .ropeproject
95 |
96 | # IntelliJ project files
97 | .idea
98 | *.iml
99 | out
100 | gen
101 | /logs/log.txt
102 | /docs/
103 | /file/tmp/*
104 |
105 | # VSCode project files
106 | **/.vscode
107 | **/.pytest_cache
108 | **/cov.xml
109 |
110 | # macOS
111 | **/.DS_Store
112 |
113 | # 配置文件
114 | /configs/
115 |
116 | # 临时文件夹
117 | /files/tmp/
118 |
119 | # .dockerignore 专属
120 | /.git/
--------------------------------------------------------------------------------
/app/validators/source.py:
--------------------------------------------------------------------------------
1 | from flask_babel import gettext
2 | from marshmallow import fields, validates_schema
3 | from marshmallow.validate import Range
4 |
5 | from app.validators.custom_message import required_message
6 | from app.validators.custom_schema import DefaultSchema
7 | from app.validators.custom_validate import need_in, object_id
8 | from flask_apikit.exceptions import ValidateError
9 | from app.constants.source import SourcePositionType
10 |
11 |
12 | class SourceSearchSchema(DefaultSchema):
13 | paging = fields.Bool(missing=True)
14 | target_id = fields.Str(required=True, validate=[object_id])
15 |
16 |
17 | class CreateImageSourceSchema(DefaultSchema):
18 | content = fields.Str(missing="")
19 | x = fields.Float(missing=0, validate=Range(min=0, max=1))
20 | y = fields.Float(missing=0, validate=Range(min=0, max=1))
21 | position_type = fields.Int(
22 | missing=SourcePositionType.IN, validate=[need_in(SourcePositionType.ids())]
23 | )
24 |
25 |
26 | class BatchSelectTranslationSchema(DefaultSchema):
27 | source_id = fields.Str(required=True, validate=[object_id])
28 | translation_id = fields.Str(required=True, validate=[object_id])
29 |
30 |
31 | class EditImageSourceSchema(DefaultSchema):
32 | content = fields.Str()
33 | x = fields.Float(validate=Range(min=0, max=1))
34 | y = fields.Float(validate=Range(min=0, max=1))
35 | vertices = fields.List(fields.Float())
36 | position_type = fields.Int(validate=[need_in(SourcePositionType.ids())])
37 |
38 | @validates_schema
39 | def verify_empty(self, data):
40 | if len(data) == 0:
41 | raise ValidateError(gettext("没有有效参数"))
42 |
43 |
44 | class EditImageSourceRankSchema(DefaultSchema):
45 | next_source_id = fields.Str(required=True, error_messages={**required_message})
46 |
47 | @validates_schema
48 | def verify_object_id(self, data):
49 | # 如果不是'end'则必须是object_id
50 | if data["next_source_id"] != "end":
51 | object_id(data["next_source_id"])
52 |
--------------------------------------------------------------------------------
/app/decorators/url.py:
--------------------------------------------------------------------------------
1 | from functools import wraps
2 | from typing import Optional
3 |
4 | from mongoengine.errors import ValidationError
5 |
6 | from app.exceptions import NotExistError, GroupTypeNotSupportError
7 | from app.models.project import Project
8 | from app.models.team import Team
9 | from app.utils.str import to_underscore
10 |
11 |
12 | def fetch_model(
13 | document: type, from_name: Optional[str] = None, to_name: Optional[str] = None
14 | ):
15 | """
16 | 从url的id中获取相对应的模型对象
17 |
18 | :param document: 从此Document中寻找
19 | :param from_name: url中的变量名,默认为 小写document名 + _id (如:team_id)
20 | :param to_name: 返回给函数的变量名,默认为 小写document名 (如:team)
21 | :return:
22 | """
23 | if from_name is None:
24 | from_name = to_underscore(document.__name__) + "_id"
25 | if to_name is None:
26 | to_name = to_underscore(document.__name__)
27 |
28 | def decorator(func):
29 | @wraps(func)
30 | def wrapper(*args, **kwargs):
31 | id = kwargs.pop(from_name)
32 | try:
33 | obj = document.objects(id=id).first()
34 | except ValidationError:
35 | obj = None
36 | if obj is None:
37 | raise NotExistError(document.__name__)
38 | return func(*args, **kwargs, **{to_name: obj})
39 |
40 | return wrapper
41 |
42 | return decorator
43 |
44 |
45 | def fetch_group(func):
46 | """
47 | 从url中,根据group_type和group_id获取group对象
48 | :return:
49 | """
50 |
51 | @wraps(func)
52 | def wrapper(*args, **kwargs):
53 | group_type = kwargs.pop("group_type")
54 | group_id = kwargs.pop("group_id")
55 | # 获取group
56 | if group_type == "teams":
57 | group = Team.objects(id=group_id).first()
58 | elif group_type == "projects":
59 | group = Project.objects(id=group_id).first()
60 | else:
61 | raise GroupTypeNotSupportError
62 | # 如果group不存在
63 | if group is None:
64 | raise NotExistError(group_type[:-1].capitalize())
65 | return func(*args, **kwargs, **{"group": group})
66 |
67 | return wrapper
68 |
--------------------------------------------------------------------------------
/.env.test.sample:
--------------------------------------------------------------------------------
1 | # Env variables for tests
2 | TESTING=YES
3 | LOG_LEVEL=DEBUG
4 |
5 | # site name
6 | SITE_NAME=萌翻TEST
7 | SITE_ORIGIN=https://test.moeflow.org
8 |
9 | # encrypt key of user sessions.
10 | SECRET_KEY=CHANGE_ME
11 |
12 | # auto-created admin user and password
13 | ADMIN_EMAIL=admin@moeflow.com
14 | ADMIN_INITIAL_PASSWORD=change_me
15 |
16 | # mongodb database
17 | MONGODB_URI="mongomock://moeflow:CHANGE_ME@127.0.0.1:27017/moeflow_test?authSource=admin"
18 |
19 | # celery job queue
20 | CELERY_BROKER_URL="amqp://moeflow:CHANGE_ME@127.0.0.1:5672/moeflow" # takes precedence over other RABBITMQ_* entries
21 |
22 | # -----------
23 | # Storage 配置
24 | # -----------
25 | STORAGE_TYPE=LOCAL_STORAGE
26 | # STORAGE_DOMAIN: 返回给客户端的图片URL前缀
27 | # 1. 如果STORAGE_TYPE为OSS
28 | # - 未设置自定义域名则填写阿里云提供的 OSS 域名,格式如:https://..aliyuncs.com/
29 | # - 如果绑定了 CDN 来加速 OSS,则填写绑定在 CDN 的域名
30 | # 2. 如果STORAGE_TYPE为LOCAL_STORAGE
31 | # - 本地储存填写绑定到服务器的域名+"/storage/",格式如:http(s)://.com/storage/,
32 | STORAGE_DOMAIN=http://127.0.0.1:5000/storage/
33 |
34 | # size limit when uploading file in MB
35 | MAX_CONTENT_LENGTH_MB=1024
36 |
37 | ## OSS_*: STORAGE_TYPE为OSS时的配置
38 | OSS_ACCESS_KEY_ID=
39 | OSS_ACCESS_KEY_SECRET=
40 | # OSS Endpoint(地域节点)
41 | # 含协议名,形如 https://oss-cn-shanghai.aliyuncs.com/
42 | OSS_ENDPOINT=
43 | OSS_BUCKET_NAME=
44 | # (可不修改) OSS 图片处理规则名称
45 | OSS_PROCESS_COVER_NAME=cover
46 | OSS_PROCESS_SAFE_CHECK_NAME=safe-check
47 |
48 | # -----------
49 | # CDN 配置
50 | # -----------
51 | # 如果绑定了 CDN 来加速 OSS,且开启了 CDN 的[阿里云 OSS 私有 Bucket 回源]和[URL 鉴权],
52 | # 此时需要设置 OSS_VIA_CDN = True,并设置 CDN URL 鉴权主/备 KEY
53 | OSS_VIA_CDN=True
54 | CDN_URL_KEY_A=
55 | CDN_URL_KEY_B=
56 |
57 | # -----------
58 | # Email 配置
59 | # -----------
60 | # 是否发送用户邮件(验证码等)
61 | ENABLE_USER_EMAIL=False
62 | # 是否发送日志邮件
63 | ENABLE_LOG_EMAIL=False
64 | # SMTP 服务器地址
65 | EMAIL_SMTP_HOST=
66 | # SMTP 服务器端口
67 | EMAIL_SMTP_PORT=
68 | # 是否使用 SSL 连接 SMTP 服务器
69 | EMAIL_USE_SSL=True
70 | # 发件邮箱地址
71 | EMAIL_ADDRESS=
72 | # SMTP 服务器登陆用户名,通常是邮箱全称
73 | EMAIL_USERNAME=
74 | # SMTP 服务器登陆密码
75 | EMAIL_PASSWORD=
76 | # 用户回信邮箱地址
77 | EMAIL_REPLY_ADDRESS=
78 | # 网站错误报告邮箱地址
79 | EMAIL_ERROR_ADDRESS=
80 |
--------------------------------------------------------------------------------
/app/apis/site_setting.py:
--------------------------------------------------------------------------------
1 | from app.core.views import MoeAPIView
2 | from app.decorators.auth import admin_required
3 | from app.models.site_setting import SiteSetting
4 | from app.validators.site_setting import SiteSettingSchema
5 |
6 |
7 | class SiteSettingAPI(MoeAPIView):
8 | @admin_required
9 | def get(self):
10 | """
11 | @api {get} /v1/site-setting 获取站点设置
12 | @apiVersion 1.0.0
13 | @apiName getSiteSettingAPI
14 | @apiGroup SiteSetting
15 | @apiUse APIHeader
16 | @apiUse TokenHeader
17 |
18 | @apiSuccessExample {json} 返回示例
19 | {
20 | "data": {
21 | "enable_whitelist": true,
22 | "whitelist_emails": [],
23 | }
24 | }
25 | """
26 | return SiteSetting.get().to_api()
27 |
28 | @admin_required
29 | def put(self):
30 | """
31 | @api {put} /v1/site-setting 修改站点设置
32 | @apiVersion 1.0.0
33 | @apiName putSiteSettingAPI
34 | @apiGroup SiteSetting
35 | @apiUse APIHeader
36 | @apiUse TokenHeader
37 |
38 | @apiParam {Boolean} enable_whitelist 是否开启白名单
39 | @apiParam {String[]} whitelist_emails 白名单邮箱列表
40 |
41 | @apiSuccessExample {json} 返回示例
42 | {
43 | "data": {
44 | "enable_whitelist": true,
45 | "whitelist_emails": [],
46 | }
47 | }
48 | """
49 | data = self.get_json(SiteSettingSchema())
50 | site_setting = SiteSetting.get()
51 | site_setting.enable_whitelist = data["enable_whitelist"]
52 | site_setting.whitelist_emails = data["whitelist_emails"]
53 | site_setting.only_allow_admin_create_team = data["only_allow_admin_create_team"]
54 | site_setting.auto_join_team_ids = data["auto_join_team_ids"]
55 | site_setting.homepage_html = data.get("homepage_html", "")
56 | site_setting.homepage_css = data.get("homepage_css", "")
57 | site_setting.save()
58 | site_setting.reload()
59 | return site_setting.to_api()
60 |
61 |
62 | class HomepageAPI(MoeAPIView):
63 | def get(self):
64 | return {
65 | "html": SiteSetting.get().homepage_html,
66 | "css": SiteSetting.get().homepage_css,
67 | }
68 |
--------------------------------------------------------------------------------
/app/validators/join_process.py:
--------------------------------------------------------------------------------
1 | from marshmallow import fields, post_load
2 |
3 | from app.exceptions import RoleNotExistError, UserNotExistError
4 | from app.models.user import User
5 | from app.validators.custom_schema import DefaultSchema
6 | from app.validators.custom_message import required_message
7 | from app.validators.custom_validate import JoinValidate, object_id
8 |
9 |
10 | class CreateInvitationSchema(DefaultSchema):
11 | user_id = fields.Str(
12 | required=True,
13 | validate=[object_id],
14 | error_messages={**required_message},
15 | )
16 | role_id = fields.Str(
17 | required=True,
18 | validate=[object_id],
19 | error_messages={**required_message},
20 | )
21 | message = fields.Str(
22 | required=True,
23 | validate=[JoinValidate.message_length],
24 | error_messages={**required_message},
25 | )
26 |
27 | @post_load
28 | def to_model(self, in_data):
29 | # 获取role和User
30 | in_data["role"] = (
31 | self.context["group"].role_cls.objects(id=in_data["role_id"]).first()
32 | )
33 | in_data["user"] = User.by_id(in_data["user_id"])
34 | # 如果缺少则抛出错误
35 | if in_data["role"] is None:
36 | raise RoleNotExistError
37 | if in_data["user"] is None:
38 | raise UserNotExistError
39 | return in_data
40 |
41 |
42 | class SearchInvitationSchema(DefaultSchema):
43 | status = fields.List(fields.Int(), missing=None)
44 |
45 |
46 | class SearchRelatedApplicationSchema(DefaultSchema):
47 | status = fields.List(fields.Int(), missing=None)
48 |
49 |
50 | class ChangeInvitationSchema(DefaultSchema):
51 | role_id = fields.Str(required=True, error_messages={**required_message})
52 |
53 |
54 | class CheckInvitationSchema(DefaultSchema):
55 | allow = fields.Boolean(required=True, error_messages={**required_message})
56 |
57 |
58 | class SearchApplicationSchema(DefaultSchema):
59 | status = fields.List(fields.Int(), missing=None)
60 |
61 |
62 | class CreateApplicationSchema(DefaultSchema):
63 | message = fields.Str(required=True, validate=[JoinValidate.message_length])
64 |
65 |
66 | class CheckApplicationSchema(DefaultSchema):
67 | allow = fields.Boolean(required=True, error_messages={**required_message})
68 |
--------------------------------------------------------------------------------
/app/scripts/fill_en_translations.py:
--------------------------------------------------------------------------------
1 | import dotenv
2 | from babel.messages.pofile import read_po, write_po
3 | import openai
4 | import logging
5 |
6 | dotenv.load_dotenv()
7 | logger = logging.getLogger(__name__)
8 | logger.setLevel(logging.DEBUG)
9 |
10 | client = openai.Client()
11 |
12 |
13 | def translate_text(text: str, target_language: str):
14 | response = client.chat.completions.create(
15 | model="gpt-4o",
16 | messages=[
17 | {
18 | "role": "user",
19 | "content": f"""You are a skillful multilanguage translatior specialized in UI i18n. Please translate the following text to {target_language}: {text}. Please only return the translated text and nothing else.""",
20 | }
21 | ],
22 | max_tokens=1000,
23 | temperature=0,
24 | )
25 | return response.choices[0].message.content
26 |
27 |
28 | def parse_and_translate_po(file_path: str, target_language: str, limit=0):
29 | # Read the .po file
30 | with open(file_path, "rb") as po_file:
31 | catalog = read_po(po_file)
32 |
33 | print(f"Translating {len(catalog)} messages to {target_language}")
34 |
35 | translated_count = 0
36 |
37 | # Translate each message
38 | for message in catalog:
39 | # print("message:", message.id, message.string)
40 | if message.id and not message.string:
41 | print(f"Translating '{message.id}'")
42 | try:
43 | translated_text = translate_text(str(message.id), target_language)
44 | message.string = translated_text
45 | logger.info(f"Translated '{message.id}' to '{message.string}'")
46 | translated_count += 1
47 | except Exception:
48 | break
49 | else:
50 | print(f"Skipping '{message.id}'")
51 | if limit and translated_count >= limit:
52 | break
53 |
54 | # Write the updated catalog back to the .po file
55 | with open(file_path, "wb") as po_file:
56 | write_po(po_file, catalog)
57 |
58 |
59 | def po_path_for_language(language_code):
60 | return f"app/translations/{language_code}/LC_MESSAGES/messages.po"
61 |
62 |
63 | if __name__ == "__main__":
64 | for lang in ["en"]:
65 | po_file_path = po_path_for_language(lang)
66 | parse_and_translate_po(po_file_path, lang, limit=500)
67 |
--------------------------------------------------------------------------------
/app/translations/__init__.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from typing import Optional
3 | from flask import g, request, has_request_context
4 | from app.constants.locale import Locale
5 | import flask_babel
6 | import app.config as _app_config
7 |
8 | logger = logging.getLogger(__name__)
9 | logger.setLevel(logging.INFO)
10 |
11 |
12 | def get_locale() -> Optional[str]:
13 | if has_request_context():
14 | # true within a request
15 | return get_request_locale()
16 |
17 | # true in job worker
18 | return None
19 |
20 |
21 | def get_request_locale() -> Optional[str]:
22 | current_user = g.get("current_user")
23 | req_header = f"{request.method} {request.path}"
24 | if (
25 | current_user
26 | and current_user.locale
27 | and current_user.locale != "auto"
28 | and current_user.locale in Locale.ids()
29 | ):
30 | # NOTE User.locale is not used , so this won't get called
31 | logging.debug(
32 | "%s set locale=%s from user pref", req_header, current_user.locale
33 | )
34 | return current_user.locale
35 | # "zh" locale asssets is created from hardcoded strings
36 | # "en" locale are machine translated
37 | best_match = request.accept_languages.best_match(["zh", "en"], default="en")
38 | logging.debug("%s set locale=%s from req", req_header, best_match)
39 | return best_match
40 |
41 |
42 | # @babel.timezoneselector
43 | # def get_timezone():
44 | # # TODO 弄清 timezone 是什么东西
45 | # current_user = g.get('current_user')
46 | # if current_user:
47 | # if current_user.timezone:
48 | # return current_user.timezone
49 |
50 |
51 | def gettext(msgid: str):
52 | translated = flask_babel.gettext(msgid)
53 | logger.debug(
54 | f"get_text({msgid}, locale={flask_babel.get_locale()}) -> {translated}"
55 | )
56 | return translated
57 |
58 |
59 | def lazy_gettext(msgid: str):
60 | translated = flask_babel.LazyString(lambda: gettext(msgid))
61 | # logger.debug(f"lazy_get_text({msgid}) -> {translated}")
62 | return translated
63 |
64 |
65 | def hardcode_text(msgid: str) -> str:
66 | """
67 | used to capture hardcoded string as msgid
68 | """
69 | return msgid
70 |
71 |
72 | def server_gettext(msgid: str):
73 | with flask_babel.force_locale(_app_config.BABEL_DEFAULT_LOCALE):
74 | return gettext(msgid)
75 |
--------------------------------------------------------------------------------
/tests/model/test_language_model.py:
--------------------------------------------------------------------------------
1 | from app.models.language import Language
2 | from app.models.team import Team
3 | from tests import MoeTestCase
4 |
5 |
6 | class LanguageModelTestCase(MoeTestCase):
7 | def test_no_space_languages(self):
8 | """中文肯定在无空格语言中"""
9 | self.assertTrue(Language.by_code("zh-CN").no_space)
10 | self.assertFalse(Language.by_code("en").no_space)
11 |
12 | def test_g_code(self):
13 | """测试谷歌翻译和OCR"""
14 | # 中文都支持
15 | self.assertTrue(Language.by_code("zh-CN").g_tra)
16 | self.assertTrue(Language.by_code("zh-CN").g_ocr)
17 | # 梵文只支持ocr
18 | self.assertFalse(Language.by_code("sa").g_tra)
19 | self.assertTrue(Language.by_code("sa").g_ocr)
20 | # 巽他语支持tra
21 | self.assertTrue(Language.by_code("su").g_tra)
22 | self.assertFalse(Language.by_code("su").g_ocr)
23 |
24 | def test_get_languages(self):
25 | """测试获取languages"""
26 | Team.create("t1")
27 | Team.create("t2")
28 | language_count = Language.objects.count()
29 | # 获取所有语言
30 | self.assertEqual(language_count, Language.get().count())
31 | # 新增了两个语言
32 | Language.create("code1", "test_language", "c", sort=10) # 创建系统语言
33 | Language.create("code2", "test_language", "c") # 创建项目语言
34 | self.assertEqual(language_count + 2, Language.objects.count())
35 | self.assertEqual(2, Language.objects(en_name="test_language").count())
36 |
37 | def test_delete_project(self):
38 | """测试删除项目会同时删除相应Language"""
39 | team1 = Team.create("t1")
40 | team2 = Team.create("t2")
41 | language_count = Language.objects.count()
42 | # 新增了两个语言
43 | Language.create("code1", "test_language", "c") # 创建系统语言
44 | Language.create("code2", "test_language", "c") # 创建项目语言
45 | self.assertEqual(language_count + 2, Language.objects.count())
46 | self.assertEqual(2, Language.objects(en_name="test_language").count())
47 | # 删除团队2,没有影响
48 | team2.clear()
49 | self.assertEqual(language_count + 2, Language.objects.count())
50 | self.assertEqual(2, Language.objects(en_name="test_language").count())
51 | # 删除团队1,没有影响
52 | team1.clear()
53 | self.assertEqual(language_count + 2, Language.objects.count())
54 | self.assertEqual(2, Language.objects(en_name="test_language").count())
55 |
--------------------------------------------------------------------------------
/manage.py:
--------------------------------------------------------------------------------
1 | import os
2 | import re
3 | import click
4 | import logging
5 | from app import flask_app
6 | from app.factory import init_db
7 |
8 | logging.basicConfig(
9 | level=logging.INFO,
10 | format="%(asctime)s %(levelname)s %(name)s %(message)s",
11 | datefmt="%Y-%m-%dT%H:%M:%S%z",
12 | force=True,
13 | )
14 |
15 | logger = logging.getLogger(__name__)
16 | logger.setLevel(logging.DEBUG)
17 |
18 |
19 | @click.group()
20 | def main():
21 | pass
22 |
23 |
24 | @click.command()
25 | def docs():
26 | """
27 | 需要安装apidoc, `npm install apidoc -g`
28 | """
29 | os.system("apidoc -i app/ -o docs/")
30 |
31 |
32 | @click.command()
33 | def migrate():
34 | """
35 | Initialize the database
36 | """
37 | init_db(flask_app)
38 |
39 |
40 | @click.command()
41 | def list_translations():
42 | from app.factory import babel
43 |
44 | with flask_app.app_context():
45 | print(babel.list_translations())
46 |
47 |
48 | @click.command("mit_file")
49 | @click.option("--file", help="path to image file")
50 | def mit_preprocess_file(file: str):
51 | from app.tasks.mit import preprocess_mit, MitPreprocessedImage
52 |
53 | proprocessed = preprocess_mit.delay(file, "CHT")
54 | proprocessed_result: dict = proprocessed.get()
55 |
56 | print("proprocessed", proprocessed_result)
57 | print("proprocessed", MitPreprocessedImage.from_dict(proprocessed_result))
58 |
59 |
60 | @click.command("mit_dir")
61 | @click.option("--dir", help="absolute path to a dir containing image files")
62 | def mit_preprocess_dir(dir: str):
63 | from app.tasks.mit import preprocess_mit, MitPreprocessedImage
64 |
65 | for file in os.listdir(dir):
66 | if not re.match(r".*\.(jpg|png|jpeg)$", file):
67 | continue
68 | full_path = os.path.join(dir, file)
69 | proprocessed = preprocess_mit.delay(full_path, "CHT")
70 | proprocessed_result = MitPreprocessedImage.from_dict(proprocessed.get())
71 |
72 | print("proprocessed", proprocessed_result)
73 | for q in proprocessed_result.text_quads:
74 | print("text block", q.pts)
75 | print(" ", q.raw_text)
76 | print(" ", q.translated)
77 |
78 |
79 | main.add_command(docs)
80 | main.add_command(migrate)
81 | main.add_command(list_translations)
82 | main.add_command(mit_preprocess_file)
83 | main.add_command(mit_preprocess_dir)
84 |
85 | if __name__ == "__main__":
86 | main()
87 |
--------------------------------------------------------------------------------
/app/exceptions/file.py:
--------------------------------------------------------------------------------
1 | from flask_babel import lazy_gettext, gettext
2 |
3 | from app.exceptions import MoeError
4 |
5 |
6 | class FileRootError(MoeError):
7 | """
8 | @apiDefine FileRootError
9 | @apiError 8000 文件异常
10 | """
11 |
12 | code = 8000
13 | message = lazy_gettext("文件异常")
14 |
15 |
16 | class FileNotExistError(FileRootError):
17 | """
18 | @apiDefine FileNotExistError
19 | @apiError 8001 文件/文件夹不存在,可能已被删除
20 | """
21 |
22 | code = 8001
23 | message = lazy_gettext("文件/文件夹不存在,可能已被删除")
24 |
25 |
26 | class FolderNotExistError(FileRootError):
27 | """
28 | @apiDefine FolderNotExistError
29 | @apiError 8002 文件夹不存在,可能已被删除
30 | """
31 |
32 | code = 8002
33 | message = lazy_gettext("文件夹不存在,可能已被删除")
34 |
35 |
36 | class SuffixNotInFileTypeError(FileRootError):
37 | """
38 | @apiDefine SuffixNotInFileTypeError
39 | @apiError 8003 后缀名必须属于原文件的文件类型
40 | """
41 |
42 | code = 8003
43 | message = lazy_gettext("后缀名必须属于原文件的文件类型")
44 |
45 |
46 | class SourceFileNotExist(FileRootError):
47 | """
48 | @apiDefine SourceFileNotExist
49 | @apiError 8004 源文件不存在
50 | """
51 |
52 | code = 8004
53 | message = lazy_gettext("源文件不存在")
54 |
55 | def __init__(self, file_not_exist_reason=None):
56 | from app.constants.file import FileNotExistReason
57 |
58 | message = ""
59 | if file_not_exist_reason == FileNotExistReason.UNKNOWN:
60 | message = gettext("未知")
61 | elif file_not_exist_reason == FileNotExistReason.NOT_UPLOAD:
62 | message = gettext("待上传")
63 | elif file_not_exist_reason == FileNotExistReason.FINISH:
64 | message = gettext("用户操作完结时清除")
65 | elif file_not_exist_reason == FileNotExistReason.BLOCK:
66 | message = gettext("含有敏感信息已删除")
67 | if message:
68 | self.message = f"{self.message}: {message}"
69 |
70 |
71 | class SourceNotExistError(FileRootError):
72 | """
73 | @apiDefine SourceNotExistError
74 | @apiError 8005 原文不存在,已被删除
75 | """
76 |
77 | code = 8005
78 | message = lazy_gettext("原文不存在,已被删除")
79 |
80 |
81 | class SourceMovingError(FileRootError):
82 | """
83 | @apiDefine SourceMovingError
84 | @apiError 8006 正在移动顺序,请稍后尝试
85 | """
86 |
87 | code = 8006
88 | message = lazy_gettext("正在移动顺序,请稍后尝试")
89 |
90 |
91 | class TranslationNotUniqueError(FileRootError):
92 | """
93 | @apiDefine TranslationNotUniqueError
94 | @apiError 8007 翻译不唯一
95 | """
96 |
97 | code = 8007
98 | message = lazy_gettext("翻译不唯一")
99 |
--------------------------------------------------------------------------------
/.env.sample:
--------------------------------------------------------------------------------
1 | # Template of moeflow-backend env variables / 萌翻Backend的环境变量模板
2 | # values like `change_me` SHOULD be changed. They are related to security or basic functionality.
3 | # 模板中值为 CHANGE_ME 的设置涉及安全或基础功能,请一定在部署时修改
4 |
5 | # site name
6 | SITE_NAME=Moeflow
7 | SITE_ORIGIN=https://change_me.com
8 |
9 | # encrypt key of user sessions.
10 | SECRET_KEY=CHANGE_ME
11 |
12 | # auto-created admin user and password
13 | ADMIN_EMAIL=change_me@change_me.com
14 | ADMIN_INITIAL_PASSWORD=change_me
15 |
16 | # i18n locale for server-context
17 | BABEL_DEFAULT_LOCALE=en
18 |
19 | # mongodb database
20 | MONGODB_URI='mongodb://moeflow:PLEASE_CHANGE_THIS@moeflow-mongodb:27017/moeflow?authSource=admin'
21 |
22 | # celery job queue
23 | CELERY_BROKER_URL="amqp://moeflow:PLEASE_CHANGE_THIS@moeflow-rabbitmq:5672/moeflow"
24 |
25 | # -----------
26 | # Storage 配置
27 | # -----------
28 | # 目前支持 LOCAL_STORAGE 和 OSS
29 | STORAGE_TYPE=LOCAL_STORAGE
30 | # STORAGE_DOMAIN: 返回给客户端的图片URL前缀
31 | # 1. 如果STORAGE_TYPE为OSS
32 | # - 未设置自定义域名则填写阿里云提供的 OSS 域名,格式如:https://..aliyuncs.com/
33 | # - 如果绑定了 CDN 来加速 OSS,则填写绑定在 CDN 的域名
34 | # 2. 如果STORAGE_TYPE为LOCAL_STORAGE
35 | # - 本地储存填写绑定到服务器的域名+"/storage/",格式如:http(s)://.com/storage/,
36 | STORAGE_DOMAIN=https://change_me.org/storage/
37 |
38 | # size limit when uploading file in MB
39 | MAX_CONTENT_LENGTH_MB=1024
40 |
41 | ## OSS_*: STORAGE_TYPE为OSS时的配置
42 | OSS_ACCESS_KEY_ID=
43 | OSS_ACCESS_KEY_SECRET=
44 | # OSS Endpoint(地域节点)
45 | # 含协议名,形如 https://oss-cn-shanghai.aliyuncs.com/
46 | OSS_ENDPOINT=
47 | OSS_BUCKET_NAME=
48 | # (可不修改) OSS 图片处理规则名称
49 | OSS_PROCESS_COVER_NAME=cover
50 | OSS_PROCESS_SAFE_CHECK_NAME=safe-check
51 |
52 | # -----------
53 | # CDN 配置
54 | # -----------
55 | # 如果绑定了 CDN 来加速 OSS,且开启了 CDN 的[阿里云 OSS 私有 Bucket 回源]和[URL 鉴权],
56 | # 此时需要设置 OSS_VIA_CDN = True,并设置 CDN URL 鉴权主/备 KEY
57 | OSS_VIA_CDN=True
58 | CDN_URL_KEY_A=
59 | CDN_URL_KEY_B=
60 |
61 | # -----------
62 | # Email 配置
63 | # -----------
64 | # 是否发送用户邮件(验证码等)
65 | ENABLE_USER_EMAIL=False
66 | # 是否发送日志邮件
67 | ENABLE_LOG_EMAIL=False
68 | # SMTP 服务器地址
69 | EMAIL_SMTP_HOST=
70 | # SMTP 服务器端口
71 | EMAIL_SMTP_PORT=
72 | # 是否使用 SSL 连接 SMTP 服务器
73 | EMAIL_USE_SSL=True
74 | # 发件邮箱地址
75 | EMAIL_ADDRESS=
76 | # SMTP 服务器登陆用户名,通常是邮箱全称
77 | EMAIL_USERNAME=
78 | # SMTP 服务器登陆密码
79 | EMAIL_PASSWORD=
80 | # 用户回信邮箱地址
81 | EMAIL_REPLY_ADDRESS=
82 | # 网站错误报告邮箱地址
83 | EMAIL_ERROR_ADDRESS=
84 |
85 | # Options for experimental features. only enable if you know what you are doing.
86 | # MIT_STORAGE_ROOT=/app/storage
87 | # CELERY_BACKEND_URL='mongodb://moeflow:PLEASE_CHANGE_THIS@moeflow-mongodb:27017/moeflow?authSource=admin'
88 |
--------------------------------------------------------------------------------
/app/tasks/output_team_projects.py:
--------------------------------------------------------------------------------
1 | """
2 | 导出项目
3 | """
4 |
5 | import datetime
6 | from flask import Flask
7 |
8 | from app import celery
9 |
10 | from app.constants.output import OutputTypes
11 | from app.constants.project import ProjectStatus
12 | from app.models import connect_db
13 | from app.tasks.output_project import output_project
14 | from . import SyncResult
15 | from celery.utils.log import get_task_logger
16 |
17 | logger = get_task_logger(__name__)
18 |
19 |
20 | @celery.task(name="tasks.output_team_projects_task")
21 | def output_team_projects_task(team_id, current_user_id):
22 | """
23 | 创建团队的所有项目的导出任务
24 |
25 | :param team_id: 团队Id
26 | :return:
27 | """
28 | from app.models.file import File
29 | from app.models.project import Project
30 | from app.models.output import Output
31 | from app.models.team import Team
32 | from app.models.target import Target
33 | from app.models.user import User
34 |
35 | (File, Project, Team, Target, User)
36 | connect_db(celery.conf.app_config)
37 | app = Flask(__name__)
38 | app.config.from_object(celery.conf.app_config)
39 |
40 | OUTPUT_WAIT_SECONDS = celery.conf.app_config.get("OUTPUT_WAIT_SECONDS", 60 * 5)
41 | current_user = User.by_id(current_user_id)
42 |
43 | team = Team.by_id(team_id)
44 | for project in team.projects(status=ProjectStatus.WORKING):
45 | for target in project.targets():
46 | # 等待一定时间后允许再次导出
47 | last_output = target.outputs().first()
48 | if last_output and (
49 | datetime.datetime.utcnow() - last_output.create_time
50 | < datetime.timedelta(seconds=OUTPUT_WAIT_SECONDS)
51 | ):
52 | continue
53 | # 删除三个导出之前的
54 | old_targets = target.outputs().skip(2)
55 | Output.delete_real_files(old_targets)
56 | old_targets.delete()
57 | # 创建新target
58 | output = Output.create(
59 | project=project,
60 | target=target,
61 | user=current_user,
62 | type=OutputTypes.ALL,
63 | )
64 | output_project(str(output.id))
65 |
66 | return f"成功:已创建 Team <{str(team.id)}> 所有项目的导出任务"
67 |
68 |
69 | def output_team_projects(team_id, current_user_id, /, *, run_sync=False):
70 | alive_workers = celery.control.ping()
71 | if len(alive_workers) == 0 or run_sync:
72 | # 同步执行
73 | output_team_projects_task(team_id, current_user_id)
74 | return SyncResult()
75 | else:
76 | # 异步执行
77 | return output_team_projects_task.delay(team_id, current_user_id)
78 |
--------------------------------------------------------------------------------
/app/apis/avatar.py:
--------------------------------------------------------------------------------
1 | """
2 | 关于用户个人的API
3 | """
4 |
5 | from bson import ObjectId
6 | from flask import current_app, request
7 |
8 | from app import oss
9 | from app.core.views import MoeAPIView
10 | from app.decorators.auth import token_required
11 | from app.exceptions import UploadFileNotFoundError
12 | from app.models.team import Team, TeamPermission
13 | from flask_babel import gettext, lazy_gettext
14 | from app.utils.logging import logger
15 | import oss2
16 | from app.exceptions.base import NoPermissionError, RequestDataWrongError
17 |
18 |
19 | class AvatarAPI(MoeAPIView):
20 | @token_required
21 | def put(self):
22 | """@apiDeprecated
23 | @api {put} /v1/avatar 修改头像
24 | @apiVersion 1.0.0
25 | @apiName change_avatar
26 | @apiGroup Me
27 | @apiUse APIHeader
28 | @apiUse TokenHeader
29 |
30 | @apiParam {File} file 头像文件
31 | @apiParam {string} type 类型
32 | @apiParam {ObjectId} id ID
33 |
34 | @apiSuccess {String} avatar 头像地址
35 | @apiSuccessExample {json} 返回示例
36 | {
37 | "avatar": "http://moeflow.com/avatar/AbcDEF123.jpg"
38 | }
39 |
40 | @apiUse ValidateError
41 | """
42 | file = request.files.get("file")
43 | owner_type = request.form.get("type")
44 | owner_id = request.form.get("id")
45 | if not file:
46 | raise UploadFileNotFoundError("请选择图片")
47 | if owner_type == "user":
48 | avatar_prefix = current_app.config["OSS_USER_AVATAR_PREFIX"]
49 | avatar_owner = self.current_user
50 | elif owner_type == "team":
51 | avatar_prefix = current_app.config["OSS_TEAM_AVATAR_PREFIX"]
52 | avatar_owner = Team.by_id(owner_id)
53 | if not self.current_user.can(avatar_owner, TeamPermission.CHANGE):
54 | raise NoPermissionError
55 | else:
56 | raise RequestDataWrongError(lazy_gettext("不支持的头像类型"))
57 | if owner_type != "user" and owner_id is None:
58 | raise RequestDataWrongError(lazy_gettext("缺少id"))
59 | filename = str(ObjectId()) + ".jpg"
60 | oss.upload(avatar_prefix, filename, file)
61 | # 删除旧的头像
62 | if avatar_owner.has_avatar():
63 | try:
64 | oss.delete(
65 | avatar_prefix,
66 | avatar_owner._avatar,
67 | )
68 | except oss2.exceptions.NoSuchKey as e:
69 | logger.error(e)
70 | except Exception as e:
71 | logger.error(e)
72 | # 设置新的头像
73 | avatar_owner.avatar = filename
74 | avatar_owner.save()
75 | return {"message": gettext("修改成功"), "avatar": avatar_owner.avatar}
76 |
--------------------------------------------------------------------------------
/app/tasks/thumbnail.py:
--------------------------------------------------------------------------------
1 | """
2 | 导出项目
3 | """
4 |
5 | import os
6 | from PIL import Image, ImageOps
7 |
8 | from app import STORAGE_PATH, celery
9 | from app.constants.storage import StorageType
10 | from app.exceptions.file import FileNotExistError
11 | from app import oss
12 |
13 | from app.models import connect_db
14 | from . import SyncResult
15 | from celery.utils.log import get_task_logger
16 |
17 | logger = get_task_logger(__name__)
18 |
19 |
20 | @celery.task(name="tasks.create_thumbnail_task")
21 | def create_thumbnail_task(image_id: str):
22 | """
23 | 压缩整个项目
24 |
25 | :param project_id: 项目ID
26 | :return:
27 | """
28 | from app.models.file import File
29 | from app.models.project import Project
30 | from app.models.output import Output
31 | from app.models.team import Team
32 | from app.models.target import Target
33 | from app.models.user import User
34 |
35 | (File, Project, Team, Target, User, Output)
36 |
37 | oss_file_prefix = celery.conf.app_config["OSS_FILE_PREFIX"]
38 | connect_db(celery.conf.app_config)
39 | oss.init(celery.conf.app_config)
40 | if celery.conf.app_config["STORAGE_TYPE"] != StorageType.LOCAL_STORAGE:
41 | return f"失败:创建缩略图失败,非本地模式 {image_id}"
42 | try:
43 | image = File.by_id(image_id)
44 | if not oss.is_exist(oss_file_prefix, image.save_name):
45 | return f"失败:创建缩略图失败,原图文件未找到 {image_id}"
46 | image_path = os.path.join(STORAGE_PATH, oss_file_prefix, image.save_name)
47 | cover_image_path = os.path.join(
48 | STORAGE_PATH,
49 | oss_file_prefix,
50 | celery.conf.app_config["OSS_PROCESS_COVER_NAME"] + "-" + image.save_name,
51 | )
52 | safe_check_image_path = os.path.join(
53 | STORAGE_PATH,
54 | oss_file_prefix,
55 | celery.conf.app_config["OSS_PROCESS_SAFE_CHECK_NAME"]
56 | + "-"
57 | + image.save_name,
58 | )
59 | original = Image.open(image_path)
60 | thumbnail = ImageOps.fit(original, (180, 140), Image.ANTIALIAS)
61 | original.close()
62 | thumbnail.save(cover_image_path)
63 | thumbnail2 = Image.open(image_path)
64 | thumbnail2.thumbnail((400, 500))
65 | thumbnail2.save(safe_check_image_path)
66 | thumbnail2.close()
67 | except FileNotExistError:
68 | return f"失败:创建缩略图失败,原图不存在 {image_id}"
69 | except Exception:
70 | logger.exception(Exception)
71 | return f"失败:创建缩略图失败 {image_id}"
72 | return f"成功:创建缩略图成功 {image_id}"
73 |
74 |
75 | def create_thumbnail(image_id, /, *, run_sync=False):
76 | alive_workers = celery.control.ping()
77 | if len(alive_workers) == 0 or run_sync:
78 | # 同步执行
79 | create_thumbnail_task(image_id)
80 | return SyncResult()
81 | else:
82 | # 异步执行
83 | return create_thumbnail_task.delay(image_id)
84 |
--------------------------------------------------------------------------------
/files/test/3kbA.txt:
--------------------------------------------------------------------------------
1 | AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
--------------------------------------------------------------------------------
/tests/api/test_site_setting_api.py:
--------------------------------------------------------------------------------
1 | from app.exceptions import NoPermissionError
2 | from app.models.site_setting import SiteSetting
3 | from tests import MoeAPITestCase
4 |
5 |
6 | class TestSiteSettingAPI(MoeAPITestCase):
7 | def test_get_site_setting(self):
8 | admin_user = self.create_user("admin")
9 | admin_user.admin = True
10 | admin_user.save()
11 | admin_token = admin_user.generate_token()
12 | user = self.create_user("user")
13 | user_token = user.generate_token()
14 | # 普通用户, 无权限
15 | data = self.get("/v1/admin/site-setting", token=user_token)
16 | self.assertErrorEqual(data, NoPermissionError)
17 | # 管理员, 有权限
18 | data = self.get("/v1/admin/site-setting", token=admin_token)
19 | self.assertErrorEqual(data)
20 |
21 | def test_put_site_setting(self):
22 | admin_user = self.create_user("admin")
23 | admin_user.admin = True
24 | admin_user.save()
25 | admin_token = admin_user.generate_token()
26 | user = self.create_user("user")
27 | user_token = user.generate_token()
28 | site_setting = SiteSetting.get()
29 | site_setting.enable_whitelist = True
30 | site_setting.only_allow_admin_create_team = True
31 | site_setting.auto_join_team_ids = []
32 | site_setting.whitelist_emails = []
33 | site_setting.save()
34 | site_setting.reload()
35 | self.assertEqual(site_setting.enable_whitelist, True)
36 | self.assertEqual(site_setting.whitelist_emails, [])
37 | self.assertEqual(site_setting.only_allow_admin_create_team, True)
38 | self.assertEqual(site_setting.auto_join_team_ids, [])
39 | new_setting_json = {
40 | "enable_whitelist": False,
41 | "whitelist_emails": ["admin1@moeflow.com", "admin2@moeflow.com"],
42 | "only_allow_admin_create_team": False,
43 | "auto_join_team_ids": ["5e7f7b2b4d9b4b0b8c1c1c1c"],
44 | }
45 | # 普通用户, 无权限
46 | data = self.put(
47 | "/v1/admin/site-setting", json=new_setting_json, token=user_token
48 | )
49 | self.assertErrorEqual(data, NoPermissionError)
50 | # 管理员, 有权限
51 | data = self.put(
52 | "/v1/admin/site-setting", json=new_setting_json, token=admin_token
53 | )
54 | self.assertErrorEqual(data)
55 | site_setting.reload()
56 | self.assertEqual(
57 | site_setting.enable_whitelist, new_setting_json["enable_whitelist"]
58 | )
59 | self.assertEqual(
60 | site_setting.whitelist_emails, new_setting_json["whitelist_emails"]
61 | )
62 | self.assertEqual(
63 | site_setting.only_allow_admin_create_team,
64 | new_setting_json["only_allow_admin_create_team"],
65 | )
66 | self.assertEqual(
67 | [str(id) for id in site_setting.auto_join_team_ids],
68 | new_setting_json["auto_join_team_ids"],
69 | )
70 |
--------------------------------------------------------------------------------
/app/apis/type.py:
--------------------------------------------------------------------------------
1 | from app.constants.locale import Locale
2 | from app.core.views import MoeAPIView
3 | from app.models.team import Team
4 | from app.exceptions.base import RequestDataWrongError
5 | from app.models.project import Project
6 | from flask_apikit.utils.query import QueryParser
7 |
8 |
9 | class TypeAPI(MoeAPIView):
10 | """
11 | @api {post} /v1/types/locale 获取语言
12 | @apiVersion 1.0.0
13 | @apiName type_local
14 | @apiGroup Type
15 | @apiUse APIHeader
16 | """
17 |
18 | """
19 | @api {post} /v1/types/permission 获取权限选项
20 | @apiVersion 1.0.0
21 | @apiName type_permission
22 | @apiGroup Type
23 | @apiUse APIHeader
24 | @apiParam {String} group_type 团体类型(team/project)
25 | """
26 | """
27 | @api {post} /v1/types/allow-apply-type 获取谁允许申请选项
28 | @apiVersion 1.0.0
29 | @apiName type_allow_apply_type
30 | @apiGroup Type
31 | @apiUse APIHeader
32 | @apiParam {String} group_type 团体类型(team/project)
33 | """
34 | """
35 | @api {post} /v1/types/application-check-type 获取如何处理申请选项
36 | @apiVersion 1.0.0
37 | @apiName type_application_check_type
38 | @apiGroup Type
39 | @apiUse APIHeader
40 | @apiParam {String} group_type 团体类型(team/project)
41 | """
42 | """
43 | @api {post} /v1/types/system-role 获取系统角色
44 | @apiVersion 1.0.0
45 | @apiName type_system_role
46 | @apiGroup Type
47 | @apiUse APIHeader
48 | @apiParam {String} group_type 团体类型(team/project)
49 | """
50 |
51 | def get(self, type_name):
52 | """获取各种类型"""
53 | # TODO: 将locale移动到独立的API中
54 | if type_name == "locale":
55 | return Locale.to_api()
56 | # 区分 team 或 project 类型的
57 | else:
58 | query = self.get_query({"with_creator": QueryParser.bool})
59 | if query.get("group_type") not in ["team", "project"]:
60 | raise RequestDataWrongError(
61 | message="'group_type' not in ['team', 'project']"
62 | )
63 | # 获取相应的团体类
64 | if query["group_type"] == "team":
65 | group_cls = Team
66 | else:
67 | group_cls = Project
68 | if type_name == "permission":
69 | # 团队权限
70 | return group_cls.permission_cls.to_api()
71 | elif type_name == "allow-apply-type":
72 | # 团队申请类型
73 | return group_cls.allow_apply_type_cls.to_api()
74 | elif type_name == "application-check-type":
75 | # 团队申请审核类型
76 | return group_cls.application_check_type_cls.to_api()
77 | elif type_name == "system-role":
78 | with_creator = query.get("with_creator", False)
79 | # 团队系统角色
80 | return [
81 | role.to_api()
82 | for role in group_cls.role_cls.system_roles(
83 | without_creator=not with_creator
84 | )
85 | ]
86 |
--------------------------------------------------------------------------------
/files/ps_script/ps_script_res/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## [1.7.3] - 2022-09-12
4 | ### Fixed
5 | - 修复载入字体配置功能失效的问题
6 |
7 |
8 | ## [1.7.2] - 2022-07-27
9 | 修复怨声载道的脚本崩溃问题...
10 | ### Fixed
11 | - 修复若不存在PS动作却保存了配置,脚本有可能崩溃的问题
12 | - 修复如果配置中存有当前系统不存在的字体,脚本崩溃的问题,问题很严重且高发,因为配置会自动储存、启动时装载,很多时候表现为启动时崩溃
13 |
14 | ## [1.7.1] - 2021-09-23
15 | ### Changed
16 | - 改进涂白功能填充颜色的精准度,之前取的点的颜色只采样单点,容易受文字附近的噪点干扰,已改为采样区域颜色
17 | ### Fixed
18 | - 修复涂白功能,Label位置正好在位于文字上时,会涂白整个页面的问题
19 |
20 | ## [1.7.0] - 2021-09-19
21 | 重写了涂白功能的逻辑,避免各种神奇问题。
22 | ### Added
23 | - 涂白功能,允许用户自定义容差
24 | - 打开翻译文本时,自动探测所在目录下是否存在“images”等文件夹,若存在则默认当作图源文件夹(配合萌翻的工程导出结构)
25 | ### Changed
26 | - 涂白功能,填充的颜色不再是白色,会自动取label所在的点的颜色来填充
27 | ### Fixed
28 | - 修复涂白功能,遇到粘连的框,会把两框相连部分的框涂白的问题
29 | - 修复涂白功能,对话框在4个角落时,整页都会被涂白的问题
30 | - 修复导出的PSD的Label图层分组,混合模式为“穿透”而不是“正常”的问题
31 |
32 | ## [1.6.1] - 2021-09-17
33 | ### Fixed
34 | - 修复涂白功能在高DPI图片下可能将对话框的边缘覆盖的问题
35 |
36 | ## [1.6.0] - 2021-03-14
37 | ### Added
38 | - 增强“预览匹配结果”按钮功能,显示名改为“检查图源匹配情况”,可以用它检查文件匹配(之前只在勾选“按顺序匹配图片”时可用),改用一个可滚动的文本框以显示大量内容
39 | - 执行导入前,先检查图源、模板文件是否全部存在,有缺失则不继续执行,避免在运行过程中才发现错误
40 | ### Changed
41 | - 启动时,将UI显示在屏幕中心
42 | - 改进“按顺序匹配图片文件”功能,会自动剔除非图片文件,之前匹配到到txt的情况不会再出现了
43 | - 发生未预料到的异常时,直接显示所有log方便排障
44 | ### Fixed
45 | - 修复“按顺序匹配图片文件”的“预览匹配结果”不停地弹出提示窗的问题
46 | - 修复当模板中的图层为相同名字时,不会被完全删除的问题(模板中原有的图层,在导入后应该被全部删除)
47 |
48 | ## [1.5.0] - 2020-05-19
49 | ### Added
50 | - 增加PS文档模板功能:使用模板可以方便地实现一些格式设置,以替代之前一部分需要录制动作的操作
51 | - PS文档功能UI选项:“自动”——根据当前系统语言自动选择模板;“不使用模板”——保持以前脚本的行为;“自定义模板”
52 | - PS文档功能支持为每个标签分组自定义文本图层的样式,只需在模板文档中添加与分组同名的图层
53 | - PS文档功能支持“dialog-overlay”图层,可自定义涂白覆盖层的样式
54 | - 导入时自动保存配置,下次打开脚本时自动加载上次的配置
55 | - 增加TIFF格式图片导入支持
56 | - 增加“输出格式”选项,脚本将自动转换导出的格式,此前脚本仅支持保存为PSD格式
57 | ### Changed
58 | - 此次更新配置项变更较大,不再兼容此前保存的ini配置文件,配置文件的格式改为JSON
59 | - 为配合模板功能,将“输出横排文字”选项改为“文字方向”,选项有“默认”、“横向”、“竖向”
60 | - UI,重新分类功能
61 | - UI,修改表述“忽略图片文件名”->“按顺序匹配图片文件
62 | - UI,“导出没有标号的文档”->“不输出未标号图片”,修改表述且默认输出全部图片,以免默认选项导致遗漏图片
63 | ### Fixed
64 | - 修复行距、间距组合等格式丢失的问题,通过引入PS文档模板功能解决
65 | - 修复PS动作不存在时报错的问题
66 | - 修复文件名中存在中括号时,lp文本文件解析不正常的问题(by Jason23347)
67 | - 修复路径中存在'%'时,脚本工作不正常的问题
68 |
69 |
70 | ## [1.4.1] - 2020-01-16
71 | ### Fixed
72 | - 修复“执行动作组”功能,保存配置项失效的问题
73 |
74 | ## [1.4.0] - 2020-01-12
75 | ### Changed
76 | - 发布时不再区分中、英文版本,由Photoshop中设置的语言自动切换
77 | - 改变设置行距功能的行为,从“字符的行距值”改为“段落自动行距的百分比值”
78 | - 默认启用设置行距功能(默认值120%)
79 | - UI优化:调整UI元素位置,简化文字描述
80 |
81 | ## [1.3.0] - 2020-01-05
82 | ### Added
83 | - 增加设置行距功能
84 |
85 | ### Changed
86 | - 修改表述"处理无标号文档"->“导出没有标号的文档”
87 | - “导出没有标号的文档”选项默认值为真,避免漏页
88 |
89 | ### Fixed
90 | - 修复UI上不显示“使文字方向为横向”选项的问题
91 | - 修复指定图源后缀名的JPEG没加“.”,导致文件名替换不正常的问题
92 | - 修复文本行距默认可能不为“自动”的问题
93 |
94 | ## [1.2.2] - 2017-12-27
95 | ### Fixed
96 | - 修复1.2.1脚本界面在mac下中文乱码的问题(发布时未转换成utf8)
97 | - 修复导出过程中遇到ps无法打开的文件时报错崩溃的问题, 将所有导出过程中捕获到的错误在完毕后一次性显示出来
98 |
99 | ## [1.2.1] - 2017-10-28
100 | ### Fixed
101 | - 修复1.2.0中修复的"CS2017下执行不存在的动作时提示'命令'播放'不可用'的问题"时造成动作全部不执行的问题
102 |
103 | ## [1.2.0] - 2017-06-18
104 | 开始使用语义化版本号
105 | ### Fixed
106 | - 修复PS CS2017下执行不存在的动作时提示"命令'播放'不可用"的问题
107 |
--------------------------------------------------------------------------------
/app/exceptions/auth.py:
--------------------------------------------------------------------------------
1 | from flask_babel import lazy_gettext
2 |
3 | from .base import MoeError
4 |
5 |
6 | class AuthRootError(MoeError):
7 | """
8 | @apiDefine AuthRootError
9 | @apiError 1000 验证异常
10 | """
11 |
12 | code = 1000
13 | message = lazy_gettext("验证异常")
14 |
15 |
16 | class EmailNotRegisteredError(AuthRootError):
17 | """
18 | @apiDefine EmailNotRegisteredError
19 | @apiError 1001 此邮箱未注册
20 | """
21 |
22 | code = 1001
23 | message = lazy_gettext("此邮箱未注册")
24 |
25 |
26 | class PasswordWrongError(AuthRootError):
27 | """
28 | @apiDefine PasswordWrongError
29 | @apiError 1002 密码错误
30 | """
31 |
32 | code = 1002
33 | message = lazy_gettext("密码错误")
34 |
35 |
36 | class NeedTokenError(AuthRootError):
37 | """
38 | @apiDefine NeedTokenError
39 | @apiError 1003 需要令牌
40 | """
41 |
42 | status_code = 401
43 | code = 1003
44 | message = lazy_gettext("需要令牌")
45 |
46 |
47 | class BadTokenError(AuthRootError):
48 | """
49 | @apiDefine BadTokenError
50 | @apiError 1004 无效的令牌: [详细原因]
51 | """
52 |
53 | status_code = 401
54 | code = 1004
55 | message = lazy_gettext("无效的令牌")
56 |
57 |
58 | class UserNotExistError(AuthRootError):
59 | """
60 | @apiDefine UserNotExistError
61 | @apiError 1005 用户不存在
62 | """
63 |
64 | code = 1005
65 | message = lazy_gettext("用户不存在")
66 |
67 |
68 | class UserBannedError(AuthRootError):
69 | """
70 | @apiDefine UserBannedError
71 | @apiError 1006 用户被封禁
72 | """
73 |
74 | code = 1006
75 | message = lazy_gettext("此用户被封禁")
76 |
77 |
78 | class EmailRegexError(AuthRootError):
79 | """
80 | @apiDefine EmailRegexError
81 | @apiError 1007 邮箱格式不正确
82 | """
83 |
84 | code = 1007
85 | message = lazy_gettext("邮箱格式不正确")
86 |
87 |
88 | class EmailRegisteredError(AuthRootError):
89 | """
90 | @apiDefine EmailRegisteredError
91 | @apiError 1008 此被邮箱已注册,请直接登录或使用其他邮箱
92 | """
93 |
94 | code = 1008
95 | message = lazy_gettext("此邮箱已被注册")
96 |
97 |
98 | class UserNameRegexError(AuthRootError):
99 | """
100 | @apiDefine UserNameRegexError
101 | @apiError 1009 仅可使用中文/日文/韩文/英文/数字/_
102 | """
103 |
104 | code = 1009
105 | message = lazy_gettext("仅可使用中文/日文/韩文/英文/数字/_")
106 |
107 |
108 | class UserNameRegisteredError(AuthRootError):
109 | """
110 | @apiDefine UserNameRegisteredError
111 | @apiError 1010 此昵称已被使用
112 | """
113 |
114 | code = 1010
115 | message = lazy_gettext("此昵称已被使用")
116 |
117 |
118 | class UserNameLengthError(AuthRootError):
119 | """
120 | @apiDefine UserNameLengthError
121 | @apiError 1011 长度为2到18个字符
122 | """
123 |
124 | code = 1011
125 | message = lazy_gettext("长度为2到18个字符")
126 |
127 |
128 | class EmailNotInWhitelistError(AuthRootError):
129 | """
130 | @apiDefine EmailNotInWhitelistError
131 | @apiError 1011 邮箱不在白名单中,请联系管理员添加
132 | """
133 |
134 | code = 1012
135 | message = lazy_gettext("邮箱不在白名单中,请联系管理员添加")
136 |
--------------------------------------------------------------------------------
/app/validators/team.py:
--------------------------------------------------------------------------------
1 | from marshmallow import fields, post_load, validates_schema
2 |
3 | from app.models.team import Team
4 | from app.constants.role import RoleType
5 | from app.validators.custom_message import required_message
6 | from app.validators.custom_schema import DefaultSchema
7 | from app.validators.custom_validate import TeamValidate, need_in, object_id
8 |
9 |
10 | class CreateTeamSchema(DefaultSchema):
11 | """创建团队验证器"""
12 |
13 | name = fields.Str(
14 | required=True,
15 | validate=[TeamValidate.valid_new_name],
16 | error_messages={**required_message},
17 | )
18 | intro = fields.Str(
19 | required=True,
20 | validate=[TeamValidate.intro_length],
21 | error_messages={**required_message},
22 | )
23 | allow_apply_type = fields.Int(
24 | required=True, validate=[need_in(Team.allow_apply_type_cls.ids())]
25 | )
26 | application_check_type = fields.Int(
27 | required=True,
28 | validate=[need_in(Team.application_check_type_cls.ids())],
29 | )
30 | default_role = fields.Str(required=True, validate=[object_id])
31 |
32 | @validates_schema
33 | def verify_default_role(self, data):
34 | # 角色必须在系统团队的角色中
35 | need_in(
36 | [str(role.id) for role in Team.role_cls.system_roles(without_creator=True)]
37 | )(data["default_role"], field_name="default_role")
38 |
39 | @post_load
40 | def to_model(self, in_data):
41 | """通过id获取模型,以供直接使用"""
42 | # 获取默认角色
43 | in_data["default_role"] = Team.role_cls.by_id(in_data["default_role"])
44 | return in_data
45 |
46 |
47 | class EditTeamSchema(DefaultSchema):
48 | """修改团队验证器"""
49 |
50 | name = fields.Str(error_messages={**required_message})
51 | intro = fields.Str(
52 | validate=[TeamValidate.intro_length],
53 | error_messages={**required_message},
54 | )
55 | allow_apply_type = fields.Int(validate=[need_in(Team.allow_apply_type_cls.ids())])
56 | application_check_type = fields.Int(
57 | validate=[need_in(Team.application_check_type_cls.ids())]
58 | )
59 | default_role = fields.Str(validate=[object_id])
60 |
61 | @validates_schema
62 | def verify_name(self, data):
63 | # 如果新名字和旧名字不同,检查是否合法
64 | if "name" in data and data["name"] != self.context["team"].name:
65 | TeamValidate.valid_new_name(data["name"], field_name="name")
66 |
67 | @validates_schema
68 | def verify_default_role(self, data):
69 | # 角色必须在团队的角色中
70 | if "default_role" in data:
71 | need_in(
72 | [
73 | str(role.id)
74 | for role in self.context["team"].roles(
75 | type=RoleType.ALL, without_creator=True
76 | )
77 | ]
78 | )(data["default_role"], field_name="default_role")
79 |
80 | @post_load
81 | def to_model(self, in_data):
82 | """通过id获取模型,以供直接使用"""
83 | # 获取默认角色
84 | if "default_role" in in_data:
85 | in_data["default_role"] = Team.role_cls.by_id(in_data["default_role"])
86 | return in_data
87 |
--------------------------------------------------------------------------------
/app/constants/base.py:
--------------------------------------------------------------------------------
1 | from typing import Dict, List, Union
2 |
3 |
4 | class Type:
5 | """用于定义类型,并向api返回介绍"""
6 |
7 | @classmethod
8 | def get_detail_by_value(
9 | cls, value: Union[int, str], detail_name: str, default_value: str = ""
10 | ) -> str:
11 | """
12 | 获取某个值的详细信息
13 |
14 | :param attr: 类型名称
15 | :param detail_name: 详细内容名称
16 | :param default_value: 默认值
17 | """
18 | for attr in dir(cls):
19 | if attr.isupper() and getattr(cls, attr) == value:
20 | return cls.get_detail(attr, detail_name, default_value=default_value)
21 | return default_value
22 |
23 | @classmethod
24 | def get_detail(cls, attr: str, detail_name: str, default_value: str = "") -> str:
25 | """
26 | 获取某个值的详细信息
27 |
28 | :param attr: 类型名称
29 | :param detail_name: 详细内容名称
30 | :param default_value: 默认值
31 | """
32 | detail = cls.details.get(attr)
33 | if detail is None:
34 | return default_value
35 | if detail_name not in detail:
36 | return default_value
37 | return detail[detail_name]
38 |
39 | @classmethod
40 | def to_api(
41 | cls,
42 | ids: Union[List[int], List[str], None] = None,
43 | id: Union[int, str, None] = None,
44 | ) -> Union[List[Dict], Dict]:
45 | """转化为前端使用数组,并加上介绍"""
46 | # 如果指定了id,则返回相应id的类型
47 | if id:
48 | for attr in dir(cls):
49 | if attr.isupper() and getattr(cls, attr) == id:
50 | return {
51 | "id": getattr(cls, attr),
52 | # 没有名称则设置为属性名
53 | "name": cls.get_detail(attr, "name", default_value=attr),
54 | "intro": cls.get_detail(attr, "intro", default_value=""),
55 | }
56 | raise ValueError(f"{id} 不存在于类 {cls.__name__}")
57 | else:
58 | # 需要获取的ids
59 | if ids is None:
60 | ids = cls.ids()
61 | # 获取排序后的列表
62 | data = sorted(
63 | [
64 | {
65 | "id": getattr(cls, attr),
66 | # 没有名称则设置为属性名
67 | "name": cls.get_detail(attr, "name", default_value=attr),
68 | "intro": cls.get_detail(attr, "intro", default_value=""),
69 | }
70 | for attr in dir(cls)
71 | if attr.isupper() and getattr(cls, attr) in ids
72 | ],
73 | key=lambda d: d["id"],
74 | )
75 | return data
76 |
77 | @classmethod
78 | def ids(cls):
79 | """返回所有大写常量的值"""
80 | ids = [getattr(cls, attr) for attr in dir(cls) if attr.isupper()]
81 | return ids
82 |
83 |
84 | class IntType(Type):
85 | """用于一些整形的类型"""
86 |
87 | @classmethod
88 | def ids(cls) -> List[int]:
89 | return super().ids()
90 |
91 |
92 | class StrType(Type):
93 | """用于一些字符串的类型"""
94 |
95 | @classmethod
96 | def ids(cls) -> List[str]:
97 | return super().ids()
98 |
--------------------------------------------------------------------------------
/app/models/target.py:
--------------------------------------------------------------------------------
1 | from app.exceptions.project import TargetNotExistError
2 | from app.exceptions import TargetAndSourceLanguageSameError
3 | import datetime
4 |
5 | from mongoengine import (
6 | CASCADE,
7 | DENY,
8 | DateTimeField,
9 | Document,
10 | IntField,
11 | ReferenceField,
12 | StringField,
13 | )
14 |
15 | from app.models.language import Language
16 | from app.models.output import Output
17 | from typing import TYPE_CHECKING
18 | from app.exceptions import SameTargetLanguageError
19 |
20 | if TYPE_CHECKING:
21 | from app.models.project import Project
22 |
23 |
24 | class Target(Document):
25 | """项目的目标语言,用于储存语言相关的Cache和其他信息"""
26 |
27 | project = ReferenceField("Project", db_field="t", required=True)
28 | language = ReferenceField(
29 | Language, required=True, db_field="l", reverse_delete_rule=DENY
30 | )
31 | translated_source_count = IntField(db_field="tsc", default=0) # 已翻译的原文数量
32 | checked_source_count = IntField(db_field="csc", default=0) # 已校对的原文数量
33 | create_time = DateTimeField(db_field="ct", default=datetime.datetime.utcnow)
34 | edit_time = DateTimeField(
35 | db_field="et", default=datetime.datetime.utcnow
36 | ) # 修改时间
37 | intro = StringField(db_field="i", default="") # 目标介绍
38 |
39 | @classmethod
40 | def create(
41 | cls, project: "Project", language: Language, intro: str = ""
42 | ) -> "Target":
43 | """创建项目目标"""
44 | # 这个语言已存在则报错
45 | old_target = project.targets(language=language)
46 | if old_target:
47 | raise SameTargetLanguageError
48 | # 不能和源语言相同
49 | if language == project.source_language:
50 | raise TargetAndSourceLanguageSameError
51 | # 创建新的目标语言
52 | target = Target(project=project, language=language, intro=intro).save()
53 | project.update(inc__target_count=1)
54 | project.reload()
55 | # 对已有文件创建FileTargetCache
56 | from app.models.file import File
57 |
58 | for file in File.objects(project=project):
59 | file.create_target_cache(target)
60 | return target
61 |
62 | def outputs(self) -> list[Output]:
63 | """所有导出"""
64 | outputs = Output.objects(project=self.project, target=self).order_by(
65 | "-create_time"
66 | )
67 | return outputs
68 |
69 | def clear(self):
70 | # 清理 output 文件
71 | Output.delete_real_files(self.outputs())
72 | # 减少project的计数
73 | self.project.update(dec__target_count=1)
74 | self.delete()
75 |
76 | @classmethod
77 | def by_id(cls, id):
78 | file = cls.objects(id=id).first()
79 | if file is None:
80 | raise TargetNotExistError
81 | return file
82 |
83 | def to_api(self):
84 | return {
85 | "id": str(self.id),
86 | "language": self.language.to_api(),
87 | "translated_source_count": self.translated_source_count,
88 | "checked_source_count": self.checked_source_count,
89 | "create_time": self.create_time.isoformat(),
90 | "edit_time": self.edit_time.isoformat(),
91 | "intro": self.intro,
92 | }
93 |
94 |
95 | Target.register_delete_rule(Output, "target", CASCADE)
96 |
--------------------------------------------------------------------------------
/app/validators/v_code.py:
--------------------------------------------------------------------------------
1 | from flask_babel import gettext
2 | from marshmallow import ValidationError, fields, validates_schema
3 |
4 | from app.exceptions import VCodeRootError
5 | from app.models.user import User
6 | from app.models.v_code import Captcha, VCode
7 | from app.validators.custom_message import (
8 | email_invalid_message,
9 | required_message,
10 | )
11 | from app.validators.custom_schema import DefaultSchema
12 | from app.validators.custom_validate import UserValidate
13 |
14 |
15 | def password_validator(email, password, field_name="password"):
16 | """
17 | 密码验证器
18 |
19 | :param email: 邮箱
20 | :param password: 密码
21 | :param field_name: 验证错误显示字段的名称
22 | :return:
23 | """
24 | # 密码错误
25 | user = User.get_by_email(email)
26 | if user is None:
27 | raise ValidationError(gettext("用户不存在"), [field_name])
28 | if not user.verify_password(password):
29 | raise ValidationError(gettext("密码错误"), [field_name])
30 |
31 |
32 | def captcha_validator(info, content, field_name="captcha"):
33 | """
34 | 人机验证码验证器
35 |
36 | :param info: 验证码标识符
37 | :param content: 验证码内容
38 | :param field_name: 验证错误显示字段的名称
39 | :return:
40 | """
41 | try:
42 | Captcha.verify(code_info=info, code_content=content)
43 | except VCodeRootError as e:
44 | raise ValidationError(e.message, [field_name])
45 |
46 |
47 | def v_code_validator(
48 | v_code_type, info, content, field_name="v_code", delete_after_verified=True
49 | ):
50 | """
51 | 验证码验证器
52 |
53 | :param v_code_type: 验证码类型
54 | :param info: 验证码标识符
55 | :param content: 验证码内容
56 | :param field_name: 验证错误显示字段的名称
57 | :return:
58 | """
59 | try:
60 | VCode.verify(
61 | code_type=v_code_type,
62 | code_info=info,
63 | code_content=content,
64 | delete_after_verified=delete_after_verified,
65 | )
66 | except VCodeRootError as e:
67 | raise ValidationError(e.message, [field_name])
68 |
69 |
70 | class ConfirmEmailVCodeSchema(DefaultSchema):
71 | """发送确认邮箱邮件验证"""
72 |
73 | email = fields.Email(
74 | required=True,
75 | validate=[UserValidate.valid_new_email],
76 | error_messages={**required_message, **email_invalid_message},
77 | )
78 | captcha_info = fields.Str(required=True, error_messages={**required_message})
79 | captcha = fields.Str(required=True, error_messages={**required_message})
80 |
81 | @validates_schema
82 | def verify_captcha(self, data):
83 | captcha_validator(data["captcha_info"], data["captcha"])
84 |
85 |
86 | class ResetPasswordVCodeSchema(DefaultSchema):
87 | """发送重置密码邮件验证"""
88 |
89 | email = fields.Email(
90 | required=True,
91 | validate=[UserValidate.exist_email],
92 | error_messages={**required_message, **email_invalid_message},
93 | )
94 | captcha_info = fields.Str(required=True, error_messages={**required_message})
95 | captcha = fields.Str(required=True, error_messages={**required_message})
96 |
97 | @validates_schema
98 | def verify_captcha(self, data):
99 | captcha_validator(data["captcha_info"], data["captcha"])
100 |
--------------------------------------------------------------------------------
/app/apis/project_set.py:
--------------------------------------------------------------------------------
1 | from flask_babel import gettext
2 |
3 | from app.core.views import MoeAPIView
4 | from app.decorators.auth import token_required
5 | from app.decorators.url import fetch_model
6 | from app.exceptions import NoPermissionError
7 | from app.models.project import ProjectSet
8 | from app.models.team import TeamPermission
9 | from app.validators.project import ProjectSetsSchema
10 |
11 |
12 | class ProjectSetAPI(MoeAPIView):
13 | @token_required
14 | @fetch_model(ProjectSet)
15 | def get(self, project_set):
16 | """
17 | @api {get} /v1/project-sets/ 获取项目集
18 | @apiVersion 1.0.0
19 | @apiName get_project_set
20 | @apiGroup ProjectSet
21 | @apiUse APIHeader
22 | @apiUse TokenHeader
23 |
24 | @apiParam {String} team_id 团队id
25 | @apiParam {String} [word] 模糊查询的名称
26 |
27 | @apiSuccessExample {json} 返回示例
28 | {
29 |
30 | }
31 | """
32 | # 检查是否有访问团队权限
33 | if not self.current_user.can(project_set.team, TeamPermission.ACCESS):
34 | raise NoPermissionError
35 | return project_set.to_api()
36 |
37 | @token_required
38 | @fetch_model(ProjectSet)
39 | def put(self, project_set):
40 | """
41 | @api {put} /v1/project-sets/ 修改项目集
42 | @apiVersion 1.0.0
43 | @apiName edit_team_project_set
44 | @apiGroup ProjectSet
45 | @apiUse APIHeader
46 | @apiUse TokenHeader
47 |
48 | @apiParam {String} project_set_id 项目集id
49 | @apiParamExample {json} 请求示例
50 | {
51 | "name": "name"
52 | }
53 |
54 | @apiSuccessExample {json} 返回示例
55 | {
56 |
57 | }
58 | """
59 | # 检查是否有权限
60 | if not self.current_user.can(
61 | project_set.team, TeamPermission.CHANGE_PROJECT_SET
62 | ):
63 | raise NoPermissionError
64 | # 默认项目集不能编辑
65 | if project_set.default:
66 | raise NoPermissionError(gettext("默认项目集不能进行设置"))
67 | # 获取data
68 | data = self.get_json(ProjectSetsSchema())
69 | project_set.name = data["name"]
70 | project_set.save()
71 | return {"message": gettext("修改成功"), "project_set": project_set.to_api()}
72 |
73 | @token_required
74 | @fetch_model(ProjectSet)
75 | def delete(self, project_set):
76 | """
77 | @api {delete} /v1/project-sets/ 删除项目集
78 | @apiDescription
79 | 此时所有项目将被移动到未归类项目,需要给用户一个确认删除提示,如“删除后此项目集的所有内容将移动到‘未分类项目’中”
80 | @apiVersion 1.0.0
81 | @apiName delete_team_project_set
82 | @apiGroup ProjectSet
83 | @apiUse APIHeader
84 | @apiUse TokenHeader
85 |
86 | @apiParam {String} project_set_id 项目集id
87 |
88 | @apiSuccessExample {json} 返回示例
89 | {
90 |
91 | }
92 | """
93 | # 检查是否有权限
94 | if not self.current_user.can(
95 | project_set.team, TeamPermission.DELETE_PROJECT_SET
96 | ):
97 | raise NoPermissionError
98 | # 默认项目集不能删除
99 | if project_set.default:
100 | raise NoPermissionError(gettext("默认项目集不能删除"))
101 | project_set.clear()
102 | return {"message": gettext("删除成功")}
103 |
--------------------------------------------------------------------------------
/app/apis/manga_image_translator.py:
--------------------------------------------------------------------------------
1 | # Translation preprocess API backed by manga-image-translator worker
2 | from app.core.views import MoeAPIView
3 | from flask import request
4 |
5 | from app.exceptions.base import UploadFileNotFoundError
6 | from app.tasks.mit import (
7 | mit_ocr,
8 | mit_detect_text,
9 | mit_translate,
10 | mit_detect_text_default_params,
11 | mit_ocr_default_params,
12 | )
13 | from app.tasks import queue_task, wait_result_sync
14 | from app import app_config
15 | from werkzeug.datastructures import FileStorage
16 | from tempfile import NamedTemporaryFile
17 | import os
18 | from app.utils.logging import logger
19 |
20 | MIT_STORAGE_ROOT = app_config.get("MIT_STORAGE_ROOT", "/MIT_STORAGE_ROOT_UNDEFINED")
21 |
22 |
23 | def _wait_task_result(task_id: str):
24 | try:
25 | result = wait_result_sync(task_id, timeout=1)
26 | return {"task_id": task_id, "status": "success", "result": result}
27 | except TimeoutError:
28 | return {
29 | "task_id": task_id,
30 | "status": "pending",
31 | }
32 | except Exception as e:
33 | return {"task_id": task_id, "status": "fail", "message": str(e)}
34 |
35 |
36 | class MitImageApi(MoeAPIView):
37 | # upload image file for other APIs
38 | def post(self):
39 | logger.info("files: %s", request.files)
40 | blob: None | FileStorage = request.files.get("file")
41 | logger.info("blob: %s", blob)
42 | if not (blob and blob.filename.endswith((".jpg", ".jpeg", ".png", ".gif"))):
43 | raise UploadFileNotFoundError("Please select an image file")
44 | tmpfile = NamedTemporaryFile(dir=MIT_STORAGE_ROOT, delete=False)
45 | tmpfile.write(blob.read())
46 | tmpfile.close()
47 | return {"filename": tmpfile.name}
48 |
49 |
50 | class MitImageTaskApi(MoeAPIView):
51 | def post(self):
52 | task_params: dict[str, str] = self.get_json()
53 | logger.info("task_params: %s", task_params)
54 | tmpfile_name = task_params.pop("filename", None)
55 | if not tmpfile_name:
56 | raise ValueError("Filename required")
57 | tmpfile_path = os.path.join(MIT_STORAGE_ROOT, tmpfile_name)
58 | if os.path.commonprefix([tmpfile_path, MIT_STORAGE_ROOT]) != MIT_STORAGE_ROOT:
59 | raise ValueError("Invalid filename")
60 | if not os.path.isfile(tmpfile_path):
61 | raise ValueError("File not found")
62 | task_name = task_params.pop("task_name", None)
63 | if task_name == "mit_detect_text":
64 | merged_params = mit_detect_text_default_params.copy()
65 | merged_params.update(task_params)
66 | task_id = queue_task(mit_detect_text, tmpfile_path, **merged_params)
67 | return {"task_id": task_id}
68 | elif task_name == "mit_ocr":
69 | merged_params = mit_ocr_default_params.copy()
70 | merged_params.update(task_params)
71 | task_id = queue_task(mit_ocr, tmpfile_path, **merged_params)
72 | return {"task_id": task_id}
73 | else:
74 | raise ValueError("Invalid task name")
75 |
76 | def get(self, task_id: str):
77 | return _wait_task_result(task_id)
78 |
79 |
80 | class MitTranslateTaskApi(MoeAPIView):
81 | def post(self):
82 | task_params = self.get_json()
83 | task_id = queue_task(mit_translate, **task_params)
84 | return {"task_id": task_id}
85 |
86 | def get(self, task_id: str):
87 | return _wait_task_result(task_id)
88 |
--------------------------------------------------------------------------------
/app/models/invitation.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | from flask_babel import gettext
4 | from mongoengine import (
5 | Document,
6 | ReferenceField,
7 | GenericReferenceField,
8 | IntField,
9 | StringField,
10 | DateTimeField,
11 | )
12 |
13 | from app.exceptions import InvitationFinishedError
14 | from app.utils.mongo import mongo_slice
15 |
16 |
17 | class InvitationStatus:
18 | PENDING = 1
19 | ALLOW = 2
20 | DENY = 3
21 |
22 |
23 | class Invitation(Document):
24 | user = ReferenceField("User", db_field="u", required=True)
25 | operator = ReferenceField("User", db_field="o", required=True)
26 | group = GenericReferenceField(
27 | choices=["Team", "Project"], db_field="g", required=True
28 | )
29 | role = GenericReferenceField(
30 | choices=["TeamRole", "ProjectRole"], db_field="r", required=True
31 | )
32 | status: int = IntField(
33 | required=True, db_field="s", default=InvitationStatus.PENDING
34 | )
35 | message: str = StringField(required=True, db_field="m", default="")
36 | create_time = DateTimeField(db_field="c", default=datetime.datetime.utcnow)
37 |
38 | @classmethod
39 | def get(cls, user=None, group=None, status=None, skip=None, limit=None):
40 | invitations: list[Invitation] = cls.objects()
41 | if user:
42 | invitations = invitations.filter(user=user)
43 | if group:
44 | invitations = invitations.filter(group=group)
45 | if status:
46 | # 如果是[1]这种只有一个参数的,则提取数组第一个元素,不使用in查询
47 | if isinstance(status, list) and len(status) == 1:
48 | status = status[0]
49 | # 数组使用in查询
50 | if isinstance(status, list):
51 | invitations = invitations.filter(status__in=status)
52 | else:
53 | invitations = invitations.filter(status=status)
54 | # 排序
55 | invitations = invitations.order_by("status", "-id")
56 | # 处理分页
57 | invitations = mongo_slice(invitations, skip, limit)
58 | return invitations
59 |
60 | def can_change_status(self):
61 | """检查邀请是否可转变状态"""
62 | if self.status == InvitationStatus.DENY:
63 | raise InvitationFinishedError(gettext("已被拒绝"))
64 | if self.status == InvitationStatus.ALLOW:
65 | raise InvitationFinishedError(gettext("已被同意"))
66 |
67 | def allow(self):
68 | self.can_change_status()
69 | self.user.join(self.group, self.role)
70 | self.status = InvitationStatus.ALLOW
71 | self.save()
72 |
73 | def deny(self):
74 | self.can_change_status()
75 | self.status = InvitationStatus.DENY
76 | self.save()
77 |
78 | def to_api(self):
79 | """
80 | @apiDefine InvitationInfoModel
81 | @apiSuccess {String} id ID
82 | @apiSuccess {Object} user 用户公共信息
83 | @apiSuccess {Object} group 团体
84 | @apiSuccess {String} group_type 目标类型,有team,project
85 | @apiSuccess {Object} operator 操作人
86 | @apiSuccess {String} create_time 创建时间
87 | @apiSuccess {Number} status 状态
88 | """
89 | return {
90 | "id": str(self.id),
91 | "user": self.user.to_api(),
92 | "group": self.group.to_api(),
93 | "group_type": self.group.group_type,
94 | "role": self.role.to_api(),
95 | "operator": self.operator.to_api() if self.operator else None,
96 | "create_time": self.create_time.isoformat(),
97 | "status": self.status,
98 | }
99 |
--------------------------------------------------------------------------------
/app/exceptions/base.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | """
3 | 基础错误1-99为flask-apikit预留的错误code
4 | """
5 |
6 | from flask_apikit.exceptions import APIError
7 | from flask_babel import lazy_gettext
8 |
9 | """
10 | @apiDefine ValidateError
11 | @apiError 2 字段验证错误
12 | """
13 | """
14 | @apiDefine QueryError
15 | @apiError 3 Query字符串处理错误
16 | """
17 |
18 |
19 | class MoeError(APIError):
20 | """
21 | @apiDefine MoeError
22 | @apiError 100 未定义的错误
23 | """
24 |
25 | code = 100
26 | message = lazy_gettext("未定义的错误")
27 |
28 |
29 | class InvalidObjectIdError(MoeError):
30 | """
31 | @apiDefine InvalidObjectIdError
32 | @apiError 101 错误的ObjectId
33 | """
34 |
35 | code = 101
36 | message = lazy_gettext("错误的ObjectId")
37 |
38 |
39 | class RoleNotExistError(MoeError):
40 | """
41 | @apiDefine RoleNotExistError
42 | @apiError 102 角色不存在
43 | """
44 |
45 | code = 102
46 | message = lazy_gettext("角色不存在")
47 |
48 |
49 | class NoPermissionError(MoeError):
50 | """
51 | @apiDefine NoPermissionError
52 | @apiError 103 没有权限
53 | """
54 |
55 | code = 103
56 | message = lazy_gettext("没有权限")
57 |
58 | def __init__(self, message=None):
59 | """
60 | :param message: 附加message
61 | """
62 | # 如果定义了附加message,则替换
63 | if message:
64 | self.message = message
65 |
66 |
67 | class NotExistError(MoeError):
68 | """
69 | @apiDefine NotExistError
70 | @apiError 104 某种没有定义不存在错误的对象不存在
71 | """
72 |
73 | code = 104
74 |
75 | def __new__(cls, cls_name):
76 | # 自动在exceptions中寻找为NotExistError的错误
77 | exceptions = getattr(__import__("app"), "exceptions")
78 | try:
79 | e = getattr(exceptions, cls_name + "NotExistError")
80 | except AttributeError:
81 | e = None
82 | if e:
83 | return e
84 | else:
85 | return super().__new__(cls)
86 |
87 |
88 | class CommaStrNotAllInt(MoeError):
89 | """
90 | @apiDefine CommaStrNotAllInt
91 | @apiError 105 逗号分割的数字字符串包含非数字
92 | """
93 |
94 | code = 105
95 | message = lazy_gettext("逗号分割的数字字符串包含非数字")
96 |
97 |
98 | class PermissionNotExistError(MoeError):
99 | """
100 | @apiDefine PermissionNotExistError
101 | @apiError 106 权限不存在
102 | """
103 |
104 | code = 106
105 | message = lazy_gettext("权限不存在")
106 |
107 |
108 | class FilenameIllegalError(MoeError):
109 | """
110 | @apiDefine FilenameIllegalError
111 | @apiError 107 文件名错误
112 | """
113 |
114 | code = 107
115 | message = lazy_gettext("文件名错误")
116 |
117 |
118 | class FileTypeNotSupportError(MoeError):
119 | """
120 | @apiDefine FileTypeNotSupportError
121 | @apiError 108 文件类型不支持此操作
122 | """
123 |
124 | code = 108
125 | message = lazy_gettext("文件类型不支持此操作")
126 |
127 |
128 | class UploadFileNotFoundError(MoeError):
129 | """
130 | @apiDefine UploadFileNotFoundError
131 | @apiError 109 上传未包含文件
132 | """
133 |
134 | code = 109
135 | message = lazy_gettext("上传未包含文件")
136 |
137 |
138 | class RequestDataEmptyError(MoeError):
139 | """
140 | @apiDefine RequestDataEmptyError
141 | @apiError 110 请求参数不能为空或没有需要的值
142 | """
143 |
144 | code = 110
145 | message = lazy_gettext("请求参数不能为空或没有需要的值")
146 |
147 |
148 | class RequestDataWrongError(MoeError):
149 | """
150 | @apiDefine RequestDataEmptyError
151 | @apiError 111 请求参数错误
152 | """
153 |
154 | code = 111
155 | message = lazy_gettext("请求参数错误")
156 |
--------------------------------------------------------------------------------
/app/exceptions/join_process.py:
--------------------------------------------------------------------------------
1 | from flask_babel import lazy_gettext
2 |
3 | from .base import MoeError
4 |
5 |
6 | class JoinProcessRootError(MoeError):
7 | """
8 | @apiDefine JoinProcessRootError
9 | @apiError 5000 加入流程异常
10 | """
11 |
12 | code = 5000
13 | message = lazy_gettext("加入流程异常")
14 |
15 |
16 | class GroupTypeNotSupportError(JoinProcessRootError):
17 | """
18 | @apiDefine GroupTypeNotSupportError
19 | @apiError 5001 不支持此团体类型
20 | """
21 |
22 | code = 5001
23 | message = lazy_gettext("不支持此团体类型")
24 |
25 |
26 | class UserAlreadyJoinedError(JoinProcessRootError):
27 | """
28 | @apiDefine UserAlreadyJoinedError
29 | @apiError 5003 此用户已经加入了
30 | """
31 |
32 | code = 5003
33 |
34 | def __init__(self, name):
35 | self.message = lazy_gettext("用户已经在 “{name}” 中").format(name=name)
36 |
37 |
38 | class InvitationAlreadyExistError(JoinProcessRootError):
39 | """
40 | @apiDefine InvitationAlreadyExistError
41 | @apiError 5004 已邀请此用户,请等待用户确认
42 | """
43 |
44 | code = 5004
45 | message = lazy_gettext("已邀请此用户,请等待用户确认")
46 |
47 |
48 | class ApplicationAlreadyExistError(JoinProcessRootError):
49 | """
50 | @apiDefine ApplicationAlreadyExistError
51 | @apiError 5005 您已经申请,请等待管理员确认
52 | """
53 |
54 | code = 5005
55 | message = lazy_gettext("您已经申请,请等待管理员确认")
56 |
57 |
58 | class InvitationNotExistError(JoinProcessRootError):
59 | """
60 | @apiDefine InvitationNotExistError
61 | @apiError 5006 邀请不存在
62 | """
63 |
64 | code = 5006
65 | message = lazy_gettext("邀请不存在")
66 |
67 |
68 | class ApplicationNotExistError(JoinProcessRootError):
69 | """
70 | @apiDefine ApplicationNotExistError
71 | @apiError 5007 申请不存在
72 | """
73 |
74 | code = 5007
75 | message = lazy_gettext("邀请不存在")
76 |
77 |
78 | class AllowApplyTypeNotExistError(JoinProcessRootError):
79 | """
80 | @apiDefine AllowApplyTypeNotExistError
81 | @apiError 5008 允许申请类型不存在
82 | """
83 |
84 | code = 5008
85 | message = lazy_gettext("允许申请类型不存在")
86 |
87 |
88 | class ApplicationCheckTypeNotExistError(JoinProcessRootError):
89 | """
90 | @apiDefine ApplicationCheckTypeNotExistError
91 | @apiError 5009 申请审核类型不存在
92 | """
93 |
94 | code = 5009
95 | message = lazy_gettext("申请审核类型不存在")
96 |
97 |
98 | class InvitationFinishedError(JoinProcessRootError):
99 | """
100 | @apiDefine InvitationFinishedError
101 | @apiError 5010 无法修改此邀请
102 | """
103 |
104 | code = 5010
105 | message = lazy_gettext("无法修改此邀请")
106 |
107 |
108 | class ApplicationFinishedError(JoinProcessRootError):
109 | """
110 | @apiDefine InvitationFinishedError
111 | @apiError 5011 此申请不能进行操作
112 | """
113 |
114 | code = 5011
115 | message = lazy_gettext("无法修改此申请")
116 |
117 |
118 | class TargetIsFullError(JoinProcessRootError):
119 | """
120 | @apiDefine TargetIsFullError
121 | @apiError 5012 已满员,不可申请加入或邀请新成员
122 | """
123 |
124 | code = 5012
125 | message = lazy_gettext("已满员,不可申请加入或邀请新成员")
126 |
127 |
128 | class CreatorCanNotLeaveError(JoinProcessRootError):
129 | """
130 | @apiDefine CreatorCanNotLeaveError
131 | @apiError 5013 创建者无法退出团体,请转移创建者权限后操作
132 | """
133 |
134 | code = 5013
135 | message = lazy_gettext("创建者无法退出团体,请转移创建者权限后操作")
136 |
137 |
138 | class GroupNotOpenError(JoinProcessRootError):
139 | """
140 | @apiDefine GroupNotOpenError
141 | @apiError 5014 此团体不向公众开放
142 | """
143 |
144 | code = 5014
145 | message = lazy_gettext("此团体不向公众开放")
146 |
--------------------------------------------------------------------------------
/tests/model/test_file_storage_model.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | from bson import ObjectId
4 |
5 | # FIXME: testee should be parametrized instance, not singleton
6 | from app import TMP_PATH, oss
7 | from app.constants.storage import StorageType
8 | from tests import MoeTestCase
9 |
10 |
11 | class OSSModelTestCase(MoeTestCase):
12 | path = "test/"
13 |
14 | def test_upload_and_delete(self):
15 | """测试上传删除"""
16 | filename = str(ObjectId()) + ".txt"
17 | self.assertFalse(oss.is_exist(self.path, filename))
18 | oss.upload(self.path, filename, "1")
19 | self.assertTrue(oss.is_exist(self.path, filename))
20 | # 清理,删除上传的内容
21 | oss.delete(self.path, filename)
22 | self.assertFalse(oss.is_exist(self.path, filename))
23 |
24 | def test_delete(self):
25 | """测试删除和批量删除"""
26 | # 上传三个文件
27 | filename1 = str(ObjectId()) + ".txt"
28 | filename2 = str(ObjectId()) + ".txt"
29 | filename3 = str(ObjectId()) + ".txt"
30 | self.assertFalse(oss.is_exist(self.path, filename1))
31 | self.assertFalse(oss.is_exist(self.path, filename2))
32 | self.assertFalse(oss.is_exist(self.path, filename3))
33 | oss.upload(self.path, filename1, "1")
34 | oss.upload(self.path, filename2, "2")
35 | oss.upload(self.path, filename3, "3")
36 | self.assertTrue(oss.is_exist(self.path, filename1))
37 | self.assertTrue(oss.is_exist(self.path, filename2))
38 | self.assertTrue(oss.is_exist(self.path, filename3))
39 | # 删除第一个
40 | oss.delete(self.path, filename1)
41 | self.assertFalse(oss.is_exist(self.path, filename1))
42 | # 批量删除后两个
43 | oss.delete(self.path, [filename2, filename3])
44 | self.assertFalse(oss.is_exist(self.path, filename2))
45 | self.assertFalse(oss.is_exist(self.path, filename3))
46 |
47 | def test_download(self):
48 | """测试下载"""
49 | # 上传两个文件
50 | filename1 = str(ObjectId()) + ".txt"
51 | filename2 = str(ObjectId()) + ".txt"
52 | self.assertFalse(oss.is_exist(self.path, filename1))
53 | self.assertFalse(oss.is_exist(self.path, filename2))
54 | oss.upload(self.path, filename1, filename1)
55 | oss.upload(self.path, filename2, filename2)
56 | self.assertTrue(oss.is_exist(self.path, filename1))
57 | self.assertTrue(oss.is_exist(self.path, filename2))
58 | # 下载第一个为对象
59 | file1 = oss.download(self.path, filename1)
60 | self.assertEqual(filename1.encode("utf-8"), file1.read())
61 | # 下载第二个为文件
62 | file2_path = os.path.join(TMP_PATH, filename2)
63 | oss.download(self.path, filename2, local_path=file2_path)
64 | with open(file2_path) as file2:
65 | self.assertEqual(filename2, file2.read())
66 | # 清理,删除这两个文件
67 | os.remove(file2_path) # 删除本地文件
68 | oss.delete(self.path, [filename1, filename2])
69 | self.assertFalse(oss.is_exist(self.path, filename1))
70 | self.assertFalse(oss.is_exist(self.path, filename2))
71 |
72 | def test_sign_url(self):
73 | """测试签发url"""
74 | # 上传个文件
75 | filename1 = str(ObjectId()) + ".jpg"
76 | self.assertFalse(oss.is_exist(self.path, filename1))
77 | oss.upload(self.path, filename1, filename1)
78 | self.assertTrue(oss.is_exist(self.path, filename1))
79 | # it creates an url for user agent
80 | url = oss.sign_url(self.path, filename1)
81 | # FIXME this only works in CI test
82 | self.assertEqual(url, f"http://127.0.0.1:5000/storage/test/{filename1}")
83 | # 清理,删除这个文件
84 | oss.delete(self.path, [filename1])
85 | self.assertFalse(oss.is_exist(self.path, filename1))
86 |
--------------------------------------------------------------------------------
/tests/base/test_test_utils.py:
--------------------------------------------------------------------------------
1 | from app.models.user import User
2 | from app.models.team import Team
3 | from app.models.project import ProjectSet, Project
4 | from app.models.file import File
5 | from tests import (
6 | DEFAULT_PROJECT_SETS_COUNT,
7 | DEFAULT_TEAMS_COUNT,
8 | DEFAULT_USERS_COUNT,
9 | MoeTestCase,
10 | )
11 |
12 |
13 | class TestTestUtilsCase(MoeTestCase):
14 | """测试 测试帮助函数"""
15 |
16 | def test_create_user(self):
17 | self.create_user("user")
18 |
19 | # 测试数量
20 | self.assertEqual(User.objects.count(), DEFAULT_USERS_COUNT + 1)
21 | # 测试名称
22 | self.assertIsNotNone(User.get_by_email("user@test.com"))
23 |
24 | def test_create_team(self):
25 | self.create_team("team")
26 |
27 | # 测试数量
28 | self.assertEqual(Team.objects.count(), DEFAULT_TEAMS_COUNT + 1)
29 | self.assertEqual(User.objects.count(), DEFAULT_USERS_COUNT + 1)
30 | # 测试名称
31 | team = Team.objects.skip(DEFAULT_TEAMS_COUNT).first()
32 | self.assertEqual(team.name, "team")
33 | self.assertIsNotNone(User.get_by_email("team-creator@test.com"))
34 |
35 | def test_create_project_set(self):
36 | self.create_project_set("project_set")
37 |
38 | # 测试数量
39 | self.assertEqual(ProjectSet.objects.count(), DEFAULT_PROJECT_SETS_COUNT + 2)
40 | self.assertEqual(Team.objects.count(), DEFAULT_TEAMS_COUNT + 1)
41 | self.assertEqual(User.objects.count(), DEFAULT_USERS_COUNT + 1)
42 | # 测试名称
43 | project_set = ProjectSet.objects(default=False).first()
44 | self.assertEqual(project_set.name, "project_set")
45 | team = Team.objects.skip(DEFAULT_TEAMS_COUNT).first()
46 | self.assertEqual(team.name, "project_set-team")
47 | self.assertIsNotNone(User.get_by_email("project_set-team-creator@test.com"))
48 |
49 | def test_create_project(self):
50 | self.create_project("project")
51 |
52 | # 测试数量
53 | self.assertEqual(Project.objects.count(), 1)
54 | self.assertEqual(ProjectSet.objects.count(), DEFAULT_PROJECT_SETS_COUNT + 2)
55 | self.assertEqual(Team.objects.count(), DEFAULT_TEAMS_COUNT + 1)
56 | self.assertEqual(User.objects.count(), DEFAULT_USERS_COUNT + 1)
57 | # 测试名称
58 | project = Project.objects.first()
59 | self.assertEqual(project.name, "project")
60 | project_set = ProjectSet.objects(default=False).first()
61 | self.assertEqual(project_set.name, "project-project_set")
62 | team = Team.objects.skip(DEFAULT_TEAMS_COUNT).first()
63 | self.assertEqual(team.name, "project-project_set-team")
64 | self.assertIsNotNone(
65 | User.get_by_email("project-project_set-team-creator@test.com")
66 | )
67 |
68 | def test_create_file(self):
69 | with self.app.test_request_context():
70 | self.create_file("file.txt")
71 |
72 | # 测试数量
73 | self.assertEqual(File.objects.count(), 1)
74 | self.assertEqual(Project.objects.count(), 1)
75 | self.assertEqual(ProjectSet.objects.count(), DEFAULT_PROJECT_SETS_COUNT + 2)
76 | self.assertEqual(Team.objects.count(), DEFAULT_TEAMS_COUNT + 1)
77 | self.assertEqual(User.objects.count(), DEFAULT_USERS_COUNT + 1)
78 | # 测试名称
79 | file = File.objects.first()
80 | self.assertEqual(file.name, "file.txt")
81 | project = Project.objects.first()
82 | self.assertEqual(project.name, "file.txt-project")
83 | project_set = ProjectSet.objects(default=False).first()
84 | self.assertEqual(project_set.name, "file.txt-project-project_set")
85 | team = Team.objects.skip(DEFAULT_TEAMS_COUNT).first()
86 | self.assertEqual(team.name, "file.txt-project-project_set-team")
87 | self.assertIsNotNone(
88 | User.get_by_email("file.txt-project-project_set-team-creator@test.com")
89 | )
90 |
--------------------------------------------------------------------------------
/app/apis/member.py:
--------------------------------------------------------------------------------
1 | from typing import Union
2 | from flask import request
3 |
4 | from app.core.responses import MoePagination
5 | from app.core.views import MoeAPIView
6 | from app.decorators.auth import token_required
7 | from app.decorators.url import fetch_model, fetch_group
8 | from app.exceptions import NoPermissionError
9 | from app.models.project import Project
10 | from app.models.team import Team
11 | from app.models.user import User
12 | from app.validators.member import ChangeMemberSchema
13 |
14 |
15 | class MemberListAPI(MoeAPIView):
16 | @token_required
17 | @fetch_group
18 | def get(self, group):
19 | """
20 | @api {get} /v1///users 获取团体的成员
21 | @apiVersion 1.0.0
22 | @apiName get_group_user
23 | @apiGroup Member
24 | @apiUse APIHeader
25 | @apiUse TokenHeader
26 |
27 | @apiParam {String} group_type 团体类型,支持 “team”、“project”
28 | @apiParam {String} group_id 团队 ID
29 | @apiSuccessExample {json} 返回示例
30 | [
31 | {
32 | name: ""
33 | ...
34 | }
35 | ]
36 | """
37 | if not self.current_user.can(group, group.permission_cls.ACCESS):
38 | raise NoPermissionError
39 | # 获取查询参数
40 | word = request.args.get("word")
41 | # 分页
42 | p = MoePagination()
43 | users = group.users(skip=p.skip, limit=p.limit, word=word)
44 | # 获取用户关系
45 | relations = group.relation_cls.objects(group=group, user__in=users)
46 | # 构建字典用于快速匹配
47 | user_roles_data = {}
48 | for relation in relations:
49 | user_roles_data[str(relation.user.id)] = relation.role.to_api()
50 | # 构建数据
51 | data = []
52 | for user in users:
53 | user_data = user.to_api()
54 | user_role_data = user_roles_data.get(str(user.id))
55 | if user_role_data:
56 | user_data["role"] = user_role_data
57 | else:
58 | user_data["role"] = None
59 | data.append(user_data)
60 | return p.set_data(data=data, count=users.count())
61 |
62 |
63 | class MemberAPI(MoeAPIView):
64 | @token_required
65 | @fetch_group
66 | @fetch_model(User)
67 | def put(self, group: Union[Project, Team], user: User):
68 | """
69 | @api {put} /v1///users/ 修改团体的成员
70 | @apiVersion 1.0.0
71 | @apiName edit_group_user
72 | @apiGroup Member
73 | @apiUse APIHeader
74 | @apiUse TokenHeader
75 |
76 | @apiParam {String} group_type 团体类型,支持 “team”、“project”
77 | @apiParam {String} group_id 团队 ID
78 | @apiParam {String} role 角色 ID
79 | @apiParamExample {json} 请求示例
80 | {
81 | "role": "roleID"
82 | }
83 |
84 | @apiSuccessExample {json} 返回示例
85 | {
86 | "message": "修改成功"
87 | }
88 | """
89 | # 处理请求数据
90 | data = self.get_json(ChangeMemberSchema())
91 | role = group.change_user_role(user, data["role"], operator=self.current_user)
92 | return {"message": "设置成功", "role": role.to_api()}
93 |
94 | @token_required
95 | @fetch_group
96 | @fetch_model(User)
97 | def delete(self, group: Union[Project, Team], user: User):
98 | """
99 | @api {delete} /v1///users/ 删除团体的成员
100 | @apiVersion 1.0.0
101 | @apiName delete_group_user
102 | @apiGroup Member
103 | @apiUse APIHeader
104 | @apiUse TokenHeader
105 |
106 | @apiParam {String} group_type 团体类型,支持 “team”、“project”
107 | @apiParam {String} group_id 团队 ID
108 | @apiSuccessExample {json} 返回示例
109 | {
110 | "message": "删除成功"
111 | }
112 | """
113 | return group.delete_uesr(user, operator=self.current_user)
114 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | # GENERATED: run make requirements.txt to recreate lock file
2 | asgiref==3.7.2
3 | Flask-APIKit==0.0.7
4 | Flask==2.2.5
5 | click==8.1.3
6 | itsdangerous==2.0.1
7 | Jinja2==3.1.6
8 | MarkupSafe==3.0.2
9 | Werkzeug==3.0.6
10 | MarkupSafe==3.0.2
11 | marshmallow==3.0.0b20
12 | flask-babel==4.0.0
13 | babel==2.17.0
14 | Flask==2.2.5
15 | click==8.1.3
16 | itsdangerous==2.0.1
17 | Jinja2==3.1.6
18 | MarkupSafe==3.0.2
19 | Werkzeug==3.0.6
20 | MarkupSafe==3.0.2
21 | Jinja2==3.1.6
22 | MarkupSafe==3.0.2
23 | pytz==2025.2
24 | flower==2.0.1
25 | celery==5.3.6
26 | billiard==4.2.1
27 | click==8.1.3
28 | click-didyoumean==0.3.1
29 | click==8.1.3
30 | click-plugins==1.1.1
31 | click==8.1.3
32 | click-repl==0.3.0
33 | click==8.1.3
34 | prompt_toolkit==3.0.51
35 | wcwidth==0.2.13
36 | kombu==5.5.3
37 | amqp==5.3.1
38 | vine==5.1.0
39 | tzdata==2025.2
40 | vine==5.1.0
41 | python-dateutil==2.9.0.post0
42 | six==1.17.0
43 | tzdata==2025.2
44 | vine==5.1.0
45 | humanize==4.12.3
46 | prometheus_client==0.21.1
47 | pytz==2025.2
48 | tornado==6.4.2
49 | google-cloud-storage==1.33.0
50 | google-auth==1.35.0
51 | cachetools==4.2.4
52 | pyasn1_modules==0.4.2
53 | pyasn1==0.6.1
54 | rsa==4.9.1
55 | pyasn1==0.6.1
56 | setuptools==65.5.0
57 | six==1.17.0
58 | google-cloud-core==1.7.3
59 | google-api-core==2.10.2
60 | google-auth==1.35.0
61 | cachetools==4.2.4
62 | pyasn1_modules==0.4.2
63 | pyasn1==0.6.1
64 | rsa==4.9.1
65 | pyasn1==0.6.1
66 | setuptools==65.5.0
67 | six==1.17.0
68 | googleapis-common-protos==1.70.0
69 | protobuf==4.25.7
70 | protobuf==4.25.7
71 | requests==2.22.0
72 | certifi==2025.4.26
73 | chardet==3.0.4
74 | idna==2.8
75 | urllib3==1.25.11
76 | google-auth==1.35.0
77 | cachetools==4.2.4
78 | pyasn1_modules==0.4.2
79 | pyasn1==0.6.1
80 | rsa==4.9.1
81 | pyasn1==0.6.1
82 | setuptools==65.5.0
83 | six==1.17.0
84 | six==1.17.0
85 | google-resumable-media==1.3.3
86 | google-crc32c==1.7.1
87 | six==1.17.0
88 | requests==2.22.0
89 | certifi==2025.4.26
90 | chardet==3.0.4
91 | idna==2.8
92 | urllib3==1.25.11
93 | gunicorn==20.0.4
94 | setuptools==65.5.0
95 | mongoengine==0.20.0
96 | pymongo==3.13.0
97 | mongomock==4.1.2
98 | packaging==25.0
99 | sentinels==1.0.0
100 | oss2==2.7.0
101 | aliyun-python-sdk-core-v3==2.13.3
102 | jmespath==0.10.0
103 | pycryptodome==3.22.0
104 | aliyun-python-sdk-kms==2.16.5
105 | aliyun-python-sdk-core==2.16.0
106 | cryptography==44.0.3
107 | cffi==1.17.1
108 | pycparser==2.22
109 | jmespath==0.10.0
110 | crcmod==1.7
111 | pycryptodome==3.22.0
112 | requests==2.22.0
113 | certifi==2025.4.26
114 | chardet==3.0.4
115 | idna==2.8
116 | urllib3==1.25.11
117 | Pillow==9.5.0
118 | pip==22.3
119 | pipdeptree==2.13.2
120 | pytest-cov==5.0.0
121 | coverage==7.8.0
122 | pytest==8.2.1
123 | iniconfig==2.1.0
124 | packaging==25.0
125 | pluggy==1.5.0
126 | pytest-dotenv==0.5.2
127 | pytest==8.2.1
128 | iniconfig==2.1.0
129 | packaging==25.0
130 | pluggy==1.5.0
131 | python-dotenv==1.0.1
132 | pytest-html==4.1.1
133 | Jinja2==3.1.6
134 | MarkupSafe==3.0.2
135 | pytest==8.2.1
136 | iniconfig==2.1.0
137 | packaging==25.0
138 | pluggy==1.5.0
139 | pytest-metadata==3.1.1
140 | pytest==8.2.1
141 | iniconfig==2.1.0
142 | packaging==25.0
143 | pluggy==1.5.0
144 | pytest-md==0.2.0
145 | pytest==8.2.1
146 | iniconfig==2.1.0
147 | packaging==25.0
148 | pluggy==1.5.0
149 | pytest-xdist==3.6.1
150 | execnet==2.1.1
151 | pytest==8.2.1
152 | iniconfig==2.1.0
153 | packaging==25.0
154 | pluggy==1.5.0
155 | redis==5.0.3
156 | async-timeout==5.0.1
157 | ruff==0.11.8
158 |
--------------------------------------------------------------------------------
/app/constants/file.py:
--------------------------------------------------------------------------------
1 | from flask_babel import lazy_gettext
2 | from app.constants.base import IntType
3 |
4 |
5 | class FileType(IntType):
6 | UNKNOWN = 0 # 未知
7 | FOLDER = 1 # 文件夹
8 | IMAGE = 2 # 图片
9 | TEXT = 3 # 纯文本
10 |
11 | SUPPORTED = (IMAGE,) # 支持用于翻译的格式,在上传时检查
12 | TEST_SUPPORTED = (IMAGE, TEXT) # 支持用于翻译的格式,在上传时检查(用于测试环境)
13 |
14 | @staticmethod
15 | def by_suffix(suffix):
16 | # 后缀名转suffix对照表
17 | suffix_type_map = {
18 | "jpg": FileType.IMAGE,
19 | "jpeg": FileType.IMAGE,
20 | "png": FileType.IMAGE,
21 | "bmp": FileType.IMAGE,
22 | "gif": FileType.IMAGE,
23 | "txt": FileType.TEXT,
24 | }
25 | t = suffix_type_map.get(suffix.lower(), FileType.UNKNOWN)
26 | return t
27 |
28 |
29 | class FileNotExistReason:
30 | """源文件不存在的原因"""
31 |
32 | UNKNOWN = 0 # 未知
33 | NOT_UPLOAD = 1 # 还没有上传
34 | FINISH = 2 # 因为完结被删除
35 | # 3 还未使用
36 | BLOCK = 4 # 因为屏蔽被删除
37 |
38 |
39 | class FileSafeStatus:
40 | """安全检查状态"""
41 |
42 | # 第一步
43 | NEED_MACHINE_CHECK = 0 # 需要机器检测
44 | QUEUING = 1 # 机器检测排队中
45 | WAIT_RESULT = 2 # 机器检测等待结果
46 | # 第二步(根据机器检测结果)
47 | NEED_HUMAN_CHECK = 3 # 需要人工检查
48 | # 第三步
49 | SAFE = 4 # 已检测安全
50 | BLOCK = 5 # 文件被删除屏蔽,需要重新上传
51 |
52 |
53 | class ParseStatus(IntType):
54 | NOT_START = 0 # 未开始
55 | QUEUING = 1 # 排队中
56 | PARSING = 2 # 解析中
57 | PARSE_FAILED = 3 # 解析失败
58 | PARSE_SUCCEEDED = 4 # 解析成功
59 |
60 | details = {
61 | "NOT_START": {"name": lazy_gettext("解析未开始")},
62 | "QUEUING": {"name": lazy_gettext("解析排队中")},
63 | "PARSING": {"name": lazy_gettext("解析中")},
64 | "PARSE_FAILED": {"name": lazy_gettext("解析失败")},
65 | "PARSE_SUCCEEDED": {"name": lazy_gettext("解析成功")},
66 | }
67 |
68 |
69 | class ImageParseStatus(ParseStatus):
70 | details = {
71 | "NOT_START": {"name": lazy_gettext("自动标记未开始")},
72 | "QUEUING": {"name": lazy_gettext("排队中")},
73 | "PARSING": {"name": lazy_gettext("自动标记中")},
74 | "PARSE_FAILED": {"name": lazy_gettext("自动标记失败")},
75 | "PARSE_SUCCEEDED": {"name": lazy_gettext("自动标记完成")},
76 | }
77 |
78 |
79 | class ParseErrorType(IntType):
80 | UNKNOWN = 0 # 其他错误
81 | TEXT_UNKNOWN_CHARSET = 1 # 未知字符集
82 | FILE_CAN_NOT_READ = 2 # 文件无法读取,请确认文件完好或尝试重新上传
83 | IMAGE_PARSE_ALONE_ERROR = 3 # 图片单独处理时读取失败
84 | IMAGE_CAN_NOT_DOWNLOAD_FROM_OSS = 4 # 图片无法从 OSS 下载
85 | IMAGE_TOO_LARGE = 5 # 图片超过 20MB 无法 OCR
86 | IMAGE_OCR_SERVER_DISCONNECT = 6 # 连接 OCR 服务器失败,请稍后重试
87 |
88 | details = {
89 | "UNKNOWN": {"name": lazy_gettext("其他错误")},
90 | "TEXT_UNKNOWN_CHARSET": {"name": lazy_gettext("未知字符集")},
91 | "FILE_CAN_NOT_READ": {
92 | "name": lazy_gettext("文件无法读取,请确认文件完好或尝试重新上传")
93 | },
94 | "IMAGE_PARSE_ALONE_ERROR": {
95 | "name": lazy_gettext("图片读取失败,请稍后再试(1)")
96 | },
97 | "IMAGE_CAN_NOT_DOWNLOAD_FROM_OSS": {
98 | "name": lazy_gettext("图片读取失败,请稍后再试(2)")
99 | },
100 | "IMAGE_TOO_LARGE": {"name": lazy_gettext("图片超过 20MB 无法标记")},
101 | "IMAGE_OCR_SERVER_DISCONNECT": {
102 | "name": lazy_gettext("自动标记服务离线,请稍后再试")
103 | },
104 | }
105 |
106 |
107 | class ImageOCRPercent(IntType):
108 | QUEUING = 0
109 | WAITING_PARSE_ALONE = 1 # 等待单独OCR
110 | DOWALOADING = 10 # 下载图片
111 | MERGING = 20 # 已下载,合并中
112 | OCRING = 55 # 已合并,OCR中
113 | LABELING = 90 # 已OCR,标记中
114 | FINISHED = 100
115 |
116 | details = {
117 | "QUEUING": {"name": lazy_gettext("已加入队列")},
118 | "WAITING_PARSE_ALONE": {"name": lazy_gettext("重试中")},
119 | "DOWALOADING": {"name": lazy_gettext("翻找图片中")},
120 | "MERGING": {"name": lazy_gettext("整理数据中")},
121 | "OCRING": {"name": lazy_gettext("图片识别中")},
122 | "LABELING": {"name": lazy_gettext("标记中")},
123 | "FINISHED": {"name": lazy_gettext("自动标记完成")},
124 | }
125 |
126 |
127 | class FindTermsStatus:
128 | QUEUING = 0 # 排队中
129 | FINDING = 1 # 寻找中
130 | FINISHED = 2 # 解析成功
131 |
--------------------------------------------------------------------------------
/app/utils/logging.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | """
3 | from app.utils.logging import logger
4 | 使用 logger 记录即可
5 | """
6 |
7 | import os
8 | import logging
9 | from logging.handlers import SMTPHandler
10 | from flask import Flask
11 | from typing import Optional
12 |
13 | logger = logging.getLogger(__name__)
14 | root_logger = logging.getLogger("root")
15 |
16 |
17 | class SMTPSSLHandler(SMTPHandler):
18 | """使用SMTP_SSL以支持SSL"""
19 |
20 | def emit(self, record):
21 | """
22 | Emit a record.
23 |
24 | Format the record and send it to the specified addressees.
25 | """
26 | try:
27 | import smtplib
28 | from email.message import EmailMessage
29 | import email.utils
30 |
31 | port = self.mailport
32 | if not port:
33 | port = smtplib.SMTP_PORT
34 | smtp = smtplib.SMTP_SSL(self.mailhost, port, timeout=self.timeout)
35 | msg = EmailMessage()
36 | msg["From"] = self.fromaddr
37 | msg["To"] = ",".join(self.toaddrs)
38 | msg["Subject"] = self.getSubject(record)
39 | msg["Date"] = email.utils.localtime()
40 | msg.set_content(self.format(record))
41 | if self.username:
42 | if self.secure is not None:
43 | smtp.ehlo()
44 | smtp.starttls(*self.secure)
45 | smtp.ehlo()
46 | smtp.login(self.username, self.password)
47 | smtp.send_message(msg)
48 | smtp.quit()
49 | except Exception:
50 | self.handleError(record)
51 |
52 |
53 | _logger_configured = False
54 |
55 |
56 | def configure_root_logger(override: Optional[str] = None):
57 | global _logger_configured
58 | if _logger_configured:
59 | raise AssertionError("configure_root_logger already executed")
60 | _logger_configured = True
61 | logging.debug(
62 | "configuring root logger %s %s",
63 | root_logger.level,
64 | root_logger.getEffectiveLevel(),
65 | )
66 | level = override or os.environ.get("LOG_LEVEL")
67 | if not level:
68 | return
69 | logging.basicConfig(
70 | format="[%(asctime)s] %(levelname)s %(name)s %(message)s",
71 | datefmt="%Y-%m-%dT%H:%M:%S%z",
72 | force=True, # why the f is this required?
73 | level=getattr(logging, level.upper()),
74 | )
75 | logging.debug("reset log level %s", level)
76 |
77 |
78 | def configure_extra_logs(app: Flask):
79 | if app.config.get("ENABLE_LOG_EMAIL"):
80 | _enable_email_error_log(app)
81 | if app.config.get("LOG_PATH"):
82 | _enable_file_log(app)
83 |
84 |
85 | def _enable_file_log(app: Flask):
86 | file_formatter = logging.Formatter(
87 | "[%(asctime)s %(pathname)s:%(lineno)d] (%(levelname)s) %(message)s"
88 | )
89 | log_path = app.config.get("LOG_PATH")
90 | log_folder = os.path.dirname(log_path)
91 | if not os.path.isdir(log_folder):
92 | os.makedirs(log_folder)
93 | file_handler = logging.FileHandler(log_path)
94 | file_handler.setFormatter(file_formatter)
95 | logger.addHandler(file_handler)
96 |
97 |
98 | def _enable_email_error_log(app: Flask):
99 | # === 邮件输出 ===
100 |
101 | mail_formatter = logging.Formatter(
102 | """
103 | Message type: %(levelname)s
104 | Location: %(pathname)s:%(lineno)d
105 | Module: %(module)s
106 | Function: %(funcName)s
107 | Time: %(asctime)s
108 |
109 | Message:
110 |
111 | %(message)s
112 | """
113 | )
114 | mail_handler = SMTPSSLHandler(
115 | (app.config["EMAIL_SMTP_HOST"], app.config["EMAIL_SMTP_PORT"]),
116 | app.config["EMAIL_ADDRESS"],
117 | app.config["EMAIL_ERROR_ADDRESS"],
118 | "萌翻站点发生错误",
119 | credentials=(
120 | app.config["EMAIL_ADDRESS"],
121 | app.config["EMAIL_PASSWORD"],
122 | ),
123 | )
124 | mail_handler.setLevel(logging.ERROR)
125 | mail_handler.setFormatter(mail_formatter)
126 | logger.addHandler(mail_handler)
127 | app.logger.addHandler(mail_handler)
128 |
--------------------------------------------------------------------------------
/tests/base/test_regex.py:
--------------------------------------------------------------------------------
1 | import re
2 |
3 | from app.regexs import EMAIL_REGEX
4 | from tests import MoeTestCase
5 |
6 |
7 | class RegexTestCase(MoeTestCase):
8 | def test_email_regex(self):
9 | # 禁止的邮箱格式
10 | self.assertIsNone(re.match(EMAIL_REGEX, "a"))
11 | self.assertIsNone(re.match(EMAIL_REGEX, "aaa"))
12 | self.assertIsNone(re.match(EMAIL_REGEX, "a."))
13 | self.assertIsNone(re.match(EMAIL_REGEX, "aaa."))
14 | self.assertIsNone(re.match(EMAIL_REGEX, "a.a"))
15 | self.assertIsNone(re.match(EMAIL_REGEX, "aaa.a"))
16 | self.assertIsNone(re.match(EMAIL_REGEX, "a.aaa"))
17 | self.assertIsNone(re.match(EMAIL_REGEX, "aaa.aaa"))
18 | self.assertIsNone(re.match(EMAIL_REGEX, "a@a"))
19 | self.assertIsNone(re.match(EMAIL_REGEX, "aaa@aaa"))
20 | self.assertIsNone(re.match(EMAIL_REGEX, "a@a."))
21 | self.assertIsNone(re.match(EMAIL_REGEX, "aaa@aaa."))
22 | self.assertIsNone(re.match(EMAIL_REGEX, "a@.a"))
23 | self.assertIsNone(re.match(EMAIL_REGEX, "aaa@.aaa"))
24 | self.assertIsNone(re.match(EMAIL_REGEX, "a@a..c"))
25 | self.assertIsNone(re.match(EMAIL_REGEX, "aaa@aaa..c"))
26 | self.assertIsNone(re.match(EMAIL_REGEX, "a@a..aaa"))
27 | self.assertIsNone(re.match(EMAIL_REGEX, "aaa@aaa..aaa"))
28 | self.assertIsNone(re.match(EMAIL_REGEX, "a@a..aaa"))
29 | self.assertIsNone(re.match(EMAIL_REGEX, "aaa@aaa..aaa"))
30 | self.assertIsNone(re.match(EMAIL_REGEX, "a@a...aaa"))
31 | self.assertIsNone(re.match(EMAIL_REGEX, "aaa@aaa...aaa"))
32 | self.assertIsNone(re.match(EMAIL_REGEX, "aaa@.aaa.aaa"))
33 | self.assertIsNone(re.match(EMAIL_REGEX, "a@a.a"))
34 | self.assertIsNone(re.match(EMAIL_REGEX, "a@a.a.a.a.a.a"))
35 | self.assertIsNone(re.match(EMAIL_REGEX, "aaa@aaa.a"))
36 | self.assertIsNone(re.match(EMAIL_REGEX, "a@a.a."))
37 | self.assertIsNone(re.match(EMAIL_REGEX, "aaa@aaa.aaa."))
38 | self.assertIsNone(re.match(EMAIL_REGEX, "aaa@aaa.网"))
39 | # 多个 @
40 | self.assertIsNone(re.match(EMAIL_REGEX, "@aa@aa.aa"))
41 | self.assertIsNone(re.match(EMAIL_REGEX, "a@a@aa.aa"))
42 | self.assertIsNone(re.match(EMAIL_REGEX, "aa@@aa.aa"))
43 | self.assertIsNone(re.match(EMAIL_REGEX, "aa@a@a.aa"))
44 | self.assertIsNone(re.match(EMAIL_REGEX, "aa@aa@.aa"))
45 | self.assertIsNone(re.match(EMAIL_REGEX, "aa@aa.@aa"))
46 | self.assertIsNone(re.match(EMAIL_REGEX, "aa@aa.a@a"))
47 | self.assertIsNone(re.match(EMAIL_REGEX, "aa@aa.aa@"))
48 | self.assertIsNone(re.match(EMAIL_REGEX, "aa@@@aa.aa"))
49 | # 空格
50 | self.assertIsNone(re.match(EMAIL_REGEX, " aa@aa.aa"))
51 | self.assertIsNone(re.match(EMAIL_REGEX, "a a@aa.aa"))
52 | self.assertIsNone(re.match(EMAIL_REGEX, "aa @aa.aa"))
53 | self.assertIsNone(re.match(EMAIL_REGEX, "aa@ aa.aa"))
54 | self.assertIsNone(re.match(EMAIL_REGEX, "aa@a a.aa"))
55 | self.assertIsNone(re.match(EMAIL_REGEX, "aa@aa .aa"))
56 | self.assertIsNone(re.match(EMAIL_REGEX, "aa@aa. aa"))
57 | self.assertIsNone(re.match(EMAIL_REGEX, "aa@aa.a a"))
58 | self.assertIsNone(re.match(EMAIL_REGEX, "aa@aa.aa "))
59 | # 允许个邮箱格式(只要符合 x@x.xx的都允许)
60 | self.assertIsNotNone(re.match(EMAIL_REGEX, "a@a.aa"))
61 | self.assertIsNotNone(re.match(EMAIL_REGEX, "aa@aa.aa"))
62 | self.assertIsNotNone(re.match(EMAIL_REGEX, "aaa@aaa.aa"))
63 | self.assertIsNotNone(re.match(EMAIL_REGEX, "aaa.aaa@aaa.aa"))
64 | self.assertIsNotNone(re.match(EMAIL_REGEX, "aaa.aaa.aaa@aaa.aaa.aa"))
65 | self.assertIsNotNone(re.match(EMAIL_REGEX, "万维网.aaa.cn@万维网.aaa.aa"))
66 | self.assertIsNotNone(re.match(EMAIL_REGEX, "aaa.aaa+aaa+123@aaa.aa"))
67 | self.assertIsNotNone(re.match(EMAIL_REGEX, "aaa...aaa@aaa.aa"))
68 | self.assertIsNotNone(re.match(EMAIL_REGEX, "...aaa...aaa...@aaa.aa"))
69 | # 后缀
70 | self.assertIsNotNone(re.match(EMAIL_REGEX, "a@a.aaa"))
71 | self.assertIsNotNone(re.match(EMAIL_REGEX, "a@a.aaaa"))
72 | # 其他
73 | self.assertIsNotNone(re.match(EMAIL_REGEX, "万维网.网站+子网@万维网.网站"))
74 | self.assertIsNotNone(
75 | re.match(EMAIL_REGEX, "aaaaaaaaaaaaa@aaaaaaaaaaaaa.aaaaaaaaaaaaa")
76 | )
77 |
--------------------------------------------------------------------------------
/app/models/output.py:
--------------------------------------------------------------------------------
1 | import os
2 | from app.utils import default
3 | from mongoengine.base.fields import ObjectIdField
4 | from mongoengine.fields import ListField
5 | from app.constants.output import OutputStatus, OutputTypes
6 | from app.exceptions.output import OutputNotExistError
7 | import datetime
8 | from flask import current_app
9 |
10 | from app.utils.logging import logger
11 | from mongoengine import DateTimeField, Document, IntField, ReferenceField, StringField
12 | from typing import List, TYPE_CHECKING
13 | import oss2
14 | from app import oss
15 |
16 | if TYPE_CHECKING:
17 | from app.models.project import Project
18 | from app.models.target import Target
19 | from app.models.user import User
20 |
21 |
22 | class Output(Document):
23 | """项目的导出"""
24 |
25 | project = ReferenceField("Project", db_field="p", required=True)
26 | target = ReferenceField("Target", db_field="t", required=True)
27 | user = ReferenceField("User", db_field="u") # 操作人
28 | status = IntField(db_field="s", default=OutputStatus.QUEUING)
29 | type = IntField(db_field="ty", default=OutputTypes.ALL)
30 | file_name = StringField(df_field="fn", default="")
31 | create_time = DateTimeField(db_field="ct", default=datetime.datetime.utcnow)
32 | file_ids_include = ListField(ObjectIdField(), default=list)
33 | file_ids_exclude = ListField(ObjectIdField(), default=list)
34 |
35 | @classmethod
36 | def create(
37 | cls,
38 | /,
39 | *,
40 | project: "Project",
41 | target: "Target",
42 | user: "User",
43 | type: int,
44 | file_ids_include: List[str] = None,
45 | file_ids_exclude: List[str] = None,
46 | ) -> "Output":
47 | output = cls(
48 | project=project,
49 | target=target,
50 | user=user,
51 | type=type,
52 | file_ids_include=file_ids_include,
53 | file_ids_exclude=file_ids_exclude,
54 | ).save()
55 | return output
56 |
57 | @classmethod
58 | def delete_real_files(cls, outputs):
59 | try:
60 | oss.delete(
61 | current_app.config["OSS_OUTPUT_PREFIX"],
62 | [str(output.id) + "/" + output.file_name for output in outputs],
63 | )
64 | oss.rmdir(
65 | [
66 | os.path.join(
67 | current_app.config["OSS_OUTPUT_PREFIX"], str(output.id)
68 | )
69 | for output in outputs
70 | ],
71 | )
72 | except oss2.exceptions.NoSuchKey as e:
73 | logger.error(e)
74 | except Exception as e:
75 | logger.error(e)
76 |
77 | def delete_real_file(self):
78 | try:
79 | oss.delete(
80 | current_app.config["OSS_OUTPUT_PREFIX"] + str(self.id) + "/",
81 | self.file_name,
82 | )
83 | except oss2.exceptions.NoSuchKey as e:
84 | logger.error(e)
85 | except Exception as e:
86 | logger.error(e)
87 |
88 | def clear(self):
89 | self.delete()
90 |
91 | @classmethod
92 | def by_id(cls, id):
93 | file = cls.objects(id=id).first()
94 | if file is None:
95 | raise OutputNotExistError
96 | return file
97 |
98 | def to_api(self):
99 | data = {
100 | "id": str(self.id),
101 | "project": self.project.to_api(),
102 | "target": self.target.to_api(),
103 | "user": default(self.user, None, "to_api"),
104 | "type": self.type,
105 | "status": self.status,
106 | "status_details": OutputStatus.to_api(),
107 | "file_ids_include": [str(id) for id in self.file_ids_include],
108 | "file_ids_exclude": [str(id) for id in self.file_ids_exclude],
109 | "create_time": self.create_time.isoformat(),
110 | }
111 | if self.status == OutputStatus.SUCCEEDED:
112 | data["link"] = oss.sign_url(
113 | current_app.config["OSS_OUTPUT_PREFIX"] + str(self.id) + "/",
114 | self.file_name,
115 | download=True,
116 | )
117 | return data
118 |
--------------------------------------------------------------------------------
/app/apis/role.py:
--------------------------------------------------------------------------------
1 | from typing import Union
2 | from app.core.responses import MoePagination
3 | from app.core.views import MoeAPIView
4 | from app.decorators.auth import token_required
5 | from app.decorators.url import fetch_group
6 | from app.exceptions import NoPermissionError
7 | from app.models.team import Team
8 | from app.models.project import Project
9 | from app.validators.role import RoleSchema
10 |
11 |
12 | class RoleListAPI(MoeAPIView):
13 | @token_required
14 | @fetch_group
15 | def get(self, group: Union[Team, Project]):
16 | """
17 | @api {get} /v1///roles 获取自定义角色
18 | @apiVersion 1.0.0
19 | @apiName get_team_role
20 | @apiGroup Role
21 | @apiUse APIHeader
22 | @apiUse TokenHeader
23 |
24 | @apiParam {String} group_type 团体类型,支持 “team”、“project”
25 | @apiParam {String} group_id 团队 ID
26 | @apiParam {String} team_id 团队id
27 |
28 | @apiSuccessExample {json} 返回示例
29 | {
30 |
31 | }
32 | """
33 | p = MoePagination()
34 | if not self.current_user.can(group, group.permission_cls.ACCESS):
35 | raise NoPermissionError
36 | objects = group.roles(skip=p.skip, limit=p.limit)
37 | return p.set_objects(objects)
38 |
39 | @token_required
40 | @fetch_group
41 | def post(self, group):
42 | """
43 | @api {post} /v1///roles 创建自定义角色
44 | @apiVersion 1.0.0
45 | @apiName create_team_role
46 | @apiGroup Role
47 | @apiUse APIHeader
48 | @apiUse TokenHeader
49 |
50 | @apiParam {String} group_type 团体类型,支持 “team”、“project”
51 | @apiParam {String} group_id 团队 ID
52 | @apiParam {String} set_id 项目集id
53 |
54 | @apiSuccessExample {json} 返回示例
55 | {
56 |
57 | }
58 | """
59 | if not self.current_user.can(group, group.permission_cls.CREATE_ROLE):
60 | raise NoPermissionError
61 | data = self.get_json(
62 | RoleSchema(),
63 | context={"current_user_role": self.current_user.get_role(group)},
64 | )
65 | group.create_role(
66 | name=data["name"],
67 | level=data["level"],
68 | permissions=data["permissions"],
69 | intro=data["intro"],
70 | operator=self.current_user,
71 | )
72 |
73 |
74 | class RoleAPI(MoeAPIView):
75 | @token_required
76 | @fetch_group
77 | def put(self, group: Union[Team, Project], role_id: str):
78 | """
79 | @api {put} /v1///roles/ 修改自定义角色
80 | @apiVersion 1.0.0
81 | @apiName create_team_role
82 | @apiGroup Role
83 | @apiUse APIHeader
84 | @apiUse TokenHeader
85 |
86 | @apiParam {String} group_type 团体类型,支持 “team”、“project”
87 | @apiParam {String} group_id 团队 ID
88 | @apiParam {String} role_id 角色id
89 |
90 | @apiSuccessExample {json} 返回示例
91 | {
92 |
93 | }
94 | """
95 | if not self.current_user.can(group, group.permission_cls.CREATE_ROLE):
96 | raise NoPermissionError
97 | data = self.get_json(
98 | RoleSchema(),
99 | context={"current_user_role": self.current_user.get_role(group)},
100 | )
101 | group.edit_role(
102 | id=role_id,
103 | name=data["name"],
104 | level=data["level"],
105 | permissions=data["permissions"],
106 | intro=data["intro"],
107 | operator=self.current_user,
108 | )
109 |
110 | @token_required
111 | @fetch_group
112 | def delete(self, group, role_id):
113 | """
114 | @api {delete} /v1///roles/ 删除自定义角色
115 | @apiVersion 1.0.0
116 | @apiName delete_team_role
117 | @apiGroup Role
118 | @apiUse APIHeader
119 | @apiUse TokenHeader
120 |
121 | @apiParam {String} group_type 团体类型,支持 “team”、“project”
122 | @apiParam {String} group_id 团队 ID
123 | @apiParam {String} role_id 角色id
124 |
125 | @apiSuccessExample {json} 返回示例
126 | {
127 |
128 | }
129 | """
130 | if not self.current_user.can(group, group.permission_cls.DELETE_ROLE):
131 | raise NoPermissionError
132 | group.delete_role(id=role_id)
133 |
--------------------------------------------------------------------------------
/app/tasks/import_from_labelplus.py:
--------------------------------------------------------------------------------
1 | """
2 | 导出项目
3 | """
4 |
5 | from app.constants.project import (
6 | ImportFromLabelplusErrorType,
7 | ImportFromLabelplusStatus,
8 | )
9 | from flask import Flask
10 |
11 | from app import celery
12 |
13 | from app.models import connect_db
14 | from . import SyncResult, _FORCE_SYNC_TASK
15 | from celery.utils.log import get_task_logger
16 | from app.utils.labelplus import load_from_labelplus
17 | from app.constants.source import SourcePositionType
18 | from celery.result import AsyncResult
19 |
20 | logger = get_task_logger(__name__)
21 |
22 |
23 | @celery.task(name="tasks.import_from_labelplus_task")
24 | def import_from_labelplus_task(project_id):
25 | """
26 | 压缩整个项目
27 |
28 | :param project_id: 项目ID
29 | :return:
30 | """
31 | from app.models.project import Project
32 | from app.models.team import Team
33 |
34 | (Project, Team)
35 | connect_db(celery.conf.app_config)
36 | app = Flask(__name__)
37 | app.config.from_object(celery.conf.app_config)
38 |
39 | project: Project = Project.objects(id=project_id).first()
40 | if project is None:
41 | return f"从 Labelplus 导入失败:项目不存在,Project {project_id}"
42 | target = project.targets().first()
43 | creator = project.users(role=project.role_cls.by_system_code("creator")).first()
44 | if target is None:
45 | project.update(
46 | import_from_labelplus_txt="",
47 | import_from_labelplus_status=ImportFromLabelplusStatus.ERROR,
48 | import_from_labelplus_error_type=ImportFromLabelplusErrorType.NO_TARGET,
49 | )
50 | return f"失败:目标语言不存在,Project {project_id}"
51 | if creator is None:
52 | project.update(
53 | import_from_labelplus_txt="",
54 | import_from_labelplus_status=ImportFromLabelplusStatus.ERROR,
55 | import_from_labelplus_error_type=ImportFromLabelplusErrorType.NO_CREATOR,
56 | )
57 | return f"失败:创建者不存在,Project {project_id}"
58 | try:
59 | if target and creator:
60 | project.update(
61 | import_from_labelplus_percent=0,
62 | import_from_labelplus_status=ImportFromLabelplusStatus.RUNNING,
63 | )
64 | labelplus_data = load_from_labelplus(project.import_from_labelplus_txt)
65 | file_count = len(labelplus_data)
66 | for file_index, labelplus_file in enumerate(labelplus_data):
67 | file = project.create_file(labelplus_file["file_name"])
68 | for labelplus_label in labelplus_file["labels"]:
69 | source = file.create_source(
70 | content="",
71 | x=labelplus_label["x"],
72 | y=labelplus_label["y"],
73 | position_type=SourcePositionType.IN
74 | if labelplus_label["position_type"] == SourcePositionType.IN
75 | else SourcePositionType.OUT,
76 | )
77 | source.create_translation(
78 | content=labelplus_label["translation"],
79 | target=target,
80 | user=creator,
81 | )
82 | project.update(
83 | import_from_labelplus_percent=int((file_index / file_count) * 100)
84 | )
85 | except Exception:
86 | logger.exception(Exception)
87 | project.update(
88 | import_from_labelplus_txt="",
89 | import_from_labelplus_status=ImportFromLabelplusStatus.ERROR,
90 | import_from_labelplus_error_type=ImportFromLabelplusErrorType.PARSE_FAILED,
91 | )
92 | return f"失败:解析/创建时发生错误,详见 log,Project {project_id}"
93 | project.update(
94 | import_from_labelplus_txt="",
95 | import_from_labelplus_percent=0,
96 | import_from_labelplus_status=ImportFromLabelplusStatus.SUCCEEDED,
97 | )
98 | return f"成功:Project {project_id}"
99 |
100 |
101 | def import_from_labelplus(project_id, /, *, run_sync=False) -> SyncResult | AsyncResult:
102 | alive_workers = celery.control.ping()
103 | if len(alive_workers) == 0 or run_sync or _FORCE_SYNC_TASK:
104 | # 同步执行
105 | import_from_labelplus_task(project_id)
106 | return SyncResult()
107 | else:
108 | # 异步执行
109 | return import_from_labelplus_task.delay(project_id)
110 |
--------------------------------------------------------------------------------
/app/tasks/email.py:
--------------------------------------------------------------------------------
1 | """
2 | 提供邮件SMTP异步发送服务
3 | """
4 |
5 | import email
6 | import smtplib
7 | from email.header import Header
8 | from email.mime.multipart import MIMEMultipart
9 | from email.mime.text import MIMEText
10 |
11 | from flask import render_template
12 |
13 | from app import celery
14 |
15 |
16 | @celery.task(name="tasks.email_task", time_limit=35)
17 | def email_task(
18 | to_address,
19 | subject,
20 | html_content=None,
21 | text_content=None,
22 | reply_address=None,
23 | from_address=None,
24 | from_username=None,
25 | ):
26 | """发送邮件"""
27 | if not celery.conf.app_config["ENABLE_USER_EMAIL"]:
28 | return "未开启用户邮件配置"
29 | email_smtp_host = celery.conf.app_config["EMAIL_SMTP_HOST"]
30 | email_smtp_port = celery.conf.app_config["EMAIL_SMTP_PORT"]
31 | email_use_ssl = celery.conf.app_config["EMAIL_USE_SSL"]
32 | email_address = celery.conf.app_config["EMAIL_ADDRESS"]
33 | email_username = celery.conf.app_config["EMAIL_USERNAME"]
34 | email_password = celery.conf.app_config["EMAIL_PASSWORD"]
35 | email_reply_address = celery.conf.app_config["EMAIL_REPLY_ADDRESS"]
36 | if from_address is None:
37 | from_address = email_address
38 | if from_username is None:
39 | from_username = email_username
40 | if reply_address is None:
41 | reply_address = email_reply_address
42 | # 构建alternative结构
43 | msg = MIMEMultipart("alternative")
44 | msg["Subject"] = Header(subject).encode()
45 | msg["From"] = "%s <%s>" % (Header(from_username).encode(), from_address)
46 | msg["To"] = (
47 | to_address # 收件人地址或是地址列表,支持多个收件人,最多30个 ['***', '***']
48 | )
49 | msg["Reply-to"] = reply_address # 自定义的回复地址
50 | msg["Message-id"] = email.utils.make_msgid()
51 | msg["Date"] = email.utils.formatdate()
52 | # 构建alternative的text/html部分
53 | text_html = MIMEText(html_content.encode(), _subtype="html", _charset="UTF-8")
54 | msg.attach(text_html)
55 | # 构建alternative的text/plain部分
56 | if text_content:
57 | text_plain = MIMEText(text_content.encode(), _subtype="plain", _charset="UTF-8")
58 | msg.attach(text_plain)
59 | # 发送邮件
60 | try:
61 | # 是否使用ssl
62 | if email_use_ssl:
63 | client = smtplib.SMTP_SSL(email_smtp_host, email_smtp_port)
64 | else:
65 | client = smtplib.SMTP(email_smtp_host, email_smtp_port)
66 | if not email_use_ssl:
67 | try:
68 | client.starttls()
69 | # client.ehlo_or_helo_if_needed()
70 | except smtplib.SMTPNotSupportedError:
71 | pass
72 | # 开启DEBUG模式
73 | client.set_debuglevel(0)
74 | client.login(from_username, email_password)
75 | # 发件人和认证地址必须一致
76 | # 备注:若想取到DATA命令返回值,可参考smtplib的sendmaili封装方法:
77 | # 使用SMTP.mail/SMTP.rcpt/SMTP.data方法
78 | client.sendmail(from_address, to_address, msg.as_string())
79 | client.quit()
80 | return "发送成功"
81 | except smtplib.SMTPConnectError as e:
82 | return "发送失败,连接失败:", str(e.smtp_code), str(e.smtp_error)
83 | except smtplib.SMTPAuthenticationError as e:
84 | return "发送失败,认证错误:", str(e.smtp_code), str(e.smtp_error)
85 | except smtplib.SMTPSenderRefused as e:
86 | return "发送失败,发件人被拒绝:", str(e.smtp_code), str(e.smtp_error)
87 | except smtplib.SMTPRecipientsRefused as e:
88 | return "发送失败,收件人被拒绝:", str(e.smtp_code), str(e.smtp_error)
89 | except smtplib.SMTPDataError as e:
90 | return "发送失败,数据接收拒绝:", str(e.smtp_code), str(e.smtp_error)
91 | except smtplib.SMTPException as e:
92 | return "发送失败, ", str(e.message)
93 | except Exception as e:
94 | return "发送异常, ", str(e)
95 |
96 |
97 | def send_email(
98 | to_address,
99 | subject,
100 | html_content=None,
101 | text_content=None,
102 | reply_address=None,
103 | from_address=None,
104 | from_username=None,
105 | template=None,
106 | template_data=None,
107 | ):
108 | # 如果提供了模板,则使用模板创建内容
109 | if template:
110 | html_content = render_template(template + ".html", **template_data)
111 | text_content = render_template(template + ".txt", **template_data)
112 | email_task.delay(
113 | to_address,
114 | subject,
115 | html_content,
116 | text_content,
117 | reply_address,
118 | from_address,
119 | from_username,
120 | )
121 |
--------------------------------------------------------------------------------
/app/apis/translation.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | from app.core.views import MoeAPIView
3 | from app.decorators.auth import token_required
4 | from app.decorators.url import fetch_model
5 | from app.exceptions import NoPermissionError
6 | from app.models.file import Source, Translation
7 | from app.models.project import ProjectPermission
8 | from app.validators.translation import (
9 | CreateTranslationSchema,
10 | EditTranslationSchema,
11 | )
12 |
13 |
14 | class SourceTranslationListAPI(MoeAPIView):
15 | @token_required
16 | @fetch_model(Source)
17 | def post(self, source):
18 | """
19 | @api {post} /v1/sources//translations 新建/修改我的翻译
20 | @apiVersion 1.0.0
21 | @apiName add_translation
22 | @apiGroup Translation
23 | @apiUse APIHeader
24 | @apiUse TokenHeader
25 |
26 | @apiParam {String} content 翻译内容,当内容为空时,删除我的翻译并返回204
27 | @apiParam {String} target_id 目标语言ID
28 |
29 | @apiSuccessExample {json} 返回示例
30 | {
31 |
32 | }
33 | """
34 | if not self.current_user.can(source.file.project, ProjectPermission.ADD_TRA):
35 | raise NoPermissionError
36 | data = self.get_json(CreateTranslationSchema())
37 | target = source.file.project.target_by_id(data["target_id"])
38 | translation = source.create_translation(
39 | data["content"], target=target, user=self.current_user
40 | )
41 | if translation:
42 | return translation.to_api()
43 |
44 |
45 | class TranslationAPI(MoeAPIView):
46 | @token_required
47 | @fetch_model(Translation)
48 | def put(self, translation):
49 | """
50 | @api {put} /v1/translations/ 修改、校对、选定翻译
51 | @apiVersion 1.0.0
52 | @apiName edit_translation
53 | @apiGroup Translation
54 | @apiUse APIHeader
55 | @apiUse TokenHeader
56 |
57 | @apiParam {String} [content] 翻译内容
58 | @apiParam {String} [proofread_content] 校对内容
59 | @apiParam {Boolean} [selected] 是否选定
60 |
61 | @apiSuccessExample {json} 返回示例
62 | {
63 |
64 | }
65 | """
66 | data = self.get_json(EditTranslationSchema())
67 | if "selected" in data: # 检查是否有校对权限
68 | if not self.current_user.can(
69 | translation.source.file.project, ProjectPermission.CHECK_TRA
70 | ):
71 | raise NoPermissionError
72 | if data["selected"] is True:
73 | translation.select(user=self.current_user)
74 | else:
75 | translation.unselect()
76 | translation.reload()
77 | if "content" in data: # 仅可以修改自己的翻译
78 | # !这个前端未用的,目前修改/新增自己的翻译都是调用新增翻译接口,未来可能用于修改他人翻译
79 | if translation.user != self.current_user:
80 | raise NoPermissionError
81 | if data["content"] == "" and translation.proofread_content == "":
82 | translation.clear()
83 | return
84 | translation.content = data["content"]
85 | translation.update_cache("edit_time", datetime.datetime.utcnow())
86 | if "proofread_content" in data: # 检查是否有校对权限
87 | if not self.current_user.can(
88 | translation.source.file.project,
89 | ProjectPermission.PROOFREAD_TRA,
90 | ):
91 | raise NoPermissionError
92 | if data["proofread_content"] == "" and translation.content == "":
93 | translation.clear()
94 | return
95 | translation.proofread_content = data["proofread_content"]
96 | if data["proofread_content"] == "":
97 | translation.proofreader = None
98 | else:
99 | translation.proofreader = self.current_user
100 | translation.update_cache("edit_time", datetime.datetime.utcnow())
101 | translation.save()
102 | return translation.to_api()
103 |
104 | @token_required
105 | @fetch_model(Translation)
106 | def delete(self, translation):
107 | """
108 | @api {delete} /v1/translations/ 删除翻译
109 | @apiVersion 1.0.0
110 | @apiName delete_translation
111 | @apiGroup Translation
112 | @apiUse APIHeader
113 | @apiUse TokenHeader
114 |
115 | @apiSuccessExample {json} 返回示例
116 | {
117 |
118 | }
119 | """
120 | if translation.user != self.current_user and not self.current_user.can(
121 | translation.source.file.project, ProjectPermission.DELETE_TRA
122 | ):
123 | raise NoPermissionError
124 | translation.clear()
125 |
--------------------------------------------------------------------------------
/files/fonts/OFL.txt:
--------------------------------------------------------------------------------
1 | Copyright 2020 The Open Sans Project Authors (https://github.com/googlefonts/opensans)
2 |
3 | This Font Software is licensed under the SIL Open Font License, Version 1.1.
4 | This license is copied below, and is also available with a FAQ at:
5 | http://scripts.sil.org/OFL
6 |
7 |
8 | -----------------------------------------------------------
9 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10 | -----------------------------------------------------------
11 |
12 | PREAMBLE
13 | The goals of the Open Font License (OFL) are to stimulate worldwide
14 | development of collaborative font projects, to support the font creation
15 | efforts of academic and linguistic communities, and to provide a free and
16 | open framework in which fonts may be shared and improved in partnership
17 | with others.
18 |
19 | The OFL allows the licensed fonts to be used, studied, modified and
20 | redistributed freely as long as they are not sold by themselves. The
21 | fonts, including any derivative works, can be bundled, embedded,
22 | redistributed and/or sold with any software provided that any reserved
23 | names are not used by derivative works. The fonts and derivatives,
24 | however, cannot be released under any other type of license. The
25 | requirement for fonts to remain under this license does not apply
26 | to any document created using the fonts or their derivatives.
27 |
28 | DEFINITIONS
29 | "Font Software" refers to the set of files released by the Copyright
30 | Holder(s) under this license and clearly marked as such. This may
31 | include source files, build scripts and documentation.
32 |
33 | "Reserved Font Name" refers to any names specified as such after the
34 | copyright statement(s).
35 |
36 | "Original Version" refers to the collection of Font Software components as
37 | distributed by the Copyright Holder(s).
38 |
39 | "Modified Version" refers to any derivative made by adding to, deleting,
40 | or substituting -- in part or in whole -- any of the components of the
41 | Original Version, by changing formats or by porting the Font Software to a
42 | new environment.
43 |
44 | "Author" refers to any designer, engineer, programmer, technical
45 | writer or other person who contributed to the Font Software.
46 |
47 | PERMISSION & CONDITIONS
48 | Permission is hereby granted, free of charge, to any person obtaining
49 | a copy of the Font Software, to use, study, copy, merge, embed, modify,
50 | redistribute, and sell modified and unmodified copies of the Font
51 | Software, subject to the following conditions:
52 |
53 | 1) Neither the Font Software nor any of its individual components,
54 | in Original or Modified Versions, may be sold by itself.
55 |
56 | 2) Original or Modified Versions of the Font Software may be bundled,
57 | redistributed and/or sold with any software, provided that each copy
58 | contains the above copyright notice and this license. These can be
59 | included either as stand-alone text files, human-readable headers or
60 | in the appropriate machine-readable metadata fields within text or
61 | binary files as long as those fields can be easily viewed by the user.
62 |
63 | 3) No Modified Version of the Font Software may use the Reserved Font
64 | Name(s) unless explicit written permission is granted by the corresponding
65 | Copyright Holder. This restriction only applies to the primary font name as
66 | presented to the users.
67 |
68 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69 | Software shall not be used to promote, endorse or advertise any
70 | Modified Version, except to acknowledge the contribution(s) of the
71 | Copyright Holder(s) and the Author(s) or with their explicit written
72 | permission.
73 |
74 | 5) The Font Software, modified or unmodified, in part or in whole,
75 | must be distributed entirely under this license, and must not be
76 | distributed under any other license. The requirement for fonts to
77 | remain under this license does not apply to any document created
78 | using the Font Software.
79 |
80 | TERMINATION
81 | This license becomes null and void if any of the above conditions are
82 | not met.
83 |
84 | DISCLAIMER
85 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93 | OTHER DEALINGS IN THE FONT SOFTWARE.
94 |
--------------------------------------------------------------------------------