Compare commits

..
57 Commits
Author SHA1 Message Date
Wu Clan 74df1d1ce2 Bump sqlalchemy crud plus from 0.12.0 to 0.12.1 (#848) 2025-10-13 10:45:49 +08:00
Wu Clan 54e09b68c7 Bump fastapi and sqlalchemy to support python 3.14 (#847) 2025-10-11 13:19:10 +08:00
Wu Clan 5762834744 Update the ruff rules and format the code (#846)
* Update the ruff rules and format the code

* Update the per-file-ignores

* Update the ci

* Update rules

* Fix codes

* Fix pagination

* Update rules
2025-10-10 19:02:49 +08:00
Wu Clan 51354593d0 Bump dependencies and pre-commits (#845) 2025-10-09 13:26:56 +08:00
Wu Clan 8751425e2b Fix SQL script syntax error in config plugin (#843) 2025-09-27 12:33:58 +08:00
Wu Clan 921f05fa19 Fix the pgsql script in dict plugin (#842) 2025-09-27 12:17:45 +08:00
Wu Clan 5754c5449d Update the changelog for v1.8.2 (#841) 2025-09-26 13:32:05 +08:00
Wu Clan 2f535e3510 Update the version number to 1.8.2 (#840) 2025-09-26 13:28:41 +08:00
Wu Clan c25e447530 Update docker and deployment script comments (#839)
* Update docker and deployment script comments

* Update celery stdout logfile conf
2025-09-26 13:18:34 +08:00
Wu Clan 13359b736c Fix the phone filter in the user list (#838) 2025-09-26 12:08:53 +08:00
Wu Clan 500f20d91c Add extension parameters to paging data (#835) 2025-09-26 12:03:41 +08:00
Wu Clan d4de42fb46 Add the refresh token to demo mode exclusion (#834) 2025-09-26 12:03:27 +08:00
Wu Clan 3f0537a727 Simplify the code generation CLI parameters (#837) 2025-09-26 12:03:08 +08:00
Wu Clan d1cdb9e462 Fix the get model object method return logic (#832)
* Fix the get model object method return

* Update the error trigger method

* Delete the error log
2025-09-22 20:51:32 +08:00
Wu Clan 6c029b9be7 Fix code generation template missing Decimal (#830)
* Fix code generation template missing Decimal

* Update template rendering

* Update version number

* Fix lint
2025-09-22 10:46:13 +08:00
Wu Clan df12a0c701 Update the user agent in opera log model (#831) 2025-09-22 10:42:26 +08:00
Wu Clan 0be5685d9f Add version number output to the startup CLI (#820) 2025-09-17 11:11:50 +08:00
Wu Clan 3efca8af37 Update the dict plugin table structure (#817)
* Update the dict plugin table structure

* Update dict sql

* FIx the dict plugin sql
2025-09-15 09:04:30 +08:00
Wu Clan 82b2f808ac Fix the summary in opera log middleware (#816) 2025-09-14 13:22:24 +08:00
Wu Clan f417374f3e Update the login captcha verify logic (#815) 2025-09-12 21:02:21 +08:00
Wu Clan 237534c63e Update the startup progress information display (#812) 2025-09-12 15:44:17 +08:00
morty zhao b525124fdf Fix special character password support in alembic (#811) 2025-09-12 11:07:55 +08:00
Wu Clan 5217c95451 Fix summary default in opera log middleware (#809) 2025-09-11 18:21:40 +08:00
B ea973cac90 Fix pgsql syntax error in code generation (#808)
* Update title field in OperaLogSchemaBase to allow None values

* Update response type in get_all_tables to allow None values in dictionary

* Fix code generation error in PostgreSQL

* Update opera_log.py
2025-09-11 17:53:15 +08:00
Wu Clan 1bedda0e66 Update the offline location information parse (#807) 2025-09-11 14:49:09 +08:00
B c1087fa51d Fix the subprocess ensurepip got stuck in Linux (#806)
* Fix ensurepip_install

* fix code format
2025-09-10 19:42:13 +08:00
Wu Clan cfddf44e7e Update the swagger docs version to dynamic (#805) 2025-09-09 19:36:33 +08:00
Wu Clan 085b32e921 Update the changelog for v1.8.1 (#804) 2025-09-09 19:19:30 +08:00
Wu Clan 2099c33aad Fix alembic migration file missing import (#803) 2025-09-09 17:21:23 +08:00
yzbf b809497aba Fix the subprocess check call failed in windows (#802)
* fix: 修复Windows下subprocess调用失败的问题

* fix: 修复代码格式问题
2025-09-09 16:21:51 +08:00
Wu Clan a6fe8c0e8d Fix console import in main module (#800) 2025-09-07 19:33:55 +08:00
Wu Clan 629cf7ce45 Optimize the code generation interaction logic (#799) 2025-09-06 19:24:02 +08:00
Wu Clan fad6c05d41 Add the code generator to the CLI (#798)
* Add the code generator to the CLI

* Optimize run_await implementation

* Optimize interactions
2025-09-05 23:43:05 +08:00
Wu Clan 77d59d3453 Add schema for the tree data (#796) 2025-09-04 16:38:53 +08:00
Wu Clan 931d6b281a Fix the psycopg dependency naming (#795) 2025-09-04 11:54:39 +08:00
Wu Clan d929f0dbdc Fix sql script syntax error in config plugin (#793) 2025-09-03 19:57:11 +08:00
Wu Clan 6d1b47903e Add the dict type query all interface (#794) 2025-09-03 19:56:56 +08:00
Wu Clan 8420ccf190 Update the notice model and pagination (#792) 2025-09-02 15:51:07 +08:00
Wu Clan 1e38e1918b Bump dependencies and pre-commits (#791) 2025-09-02 12:45:34 +08:00
IAseven 52e78152a1 Fix the tzinfo comparison in the timezone type (#787) 2025-08-29 10:30:42 +08:00
Wu Clan dcb07c60f2 Add the Google OAuth2 login (#790) 2025-08-28 22:10:22 +08:00
Wu Clan 33c9f78cf0 Update the handling of CORS 500 status code (#789) 2025-08-28 15:36:06 +08:00
Wu Clan 37b1033697 Update the opera log exception message record (#788) 2025-08-28 15:01:01 +08:00
Wu Clan 0aa4ebc635 Update the model datetime column type to custom (#786)
* Update the model datetime column type to custom

* Update the schema datetime filed json encoder
2025-08-27 20:07:35 +08:00
language 30d4107670 Fix CLI openapi url in production environment (#785) 2025-08-26 09:39:30 +08:00
Wu Clan dcf235db78 Fix code generation template params and filename (#784) 2025-08-22 13:20:30 +08:00
Wu Clan 1dfcd7ae3c Update the celery task result table creation logic (#783)
* Update the celery task result table creation logic

* Disable beat_sync_every config

* Update the prepared comment
2025-08-21 23:46:52 +08:00
wuyao4 2b28244099 Fix the plugin model object detection logic (#782) 2025-08-21 18:17:50 +08:00
Wu Clan f71071f905 Fix the celery beat distributed lock timeout (#779)
* Fix the celery beat distributed lock timeout

* Fix redis client not close after plugin parse

* Optimize code layout and lock extend
2025-08-21 13:50:57 +08:00
Wu Clan 34c9c398ca Fix redis client not close after plugin parse (#780) 2025-08-21 10:58:53 +08:00
Wu Clan f099777dd3 Fix the docker compose env configuration file (#778)
* Fix the docker compose env configuration file

* Fix CELERY_RABBITMQ_HOST value
2025-08-19 13:21:11 +08:00
Wu Clan c95be82cb9 Update the env parameter value in env file (#777) 2025-08-19 12:50:08 +08:00
Wu Clan 00ec039095 Add test data SQL script for config plugin (#776) 2025-08-19 12:38:22 +08:00
Wu Clan 4d087355b4 Optimize email sending config update logic (#775) 2025-08-19 12:19:31 +08:00
Wu Clan d57fabcbb4 Add bulk update interface for config plugin (#774)
* Add bulk update interface for config plugin

* Remove email sending update
2025-08-19 12:18:38 +08:00
yanlingsishao 3a32522e2b Optimize the celery crontab expression validation (#773) 2025-08-18 22:19:59 +08:00
Wu Clanandwu-clan 22f8850bf7 Update the changelog for v1.8.0 (#772)
This commit was created by changelog-from-release in 'Release changelog' CI workflow

Co-authored-by: wu-clan <wu-clan@users.noreply.github.com>
2025-08-15 20:09:28 +08:00
297 changed files with 3827 additions and 3014 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
name: lint ${{ matrix.python-version }} name: lint ${{ matrix.python-version }}
strategy: strategy:
matrix: matrix:
python-version: [ '3.10', '3.11', '3.12', '3.13' ] python-version: [ '3.10', '3.11', '3.12', '3.13', '3.14' ]
fail-fast: false fail-fast: false
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
+5 -8
View File
@@ -1,26 +1,23 @@
repos: repos:
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0 rev: v6.0.0
hooks: hooks:
# - id: check-added-large-files - id: end-of-file-fixer
# - id: end-of-file-fixer
- id: check-yaml - id: check-yaml
- id: check-toml - id: check-toml
- repo: https://github.com/charliermarsh/ruff-pre-commit - repo: https://github.com/charliermarsh/ruff-pre-commit
rev: v0.11.9 rev: v0.14.0
hooks: hooks:
- id: ruff - id: ruff-check
args: args:
# https://github.com/astral-sh/ruff-pre-commit/issues/64 # https://github.com/astral-sh/ruff-pre-commit/issues/64
- '--config'
- 'backend/.ruff.toml'
- '--fix' - '--fix'
- '--unsafe-fixes' - '--unsafe-fixes'
- id: ruff-format - id: ruff-format
- repo: https://github.com/astral-sh/uv-pre-commit - repo: https://github.com/astral-sh/uv-pre-commit
rev: 0.7.3 rev: 0.9.0
hooks: hooks:
- id: uv-lock - id: uv-lock
- id: uv-export - id: uv-export
+174
View File
@@ -0,0 +1,174 @@
line-length = 120
preview = true
fix = true
unsafe-fixes = true
show-fixes = true
required-version = ">=0.13.0"
[lint]
select = [
"FAST",
"ANN001",
"ANN201",
"ANN202",
"ANN204",
"ANN205",
"ANN206",
"ASYNC110",
"ASYNC116",
"ASYNC210",
"ASYNC212",
"ASYNC230",
"ASYNC240",
"ASYNC250",
"ASYNC251",
"S310",
"FBT001",
"FBT002",
"B002",
"B005",
"B006",
"B007",
"B008",
"B009",
"B010",
"B013",
"B014",
"B019",
"B020",
"B021",
"B024",
"B025",
"B026",
"B027",
"B039",
"COM",
"C402",
"C403",
"C404",
"C408",
"C410",
"C411",
"C414",
"C416",
"C417",
"C418",
"C419",
"C420",
"DTZ",
"EXE",
"ISC001",
"ISC002",
"ISC003",
"PIE",
"PYI009",
"PYI010",
"PYI011",
"PYI012",
"PYI013",
"PYI016",
"PYI017",
"PYI019",
"PYI020",
"PYI021",
"PYI024",
"PYI026",
"PYI030",
"PYI033",
"PYI034",
"PYI036",
"PYI041",
"PYI042",
"PYI055",
"PYI061",
"PYI062",
"PYI063",
"Q001",
"Q002",
"RSE102",
"RET501",
"RET505",
"RET506",
"RET507",
"RET508",
"SIM101",
"SIM102",
"SIM103",
"SIM107",
"SIM108",
"SIM109",
"SIM110",
"SIM114",
"SIM115",
"SIM201",
"SIM202",
"SIM210",
"SIM211",
"SIM212",
"SIM300",
"SIM401",
"SIM910",
"TID252",
"TC",
"FLY",
"I",
"C901",
"N",
"PERF",
"E",
"W",
"D404",
"D417",
"D419",
"F",
"PGH",
"PLC1901",
"UP",
"FURB",
"RUF",
"TRY",
]
ignore = [
"COM812",
"PGH003",
"RUF001",
"RUF002",
"RUF003",
"RUF006",
"RUF012",
"TRY400",
"TRY003",
"TRY301"
]
[lint.per-file-ignores]
"**/model/*.py" = ["TC003"]
"backend/common/socketio/server.py" = ["ANN001"]
"backend/common/exception/exception_handler.py" = ["ANN202","RUF029"]
[lint.flake8-pytest-style]
parametrize-names-type = "list"
parametrize-values-row-type = "list"
parametrize-values-type = "list"
[lint.flake8-quotes]
inline-quotes = "single"
[lint.flake8-type-checking]
runtime-evaluated-base-classes = ["pydantic.BaseModel", "sqlalchemy.orm.DeclarativeBase"]
[lint.flake8-unused-arguments]
ignore-variadic-names = true
[lint.isort]
case-sensitive = true
lines-between-types = 1
order-by-type = true
[lint.pylint]
allow-dunder-method-names = ["__tablename__", "__table_args__"]
[format]
docstring-code-format = true
preview = true
quote-style = "single"
+126
View File
@@ -1,3 +1,126 @@
<a id="v1.8.2"></a>
# [v1.8.2](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.8.2) - 2025-09-26
## What's Changed
* Update changelog for v1.8.1 by [@wu-clan](https://github.com/wu-clan) in [#804](https://github.com/fastapi-practices/fastapi_best_architecture/pull/804)
* Update the swagger docs version to dynamic by [@wu-clan](https://github.com/wu-clan) in [#805](https://github.com/fastapi-practices/fastapi_best_architecture/pull/805)
* Fix the subprocess ensurepip got stuck in Linux by [@byte-voyager](https://github.com/byte-voyager) in [#806](https://github.com/fastapi-practices/fastapi_best_architecture/pull/806)
* Update the offline location information parse by [@wu-clan](https://github.com/wu-clan) in [#807](https://github.com/fastapi-practices/fastapi_best_architecture/pull/807)
* Fix pgsql syntax error in code generation by [@byte-voyager](https://github.com/byte-voyager) in [#808](https://github.com/fastapi-practices/fastapi_best_architecture/pull/808)
* Fix summary default in opera log middleware by [@wu-clan](https://github.com/wu-clan) in [#809](https://github.com/fastapi-practices/fastapi_best_architecture/pull/809)
* Fix special character password support in alembic by [@MortyZhaoy](https://github.com/MortyZhaoy) in [#811](https://github.com/fastapi-practices/fastapi_best_architecture/pull/811)
* Update the startup progress information display by [@wu-clan](https://github.com/wu-clan) in [#812](https://github.com/fastapi-practices/fastapi_best_architecture/pull/812)
* Update the login captcha verify logic by [@wu-clan](https://github.com/wu-clan) in [#815](https://github.com/fastapi-practices/fastapi_best_architecture/pull/815)
* Fix the summary in opera log middleware by [@wu-clan](https://github.com/wu-clan) in [#816](https://github.com/fastapi-practices/fastapi_best_architecture/pull/816)
* Update the dict plugin table structure by [@wu-clan](https://github.com/wu-clan) in [#817](https://github.com/fastapi-practices/fastapi_best_architecture/pull/817)
* Add version number output to the startup CLI by [@wu-clan](https://github.com/wu-clan) in [#820](https://github.com/fastapi-practices/fastapi_best_architecture/pull/820)
* Update the user agent in opera log model by [@wu-clan](https://github.com/wu-clan) in [#831](https://github.com/fastapi-practices/fastapi_best_architecture/pull/831)
* Fix code generation template missing Decimal by [@wu-clan](https://github.com/wu-clan) in [#830](https://github.com/fastapi-practices/fastapi_best_architecture/pull/830)
* Fix the get model object method return logic by [@wu-clan](https://github.com/wu-clan) in [#832](https://github.com/fastapi-practices/fastapi_best_architecture/pull/832)
* Simplify the code generation CLI parameters by [@wu-clan](https://github.com/wu-clan) in [#837](https://github.com/fastapi-practices/fastapi_best_architecture/pull/837)
* Add the refresh token to demo mode exclusion by [@wu-clan](https://github.com/wu-clan) in [#834](https://github.com/fastapi-practices/fastapi_best_architecture/pull/834)
* Add extension parameters to paging data by [@wu-clan](https://github.com/wu-clan) in [#835](https://github.com/fastapi-practices/fastapi_best_architecture/pull/835)
* Fix the phone filter in the user list by [@wu-clan](https://github.com/wu-clan) in [#838](https://github.com/fastapi-practices/fastapi_best_architecture/pull/838)
* Update docker and deployment script comments by [@wu-clan](https://github.com/wu-clan) in [#839](https://github.com/fastapi-practices/fastapi_best_architecture/pull/839)
* Update the version number to 1.8.2 by [@wu-clan](https://github.com/wu-clan) in [#840](https://github.com/fastapi-practices/fastapi_best_architecture/pull/840)
## New Contributors
* [@byte-voyager](https://github.com/byte-voyager) made their first contribution in [#806](https://github.com/fastapi-practices/fastapi_best_architecture/pull/806)
* [@MortyZhaoy](https://github.com/MortyZhaoy) made their first contribution in [#811](https://github.com/fastapi-practices/fastapi_best_architecture/pull/811)
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.8.1...v1.8.2
[Changes][v1.8.2]
<a id="v1.8.1"></a>
# [v1.8.1](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.8.1) - 2025-09-09
## What's Changed
* Update changelog for v1.8.0 by [@wu-clan](https://github.com/wu-clan) in [#772](https://github.com/fastapi-practices/fastapi_best_architecture/pull/772)
* Optimize the celery crontab expression validation by [@yanlingsishao](https://github.com/yanlingsishao) in [#773](https://github.com/fastapi-practices/fastapi_best_architecture/pull/773)
* Add bulk update interface for config plugin by [@wu-clan](https://github.com/wu-clan) in [#774](https://github.com/fastapi-practices/fastapi_best_architecture/pull/774)
* Optimize email sending config update logic by [@wu-clan](https://github.com/wu-clan) in [#775](https://github.com/fastapi-practices/fastapi_best_architecture/pull/775)
* Add test data SQL script for config plugin by [@wu-clan](https://github.com/wu-clan) in [#776](https://github.com/fastapi-practices/fastapi_best_architecture/pull/776)
* Update the env parameter value in env file by [@wu-clan](https://github.com/wu-clan) in [#777](https://github.com/fastapi-practices/fastapi_best_architecture/pull/777)
* Fix the docker compose env configuration file by [@wu-clan](https://github.com/wu-clan) in [#778](https://github.com/fastapi-practices/fastapi_best_architecture/pull/778)
* Fix redis client not close after plugin parse by [@wu-clan](https://github.com/wu-clan) in [#780](https://github.com/fastapi-practices/fastapi_best_architecture/pull/780)
* Fix the celery beat distributed lock timeout by [@wu-clan](https://github.com/wu-clan) in [#779](https://github.com/fastapi-practices/fastapi_best_architecture/pull/779)
* Fix the plugin model object detection logic by [@wuyao4](https://github.com/wuyao4) in [#782](https://github.com/fastapi-practices/fastapi_best_architecture/pull/782)
* Update the celery task result table creation logic by [@wu-clan](https://github.com/wu-clan) in [#783](https://github.com/fastapi-practices/fastapi_best_architecture/pull/783)
* Fix code generation template params and filename by [@wu-clan](https://github.com/wu-clan) in [#784](https://github.com/fastapi-practices/fastapi_best_architecture/pull/784)
* Fix CLI openapi url in production environment by [@siyue-wang](https://github.com/siyue-wang) in [#785](https://github.com/fastapi-practices/fastapi_best_architecture/pull/785)
* Update the model datetime column type to custom by [@wu-clan](https://github.com/wu-clan) in [#786](https://github.com/fastapi-practices/fastapi_best_architecture/pull/786)
* Update the opera log exception message record by [@wu-clan](https://github.com/wu-clan) in [#788](https://github.com/fastapi-practices/fastapi_best_architecture/pull/788)
* Update the handling of CORS 500 status code by [@wu-clan](https://github.com/wu-clan) in [#789](https://github.com/fastapi-practices/fastapi_best_architecture/pull/789)
* Add the Google OAuth2 login by [@wu-clan](https://github.com/wu-clan) in [#790](https://github.com/fastapi-practices/fastapi_best_architecture/pull/790)
* Fix the tzinfo comparison in the timezone type by [@IAseven](https://github.com/IAseven) in [#787](https://github.com/fastapi-practices/fastapi_best_architecture/pull/787)
* Bump dependencies and pre-commits by [@wu-clan](https://github.com/wu-clan) in [#791](https://github.com/fastapi-practices/fastapi_best_architecture/pull/791)
* Update the notice model and pagination by [@wu-clan](https://github.com/wu-clan) in [#792](https://github.com/fastapi-practices/fastapi_best_architecture/pull/792)
* Add the dict type query all interface by [@wu-clan](https://github.com/wu-clan) in [#794](https://github.com/fastapi-practices/fastapi_best_architecture/pull/794)
* Fix sql script syntax error in config plugin by [@wu-clan](https://github.com/wu-clan) in [#793](https://github.com/fastapi-practices/fastapi_best_architecture/pull/793)
* Fix the psycopg dependency naming by [@wu-clan](https://github.com/wu-clan) in [#795](https://github.com/fastapi-practices/fastapi_best_architecture/pull/795)
* Add schema for the tree data by [@wu-clan](https://github.com/wu-clan) in [#796](https://github.com/fastapi-practices/fastapi_best_architecture/pull/796)
* Add the code generator to the CLI by [@wu-clan](https://github.com/wu-clan) in [#798](https://github.com/fastapi-practices/fastapi_best_architecture/pull/798)
* Optimize the code generation interaction logic by [@wu-clan](https://github.com/wu-clan) in [#799](https://github.com/fastapi-practices/fastapi_best_architecture/pull/799)
* Fix console import in main module by [@wu-clan](https://github.com/wu-clan) in [#800](https://github.com/fastapi-practices/fastapi_best_architecture/pull/800)
* Fix the subprocess check call failed in windows by [@yzbf-lin](https://github.com/yzbf-lin) in [#802](https://github.com/fastapi-practices/fastapi_best_architecture/pull/802)
* Fix alembic migration file missing import by [@wu-clan](https://github.com/wu-clan) in [#803](https://github.com/fastapi-practices/fastapi_best_architecture/pull/803)
## New Contributors
* [@yanlingsishao](https://github.com/yanlingsishao) made their first contribution in [#773](https://github.com/fastapi-practices/fastapi_best_architecture/pull/773)
* [@wuyao4](https://github.com/wuyao4) made their first contribution in [#782](https://github.com/fastapi-practices/fastapi_best_architecture/pull/782)
* [@siyue-wang](https://github.com/siyue-wang) made their first contribution in [#785](https://github.com/fastapi-practices/fastapi_best_architecture/pull/785)
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.8.0...v1.8.1
[Changes][v1.8.1]
<a id="v1.8.0"></a>
# [v1.8.0](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.8.0) - 2025-08-15
## What's Changed
* Update changelog for v1.7.0 by [@wu-clan](https://github.com/wu-clan) in [#729](https://github.com/fastapi-practices/fastapi_best_architecture/pull/729)
* Simplify task crontab expression validation by [@wu-clan](https://github.com/wu-clan) in [#733](https://github.com/fastapi-practices/fastapi_best_architecture/pull/733)
* Add distributed lock for scheduled task by [@wu-clan](https://github.com/wu-clan) in [#732](https://github.com/fastapi-practices/fastapi_best_architecture/pull/732)
* Update the default cache period for userinfo by [@wu-clan](https://github.com/wu-clan) in [#734](https://github.com/fastapi-practices/fastapi_best_architecture/pull/734)
* Fix celery CLI option to required by [@wu-clan](https://github.com/wu-clan) in [#737](https://github.com/fastapi-practices/fastapi_best_architecture/pull/737)
* Add auth whitelist regular expression config by [@wu-clan](https://github.com/wu-clan) in [#738](https://github.com/fastapi-practices/fastapi_best_architecture/pull/738)
* Fix the opera log field encryption by [@wu-clan](https://github.com/wu-clan) in [#739](https://github.com/fastapi-practices/fastapi_best_architecture/pull/739)
* Update the OAuth2 login password policy by [@wu-clan](https://github.com/wu-clan) in [#741](https://github.com/fastapi-practices/fastapi_best_architecture/pull/741)
* Add update support for user email and phone by [@wu-clan](https://github.com/wu-clan) in [#742](https://github.com/fastapi-practices/fastapi_best_architecture/pull/742)
* Fix the error trigger when model auto import by [@wu-clan](https://github.com/wu-clan) in [#743](https://github.com/fastapi-practices/fastapi_best_architecture/pull/743)
* Simplify the plugin status update logic by [@wu-clan](https://github.com/wu-clan) in [#744](https://github.com/fastapi-practices/fastapi_best_architecture/pull/744)
* Add some interfaces for user profiles by [@wu-clan](https://github.com/wu-clan) in [#745](https://github.com/fastapi-practices/fastapi_best_architecture/pull/745)
* Add schedule task demo that contains params by [@wu-clan](https://github.com/wu-clan) in [#746](https://github.com/fastapi-practices/fastapi_best_architecture/pull/746)
* Fix the kwargs params of schedule task by [@wu-clan](https://github.com/wu-clan) in [#747](https://github.com/fastapi-practices/fastapi_best_architecture/pull/747)
* Refactor code generation files and routes by [@wu-clan](https://github.com/wu-clan) in [#748](https://github.com/fastapi-practices/fastapi_best_architecture/pull/748)
* Refactor task routes and add control routes by [@wu-clan](https://github.com/wu-clan) in [#749](https://github.com/fastapi-practices/fastapi_best_architecture/pull/749)
* Fix message format in validation exception handler by [@wu-clan](https://github.com/wu-clan) in [#755](https://github.com/fastapi-practices/fastapi_best_architecture/pull/755)
* Update the opera log desensitization method by [@wu-clan](https://github.com/wu-clan) in [#756](https://github.com/fastapi-practices/fastapi_best_architecture/pull/756)
* Add business pagination in the code generator by [@wu-clan](https://github.com/wu-clan) in [#757](https://github.com/fastapi-practices/fastapi_best_architecture/pull/757)
* Optimize the data sort logic of tree nodes by [@wu-clan](https://github.com/wu-clan) in [#758](https://github.com/fastapi-practices/fastapi_best_architecture/pull/758)
* Update log output config and format by [@wu-clan](https://github.com/wu-clan) in [#759](https://github.com/fastapi-practices/fastapi_best_architecture/pull/759)
* Update the naming of table creation function by [@wu-clan](https://github.com/wu-clan) in [#760](https://github.com/fastapi-practices/fastapi_best_architecture/pull/760)
* Optimize the opera log storage logic through queue by [@IAseven](https://github.com/IAseven) in [#750](https://github.com/fastapi-practices/fastapi_best_architecture/pull/750)
* Optimize naming and preview in code generation by [@wu-clan](https://github.com/wu-clan) in [#764](https://github.com/fastapi-practices/fastapi_best_architecture/pull/764)
* Update the description for the run file by [@wu-clan](https://github.com/wu-clan) in [#766](https://github.com/fastapi-practices/fastapi_best_architecture/pull/766)
* Optimize the timezone datetime return encoder by [@wu-clan](https://github.com/wu-clan) in [#767](https://github.com/fastapi-practices/fastapi_best_architecture/pull/767)
* Update the content layout of the config file by [@wu-clan](https://github.com/wu-clan) in [#768](https://github.com/fastapi-practices/fastapi_best_architecture/pull/768)
* Add a standalone email sending plugin by [@wu-clan](https://github.com/wu-clan) in [#769](https://github.com/fastapi-practices/fastapi_best_architecture/pull/769)
* Add i18n support for response message by [@downdawn](https://github.com/downdawn) in [#753](https://github.com/fastapi-practices/fastapi_best_architecture/pull/753)
* Update the menu title in SQL scripts by [@wu-clan](https://github.com/wu-clan) in [#770](https://github.com/fastapi-practices/fastapi_best_architecture/pull/770)
* Update the version number to 1.8.0 by [@wu-clan](https://github.com/wu-clan) in [#771](https://github.com/fastapi-practices/fastapi_best_architecture/pull/771)
## New Contributors
* [@IAseven](https://github.com/IAseven) made their first contribution in [#750](https://github.com/fastapi-practices/fastapi_best_architecture/pull/750)
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.7.0...v1.8.0
[Changes][v1.8.0]
<a id="v1.7.0"></a> <a id="v1.7.0"></a>
# [v1.7.0](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.7.0) - 2025-07-16 # [v1.7.0](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.7.0) - 2025-07-16
@@ -714,6 +837,9 @@
[Changes][v1.0.0] [Changes][v1.0.0]
[v1.8.2]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.8.1...v1.8.2
[v1.8.1]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.8.0...v1.8.1
[v1.8.0]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.7.0...v1.8.0
[v1.7.0]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.6.0...v1.7.0 [v1.7.0]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.6.0...v1.7.0
[v1.6.0]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.5.2...v1.6.0 [v1.6.0]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.5.2...v1.6.0
[v1.5.2]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.5.1...v1.5.2 [v1.5.2]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.5.1...v1.5.2
+2 -2
View File
@@ -43,7 +43,7 @@ WORKDIR /fba/backend
# === FastAPI server image === # === FastAPI server image ===
FROM base_server AS fastapi_server FROM base_server AS fastapi_server
COPY deploy/backend/fastapi_server.conf /etc/supervisor/conf.d/ COPY deploy/backend/fba_server.conf /etc/supervisor/conf.d/
RUN mkdir -p /var/log/fastapi_server RUN mkdir -p /var/log/fastapi_server
@@ -54,7 +54,7 @@ CMD ["/usr/local/bin/granian", "main:app", "--interface", "asgi", "--host", "0.0
# === Celery server image === # === Celery server image ===
FROM base_server AS celery FROM base_server AS celery
COPY deploy/backend/celery.conf /etc/supervisor/conf.d/ COPY deploy/backend/fba_celery.conf /etc/supervisor/conf.d/
RUN mkdir -p /var/log/celery RUN mkdir -p /var/log/celery
+3 -1
View File
@@ -1,4 +1,4 @@
# Env: dev、pro # Env
ENVIRONMENT='dev' ENVIRONMENT='dev'
# Database # Database
DATABASE_TYPE='mysql' DATABASE_TYPE='mysql'
@@ -26,6 +26,8 @@ CELERY_RABBITMQ_PASSWORD='guest'
# [ Plugin ] oauth2 # [ Plugin ] oauth2
OAUTH2_GITHUB_CLIENT_ID='test' OAUTH2_GITHUB_CLIENT_ID='test'
OAUTH2_GITHUB_CLIENT_SECRET='test' OAUTH2_GITHUB_CLIENT_SECRET='test'
OAUTH2_GOOGLE_CLIENT_ID='test'
OAUTH2_GOOGLE_CLIENT_SECRET='test'
OAUTH2_LINUX_DO_CLIENT_ID='test' OAUTH2_LINUX_DO_CLIENT_ID='test'
OAUTH2_LINUX_DO_CLIENT_SECRET='test' OAUTH2_LINUX_DO_CLIENT_SECRET='test'
# [ Plugin ] email # [ Plugin ] email
-55
View File
@@ -1,55 +0,0 @@
line-length = 120
cache-dir = ".ruff_cache"
target-version = "py310"
unsafe-fixes = true
show-fixes = true
[lint]
select = [
"E",
"F",
"I",
"TC",
# W
"W505",
# PT
"PT018",
# SIM
"SIM101",
"SIM114",
# PGH
"PGH004",
# PL
"PLE1142",
# RUF
"RUF100",
# UP
"UP007"
]
preview = true
ignore = ["FURB101"]
[lint.flake8-pytest-style]
mark-parentheses = false
parametrize-names-type = "list"
parametrize-values-row-type = "list"
parametrize-values-type = "tuple"
[lint.flake8-unused-arguments]
ignore-variadic-names = true
[lint.isort]
lines-between-types = 1
order-by-type = true
[lint.per-file-ignores]
"**/api/v1/*.py" = ["TC"]
"**/model/*.py" = ["TC003"]
"**/model/__init__.py" = ["F401"]
"**/tests/*.py" = ["E402"]
[format]
preview = true
quote-style = "single"
docstring-code-format = true
skip-magic-trailing-comma = false
+1 -8
View File
@@ -1,13 +1,6 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from backend.common.i18n import i18n from backend.common.i18n import i18n
from backend.utils.console import console
__version__ = '1.8.0' __version__ = '1.8.2'
def get_version() -> str | None:
console.print(f'[cyan]{__version__}[/]')
# 初始化 i18n # 初始化 i18n
+7 -9
View File
@@ -1,9 +1,6 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# ruff: noqa: F403, F401, I001, RUF100
import asyncio import asyncio
import os import os
import sys
from logging.config import fileConfig from logging.config import fileConfig
from alembic import context from alembic import context
@@ -11,8 +8,6 @@ from sqlalchemy import pool
from sqlalchemy.engine import Connection from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config from sqlalchemy.ext.asyncio import async_engine_from_config
sys.path.append('../')
from backend.app import get_app_models from backend.app import get_app_models
from backend.common.model import MappedBase from backend.common.model import MappedBase
from backend.core import path_conf from backend.core import path_conf
@@ -42,10 +37,13 @@ if alembic_config.config_file_name is not None:
target_metadata = MappedBase.metadata target_metadata = MappedBase.metadata
# other values from the config, defined by the needs of env.py, # other values from the config, defined by the needs of env.py,
alembic_config.set_main_option('sqlalchemy.url', SQLALCHEMY_DATABASE_URL.render_as_string(hide_password=False)) alembic_config.set_main_option(
'sqlalchemy.url',
SQLALCHEMY_DATABASE_URL.render_as_string(hide_password=False).replace('%', '%%'),
)
def run_migrations_offline(): def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode. """Run migrations in 'offline' mode.
This configures the context with just a URL This configures the context with just a URL
@@ -74,7 +72,7 @@ def run_migrations_offline():
def do_run_migrations(connection: Connection) -> None: def do_run_migrations(connection: Connection) -> None:
# 当迁移无变化时,不生成迁移记录 # 当迁移无变化时,不生成迁移记录
def process_revision_directives(context, revision, directives): def process_revision_directives(context, revision, directives) -> None: # noqa: ANN001
if alembic_config.cmd_opts.autogenerate: if alembic_config.cmd_opts.autogenerate:
script = directives[0] script = directives[0]
if script.upgrade_ops.is_empty(): if script.upgrade_ops.is_empty():
+1
View File
@@ -7,6 +7,7 @@ Create Date: ${create_date}
""" """
from alembic import op from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
import backend.common.model
${imports if imports else ""} ${imports if imports else ""}
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
+10 -26
View File
@@ -1,41 +1,25 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import inspect
import os.path import os.path
from backend.common.log import log
from backend.core.path_conf import BASE_PATH from backend.core.path_conf import BASE_PATH
from backend.utils.import_parse import import_module_cached from backend.utils.import_parse import get_model_objects
def get_app_models(): def get_app_models() -> list[type]:
"""获取 app 所有模型类""" """获取 app 所有模型类"""
app_path = os.path.join(BASE_PATH, 'app') app_path = BASE_PATH / 'app'
list_dirs = os.listdir(app_path) list_dirs = os.listdir(app_path)
apps = [] apps = [d for d in list_dirs if os.path.isdir(os.path.join(app_path, d)) and d != '__pycache__']
for d in list_dirs: objs = []
if os.path.isdir(os.path.join(app_path, d)) and d != '__pycache__':
apps.append(d)
classes = []
for app in apps: for app in apps:
try: module_path = f'backend.app.{app}.model'
module_path = f'backend.app.{app}.model' obj = get_model_objects(module_path)
module = import_module_cached(module_path) if obj:
except ModuleNotFoundError as e: objs.extend(obj)
log.warning(f'应用 {app} 中不包含 model 相关配置: {e}')
continue
except Exception as e:
raise e
for name, obj in inspect.getmembers(module): return objs
if inspect.isclass(obj):
classes.append(obj)
return classes
# import all app models for auto create db tables # import all app models for auto create db tables
-2
View File
@@ -1,2 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
-2
View File
@@ -1,2 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
-2
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from fastapi import APIRouter from fastapi import APIRouter
from backend.app.admin.api.v1.auth import router as auth_router from backend.app.admin.api.v1.auth import router as auth_router
-2
View File
@@ -1,2 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from fastapi import APIRouter from fastapi import APIRouter
from backend.app.admin.api.v1.auth.auth import router as auth_router from backend.app.admin.api.v1.auth.auth import router as auth_router
+5 -4
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, Request, Response from fastapi import APIRouter, Depends, Request, Response
@@ -29,7 +27,10 @@ async def login_swagger(obj: Annotated[HTTPBasicCredentials, Depends()]) -> GetS
dependencies=[Depends(RateLimiter(times=5, minutes=1))], dependencies=[Depends(RateLimiter(times=5, minutes=1))],
) )
async def login( async def login(
request: Request, response: Response, obj: AuthLoginParam, background_tasks: BackgroundTasks request: Request,
response: Response,
obj: AuthLoginParam,
background_tasks: BackgroundTasks,
) -> ResponseSchemaModel[GetLoginToken]: ) -> ResponseSchemaModel[GetLoginToken]:
data = await auth_service.login(request=request, response=response, obj=obj, background_tasks=background_tasks) data = await auth_service.login(request=request, response=response, obj=obj, background_tasks=background_tasks)
return response_base.success(data=data) return response_base.success(data=data)
@@ -41,7 +42,7 @@ async def get_codes(request: Request) -> ResponseSchemaModel[list[str]]:
return response_base.success(data=codes) return response_base.success(data=codes)
@router.post('/tokens', summary='刷新 token') @router.post('/refresh', summary='刷新 token')
async def refresh_token(request: Request) -> ResponseSchemaModel[GetNewToken]: async def refresh_token(request: Request) -> ResponseSchemaModel[GetNewToken]:
data = await auth_service.refresh_token(request=request) data = await auth_service.refresh_token(request=request)
return response_base.success(data=data) return response_base.success(data=data)
+5 -5
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 from uuid import uuid4
# -*- coding: utf-8 -*-
from fast_captcha import img_captcha from fast_captcha import img_captcha
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
from fastapi_limiter.depends import RateLimiter from fastapi_limiter.depends import RateLimiter
@@ -24,11 +24,11 @@ async def get_captcha(request: Request) -> ResponseSchemaModel[GetCaptchaDetail]
""" """
img_type: str = 'base64' img_type: str = 'base64'
img, code = await run_in_threadpool(img_captcha, img_byte=img_type) img, code = await run_in_threadpool(img_captcha, img_byte=img_type)
ip = request.state.ip uuid = str(uuid4())
await redis_client.set( await redis_client.set(
f'{settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{ip}', f'{settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{uuid}',
code, code,
ex=settings.CAPTCHA_LOGIN_EXPIRE_SECONDS, ex=settings.CAPTCHA_LOGIN_EXPIRE_SECONDS,
) )
data = GetCaptchaDetail(image_type=img_type, image=img) data = GetCaptchaDetail(uuid=uuid, img_type=img_type, image=img)
return response_base.success(data=data) return response_base.success(data=data)
-2
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from fastapi import APIRouter from fastapi import APIRouter
from backend.app.admin.api.v1.log.login_log import router as login_log from backend.app.admin.api.v1.log.login_log import router as login_log
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from fastapi import APIRouter from fastapi import APIRouter
from backend.app.admin.api.v1.monitor.online import router as token_router from backend.app.admin.api.v1.monitor.online import router as token_router
+2 -4
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import json import json
from typing import Annotated from typing import Annotated
@@ -37,8 +35,8 @@ async def get_sessions(
'browser': extra_info.get('browser', '未知'), 'browser': extra_info.get('browser', '未知'),
'device': extra_info.get('device', '未知'), 'device': extra_info.get('device', '未知'),
'last_login_time': extra_info.get('last_login_time', '未知'), 'last_login_time': extra_info.get('last_login_time', '未知'),
} },
) ),
) )
for key in token_keys: for key in token_keys:
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from fastapi import APIRouter from fastapi import APIRouter
from backend.common.response.response_schema import ResponseModel, response_base from backend.common.response.response_schema import ResponseModel, response_base
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from fastapi import APIRouter from fastapi import APIRouter
from starlette.concurrency import run_in_threadpool from starlette.concurrency import run_in_threadpool
-2
View File
@@ -1,5 +1,3 @@
# !/usr/bin/env python3
# -*- coding: utf-8 -*-
from fastapi import APIRouter from fastapi import APIRouter
from backend.app.admin.api.v1.sys.data_rule import router as data_rule_router from backend.app.admin.api.v1.sys.data_rule import router as data_rule_router
+4 -4
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query from fastapi import APIRouter, Depends, Path, Query
@@ -59,7 +57,8 @@ async def get_data_rule(
], ],
) )
async def get_data_rules_paged( async def get_data_rules_paged(
db: CurrentSession, name: Annotated[str | None, Query(description='规则名称')] = None db: CurrentSession,
name: Annotated[str | None, Query(description='规则名称')] = None,
) -> ResponseSchemaModel[PageData[GetDataRuleDetail]]: ) -> ResponseSchemaModel[PageData[GetDataRuleDetail]]:
data_rule_select = await data_rule_service.get_select(name=name) data_rule_select = await data_rule_service.get_select(name=name)
page_data = await paging_data(db, data_rule_select) page_data = await paging_data(db, data_rule_select)
@@ -88,7 +87,8 @@ async def create_data_rule(obj: CreateDataRuleParam) -> ResponseModel:
], ],
) )
async def update_data_rule( async def update_data_rule(
pk: Annotated[int, Path(description='数据规则 ID')], obj: UpdateDataRuleParam pk: Annotated[int, Path(description='数据规则 ID')],
obj: UpdateDataRuleParam,
) -> ResponseModel: ) -> ResponseModel:
count = await data_rule_service.update(pk=pk, obj=obj) count = await data_rule_service.update(pk=pk, obj=obj)
if count > 0: if count > 0:
+5 -5
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query from fastapi import APIRouter, Depends, Path, Query
@@ -85,7 +83,8 @@ async def create_data_scope(obj: CreateDataScopeParam) -> ResponseModel:
], ],
) )
async def update_data_scope( async def update_data_scope(
pk: Annotated[int, Path(description='数据范围 ID')], obj: UpdateDataScopeParam pk: Annotated[int, Path(description='数据范围 ID')],
obj: UpdateDataScopeParam,
) -> ResponseModel: ) -> ResponseModel:
count = await data_scope_service.update(pk=pk, obj=obj) count = await data_scope_service.update(pk=pk, obj=obj)
if count > 0: if count > 0:
@@ -102,8 +101,9 @@ async def update_data_scope(
], ],
) )
async def update_data_scope_rules( async def update_data_scope_rules(
pk: Annotated[int, Path(description='数据范围 ID')], rule_ids: UpdateDataScopeRuleParam pk: Annotated[int, Path(description='数据范围 ID')],
): rule_ids: UpdateDataScopeRuleParam,
) -> ResponseModel:
count = await data_scope_service.update_data_scope_rule(pk=pk, rule_ids=rule_ids) count = await data_scope_service.update_data_scope_rule(pk=pk, rule_ids=rule_ids)
if count > 0: if count > 0:
return response_base.success() return response_base.success()
+3 -5
View File
@@ -1,10 +1,8 @@
#!/usr/bin/env python3 from typing import Annotated
# -*- coding: utf-8 -*-
from typing import Annotated, Any
from fastapi import APIRouter, Depends, Path, Query, Request from fastapi import APIRouter, Depends, Path, Query, Request
from backend.app.admin.schema.dept import CreateDeptParam, GetDeptDetail, UpdateDeptParam from backend.app.admin.schema.dept import CreateDeptParam, GetDeptDetail, GetDeptTree, UpdateDeptParam
from backend.app.admin.service.dept_service import dept_service from backend.app.admin.service.dept_service import dept_service
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
from backend.common.security.jwt import DependsJwtAuth from backend.common.security.jwt import DependsJwtAuth
@@ -27,7 +25,7 @@ async def get_dept_tree(
leader: Annotated[str | None, Query(description='部门负责人')] = None, leader: Annotated[str | None, Query(description='部门负责人')] = None,
phone: Annotated[str | None, Query(description='联系电话')] = None, phone: Annotated[str | None, Query(description='联系电话')] = None,
status: Annotated[int | None, Query(description='状态')] = None, status: Annotated[int | None, Query(description='状态')] = None,
) -> ResponseSchemaModel[list[dict[str, Any]]]: ) -> ResponseSchemaModel[list[GetDeptTree]]:
dept = await dept_service.get_tree(request=request, name=name, leader=leader, phone=phone, status=status) dept = await dept_service.get_tree(request=request, name=name, leader=leader, phone=phone, status=status)
return response_base.success(data=dept) return response_base.success(data=dept)
-2
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, File, UploadFile from fastapi import APIRouter, Depends, File, UploadFile
+2 -4
View File
@@ -1,10 +1,8 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Annotated, Any from typing import Annotated, Any
from fastapi import APIRouter, Depends, Path, Query, Request from fastapi import APIRouter, Depends, Path, Query, Request
from backend.app.admin.schema.menu import CreateMenuParam, GetMenuDetail, UpdateMenuParam from backend.app.admin.schema.menu import CreateMenuParam, GetMenuDetail, GetMenuTree, UpdateMenuParam
from backend.app.admin.service.menu_service import menu_service from backend.app.admin.service.menu_service import menu_service
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
from backend.common.security.jwt import DependsJwtAuth from backend.common.security.jwt import DependsJwtAuth
@@ -30,7 +28,7 @@ async def get_menu(pk: Annotated[int, Path(description='菜单 ID')]) -> Respons
async def get_menu_tree( async def get_menu_tree(
title: Annotated[str | None, Query(description='菜单标题')] = None, title: Annotated[str | None, Query(description='菜单标题')] = None,
status: Annotated[int | None, Query(description='状体')] = None, status: Annotated[int | None, Query(description='状体')] = None,
) -> ResponseSchemaModel[list[dict[str, Any]]]: ) -> ResponseSchemaModel[list[GetMenuTree]]:
menu = await menu_service.get_tree(title=title, status=status) menu = await menu_service.get_tree(title=title, status=status)
return response_base.success(data=menu) return response_base.success(data=menu)
+4 -5
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Annotated, Any from typing import Annotated, Any
from fastapi import APIRouter, Depends, File, Path, UploadFile from fastapi import APIRouter, Depends, File, Path, UploadFile
@@ -46,8 +44,9 @@ async def install_plugin(
plugin_name = await plugin_service.install(type=type, file=file, repo_url=repo_url) plugin_name = await plugin_service.install(type=type, file=file, repo_url=repo_url)
return response_base.success( return response_base.success(
res=CustomResponse( res=CustomResponse(
code=200, msg=f'插件 {plugin_name} 安装成功,请根据插件说明(README.md)进行相关配置并重启服务' code=200,
) msg=f'插件 {plugin_name} 安装成功,请根据插件说明(README.md)进行相关配置并重启服务',
),
) )
@@ -63,7 +62,7 @@ async def install_plugin(
async def uninstall_plugin(plugin: Annotated[str, Path(description='插件名称')]) -> ResponseModel: async def uninstall_plugin(plugin: Annotated[str, Path(description='插件名称')]) -> ResponseModel:
await plugin_service.uninstall(plugin=plugin) await plugin_service.uninstall(plugin=plugin)
return response_base.success( return response_base.success(
res=CustomResponse(code=200, msg=f'插件 {plugin} 卸载成功,请根据插件说明(README.md)移除相关配置并重启服务') res=CustomResponse(code=200, msg=f'插件 {plugin} 卸载成功,请根据插件说明(README.md)移除相关配置并重启服务'),
) )
+7 -6
View File
@@ -1,9 +1,8 @@
#!/usr/bin/env python3 from typing import Annotated
# -*- coding: utf-8 -*-
from typing import Annotated, Any
from fastapi import APIRouter, Depends, Path, Query from fastapi import APIRouter, Depends, Path, Query
from backend.app.admin.schema.menu import GetMenuTree
from backend.app.admin.schema.role import ( from backend.app.admin.schema.role import (
CreateRoleParam, CreateRoleParam,
DeleteRoleParam, DeleteRoleParam,
@@ -33,7 +32,7 @@ async def get_all_roles() -> ResponseSchemaModel[list[GetRoleDetail]]:
@router.get('/{pk}/menus', summary='获取角色菜单树', dependencies=[DependsJwtAuth]) @router.get('/{pk}/menus', summary='获取角色菜单树', dependencies=[DependsJwtAuth])
async def get_role_menu_tree( async def get_role_menu_tree(
pk: Annotated[int, Path(description='角色 ID')], pk: Annotated[int, Path(description='角色 ID')],
) -> ResponseSchemaModel[list[dict[str, Any] | None]]: ) -> ResponseSchemaModel[list[GetMenuTree] | None]:
menu = await role_service.get_menu_tree(pk=pk) menu = await role_service.get_menu_tree(pk=pk)
return response_base.success(data=menu) return response_base.success(data=menu)
@@ -105,7 +104,8 @@ async def update_role(pk: Annotated[int, Path(description='角色 ID')], obj: Up
], ],
) )
async def update_role_menus( async def update_role_menus(
pk: Annotated[int, Path(description='角色 ID')], menu_ids: UpdateRoleMenuParam pk: Annotated[int, Path(description='角色 ID')],
menu_ids: UpdateRoleMenuParam,
) -> ResponseModel: ) -> ResponseModel:
count = await role_service.update_role_menu(pk=pk, menu_ids=menu_ids) count = await role_service.update_role_menu(pk=pk, menu_ids=menu_ids)
if count > 0: if count > 0:
@@ -122,7 +122,8 @@ async def update_role_menus(
], ],
) )
async def update_role_scopes( async def update_role_scopes(
pk: Annotated[int, Path(description='角色 ID')], scope_ids: UpdateRoleScopeParam pk: Annotated[int, Path(description='角色 ID')],
scope_ids: UpdateRoleScopeParam,
) -> ResponseModel: ) -> ResponseModel:
count = await role_service.update_role_scope(pk=pk, scope_ids=scope_ids) count = await role_service.update_role_scope(pk=pk, scope_ids=scope_ids)
if count > 0: if count > 0:
+7 -5
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Body, Depends, Path, Query, Request from fastapi import APIRouter, Body, Depends, Path, Query, Request
@@ -73,7 +71,9 @@ async def create_user(request: Request, obj: AddUserParam) -> ResponseSchemaMode
@router.put('/{pk}', summary='更新用户信息', dependencies=[DependsRBAC]) @router.put('/{pk}', summary='更新用户信息', dependencies=[DependsRBAC])
async def update_user( async def update_user(
request: Request, pk: Annotated[int, Path(description='用户 ID')], obj: UpdateUserParam request: Request,
pk: Annotated[int, Path(description='用户 ID')],
obj: UpdateUserParam,
) -> ResponseModel: ) -> ResponseModel:
count = await user_service.update(request=request, pk=pk, obj=obj) count = await user_service.update(request=request, pk=pk, obj=obj)
if count > 0: if count > 0:
@@ -115,7 +115,8 @@ async def reset_user_password(
@router.put('/me/nickname', summary='更新当前用户昵称', dependencies=[DependsJwtAuth]) @router.put('/me/nickname', summary='更新当前用户昵称', dependencies=[DependsJwtAuth])
async def update_user_nickname( async def update_user_nickname(
request: Request, nickname: Annotated[str, Body(embed=True, description='用户昵称')] request: Request,
nickname: Annotated[str, Body(embed=True, description='用户昵称')],
) -> ResponseModel: ) -> ResponseModel:
count = await user_service.update_nickname(request=request, nickname=nickname) count = await user_service.update_nickname(request=request, nickname=nickname)
if count > 0: if count > 0:
@@ -125,7 +126,8 @@ async def update_user_nickname(
@router.put('/me/avatar', summary='更新当前用户头像', dependencies=[DependsJwtAuth]) @router.put('/me/avatar', summary='更新当前用户头像', dependencies=[DependsJwtAuth])
async def update_user_avatar( async def update_user_avatar(
request: Request, avatar: Annotated[str, Body(embed=True, description='用户头像地址')] request: Request,
avatar: Annotated[str, Body(embed=True, description='用户头像地址')],
) -> ResponseModel: ) -> ResponseModel:
count = await user_service.update_avatar(request=request, avatar=avatar) count = await user_service.update_avatar(request=request, avatar=avatar)
if count > 0: if count > 0:
-2
View File
@@ -1,2 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
+1 -3
View File
@@ -1,6 +1,4 @@
#!/usr/bin/env python3 from collections.abc import Sequence
# -*- coding: utf-8 -*-
from typing import Sequence
from sqlalchemy import Select from sqlalchemy import Select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
+1 -3
View File
@@ -1,6 +1,4 @@
#!/usr/bin/env python3 from collections.abc import Sequence
# -*- coding: utf-8 -*-
from typing import Sequence
from sqlalchemy import Select, select from sqlalchemy import Select, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
+1 -3
View File
@@ -1,6 +1,4 @@
#!/usr/bin/env python3 from collections.abc import Sequence
# -*- coding: utf-8 -*-
from typing import Sequence
from fastapi import Request from fastapi import Request
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
-2
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from sqlalchemy import Select from sqlalchemy import Select
from sqlalchemy import delete as sa_delete from sqlalchemy import delete as sa_delete
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
+1 -3
View File
@@ -1,6 +1,4 @@
#!/usr/bin/env python3 from collections.abc import Sequence
# -*- coding: utf-8 -*-
from typing import Sequence
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy_crud_plus import CRUDPlus from sqlalchemy_crud_plus import CRUDPlus
-2
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from sqlalchemy import Select from sqlalchemy import Select
from sqlalchemy import delete as sa_delete from sqlalchemy import delete as sa_delete
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
+1 -3
View File
@@ -1,6 +1,4 @@
#!/usr/bin/env python3 from collections.abc import Sequence
# -*- coding: utf-8 -*-
from typing import Sequence
from sqlalchemy import Select, select from sqlalchemy import Select, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
+9 -7
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import bcrypt import bcrypt
from sqlalchemy import select from sqlalchemy import select
@@ -200,7 +198,7 @@ class CRUDUser(CRUDPlus[User]):
if username: if username:
filters['username__like'] = f'%{username}%' filters['username__like'] = f'%{username}%'
if phone: if phone:
filters['phone_like'] = f'%{phone}%' filters['phone__like'] = f'%{phone}%'
if status is not None: if status is not None:
filters['status'] = status filters['status'] = status
@@ -214,7 +212,7 @@ class CRUDUser(CRUDPlus[User]):
**filters, **filters,
) )
async def set_super(self, db: AsyncSession, user_id: int, is_super: bool) -> int: async def set_super(self, db: AsyncSession, user_id: int, *, is_super: bool) -> int:
""" """
设置用户超级管理员状态 设置用户超级管理员状态
@@ -225,7 +223,7 @@ class CRUDUser(CRUDPlus[User]):
""" """
return await self.update_model(db, user_id, {'is_superuser': is_super}) return await self.update_model(db, user_id, {'is_superuser': is_super})
async def set_staff(self, db: AsyncSession, user_id: int, is_staff: bool) -> int: async def set_staff(self, db: AsyncSession, user_id: int, *, is_staff: bool) -> int:
""" """
设置用户后台登录状态 设置用户后台登录状态
@@ -247,7 +245,7 @@ class CRUDUser(CRUDPlus[User]):
""" """
return await self.update_model(db, user_id, {'status': status}) return await self.update_model(db, user_id, {'status': status})
async def set_multi_login(self, db: AsyncSession, user_id: int, multi_login: bool) -> int: async def set_multi_login(self, db: AsyncSession, user_id: int, *, multi_login: bool) -> int:
""" """
设置用户多端登录状态 设置用户多端登录状态
@@ -259,7 +257,11 @@ class CRUDUser(CRUDPlus[User]):
return await self.update_model(db, user_id, {'is_multi_login': multi_login}) return await self.update_model(db, user_id, {'is_multi_login': multi_login})
async def get_with_relation( async def get_with_relation(
self, db: AsyncSession, *, user_id: int | None = None, username: str | None = None self,
db: AsyncSession,
*,
user_id: int | None = None,
username: str | None = None,
) -> User | None: ) -> User | None:
""" """
获取用户关联信息 获取用户关联信息
+8 -10
View File
@@ -1,10 +1,8 @@
#!/usr/bin/env python3 from backend.app.admin.model.data_rule import DataRule as DataRule
# -*- coding: utf-8 -*- from backend.app.admin.model.data_scope import DataScope as DataScope
from backend.app.admin.model.data_rule import DataRule from backend.app.admin.model.dept import Dept as Dept
from backend.app.admin.model.data_scope import DataScope from backend.app.admin.model.login_log import LoginLog as LoginLog
from backend.app.admin.model.dept import Dept from backend.app.admin.model.menu import Menu as Menu
from backend.app.admin.model.login_log import LoginLog from backend.app.admin.model.opera_log import OperaLog as OperaLog
from backend.app.admin.model.menu import Menu from backend.app.admin.model.role import Role as Role
from backend.app.admin.model.opera_log import OperaLog from backend.app.admin.model.user import User as User
from backend.app.admin.model.role import Role
from backend.app.admin.model.user import User
+1 -3
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -25,7 +23,7 @@ class DataRule(Base):
column: Mapped[str] = mapped_column(String(20), comment='模型字段名') column: Mapped[str] = mapped_column(String(20), comment='模型字段名')
operator: Mapped[int] = mapped_column(comment='运算符(0and、1or') operator: Mapped[int] = mapped_column(comment='运算符(0and、1or')
expression: Mapped[int] = mapped_column( expression: Mapped[int] = mapped_column(
comment='表达式(0==、1!=、2>、3>=、4<、5<=、6in、7not_in' comment='表达式(0==、1!=、2>、3>=、4<、5<=、6in、7not_in',
) )
value: Mapped[str] = mapped_column(String(255), comment='规则值') value: Mapped[str] = mapped_column(String(255), comment='规则值')
-2
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
+11 -7
View File
@@ -1,8 +1,6 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING
from sqlalchemy import BigInteger, Boolean, ForeignKey, String from sqlalchemy import BigInteger, Boolean, ForeignKey, String
from sqlalchemy.dialects.postgresql import INTEGER from sqlalchemy.dialects.postgresql import INTEGER
@@ -27,15 +25,21 @@ class Dept(Base):
email: Mapped[str | None] = mapped_column(String(50), default=None, comment='邮箱') email: Mapped[str | None] = mapped_column(String(50), default=None, comment='邮箱')
status: Mapped[int] = mapped_column(default=1, comment='部门状态(0停用 1正常)') status: Mapped[int] = mapped_column(default=1, comment='部门状态(0停用 1正常)')
del_flag: Mapped[bool] = mapped_column( del_flag: Mapped[bool] = mapped_column(
Boolean().with_variant(INTEGER, 'postgresql'), default=False, comment='删除标志(0删除 1存在)' Boolean().with_variant(INTEGER, 'postgresql'),
default=False,
comment='删除标志(0删除 1存在)',
) )
# 父级部门一对多 # 父级部门一对多
parent_id: Mapped[int | None] = mapped_column( parent_id: Mapped[int | None] = mapped_column(
BigInteger, ForeignKey('sys_dept.id', ondelete='SET NULL'), default=None, index=True, comment='父部门ID' BigInteger,
ForeignKey('sys_dept.id', ondelete='SET NULL'),
default=None,
index=True,
comment='父部门ID',
) )
parent: Mapped[Optional['Dept']] = relationship(init=False, back_populates='children', remote_side=[id]) parent: Mapped[Dept | None] = relationship(init=False, back_populates='children', remote_side=[id])
children: Mapped[Optional[list['Dept']]] = relationship(init=False, back_populates='parent') children: Mapped[list[Dept] | None] = relationship(init=False, back_populates='parent')
# 部门用户一对多 # 部门用户一对多
users: Mapped[list[User]] = relationship(init=False, back_populates='dept') users: Mapped[list[User]] = relationship(init=False, back_populates='dept')
+7 -6
View File
@@ -1,13 +1,11 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from datetime import datetime from datetime import datetime
from sqlalchemy import DateTime, String from sqlalchemy import String
from sqlalchemy.dialects.mysql import LONGTEXT from sqlalchemy.dialects.mysql import LONGTEXT
from sqlalchemy.dialects.postgresql import TEXT from sqlalchemy.dialects.postgresql import TEXT
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from backend.common.model import DataClassBase, id_key from backend.common.model import DataClassBase, TimeZone, id_key
from backend.utils.timezone import timezone from backend.utils.timezone import timezone
@@ -29,7 +27,10 @@ class LoginLog(DataClassBase):
browser: Mapped[str | None] = mapped_column(String(50), comment='浏览器') browser: Mapped[str | None] = mapped_column(String(50), comment='浏览器')
device: Mapped[str | None] = mapped_column(String(50), comment='设备') device: Mapped[str | None] = mapped_column(String(50), comment='设备')
msg: Mapped[str] = mapped_column(LONGTEXT().with_variant(TEXT, 'postgresql'), comment='提示消息') msg: Mapped[str] = mapped_column(LONGTEXT().with_variant(TEXT, 'postgresql'), comment='提示消息')
login_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), comment='登录时间') login_time: Mapped[datetime] = mapped_column(TimeZone, comment='登录时间')
created_time: Mapped[datetime] = mapped_column( created_time: Mapped[datetime] = mapped_column(
DateTime(timezone=True), init=False, default_factory=timezone.now, comment='创建时间' TimeZone,
init=False,
default_factory=timezone.now,
comment='创建时间',
) )
-2
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from sqlalchemy import BigInteger, Column, ForeignKey, Table from sqlalchemy import BigInteger, Column, ForeignKey, Table
from backend.common.model import MappedBase from backend.common.model import MappedBase
+14 -8
View File
@@ -1,8 +1,6 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING
from sqlalchemy import BigInteger, ForeignKey, String from sqlalchemy import BigInteger, ForeignKey, String
from sqlalchemy.dialects.mysql import LONGTEXT from sqlalchemy.dialects.mysql import LONGTEXT
@@ -34,18 +32,26 @@ class Menu(Base):
display: Mapped[int] = mapped_column(default=1, comment='是否显示(0否 1是)') display: Mapped[int] = mapped_column(default=1, comment='是否显示(0否 1是)')
cache: Mapped[int] = mapped_column(default=1, comment='是否缓存(0否 1是)') cache: Mapped[int] = mapped_column(default=1, comment='是否缓存(0否 1是)')
link: Mapped[str | None] = mapped_column( link: Mapped[str | None] = mapped_column(
LONGTEXT().with_variant(TEXT, 'postgresql'), default=None, comment='外链地址' LONGTEXT().with_variant(TEXT, 'postgresql'),
default=None,
comment='外链地址',
) )
remark: Mapped[str | None] = mapped_column( remark: Mapped[str | None] = mapped_column(
LONGTEXT().with_variant(TEXT, 'postgresql'), default=None, comment='备注' LONGTEXT().with_variant(TEXT, 'postgresql'),
default=None,
comment='备注',
) )
# 父级菜单一对多 # 父级菜单一对多
parent_id: Mapped[int | None] = mapped_column( parent_id: Mapped[int | None] = mapped_column(
BigInteger, ForeignKey('sys_menu.id', ondelete='SET NULL'), default=None, index=True, comment='父菜单ID' BigInteger,
ForeignKey('sys_menu.id', ondelete='SET NULL'),
default=None,
index=True,
comment='父菜单ID',
) )
parent: Mapped[Optional['Menu']] = relationship(init=False, back_populates='children', remote_side=[id]) parent: Mapped[Menu | None] = relationship(init=False, back_populates='children', remote_side=[id])
children: Mapped[Optional[list['Menu']]] = relationship(init=False, back_populates='parent') children: Mapped[list[Menu] | None] = relationship(init=False, back_populates='parent')
# 菜单角色多对多 # 菜单角色多对多
roles: Mapped[list[Role]] = relationship(init=False, secondary=sys_role_menu, back_populates='menus') roles: Mapped[list[Role]] = relationship(init=False, secondary=sys_role_menu, back_populates='menus')
+8 -7
View File
@@ -1,13 +1,11 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from datetime import datetime from datetime import datetime
from sqlalchemy import DateTime, String from sqlalchemy import String
from sqlalchemy.dialects.mysql import JSON, LONGTEXT from sqlalchemy.dialects.mysql import JSON, LONGTEXT
from sqlalchemy.dialects.postgresql import TEXT from sqlalchemy.dialects.postgresql import TEXT
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from backend.common.model import DataClassBase, id_key from backend.common.model import DataClassBase, TimeZone, id_key
from backend.utils.timezone import timezone from backend.utils.timezone import timezone
@@ -26,7 +24,7 @@ class OperaLog(DataClassBase):
country: Mapped[str | None] = mapped_column(String(50), comment='国家') country: Mapped[str | None] = mapped_column(String(50), comment='国家')
region: Mapped[str | None] = mapped_column(String(50), comment='地区') region: Mapped[str | None] = mapped_column(String(50), comment='地区')
city: Mapped[str | None] = mapped_column(String(50), comment='城市') city: Mapped[str | None] = mapped_column(String(50), comment='城市')
user_agent: Mapped[str] = mapped_column(String(255), comment='请求头') user_agent: Mapped[str] = mapped_column(LONGTEXT().with_variant(TEXT, 'postgresql'), comment='请求头')
os: Mapped[str | None] = mapped_column(String(50), comment='操作系统') os: Mapped[str | None] = mapped_column(String(50), comment='操作系统')
browser: Mapped[str | None] = mapped_column(String(50), comment='浏览器') browser: Mapped[str | None] = mapped_column(String(50), comment='浏览器')
device: Mapped[str | None] = mapped_column(String(50), comment='设备') device: Mapped[str | None] = mapped_column(String(50), comment='设备')
@@ -35,7 +33,10 @@ class OperaLog(DataClassBase):
code: Mapped[str] = mapped_column(String(20), insert_default='200', comment='操作状态码') code: Mapped[str] = mapped_column(String(20), insert_default='200', comment='操作状态码')
msg: Mapped[str | None] = mapped_column(LONGTEXT().with_variant(TEXT, 'postgresql'), comment='提示消息') msg: Mapped[str | None] = mapped_column(LONGTEXT().with_variant(TEXT, 'postgresql'), comment='提示消息')
cost_time: Mapped[float] = mapped_column(insert_default=0.0, comment='请求耗时(ms') cost_time: Mapped[float] = mapped_column(insert_default=0.0, comment='请求耗时(ms')
opera_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), comment='操作时间') opera_time: Mapped[datetime] = mapped_column(TimeZone, comment='操作时间')
created_time: Mapped[datetime] = mapped_column( created_time: Mapped[datetime] = mapped_column(
DateTime(timezone=True), init=False, default_factory=timezone.now, comment='创建时间' TimeZone,
init=False,
default_factory=timezone.now,
comment='创建时间',
) )
+6 -4
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -25,10 +23,14 @@ class Role(Base):
name: Mapped[str] = mapped_column(String(20), unique=True, comment='角色名称') name: Mapped[str] = mapped_column(String(20), unique=True, comment='角色名称')
status: Mapped[int] = mapped_column(default=1, comment='角色状态(0停用 1正常)') status: Mapped[int] = mapped_column(default=1, comment='角色状态(0停用 1正常)')
is_filter_scopes: Mapped[bool] = mapped_column( is_filter_scopes: Mapped[bool] = mapped_column(
Boolean().with_variant(INTEGER, 'postgresql'), default=True, comment='过滤数据权限(0否 1是)' Boolean().with_variant(INTEGER, 'postgresql'),
default=True,
comment='过滤数据权限(0否 1是)',
) )
remark: Mapped[str | None] = mapped_column( remark: Mapped[str | None] = mapped_column(
LONGTEXT().with_variant(TEXT, 'postgresql'), default=None, comment='备注' LONGTEXT().with_variant(TEXT, 'postgresql'),
default=None,
comment='备注',
) )
# 角色用户多对多 # 角色用户多对多
+19 -12
View File
@@ -1,16 +1,14 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from sqlalchemy import VARBINARY, Boolean, DateTime, ForeignKey, String from sqlalchemy import VARBINARY, Boolean, ForeignKey, String
from sqlalchemy.dialects.postgresql import BYTEA, INTEGER from sqlalchemy.dialects.postgresql import BYTEA, INTEGER
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.app.admin.model.m2m import sys_user_role from backend.app.admin.model.m2m import sys_user_role
from backend.common.model import Base, id_key from backend.common.model import Base, TimeZone, id_key
from backend.database.db import uuid4_str from backend.database.db import uuid4_str
from backend.utils.timezone import timezone from backend.utils.timezone import timezone
@@ -34,24 +32,33 @@ class User(Base):
avatar: Mapped[str | None] = mapped_column(String(255), default=None, comment='头像') avatar: Mapped[str | None] = mapped_column(String(255), default=None, comment='头像')
status: Mapped[int] = mapped_column(default=1, index=True, comment='用户账号状态(0停用 1正常)') status: Mapped[int] = mapped_column(default=1, index=True, comment='用户账号状态(0停用 1正常)')
is_superuser: Mapped[bool] = mapped_column( is_superuser: Mapped[bool] = mapped_column(
Boolean().with_variant(INTEGER, 'postgresql'), default=False, comment='超级权限(0否 1是)' Boolean().with_variant(INTEGER, 'postgresql'),
default=False,
comment='超级权限(0否 1是)',
) )
is_staff: Mapped[bool] = mapped_column( is_staff: Mapped[bool] = mapped_column(
Boolean().with_variant(INTEGER, 'postgresql'), default=False, comment='后台管理登陆(0否 1是)' Boolean().with_variant(INTEGER, 'postgresql'),
default=False,
comment='后台管理登陆(0否 1是)',
) )
is_multi_login: Mapped[bool] = mapped_column( is_multi_login: Mapped[bool] = mapped_column(
Boolean().with_variant(INTEGER, 'postgresql'), default=False, comment='是否重复登陆(0否 1是)' Boolean().with_variant(INTEGER, 'postgresql'),
) default=False,
join_time: Mapped[datetime] = mapped_column( comment='是否重复登陆(0否 1是)',
DateTime(timezone=True), init=False, default_factory=timezone.now, comment='注册时间'
) )
join_time: Mapped[datetime] = mapped_column(TimeZone, init=False, default_factory=timezone.now, comment='注册时间')
last_login_time: Mapped[datetime | None] = mapped_column( last_login_time: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), init=False, onupdate=timezone.now, comment='上次登录' TimeZone,
init=False,
onupdate=timezone.now,
comment='上次登录',
) )
# 部门用户一对多 # 部门用户一对多
dept_id: Mapped[int | None] = mapped_column( dept_id: Mapped[int | None] = mapped_column(
ForeignKey('sys_dept.id', ondelete='SET NULL'), default=None, comment='部门关联ID' ForeignKey('sys_dept.id', ondelete='SET NULL'),
default=None,
comment='部门关联ID',
) )
dept: Mapped[Dept | None] = relationship(init=False, back_populates='users') dept: Mapped[Dept | None] = relationship(init=False, back_populates='users')
-2
View File
@@ -1,2 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
+2 -3
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from pydantic import Field from pydantic import Field
from backend.common.schema import SchemaBase from backend.common.schema import SchemaBase
@@ -8,5 +6,6 @@ from backend.common.schema import SchemaBase
class GetCaptchaDetail(SchemaBase): class GetCaptchaDetail(SchemaBase):
"""验证码详情""" """验证码详情"""
image_type: str = Field(description='图片类型') uuid: str = Field(description='图片唯一标识')
img_type: str = Field(description='图片类型')
image: str = Field(description='图片内容') image: str = Field(description='图片内容')
-2
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from datetime import datetime from datetime import datetime
from pydantic import ConfigDict, Field from pydantic import ConfigDict, Field
-2
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from datetime import datetime from datetime import datetime
from pydantic import ConfigDict, Field from pydantic import ConfigDict, Field
+6 -2
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from datetime import datetime from datetime import datetime
from pydantic import ConfigDict, Field from pydantic import ConfigDict, Field
@@ -37,3 +35,9 @@ class GetDeptDetail(DeptSchemaBase):
del_flag: bool = Field(description='是否删除') del_flag: bool = Field(description='是否删除')
created_time: datetime = Field(description='创建时间') created_time: datetime = Field(description='创建时间')
updated_time: datetime | None = Field(None, description='更新时间') updated_time: datetime | None = Field(None, description='更新时间')
class GetDeptTree(GetDeptDetail):
"""获取部门树"""
children: list['GetDeptTree'] | None = Field(None, description='子菜单')
-2
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from datetime import datetime from datetime import datetime
from pydantic import ConfigDict, Field from pydantic import ConfigDict, Field
+6 -2
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from datetime import datetime from datetime import datetime
from pydantic import ConfigDict, Field from pydantic import ConfigDict, Field
@@ -43,3 +41,9 @@ class GetMenuDetail(MenuSchemaBase):
id: int = Field(description='菜单 ID') id: int = Field(description='菜单 ID')
created_time: datetime = Field(description='创建时间') created_time: datetime = Field(description='创建时间')
updated_time: datetime | None = Field(None, description='更新时间') updated_time: datetime | None = Field(None, description='更新时间')
class GetMenuTree(GetMenuDetail):
"""获取菜单树"""
children: list['GetMenuTree'] | None = Field(None, description='子菜单')
-2
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
-2
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from datetime import datetime from datetime import datetime
from pydantic import ConfigDict, Field from pydantic import ConfigDict, Field
-2
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from datetime import datetime from datetime import datetime
from pydantic import Field from pydantic import Field
+1 -2
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
@@ -22,6 +20,7 @@ class AuthSchemaBase(SchemaBase):
class AuthLoginParam(AuthSchemaBase): class AuthLoginParam(AuthSchemaBase):
"""用户登录参数""" """用户登录参数"""
uuid: str = Field(description='验证码 UUID')
captcha: str = Field(description='验证码') captcha: str = Field(description='验证码')
-2
View File
@@ -1,2 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
+36 -35
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from fastapi import Request, Response from fastapi import Request, Response
from fastapi.security import HTTPBasicCredentials from fastapi.security import HTTPBasicCredentials
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -49,9 +47,8 @@ class AuthService:
if user.password is None: if user.password is None:
raise errors.AuthorizationError(msg='用户名或密码有误') raise errors.AuthorizationError(msg='用户名或密码有误')
else: if not password_verify(password, user.password):
if not password_verify(password, user.password): raise errors.AuthorizationError(msg='用户名或密码有误')
raise errors.AuthorizationError(msg='用户名或密码有误')
if not user.status: if not user.status:
raise errors.AuthorizationError(msg='用户已被锁定, 请联系统管理员') raise errors.AuthorizationError(msg='用户已被锁定, 请联系统管理员')
@@ -70,14 +67,19 @@ class AuthService:
await user_dao.update_login_time(db, obj.username) await user_dao.update_login_time(db, obj.username)
access_token = await create_access_token( access_token = await create_access_token(
user.id, user.id,
user.is_multi_login, multi_login=user.is_multi_login,
# extra info # extra info
swagger=True, swagger=True,
) )
return access_token.access_token, user return access_token.access_token, user
async def login( async def login(
self, *, request: Request, response: Response, obj: AuthLoginParam, background_tasks: BackgroundTasks self,
*,
request: Request,
response: Response,
obj: AuthLoginParam,
background_tasks: BackgroundTasks,
) -> GetLoginToken: ) -> GetLoginToken:
""" """
用户登录 用户登录
@@ -92,17 +94,17 @@ class AuthService:
user = None user = None
try: try:
user = await self.user_verify(db, obj.username, obj.password) user = await self.user_verify(db, obj.username, obj.password)
captcha_code = await redis_client.get(f'{settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{request.state.ip}') captcha_code = await redis_client.get(f'{settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{obj.uuid}')
if not captcha_code: if not captcha_code:
raise errors.RequestError(msg=t('error.captcha.expired')) raise errors.RequestError(msg=t('error.captcha.expired'))
if captcha_code.lower() != obj.captcha.lower(): if captcha_code.lower() != obj.captcha.lower():
raise errors.CustomError(error=CustomErrorCode.CAPTCHA_ERROR) raise errors.CustomError(error=CustomErrorCode.CAPTCHA_ERROR)
await redis_client.delete(f'{settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{request.state.ip}') await redis_client.delete(f'{settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{obj.uuid}')
await user_dao.update_login_time(db, obj.username) await user_dao.update_login_time(db, obj.username)
await db.refresh(user) await db.refresh(user)
access_token = await create_access_token( access_token = await create_access_token(
user.id, user.id,
user.is_multi_login, multi_login=user.is_multi_login,
# extra info # extra info
username=user.username, username=user.username,
nickname=user.nickname, nickname=user.nickname,
@@ -112,7 +114,11 @@ class AuthService:
browser=request.state.browser, browser=request.state.browser,
device=request.state.device, device=request.state.device,
) )
refresh_token = await create_refresh_token(access_token.session_uuid, user.id, user.is_multi_login) refresh_token = await create_refresh_token(
access_token.session_uuid,
user.id,
multi_login=user.is_multi_login,
)
response.set_cookie( response.set_cookie(
key=settings.COOKIE_REFRESH_TOKEN_KEY, key=settings.COOKIE_REFRESH_TOKEN_KEY,
value=refresh_token.refresh_token, value=refresh_token.refresh_token,
@@ -128,32 +134,28 @@ class AuthService:
log.error('登陆错误: 用户密码有误') log.error('登陆错误: 用户密码有误')
task = BackgroundTask( task = BackgroundTask(
login_log_service.create, login_log_service.create,
**dict( db=db,
db=db, request=request,
request=request, user_uuid=user.uuid if user else uuid4_str(),
user_uuid=user.uuid if user else uuid4_str(), username=obj.username,
username=obj.username, login_time=timezone.now(),
login_time=timezone.now(), status=LoginLogStatusType.fail.value,
status=LoginLogStatusType.fail.value, msg=e.msg,
msg=e.msg,
),
) )
raise errors.RequestError(code=e.code, msg=e.msg, background=task) raise errors.RequestError(code=e.code, msg=e.msg, background=task)
except Exception as e: except Exception as e:
log.error(f'登陆错误: {e}') log.error(f'登陆错误: {e}')
raise e raise
else: else:
background_tasks.add_task( background_tasks.add_task(
login_log_service.create, login_log_service.create,
**dict( db=db,
db=db, request=request,
request=request, user_uuid=user.uuid,
user_uuid=user.uuid, username=obj.username,
username=obj.username, login_time=timezone.now(),
login_time=timezone.now(), status=LoginLogStatusType.success.value,
status=LoginLogStatusType.success.value, msg=t('success.login.success'),
msg=t('success.login.success'),
),
) )
data = GetLoginToken( data = GetLoginToken(
access_token=access_token.access_token, access_token=access_token.access_token,
@@ -204,16 +206,15 @@ class AuthService:
user = await user_dao.get(db, token_payload.id) user = await user_dao.get(db, token_payload.id)
if not user: if not user:
raise errors.NotFoundError(msg='用户不存在') raise errors.NotFoundError(msg='用户不存在')
elif not user.status: if not user.status:
raise errors.AuthorizationError(msg='用户已被锁定, 请联系统管理员') raise errors.AuthorizationError(msg='用户已被锁定, 请联系统管理员')
if not user.is_multi_login: if not user.is_multi_login and await redis_client.keys(match=f'{settings.TOKEN_REDIS_PREFIX}:{user.id}:*'):
if await redis_client.keys(match=f'{settings.TOKEN_REDIS_PREFIX}:{user.id}:*'): raise errors.ForbiddenError(msg='此用户已在异地登录,请重新登录并及时修改密码')
raise errors.ForbiddenError(msg='此用户已在异地登录,请重新登录并及时修改密码')
new_token = await create_new_token( new_token = await create_new_token(
refresh_token, refresh_token,
token_payload.session_uuid, token_payload.session_uuid,
user.id, user.id,
user.is_multi_login, multi_login=user.is_multi_login,
# extra info # extra info
username=user.username, username=user.username,
nickname=user.nickname, nickname=user.nickname,
@@ -1,6 +1,4 @@
#!/usr/bin/env python3 from collections.abc import Sequence
# -*- coding: utf-8 -*-
from typing import Sequence
from sqlalchemy import Select from sqlalchemy import Select
@@ -103,9 +101,8 @@ class DataRuleService:
data_rule = await data_rule_dao.get(db, pk) data_rule = await data_rule_dao.get(db, pk)
if not data_rule: if not data_rule:
raise errors.NotFoundError(msg='数据规则不存在') raise errors.NotFoundError(msg='数据规则不存在')
if data_rule.name != obj.name: if data_rule.name != obj.name and await data_rule_dao.get_by_name(db, obj.name):
if await data_rule_dao.get_by_name(db, obj.name): raise errors.ConflictError(msg='数据规则已存在')
raise errors.ConflictError(msg='数据规则已存在')
count = await data_rule_dao.update(db, pk, obj) count = await data_rule_dao.update(db, pk, obj)
return count return count
@@ -1,6 +1,4 @@
#!/usr/bin/env python3 from collections.abc import Sequence
# -*- coding: utf-8 -*-
from typing import Sequence
from sqlalchemy import Select from sqlalchemy import Select
@@ -94,9 +92,8 @@ class DataScopeService:
data_scope = await data_scope_dao.get(db, pk) data_scope = await data_scope_dao.get(db, pk)
if not data_scope: if not data_scope:
raise errors.NotFoundError(msg='数据范围不存在') raise errors.NotFoundError(msg='数据范围不存在')
if data_scope.name != obj.name: if data_scope.name != obj.name and await data_scope_dao.get_by_name(db, obj.name):
if await data_scope_dao.get_by_name(db, obj.name): raise errors.ConflictError(msg='数据范围已存在')
raise errors.ConflictError(msg='数据范围已存在')
count = await data_scope_dao.update(db, pk, obj) count = await data_scope_dao.update(db, pk, obj)
for role in await data_scope.awaitable_attrs.roles: for role in await data_scope.awaitable_attrs.roles:
for user in await role.awaitable_attrs.users: for user in await role.awaitable_attrs.users:
+8 -6
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Any from typing import Any
from fastapi import Request from fastapi import Request
@@ -33,7 +31,12 @@ class DeptService:
@staticmethod @staticmethod
async def get_tree( async def get_tree(
*, request: Request, name: str | None, leader: str | None, phone: str | None, status: int | None *,
request: Request,
name: str | None,
leader: str | None,
phone: str | None,
status: int | None,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
""" """
获取部门树形结构 获取部门树形结构
@@ -81,9 +84,8 @@ class DeptService:
dept = await dept_dao.get(db, pk) dept = await dept_dao.get(db, pk)
if not dept: if not dept:
raise errors.NotFoundError(msg='部门不存在') raise errors.NotFoundError(msg='部门不存在')
if dept.name != obj.name: if dept.name != obj.name and await dept_dao.get_by_name(db, obj.name):
if await dept_dao.get_by_name(db, obj.name): raise errors.ConflictError(msg='部门名称已存在')
raise errors.ConflictError(msg='部门名称已存在')
if obj.parent_id: if obj.parent_id:
parent_dept = await dept_dao.get(db, obj.parent_id) parent_dept = await dept_dao.get(db, obj.parent_id)
if not parent_dept: if not parent_dept:
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from datetime import datetime from datetime import datetime
from fastapi import Request from fastapi import Request
+3 -7
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Any from typing import Any
from fastapi import Request from fastapi import Request
@@ -61,8 +59,7 @@ class MenuService:
menu_ids = set() menu_ids = set()
if roles: if roles:
for role in roles: for role in roles:
for menu in role.menus: menu_ids.update(menu.id for menu in role.menus)
menu_ids.add(menu.id)
menu_data = await menu_dao.get_sidebar(db, list(menu_ids)) menu_data = await menu_dao.get_sidebar(db, list(menu_ids))
menu_tree = get_vben5_tree_data(menu_data) menu_tree = get_vben5_tree_data(menu_data)
return menu_tree return menu_tree
@@ -98,9 +95,8 @@ class MenuService:
menu = await menu_dao.get(db, pk) menu = await menu_dao.get(db, pk)
if not menu: if not menu:
raise errors.NotFoundError(msg='菜单不存在') raise errors.NotFoundError(msg='菜单不存在')
if menu.title != obj.title: if menu.title != obj.title and await menu_dao.get_by_title(db, obj.title):
if await menu_dao.get_by_title(db, obj.title): raise errors.ConflictError(msg='菜单标题已存在')
raise errors.ConflictError(msg='菜单标题已存在')
if obj.parent_id: if obj.parent_id:
parent_menu = await menu_dao.get(db, obj.parent_id) parent_menu = await menu_dao.get(db, obj.parent_id)
if not parent_menu: if not parent_menu:
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from sqlalchemy import Select from sqlalchemy import Select
from backend.app.admin.crud.crud_opera_log import opera_log_dao from backend.app.admin.crud.crud_opera_log import opera_log_dao
+12 -16
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import io import io
import json import json
import os import os
@@ -8,6 +6,8 @@ import zipfile
from typing import Any from typing import Any
import anyio
from fastapi import UploadFile from fastapi import UploadFile
from backend.common.enums import PluginType, StatusType from backend.common.enums import PluginType, StatusType
@@ -26,14 +26,10 @@ class PluginService:
@staticmethod @staticmethod
async def get_all() -> list[dict[str, Any]]: async def get_all() -> list[dict[str, Any]]:
"""获取所有插件""" """获取所有插件"""
keys = []
result = []
async for key in redis_client.scan_iter(f'{settings.PLUGIN_REDIS_PREFIX}:*'): keys = [key async for key in redis_client.scan_iter(f'{settings.PLUGIN_REDIS_PREFIX}:*')]
keys.append(key)
for info in await redis_client.mget(*keys): result = [json.loads(info) for info in await redis_client.mget(*keys)]
result.append(json.loads(info))
return result return result
@@ -61,24 +57,24 @@ class PluginService:
return await install_git_plugin(repo_url) return await install_git_plugin(repo_url)
@staticmethod @staticmethod
async def uninstall(*, plugin: str): async def uninstall(*, plugin: str) -> None:
""" """
卸载插件 卸载插件
:param plugin: 插件名称 :param plugin: 插件名称
:return: :return:
""" """
plugin_dir = os.path.join(PLUGIN_DIR, plugin) plugin_dir = anyio.Path(PLUGIN_DIR / plugin)
if not os.path.exists(plugin_dir): if not await plugin_dir.exists():
raise errors.NotFoundError(msg='插件不存在') raise errors.NotFoundError(msg='插件不存在')
await uninstall_requirements_async(plugin) await uninstall_requirements_async(plugin)
bacup_dir = os.path.join(PLUGIN_DIR, f'{plugin}.{timezone.now().strftime("%Y%m%d%H%M%S")}.backup') bacup_dir = PLUGIN_DIR / f'{plugin}.{timezone.now().strftime("%Y%m%d%H%M%S")}.backup'
shutil.move(plugin_dir, bacup_dir) shutil.move(plugin_dir, bacup_dir)
await redis_client.delete(f'{settings.PLUGIN_REDIS_PREFIX}:{plugin}') await redis_client.delete(f'{settings.PLUGIN_REDIS_PREFIX}:{plugin}')
await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'ture') await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'ture')
@staticmethod @staticmethod
async def update_status(*, plugin: str): async def update_status(*, plugin: str) -> None:
""" """
更新插件状态 更新插件状态
@@ -107,8 +103,8 @@ class PluginService:
:param plugin: 插件名称 :param plugin: 插件名称
:return: :return:
""" """
plugin_dir = os.path.join(PLUGIN_DIR, plugin) plugin_dir = anyio.Path(PLUGIN_DIR / plugin)
if not os.path.exists(plugin_dir): if not await plugin_dir.exists():
raise errors.NotFoundError(msg='插件不存在') raise errors.NotFoundError(msg='插件不存在')
bio = io.BytesIO() bio = io.BytesIO()
@@ -117,7 +113,7 @@ class PluginService:
dirs[:] = [d for d in dirs if d != '__pycache__'] dirs[:] = [d for d in dirs if d != '__pycache__']
for file in files: for file in files:
file_path = os.path.join(root, file) file_path = os.path.join(root, file)
arcname = os.path.relpath(file_path, start=plugin_dir) arcname = os.path.relpath(file_path, start=plugin_dir) # noqa: ASYNC240
zf.write(file_path, os.path.join(plugin, arcname)) zf.write(file_path, os.path.join(plugin, arcname))
bio.seek(0) bio.seek(0)
+4 -6
View File
@@ -1,6 +1,5 @@
#!/usr/bin/env python3 from collections.abc import Sequence
# -*- coding: utf-8 -*- from typing import Any
from typing import Any, Sequence
from sqlalchemy import Select from sqlalchemy import Select
@@ -114,9 +113,8 @@ class RoleService:
role = await role_dao.get(db, pk) role = await role_dao.get(db, pk)
if not role: if not role:
raise errors.NotFoundError(msg='角色不存在') raise errors.NotFoundError(msg='角色不存在')
if role.name != obj.name: if role.name != obj.name and await role_dao.get_by_name(db, obj.name):
if await role_dao.get_by_name(db, obj.name): raise errors.ConflictError(msg='角色已存在')
raise errors.ConflictError(msg='角色已存在')
count = await role_dao.update(db, pk, obj) count = await role_dao.update(db, pk, obj)
for user in await role.awaitable_attrs.users: for user in await role.awaitable_attrs.users:
await redis_client.delete_prefix(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}') await redis_client.delete_prefix(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
+10 -12
View File
@@ -1,8 +1,6 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import random import random
from typing import Sequence from collections.abc import Sequence
from fastapi import Request from fastapi import Request
from sqlalchemy import Select from sqlalchemy import Select
@@ -83,7 +81,7 @@ class UserService:
superuser_verify(request) superuser_verify(request)
if await user_dao.get_by_username(db, obj.username): if await user_dao.get_by_username(db, obj.username):
raise errors.ConflictError(msg='用户名已注册') raise errors.ConflictError(msg='用户名已注册')
obj.nickname = obj.nickname if obj.nickname else f'#{random.randrange(88888, 99999)}' obj.nickname = obj.nickname or f'#{random.randrange(88888, 99999)}'
if not obj.password: if not obj.password:
raise errors.RequestError(msg='密码不允许为空') raise errors.RequestError(msg='密码不允许为空')
if not await dept_dao.get(db, obj.dept_id): if not await dept_dao.get(db, obj.dept_id):
@@ -108,9 +106,8 @@ class UserService:
user = await user_dao.get_with_relation(db, user_id=pk) user = await user_dao.get_with_relation(db, user_id=pk)
if not user: if not user:
raise errors.NotFoundError(msg='用户不存在') raise errors.NotFoundError(msg='用户不存在')
if obj.username != user.username: if obj.username != user.username and await user_dao.get_by_username(db, obj.username):
if await user_dao.get_by_username(db, obj.username): raise errors.ConflictError(msg='用户名已注册')
raise errors.ConflictError(msg='用户名已注册')
for role_id in obj.roles: for role_id in obj.roles:
if not await role_dao.get(db, role_id): if not await role_dao.get(db, role_id):
raise errors.NotFoundError(msg='角色不存在') raise errors.NotFoundError(msg='角色不存在')
@@ -119,7 +116,7 @@ class UserService:
return count return count
@staticmethod @staticmethod
async def update_permission(*, request: Request, pk: int, type: UserPermissionType) -> int: async def update_permission(*, request: Request, pk: int, type: UserPermissionType) -> int: # noqa: C901
""" """
更新用户权限 更新用户权限
@@ -137,14 +134,14 @@ class UserService:
raise errors.NotFoundError(msg='用户不存在') raise errors.NotFoundError(msg='用户不存在')
if pk == request.user.id: if pk == request.user.id:
raise errors.ForbiddenError(msg='禁止修改自身权限') raise errors.ForbiddenError(msg='禁止修改自身权限')
count = await user_dao.set_super(db, pk, not user.status) count = await user_dao.set_super(db, pk, is_super=not user.status)
case UserPermissionType.staff: case UserPermissionType.staff:
user = await user_dao.get(db, pk) user = await user_dao.get(db, pk)
if not user: if not user:
raise errors.NotFoundError(msg='用户不存在') raise errors.NotFoundError(msg='用户不存在')
if pk == request.user.id: if pk == request.user.id:
raise errors.ForbiddenError(msg='禁止修改自身权限') raise errors.ForbiddenError(msg='禁止修改自身权限')
count = await user_dao.set_staff(db, pk, not user.is_staff) count = await user_dao.set_staff(db, pk, is_staff=not user.is_staff)
case UserPermissionType.status: case UserPermissionType.status:
user = await user_dao.get(db, pk) user = await user_dao.get(db, pk)
if not user: if not user:
@@ -158,7 +155,7 @@ class UserService:
raise errors.NotFoundError(msg='用户不存在') raise errors.NotFoundError(msg='用户不存在')
multi_login = user.is_multi_login if pk != user.id else request.user.is_multi_login multi_login = user.is_multi_login if pk != user.id else request.user.is_multi_login
new_multi_login = not multi_login new_multi_login = not multi_login
count = await user_dao.set_multi_login(db, pk, new_multi_login) count = await user_dao.set_multi_login(db, pk, multi_login=new_multi_login)
token = get_token(request) token = get_token(request)
token_payload = jwt_decode(token) token_payload = jwt_decode(token)
if pk == user.id: if pk == user.id:
@@ -166,7 +163,8 @@ class UserService:
if not new_multi_login: if not new_multi_login:
key_prefix = f'{settings.TOKEN_REDIS_PREFIX}:{user.id}' key_prefix = f'{settings.TOKEN_REDIS_PREFIX}:{user.id}'
await redis_client.delete_prefix( await redis_client.delete_prefix(
key_prefix, exclude=f'{key_prefix}:{token_payload.session_uuid}' key_prefix,
exclude=f'{key_prefix}:{token_payload.session_uuid}',
) )
else: else:
# 系统管理员修改他人时,他人 token 全部失效 # 系统管理员修改他人时,他人 token 全部失效
-2
View File
@@ -1,2 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
@@ -1,2 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from starlette.testclient import TestClient from starlette.testclient import TestClient
+1 -3
View File
@@ -1,6 +1,4 @@
#!/usr/bin/env python3 from collections.abc import Generator
# -*- coding: utf-8 -*-
from typing import Generator
import pytest import pytest
@@ -1,2 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
+1 -3
View File
@@ -1,6 +1,4 @@
#!/usr/bin/env python3 from collections.abc import AsyncGenerator
# -*- coding: utf-8 -*-
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio.session import AsyncSession from sqlalchemy.ext.asyncio.session import AsyncSession
-2
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from fastapi import APIRouter from fastapi import APIRouter
from backend.app.admin.api.router import v1 as admin_v1 from backend.app.admin.api.router import v1 as admin_v1
-2
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import sys import sys
from backend.core.path_conf import BASE_PATH from backend.core.path_conf import BASE_PATH
+1 -3
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from starlette.concurrency import run_in_threadpool from starlette.concurrency import run_in_threadpool
from backend.app.task.celery import celery_app from backend.app.task.celery import celery_app
@@ -7,7 +5,7 @@ from backend.common.socketio.server import sio
@sio.event @sio.event
async def task_worker_status(sid, data): async def task_worker_status(sid, data) -> None: # noqa: ANN001
"""任务 Worker 状态事件""" """任务 Worker 状态事件"""
worker = await run_in_threadpool(celery_app.control.ping) worker = await run_in_threadpool(celery_app.control.ping)
await sio.emit('task_worker_status', worker, sid) await sio.emit('task_worker_status', worker, sid)
-2
View File
@@ -1,2 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
-2
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from fastapi import APIRouter from fastapi import APIRouter
from backend.app.task.api.v1.control import router as task_control_router from backend.app.task.api.v1.control import router as task_control_router
-2
View File
@@ -1,2 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
+1 -3
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, Path from fastapi import APIRouter, Depends, Path
@@ -24,7 +22,7 @@ async def get_task_registered() -> ResponseSchemaModel[list[TaskRegisteredDetail
raise errors.ServerError(msg='Celery Worker 暂不可用,请稍后重试') raise errors.ServerError(msg='Celery Worker 暂不可用,请稍后重试')
task_registered = [] task_registered = []
celery_app_tasks = celery_app.tasks celery_app_tasks = celery_app.tasks
for _, tasks in registered.items(): for tasks in registered.values():
for task in tasks: for task in tasks:
task_ins = celery_app_tasks.get(task) task_ins = celery_app_tasks.get(task)
if task_ins: if task_ins:
-2
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query from fastapi import APIRouter, Depends, Path, Query
+8 -5
View File
@@ -1,10 +1,12 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query from fastapi import APIRouter, Depends, Path, Query
from backend.app.task.schema.scheduler import CreateTaskSchedulerParam, GetTaskSchedulerDetail, UpdateTaskSchedulerParam from backend.app.task.schema.scheduler import (
CreateTaskSchedulerParam,
GetTaskSchedulerDetail,
UpdateTaskSchedulerParam,
)
from backend.app.task.service.scheduler_service import task_scheduler_service from backend.app.task.service.scheduler_service import task_scheduler_service
from backend.common.pagination import DependsPagination, PageData, paging_data from backend.common.pagination import DependsPagination, PageData, paging_data
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
@@ -40,7 +42,7 @@ async def get_task_scheduler(
) )
async def get_task_scheduler_paged( async def get_task_scheduler_paged(
db: CurrentSession, db: CurrentSession,
name: Annotated[int, Path(description='任务调度名称')] = None, name: Annotated[int | None, Path(description='任务调度名称')] = None,
type: Annotated[int | None, Query(description='任务调度类型')] = None, type: Annotated[int | None, Query(description='任务调度类型')] = None,
) -> ResponseSchemaModel[PageData[GetTaskSchedulerDetail]]: ) -> ResponseSchemaModel[PageData[GetTaskSchedulerDetail]]:
task_scheduler_select = await task_scheduler_service.get_select(name=name, type=type) task_scheduler_select = await task_scheduler_service.get_select(name=name, type=type)
@@ -70,7 +72,8 @@ async def create_task_scheduler(obj: CreateTaskSchedulerParam) -> ResponseModel:
], ],
) )
async def update_task_scheduler( async def update_task_scheduler(
pk: Annotated[int, Path(description='任务调度 ID')], obj: UpdateTaskSchedulerParam pk: Annotated[int, Path(description='任务调度 ID')],
obj: UpdateTaskSchedulerParam,
) -> ResponseModel: ) -> ResponseModel:
count = await task_scheduler_service.update(pk=pk, obj=obj) count = await task_scheduler_service.update(pk=pk, obj=obj)
if count > 0: if count > 0:
+13 -15
View File
@@ -1,20 +1,17 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import os import os
import celery import celery
import celery_aio_pool import celery_aio_pool
from backend.app.task.model.result import OVERWRITE_CELERY_RESULT_GROUP_TABLE_NAME, OVERWRITE_CELERY_RESULT_TABLE_NAME
from backend.app.task.tasks.beat import LOCAL_BEAT_SCHEDULE from backend.app.task.tasks.beat import LOCAL_BEAT_SCHEDULE
from backend.core.conf import settings from backend.core.conf import settings
from backend.core.path_conf import BASE_PATH from backend.core.path_conf import BASE_PATH
def find_task_packages(): def find_task_packages() -> list[str]:
packages = [] packages = []
task_dir = os.path.join(BASE_PATH, 'app', 'task', 'tasks') task_dir = BASE_PATH / 'app' / 'task' / 'tasks'
for root, dirs, files in os.walk(task_dir): for root, _dirs, files in os.walk(task_dir):
if 'tasks.py' in files: if 'tasks.py' in files:
package = root.replace(str(BASE_PATH.parent) + os.path.sep, '').replace(os.path.sep, '.') package = root.replace(str(BASE_PATH.parent) + os.path.sep, '').replace(os.path.sep, '.')
packages.append(package) packages.append(package)
@@ -30,22 +27,19 @@ def init_celery() -> celery.Celery:
celery.app.trace.build_tracer = celery_aio_pool.build_async_tracer celery.app.trace.build_tracer = celery_aio_pool.build_async_tracer
celery.app.trace.reset_worker_optimizations() celery.app.trace.reset_worker_optimizations()
# https://docs.celeryq.dev/en/stable/userguide/configuration.html
app = celery.Celery( app = celery.Celery(
'fba_celery', 'fba_celery',
broker=f'redis://:{settings.REDIS_PASSWORD}@{settings.REDIS_HOST}:{settings.REDIS_PORT}/{settings.CELERY_BROKER_REDIS_DATABASE}' broker_url=f'redis://:{settings.REDIS_PASSWORD}@{settings.REDIS_HOST}:{settings.REDIS_PORT}/{settings.CELERY_BROKER_REDIS_DATABASE}'
if settings.CELERY_BROKER == 'redis' if settings.CELERY_BROKER == 'redis'
else f'amqp://{settings.CELERY_RABBITMQ_USERNAME}:{settings.CELERY_RABBITMQ_PASSWORD}@{settings.CELERY_RABBITMQ_HOST}:{settings.CELERY_RABBITMQ_PORT}', else f'amqp://{settings.CELERY_RABBITMQ_USERNAME}:{settings.CELERY_RABBITMQ_PASSWORD}@{settings.CELERY_RABBITMQ_HOST}:{settings.CELERY_RABBITMQ_PORT}',
broker_connection_retry_on_startup=True, broker_connection_retry_on_startup=True,
backend=f'db+{settings.DATABASE_TYPE}+{"pymysql" if settings.DATABASE_TYPE == "mysql" else "psycopg"}' result_backend=f'db+{settings.DATABASE_TYPE}+{"pymysql" if settings.DATABASE_TYPE == "mysql" else "psycopg"}'
f'://{settings.DATABASE_USER}:{settings.DATABASE_PASSWORD}@{settings.DATABASE_HOST}:{settings.DATABASE_PORT}/{settings.DATABASE_SCHEMA}', f'://{settings.DATABASE_USER}:{settings.DATABASE_PASSWORD}@{settings.DATABASE_HOST}:{settings.DATABASE_PORT}/{settings.DATABASE_SCHEMA}',
database_engine_options={'echo': settings.DATABASE_ECHO},
database_table_names={
'task': OVERWRITE_CELERY_RESULT_TABLE_NAME,
'group': OVERWRITE_CELERY_RESULT_GROUP_TABLE_NAME,
},
result_extended=True, result_extended=True,
# result_expires=0, # 清理任务结果,默认每天凌晨 4 点,0 或 None 表示不清理 database_engine_options={'echo': settings.DATABASE_ECHO},
# beat_sync_every=1, # 保存任务状态周期,默认 3 * 60 秒 # result_expires=0,
# beat_sync_every=1,
beat_schedule=LOCAL_BEAT_SCHEDULE, beat_schedule=LOCAL_BEAT_SCHEDULE,
beat_scheduler='backend.app.task.utils.schedulers:DatabaseScheduler', beat_scheduler='backend.app.task.utils.schedulers:DatabaseScheduler',
task_cls='backend.app.task.tasks.base:TaskBase', task_cls='backend.app.task.tasks.base:TaskBase',
@@ -54,6 +48,10 @@ def init_celery() -> celery.Celery:
timezone=settings.DATETIME_TIMEZONE, timezone=settings.DATETIME_TIMEZONE,
) )
# 在 Celery 中设置此参数无效
# 参数:https://github.com/celery/celery/issues/7270
app.loader.override_backends = {'db': 'backend.app.task.database:DatabaseBackend'}
# 自动发现任务 # 自动发现任务
packages = find_task_packages() packages = find_task_packages()
app.autodiscover_tasks(packages) app.autodiscover_tasks(packages)
-2
View File
@@ -1,2 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
+1 -3
View File
@@ -1,10 +1,8 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from sqlalchemy import Select from sqlalchemy import Select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy_crud_plus import CRUDPlus from sqlalchemy_crud_plus import CRUDPlus
from backend.app.task.model.result import TaskResult from backend.app.task.model import TaskResult
class CRUDTaskResult(CRUDPlus[TaskResult]): class CRUDTaskResult(CRUDPlus[TaskResult]):
+3 -5
View File
@@ -1,6 +1,4 @@
#!/usr/bin/env python3 from collections.abc import Sequence
# -*- coding: utf-8 -*-
from typing import Sequence
from sqlalchemy import Select from sqlalchemy import Select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -86,7 +84,7 @@ class CRUDTaskScheduler(CRUDPlus[TaskScheduler]):
TaskScheduler.no_changes = False TaskScheduler.no_changes = False
return 1 return 1
async def set_status(self, db: AsyncSession, pk: int, status: bool) -> int: async def set_status(self, db: AsyncSession, pk: int, *, status: bool) -> int:
""" """
设置任务调度状态 设置任务调度状态
@@ -96,7 +94,7 @@ class CRUDTaskScheduler(CRUDPlus[TaskScheduler]):
:return: :return:
""" """
task_scheduler = await self.get(db, pk) task_scheduler = await self.get(db, pk)
setattr(task_scheduler, 'enabled', status) task_scheduler.enabled = status
TaskScheduler.no_changes = False TaskScheduler.no_changes = False
return 1 return 1
+178
View File
@@ -0,0 +1,178 @@
from celery import states
from celery.backends.base import BaseBackend
from celery.backends.database import retry, session_cleanup
from celery.exceptions import ImproperlyConfigured
from celery.utils.time import maybe_timedelta
from sqlalchemy import PickleType
from sqlalchemy.orm import Session
from backend.app.task.model.result import Task, TaskExtended, TaskSet
from backend.app.task.session import SessionManager
"""
重写 from celery.backends.database 内部 DatabaseBackend 此类实现与模型配合不佳导致 fba 创建表和 alembic 迁移困难
"""
class DatabaseBackend(BaseBackend):
"""The database result backend."""
# ResultSet.iterate should sleep this much between each pool,
# to not bombard the database with queries.
subpolling_interval = 0.5
task_cls = Task
taskset_cls = TaskSet
def __init__(self, dburi=None, engine_options=None, url=None, **kwargs) -> None: # noqa: ANN001
# The `url` argument was added later and is used by
# the app to set backend by url (celery.app.backends.by_url)
super().__init__(expires_type=maybe_timedelta, url=url, **kwargs)
conf = self.app.conf
if self.extended_result:
self.task_cls = TaskExtended
self.url = url or dburi or conf.database_url
self.engine_options = dict(engine_options or {}, **conf.database_engine_options or {})
self.short_lived_sessions = kwargs.get('short_lived_sessions', conf.database_short_lived_sessions)
schemas = conf.database_table_schemas or {}
tablenames = conf.database_table_names or {}
self.task_cls.configure(schema=schemas.get('task'), name=tablenames.get('task'))
self.taskset_cls.configure(schema=schemas.get('group'), name=tablenames.get('group'))
if not self.url:
raise ImproperlyConfigured(
'Missing connection string! Do you have the database_url setting set to a real value?',
)
self.session_manager = SessionManager()
create_tables_at_setup = conf.database_create_tables_at_setup
if create_tables_at_setup is True:
self._create_tables()
@property
def extended_result(self): # noqa: ANN201
return self.app.conf.find_value_for_key('extended', 'result')
def _create_tables(self) -> None:
"""Create the task and taskset tables."""
self.result_session()
def result_session(self, session_manager=None) -> Session: # noqa: ANN001
if session_manager is None:
session_manager = self.session_manager
return session_manager.session_factory(
dburi=self.url,
short_lived_sessions=self.short_lived_sessions,
**self.engine_options,
)
@retry
def _store_result(self, task_id, result, state, traceback=None, request=None, **kwargs) -> None: # noqa: ANN001
"""Store return value and state of an executed task."""
session = self.result_session()
with session_cleanup(session):
task = list(session.query(self.task_cls).filter(self.task_cls.task_id == task_id))
task = task and task[0]
if not task:
task = self.task_cls(task_id)
task.task_id = task_id
session.add(task)
session.flush()
self._update_result(task, result, state, traceback=traceback, request=request)
session.commit()
def _update_result(self, task, result, state, traceback=None, request=None) -> None: # noqa: ANN001
meta = self._get_result_meta(
result=result,
state=state,
traceback=traceback,
request=request,
format_date=False,
encode=True,
)
# Exclude the primary key id and task_id columns
# as we should not set it None
columns = [column.name for column in self.task_cls.__table__.columns if column.name not in {'id', 'task_id'}]
# Iterate through the columns name of the table
# to set the value from meta.
# If the value is not present in meta, set None
for column in columns:
value = meta.get(column)
setattr(task, column, value)
@retry
def _get_task_meta_for(self, task_id: str): # noqa: ANN202
"""Get task meta-data for a task by id."""
session = self.result_session()
with session_cleanup(session):
task = list(session.query(self.task_cls).filter(self.task_cls.task_id == task_id))
task = task and task[0]
if not task:
task = self.task_cls(task_id)
task.status = states.PENDING
task.result = None
data = task.to_dict()
if data.get('args', None) is not None:
data['args'] = self.decode(data['args'])
if data.get('kwargs', None) is not None:
data['kwargs'] = self.decode(data['kwargs'])
return self.meta_from_decoded(data)
@retry
def _save_group(self, group_id: str, result: PickleType): # noqa: ANN202
"""Store the result of an executed group."""
session = self.result_session()
with session_cleanup(session):
group = self.taskset_cls(group_id, result)
session.add(group)
session.flush()
session.commit()
return result
@retry
def _restore_group(self, group_id: str) -> dict | None:
"""Get meta-data for group by id."""
session = self.result_session()
with session_cleanup(session):
group = session.query(self.taskset_cls).filter(self.taskset_cls.taskset_id == group_id).first()
if group:
return group.to_dict()
@retry
def _delete_group(self, group_id: str) -> None:
"""Delete meta-data for group by id."""
session = self.result_session()
with session_cleanup(session):
session.query(self.taskset_cls).filter(self.taskset_cls.taskset_id == group_id).delete()
session.flush()
session.commit()
@retry
def _forget(self, task_id: str) -> None:
"""Forget about result."""
session = self.result_session()
with session_cleanup(session):
session.query(self.task_cls).filter(self.task_cls.task_id == task_id).delete()
session.commit()
def cleanup(self) -> None:
"""Delete expired meta-data."""
session = self.result_session()
expires = self.expires
now = self.app.now()
with session_cleanup(session):
session.query(self.task_cls).filter(self.task_cls.date_done < (now - expires)).delete()
session.query(self.taskset_cls).filter(self.taskset_cls.date_done < (now - expires)).delete()
session.commit()
def __reduce__(self, args=(), kwargs=None): # noqa: ANN001, ANN204
kwargs = kwargs or {}
kwargs.update({'dburi': self.url, 'expires': self.expires, 'engine_options': self.engine_options})
return super().__reduce__(args, kwargs)
-2
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from backend.common.enums import IntEnum, StrEnum from backend.common.enums import IntEnum, StrEnum
+2 -3
View File
@@ -1,3 +1,2 @@
#!/usr/bin/env python3 from backend.app.task.model.result import TaskExtended as TaskResult # noqa: F401
# -*- coding: utf-8 -*- from backend.app.task.model.scheduler import TaskScheduler as TaskScheduler
from backend.app.task.model.scheduler import TaskScheduler
+108 -7
View File
@@ -1,9 +1,110 @@
#!/usr/bin/env python3 from datetime import datetime, timezone
# -*- coding: utf-8 -*-
from celery.backends.database.models import TaskExtended as TaskResult
OVERWRITE_CELERY_RESULT_TABLE_NAME = 'task_result' import sqlalchemy as sa
OVERWRITE_CELERY_RESULT_GROUP_TABLE_NAME = 'task_group_result'
# 重写表名配置 from celery import states
TaskResult.configure(name=OVERWRITE_CELERY_RESULT_TABLE_NAME) from sqlalchemy.types import PickleType
from backend.common.model import MappedBase
"""
重写 celery.backends.database.models 内部所有模型适配 fba 创建表和 alembic 迁移
"""
class Task(MappedBase):
"""Task result/status."""
__tablename__ = 'task_result'
__table_args__ = {'comment': '任务结果表'}
id = sa.Column(sa.Integer, sa.Sequence('task_id_sequence'), primary_key=True, autoincrement=True)
task_id = sa.Column(sa.String(155), unique=True)
status = sa.Column(sa.String(50), default=states.PENDING)
result = sa.Column(PickleType, nullable=True)
date_done = sa.Column(
sa.DateTime,
default=datetime.now(timezone.utc),
onupdate=datetime.now(timezone.utc),
nullable=True,
)
traceback = sa.Column(sa.Text, nullable=True)
def __init__(self, task_id: str) -> None:
self.task_id = task_id
def to_dict(self) -> dict:
return {
'task_id': self.task_id,
'status': self.status,
'result': self.result,
'traceback': self.traceback,
'date_done': self.date_done,
}
def __repr__(self) -> str:
return f'<Task {self.task_id} state: {self.status}>'
@classmethod
def configure(cls, schema=None, name=None) -> None: # noqa: ANN001
cls.__table__.schema = schema
cls.id.default.schema = schema
cls.__table__.name = name or cls.__tablename__
class TaskExtended(Task):
"""For the extend result."""
__tablename__ = 'task_result'
__table_args__ = {'extend_existing': True, 'comment': '任务结果表'}
name = sa.Column(sa.String(155), nullable=True)
args = sa.Column(sa.LargeBinary, nullable=True)
kwargs = sa.Column(sa.LargeBinary, nullable=True)
worker = sa.Column(sa.String(155), nullable=True)
retries = sa.Column(sa.Integer, nullable=True)
queue = sa.Column(sa.String(155), nullable=True)
def to_dict(self) -> dict:
task_dict = super().to_dict()
task_dict.update({
'name': self.name,
'args': self.args,
'kwargs': self.kwargs,
'worker': self.worker,
'retries': self.retries,
'queue': self.queue,
})
return task_dict
class TaskSet(MappedBase):
"""TaskSet result."""
__tablename__ = 'task_set_result'
__table_args__ = {'comment': '任务集结果表'}
id = sa.Column(sa.Integer, sa.Sequence('taskset_id_sequence'), autoincrement=True, primary_key=True)
taskset_id = sa.Column(sa.String(155), unique=True)
result = sa.Column(PickleType, nullable=True)
date_done = sa.Column(sa.DateTime, default=datetime.now(timezone.utc), nullable=True)
def __init__(self, taskset_id, result) -> None: # noqa: ANN001
self.taskset_id = taskset_id
self.result = result
def to_dict(self) -> dict:
return {
'taskset_id': self.taskset_id,
'result': self.result,
'date_done': self.date_done,
}
def __repr__(self) -> str:
return f'<TaskSet: {self.taskset_id}>'
@classmethod
def configure(cls, schema=None, name=None) -> None: # noqa: ANN001
cls.__table__.schema = schema
cls.id.default.schema = schema
cls.__table__.name = name or cls.__tablename__
+17 -16
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import asyncio import asyncio
from datetime import datetime from datetime import datetime
@@ -7,7 +5,6 @@ from datetime import datetime
from sqlalchemy import ( from sqlalchemy import (
JSON, JSON,
Boolean, Boolean,
DateTime,
String, String,
event, event,
) )
@@ -16,7 +13,7 @@ from sqlalchemy.dialects.postgresql import INTEGER, TEXT
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from backend.common.exception import errors from backend.common.exception import errors
from backend.common.model import Base, id_key from backend.common.model import Base, TimeZone, id_key
from backend.core.conf import settings from backend.core.conf import settings
from backend.database.redis import redis_client from backend.database.redis import redis_client
from backend.utils.timezone import timezone from backend.utils.timezone import timezone
@@ -35,46 +32,50 @@ class TaskScheduler(Base):
queue: Mapped[str | None] = mapped_column(String(255), comment='CELERY_TASK_QUEUES 中定义的队列') queue: Mapped[str | None] = mapped_column(String(255), comment='CELERY_TASK_QUEUES 中定义的队列')
exchange: Mapped[str | None] = mapped_column(String(255), comment='低级别 AMQP 路由的交换机') exchange: Mapped[str | None] = mapped_column(String(255), comment='低级别 AMQP 路由的交换机')
routing_key: Mapped[str | None] = mapped_column(String(255), comment='低级别 AMQP 路由的路由密钥') routing_key: Mapped[str | None] = mapped_column(String(255), comment='低级别 AMQP 路由的路由密钥')
start_time: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), comment='任务开始触发的时间') start_time: Mapped[datetime | None] = mapped_column(TimeZone, comment='任务开始触发的时间')
expire_time: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), comment='任务不再触发的截止时间') expire_time: Mapped[datetime | None] = mapped_column(TimeZone, comment='任务不再触发的截止时间')
expire_seconds: Mapped[int | None] = mapped_column(comment='任务不再触发的秒数时间差') expire_seconds: Mapped[int | None] = mapped_column(comment='任务不再触发的秒数时间差')
type: Mapped[int] = mapped_column(comment='调度类型(0间隔 1定时)') type: Mapped[int] = mapped_column(comment='调度类型(0间隔 1定时)')
interval_every: Mapped[int | None] = mapped_column(comment='任务再次运行前的间隔周期数') interval_every: Mapped[int | None] = mapped_column(comment='任务再次运行前的间隔周期数')
interval_period: Mapped[str | None] = mapped_column(String(255), comment='任务运行之间的周期类型') interval_period: Mapped[str | None] = mapped_column(String(255), comment='任务运行之间的周期类型')
crontab: Mapped[str | None] = mapped_column(String(50), default='* * * * *', comment='任务运行的 Crontab 计划') crontab: Mapped[str | None] = mapped_column(String(50), default='* * * * *', comment='任务运行的 Crontab 计划')
one_off: Mapped[bool] = mapped_column( one_off: Mapped[bool] = mapped_column(
Boolean().with_variant(INTEGER, 'postgresql'), default=False, comment='是否仅运行一次' Boolean().with_variant(INTEGER, 'postgresql'),
default=False,
comment='是否仅运行一次',
) )
enabled: Mapped[bool] = mapped_column( enabled: Mapped[bool] = mapped_column(
Boolean().with_variant(INTEGER, 'postgresql'), default=True, comment='是否启用任务' Boolean().with_variant(INTEGER, 'postgresql'),
default=True,
comment='是否启用任务',
) )
total_run_count: Mapped[int] = mapped_column(default=0, comment='任务触发的总次数') total_run_count: Mapped[int] = mapped_column(default=0, comment='任务触发的总次数')
last_run_time: Mapped[datetime | None] = mapped_column( last_run_time: Mapped[datetime | None] = mapped_column(TimeZone, default=None, comment='任务最后触发的时间')
DateTime(timezone=True), default=None, comment='任务最后触发的时间'
)
remark: Mapped[str | None] = mapped_column( remark: Mapped[str | None] = mapped_column(
LONGTEXT().with_variant(TEXT, 'postgresql'), default=None, comment='备注' LONGTEXT().with_variant(TEXT, 'postgresql'),
default=None,
comment='备注',
) )
no_changes: bool = False no_changes: bool = False
@staticmethod @staticmethod
def before_insert_or_update(mapper, connection, target): def before_insert_or_update(mapper, connection, target) -> None: # noqa: ANN001
if target.expire_seconds is not None and target.expire_time: if target.expire_seconds is not None and target.expire_time:
raise errors.ConflictError(msg='expires 和 expire_seconds 只能设置一个') raise errors.ConflictError(msg='expires 和 expire_seconds 只能设置一个')
@classmethod @classmethod
def changed(cls, mapper, connection, target): def changed(cls, mapper, connection, target) -> None: # noqa: ANN001
if not target.no_changes: if not target.no_changes:
cls.update_changed(mapper, connection, target) cls.update_changed(mapper, connection, target)
@classmethod @classmethod
async def update_changed_async(cls): async def update_changed_async(cls) -> None:
now = timezone.now() now = timezone.now()
await redis_client.set(f'{settings.CELERY_REDIS_PREFIX}:last_update', timezone.to_str(now)) await redis_client.set(f'{settings.CELERY_REDIS_PREFIX}:last_update', timezone.to_str(now))
@classmethod @classmethod
def update_changed(cls, mapper, connection, target): def update_changed(cls, mapper, connection, target) -> None: # noqa: ANN001
asyncio.create_task(cls.update_changed_async()) asyncio.create_task(cls.update_changed_async())
-2
View File
@@ -1,2 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

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