├── 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 | [![codecov](https://codecov.io/gh/moeflow-com/moeflow-backend/graph/badge.svg?token=LQJBLB495F)](https://codecov.io/gh/moeflow-com/moeflow-backend) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=moeflow-com_moeflow-backend&metric=alert_status)](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 |
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/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 | ![img](pic.jpg) 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 | --------------------------------------------------------------------------------