Compare commits

..

7 Commits

Author SHA1 Message Date
15b2efe52a fix: 修复 update_group_access 中 app 变量未赋值的问题
All checks were successful
Test / test (push) Successful in 2m34s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:31:02 +08:00
6c96c43bea refactor: 统一 sqlmodel_ext 用法至官方推荐模式
Some checks failed
Test / test (push) Failing after 3m47s
- 替换 Field(max_length=X) 为 StrX/TextX 类型别名(21 个 sqlmodels 文件)
- 替换 get + 404 检查为 get_exist_one()(17 个路由文件,约 50 处)
- 替换 save + session.refresh 为 save(load=...)
- 替换 session.add + commit 为 save()(dav/provider.py)
- 更新所有依赖至最新版本

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:13:16 +08:00
9185f26b83 feat: 添加 EPUB 阅读器、3D 模型预览和字体查看器应用,启用 Office 在线预览
All checks were successful
Test / test (push) Successful in 2m31s
2026-02-26 12:50:24 +08:00
f4052d229a fix: clean up empty parent directories after file deletion
All checks were successful
Test / test (push) Successful in 2m32s
Prevent local storage fragmentation by removing empty directories
left behind when files are permanently deleted or moved to trash.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 15:56:44 +08:00
bc2182720d feat: implement avatar upload, Gravatar support, and avatar settings
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 15:56:24 +08:00
eddf38d316 chore: remove applied migration script
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 15:56:07 +08:00
03e768d232 chore: update .gitignore for avatar and dev directories
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 15:56:00 +08:00
62 changed files with 1344 additions and 897 deletions

View File

@@ -5,7 +5,8 @@
"Bash(findstr:*)", "Bash(findstr:*)",
"Bash(find:*)", "Bash(find:*)",
"Bash(yarn tsc:*)", "Bash(yarn tsc:*)",
"Bash(dir:*)" "Bash(dir:*)",
"mcp__server-notify__notify"
] ]
} }
} }

5
.gitignore vendored
View File

@@ -1,8 +1,6 @@
# Python # Python
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
*.pyo
*.pyd
*.so *.so
*.egg *.egg
*.egg-info/ *.egg-info/
@@ -79,3 +77,6 @@ statics/
# 许可证密钥(保密) # 许可证密钥(保密)
license_private.pem license_private.pem
license.key license.key
avatar/
.dev/

2
ee

Submodule ee updated: 52921f9ffe...cc32d8db91

20
main.py
View File

@@ -17,24 +17,26 @@ from utils.http.http_exceptions import raise_internal_error
from utils.lifespan import lifespan from utils.lifespan import lifespan
# 尝试加载企业版功能 # 尝试加载企业版功能
_has_ee: bool = False
try: try:
from ee import init_ee from ee import init_ee
from ee.license import LicenseError from ee.license import LicenseError
from ee.routers import ee_router
async def _init_ee_and_routes() -> None: _has_ee = True
async def _init_ee() -> None:
"""启动时验证许可证,路由由 license_valid_required 依赖保护"""
try: try:
await init_ee() await init_ee()
except LicenseError as exc: except LicenseError as exc:
l.critical(f"许可证验证失败: {exc}") l.critical(f"许可证验证失败: {exc}")
raise SystemExit(1) from exc raise SystemExit(1) from exc
from ee.routers import ee_router lifespan.add_startup(_init_ee)
from routers.api.v1 import router as v1_router except ImportError as exc:
v1_router.include_router(ee_router) ee_router = None
l.info(f"以 Community 版本运行 (原因: {exc})")
lifespan.add_startup(_init_ee_and_routes)
except ImportError:
l.info("以 Community 版本运行")
STATICS_DIR: Path = (Path(__file__).parent / "statics").resolve() STATICS_DIR: Path = (Path(__file__).parent / "statics").resolve()
"""前端静态文件目录(由 Docker 构建时复制)""" """前端静态文件目录(由 Docker 构建时复制)"""
@@ -95,6 +97,8 @@ async def handle_unexpected_exceptions(
# 挂载路由 # 挂载路由
app.include_router(router) app.include_router(router)
if _has_ee:
app.include_router(ee_router, prefix="/api/v1")
# 挂载 WebDAV 协议端点(优先于 SPA catch-all # 挂载 WebDAV 协议端点(优先于 SPA catch-all
app.mount("/dav", dav_app) app.mount("/dav", dav_app)

View File

@@ -1,88 +0,0 @@
-- 迁移:文件元数据系统重构
-- 将固定列 FileMetadata 表替换为灵活的 ObjectMetadata KV 表
-- 日期2026-02-23
BEGIN;
-- ==================== 1. object 表新增 mime_type 列 ====================
ALTER TABLE object ADD COLUMN IF NOT EXISTS mime_type VARCHAR(127);
-- ==================== 2. physicalfile 表新增 checksum_sha256 列 ====================
ALTER TABLE physicalfile ADD COLUMN IF NOT EXISTS checksum_sha256 VARCHAR(64);
-- ==================== 3. 创建 objectmetadata KV 表 ====================
CREATE TABLE IF NOT EXISTS objectmetadata (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
object_id UUID NOT NULL REFERENCES object(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
value TEXT NOT NULL,
is_public BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT uq_object_metadata_object_name UNIQUE (object_id, name)
);
CREATE INDEX IF NOT EXISTS ix_object_metadata_object_id
ON objectmetadata (object_id);
-- ==================== 4. 创建 custompropertydefinition 表 ====================
CREATE TABLE IF NOT EXISTS custompropertydefinition (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
owner_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
type VARCHAR NOT NULL,
icon VARCHAR(100),
options JSON,
default_value VARCHAR(500),
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS ix_custompropertydefinition_owner_id
ON custompropertydefinition (owner_id);
-- ==================== 5. 迁移旧数据(从 filemetadata 到 objectmetadata====================
-- 将 filemetadata 中的数据迁移到 objectmetadata KV 格式
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'filemetadata') THEN
-- 同时将 mime_type 上提到 object 表
UPDATE object o
SET mime_type = fm.mime_type
FROM filemetadata fm
WHERE fm.object_id = o.id AND fm.mime_type IS NOT NULL;
-- 将 checksum_sha256 迁移到 physicalfile 表
UPDATE physicalfile pf
SET checksum_sha256 = fm.checksum_sha256
FROM filemetadata fm
JOIN object o ON fm.object_id = o.id
WHERE o.physical_file_id = pf.id AND fm.checksum_sha256 IS NOT NULL;
-- 迁移 width → exif:width
INSERT INTO objectmetadata (id, object_id, name, value, is_public)
SELECT gen_random_uuid(), object_id, 'exif:width', CAST(width AS TEXT), true
FROM filemetadata WHERE width IS NOT NULL;
-- 迁移 height → exif:height
INSERT INTO objectmetadata (id, object_id, name, value, is_public)
SELECT gen_random_uuid(), object_id, 'exif:height', CAST(height AS TEXT), true
FROM filemetadata WHERE height IS NOT NULL;
-- 迁移 duration → stream:duration
INSERT INTO objectmetadata (id, object_id, name, value, is_public)
SELECT gen_random_uuid(), object_id, 'stream:duration', CAST(duration AS TEXT), true
FROM filemetadata WHERE duration IS NOT NULL;
-- 迁移 bitrate → stream:bitrate
INSERT INTO objectmetadata (id, object_id, name, value, is_public)
SELECT gen_random_uuid(), object_id, 'stream:bitrate', CAST(bitrate AS TEXT), true
FROM filemetadata WHERE bitrate IS NOT NULL;
-- 删除旧表
DROP TABLE filemetadata;
END IF;
END $$;
COMMIT;

View File

@@ -5,6 +5,7 @@ from utils.conf import appmeta
from .admin import admin_router from .admin import admin_router
from .callback import callback_router from .callback import callback_router
from .category import category_router
from .directory import directory_router from .directory import directory_router
from .download import download_router from .download import download_router
from .file import router as file_router from .file import router as file_router
@@ -14,7 +15,6 @@ from .trash import trash_router
from .site import site_router from .site import site_router
from .slave import slave_router from .slave import slave_router
from .user import user_router from .user import user_router
from .vas import vas_router
from .webdav import webdav_router from .webdav import webdav_router
router = APIRouter(prefix="/v1") router = APIRouter(prefix="/v1")
@@ -24,6 +24,7 @@ router = APIRouter(prefix="/v1")
if appmeta.mode == "master": if appmeta.mode == "master":
router.include_router(admin_router) router.include_router(admin_router)
router.include_router(callback_router) router.include_router(callback_router)
router.include_router(category_router)
router.include_router(directory_router) router.include_router(directory_router)
router.include_router(download_router) router.include_router(download_router)
router.include_router(file_router) router.include_router(file_router)
@@ -32,7 +33,6 @@ if appmeta.mode == "master":
router.include_router(site_router) router.include_router(site_router)
router.include_router(trash_router) router.include_router(trash_router)
router.include_router(user_router) router.include_router(user_router)
router.include_router(vas_router)
router.include_router(webdav_router) router.include_router(webdav_router)
elif appmeta.mode == "slave": elif appmeta.mode == "slave":
router.include_router(slave_router) router.include_router(slave_router)

View File

@@ -16,6 +16,12 @@ from sqlmodels.setting import (
from sqlmodels.setting import SettingsType from sqlmodels.setting import SettingsType
from utils import http_exceptions from utils import http_exceptions
from utils.conf import appmeta from utils.conf import appmeta
try:
from ee.service import get_cached_license
except ImportError:
get_cached_license = None
from .file import admin_file_router from .file import admin_file_router
from .file_app import admin_file_app_router from .file_app import admin_file_app_router
from .group import admin_group_router from .group import admin_group_router
@@ -24,7 +30,6 @@ from .share import admin_share_router
from .task import admin_task_router from .task import admin_task_router
from .user import admin_user_router from .user import admin_user_router
from .theme import admin_theme_router from .theme import admin_theme_router
from .vas import admin_vas_router
class Aria2TestRequest(SQLModelBase): class Aria2TestRequest(SQLModelBase):
@@ -50,7 +55,6 @@ admin_router.include_router(admin_policy_router)
admin_router.include_router(admin_share_router) admin_router.include_router(admin_share_router)
admin_router.include_router(admin_task_router) admin_router.include_router(admin_task_router)
admin_router.include_router(admin_theme_router) admin_router.include_router(admin_theme_router)
admin_router.include_router(admin_vas_router)
# 离线下载 /api/admin/aria2 # 离线下载 /api/admin/aria2
admin_aria2_router = APIRouter( admin_aria2_router = APIRouter(
@@ -159,14 +163,24 @@ async def router_admin_get_summary(session: SessionDep) -> AdminSummaryResponse:
if site_url_setting and site_url_setting.value: if site_url_setting and site_url_setting.value:
site_urls.append(site_url_setting.value) site_urls.append(site_url_setting.value)
# 许可证信息(从设置读取或使用默认值 # 许可证信息(Pro 版本从缓存读取CE 版本永不过期
license_info = LicenseInfo( if appmeta.IsPro and get_cached_license:
expired_at=now + timedelta(days=365), payload = get_cached_license()
signed_at=now, license_info = LicenseInfo(
root_domains=[], expired_at=payload.expires_at,
domains=[], signed_at=payload.issued_at,
vol_domains=[], root_domains=[],
) domains=[payload.domain],
vol_domains=[],
)
else:
license_info = LicenseInfo(
expired_at=datetime.max,
signed_at=now,
root_domains=[],
domains=[],
vol_domains=[],
)
# 版本信息 # 版本信息
version_info = VersionInfo( version_info = VersionInfo(
@@ -225,11 +239,11 @@ async def router_admin_update_settings(
if existing: if existing:
existing.value = item.value existing.value = item.value
await existing.save(session) existing = await existing.save(session)
updated_count += 1 updated_count += 1
else: else:
new_setting = Setting(type=item.type, name=item.name, value=item.value) new_setting = Setting(type=item.type, name=item.name, value=item.value)
await new_setting.save(session) new_setting = await new_setting.save(session)
created_count += 1 created_count += 1
l.info(f"管理员更新了 {updated_count} 个设置项,新建了 {created_count} 个设置项") l.info(f"管理员更新了 {updated_count} 个设置项,新建了 {created_count} 个设置项")

View File

@@ -54,7 +54,7 @@ async def _set_ban_recursive(
obj.banned_by = None obj.banned_by = None
obj.ban_reason = None obj.ban_reason = None
await obj.save(session) obj = await obj.save(session)
count += 1 count += 1
return count return count
@@ -131,9 +131,7 @@ async def router_admin_preview_file(
:param file_id: 文件UUID :param file_id: 文件UUID
:return: 文件内容 :return: 文件内容
""" """
file_obj = await Object.get(session, Object.id == file_id) file_obj = await Object.get_exist_one(session, file_id)
if not file_obj:
raise HTTPException(status_code=404, detail="文件不存在")
if not file_obj.is_file: if not file_obj.is_file:
raise HTTPException(status_code=400, detail="对象不是文件") raise HTTPException(status_code=400, detail="对象不是文件")
@@ -182,9 +180,7 @@ async def router_admin_ban_file(
:param claims: 当前管理员 JWT claims :param claims: 当前管理员 JWT claims
:return: 封禁结果 :return: 封禁结果
""" """
file_obj = await Object.get(session, Object.id == file_id) file_obj = await Object.get_exist_one(session, file_id)
if not file_obj:
raise HTTPException(status_code=404, detail="文件不存在")
count = await _set_ban_recursive(session, file_obj, request.ban, claims.sub, request.reason) count = await _set_ban_recursive(session, file_obj, request.ban, claims.sub, request.reason)
@@ -212,9 +208,7 @@ async def router_admin_delete_file(
:param delete_physical: 是否同时删除物理文件 :param delete_physical: 是否同时删除物理文件
:return: 删除结果 :return: 删除结果
""" """
file_obj = await Object.get(session, Object.id == file_id) file_obj = await Object.get_exist_one(session, file_id)
if not file_obj:
raise HTTPException(status_code=404, detail="文件不存在")
if not file_obj.is_file: if not file_obj.is_file:
raise HTTPException(status_code=400, detail="对象不是文件") raise HTTPException(status_code=400, detail="对象不是文件")

View File

@@ -151,9 +151,7 @@ async def get_file_app(
错误处理: 错误处理:
- 404: 应用不存在 - 404: 应用不存在
""" """
app: FileApp | None = await FileApp.get(session, FileApp.id == app_id) app = await FileApp.get_exist_one(session, app_id)
if not app:
http_exceptions.raise_not_found("应用不存在")
extensions = await FileAppExtension.get( extensions = await FileAppExtension.get(
session, session,
@@ -186,9 +184,7 @@ async def update_file_app(
- 404: 应用不存在 - 404: 应用不存在
- 409: 新 app_key 已被其他应用使用 - 409: 新 app_key 已被其他应用使用
""" """
app: FileApp | None = await FileApp.get(session, FileApp.id == app_id) app = await FileApp.get_exist_one(session, app_id)
if not app:
http_exceptions.raise_not_found("应用不存在")
# 检查 app_key 唯一性 # 检查 app_key 唯一性
if request.app_key is not None and request.app_key != app.app_key: if request.app_key is not None and request.app_key != app.app_key:
@@ -235,9 +231,7 @@ async def delete_file_app(
错误处理: 错误处理:
- 404: 应用不存在 - 404: 应用不存在
""" """
app: FileApp | None = await FileApp.get(session, FileApp.id == app_id) app = await FileApp.get_exist_one(session, app_id)
if not app:
http_exceptions.raise_not_found("应用不存在")
app_name = app.app_key app_name = app.app_key
await FileApp.delete(session, app) await FileApp.delete(session, app)
@@ -263,9 +257,7 @@ async def update_extensions(
错误处理: 错误处理:
- 404: 应用不存在 - 404: 应用不存在
""" """
app: FileApp | None = await FileApp.get(session, FileApp.id == app_id) app = await FileApp.get_exist_one(session, app_id)
if not app:
http_exceptions.raise_not_found("应用不存在")
# 保留旧扩展名的 wopi_action_urlDiscovery 填充的值) # 保留旧扩展名的 wopi_action_urlDiscovery 填充的值)
old_extensions: list[FileAppExtension] = await FileAppExtension.get( old_extensions: list[FileAppExtension] = await FileAppExtension.get(
@@ -330,9 +322,7 @@ async def update_group_access(
错误处理: 错误处理:
- 404: 应用不存在 - 404: 应用不存在
""" """
app: FileApp | None = await FileApp.get(session, FileApp.id == app_id) app = await FileApp.get_exist_one(session, app_id)
if not app:
http_exceptions.raise_not_found("应用不存在")
# 删除旧的用户组关联 # 删除旧的用户组关联
old_links_result = await session.exec( old_links_result = await session.exec(
@@ -387,9 +377,7 @@ async def discover_wopi(
- 400: 非 WOPI 类型 / discovery URL 未配置 / XML 解析失败 - 400: 非 WOPI 类型 / discovery URL 未配置 / XML 解析失败
- 502: WOPI 服务端不可达或返回无效响应 - 502: WOPI 服务端不可达或返回无效响应
""" """
app: FileApp | None = await FileApp.get(session, FileApp.id == app_id) app = await FileApp.get_exist_one(session, app_id)
if not app:
http_exceptions.raise_not_found("应用不存在")
if app.type != FileAppType.WOPI: if app.type != FileAppType.WOPI:
http_exceptions.raise_bad_request("仅 WOPI 类型应用支持自动发现") http_exceptions.raise_bad_request("仅 WOPI 类型应用支持自动发现")

View File

@@ -63,10 +63,7 @@ async def router_admin_get_group(
:param group_id: 用户组UUID :param group_id: 用户组UUID
:return: 用户组详情 :return: 用户组详情
""" """
group = await Group.get(session, Group.id == group_id, load=[Group.options, Group.policies]) group = await Group.get_exist_one(session, group_id, load=[Group.options, Group.policies])
if not group:
raise HTTPException(status_code=404, detail="用户组不存在")
# 直接访问已加载的关系,无需额外查询 # 直接访问已加载的关系,无需额外查询
policies = group.policies policies = group.policies
@@ -94,9 +91,7 @@ async def router_admin_get_group_members(
:return: 分页成员列表 :return: 分页成员列表
""" """
# 验证组存在 # 验证组存在
group = await Group.get(session, Group.id == group_id) await Group.get_exist_one(session, group_id)
if not group:
raise HTTPException(status_code=404, detail="用户组不存在")
result = await User.get_with_count(session, User.group_id == group_id, table_view=table_view) result = await User.get_with_count(session, User.group_id == group_id, table_view=table_view)
@@ -138,10 +133,11 @@ async def router_admin_create_group(
speed_limit=request.speed_limit, speed_limit=request.speed_limit,
) )
group = await group.save(session) group = await group.save(session)
group_id_val: UUID = group.id
# 创建选项 # 创建选项
options = GroupOptions( options = GroupOptions(
group_id=group.id, group_id=group_id_val,
share_download=request.share_download, share_download=request.share_download,
share_free=request.share_free, share_free=request.share_free,
relocate=request.relocate, relocate=request.relocate,
@@ -154,11 +150,11 @@ async def router_admin_create_group(
aria2=request.aria2, aria2=request.aria2,
redirected_source=request.redirected_source, redirected_source=request.redirected_source,
) )
await options.save(session) options = await options.save(session)
# 关联存储策略 # 关联存储策略
for policy_id in request.policy_ids: for policy_id in request.policy_ids:
link = GroupPolicyLink(group_id=group.id, policy_id=policy_id) link = GroupPolicyLink(group_id=group_id_val, policy_id=policy_id)
session.add(link) session.add(link)
await session.commit() await session.commit()
@@ -185,9 +181,7 @@ async def router_admin_update_group(
:param request: 更新请求 :param request: 更新请求
:return: 更新结果 :return: 更新结果
""" """
group = await Group.get(session, Group.id == group_id, load=Group.options) group = await Group.get_exist_one(session, group_id, load=Group.options)
if not group:
raise HTTPException(status_code=404, detail="用户组不存在")
# 检查名称唯一性(如果要更新名称) # 检查名称唯一性(如果要更新名称)
if request.name and request.name != group.name: if request.name and request.name != group.name:
@@ -217,7 +211,7 @@ async def router_admin_update_group(
if options_data: if options_data:
for key, value in options_data.items(): for key, value in options_data.items():
setattr(group.options, key, value) setattr(group.options, key, value)
await group.options.save(session) group.options = await group.options.save(session)
# 更新策略关联 # 更新策略关联
if request.policy_ids is not None: if request.policy_ids is not None:
@@ -255,9 +249,7 @@ async def router_admin_delete_group(
:param group_id: 用户组UUID :param group_id: 用户组UUID
:return: 删除结果 :return: 删除结果
""" """
group = await Group.get(session, Group.id == group_id) group = await Group.get_exist_one(session, group_id)
if not group:
raise HTTPException(status_code=404, detail="用户组不存在")
# 检查是否有用户属于该组 # 检查是否有用户属于该组
user_count = await User.count(session, User.group_id == group_id) user_count = await User.count(session, User.group_id == group_id)

View File

@@ -332,7 +332,7 @@ async def router_policy_add_policy(
s3_path_style=request.s3_path_style, s3_path_style=request.s3_path_style,
s3_region=request.s3_region, s3_region=request.s3_region,
) )
await options.save(session) options = await options.save(session)
@admin_policy_router.post( @admin_policy_router.post(
path='/cors', path='/cors',
@@ -383,9 +383,7 @@ async def router_policy_onddrive_oauth(
:param policy_id: 存储策略UUID :param policy_id: 存储策略UUID
:return: OAuth URL :return: OAuth URL
""" """
policy = await Policy.get(session, Policy.id == policy_id) policy = await Policy.get_exist_one(session, policy_id)
if not policy:
raise HTTPException(status_code=404, detail="存储策略不存在")
# TODO: 实现OneDrive OAuth # TODO: 实现OneDrive OAuth
raise HTTPException(status_code=501, detail="OneDrive OAuth暂未实现") raise HTTPException(status_code=501, detail="OneDrive OAuth暂未实现")
@@ -408,9 +406,7 @@ async def router_policy_get_policy(
:param policy_id: 存储策略UUID :param policy_id: 存储策略UUID
:return: 策略详情 :return: 策略详情
""" """
policy = await Policy.get(session, Policy.id == policy_id, load=Policy.options) policy = await Policy.get_exist_one(session, policy_id, load=Policy.options)
if not policy:
raise HTTPException(status_code=404, detail="存储策略不存在")
# 获取使用此策略的用户组 # 获取使用此策略的用户组
groups = await policy.awaitable_attrs.groups groups = await policy.awaitable_attrs.groups
@@ -459,9 +455,7 @@ async def router_policy_delete_policy(
:param policy_id: 存储策略UUID :param policy_id: 存储策略UUID
:return: 删除结果 :return: 删除结果
""" """
policy = await Policy.get(session, Policy.id == policy_id) policy = await Policy.get_exist_one(session, policy_id)
if not policy:
raise HTTPException(status_code=404, detail="存储策略不存在")
# 检查是否有文件使用此策略 # 检查是否有文件使用此策略
file_count = await Object.count(session, Object.policy_id == policy_id) file_count = await Object.count(session, Object.policy_id == policy_id)
@@ -503,9 +497,7 @@ async def router_policy_update_policy(
:param policy_id: 存储策略UUID :param policy_id: 存储策略UUID
:param request: 更新请求 :param request: 更新请求
""" """
policy = await Policy.get(session, Policy.id == policy_id, load=Policy.options) policy = await Policy.get_exist_one(session, policy_id, load=Policy.options)
if not policy:
raise HTTPException(status_code=404, detail="存储策略不存在")
# 检查名称唯一性(如果要更新名称) # 检查名称唯一性(如果要更新名称)
if request.name and request.name != policy.name: if request.name and request.name != policy.name:
@@ -529,10 +521,10 @@ async def router_policy_update_policy(
if policy.options: if policy.options:
for key, value in options_data.items(): for key, value in options_data.items():
setattr(policy.options, key, value) setattr(policy.options, key, value)
await policy.options.save(session) policy.options = await policy.options.save(session)
else: else:
options = PolicyOptions(policy_id=policy.id, **options_data) options = PolicyOptions(policy_id=policy.id, **options_data)
await options.save(session) options = await options.save(session)
l.info(f"管理员更新了存储策略: {policy_id}") l.info(f"管理员更新了存储策略: {policy_id}")

View File

@@ -155,9 +155,7 @@ async def router_admin_delete_share(
:param share_id: 分享ID :param share_id: 分享ID
:return: 删除结果 :return: 删除结果
""" """
share = await Share.get(session, Share.id == share_id) share = await Share.get_exist_one(session, share_id)
if not share:
raise HTTPException(status_code=404, detail="分享不存在")
await Share.delete(session, share) await Share.delete(session, share)

View File

@@ -150,9 +150,7 @@ async def router_admin_delete_task(
:param task_id: 任务ID :param task_id: 任务ID
:return: 删除结果 :return: 删除结果
""" """
task = await Task.get(session, Task.id == task_id) task = await Task.get_exist_one(session, task_id)
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
await Task.delete(session, task) await Task.delete(session, task)

View File

@@ -71,7 +71,7 @@ async def router_admin_theme_create(
name=request.name, name=request.name,
**request.colors.model_dump(), **request.colors.model_dump(),
) )
await preset.save(session) preset = await preset.save(session)
l.info(f"管理员创建了主题预设: {request.name}") l.info(f"管理员创建了主题预设: {request.name}")
@@ -101,11 +101,7 @@ async def router_admin_theme_update(
- 404: 预设不存在 - 404: 预设不存在
- 409: 名称已被其他预设使用 - 409: 名称已被其他预设使用
""" """
preset: ThemePreset | None = await ThemePreset.get( preset = await ThemePreset.get_exist_one(session, preset_id)
session, ThemePreset.id == preset_id
)
if not preset:
http_exceptions.raise_not_found("主题预设不存在")
# 检查名称唯一性(排除自身) # 检查名称唯一性(排除自身)
if request.name is not None and request.name != preset.name: if request.name is not None and request.name != preset.name:
@@ -120,7 +116,7 @@ async def router_admin_theme_update(
for key, value in color_data.items(): for key, value in color_data.items():
setattr(preset, key, value) setattr(preset, key, value)
await preset.save(session) preset = await preset.save(session)
l.info(f"管理员更新了主题预设: {preset.name}") l.info(f"管理员更新了主题预设: {preset.name}")
@@ -147,11 +143,7 @@ async def router_admin_theme_delete(
副作用: 副作用:
- 关联用户的 theme_preset_id 会被数据库 SET NULL - 关联用户的 theme_preset_id 会被数据库 SET NULL
""" """
preset: ThemePreset | None = await ThemePreset.get( preset = await ThemePreset.get_exist_one(session, preset_id)
session, ThemePreset.id == preset_id
)
if not preset:
http_exceptions.raise_not_found("主题预设不存在")
await preset.delete(session) await preset.delete(session)
l.info(f"管理员删除了主题预设: {preset.name}") l.info(f"管理员删除了主题预设: {preset.name}")
@@ -180,11 +172,7 @@ async def router_admin_theme_set_default(
逻辑: 逻辑:
- 事务中先清除所有旧默认,再设新默认 - 事务中先清除所有旧默认,再设新默认
""" """
preset: ThemePreset | None = await ThemePreset.get( preset = await ThemePreset.get_exist_one(session, preset_id)
session, ThemePreset.id == preset_id
)
if not preset:
http_exceptions.raise_not_found("主题预设不存在")
# 清除所有旧默认 # 清除所有旧默认
await session.execute( await session.execute(
@@ -195,5 +183,5 @@ async def router_admin_theme_set_default(
# 设新默认 # 设新默认
preset.is_default = True preset.is_default = True
await preset.save(session) preset = await preset.save(session)
l.info(f"管理员将主题预设 '{preset.name}' 设为默认") l.info(f"管理员将主题预设 '{preset.name}' 设为默认")

View File

@@ -128,8 +128,9 @@ async def router_admin_create_user(
is_verified=True, is_verified=True,
user_id=user.id, user_id=user.id,
) )
await identity.save(session) identity = await identity.save(session)
user = await User.get(session, User.id == user.id, load=User.group)
return user.to_public() return user.to_public()
@@ -153,9 +154,7 @@ async def router_admin_update_user(
:param request: 更新请求 :param request: 更新请求
:return: 更新结果 :return: 更新结果
""" """
user = await User.get(session, User.id == user_id) user = await User.get_exist_one(session, user_id)
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
# 默认管理员不允许更改用户组(通过 Setting 中的 default_admin_id 识别) # 默认管理员不允许更改用户组(通过 Setting 中的 default_admin_id 识别)
default_admin_setting = await Setting.get( default_admin_setting = await Setting.get(
@@ -252,9 +251,7 @@ async def router_admin_calibrate_storage(
:param user_id: 用户UUID :param user_id: 用户UUID
:return: 校准结果 :return: 校准结果
""" """
user = await User.get(session, User.id == user_id) user = await User.get_exist_one(session, user_id)
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
previous_storage = user.storage previous_storage = user.storage

View File

@@ -1,81 +0,0 @@
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from middleware.auth import admin_required
from middleware.dependencies import SessionDep
from sqlmodels import (
ResponseBase,
)
admin_vas_router = APIRouter(
prefix='/vas',
tags=['admin', 'admin_vas']
)
@admin_vas_router.get(
path='/list',
summary='获取增值服务列表',
description='Get VAS list (orders and storage packs)',
dependencies=[Depends(admin_required)]
)
async def router_admin_get_vas_list(
session: SessionDep,
user_id: UUID | None = None,
page: int = 1,
page_size: int = 20,
) -> ResponseBase:
"""
获取增值服务列表(订单和存储包)。
:param session: 数据库会话
:param user_id: 按用户筛选
:param page: 页码
:param page_size: 每页数量
:return: 增值服务列表
"""
# TODO: 实现增值服务列表
# 需要查询 Order 和 StoragePack 模型
raise HTTPException(status_code=501, detail="增值服务管理暂未实现")
@admin_vas_router.get(
path='/{vas_id}',
summary='获取增值服务详情',
description='Get VAS detail by ID',
dependencies=[Depends(admin_required)]
)
async def router_admin_get_vas(
session: SessionDep,
vas_id: UUID,
) -> ResponseBase:
"""
获取增值服务详情。
:param session: 数据库会话
:param vas_id: 增值服务UUID
:return: 增值服务详情
"""
# TODO: 实现增值服务详情
raise HTTPException(status_code=501, detail="增值服务管理暂未实现")
@admin_vas_router.delete(
path='/{vas_id}',
summary='删除增值服务',
description='Delete VAS by ID',
dependencies=[Depends(admin_required)]
)
async def router_admin_delete_vas(
session: SessionDep,
vas_id: UUID,
) -> ResponseBase:
"""
删除增值服务。
:param session: 数据库会话
:param vas_id: 增值服务UUID
:return: 删除结果
"""
# TODO: 实现增值服务删除
raise HTTPException(status_code=501, detail="增值服务管理暂未实现")

View File

@@ -16,18 +16,12 @@ oauth_router = APIRouter(
tags=["callback", "oauth"], tags=["callback", "oauth"],
) )
pay_router = APIRouter(
prefix='/callback/pay',
tags=["callback", "pay"],
)
upload_router = APIRouter( upload_router = APIRouter(
prefix='/callback/upload', prefix='/callback/upload',
tags=["callback", "upload"], tags=["callback", "upload"],
) )
callback_router.include_router(oauth_router) callback_router.include_router(oauth_router)
callback_router.include_router(pay_router)
callback_router.include_router(upload_router) callback_router.include_router(upload_router)
@oauth_router.post( @oauth_router.post(
@@ -38,7 +32,7 @@ callback_router.include_router(upload_router)
def router_callback_qq() -> ResponseBase: def router_callback_qq() -> ResponseBase:
""" """
Handle QQ OAuth callback and return user information. Handle QQ OAuth callback and return user information.
Returns: Returns:
ResponseBase: A model containing the response data for the QQ OAuth callback. ResponseBase: A model containing the response data for the QQ OAuth callback.
""" """
@@ -55,11 +49,11 @@ async def router_callback_github(
GitHub OAuth 回调处理 GitHub OAuth 回调处理
- 错误响应示例: - 错误响应示例:
- { - {
'error': 'bad_verification_code', 'error': 'bad_verification_code',
'error_description': 'The code passed is incorrect or expired.', 'error_description': 'The code passed is incorrect or expired.',
'error_uri': 'https://docs.github.com/apps/managing-oauth-apps/troubleshooting-oauth-app-access-token-request-errors/#bad-verification-code' 'error_uri': 'https://docs.github.com/apps/managing-oauth-apps/troubleshooting-oauth-app-access-token-request-errors/#bad-verification-code'
} }
Returns: Returns:
PlainTextResponse: A response containing the user information from GitHub. PlainTextResponse: A response containing the user information from GitHub.
""" """
@@ -77,81 +71,6 @@ async def router_callback_github(
l.error(f"GitHub OAuth 回调异常: {e}") l.error(f"GitHub OAuth 回调异常: {e}")
return PlainTextResponse("认证过程中发生错误,请重试", status_code=500) return PlainTextResponse("认证过程中发生错误,请重试", status_code=500)
@pay_router.post(
path='/alipay',
summary='支付宝支付回调',
description='Handle Alipay payment callback and return payment status.',
)
def router_callback_alipay() -> ResponseBase:
"""
Handle Alipay payment callback and return payment status.
Returns:
ResponseBase: A model containing the response data for the Alipay payment callback.
"""
http_exceptions.raise_not_implemented()
@pay_router.post(
path='/wechat',
summary='微信支付回调',
description='Handle WeChat Pay payment callback and return payment status.',
)
def router_callback_wechat() -> ResponseBase:
"""
Handle WeChat Pay payment callback and return payment status.
Returns:
ResponseBase: A model containing the response data for the WeChat Pay payment callback.
"""
http_exceptions.raise_not_implemented()
@pay_router.post(
path='/stripe',
summary='Stripe支付回调',
description='Handle Stripe payment callback and return payment status.',
)
def router_callback_stripe() -> ResponseBase:
"""
Handle Stripe payment callback and return payment status.
Returns:
ResponseBase: A model containing the response data for the Stripe payment callback.
"""
http_exceptions.raise_not_implemented()
@pay_router.get(
path='/easypay',
summary='易支付回调',
description='Handle EasyPay payment callback and return payment status.',
)
def router_callback_easypay() -> PlainTextResponse:
"""
Handle EasyPay payment callback and return payment status.
Returns:
PlainTextResponse: A response containing the payment status for the EasyPay payment callback.
"""
http_exceptions.raise_not_implemented()
# return PlainTextResponse("success", status_code=200)
@pay_router.get(
path='/custom/{order_no}/{id}',
summary='自定义支付回调',
description='Handle custom payment callback and return payment status.',
)
def router_callback_custom(order_no: str, id: str) -> ResponseBase:
"""
Handle custom payment callback and return payment status.
Args:
order_no (str): The order number for the payment.
id (str): The ID associated with the payment.
Returns:
ResponseBase: A model containing the response data for the custom payment callback.
"""
http_exceptions.raise_not_implemented()
@upload_router.post( @upload_router.post(
path='/remote/{session_id}/{key}', path='/remote/{session_id}/{key}',
summary='远程上传回调', summary='远程上传回调',
@@ -160,11 +79,11 @@ def router_callback_custom(order_no: str, id: str) -> ResponseBase:
def router_callback_remote(session_id: str, key: str) -> ResponseBase: def router_callback_remote(session_id: str, key: str) -> ResponseBase:
""" """
Handle remote upload callback and return upload status. Handle remote upload callback and return upload status.
Args: Args:
session_id (str): The session ID for the upload. session_id (str): The session ID for the upload.
key (str): The key for the uploaded file. key (str): The key for the uploaded file.
Returns: Returns:
ResponseBase: A model containing the response data for the remote upload callback. ResponseBase: A model containing the response data for the remote upload callback.
""" """
@@ -178,15 +97,15 @@ def router_callback_remote(session_id: str, key: str) -> ResponseBase:
def router_callback_qiniu(session_id: str) -> ResponseBase: def router_callback_qiniu(session_id: str) -> ResponseBase:
""" """
Handle Qiniu Cloud upload callback and return upload status. Handle Qiniu Cloud upload callback and return upload status.
Args: Args:
session_id (str): The session ID for the upload. session_id (str): The session ID for the upload.
Returns: Returns:
ResponseBase: A model containing the response data for the Qiniu Cloud upload callback. ResponseBase: A model containing the response data for the Qiniu Cloud upload callback.
""" """
http_exceptions.raise_not_implemented() http_exceptions.raise_not_implemented()
@upload_router.post( @upload_router.post(
path='/tencent/{session_id}', path='/tencent/{session_id}',
summary='腾讯云上传回调', summary='腾讯云上传回调',
@@ -195,16 +114,16 @@ def router_callback_qiniu(session_id: str) -> ResponseBase:
def router_callback_tencent(session_id: str) -> ResponseBase: def router_callback_tencent(session_id: str) -> ResponseBase:
""" """
Handle Tencent Cloud upload callback and return upload status. Handle Tencent Cloud upload callback and return upload status.
Args: Args:
session_id (str): The session ID for the upload. session_id (str): The session ID for the upload.
Returns: Returns:
ResponseBase: A model containing the response data for the Tencent Cloud upload callback. ResponseBase: A model containing the response data for the Tencent Cloud upload callback.
""" """
http_exceptions.raise_not_implemented() http_exceptions.raise_not_implemented()
@upload_router.post( @upload_router.post(
path='/aliyun/{session_id}', path='/aliyun/{session_id}',
summary='阿里云上传回调', summary='阿里云上传回调',
description='Handle Aliyun upload callback and return upload status.', description='Handle Aliyun upload callback and return upload status.',
@@ -212,16 +131,16 @@ def router_callback_tencent(session_id: str) -> ResponseBase:
def router_callback_aliyun(session_id: str) -> ResponseBase: def router_callback_aliyun(session_id: str) -> ResponseBase:
""" """
Handle Aliyun upload callback and return upload status. Handle Aliyun upload callback and return upload status.
Args: Args:
session_id (str): The session ID for the upload. session_id (str): The session ID for the upload.
Returns: Returns:
ResponseBase: A model containing the response data for the Aliyun upload callback. ResponseBase: A model containing the response data for the Aliyun upload callback.
""" """
http_exceptions.raise_not_implemented() http_exceptions.raise_not_implemented()
@upload_router.post( @upload_router.post(
path='/upyun/{session_id}', path='/upyun/{session_id}',
summary='又拍云上传回调', summary='又拍云上传回调',
description='Handle Upyun upload callback and return upload status.', description='Handle Upyun upload callback and return upload status.',
@@ -229,10 +148,10 @@ def router_callback_aliyun(session_id: str) -> ResponseBase:
def router_callback_upyun(session_id: str) -> ResponseBase: def router_callback_upyun(session_id: str) -> ResponseBase:
""" """
Handle Upyun upload callback and return upload status. Handle Upyun upload callback and return upload status.
Args: Args:
session_id (str): The session ID for the upload. session_id (str): The session ID for the upload.
Returns: Returns:
ResponseBase: A model containing the response data for the Upyun upload callback. ResponseBase: A model containing the response data for the Upyun upload callback.
""" """
@@ -246,10 +165,10 @@ def router_callback_upyun(session_id: str) -> ResponseBase:
def router_callback_aws(session_id: str) -> ResponseBase: def router_callback_aws(session_id: str) -> ResponseBase:
""" """
Handle AWS S3 upload callback and return upload status. Handle AWS S3 upload callback and return upload status.
Args: Args:
session_id (str): The session ID for the upload. session_id (str): The session ID for the upload.
Returns: Returns:
ResponseBase: A model containing the response data for the AWS S3 upload callback. ResponseBase: A model containing the response data for the AWS S3 upload callback.
""" """
@@ -263,10 +182,10 @@ def router_callback_aws(session_id: str) -> ResponseBase:
def router_callback_onedrive_finish(session_id: str) -> ResponseBase: def router_callback_onedrive_finish(session_id: str) -> ResponseBase:
""" """
Handle OneDrive upload completion callback and return upload status. Handle OneDrive upload completion callback and return upload status.
Args: Args:
session_id (str): The session ID for the upload. session_id (str): The session ID for the upload.
Returns: Returns:
ResponseBase: A model containing the response data for the OneDrive upload completion callback. ResponseBase: A model containing the response data for the OneDrive upload completion callback.
""" """
@@ -280,7 +199,7 @@ def router_callback_onedrive_finish(session_id: str) -> ResponseBase:
def router_callback_onedrive_auth() -> ResponseBase: def router_callback_onedrive_auth() -> ResponseBase:
""" """
Handle OneDrive authorization callback and return authorization status. Handle OneDrive authorization callback and return authorization status.
Returns: Returns:
ResponseBase: A model containing the response data for the OneDrive authorization callback. ResponseBase: A model containing the response data for the OneDrive authorization callback.
""" """
@@ -294,8 +213,8 @@ def router_callback_onedrive_auth() -> ResponseBase:
def router_callback_google_auth() -> ResponseBase: def router_callback_google_auth() -> ResponseBase:
""" """
Handle Google OAuth completion callback and return authorization status. Handle Google OAuth completion callback and return authorization status.
Returns: Returns:
ResponseBase: A model containing the response data for the Google OAuth completion callback. ResponseBase: A model containing the response data for the Google OAuth completion callback.
""" """
http_exceptions.raise_not_implemented() http_exceptions.raise_not_implemented()

View File

@@ -0,0 +1,100 @@
"""
文件分类筛选端点
按文件类型分类(图片/视频/音频/文档)查询用户的所有文件,
跨目录搜索,支持分页。扩展名映射从数据库 Setting 表读取。
"""
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException
from loguru import logger as l
from middleware.auth import auth_required
from middleware.dependencies import SessionDep, TableViewRequestDep
from sqlmodels import (
FileCategory,
ListResponse,
Object,
ObjectResponse,
ObjectType,
Setting,
SettingsType,
User,
)
category_router = APIRouter(
prefix="/category",
tags=["category"],
)
@category_router.get(
path="/{category}",
summary="按分类获取文件列表",
)
async def router_category_list(
session: SessionDep,
user: Annotated[User, Depends(auth_required)],
category: FileCategory,
table_view: TableViewRequestDep,
) -> ListResponse[ObjectResponse]:
"""
按文件类型分类查询用户的所有文件
跨所有目录搜索,返回分页结果。
扩展名配置从数据库 Setting 表读取type=file_category
认证:
- JWT token in Authorization header
路径参数:
- category: 文件分类image / video / audio / document
查询参数:
- offset: 分页偏移量默认0
- limit: 每页数量默认20最大100
- desc: 是否降序默认true
- order: 排序字段created_at / updated_at
响应:
- ListResponse[ObjectResponse]: 分页文件列表
错误处理:
- HTTPException 422: category 参数无效
- HTTPException 404: 该分类未配置扩展名
"""
# 从数据库读取该分类的扩展名配置
setting = await Setting.get(
session,
(Setting.type == SettingsType.FILE_CATEGORY) & (Setting.name == category.value),
)
if not setting or not setting.value:
raise HTTPException(status_code=404, detail=f"分类 {category.value} 未配置扩展名")
extensions = [ext.strip() for ext in setting.value.split(",") if ext.strip()]
if not extensions:
raise HTTPException(status_code=404, detail=f"分类 {category.value} 扩展名列表为空")
result = await Object.get_by_category(
session,
user.id,
extensions,
table_view=table_view,
)
items = [
ObjectResponse(
id=obj.id,
name=obj.name,
type=ObjectType.FILE,
size=obj.size,
mime_type=obj.mime_type,
thumb=False,
created_at=obj.created_at,
updated_at=obj.updated_at,
source_enabled=False,
)
for obj in result.items
]
return ListResponse(count=result.count, items=items)

View File

@@ -206,4 +206,4 @@ async def router_directory_create(
parent_id=parent_id, parent_id=parent_id,
policy_id=policy_id, policy_id=policy_id,
) )
await new_folder.save(session) new_folder = await new_folder.save(session)

View File

@@ -184,9 +184,7 @@ async def create_upload_session(
# 确定存储策略 # 确定存储策略
policy_id = request.policy_id or parent.policy_id policy_id = request.policy_id or parent.policy_id
policy = await Policy.get(session, Policy.id == policy_id) policy = await Policy.get_exist_one(session, policy_id)
if not policy:
raise HTTPException(status_code=404, detail="存储策略不存在")
# 校验用户组是否有权使用该策略(仅当用户显式指定 policy_id 时) # 校验用户组是否有权使用该策略(仅当用户显式指定 policy_id 时)
if request.policy_id: if request.policy_id:
@@ -711,9 +709,7 @@ async def create_empty_file(
# 确定存储策略 # 确定存储策略
policy_id = request.policy_id or parent.policy_id policy_id = request.policy_id or parent.policy_id
policy = await Policy.get(session, Policy.id == policy_id) policy = await Policy.get_exist_one(session, policy_id)
if not policy:
raise HTTPException(status_code=404, detail="存储策略不存在")
# 生成存储路径并创建空文件 # 生成存储路径并创建空文件
storage_path: str | None = None storage_path: str | None = None
@@ -942,7 +938,7 @@ async def file_get(
# 递增下载次数 # 递增下载次数
link.downloads += 1 link.downloads += 1
await link.save(session) link = await link.save(session)
if policy.type == PolicyType.LOCAL: if policy.type == PolicyType.LOCAL:
storage_service = LocalStorageService(policy) storage_service = LocalStorageService(policy)
@@ -996,7 +992,7 @@ async def file_source_redirect(
# 递增下载次数 # 递增下载次数
link.downloads += 1 link.downloads += 1
await link.save(session) link = await link.save(session)
if policy.type == PolicyType.LOCAL: if policy.type == PolicyType.LOCAL:
storage_service = LocalStorageService(policy) storage_service = LocalStorageService(policy)

View File

@@ -112,9 +112,7 @@ async def router_object_create(
# 确定存储策略 # 确定存储策略
policy_id = request.policy_id or parent.policy_id policy_id = request.policy_id or parent.policy_id
policy = await Policy.get(session, Policy.id == policy_id) policy = await Policy.get_exist_one(session, policy_id)
if not policy:
raise HTTPException(status_code=404, detail="存储策略不存在")
parent_id = parent.id parent_id = parent.id
@@ -149,7 +147,7 @@ async def router_object_create(
owner_id=user_id, owner_id=user_id,
policy_id=policy_id, policy_id=policy_id,
) )
await file_object.save(session) file_object = await file_object.save(session)
l.info(f"创建空白文件: {request.name}") l.info(f"创建空白文件: {request.name}")
@@ -474,7 +472,7 @@ async def router_object_rename(
# 更新名称 # 更新名称
obj.name = new_name obj.name = new_name
await obj.save(session) obj = await obj.save(session)
l.info(f"用户 {user_id} 将对象 {obj.id} 重命名为 {new_name}") l.info(f"用户 {user_id} 将对象 {obj.id} 重命名为 {new_name}")
@@ -682,7 +680,7 @@ async def router_object_switch_policy(
dest_policy_id=dest_policy_id, dest_policy_id=dest_policy_id,
object_id=obj_id, object_id=obj_id,
) )
await task_props.save(session) task_props = await task_props.save(session)
if obj_is_file: if obj_is_file:
# 文件:后台迁移 # 文件:后台迁移
@@ -698,7 +696,7 @@ async def router_object_switch_policy(
# 目录:先更新目录自身的 policy_id # 目录:先更新目录自身的 policy_id
obj = await Object.get(session, Object.id == obj_id) obj = await Object.get(session, Object.id == obj_id)
obj.policy_id = dest_policy_id obj.policy_id = dest_policy_id
await obj.save(session) obj = await obj.save(session)
if request.is_migrate_existing: if request.is_migrate_existing:
# 后台迁移所有已有文件 # 后台迁移所有已有文件
@@ -715,7 +713,7 @@ async def router_object_switch_policy(
task = await Task.get(session, Task.id == task_id) task = await Task.get(session, Task.id == task_id)
task.status = TaskStatus.COMPLETED task.status = TaskStatus.COMPLETED
task.progress = 100 task.progress = 100
await task.save(session) task = await task.save(session)
# 重新获取 task 以读取最新状态 # 重新获取 task 以读取最新状态
task = await Task.get(session, Task.id == task_id) task = await Task.get(session, Task.id == task_id)
@@ -850,7 +848,7 @@ async def router_patch_object_metadata(
) )
if existing: if existing:
existing.value = patch.value existing.value = patch.value
await existing.save(session) existing = await existing.save(session)
else: else:
entry = ObjectMetadata( entry = ObjectMetadata(
object_id=object_id, object_id=object_id,
@@ -858,6 +856,6 @@ async def router_patch_object_metadata(
value=patch.value, value=patch.value,
is_public=True, is_public=True,
) )
await entry.save(session) entry = await entry.save(session)
l.info(f"用户 {user.id} 更新了对象 {object_id}{len(request.patches)} 条元数据") l.info(f"用户 {user.id} 更新了对象 {object_id}{len(request.patches)} 条元数据")

View File

@@ -102,7 +102,7 @@ async def router_create_custom_property(
options=request.options, options=request.options,
default_value=request.default_value, default_value=request.default_value,
) )
await definition.save(session) definition = await definition.save(session)
l.info(f"用户 {user.id} 创建了自定义属性: {request.name}") l.info(f"用户 {user.id} 创建了自定义属性: {request.name}")
@@ -128,12 +128,7 @@ async def router_update_custom_property(
- 404: 属性定义不存在 - 404: 属性定义不存在
- 403: 无权操作此属性 - 403: 无权操作此属性
""" """
definition = await CustomPropertyDefinition.get( definition = await CustomPropertyDefinition.get_exist_one(session, id)
session,
CustomPropertyDefinition.id == id,
)
if not definition:
raise HTTPException(status_code=404, detail="自定义属性不存在")
if definition.owner_id != user.id: if definition.owner_id != user.id:
raise HTTPException(status_code=403, detail="无权操作此属性") raise HTTPException(status_code=403, detail="无权操作此属性")
@@ -163,12 +158,7 @@ async def router_delete_custom_property(
- 404: 属性定义不存在 - 404: 属性定义不存在
- 403: 无权操作此属性 - 403: 无权操作此属性
""" """
definition = await CustomPropertyDefinition.get( definition = await CustomPropertyDefinition.get_exist_one(session, id)
session,
CustomPropertyDefinition.id == id,
)
if not definition:
raise HTTPException(status_code=404, detail="自定义属性不存在")
if definition.owner_id != user.id: if definition.owner_id != user.id:
raise HTTPException(status_code=403, detail="无权操作此属性") raise HTTPException(status_code=403, detail="无权操作此属性")

View File

@@ -45,12 +45,7 @@ async def router_share_get(
4. 返回分享详情(含文件树和分享者信息) 4. 返回分享详情(含文件树和分享者信息)
""" """
# 1. 查询分享(预加载 user 和 object # 1. 查询分享(预加载 user 和 object
share = await Share.get( share = await Share.get_exist_one(session, id, load=[Share.user, Share.object])
session, Share.id == id,
load=[Share.user, Share.object],
)
if not share:
http_exceptions.raise_not_found(detail="分享不存在或已被取消")
# 2. 检查过期 # 2. 检查过期
now = datetime.now() now = datetime.now()
@@ -474,16 +469,29 @@ def router_share_update(id: str) -> ResponseBase:
path='/{id}', path='/{id}',
summary='删除分享', summary='删除分享',
description='Delete a share by ID.', description='Delete a share by ID.',
dependencies=[Depends(auth_required)] status_code=204,
) )
def router_share_delete(id: str) -> ResponseBase: async def router_share_delete(
session: SessionDep,
user: Annotated[User, Depends(auth_required)],
id: UUID,
) -> None:
""" """
Delete a share by ID. 删除分享
Args: 认证:需要 JWT token
id (str): The ID of the share to be deleted.
流程:
Returns: 1. 通过分享ID查找分享
ResponseBase: A model containing the response data for the deleted share. 2. 验证分享属于当前用户
3. 删除分享记录
""" """
http_exceptions.raise_not_implemented() share = await Share.get_exist_one(session, id)
if share.user_id != user.id:
http_exceptions.raise_forbidden(detail="无权删除此分享")
user_id = user.id
share_code = share.code
await Share.delete(session, share)
l.info(f"用户 {user_id} 删除了分享: {share_code}")

View File

@@ -82,7 +82,8 @@ async def router_site_config(session: SessionDep) -> SiteConfigResponse:
(Setting.type == SettingsType.REGISTER) | (Setting.type == SettingsType.REGISTER) |
(Setting.type == SettingsType.CAPTCHA) | (Setting.type == SettingsType.CAPTCHA) |
(Setting.type == SettingsType.AUTH) | (Setting.type == SettingsType.AUTH) |
(Setting.type == SettingsType.OAUTH), (Setting.type == SettingsType.OAUTH) |
(Setting.type == SettingsType.AVATAR),
fetch_mode="all", fetch_mode="all",
) )
@@ -122,6 +123,7 @@ async def router_site_config(session: SessionDep) -> SiteConfigResponse:
password_required=s.get("auth_password_required") == "1", password_required=s.get("auth_password_required") == "1",
phone_binding_required=s.get("auth_phone_binding_required") == "1", phone_binding_required=s.get("auth_phone_binding_required") == "1",
email_binding_required=s.get("auth_email_binding_required") == "1", email_binding_required=s.get("auth_email_binding_required") == "1",
avatar_max_size=int(s["avatar_size"]),
footer_code=s.get("footer_code"), footer_code=s.get("footer_code"),
tos_url=s.get("tos_url"), tos_url=s.get("tos_url"),
privacy_url=s.get("privacy_url"), privacy_url=s.get("privacy_url"),

View File

@@ -5,6 +5,7 @@ import json
import jwt import jwt
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse, RedirectResponse
from itsdangerous import URLSafeTimedSerializer from itsdangerous import URLSafeTimedSerializer
from loguru import logger from loguru import logger
from webauthn import ( from webauthn import (
@@ -233,7 +234,7 @@ async def router_user_register(
group_id=default_group.id, group_id=default_group.id,
) )
new_user_id = new_user.id new_user_id = new_user.id
await new_user.save(session) new_user = await new_user.save(session)
# 7. 创建 AuthIdentity # 7. 创建 AuthIdentity
hashed_password = Password.hash(request.credential) if request.credential else None hashed_password = Password.hash(request.credential) if request.credential else None
@@ -245,7 +246,7 @@ async def router_user_register(
is_verified=False, is_verified=False,
user_id=new_user_id, user_id=new_user_id,
) )
await identity.save(session) identity = await identity.save(session)
# 8. 创建用户根目录(使用用户组关联的第一个存储策略) # 8. 创建用户根目录(使用用户组关联的第一个存储策略)
await session.refresh(default_group, ['policies']) await session.refresh(default_group, ['policies'])
@@ -358,20 +359,78 @@ def router_user_profile(id: str) -> sqlmodels.ResponseBase:
@user_router.get( @user_router.get(
path='/avatar/{id}/{size}', path='/avatar/{id}/{size}',
summary='获取用户头像', summary='获取用户头像',
description='Get user avatar by ID and size.', response_model=None,
) )
def router_user_avatar(id: str, size: int = 128) -> sqlmodels.ResponseBase: async def router_user_avatar(
session: SessionDep,
id: UUID,
size: int = 128,
) -> FileResponse | RedirectResponse:
""" """
Get user avatar by ID and size. 获取指定用户指定尺寸的头像(公开端点,无需认证)
Args: 路径参数:
id (str): The user ID. - id: 用户 UUID
size (int): The size of the avatar image. - size: 请求的头像尺寸px默认 128
Returns: 行为:
str: A Base64 encoded string of the user avatar image. - default: 302 重定向到 Gravatar identicon
- gravatar: 302 重定向到 Gravatar使用用户邮箱 MD5
- file: 返回本地 WebP 文件
响应:
- 200: image/webpfile 模式)
- 302: 重定向到外部 URLdefault/gravatar 模式)
- 404: 用户不存在
缓存Cache-Control: public, max-age=3600
""" """
http_exceptions.raise_not_implemented() import aiofiles.os
from service.avatar import (
get_avatar_file_path,
get_avatar_settings,
gravatar_url,
resolve_avatar_size,
)
user = await sqlmodels.User.get(session, sqlmodels.User.id == id)
if not user:
http_exceptions.raise_not_found("用户不存在")
avatar_path, _, size_l, size_m, size_s = await get_avatar_settings(session)
if user.avatar == "file":
size_label = resolve_avatar_size(size, size_l, size_m, size_s)
file_path = get_avatar_file_path(avatar_path, user.id, size_label)
if not await aiofiles.os.path.exists(file_path):
# 文件丢失,降级为 identicon
fallback_url = gravatar_url(str(user.id), size, "https://www.gravatar.com/")
return RedirectResponse(url=fallback_url, status_code=302)
return FileResponse(
path=file_path,
media_type="image/webp",
headers={"Cache-Control": "public, max-age=3600"},
)
elif user.avatar == "gravatar":
gravatar_setting = await sqlmodels.Setting.get(
session,
(sqlmodels.Setting.type == sqlmodels.SettingsType.AVATAR)
& (sqlmodels.Setting.name == "gravatar_server"),
)
server = gravatar_setting.value if gravatar_setting else "https://www.gravatar.com/"
email = user.email or str(user.id)
url = gravatar_url(email, size, server)
return RedirectResponse(url=url, status_code=302)
else:
# default: identicon
email_or_id = user.email or str(user.id)
url = gravatar_url(email_or_id, size, "https://www.gravatar.com/")
return RedirectResponse(url=url, status_code=302)
##################### #####################
# 需要登录的接口 # 需要登录的接口
@@ -435,9 +494,24 @@ async def router_user_storage(
if not group: if not group:
raise HTTPException(status_code=404, detail="用户组不存在") raise HTTPException(status_code=404, detail="用户组不存在")
# [TODO] 总空间加上用户购买的额外空间 # 查询用户所有未过期容量包的 size 总和
from datetime import datetime
from sqlalchemy import func, select, and_, or_
total: int = group.max_storage now = datetime.now()
stmt = select(func.coalesce(func.sum(sqlmodels.StoragePack.size), 0)).where(
and_(
sqlmodels.StoragePack.user_id == user.id,
or_(
sqlmodels.StoragePack.expired_time.is_(None),
sqlmodels.StoragePack.expired_time > now,
),
)
)
result = await session.exec(stmt)
active_packs_total: int = result.scalar_one()
total: int = group.max_storage + active_packs_total
used: int = user.storage used: int = user.storage
free: int = max(0, total - used) free: int = max(0, total - used)
@@ -579,7 +653,7 @@ async def router_user_authn_finish(
is_verified=True, is_verified=True,
user_id=user.id, user_id=user.id,
) )
await identity.save(session) identity = await identity.save(session)
return authn.to_detail_response() return authn.to_detail_response()

View File

@@ -1,7 +1,7 @@
from typing import Annotated from typing import Annotated
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
import sqlmodels import sqlmodels
@@ -165,34 +165,121 @@ async def router_user_settings(
@user_settings_router.post( @user_settings_router.post(
path='/avatar', path='/avatar',
summary='从文件上传头像', summary='从文件上传头像',
description='Upload user avatar from file.', status_code=204,
dependencies=[Depends(auth_required)],
) )
def router_user_settings_avatar() -> sqlmodels.ResponseBase: async def router_user_settings_avatar(
session: SessionDep,
user: Annotated[sqlmodels.user.User, Depends(auth_required)],
file: UploadFile = File(...),
) -> None:
""" """
Upload user avatar from file. 上传头像文件
Returns: 认证JWT token
dict: A dictionary containing the result of the avatar upload. 请求体multipart/form-datafile 字段
流程:
1. 验证文件 MIME 类型JPEG/PNG/GIF/WebP
2. 验证文件大小 <= avatar_size 设置(默认 2MB
3. 调用 Pillow 验证图片有效性并处理(居中裁剪、缩放 L/M/S
4. 保存三种尺寸的 WebP 文件
5. 更新 User.avatar = "file"
错误处理:
- 400: 文件类型不支持 / 图片无法解析
- 413: 文件过大
""" """
http_exceptions.raise_not_implemented() from service.avatar import (
ALLOWED_CONTENT_TYPES,
get_avatar_settings,
process_and_save_avatar,
)
# 验证 MIME 类型
if file.content_type not in ALLOWED_CONTENT_TYPES:
http_exceptions.raise_bad_request(
f"不支持的图片格式,允许: {', '.join(ALLOWED_CONTENT_TYPES)}"
)
# 读取并验证大小
_, max_upload_size, _, _, _ = await get_avatar_settings(session)
raw_bytes = await file.read()
if len(raw_bytes) > max_upload_size:
raise HTTPException(
status_code=413,
detail=f"文件过大,最大允许 {max_upload_size} 字节",
)
# 处理并保存(内部会验证图片有效性,无效抛出 ValueError
try:
await process_and_save_avatar(session, user.id, raw_bytes)
except ValueError as e:
http_exceptions.raise_bad_request(str(e))
# 更新用户头像字段
user.avatar = "file"
user = await user.save(session)
@user_settings_router.put( @user_settings_router.put(
path='/avatar', path='/avatar',
summary='设定为Gravatar头像', summary='设定为 Gravatar 头像',
description='Set user avatar to Gravatar.',
dependencies=[Depends(auth_required)],
status_code=204, status_code=204,
) )
def router_user_settings_avatar_gravatar() -> None: async def router_user_settings_avatar_gravatar(
session: SessionDep,
user: Annotated[sqlmodels.user.User, Depends(auth_required)],
) -> None:
""" """
Set user avatar to Gravatar. 将头像切换为 Gravatar
Returns: 认证JWT token
dict: A dictionary containing the result of setting the Gravatar avatar.
流程:
1. 验证用户有邮箱Gravatar 基于邮箱 MD5
2. 如果当前是 FILE 头像,删除本地文件
3. 更新 User.avatar = "gravatar"
错误处理:
- 400: 用户没有邮箱
""" """
http_exceptions.raise_not_implemented() from service.avatar import delete_avatar_files
if not user.email:
http_exceptions.raise_bad_request("Gravatar 需要邮箱,请先绑定邮箱")
if user.avatar == "file":
await delete_avatar_files(session, user.id)
user.avatar = "gravatar"
user = await user.save(session)
@user_settings_router.delete(
path='/avatar',
summary='重置头像为默认',
status_code=204,
)
async def router_user_settings_avatar_delete(
session: SessionDep,
user: Annotated[sqlmodels.user.User, Depends(auth_required)],
) -> None:
"""
重置头像为默认
认证JWT token
流程:
1. 如果当前是 FILE 头像,删除本地文件
2. 更新 User.avatar = "default"
"""
from service.avatar import delete_avatar_files
if user.avatar == "file":
await delete_avatar_files(session, user.id)
user.avatar = "default"
user = await user.save(session)
@user_settings_router.patch( @user_settings_router.patch(
@@ -234,7 +321,7 @@ async def router_user_settings_theme(
user.color_error = request.theme_colors.error user.color_error = request.theme_colors.error
user.color_neutral = request.theme_colors.neutral user.color_neutral = request.theme_colors.neutral
await user.save(session) user = await user.save(session)
@user_settings_router.patch( @user_settings_router.patch(
@@ -271,7 +358,7 @@ async def router_user_settings_change_password(
http_exceptions.raise_forbidden("当前密码错误") http_exceptions.raise_forbidden("当前密码错误")
email_identity.credential = Password.hash(request.new_password) email_identity.credential = Password.hash(request.new_password)
await email_identity.save(session) email_identity = await email_identity.save(session)
@user_settings_router.patch( @user_settings_router.patch(
@@ -305,7 +392,7 @@ async def router_user_settings_patch(
http_exceptions.raise_bad_request(f"设置项 {option.value} 不允许为空") http_exceptions.raise_bad_request(f"设置项 {option.value} 不允许为空")
setattr(user, option.value, value) setattr(user, option.value, value)
await user.save(session) user = await user.save(session)
@user_settings_router.get( @user_settings_router.get(
@@ -367,7 +454,7 @@ async def router_user_settings_2fa_enable(
extra: dict = orjson.loads(email_identity.extra_data) if email_identity.extra_data else {} extra: dict = orjson.loads(email_identity.extra_data) if email_identity.extra_data else {}
extra["two_factor"] = secret extra["two_factor"] = secret
email_identity.extra_data = orjson.dumps(extra).decode('utf-8') email_identity.extra_data = orjson.dumps(extra).decode('utf-8')
await email_identity.save(session) email_identity = await email_identity.save(session)
# ==================== 认证身份管理 ==================== # ==================== 认证身份管理 ====================

View File

@@ -79,9 +79,7 @@ async def set_default_viewer(
if existing: if existing:
existing.app_id = request.app_id existing.app_id = request.app_id
existing = await existing.save(session) existing = await existing.save(session, load=UserFileAppDefault.app)
# 重新加载 app 关系
await session.refresh(existing, attribute_names=["app"])
return existing.to_response() return existing.to_response()
else: else:
new_default = UserFileAppDefault( new_default = UserFileAppDefault(
@@ -89,9 +87,7 @@ async def set_default_viewer(
extension=normalized_ext, extension=normalized_ext,
app_id=request.app_id, app_id=request.app_id,
) )
new_default = await new_default.save(session) new_default = await new_default.save(session, load=UserFileAppDefault.app)
# 重新加载 app 关系
await session.refresh(new_default, attribute_names=["app"])
return new_default.to_response() return new_default.to_response()

View File

@@ -1,106 +0,0 @@
from fastapi import APIRouter, Depends
from middleware.auth import auth_required
from sqlmodels import ResponseBase
from utils import http_exceptions
vas_router = APIRouter(
prefix="/vas",
tags=["vas"]
)
@vas_router.get(
path='/pack',
summary='获取容量包及配额信息',
description='Get information about storage packs and quotas.',
dependencies=[Depends(auth_required)]
)
def router_vas_pack() -> ResponseBase:
"""
Get information about storage packs and quotas.
Returns:
ResponseBase: A model containing the response data for storage packs and quotas.
"""
http_exceptions.raise_not_implemented()
@vas_router.get(
path='/product',
summary='获取商品信息,同时返回支付信息',
description='Get product information along with payment details.',
dependencies=[Depends(auth_required)]
)
def router_vas_product() -> ResponseBase:
"""
Get product information along with payment details.
Returns:
ResponseBase: A model containing the response data for products and payment information.
"""
http_exceptions.raise_not_implemented()
@vas_router.post(
path='/order',
summary='新建支付订单',
description='Create an order for a product.',
dependencies=[Depends(auth_required)]
)
def router_vas_order() -> ResponseBase:
"""
Create an order for a product.
Returns:
ResponseBase: A model containing the response data for the created order.
"""
http_exceptions.raise_not_implemented()
@vas_router.get(
path='/order/{id}',
summary='查询订单状态',
description='Get information about a specific payment order by ID.',
dependencies=[Depends(auth_required)]
)
def router_vas_order_get(id: str) -> ResponseBase:
"""
Get information about a specific payment order by ID.
Args:
id (str): The ID of the order to retrieve information for.
Returns:
ResponseBase: A model containing the response data for the specified order.
"""
http_exceptions.raise_not_implemented()
@vas_router.get(
path='/redeem',
summary='获取兑换码信息',
description='Get information about a specific redemption code.',
dependencies=[Depends(auth_required)]
)
def router_vas_redeem(code: str) -> ResponseBase:
"""
Get information about a specific redemption code.
Args:
code (str): The redemption code to retrieve information for.
Returns:
ResponseBase: A model containing the response data for the specified redemption code.
"""
http_exceptions.raise_not_implemented()
@vas_router.post(
path='/redeem',
summary='执行兑换',
description='Redeem a redemption code for a product or service.',
dependencies=[Depends(auth_required)]
)
def router_vas_redeem_post() -> ResponseBase:
"""
Redeem a redemption code for a product or service.
Returns:
ResponseBase: A model containing the response data for the redeemed code.
"""
http_exceptions.raise_not_implemented()

View File

@@ -175,8 +175,7 @@ async def _finalize_upload(
obj = await Object.get(session, Object.id == object_id) obj = await Object.get(session, Object.id == object_id)
if obj: if obj:
obj.sqlmodel_update({'size': size, 'physical_file_id': pf.id}) obj.sqlmodel_update({'size': size, 'physical_file_id': pf.id})
session.add(obj) obj = await obj.save(session)
await session.commit()
# 更新用户存储用量 # 更新用户存储用量
if size > 0: if size > 0:
@@ -193,8 +192,7 @@ async def _move_object(
obj = await Object.get(session, Object.id == object_id) obj = await Object.get(session, Object.id == object_id)
if obj: if obj:
obj.sqlmodel_update({'parent_id': new_parent_id, 'name': new_name}) obj.sqlmodel_update({'parent_id': new_parent_id, 'name': new_name})
session.add(obj) obj = await obj.save(session)
await session.commit()
async def _copy_object_recursive( async def _copy_object_recursive(

View File

@@ -263,15 +263,49 @@ class LocalStorageService:
""" """
删除文件(物理删除) 删除文件(物理删除)
删除文件后会尝试清理因此变空的父目录。
:param path: 完整文件路径 :param path: 完整文件路径
""" """
if await self.file_exists(path): if await self.file_exists(path):
try: try:
await aiofiles.os.remove(path) await aiofiles.os.remove(path)
l.debug(f"已删除文件: {path}") l.debug(f"已删除文件: {path}")
await self._cleanup_empty_parents(path)
except OSError as e: except OSError as e:
l.warning(f"删除文件失败 {path}: {e}") l.warning(f"删除文件失败 {path}: {e}")
async def _cleanup_empty_parents(self, file_path: str) -> None:
"""
从被删文件的父目录开始,向上逐级删除空目录
在以下情况停止:
- 到达存储根目录_base_path
- 遇到非空目录
- 遇到 .trash 目录
- 删除失败(权限、并发等)
:param file_path: 被删文件的完整路径
"""
current = Path(file_path).parent
while current != self._base_path and str(current).startswith(str(self._base_path)):
if current.name == '.trash':
break
try:
entries = await aiofiles.os.listdir(str(current))
if entries:
break
await aiofiles.os.rmdir(str(current))
l.debug(f"已清理空目录: {current}")
current = current.parent
except OSError as e:
l.debug(f"清理空目录失败(忽略): {current}: {e}")
break
async def move_to_trash( async def move_to_trash(
self, self,
source_path: str, source_path: str,
@@ -304,6 +338,7 @@ class LocalStorageService:
try: try:
await aiofiles.os.rename(source_path, str(trash_path)) await aiofiles.os.rename(source_path, str(trash_path))
l.info(f"文件已移动到回收站: {source_path} -> {trash_path}") l.info(f"文件已移动到回收站: {source_path} -> {trash_path}")
await self._cleanup_empty_parents(source_path)
return str(trash_path) return str(trash_path)
except OSError as e: except OSError as e:
raise StorageException(f"移动文件到回收站失败: {e}") raise StorageException(f"移动文件到回收站失败: {e}")

View File

@@ -147,7 +147,7 @@ async def migrate_single_file(
# 4. 更新 Object # 4. 更新 Object
obj.policy_id = dest_policy.id obj.policy_id = dest_policy.id
obj.physical_file_id = new_physical.id obj.physical_file_id = new_physical.id
await obj.save(session) obj = await obj.save(session)
# 5. 旧 PhysicalFile 引用计数 -1 # 5. 旧 PhysicalFile 引用计数 -1
old_physical.decrement_reference() old_physical.decrement_reference()
@@ -159,7 +159,7 @@ async def migrate_single_file(
l.warning(f"删除源文件失败(不影响迁移结果): {old_physical.storage_path}: {e}") l.warning(f"删除源文件失败(不影响迁移结果): {old_physical.storage_path}: {e}")
await PhysicalFile.delete(session, old_physical) await PhysicalFile.delete(session, old_physical)
else: else:
await old_physical.save(session) old_physical = await old_physical.save(session)
l.info(f"文件迁移完成: {obj.name} ({obj.id}), {src_policy.name}{dest_policy.name}") l.info(f"文件迁移完成: {obj.name} ({obj.id}), {src_policy.name}{dest_policy.name}")
@@ -187,12 +187,12 @@ async def migrate_file_with_task(
task.status = TaskStatus.COMPLETED task.status = TaskStatus.COMPLETED
task.progress = 100 task.progress = 100
await task.save(session) task = await task.save(session)
except Exception as e: except Exception as e:
l.error(f"文件迁移任务失败: {obj.id}: {e}") l.error(f"文件迁移任务失败: {obj.id}: {e}")
task.status = TaskStatus.ERROR task.status = TaskStatus.ERROR
task.error = str(e)[:500] task.error = str(e)[:500]
await task.save(session) task = await task.save(session)
async def migrate_directory_files( async def migrate_directory_files(
@@ -244,7 +244,7 @@ async def migrate_directory_files(
# 更新所有子目录的 policy_id # 更新所有子目录的 policy_id
for sub_folder in folders_to_update: for sub_folder in folders_to_update:
sub_folder.policy_id = dest_policy.id sub_folder.policy_id = dest_policy.id
await sub_folder.save(session) sub_folder = await sub_folder.save(session)
# 完成任务 # 完成任务
if errors: if errors:
@@ -254,7 +254,7 @@ async def migrate_directory_files(
task.status = TaskStatus.COMPLETED task.status = TaskStatus.COMPLETED
task.progress = 100 task.progress = 100
await task.save(session) task = await task.save(session)
l.info( l.info(
f"目录迁移完成: {folder.name} ({folder.id}), " f"目录迁移完成: {folder.name} ({folder.id}), "
@@ -264,7 +264,7 @@ async def migrate_directory_files(
l.error(f"目录迁移任务失败: {folder.id}: {e}") l.error(f"目录迁移任务失败: {folder.id}: {e}")
task.status = TaskStatus.ERROR task.status = TaskStatus.ERROR
task.error = str(e)[:500] task.error = str(e)[:500]
await task.save(session) task = await task.save(session)
async def _collect_objects_recursive( async def _collect_objects_recursive(

View File

@@ -287,7 +287,7 @@ async def permanently_delete_objects(
await PhysicalFile.delete(session, physical_file, commit=False) await PhysicalFile.delete(session, physical_file, commit=False)
l.debug(f"物理文件记录已删除: {physical_file.storage_path}") l.debug(f"物理文件记录已删除: {physical_file.storage_path}")
else: else:
await physical_file.save(session, commit=False) physical_file = await physical_file.save(session, commit=False)
l.debug(f"物理文件仍有 {physical_file.reference_count} 个引用: {physical_file.storage_path}") l.debug(f"物理文件仍有 {physical_file.reference_count} 个引用: {physical_file.storage_path}")
# 更新用户存储配额 # 更新用户存储配额
@@ -399,7 +399,7 @@ async def delete_object_recursive(
await PhysicalFile.delete(session, physical_file, commit=False) await PhysicalFile.delete(session, physical_file, commit=False)
l.debug(f"物理文件记录已删除: {physical_file.storage_path}") l.debug(f"物理文件记录已删除: {physical_file.storage_path}")
else: else:
await physical_file.save(session, commit=False) physical_file = await physical_file.save(session, commit=False)
l.debug(f"物理文件仍有 {physical_file.reference_count} 个引用: {physical_file.storage_path}") l.debug(f"物理文件仍有 {physical_file.reference_count} 个引用: {physical_file.storage_path}")
# 阶段三:更新用户存储配额(与删除在同一事务中) # 阶段三:更新用户存储配额(与删除在同一事务中)
@@ -458,7 +458,7 @@ async def _copy_object_recursive(
physical_file = await PhysicalFile.get(session, PhysicalFile.id == src_physical_file_id) physical_file = await PhysicalFile.get(session, PhysicalFile.id == src_physical_file_id)
if physical_file: if physical_file:
physical_file.increment_reference() physical_file.increment_reference()
await physical_file.save(session) physical_file = await physical_file.save(session)
total_copied_size += src_size total_copied_size += src_size
new_obj = await new_obj.save(session) new_obj = await new_obj.save(session)

View File

@@ -192,7 +192,7 @@ async def _login_oauth(
# 已绑定 → 更新 OAuth 信息并返回关联用户 # 已绑定 → 更新 OAuth 信息并返回关联用户
identity.display_name = nickname identity.display_name = nickname
identity.avatar_url = avatar_url identity.avatar_url = avatar_url
await identity.save(session) identity = await identity.save(session)
user: User = await User.get(session, User.id == identity.user_id, load=User.group) user: User = await User.get(session, User.id == identity.user_id, load=User.group)
if not user: if not user:
@@ -254,7 +254,7 @@ async def _auto_register_oauth_user(
is_verified=True, is_verified=True,
user_id=new_user_id, user_id=new_user_id,
) )
await identity.save(session) identity = await identity.save(session)
# 创建用户根目录 # 创建用户根目录
default_policy = await Policy.get(session, Policy.name == "本地存储") default_policy = await Policy.get(session, Policy.name == "本地存储")
@@ -335,7 +335,7 @@ async def _login_passkey(
# 更新签名计数 # 更新签名计数
authn.sign_count = verification.new_sign_count authn.sign_count = verification.new_sign_count
await authn.save(session) authn = await authn.save(session)
# 加载用户 # 加载用户
user: User = await User.get(session, User.id == authn.user_id, load=User.group) user: User = await User.get(session, User.id == authn.user_id, load=User.group)
@@ -392,7 +392,7 @@ async def _login_magic_link(
# 标记邮箱已验证 # 标记邮箱已验证
if not identity.is_verified: if not identity.is_verified:
identity.is_verified = True identity.is_verified = True
await identity.save(session) identity = await identity.save(session)
return user return user

View File

@@ -84,6 +84,7 @@ if __name__ == "__main__":
setup( setup(
name="disknext-ee", name="disknext-ee",
packages=[],
ext_modules=cythonize( ext_modules=cythonize(
extensions, extensions,
compiler_directives={'language_level': "3"}, compiler_directives={'language_level': "3"},

View File

@@ -82,6 +82,7 @@ from .object import (
ObjectResponse, ObjectResponse,
ObjectSwitchPolicyRequest, ObjectSwitchPolicyRequest,
ObjectType, ObjectType,
FileCategory,
PolicyResponse, PolicyResponse,
UploadChunkResponse, UploadChunkResponse,
UploadSession, UploadSession,
@@ -116,12 +117,22 @@ from .custom_property import (
) )
from .physical_file import PhysicalFile, PhysicalFileBase from .physical_file import PhysicalFile, PhysicalFileBase
from .uri import DiskNextURI, FileSystemNamespace from .uri import DiskNextURI, FileSystemNamespace
from .order import Order, OrderStatus, OrderType from .order import (
Order, OrderStatus, OrderType,
CreateOrderRequest, OrderResponse,
)
from .policy import ( from .policy import (
Policy, PolicyBase, PolicyCreateRequest, PolicyOptions, PolicyOptionsBase, Policy, PolicyBase, PolicyCreateRequest, PolicyOptions, PolicyOptionsBase,
PolicyType, PolicySummary, PolicyUpdateRequest, PolicyType, PolicySummary, PolicyUpdateRequest,
) )
from .redeem import Redeem, RedeemType from .product import (
Product, ProductBase, ProductType, PaymentMethod,
ProductCreateRequest, ProductUpdateRequest, ProductResponse,
)
from .redeem import (
Redeem, RedeemType,
RedeemCreateRequest, RedeemUseRequest, RedeemInfoResponse, RedeemAdminResponse,
)
from .report import Report, ReportReason from .report import Report, ReportReason
from .setting import ( from .setting import (
Setting, SettingsType, SiteConfigResponse, AuthMethodConfig, Setting, SettingsType, SiteConfigResponse, AuthMethodConfig,
@@ -134,7 +145,7 @@ from .share import (
AdminShareListItem, AdminShareListItem,
) )
from .source_link import SourceLink from .source_link import SourceLink
from .storage_pack import StoragePack from .storage_pack import StoragePack, StoragePackResponse
from .tag import Tag, TagType from .tag import Tag, TagType
from .task import Task, TaskProps, TaskPropsBase, TaskStatus, TaskType, TaskSummary, TaskSummaryBase from .task import Task, TaskProps, TaskPropsBase, TaskStatus, TaskType, TaskSummary, TaskSummaryBase
from .webdav import ( from .webdav import (

View File

@@ -10,7 +10,7 @@ from uuid import UUID
from sqlmodel import Field, Relationship, UniqueConstraint from sqlmodel import Field, Relationship, UniqueConstraint
from sqlmodel_ext import SQLModelBase, UUIDTableBaseMixin from sqlmodel_ext import SQLModelBase, UUIDTableBaseMixin, Str100, Str128, Str255, Text1024
if TYPE_CHECKING: if TYPE_CHECKING:
from .user import User from .user import User
@@ -87,7 +87,7 @@ class ChangePasswordRequest(SQLModelBase):
old_password: str = Field(min_length=1) old_password: str = Field(min_length=1)
"""当前密码""" """当前密码"""
new_password: str = Field(min_length=8, max_length=128) new_password: Str128 = Field(min_length=8)
"""新密码(至少 8 位)""" """新密码(至少 8 位)"""
@@ -103,13 +103,13 @@ class AuthIdentity(SQLModelBase, UUIDTableBaseMixin):
provider: AuthProviderType = Field(index=True) provider: AuthProviderType = Field(index=True)
"""提供者类型""" """提供者类型"""
identifier: str = Field(max_length=255, index=True) identifier: Str255 = Field(index=True)
"""标识符(邮箱/手机号/OAuth openid""" """标识符(邮箱/手机号/OAuth openid"""
credential: str | None = Field(default=None, max_length=1024) credential: Text1024 | None = None
"""凭证Argon2 哈希密码 / null""" """凭证Argon2 哈希密码 / null"""
display_name: str | None = Field(default=None, max_length=100) display_name: Str100 | None = None
"""OAuth 昵称""" """OAuth 昵称"""
avatar_url: str | None = Field(default=None, max_length=512) avatar_url: str | None = Field(default=None, max_length=512)

View File

@@ -13,7 +13,7 @@ from uuid import UUID
from sqlalchemy import JSON from sqlalchemy import JSON
from sqlmodel import Field, Relationship from sqlmodel import Field, Relationship
from sqlmodel_ext import SQLModelBase, UUIDTableBaseMixin from sqlmodel_ext import SQLModelBase, UUIDTableBaseMixin, Str100
if TYPE_CHECKING: if TYPE_CHECKING:
from .user import User from .user import User
@@ -44,13 +44,13 @@ class CustomPropertyType(StrEnum):
class CustomPropertyDefinitionBase(SQLModelBase): class CustomPropertyDefinitionBase(SQLModelBase):
"""自定义属性定义基础模型""" """自定义属性定义基础模型"""
name: str = Field(max_length=100) name: Str100
"""属性显示名称""" """属性显示名称"""
type: CustomPropertyType type: CustomPropertyType
"""属性值类型""" """属性值类型"""
icon: str | None = Field(default=None, max_length=100) icon: Str100 | None = None
"""图标标识iconify 名称)""" """图标标识iconify 名称)"""
options: list[str] | None = Field(default=None, sa_type=JSON) options: list[str] | None = Field(default=None, sa_type=JSON)
@@ -90,7 +90,7 @@ class CustomPropertyDefinition(CustomPropertyDefinitionBase, UUIDTableBaseMixin)
class CustomPropertyCreateRequest(SQLModelBase): class CustomPropertyCreateRequest(SQLModelBase):
"""创建自定义属性请求 DTO""" """创建自定义属性请求 DTO"""
name: str = Field(max_length=100) name: Str100
"""属性显示名称""" """属性显示名称"""
type: CustomPropertyType type: CustomPropertyType

View File

@@ -4,7 +4,7 @@ from uuid import UUID
from sqlmodel import Field, Relationship, UniqueConstraint, Index from sqlmodel import Field, Relationship, UniqueConstraint, Index
from sqlmodel_ext import SQLModelBase, UUIDTableBaseMixin, TableBaseMixin from sqlmodel_ext import SQLModelBase, UUIDTableBaseMixin, TableBaseMixin, Str255
if TYPE_CHECKING: if TYPE_CHECKING:
from .user import User from .user import User
@@ -141,7 +141,7 @@ class Download(DownloadBase, UUIDTableBaseMixin):
speed: int = Field(default=0) speed: int = Field(default=0)
"""下载速度bytes/s""" """下载速度bytes/s"""
parent: str | None = Field(default=None, max_length=255) parent: Str255 | None = None
"""父任务标识""" """父任务标识"""
error: str | None = Field(default=None) error: str | None = Field(default=None)

View File

@@ -20,7 +20,7 @@ from uuid import UUID
from sqlmodel import Field, Relationship, UniqueConstraint from sqlmodel import Field, Relationship, UniqueConstraint
from sqlmodel_ext import SQLModelBase, TableBaseMixin, UUIDTableBaseMixin from sqlmodel_ext import SQLModelBase, TableBaseMixin, UUIDTableBaseMixin, Str100, Str255, Text1024
if TYPE_CHECKING: if TYPE_CHECKING:
from .group import Group from .group import Group
@@ -119,7 +119,7 @@ class UserFileAppDefaultResponse(SQLModelBase):
class FileAppCreateRequest(SQLModelBase): class FileAppCreateRequest(SQLModelBase):
"""管理员创建应用请求 DTO""" """管理员创建应用请求 DTO"""
name: str = Field(max_length=100) name: Str100
"""应用名称""" """应用名称"""
app_key: str = Field(max_length=50) app_key: str = Field(max_length=50)
@@ -128,7 +128,7 @@ class FileAppCreateRequest(SQLModelBase):
type: FileAppType type: FileAppType
"""应用类型""" """应用类型"""
icon: str | None = Field(default=None, max_length=255) icon: Str255 | None = None
"""图标名称/URL""" """图标名称/URL"""
description: str | None = Field(default=None, max_length=500) description: str | None = Field(default=None, max_length=500)
@@ -140,13 +140,13 @@ class FileAppCreateRequest(SQLModelBase):
is_restricted: bool = False is_restricted: bool = False
"""是否限制用户组访问""" """是否限制用户组访问"""
iframe_url_template: str | None = Field(default=None, max_length=1024) iframe_url_template: Text1024 | None = None
"""iframe URL 模板""" """iframe URL 模板"""
wopi_discovery_url: str | None = Field(default=None, max_length=512) wopi_discovery_url: str | None = Field(default=None, max_length=512)
"""WOPI 发现端点 URL""" """WOPI 发现端点 URL"""
wopi_editor_url_template: str | None = Field(default=None, max_length=1024) wopi_editor_url_template: Text1024 | None = None
"""WOPI 编辑器 URL 模板""" """WOPI 编辑器 URL 模板"""
extensions: list[str] = [] extensions: list[str] = []
@@ -159,7 +159,7 @@ class FileAppCreateRequest(SQLModelBase):
class FileAppUpdateRequest(SQLModelBase): class FileAppUpdateRequest(SQLModelBase):
"""管理员更新应用请求 DTO所有字段可选""" """管理员更新应用请求 DTO所有字段可选"""
name: str | None = Field(default=None, max_length=100) name: Str100 | None = None
"""应用名称""" """应用名称"""
app_key: str | None = Field(default=None, max_length=50) app_key: str | None = Field(default=None, max_length=50)
@@ -168,7 +168,7 @@ class FileAppUpdateRequest(SQLModelBase):
type: FileAppType | None = None type: FileAppType | None = None
"""应用类型""" """应用类型"""
icon: str | None = Field(default=None, max_length=255) icon: Str255 | None = None
"""图标名称/URL""" """图标名称/URL"""
description: str | None = Field(default=None, max_length=500) description: str | None = Field(default=None, max_length=500)
@@ -180,13 +180,13 @@ class FileAppUpdateRequest(SQLModelBase):
is_restricted: bool | None = None is_restricted: bool | None = None
"""是否限制用户组访问""" """是否限制用户组访问"""
iframe_url_template: str | None = Field(default=None, max_length=1024) iframe_url_template: Text1024 | None = None
"""iframe URL 模板""" """iframe URL 模板"""
wopi_discovery_url: str | None = Field(default=None, max_length=512) wopi_discovery_url: str | None = Field(default=None, max_length=512)
"""WOPI 发现端点 URL""" """WOPI 发现端点 URL"""
wopi_editor_url_template: str | None = Field(default=None, max_length=1024) wopi_editor_url_template: Text1024 | None = None
"""WOPI 编辑器 URL 模板""" """WOPI 编辑器 URL 模板"""
@@ -325,7 +325,7 @@ class WopiDiscoveryResponse(SQLModelBase):
class FileApp(SQLModelBase, UUIDTableBaseMixin): class FileApp(SQLModelBase, UUIDTableBaseMixin):
"""文件查看器应用注册表""" """文件查看器应用注册表"""
name: str = Field(max_length=100) name: Str100
"""应用名称""" """应用名称"""
app_key: str = Field(max_length=50, unique=True, index=True) app_key: str = Field(max_length=50, unique=True, index=True)
@@ -334,7 +334,7 @@ class FileApp(SQLModelBase, UUIDTableBaseMixin):
type: FileAppType type: FileAppType
"""应用类型""" """应用类型"""
icon: str | None = Field(default=None, max_length=255) icon: Str255 | None = None
"""图标名称/URL""" """图标名称/URL"""
description: str | None = Field(default=None, max_length=500) description: str | None = Field(default=None, max_length=500)
@@ -346,13 +346,13 @@ class FileApp(SQLModelBase, UUIDTableBaseMixin):
is_restricted: bool = False is_restricted: bool = False
"""是否限制用户组访问""" """是否限制用户组访问"""
iframe_url_template: str | None = Field(default=None, max_length=1024) iframe_url_template: Text1024 | None = None
"""iframe URL 模板,支持 {file_url} 占位符""" """iframe URL 模板,支持 {file_url} 占位符"""
wopi_discovery_url: str | None = Field(default=None, max_length=512) wopi_discovery_url: str | None = Field(default=None, max_length=512)
"""WOPI 客户端发现端点 URL""" """WOPI 客户端发现端点 URL"""
wopi_editor_url_template: str | None = Field(default=None, max_length=1024) wopi_editor_url_template: Text1024 | None = None
"""WOPI 编辑器 URL 模板,支持 {wopi_src} {access_token} {access_token_ttl}""" """WOPI 编辑器 URL 模板,支持 {wopi_src} {access_token} {access_token_ttl}"""
# 关系 # 关系

View File

@@ -5,7 +5,7 @@ from uuid import UUID
from sqlalchemy import BigInteger from sqlalchemy import BigInteger
from sqlmodel import Field, Relationship, text from sqlmodel import Field, Relationship, text
from sqlmodel_ext import SQLModelBase, TableBaseMixin, UUIDTableBaseMixin from sqlmodel_ext import SQLModelBase, TableBaseMixin, UUIDTableBaseMixin, Str255
if TYPE_CHECKING: if TYPE_CHECKING:
from .user import User from .user import User
@@ -67,7 +67,7 @@ class GroupAllOptionsBase(GroupOptionsBase):
class GroupCreateRequest(GroupAllOptionsBase): class GroupCreateRequest(GroupAllOptionsBase):
"""创建用户组请求 DTO""" """创建用户组请求 DTO"""
name: str = Field(max_length=255) name: Str255
"""用户组名称""" """用户组名称"""
max_storage: int = Field(default=0, ge=0) max_storage: int = Field(default=0, ge=0)
@@ -92,7 +92,7 @@ class GroupCreateRequest(GroupAllOptionsBase):
class GroupUpdateRequest(SQLModelBase): class GroupUpdateRequest(SQLModelBase):
"""更新用户组请求 DTO所有字段可选""" """更新用户组请求 DTO所有字段可选"""
name: str | None = Field(default=None, max_length=255) name: Str255 | None = None
"""用户组名称""" """用户组名称"""
max_storage: int | None = Field(default=None, ge=0) max_storage: int | None = Field(default=None, ge=0)
@@ -258,7 +258,7 @@ class GroupOptions(GroupAllOptionsBase, TableBaseMixin):
class Group(GroupBase, UUIDTableBaseMixin): class Group(GroupBase, UUIDTableBaseMixin):
"""用户组模型""" """用户组模型"""
name: str = Field(max_length=255, unique=True) name: Str255 = Field(unique=True)
"""用户组名""" """用户组名"""
max_storage: int = Field(default=0, sa_type=BigInteger, sa_column_kwargs={"server_default": "0"}) max_storage: int = Field(default=0, sa_type=BigInteger, sa_column_kwargs={"server_default": "0"})

View File

@@ -130,6 +130,11 @@ default_settings: list[Setting] = [
Setting(name="sms_provider", value="", type=SettingsType.MOBILE), Setting(name="sms_provider", value="", type=SettingsType.MOBILE),
Setting(name="sms_access_key", value="", type=SettingsType.MOBILE), Setting(name="sms_access_key", value="", type=SettingsType.MOBILE),
Setting(name="sms_secret_key", value="", type=SettingsType.MOBILE), Setting(name="sms_secret_key", value="", type=SettingsType.MOBILE),
# ==================== 文件分类扩展名配置 ====================
Setting(name="image", value="jpg,jpeg,png,gif,bmp,webp,svg,ico,tiff,tif,avif,heic,heif,psd,raw", type=SettingsType.FILE_CATEGORY),
Setting(name="video", value="mp4,mkv,avi,mov,wmv,flv,webm,m4v,ts,3gp,mpg,mpeg", type=SettingsType.FILE_CATEGORY),
Setting(name="audio", value="mp3,wav,flac,aac,ogg,wma,m4a,opus,ape,aiff,mid,midi", type=SettingsType.FILE_CATEGORY),
Setting(name="document", value="pdf,doc,docx,odt,rtf,txt,tex,epub,pages,ppt,pptx,odp,key,xls,xlsx,csv,ods,numbers,tsv,md,markdown,mdx", type=SettingsType.FILE_CATEGORY),
] ]
async def init_default_settings() -> None: async def init_default_settings() -> None:
@@ -173,7 +178,7 @@ async def init_default_group() -> None:
admin=True, admin=True,
) )
admin_group_id = admin_group.id # 在 save 前保存 UUID admin_group_id = admin_group.id # 在 save 前保存 UUID
await admin_group.save(session) admin_group = await admin_group.save(session)
await GroupOptions( await GroupOptions(
group_id=admin_group_id, group_id=admin_group_id,
@@ -203,7 +208,7 @@ async def init_default_group() -> None:
web_dav_enabled=True, web_dav_enabled=True,
) )
member_group_id = member_group.id # 在 save 前保存 UUID member_group_id = member_group.id # 在 save 前保存 UUID
await member_group.save(session) member_group = await member_group.save(session)
await GroupOptions( await GroupOptions(
group_id=member_group_id, group_id=member_group_id,
@@ -222,7 +227,7 @@ async def init_default_group() -> None:
default_group_setting = await Setting.get(session, Setting.name == "default_group") default_group_setting = await Setting.get(session, Setting.name == "default_group")
if default_group_setting: if default_group_setting:
default_group_setting.value = str(member_group_id) default_group_setting.value = str(member_group_id)
await default_group_setting.save(session) default_group_setting = await default_group_setting.save(session)
# 未找到初始游客组时,则创建 # 未找到初始游客组时,则创建
if not await Group.get(session, Group.name == "游客"): if not await Group.get(session, Group.name == "游客"):
@@ -232,7 +237,7 @@ async def init_default_group() -> None:
web_dav_enabled=False, web_dav_enabled=False,
) )
guest_group_id = guest_group.id # 在 save 前保存 UUID guest_group_id = guest_group.id # 在 save 前保存 UUID
await guest_group.save(session) guest_group = await guest_group.save(session)
await GroupOptions( await GroupOptions(
group_id=guest_group_id, group_id=guest_group_id,
@@ -284,7 +289,7 @@ async def init_default_user() -> None:
group_id=admin_group.id, group_id=admin_group.id,
) )
admin_user_id = admin_user.id # 在 save 前保存 UUID admin_user_id = admin_user.id # 在 save 前保存 UUID
await admin_user.save(session) admin_user = await admin_user.save(session)
# 创建 AuthIdentity邮箱密码身份 # 创建 AuthIdentity邮箱密码身份
await AuthIdentity( await AuthIdentity(
@@ -373,7 +378,7 @@ async def init_default_theme_presets() -> None:
error=ChromaticColor.RED, error=ChromaticColor.RED,
neutral=NeutralColor.ZINC, neutral=NeutralColor.ZINC,
) )
await default_preset.save(session) default_preset = await default_preset.save(session)
log.info('已创建默认主题预设') log.info('已创建默认主题预设')
@@ -446,36 +451,43 @@ _DEFAULT_FILE_APPS: list[dict] = [
"is_enabled": True, "is_enabled": True,
"extensions": ["mp3", "wav", "ogg", "flac", "aac", "m4a", "opus"], "extensions": ["mp3", "wav", "ogg", "flac", "aac", "m4a", "opus"],
}, },
# iframe 应用(默认禁用) {
"name": "EPUB 阅读器",
"app_key": "epub_reader",
"type": "builtin",
"icon": "book-open",
"description": "阅读 EPUB 电子书",
"is_enabled": True,
"extensions": ["epub"],
},
{
"name": "3D 模型预览",
"app_key": "model_viewer",
"type": "builtin",
"icon": "cube",
"description": "预览 3D 模型",
"is_enabled": True,
"extensions": ["gltf", "glb", "stl", "obj", "fbx", "ply", "3mf"],
},
{
"name": "Font Viewer",
"app_key": "font_viewer",
"type": "builtin",
"icon": "type",
"description": "预览字体文件并显示元数据和文本样本",
"is_enabled": True,
"extensions": ["ttf", "otf", "woff", "woff2"],
},
{ {
"name": "Office 在线预览", "name": "Office 在线预览",
"app_key": "office_viewer", "app_key": "office_viewer",
"type": "iframe", "type": "iframe",
"icon": "file-word", "icon": "file-word",
"description": "使用 Microsoft Office Online 预览文档", "description": "使用 Microsoft Office Online 预览文档",
"is_enabled": False, "is_enabled": True,
"iframe_url_template": "https://view.officeapps.live.com/op/embed.aspx?src={file_url}", "iframe_url_template": "https://view.officeapps.live.com/op/embed.aspx?src={file_url}",
"extensions": ["doc", "docx", "xls", "xlsx", "ppt", "pptx"], "extensions": ["doc", "docx", "xls", "xlsx", "ppt", "pptx"],
}, },
# WOPI 应用(默认禁用)
{
"name": "Collabora Online",
"app_key": "collabora",
"type": "wopi",
"icon": "file-text",
"description": "Collabora Online 文档编辑器(需自行部署)",
"is_enabled": False,
"extensions": ["doc", "docx", "xls", "xlsx", "ppt", "pptx", "odt", "ods", "odp"],
},
{
"name": "OnlyOffice",
"app_key": "onlyoffice",
"type": "wopi",
"icon": "file-text",
"description": "OnlyOffice 文档编辑器(需自行部署)",
"is_enabled": False,
"extensions": ["doc", "docx", "xls", "xlsx", "ppt", "pptx"],
},
] ]
@@ -493,7 +505,7 @@ async def init_default_file_apps() -> None:
return return
for app_data in _DEFAULT_FILE_APPS: for app_data in _DEFAULT_FILE_APPS:
extensions = app_data.pop("extensions") extensions = app_data["extensions"]
app = FileApp( app = FileApp(
name=app_data["name"], name=app_data["name"],
@@ -515,6 +527,6 @@ async def init_default_file_apps() -> None:
extension=ext.lower(), extension=ext.lower(),
priority=i, priority=i,
) )
await ext_record.save(session) ext_record = await ext_record.save(session)
log.info(f'已创建 {len(_DEFAULT_FILE_APPS)} 个默认文件查看器应用') log.info(f'已创建 {len(_DEFAULT_FILE_APPS)} 个默认文件查看器应用')

View File

@@ -3,7 +3,7 @@ from typing import TYPE_CHECKING
from sqlmodel import Field, Relationship, text, Index from sqlmodel import Field, Relationship, text, Index
from sqlmodel_ext import SQLModelBase, TableBaseMixin from sqlmodel_ext import SQLModelBase, TableBaseMixin, Str255
if TYPE_CHECKING: if TYPE_CHECKING:
from .download import Download from .download import Download
@@ -28,13 +28,13 @@ class NodeType(StrEnum):
class Aria2ConfigurationBase(SQLModelBase): class Aria2ConfigurationBase(SQLModelBase):
"""Aria2配置基础模型""" """Aria2配置基础模型"""
rpc_url: str | None = Field(default=None, max_length=255) rpc_url: Str255 | None = None
"""RPC地址""" """RPC地址"""
rpc_secret: str | None = None rpc_secret: str | None = None
"""RPC密钥""" """RPC密钥"""
temp_path: str | None = Field(default=None, max_length=255) temp_path: Str255 | None = None
"""临时下载路径""" """临时下载路径"""
max_concurrent: int = Field(default=5, ge=1, le=50) max_concurrent: int = Field(default=5, ge=1, le=50)
@@ -70,19 +70,19 @@ class Node(SQLModelBase, TableBaseMixin):
status: NodeStatus = Field(default=NodeStatus.ONLINE) status: NodeStatus = Field(default=NodeStatus.ONLINE)
"""节点状态""" """节点状态"""
name: str = Field(max_length=255, unique=True) name: Str255 = Field(unique=True)
"""节点名称""" """节点名称"""
type: NodeType type: NodeType
"""节点类型""" """节点类型"""
server: str = Field(max_length=255) server: Str255
"""节点地址IP或域名""" """节点地址IP或域名"""
slave_key: str | None = Field(default=None, max_length=255) slave_key: Str255 | None = None
"""从机通讯密钥""" """从机通讯密钥"""
master_key: str | None = Field(default=None, max_length=255) master_key: Str255 | None = None
"""主机通讯密钥""" """主机通讯密钥"""
aria2_enabled: bool = False aria2_enabled: bool = False

View File

@@ -7,7 +7,7 @@ from enum import StrEnum
from sqlalchemy import BigInteger from sqlalchemy import BigInteger
from sqlmodel import Field, Relationship, CheckConstraint, Index, text from sqlmodel import Field, Relationship, CheckConstraint, Index, text
from sqlmodel_ext import SQLModelBase, UUIDTableBaseMixin from sqlmodel_ext import SQLModelBase, UUIDTableBaseMixin, Str255, Str256
from .policy import PolicyType from .policy import PolicyType
@@ -25,7 +25,15 @@ class ObjectType(StrEnum):
"""对象类型枚举""" """对象类型枚举"""
FILE = "file" FILE = "file"
FOLDER = "folder" FOLDER = "folder"
class FileCategory(StrEnum):
"""文件类型分类枚举,用于按类别筛选文件"""
IMAGE = "image"
VIDEO = "video"
AUDIO = "audio"
DOCUMENT = "document"
# ==================== Base 模型 ==================== # ==================== Base 模型 ====================
@@ -190,13 +198,13 @@ class Object(ObjectBase, UUIDTableBaseMixin):
# ==================== 基础字段 ==================== # ==================== 基础字段 ====================
name: str = Field(max_length=255) name: Str255
"""对象名称(文件名或目录名)""" """对象名称(文件名或目录名)"""
type: ObjectType type: ObjectType
"""对象类型file 或 folder""" """对象类型file 或 folder"""
password: str | None = Field(default=None, max_length=255) password: Str255 | None = None
"""对象独立密码(仅当用户为对象单独设置密码时有效)""" """对象独立密码(仅当用户为对象单独设置密码时有效)"""
# ==================== 文件专属字段 ==================== # ==================== 文件专属字段 ====================
@@ -204,7 +212,7 @@ class Object(ObjectBase, UUIDTableBaseMixin):
size: int = Field(default=0, sa_type=BigInteger, sa_column_kwargs={"server_default": "0"}) size: int = Field(default=0, sa_type=BigInteger, sa_column_kwargs={"server_default": "0"})
"""文件大小(字节),目录为 0""" """文件大小(字节),目录为 0"""
upload_session_id: str | None = Field(default=None, max_length=255, unique=True, index=True) upload_session_id: Str255 | None = Field(default=None, unique=True, index=True)
"""分块上传会话ID仅文件有效""" """分块上传会话ID仅文件有效"""
physical_file_id: UUID | None = Field( physical_file_id: UUID | None = Field(
@@ -469,6 +477,37 @@ class Object(ObjectBase, UUIDTableBaseMixin):
fetch_mode="all" fetch_mode="all"
) )
@classmethod
async def get_by_category(
cls,
session: 'AsyncSession',
user_id: UUID,
extensions: list[str],
table_view: 'TableViewRequest | None' = None,
) -> 'ListResponse[Object]':
"""
按扩展名列表查询用户的所有文件(跨目录)
只查询未删除、未封禁的文件对象,使用 ILIKE 匹配文件名后缀。
:param session: 数据库会话
:param user_id: 用户UUID
:param extensions: 扩展名列表(不含点号)
:param table_view: 分页排序参数
:return: 分页文件列表
"""
from sqlalchemy import or_
ext_conditions = [cls.name.ilike(f"%.{ext}") for ext in extensions]
condition = (
(cls.owner_id == user_id) &
(cls.type == ObjectType.FILE) &
(cls.deleted_at == None) &
(cls.is_banned == False) &
or_(*ext_conditions)
)
return await cls.get_with_count(session, condition, table_view=table_view)
@classmethod @classmethod
async def resolve_uri( async def resolve_uri(
cls, cls,
@@ -546,7 +585,7 @@ class Object(ObjectBase, UUIDTableBaseMixin):
class UploadSessionBase(SQLModelBase): class UploadSessionBase(SQLModelBase):
"""上传会话基础字段""" """上传会话基础字段"""
file_name: str = Field(max_length=255) file_name: Str255
"""原始文件名""" """原始文件名"""
file_size: int = Field(ge=0, sa_type=BigInteger) file_size: int = Field(ge=0, sa_type=BigInteger)
@@ -577,7 +616,7 @@ class UploadSession(UploadSessionBase, UUIDTableBaseMixin):
storage_path: str | None = Field(default=None, max_length=512) storage_path: str | None = Field(default=None, max_length=512)
"""文件存储路径""" """文件存储路径"""
s3_upload_id: str | None = Field(default=None, max_length=256) s3_upload_id: Str256 | None = None
"""S3 Multipart Upload ID仅 S3 策略使用)""" """S3 Multipart Upload ID仅 S3 策略使用)"""
s3_part_etags: str | None = None s3_part_etags: str | None = None
@@ -624,7 +663,7 @@ class UploadSession(UploadSessionBase, UUIDTableBaseMixin):
class CreateUploadSessionRequest(SQLModelBase): class CreateUploadSessionRequest(SQLModelBase):
"""创建上传会话请求 DTO""" """创建上传会话请求 DTO"""
file_name: str = Field(max_length=255) file_name: Str255
"""文件名""" """文件名"""
file_size: int = Field(ge=0) file_size: int = Field(ge=0)
@@ -681,7 +720,7 @@ class UploadChunkResponse(SQLModelBase):
class CreateFileRequest(SQLModelBase): class CreateFileRequest(SQLModelBase):
"""创建空白文件请求 DTO""" """创建空白文件请求 DTO"""
name: str = Field(max_length=255) name: Str255
"""文件名""" """文件名"""
parent_id: UUID parent_id: UUID
@@ -719,7 +758,7 @@ class ObjectRenameRequest(SQLModelBase):
id: UUID id: UUID
"""对象UUID""" """对象UUID"""
new_name: str = Field(max_length=255) new_name: Str255
"""新名称""" """新名称"""

View File

@@ -25,7 +25,7 @@ from uuid import UUID
from sqlmodel import Field, UniqueConstraint, Index, Relationship from sqlmodel import Field, UniqueConstraint, Index, Relationship
from sqlmodel_ext import SQLModelBase, UUIDTableBaseMixin from sqlmodel_ext import SQLModelBase, UUIDTableBaseMixin, Str255
if TYPE_CHECKING: if TYPE_CHECKING:
from .object import Object from .object import Object
@@ -65,7 +65,7 @@ USER_WRITABLE_NAMESPACES: set[str] = {MetadataNamespace.CUSTOM}
class ObjectMetadataBase(SQLModelBase): class ObjectMetadataBase(SQLModelBase):
"""对象元数据 KV 基础模型""" """对象元数据 KV 基础模型"""
name: str = Field(max_length=255) name: Str255
"""元数据键名格式namespace:key如 exif:width, stream:duration""" """元数据键名格式namespace:key如 exif:width, stream:duration"""
value: str value: str
@@ -113,7 +113,7 @@ class MetadataResponse(SQLModelBase):
class MetadataPatchItem(SQLModelBase): class MetadataPatchItem(SQLModelBase):
"""单条元数据补丁 DTO""" """单条元数据补丁 DTO"""
key: str = Field(max_length=255) key: Str255
"""元数据键名""" """元数据键名"""
value: str | None = None value: str | None = None

View File

@@ -1,58 +1,122 @@
from decimal import Decimal
from enum import StrEnum from enum import StrEnum
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from uuid import UUID from uuid import UUID
from sqlalchemy import Numeric
from sqlmodel import Field, Relationship from sqlmodel import Field, Relationship
from sqlmodel_ext import SQLModelBase, TableBaseMixin from sqlmodel_ext import SQLModelBase, TableBaseMixin, Str255
if TYPE_CHECKING: if TYPE_CHECKING:
from .product import Product
from .user import User from .user import User
class OrderStatus(StrEnum): class OrderStatus(StrEnum):
"""订单状态枚举""" """订单状态枚举"""
PENDING = "pending" PENDING = "pending"
"""待支付""" """待支付"""
COMPLETED = "completed" COMPLETED = "completed"
"""已完成""" """已完成"""
CANCELLED = "cancelled" CANCELLED = "cancelled"
"""已取消""" """已取消"""
class OrderType(StrEnum): class OrderType(StrEnum):
"""订单类型枚举""" """订单类型枚举"""
# [TODO] 补充具体订单类型
pass
STORAGE_PACK = "storage_pack"
"""容量包"""
GROUP_TIME = "group_time"
"""用户组时长"""
SCORE = "score"
"""积分充值"""
# ==================== DTO 模型 ====================
class CreateOrderRequest(SQLModelBase):
"""创建订单请求 DTO"""
product_id: UUID
"""商品UUID"""
num: int = Field(default=1, ge=1)
"""购买数量"""
method: str
"""支付方式"""
class OrderResponse(SQLModelBase):
"""订单响应 DTO"""
id: int
"""订单ID"""
order_no: str
"""订单号"""
type: OrderType
"""订单类型"""
method: str | None = None
"""支付方式"""
product_id: UUID | None = None
"""商品UUID"""
num: int
"""购买数量"""
name: str
"""商品名称"""
price: float
"""订单价格(元)"""
status: OrderStatus
"""订单状态"""
user_id: UUID
"""所属用户UUID"""
# ==================== 数据库模型 ====================
class Order(SQLModelBase, TableBaseMixin): class Order(SQLModelBase, TableBaseMixin):
"""订单模型""" """订单模型"""
order_no: str = Field(max_length=255, unique=True, index=True) order_no: Str255 = Field(unique=True, index=True)
"""订单号,唯一""" """订单号,唯一"""
type: int = Field(default=0, sa_column_kwargs={"server_default": "0"}) type: OrderType
"""订单类型 [TODO] 待定义枚举""" """订单类型"""
method: str | None = Field(default=None, max_length=255) method: Str255 | None = None
"""支付方式""" """支付方式"""
product_id: int | None = Field(default=None) product_id: UUID | None = Field(default=None, foreign_key="product.id", ondelete="SET NULL")
"""商品ID""" """关联商品UUID"""
num: int = Field(default=1, sa_column_kwargs={"server_default": "1"}) num: int = Field(default=1, sa_column_kwargs={"server_default": "1"})
"""购买数量""" """购买数量"""
name: str = Field(max_length=255) name: Str255
"""商品名称""" """商品名称"""
price: int = Field(default=0, sa_column_kwargs={"server_default": "0"}) price: Decimal = Field(sa_type=Numeric(12, 2), default=Decimal("0.00"))
"""订单价格(""" """订单价格("""
status: OrderStatus = Field(default=OrderStatus.PENDING) status: OrderStatus = Field(default=OrderStatus.PENDING)
"""订单状态""" """订单状态"""
# 外键 # 外键
user_id: UUID = Field( user_id: UUID = Field(
foreign_key="user.id", foreign_key="user.id",
@@ -60,6 +124,22 @@ class Order(SQLModelBase, TableBaseMixin):
ondelete="CASCADE" ondelete="CASCADE"
) )
"""所属用户UUID""" """所属用户UUID"""
# 关系 # 关系
user: "User" = Relationship(back_populates="orders") user: "User" = Relationship(back_populates="orders")
product: "Product" = Relationship(back_populates="orders")
def to_response(self) -> OrderResponse:
"""转换为响应 DTO"""
return OrderResponse(
id=self.id,
order_no=self.order_no,
type=self.type,
method=self.method,
product_id=self.product_id,
num=self.num,
name=self.name,
price=float(self.price),
status=self.status,
user_id=self.user_id,
)

View File

@@ -15,7 +15,7 @@ from uuid import UUID
from sqlalchemy import BigInteger from sqlalchemy import BigInteger
from sqlmodel import Field, Relationship, Index from sqlmodel import Field, Relationship, Index
from sqlmodel_ext import SQLModelBase, UUIDTableBaseMixin from sqlmodel_ext import SQLModelBase, UUIDTableBaseMixin, Str32, Str64
if TYPE_CHECKING: if TYPE_CHECKING:
from .object import Object from .object import Object
@@ -31,10 +31,10 @@ class PhysicalFileBase(SQLModelBase):
size: int = Field(default=0, sa_type=BigInteger) size: int = Field(default=0, sa_type=BigInteger)
"""文件大小(字节)""" """文件大小(字节)"""
checksum_md5: str | None = Field(default=None, max_length=32) checksum_md5: Str32 | None = None
"""MD5校验和用于文件去重和完整性校验""" """MD5校验和用于文件去重和完整性校验"""
checksum_sha256: str | None = Field(default=None, max_length=64) checksum_sha256: Str64 | None = None
"""SHA256校验和""" """SHA256校验和"""

View File

@@ -4,7 +4,7 @@ from uuid import UUID
from enum import StrEnum from enum import StrEnum
from sqlmodel import Field, Relationship, text from sqlmodel import Field, Relationship, text
from sqlmodel_ext import SQLModelBase, UUIDTableBaseMixin from sqlmodel_ext import SQLModelBase, UUIDTableBaseMixin, Str64, Str255
if TYPE_CHECKING: if TYPE_CHECKING:
from .object import Object from .object import Object
@@ -37,22 +37,22 @@ class PolicyType(StrEnum):
class PolicyBase(SQLModelBase): class PolicyBase(SQLModelBase):
"""存储策略基础字段,供 DTO 和数据库模型共享""" """存储策略基础字段,供 DTO 和数据库模型共享"""
name: str = Field(max_length=255) name: Str255
"""策略名称""" """策略名称"""
type: PolicyType type: PolicyType
"""存储策略类型""" """存储策略类型"""
server: str | None = Field(default=None, max_length=255) server: Str255 | None = None
"""服务器地址(本地策略为绝对路径)""" """服务器地址(本地策略为绝对路径)"""
bucket_name: str | None = Field(default=None, max_length=255) bucket_name: Str255 | None = None
"""存储桶名称""" """存储桶名称"""
is_private: bool = True is_private: bool = True
"""是否为私有空间""" """是否为私有空间"""
base_url: str | None = Field(default=None, max_length=255) base_url: Str255 | None = None
"""访问文件的基础URL""" """访问文件的基础URL"""
access_key: str | None = None access_key: str | None = None
@@ -67,10 +67,10 @@ class PolicyBase(SQLModelBase):
auto_rename: bool = False auto_rename: bool = False
"""是否自动重命名""" """是否自动重命名"""
dir_name_rule: str | None = Field(default=None, max_length=255) dir_name_rule: Str255 | None = None
"""目录命名规则""" """目录命名规则"""
file_name_rule: str | None = Field(default=None, max_length=255) file_name_rule: Str255 | None = None
"""文件命名规则""" """文件命名规则"""
is_origin_link_enable: bool = False is_origin_link_enable: bool = False
@@ -115,7 +115,7 @@ class PolicyCreateRequest(PolicyBase):
mimetype: str | None = Field(default=None, max_length=127) mimetype: str | None = Field(default=None, max_length=127)
"""MIME类型""" """MIME类型"""
od_redirect: str | None = Field(default=None, max_length=255) od_redirect: Str255 | None = None
"""OneDrive重定向地址""" """OneDrive重定向地址"""
chunk_size: int = Field(default=52428800, ge=1) chunk_size: int = Field(default=52428800, ge=1)
@@ -124,26 +124,26 @@ class PolicyCreateRequest(PolicyBase):
s3_path_style: bool = False s3_path_style: bool = False
"""是否使用S3路径风格""" """是否使用S3路径风格"""
s3_region: str = Field(default='us-east-1', max_length=64) s3_region: Str64 = 'us-east-1'
"""S3 区域(如 us-east-1、ap-southeast-1仅 S3 策略使用""" """S3 区域(如 us-east-1、ap-southeast-1仅 S3 策略使用"""
class PolicyUpdateRequest(SQLModelBase): class PolicyUpdateRequest(SQLModelBase):
"""更新存储策略请求 DTO所有字段可选""" """更新存储策略请求 DTO所有字段可选"""
name: str | None = Field(default=None, max_length=255) name: Str255 | None = None
"""策略名称""" """策略名称"""
server: str | None = Field(default=None, max_length=255) server: Str255 | None = None
"""服务器地址""" """服务器地址"""
bucket_name: str | None = Field(default=None, max_length=255) bucket_name: Str255 | None = None
"""存储桶名称""" """存储桶名称"""
is_private: bool | None = None is_private: bool | None = None
"""是否为私有空间""" """是否为私有空间"""
base_url: str | None = Field(default=None, max_length=255) base_url: Str255 | None = None
"""访问文件的基础URL""" """访问文件的基础URL"""
access_key: str | None = None access_key: str | None = None
@@ -158,10 +158,10 @@ class PolicyUpdateRequest(SQLModelBase):
auto_rename: bool | None = None auto_rename: bool | None = None
"""是否自动重命名""" """是否自动重命名"""
dir_name_rule: str | None = Field(default=None, max_length=255) dir_name_rule: Str255 | None = None
"""目录命名规则""" """目录命名规则"""
file_name_rule: str | None = Field(default=None, max_length=255) file_name_rule: Str255 | None = None
"""文件命名规则""" """文件命名规则"""
is_origin_link_enable: bool | None = None is_origin_link_enable: bool | None = None
@@ -177,7 +177,7 @@ class PolicyUpdateRequest(SQLModelBase):
mimetype: str | None = Field(default=None, max_length=127) mimetype: str | None = Field(default=None, max_length=127)
"""MIME类型""" """MIME类型"""
od_redirect: str | None = Field(default=None, max_length=255) od_redirect: Str255 | None = None
"""OneDrive重定向地址""" """OneDrive重定向地址"""
chunk_size: int | None = Field(default=None, ge=1) chunk_size: int | None = Field(default=None, ge=1)
@@ -186,7 +186,7 @@ class PolicyUpdateRequest(SQLModelBase):
s3_path_style: bool | None = None s3_path_style: bool | None = None
"""是否使用S3路径风格""" """是否使用S3路径风格"""
s3_region: str | None = Field(default=None, max_length=64) s3_region: Str64 | None = None
"""S3 区域""" """S3 区域"""
@@ -205,7 +205,7 @@ class PolicyOptionsBase(SQLModelBase):
mimetype: str | None = Field(default=None, max_length=127) mimetype: str | None = Field(default=None, max_length=127)
"""MIME类型""" """MIME类型"""
od_redirect: str | None = Field(default=None, max_length=255) od_redirect: Str255 | None = None
"""OneDrive重定向地址""" """OneDrive重定向地址"""
chunk_size: int = Field(default=52428800, sa_column_kwargs={"server_default": "52428800"}) chunk_size: int = Field(default=52428800, sa_column_kwargs={"server_default": "52428800"})
@@ -214,7 +214,7 @@ class PolicyOptionsBase(SQLModelBase):
s3_path_style: bool = Field(default=False, sa_column_kwargs={"server_default": text("false")}) s3_path_style: bool = Field(default=False, sa_column_kwargs={"server_default": text("false")})
"""是否使用S3路径风格""" """是否使用S3路径风格"""
s3_region: str = Field(default='us-east-1', max_length=64, sa_column_kwargs={"server_default": "'us-east-1'"}) s3_region: Str64 = Field(default='us-east-1', sa_column_kwargs={"server_default": "'us-east-1'"})
"""S3 区域(如 us-east-1、ap-southeast-1仅 S3 策略使用""" """S3 区域(如 us-east-1、ap-southeast-1仅 S3 策略使用"""
@@ -237,7 +237,7 @@ class Policy(PolicyBase, UUIDTableBaseMixin):
"""存储策略模型""" """存储策略模型"""
# 覆盖基类字段以添加数据库专有配置 # 覆盖基类字段以添加数据库专有配置
name: str = Field(max_length=255, unique=True) name: Str255 = Field(unique=True)
"""策略名称""" """策略名称"""
is_private: bool = Field(default=True, sa_column_kwargs={"server_default": text("true")}) is_private: bool = Field(default=True, sa_column_kwargs={"server_default": text("true")})

206
sqlmodels/product.py Normal file
View File

@@ -0,0 +1,206 @@
from decimal import Decimal
from enum import StrEnum
from typing import TYPE_CHECKING
from uuid import UUID
from sqlalchemy import Numeric, BigInteger
from sqlmodel import Field, Relationship, text
from sqlmodel_ext import SQLModelBase, UUIDTableBaseMixin, Str255
if TYPE_CHECKING:
from .order import Order
from .redeem import Redeem
class ProductType(StrEnum):
"""商品类型枚举"""
STORAGE_PACK = "storage_pack"
"""容量包"""
GROUP_TIME = "group_time"
"""用户组时长"""
SCORE = "score"
"""积分充值"""
class PaymentMethod(StrEnum):
"""支付方式枚举"""
ALIPAY = "alipay"
"""支付宝"""
WECHAT = "wechat"
"""微信支付"""
STRIPE = "stripe"
"""Stripe"""
EASYPAY = "easypay"
"""易支付"""
CUSTOM = "custom"
"""自定义支付"""
# ==================== DTO 模型 ====================
class ProductBase(SQLModelBase):
"""商品基础字段"""
name: str
"""商品名称"""
type: ProductType
"""商品类型"""
description: str | None = None
"""商品描述"""
class ProductCreateRequest(ProductBase):
"""创建商品请求 DTO"""
name: Str255
"""商品名称"""
price: Decimal = Field(ge=0, decimal_places=2)
"""商品价格(元)"""
is_active: bool = True
"""是否上架"""
sort_order: int = Field(default=0, ge=0)
"""排序权重(越大越靠前)"""
# storage_pack 专用
size: int | None = Field(default=None, ge=0)
"""容量大小字节type=storage_pack 时必填"""
duration_days: int | None = Field(default=None, ge=1)
"""有效天数type=storage_pack/group_time 时必填"""
# group_time 专用
group_id: UUID | None = None
"""目标用户组UUIDtype=group_time 时必填"""
# score 专用
score_amount: int | None = Field(default=None, ge=1)
"""积分数量type=score 时必填"""
class ProductUpdateRequest(SQLModelBase):
"""更新商品请求 DTO所有字段可选"""
name: Str255 | None = None
"""商品名称"""
description: str | None = None
"""商品描述"""
price: Decimal | None = Field(default=None, ge=0, decimal_places=2)
"""商品价格(元)"""
is_active: bool | None = None
"""是否上架"""
sort_order: int | None = Field(default=None, ge=0)
"""排序权重"""
size: int | None = Field(default=None, ge=0)
"""容量大小(字节)"""
duration_days: int | None = Field(default=None, ge=1)
"""有效天数"""
group_id: UUID | None = None
"""目标用户组UUID"""
score_amount: int | None = Field(default=None, ge=1)
"""积分数量"""
class ProductResponse(ProductBase):
"""商品响应 DTO"""
id: UUID
"""商品UUID"""
price: float
"""商品价格(元)"""
is_active: bool
"""是否上架"""
sort_order: int
"""排序权重"""
size: int | None = None
"""容量大小(字节)"""
duration_days: int | None = None
"""有效天数"""
group_id: UUID | None = None
"""目标用户组UUID"""
score_amount: int | None = None
"""积分数量"""
# ==================== 数据库模型 ====================
class Product(ProductBase, UUIDTableBaseMixin):
"""商品模型"""
name: Str255
"""商品名称"""
price: Decimal = Field(sa_type=Numeric(12, 2), default=Decimal("0.00"))
"""商品价格(元)"""
is_active: bool = Field(default=True, sa_column_kwargs={"server_default": text("true")})
"""是否上架"""
sort_order: int = Field(default=0, sa_column_kwargs={"server_default": "0"})
"""排序权重(越大越靠前)"""
# storage_pack 专用
size: int | None = Field(default=None, sa_type=BigInteger)
"""容量大小字节type=storage_pack 时必填"""
duration_days: int | None = None
"""有效天数type=storage_pack/group_time 时必填"""
# group_time 专用
group_id: UUID | None = Field(default=None, foreign_key="group.id", ondelete="SET NULL")
"""目标用户组UUIDtype=group_time 时必填"""
# score 专用
score_amount: int | None = None
"""积分数量type=score 时必填"""
# 关系
orders: list["Order"] = Relationship(back_populates="product")
"""关联的订单列表"""
redeems: list["Redeem"] = Relationship(back_populates="product")
"""关联的兑换码列表"""
def to_response(self) -> ProductResponse:
"""转换为响应 DTO"""
return ProductResponse(
id=self.id,
name=self.name,
type=self.type,
description=self.description,
price=float(self.price),
is_active=self.is_active,
sort_order=self.sort_order,
size=self.size,
duration_days=self.duration_days,
group_id=self.group_id,
score_amount=self.score_amount,
)

View File

@@ -1,22 +1,141 @@
from datetime import datetime
from enum import StrEnum from enum import StrEnum
from typing import TYPE_CHECKING
from uuid import UUID
from sqlmodel import Field, text from sqlmodel import Field, Relationship, text
from sqlmodel_ext import SQLModelBase, TableBaseMixin from sqlmodel_ext import SQLModelBase, TableBaseMixin
if TYPE_CHECKING:
from .product import Product
from .user import User
class RedeemType(StrEnum): class RedeemType(StrEnum):
"""兑换码类型枚举""" """兑换码类型枚举"""
# [TODO] 补充具体兑换码类型
pass
STORAGE_PACK = "storage_pack"
"""容量包"""
GROUP_TIME = "group_time"
"""用户组时长"""
SCORE = "score"
"""积分充值"""
# ==================== DTO 模型 ====================
class RedeemCreateRequest(SQLModelBase):
"""批量生成兑换码请求 DTO"""
product_id: UUID
"""关联商品UUID"""
count: int = Field(default=1, ge=1, le=100)
"""生成数量"""
class RedeemUseRequest(SQLModelBase):
"""使用兑换码请求 DTO"""
code: str
"""兑换码"""
class RedeemInfoResponse(SQLModelBase):
"""兑换码信息响应 DTO用户侧"""
type: RedeemType
"""兑换码类型"""
product_name: str | None = None
"""关联商品名称"""
num: int
"""可兑换数量"""
is_used: bool
"""是否已使用"""
class RedeemAdminResponse(SQLModelBase):
"""兑换码管理响应 DTO管理侧"""
id: int
"""兑换码ID"""
type: RedeemType
"""兑换码类型"""
product_id: UUID | None = None
"""关联商品UUID"""
num: int
"""可兑换数量"""
code: str
"""兑换码"""
is_used: bool
"""是否已使用"""
used_at: datetime | None = None
"""使用时间"""
used_by: UUID | None = None
"""使用者UUID"""
# ==================== 数据库模型 ====================
class Redeem(SQLModelBase, TableBaseMixin): class Redeem(SQLModelBase, TableBaseMixin):
"""兑换码模型""" """兑换码模型"""
type: int = Field(default=0, sa_column_kwargs={"server_default": "0"}) type: RedeemType
"""兑换码类型 [TODO] 待定义枚举""" """兑换码类型"""
product_id: int | None = Field(default=None, description="关联的商品/权益ID")
num: int = Field(default=1, sa_column_kwargs={"server_default": "1"}, description="可兑换数量/时长等") product_id: UUID | None = Field(default=None, foreign_key="product.id", ondelete="SET NULL")
code: str = Field(unique=True, index=True, description="兑换码,唯一") """关联商品UUID"""
used: bool = Field(default=False, sa_column_kwargs={"server_default": text("false")}, description="是否已使用")
num: int = Field(default=1, sa_column_kwargs={"server_default": "1"})
"""可兑换数量/时长等"""
code: str = Field(unique=True, index=True)
"""兑换码,唯一"""
is_used: bool = Field(default=False, sa_column_kwargs={"server_default": text("false")})
"""是否已使用"""
used_at: datetime | None = None
"""使用时间"""
used_by: UUID | None = Field(default=None, foreign_key="user.id", ondelete="SET NULL")
"""使用者UUID"""
# 关系
product: "Product" = Relationship(back_populates="redeems")
user: "User" = Relationship(back_populates="redeems")
def to_admin_response(self) -> RedeemAdminResponse:
"""转换为管理侧响应 DTO"""
return RedeemAdminResponse(
id=self.id,
type=self.type,
product_id=self.product_id,
num=self.num,
code=self.code,
is_used=self.is_used,
used_at=self.used_at,
used_by=self.used_by,
)
def to_info_response(self, product_name: str | None = None) -> RedeemInfoResponse:
"""转换为用户侧响应 DTO"""
return RedeemInfoResponse(
type=self.type,
product_name=product_name,
num=self.num,
is_used=self.is_used,
)

View File

@@ -4,7 +4,7 @@ from uuid import UUID
from sqlmodel import Field, Relationship from sqlmodel import Field, Relationship
from sqlmodel_ext import SQLModelBase, TableBaseMixin from sqlmodel_ext import SQLModelBase, TableBaseMixin, Str255
if TYPE_CHECKING: if TYPE_CHECKING:
from .share import Share from .share import Share
@@ -21,7 +21,7 @@ class Report(SQLModelBase, TableBaseMixin):
reason: int = Field(default=0, sa_column_kwargs={"server_default": "0"}) reason: int = Field(default=0, sa_column_kwargs={"server_default": "0"})
"""举报原因 [TODO] 待定义枚举""" """举报原因 [TODO] 待定义枚举"""
description: str | None = Field(default=None, max_length=255, description="补充描述") description: Str255 | None = Field(default=None, description="补充描述")
# 外键 # 外键
share_id: UUID = Field( share_id: UUID = Field(

View File

@@ -76,6 +76,9 @@ class SiteConfigResponse(SQLModelBase):
email_binding_required: bool = True email_binding_required: bool = True
"""是否强制绑定邮箱""" """是否强制绑定邮箱"""
avatar_max_size: int = 2097152
"""头像文件最大字节数(默认 2MB"""
footer_code: str | None = None footer_code: str | None = None
"""自定义页脚代码""" """自定义页脚代码"""
@@ -160,6 +163,7 @@ class SettingsType(StrEnum):
VERSION = "version" VERSION = "version"
VIEW = "view" VIEW = "view"
WOPI = "wopi" WOPI = "wopi"
FILE_CATEGORY = "file_category"
# 数据库模型 # 数据库模型
class Setting(SettingItem, TableBaseMixin): class Setting(SettingItem, TableBaseMixin):

View File

@@ -5,7 +5,7 @@ from uuid import UUID
from sqlmodel import Field, Relationship, text, UniqueConstraint, Index from sqlmodel import Field, Relationship, text, UniqueConstraint, Index
from sqlmodel_ext import SQLModelBase, UUIDTableBaseMixin from sqlmodel_ext import SQLModelBase, UUIDTableBaseMixin, Str64, Str255
from .model_base import ResponseBase from .model_base import ResponseBase
from .object import ObjectType from .object import ObjectType
@@ -52,10 +52,10 @@ class Share(SQLModelBase, UUIDTableBaseMixin):
Index("ix_share_object", "object_id"), Index("ix_share_object", "object_id"),
) )
code: str = Field(max_length=64, nullable=False, index=True) code: Str64 = Field(nullable=False, index=True)
"""分享码""" """分享码"""
password: str | None = Field(default=None, max_length=255) password: Str255 | None = None
"""分享密码(加密后)""" """分享密码(加密后)"""
object_id: UUID = Field( object_id: UUID = Field(
@@ -80,7 +80,7 @@ class Share(SQLModelBase, UUIDTableBaseMixin):
preview_enabled: bool = Field(default=True, sa_column_kwargs={"server_default": text("true")}) preview_enabled: bool = Field(default=True, sa_column_kwargs={"server_default": text("true")})
"""是否允许预览""" """是否允许预览"""
source_name: str | None = Field(default=None, max_length=255) source_name: Str255 | None = None
"""源名称(冗余字段,便于展示)""" """源名称(冗余字段,便于展示)"""
score: int = Field(default=0, ge=0) score: int = Field(default=0, ge=0)

View File

@@ -4,7 +4,7 @@ from uuid import UUID
from sqlmodel import Field, Relationship, Index from sqlmodel import Field, Relationship, Index
from sqlmodel_ext import SQLModelBase, TableBaseMixin from sqlmodel_ext import SQLModelBase, TableBaseMixin, Str255
if TYPE_CHECKING: if TYPE_CHECKING:
from .object import Object from .object import Object
@@ -17,7 +17,7 @@ class SourceLink(SQLModelBase, TableBaseMixin):
Index("ix_sourcelink_object_name", "object_id", "name"), Index("ix_sourcelink_object_name", "object_id", "name"),
) )
name: str = Field(max_length=255) name: Str255
"""链接名称""" """链接名称"""
downloads: int = Field(default=0, sa_column_kwargs={"server_default": "0"}) downloads: int = Field(default=0, sa_column_kwargs={"server_default": "0"})

View File

@@ -1,23 +1,60 @@
from typing import TYPE_CHECKING
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING
from uuid import UUID from uuid import UUID
from sqlmodel import Field, Relationship, Column, func, DateTime from sqlalchemy import BigInteger
from sqlmodel import Field, Relationship
from sqlmodel_ext import SQLModelBase, TableBaseMixin from sqlmodel_ext import SQLModelBase, TableBaseMixin, Str255
if TYPE_CHECKING: if TYPE_CHECKING:
from .user import User from .user import User
# ==================== DTO 模型 ====================
class StoragePackResponse(SQLModelBase):
"""容量包响应 DTO"""
id: int
"""容量包ID"""
name: str
"""容量包名称"""
size: int
"""容量大小(字节)"""
active_time: datetime | None = None
"""激活时间"""
expired_time: datetime | None = None
"""过期时间"""
product_id: UUID | None = None
"""来源商品UUID"""
# ==================== 数据库模型 ====================
class StoragePack(SQLModelBase, TableBaseMixin): class StoragePack(SQLModelBase, TableBaseMixin):
"""容量包模型""" """容量包模型"""
name: str = Field(max_length=255, description="容量包名称") name: Str255
active_time: datetime | None = Field(default=None, description="激活时间") """容量包名称"""
expired_time: datetime | None = Field(default=None, index=True, description="过期时间")
size: int = Field(description="容量包大小(字节)") active_time: datetime | None = None
"""激活时间"""
expired_time: datetime | None = Field(default=None, index=True)
"""过期时间"""
size: int = Field(sa_type=BigInteger)
"""容量包大小(字节)"""
product_id: UUID | None = Field(default=None, foreign_key="product.id", ondelete="SET NULL")
"""来源商品UUID"""
# 外键 # 外键
user_id: UUID = Field( user_id: UUID = Field(
foreign_key="user.id", foreign_key="user.id",
@@ -25,6 +62,17 @@ class StoragePack(SQLModelBase, TableBaseMixin):
ondelete="CASCADE" ondelete="CASCADE"
) )
"""所属用户UUID""" """所属用户UUID"""
# 关系 # 关系
user: "User" = Relationship(back_populates="storage_packs") user: "User" = Relationship(back_populates="storage_packs")
def to_response(self) -> StoragePackResponse:
"""转换为响应 DTO"""
return StoragePackResponse(
id=self.id,
name=self.name,
size=self.size,
active_time=self.active_time,
expired_time=self.expired_time,
product_id=self.product_id,
)

View File

@@ -5,7 +5,7 @@ from datetime import datetime
from sqlmodel import Field, Relationship, UniqueConstraint, Column, func, DateTime from sqlmodel import Field, Relationship, UniqueConstraint, Column, func, DateTime
from sqlmodel_ext import SQLModelBase, TableBaseMixin from sqlmodel_ext import SQLModelBase, TableBaseMixin, Str255
if TYPE_CHECKING: if TYPE_CHECKING:
from .user import User from .user import User
@@ -24,13 +24,13 @@ class Tag(SQLModelBase, TableBaseMixin):
__table_args__ = (UniqueConstraint("name", "user_id", name="uq_tag_name_user"),) __table_args__ = (UniqueConstraint("name", "user_id", name="uq_tag_name_user"),)
name: str = Field(max_length=255) name: Str255
"""标签名称""" """标签名称"""
icon: str | None = Field(default=None, max_length=255) icon: Str255 | None = None
"""标签图标""" """标签图标"""
color: str | None = Field(default=None, max_length=255) color: Str255 | None = None
"""标签颜色""" """标签颜色"""
type: TagType = Field(default=TagType.MANUAL) type: TagType = Field(default=TagType.MANUAL)

View File

@@ -3,7 +3,7 @@ from uuid import UUID
from sqlmodel import Field from sqlmodel import Field
from sqlmodel_ext import SQLModelBase, UUIDTableBaseMixin from sqlmodel_ext import SQLModelBase, UUIDTableBaseMixin, Str100
from .color import ChromaticColor, NeutralColor, ThemeColorsBase from .color import ChromaticColor, NeutralColor, ThemeColorsBase
@@ -11,7 +11,7 @@ from .color import ChromaticColor, NeutralColor, ThemeColorsBase
class ThemePresetBase(SQLModelBase): class ThemePresetBase(SQLModelBase):
"""主题预设基础字段""" """主题预设基础字段"""
name: str = Field(max_length=100) name: Str100
"""预设名称""" """预设名称"""
is_default: bool = False is_default: bool = False
@@ -42,7 +42,7 @@ class ThemePresetBase(SQLModelBase):
class ThemePreset(ThemePresetBase, UUIDTableBaseMixin): class ThemePreset(ThemePresetBase, UUIDTableBaseMixin):
"""主题预设表""" """主题预设表"""
name: str = Field(max_length=100, unique=True) name: Str100 = Field(unique=True)
"""预设名称(唯一约束)""" """预设名称(唯一约束)"""
@@ -51,7 +51,7 @@ class ThemePreset(ThemePresetBase, UUIDTableBaseMixin):
class ThemePresetCreateRequest(SQLModelBase): class ThemePresetCreateRequest(SQLModelBase):
"""创建主题预设请求 DTO""" """创建主题预设请求 DTO"""
name: str = Field(max_length=100) name: Str100
"""预设名称""" """预设名称"""
colors: ThemeColorsBase colors: ThemeColorsBase
@@ -61,7 +61,7 @@ class ThemePresetCreateRequest(SQLModelBase):
class ThemePresetUpdateRequest(SQLModelBase): class ThemePresetUpdateRequest(SQLModelBase):
"""更新主题预设请求 DTO""" """更新主题预设请求 DTO"""
name: str | None = Field(default=None, max_length=100) name: Str100 | None = None
"""预设名称(可选)""" """预设名称(可选)"""
colors: ThemeColorsBase | None = None colors: ThemeColorsBase | None = None

View File

@@ -9,7 +9,7 @@ from sqlmodel import Field, Relationship
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from sqlmodel.main import RelationshipInfo from sqlmodel.main import RelationshipInfo
from sqlmodel_ext import SQLModelBase, UUIDTableBaseMixin, TableViewRequest, ListResponse from sqlmodel_ext import SQLModelBase, UUIDTableBaseMixin, TableViewRequest, ListResponse, Str255
from .auth_identity import AuthProviderType from .auth_identity import AuthProviderType
from .color import ChromaticColor, NeutralColor, ThemeColorsBase from .color import ChromaticColor, NeutralColor, ThemeColorsBase
@@ -23,6 +23,7 @@ if TYPE_CHECKING:
from .download import Download from .download import Download
from .object import Object from .object import Object
from .order import Order from .order import Order
from .redeem import Redeem
from .share import Share from .share import Share
from .storage_pack import StoragePack from .storage_pack import StoragePack
from .tag import Tag from .tag import Tag
@@ -476,7 +477,7 @@ class User(UserBase, UUIDTableBaseMixin):
storage: int = Field(default=0, sa_type=BigInteger, sa_column_kwargs={"server_default": "0"}, ge=0) storage: int = Field(default=0, sa_type=BigInteger, sa_column_kwargs={"server_default": "0"}, ge=0)
"""已用存储空间(字节)""" """已用存储空间(字节)"""
avatar: str = Field(default="default", max_length=255) avatar: Str255 = Field(default="default")
"""头像地址""" """头像地址"""
score: int = Field(default=0, sa_column_kwargs={"server_default": "0"}, ge=0) score: int = Field(default=0, sa_column_kwargs={"server_default": "0"}, ge=0)
@@ -570,6 +571,14 @@ class User(UserBase, UUIDTableBaseMixin):
back_populates="user", back_populates="user",
sa_relationship_kwargs={"cascade": "all, delete-orphan"} sa_relationship_kwargs={"cascade": "all, delete-orphan"}
) )
redeems: list["Redeem"] = Relationship(
back_populates="user",
sa_relationship_kwargs={
"cascade": "all, delete-orphan",
"foreign_keys": "[Redeem.used_by]"
}
)
"""用户使用过的兑换码列表"""
shares: list["Share"] = Relationship( shares: list["Share"] = Relationship(
back_populates="user", back_populates="user",
sa_relationship_kwargs={"cascade": "all, delete-orphan"} sa_relationship_kwargs={"cascade": "all, delete-orphan"}

View File

@@ -5,7 +5,7 @@ from uuid import UUID
from sqlalchemy import Column, Text from sqlalchemy import Column, Text
from sqlmodel import Field, Relationship from sqlmodel import Field, Relationship
from sqlmodel_ext import SQLModelBase, TableBaseMixin from sqlmodel_ext import SQLModelBase, TableBaseMixin, Str32, Str100, Str255
if TYPE_CHECKING: if TYPE_CHECKING:
from .user import User from .user import User
@@ -51,7 +51,7 @@ class AuthnDetailResponse(SQLModelBase):
class AuthnRenameRequest(SQLModelBase): class AuthnRenameRequest(SQLModelBase):
"""WebAuthn 凭证重命名请求 DTO""" """WebAuthn 凭证重命名请求 DTO"""
name: str = Field(max_length=100) name: Str100
"""新的凭证名称""" """新的凭证名称"""
@@ -60,7 +60,7 @@ class AuthnRenameRequest(SQLModelBase):
class UserAuthn(SQLModelBase, TableBaseMixin): class UserAuthn(SQLModelBase, TableBaseMixin):
"""用户 WebAuthn 凭证模型,与 User 为多对一关系""" """用户 WebAuthn 凭证模型,与 User 为多对一关系"""
credential_id: str = Field(max_length=255, unique=True, index=True) credential_id: Str255 = Field(unique=True, index=True)
"""凭证 IDBase64URL 编码""" """凭证 IDBase64URL 编码"""
credential_public_key: str = Field(sa_column=Column(Text)) credential_public_key: str = Field(sa_column=Column(Text))
@@ -69,16 +69,16 @@ class UserAuthn(SQLModelBase, TableBaseMixin):
sign_count: int = Field(default=0, ge=0) sign_count: int = Field(default=0, ge=0)
"""签名计数器,用于防重放攻击""" """签名计数器,用于防重放攻击"""
credential_device_type: str = Field(max_length=32) credential_device_type: Str32
"""凭证设备类型:'single_device''multi_device'""" """凭证设备类型:'single_device''multi_device'"""
credential_backed_up: bool = Field(default=False) credential_backed_up: bool = Field(default=False)
"""凭证是否已备份""" """凭证是否已备份"""
transports: str | None = Field(default=None, max_length=255) transports: Str255 | None = None
"""支持的传输方式,逗号分隔,如 'usb,nfc,ble,internal'""" """支持的传输方式,逗号分隔,如 'usb,nfc,ble,internal'"""
name: str | None = Field(default=None, max_length=100) name: Str100 | None = None
"""用户自定义的凭证名称,便于识别""" """用户自定义的凭证名称,便于识别"""
# 外键 # 外键

View File

@@ -9,7 +9,7 @@ from uuid import UUID
from sqlmodel import Field, Relationship, UniqueConstraint from sqlmodel import Field, Relationship, UniqueConstraint
from sqlmodel_ext import SQLModelBase, TableBaseMixin from sqlmodel_ext import SQLModelBase, TableBaseMixin, Str255
if TYPE_CHECKING: if TYPE_CHECKING:
from .user import User from .user import User
@@ -20,7 +20,7 @@ if TYPE_CHECKING:
class WebDAVBase(SQLModelBase): class WebDAVBase(SQLModelBase):
"""WebDAV 账户基础字段""" """WebDAV 账户基础字段"""
name: str = Field(max_length=255) name: Str255
"""账户名称(同一用户下唯一)""" """账户名称(同一用户下唯一)"""
root: str = Field(default="/", sa_column_kwargs={"server_default": "'/'"}) root: str = Field(default="/", sa_column_kwargs={"server_default": "'/'"})
@@ -40,7 +40,7 @@ class WebDAV(WebDAVBase, TableBaseMixin):
__table_args__ = (UniqueConstraint("name", "user_id", name="uq_webdav_name_user"),) __table_args__ = (UniqueConstraint("name", "user_id", name="uq_webdav_name_user"),)
password: str = Field(max_length=255) password: Str255
"""密码Argon2 哈希)""" """密码Argon2 哈希)"""
# 外键 # 外键
@@ -60,10 +60,10 @@ class WebDAV(WebDAVBase, TableBaseMixin):
class WebDAVCreateRequest(SQLModelBase): class WebDAVCreateRequest(SQLModelBase):
"""创建 WebDAV 账户请求""" """创建 WebDAV 账户请求"""
name: str = Field(max_length=255) name: Str255
"""账户名称""" """账户名称"""
password: str = Field(min_length=1, max_length=255) password: Str255 = Field(min_length=1)
"""账户密码(明文,服务端哈希后存储)""" """账户密码(明文,服务端哈希后存储)"""
root: str = "/" root: str = "/"
@@ -79,7 +79,7 @@ class WebDAVCreateRequest(SQLModelBase):
class WebDAVUpdateRequest(SQLModelBase): class WebDAVUpdateRequest(SQLModelBase):
"""更新 WebDAV 账户请求""" """更新 WebDAV 账户请求"""
password: str | None = Field(default=None, min_length=1, max_length=255) password: Str255 | None = Field(default=None, min_length=1)
"""新密码(为 None 时不修改)""" """新密码(为 None 时不修改)"""
root: str | None = None root: str | None = None

View File

@@ -71,7 +71,7 @@ class UserFactory:
is_verified=True, is_verified=True,
user_id=user.id, user_id=user.id,
) )
await identity.save(session) identity = await identity.save(session)
return user return user
@@ -123,7 +123,7 @@ class UserFactory:
is_verified=True, is_verified=True,
user_id=admin.id, user_id=admin.id,
) )
await identity.save(session) identity = await identity.save(session)
return admin return admin
@@ -170,7 +170,7 @@ class UserFactory:
is_verified=True, is_verified=True,
user_id=banned_user.id, user_id=banned_user.id,
) )
await identity.save(session) identity = await identity.save(session)
return banned_user return banned_user
@@ -219,6 +219,6 @@ class UserFactory:
is_verified=True, is_verified=True,
user_id=user.id, user_id=user.id,
) )
await identity.save(session) identity = await identity.save(session)
return user return user

View File

@@ -23,6 +23,7 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../.
from main import app from main import app
from sqlmodels import Group, GroupClaims, GroupOptions, Object, ObjectType, Policy, PolicyType, Setting, SettingsType, User from sqlmodels import Group, GroupClaims, GroupOptions, Object, ObjectType, Policy, PolicyType, Setting, SettingsType, User
from sqlmodels.policy import GroupPolicyLink
from sqlmodels.auth_identity import AuthIdentity, AuthProviderType from sqlmodels.auth_identity import AuthIdentity, AuthProviderType
from sqlmodels.user import UserStatus from sqlmodels.user import UserStatus
from utils import Password from utils import Password
@@ -108,6 +109,12 @@ async def initialized_db(test_session: AsyncSession) -> AsyncSession:
Setting(type=SettingsType.AUTH, name="auth_email_binding_required", value="1"), Setting(type=SettingsType.AUTH, name="auth_email_binding_required", value="1"),
Setting(type=SettingsType.OAUTH, name="github_enabled", value="0"), Setting(type=SettingsType.OAUTH, name="github_enabled", value="0"),
Setting(type=SettingsType.OAUTH, name="qq_enabled", value="0"), Setting(type=SettingsType.OAUTH, name="qq_enabled", value="0"),
Setting(type=SettingsType.AVATAR, name="gravatar_server", value="https://www.gravatar.com/"),
Setting(type=SettingsType.AVATAR, name="avatar_size", value="2097152"),
Setting(type=SettingsType.AVATAR, name="avatar_size_l", value="200"),
Setting(type=SettingsType.AVATAR, name="avatar_size_m", value="130"),
Setting(type=SettingsType.AVATAR, name="avatar_size_s", value="50"),
Setting(type=SettingsType.PATH, name="avatar_path", value="avatar"),
] ]
for setting in settings: for setting in settings:
test_session.add(setting) test_session.add(setting)
@@ -156,7 +163,11 @@ async def initialized_db(test_session: AsyncSession) -> AsyncSession:
await test_session.refresh(admin_group) await test_session.refresh(admin_group)
await test_session.refresh(default_policy) await test_session.refresh(default_policy)
# 4. 创建用户组选项 # 4. 关联用户组与存储策略
test_session.add(GroupPolicyLink(group_id=default_group.id, policy_id=default_policy.id))
test_session.add(GroupPolicyLink(group_id=admin_group.id, policy_id=default_policy.id))
# 5. 创建用户组选项
default_group_options = GroupOptions( default_group_options = GroupOptions(
group_id=default_group.id, group_id=default_group.id,
share_download=True, share_download=True,

250
uv.lock generated
View File

@@ -238,11 +238,11 @@ wheels = [
[[package]] [[package]]
name = "cachetools" name = "cachetools"
version = "7.0.1" version = "7.0.4"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d4/07/56595285564e90777d758ebd383d6b0b971b87729bbe2184a849932a3736/cachetools-7.0.1.tar.gz", hash = "sha256:e31e579d2c5b6e2944177a0397150d312888ddf4e16e12f1016068f0c03b8341", size = 36126, upload-time = "2026-02-10T22:24:05.03Z" } sdist = { url = "https://files.pythonhosted.org/packages/a1/cc/eb3fd22f3b96b8b70ce456d0854ef08434e5ca79c02bf8db3fc07ccfca87/cachetools-7.0.4.tar.gz", hash = "sha256:7042c0e4eea87812f04744ce6ee9ed3de457875eb1f82d8a206c46d6e48b6734", size = 37379, upload-time = "2026-03-08T21:37:17.133Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/ed/9e/5faefbf9db1db466d633735faceda1f94aa99ce506ac450d232536266b32/cachetools-7.0.1-py3-none-any.whl", hash = "sha256:8f086515c254d5664ae2146d14fc7f65c9a4bce75152eb247e5a9c5e6d7b2ecf", size = 13484, upload-time = "2026-02-10T22:24:03.741Z" }, { url = "https://files.pythonhosted.org/packages/83/bc/72adfb3f2ed19eb0317f89ea9b1eeccc670ae46bc394ec2c4ba1dd8c22b7/cachetools-7.0.4-py3-none-any.whl", hash = "sha256:0c8bb1b9ec8194fa4d764accfde602dfe52f70d0f311e62792d4c3f8c051b1e9", size = 13900, upload-time = "2026-03-08T21:37:15.805Z" },
] ]
[[package]] [[package]]
@@ -282,11 +282,11 @@ wheels = [
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2026.1.4" version = "2026.2.25"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
] ]
[[package]] [[package]]
@@ -625,7 +625,7 @@ wheels = [
[[package]] [[package]]
name = "fastapi" name = "fastapi"
version = "0.132.0" version = "0.135.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "annotated-doc" }, { name = "annotated-doc" },
@@ -634,9 +634,9 @@ dependencies = [
{ name = "typing-extensions" }, { name = "typing-extensions" },
{ name = "typing-inspection" }, { name = "typing-inspection" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/a0/55/f1b4d4e478a0a1b4b1113d0f610a1b08e539b69900f97fdc97155d62fdee/fastapi-0.132.0.tar.gz", hash = "sha256:ef687847936d8a57ea6ea04cf9a85fe5f2c6ba64e22bfa721467094b69d48d92", size = 372422, upload-time = "2026-02-23T17:56:22.218Z" } sdist = { url = "https://files.pythonhosted.org/packages/e7/7b/f8e0211e9380f7195ba3f3d40c292594fd81ba8ec4629e3854c353aaca45/fastapi-0.135.1.tar.gz", hash = "sha256:d04115b508d936d254cea545b7312ecaa58a7b3a0f84952535b4c9afae7668cd", size = 394962, upload-time = "2026-03-01T18:18:29.369Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/de/6171c3363bbc5e01686e200e0880647c9270daa476d91030435cf14d32f5/fastapi-0.132.0-py3-none-any.whl", hash = "sha256:3c487d5afce196fa8ea509ae1531e96ccd5cdd2fd6eae78b73e2c20fba706689", size = 104652, upload-time = "2026-02-23T17:56:20.836Z" }, { url = "https://files.pythonhosted.org/packages/e4/72/42e900510195b23a56bde950d26a51f8b723846bfcaa0286e90287f0422b/fastapi-0.135.1-py3-none-any.whl", hash = "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e", size = 116999, upload-time = "2026-03-01T18:18:30.831Z" },
] ]
[package.optional-dependencies] [package.optional-dependencies]
@@ -653,16 +653,16 @@ standard = [
[[package]] [[package]]
name = "fastapi-cli" name = "fastapi-cli"
version = "0.0.23" version = "0.0.24"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "rich-toolkit" }, { name = "rich-toolkit" },
{ name = "typer" }, { name = "typer" },
{ name = "uvicorn", extra = ["standard"] }, { name = "uvicorn", extra = ["standard"] },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/71/9f/cbd463e57de4e977b8ea0403f95347f9150441568b1d3fe3e4949ef80ef3/fastapi_cli-0.0.23.tar.gz", hash = "sha256:210ac280ea41e73aac5a57688781256beb23c2cba3a41266896fa43e6445c8e7", size = 19763, upload-time = "2026-02-16T19:45:53.358Z" } sdist = { url = "https://files.pythonhosted.org/packages/6e/58/74797ae9e4610cfa0c6b34c8309096d3b20bb29be3b8b5fbf1004d10fa5f/fastapi_cli-0.0.24.tar.gz", hash = "sha256:1afc9c9e21d7ebc8a3ca5e31790cd8d837742be7e4f8b9236e99cb3451f0de00", size = 19043, upload-time = "2026-02-24T10:45:10.476Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/68/89/19dcfd5cd289b306abdcabac68b88a4f54b7710a2c33adc16a337ecdcdfa/fastapi_cli-0.0.23-py3-none-any.whl", hash = "sha256:7e9634fc212da0b6cfc75bd3ac366cc9dfdb43b5e9ec12e58bfd1acdd2697f25", size = 12305, upload-time = "2026-02-16T19:45:52.554Z" }, { url = "https://files.pythonhosted.org/packages/c7/4b/68f9fe268e535d79c76910519530026a4f994ce07189ac0dded45c6af825/fastapi_cli-0.0.24-py3-none-any.whl", hash = "sha256:4a1f78ed798f106b4fee85ca93b85d8fe33c0a3570f775964d37edb80b8f0edc", size = 12304, upload-time = "2026-02-24T10:45:09.552Z" },
] ]
[package.optional-dependencies] [package.optional-dependencies]
@@ -673,7 +673,7 @@ standard = [
[[package]] [[package]]
name = "fastapi-cloud-cli" name = "fastapi-cloud-cli"
version = "0.13.0" version = "0.14.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "fastar" }, { name = "fastar" },
@@ -685,9 +685,9 @@ dependencies = [
{ name = "typer" }, { name = "typer" },
{ name = "uvicorn", extra = ["standard"] }, { name = "uvicorn", extra = ["standard"] },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/de/0b/f07f4976784978ef159fd2e8f5c16f1f9d610578fb1fd976ff1315c11ea6/fastapi_cloud_cli-0.13.0.tar.gz", hash = "sha256:4d8f42337e8021c648f6cb0672de7d5b31b0fc7387a83d7b12f974600ac3f2fd", size = 38436, upload-time = "2026-02-17T05:18:19.033Z" } sdist = { url = "https://files.pythonhosted.org/packages/74/30/1665ad6bd1c285d1c6947e6ab0eae168bc44a9b45d5fc11fa930603db1c7/fastapi_cloud_cli-0.14.1.tar.gz", hash = "sha256:5b086182570008f67d9ae989870102f595e4e216493cabcd270ef6cd0401339f", size = 39999, upload-time = "2026-03-08T01:40:24.166Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/b4/88/71a1e989d17b9edb483f32e28b7891ffdd3005271518c98ba6415987c430/fastapi_cloud_cli-0.13.0-py3-none-any.whl", hash = "sha256:874a9ed8dba34ec828f198c72de9f9a38de77ac1b15083d6bc3a4d772b0bc477", size = 27631, upload-time = "2026-02-17T05:18:18.094Z" }, { url = "https://files.pythonhosted.org/packages/b6/c2/0117d2a1b93eb7c6d2084e6be320d34a404f621eb01a26c1471c0eb4ee82/fastapi_cloud_cli-0.14.1-py3-none-any.whl", hash = "sha256:99ab3a2fbd1880121a62fb9c4f584e15c42ef0fe762cb5f7380a548081e60d05", size = 28359, upload-time = "2026-03-08T01:40:24.949Z" },
] ]
[[package]] [[package]]
@@ -1630,11 +1630,11 @@ wheels = [
[[package]] [[package]]
name = "python-dotenv" name = "python-dotenv"
version = "1.2.1" version = "1.2.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
] ]
[[package]] [[package]]
@@ -1684,11 +1684,11 @@ wheels = [
[[package]] [[package]]
name = "redis" name = "redis"
version = "7.2.0" version = "7.3.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9f/32/6fac13a11e73e1bc67a2ae821a72bfe4c2d8c4c48f0267e4a952be0f1bae/redis-7.2.0.tar.gz", hash = "sha256:4dd5bf4bd4ae80510267f14185a15cba2a38666b941aff68cccf0256b51c1f26", size = 4901247, upload-time = "2026-02-16T17:16:22.797Z" } sdist = { url = "https://files.pythonhosted.org/packages/da/82/4d1a5279f6c1251d3d2a603a798a1137c657de9b12cfc1fba4858232c4d2/redis-7.3.0.tar.gz", hash = "sha256:4d1b768aafcf41b01022410b3cc4f15a07d9b3d6fe0c66fc967da2c88e551034", size = 4928081, upload-time = "2026-03-06T18:18:16.287Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/86/cf/f6180b67f99688d83e15c84c5beda831d1d341e95872d224f87ccafafe61/redis-7.2.0-py3-none-any.whl", hash = "sha256:01f591f8598e483f1842d429e8ae3a820804566f1c73dca1b80e23af9fba0497", size = 394898, upload-time = "2026-02-16T17:16:20.693Z" }, { url = "https://files.pythonhosted.org/packages/f0/28/84e57fce7819e81ec5aa1bd31c42b89607241f4fb1a3ea5b0d2dbeaea26c/redis-7.3.0-py3-none-any.whl", hash = "sha256:9d4fcb002a12a5e3c3fbe005d59c48a2cc231f87fbb2f6b70c2d89bb64fec364", size = 404379, upload-time = "2026-03-06T18:18:14.583Z" },
] ]
[package.optional-dependencies] [package.optional-dependencies]
@@ -1711,16 +1711,16 @@ wheels = [
[[package]] [[package]]
name = "rich-toolkit" name = "rich-toolkit"
version = "0.19.4" version = "0.19.7"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },
{ name = "rich" }, { name = "rich" },
{ name = "typing-extensions" }, { name = "typing-extensions" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/d0/c9/4bbf4bfee195ed1b7d7a6733cc523ca61dbfb4a3e3c12ea090aaffd97597/rich_toolkit-0.19.4.tar.gz", hash = "sha256:52e23d56f9dc30d1343eb3b3f6f18764c313fbfea24e52e6a1d6069bec9c18eb", size = 193951, upload-time = "2026-02-12T10:08:15.814Z" } sdist = { url = "https://files.pythonhosted.org/packages/42/ba/dae9e3096651042754da419a4042bc1c75e07d615f9b15066d738838e4df/rich_toolkit-0.19.7.tar.gz", hash = "sha256:133c0915872da91d4c25d85342d5ec1dfacc69b63448af1a08a0d4b4f23ef46e", size = 195877, upload-time = "2026-02-24T16:06:20.555Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/28/31/97d39719def09c134385bfcfbedfed255168b571e7beb3ad7765aae660ca/rich_toolkit-0.19.4-py3-none-any.whl", hash = "sha256:34ac344de8862801644be8b703e26becf44b047e687f208d7829e8f7cfc311d6", size = 32757, upload-time = "2026-02-12T10:08:15.037Z" }, { url = "https://files.pythonhosted.org/packages/fb/3c/c923619f6d2f5fafcc96fec0aaf9550a46cd5b6481f06e0c6b66a2a4fed0/rich_toolkit-0.19.7-py3-none-any.whl", hash = "sha256:0288e9203728c47c5a4eb60fd2f0692d9df7455a65901ab6f898437a2ba5989d", size = 32963, upload-time = "2026-02-24T16:06:22.066Z" },
] ]
[[package]] [[package]]
@@ -1778,15 +1778,15 @@ wheels = [
[[package]] [[package]]
name = "sentry-sdk" name = "sentry-sdk"
version = "2.53.0" version = "2.54.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "certifi" }, { name = "certifi" },
{ name = "urllib3" }, { name = "urllib3" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/d3/06/66c8b705179bc54087845f28fd1b72f83751b6e9a195628e2e9af9926505/sentry_sdk-2.53.0.tar.gz", hash = "sha256:6520ef2c4acd823f28efc55e43eb6ce2e6d9f954a95a3aa96b6fd14871e92b77", size = 412369, upload-time = "2026-02-16T11:11:14.743Z" } sdist = { url = "https://files.pythonhosted.org/packages/c8/e9/2e3a46c304e7fa21eaa70612f60354e32699c7102eb961f67448e222ad7c/sentry_sdk-2.54.0.tar.gz", hash = "sha256:2620c2575128d009b11b20f7feb81e4e4e8ae08ec1d36cbc845705060b45cc1b", size = 413813, upload-time = "2026-03-02T15:12:41.355Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/47/d4/2fdf854bc3b9c7f55219678f812600a20a138af2dd847d99004994eada8f/sentry_sdk-2.53.0-py2.py3-none-any.whl", hash = "sha256:46e1ed8d84355ae54406c924f6b290c3d61f4048625989a723fd622aab838899", size = 437908, upload-time = "2026-02-16T11:11:13.227Z" }, { url = "https://files.pythonhosted.org/packages/53/39/be412cc86bc6247b8f69e9383d7950711bd86f8d0a4a4b0fe8fad685bc21/sentry_sdk-2.54.0-py2.py3-none-any.whl", hash = "sha256:fd74e0e281dcda63afff095d23ebcd6e97006102cdc8e78a29f19ecdf796a0de", size = 439198, upload-time = "2026-03-02T15:12:39.546Z" },
] ]
[[package]] [[package]]
@@ -1809,37 +1809,41 @@ wheels = [
[[package]] [[package]]
name = "sqlalchemy" name = "sqlalchemy"
version = "2.0.46" version = "2.0.48"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
{ name = "typing-extensions" }, { name = "typing-extensions" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/06/aa/9ce0f3e7a9829ead5c8ce549392f33a12c4555a6c0609bb27d882e9c7ddf/sqlalchemy-2.0.46.tar.gz", hash = "sha256:cf36851ee7219c170bb0793dbc3da3e80c582e04a5437bc601bfe8c85c9216d7", size = 9865393, upload-time = "2026-01-21T18:03:45.119Z" } sdist = { url = "https://files.pythonhosted.org/packages/1f/73/b4a9737255583b5fa858e0bb8e116eb94b88c910164ed2ed719147bde3de/sqlalchemy-2.0.48.tar.gz", hash = "sha256:5ca74f37f3369b45e1f6b7b06afb182af1fd5dde009e4ffd831830d98cbe5fe7", size = 9886075, upload-time = "2026-03-02T15:28:51.474Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/4b/fa7838fe20bb752810feed60e45625a9a8b0102c0c09971e2d1d95362992/sqlalchemy-2.0.46-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:93a12da97cca70cea10d4b4fc602589c4511f96c1f8f6c11817620c021d21d00", size = 2150268, upload-time = "2026-01-21T19:05:56.621Z" }, { url = "https://files.pythonhosted.org/packages/d1/c6/569dc8bf3cd375abc5907e82235923e986799f301cd79a903f784b996fca/sqlalchemy-2.0.48-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e3070c03701037aa418b55d36532ecb8f8446ed0135acb71c678dbdf12f5b6e4", size = 2152599, upload-time = "2026-03-02T15:49:14.41Z" },
{ url = "https://files.pythonhosted.org/packages/46/c1/b34dccd712e8ea846edf396e00973dda82d598cb93762e55e43e6835eba9/sqlalchemy-2.0.46-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af865c18752d416798dae13f83f38927c52f085c52e2f32b8ab0fef46fdd02c2", size = 3276511, upload-time = "2026-01-21T18:46:49.022Z" }, { url = "https://files.pythonhosted.org/packages/6d/ff/f4e04a4bd5a24304f38cb0d4aa2ad4c0fb34999f8b884c656535e1b2b74c/sqlalchemy-2.0.48-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2645b7d8a738763b664a12a1542c89c940daa55196e8d73e55b169cc5c99f65f", size = 3278825, upload-time = "2026-03-02T15:50:38.269Z" },
{ url = "https://files.pythonhosted.org/packages/96/48/a04d9c94753e5d5d096c628c82a98c4793b9c08ca0e7155c3eb7d7db9f24/sqlalchemy-2.0.46-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8d679b5f318423eacb61f933a9a0f75535bfca7056daeadbf6bd5bcee6183aee", size = 3292881, upload-time = "2026-01-21T18:40:13.089Z" }, { url = "https://files.pythonhosted.org/packages/fe/88/cb59509e4668d8001818d7355d9995be90c321313078c912420603a7cb95/sqlalchemy-2.0.48-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b19151e76620a412c2ac1c6f977ab1b9fa7ad43140178345136456d5265b32ed", size = 3295200, upload-time = "2026-03-02T15:53:29.366Z" },
{ url = "https://files.pythonhosted.org/packages/be/f4/06eda6e91476f90a7d8058f74311cb65a2fb68d988171aced81707189131/sqlalchemy-2.0.46-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64901e08c33462acc9ec3bad27fc7a5c2b6491665f2aa57564e57a4f5d7c52ad", size = 3224559, upload-time = "2026-01-21T18:46:50.974Z" }, { url = "https://files.pythonhosted.org/packages/87/dc/1609a4442aefd750ea2f32629559394ec92e89ac1d621a7f462b70f736ff/sqlalchemy-2.0.48-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5b193a7e29fd9fa56e502920dca47dffe60f97c863494946bd698c6058a55658", size = 3226876, upload-time = "2026-03-02T15:50:39.802Z" },
{ url = "https://files.pythonhosted.org/packages/ab/a2/d2af04095412ca6345ac22b33b89fe8d6f32a481e613ffcb2377d931d8d0/sqlalchemy-2.0.46-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8ac45e8f4eaac0f9f8043ea0e224158855c6a4329fd4ee37c45c61e3beb518e", size = 3262728, upload-time = "2026-01-21T18:40:14.883Z" }, { url = "https://files.pythonhosted.org/packages/37/c3/6ae2ab5ea2fa989fbac4e674de01224b7a9d744becaf59bb967d62e99bed/sqlalchemy-2.0.48-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:36ac4ddc3d33e852da9cb00ffb08cea62ca05c39711dc67062ca2bb1fae35fd8", size = 3265045, upload-time = "2026-03-02T15:53:31.421Z" },
{ url = "https://files.pythonhosted.org/packages/31/48/1980c7caa5978a3b8225b4d230e69a2a6538a3562b8b31cea679b6933c83/sqlalchemy-2.0.46-cp313-cp313-win32.whl", hash = "sha256:8d3b44b3d0ab2f1319d71d9863d76eeb46766f8cf9e921ac293511804d39813f", size = 2111295, upload-time = "2026-01-21T18:42:52.366Z" }, { url = "https://files.pythonhosted.org/packages/6f/82/ea4665d1bb98c50c19666e672f21b81356bd6077c4574e3d2bbb84541f53/sqlalchemy-2.0.48-cp313-cp313-win32.whl", hash = "sha256:389b984139278f97757ea9b08993e7b9d1142912e046ab7d82b3fbaeb0209131", size = 2113700, upload-time = "2026-03-02T15:54:35.825Z" },
{ url = "https://files.pythonhosted.org/packages/2d/54/f8d65bbde3d877617c4720f3c9f60e99bb7266df0d5d78b6e25e7c149f35/sqlalchemy-2.0.46-cp313-cp313-win_amd64.whl", hash = "sha256:77f8071d8fbcbb2dd11b7fd40dedd04e8ebe2eb80497916efedba844298065ef", size = 2137076, upload-time = "2026-01-21T18:42:53.924Z" }, { url = "https://files.pythonhosted.org/packages/b7/2b/b9040bec58c58225f073f5b0c1870defe1940835549dafec680cbd58c3c3/sqlalchemy-2.0.48-cp313-cp313-win_amd64.whl", hash = "sha256:d612c976cbc2d17edfcc4c006874b764e85e990c29ce9bd411f926bbfb02b9a2", size = 2139487, upload-time = "2026-03-02T15:54:37.079Z" },
{ url = "https://files.pythonhosted.org/packages/56/ba/9be4f97c7eb2b9d5544f2624adfc2853e796ed51d2bb8aec90bc94b7137e/sqlalchemy-2.0.46-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1e8cc6cc01da346dc92d9509a63033b9b1bda4fed7a7a7807ed385c7dccdc10", size = 3556533, upload-time = "2026-01-21T18:33:06.636Z" }, { url = "https://files.pythonhosted.org/packages/f4/f4/7b17bd50244b78a49d22cc63c969d71dc4de54567dc152a9b46f6fae40ce/sqlalchemy-2.0.48-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69f5bc24904d3bc3640961cddd2523e361257ef68585d6e364166dfbe8c78fae", size = 3558851, upload-time = "2026-03-02T15:57:48.607Z" },
{ url = "https://files.pythonhosted.org/packages/20/a6/b1fc6634564dbb4415b7ed6419cdfeaadefd2c39cdab1e3aa07a5f2474c2/sqlalchemy-2.0.46-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:96c7cca1a4babaaf3bfff3e4e606e38578856917e52f0384635a95b226c87764", size = 3523208, upload-time = "2026-01-21T18:45:08.436Z" }, { url = "https://files.pythonhosted.org/packages/20/0d/213668e9aca61d370f7d2a6449ea4ec699747fac67d4bda1bb3d129025be/sqlalchemy-2.0.48-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd08b90d211c086181caed76931ecfa2bdfc83eea3cfccdb0f82abc6c4b876cb", size = 3525525, upload-time = "2026-03-02T16:04:38.058Z" },
{ url = "https://files.pythonhosted.org/packages/a1/d8/41e0bdfc0f930ff236f86fccd12962d8fa03713f17ed57332d38af6a3782/sqlalchemy-2.0.46-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b2a9f9aee38039cf4755891a1e50e1effcc42ea6ba053743f452c372c3152b1b", size = 3464292, upload-time = "2026-01-21T18:33:08.208Z" }, { url = "https://files.pythonhosted.org/packages/85/d7/a84edf412979e7d59c69b89a5871f90a49228360594680e667cb2c46a828/sqlalchemy-2.0.48-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1ccd42229aaac2df431562117ac7e667d702e8e44afdb6cf0e50fa3f18160f0b", size = 3466611, upload-time = "2026-03-02T15:57:50.759Z" },
{ url = "https://files.pythonhosted.org/packages/f0/8b/9dcbec62d95bea85f5ecad9b8d65b78cc30fb0ffceeb3597961f3712549b/sqlalchemy-2.0.46-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:db23b1bf8cfe1f7fda19018e7207b20cdb5168f83c437ff7e95d19e39289c447", size = 3473497, upload-time = "2026-01-21T18:45:10.552Z" }, { url = "https://files.pythonhosted.org/packages/86/55/42404ce5770f6be26a2b0607e7866c31b9a4176c819e9a7a5e0a055770be/sqlalchemy-2.0.48-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0dcbc588cd5b725162c076eb9119342f6579c7f7f55057bb7e3c6ff27e13121", size = 3475812, upload-time = "2026-03-02T16:04:40.092Z" },
{ url = "https://files.pythonhosted.org/packages/e9/f8/5ecdfc73383ec496de038ed1614de9e740a82db9ad67e6e4514ebc0708a3/sqlalchemy-2.0.46-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:56bdd261bfd0895452006d5316cbf35739c53b9bb71a170a331fa0ea560b2ada", size = 2152079, upload-time = "2026-01-21T19:05:58.477Z" }, { url = "https://files.pythonhosted.org/packages/ae/ae/29b87775fadc43e627cf582fe3bda4d02e300f6b8f2747c764950d13784c/sqlalchemy-2.0.48-cp313-cp313t-win32.whl", hash = "sha256:9764014ef5e58aab76220c5664abb5d47d5bc858d9debf821e55cfdd0f128485", size = 2141335, upload-time = "2026-03-02T15:52:51.518Z" },
{ url = "https://files.pythonhosted.org/packages/e5/bf/eba3036be7663ce4d9c050bc3d63794dc29fbe01691f2bf5ccb64e048d20/sqlalchemy-2.0.46-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33e462154edb9493f6c3ad2125931e273bbd0be8ae53f3ecd1c161ea9a1dd366", size = 3272216, upload-time = "2026-01-21T18:46:52.634Z" }, { url = "https://files.pythonhosted.org/packages/91/44/f39d063c90f2443e5b46ec4819abd3d8de653893aae92df42a5c4f5843de/sqlalchemy-2.0.48-cp313-cp313t-win_amd64.whl", hash = "sha256:e2f35b4cccd9ed286ad62e0a3c3ac21e06c02abc60e20aa51a3e305a30f5fa79", size = 2173095, upload-time = "2026-03-02T15:52:52.79Z" },
{ url = "https://files.pythonhosted.org/packages/05/45/1256fb597bb83b58a01ddb600c59fe6fdf0e5afe333f0456ed75c0f8d7bd/sqlalchemy-2.0.46-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bcdce05f056622a632f1d44bb47dbdb677f58cad393612280406ce37530eb6d", size = 3277208, upload-time = "2026-01-21T18:40:16.38Z" }, { url = "https://files.pythonhosted.org/packages/f7/b3/f437eaa1cf028bb3c927172c7272366393e73ccd104dcf5b6963f4ab5318/sqlalchemy-2.0.48-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e2d0d88686e3d35a76f3e15a34e8c12d73fc94c1dea1cd55782e695cc14086dd", size = 2154401, upload-time = "2026-03-02T15:49:17.24Z" },
{ url = "https://files.pythonhosted.org/packages/d9/a0/2053b39e4e63b5d7ceb3372cface0859a067c1ddbd575ea7e9985716f771/sqlalchemy-2.0.46-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e84b09a9b0f19accedcbeff5c2caf36e0dd537341a33aad8d680336152dc34e", size = 3221994, upload-time = "2026-01-21T18:46:54.622Z" }, { url = "https://files.pythonhosted.org/packages/6c/1c/b3abdf0f402aa3f60f0df6ea53d92a162b458fca2321d8f1f00278506402/sqlalchemy-2.0.48-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49b7bddc1eebf011ea5ab722fdbe67a401caa34a350d278cc7733c0e88fecb1f", size = 3274528, upload-time = "2026-03-02T15:50:41.489Z" },
{ url = "https://files.pythonhosted.org/packages/1e/87/97713497d9502553c68f105a1cb62786ba1ee91dea3852ae4067ed956a50/sqlalchemy-2.0.46-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4f52f7291a92381e9b4de9050b0a65ce5d6a763333406861e33906b8aa4906bf", size = 3243990, upload-time = "2026-01-21T18:40:18.253Z" }, { url = "https://files.pythonhosted.org/packages/f2/5e/327428a034407651a048f5e624361adf3f9fbac9d0fa98e981e9c6ff2f5e/sqlalchemy-2.0.48-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:426c5ca86415d9b8945c7073597e10de9644802e2ff502b8e1f11a7a2642856b", size = 3279523, upload-time = "2026-03-02T15:53:32.962Z" },
{ url = "https://files.pythonhosted.org/packages/a8/87/5d1b23548f420ff823c236f8bea36b1a997250fd2f892e44a3838ca424f4/sqlalchemy-2.0.46-cp314-cp314-win32.whl", hash = "sha256:70ed2830b169a9960193f4d4322d22be5c0925357d82cbf485b3369893350908", size = 2114215, upload-time = "2026-01-21T18:42:55.232Z" }, { url = "https://files.pythonhosted.org/packages/2a/ca/ece73c81a918add0965b76b868b7b5359e068380b90ef1656ee995940c02/sqlalchemy-2.0.48-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:288937433bd44e3990e7da2402fabc44a3c6c25d3704da066b85b89a85474ae0", size = 3224312, upload-time = "2026-03-02T15:50:42.996Z" },
{ url = "https://files.pythonhosted.org/packages/3a/20/555f39cbcf0c10cf452988b6a93c2a12495035f68b3dbd1a408531049d31/sqlalchemy-2.0.46-cp314-cp314-win_amd64.whl", hash = "sha256:3c32e993bc57be6d177f7d5d31edb93f30726d798ad86ff9066d75d9bf2e0b6b", size = 2139867, upload-time = "2026-01-21T18:42:56.474Z" }, { url = "https://files.pythonhosted.org/packages/88/11/fbaf1ae91fa4ee43f4fe79661cead6358644824419c26adb004941bdce7c/sqlalchemy-2.0.48-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8183dc57ae7d9edc1346e007e840a9f3d6aa7b7f165203a99e16f447150140d2", size = 3246304, upload-time = "2026-03-02T15:53:34.937Z" },
{ url = "https://files.pythonhosted.org/packages/3e/f0/f96c8057c982d9d8a7a68f45d69c674bc6f78cad401099692fe16521640a/sqlalchemy-2.0.46-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4dafb537740eef640c4d6a7c254611dca2df87eaf6d14d6a5fca9d1f4c3fc0fa", size = 3561202, upload-time = "2026-01-21T18:33:10.337Z" }, { url = "https://files.pythonhosted.org/packages/fa/a8/5fb0deb13930b4f2f698c5541ae076c18981173e27dd00376dbaea7a9c82/sqlalchemy-2.0.48-cp314-cp314-win32.whl", hash = "sha256:1182437cb2d97988cfea04cf6cdc0b0bb9c74f4d56ec3d08b81e23d621a28cc6", size = 2116565, upload-time = "2026-03-02T15:54:38.321Z" },
{ url = "https://files.pythonhosted.org/packages/d7/53/3b37dda0a5b137f21ef608d8dfc77b08477bab0fe2ac9d3e0a66eaeab6fc/sqlalchemy-2.0.46-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42a1643dc5427b69aca967dae540a90b0fbf57eaf248f13a90ea5930e0966863", size = 3526296, upload-time = "2026-01-21T18:45:12.657Z" }, { url = "https://files.pythonhosted.org/packages/95/7e/e83615cb63f80047f18e61e31e8e32257d39458426c23006deeaf48f463b/sqlalchemy-2.0.48-cp314-cp314-win_amd64.whl", hash = "sha256:144921da96c08feb9e2b052c5c5c1d0d151a292c6135623c6b2c041f2a45f9e0", size = 2142205, upload-time = "2026-03-02T15:54:39.831Z" },
{ url = "https://files.pythonhosted.org/packages/33/75/f28622ba6dde79cd545055ea7bd4062dc934e0621f7b3be2891f8563f8de/sqlalchemy-2.0.46-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ff33c6e6ad006bbc0f34f5faf941cfc62c45841c64c0a058ac38c799f15b5ede", size = 3470008, upload-time = "2026-01-21T18:33:11.725Z" }, { url = "https://files.pythonhosted.org/packages/83/e3/69d8711b3f2c5135e9cde5f063bc1605860f0b2c53086d40c04017eb1f77/sqlalchemy-2.0.48-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5aee45fd2c6c0f2b9cdddf48c48535e7471e42d6fb81adfde801da0bd5b93241", size = 3563519, upload-time = "2026-03-02T15:57:52.387Z" },
{ url = "https://files.pythonhosted.org/packages/a9/42/4afecbbc38d5e99b18acef446453c76eec6fbd03db0a457a12a056836e22/sqlalchemy-2.0.46-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:82ec52100ec1e6ec671563bbd02d7c7c8d0b9e71a0723c72f22ecf52d1755330", size = 3476137, upload-time = "2026-01-21T18:45:15.001Z" }, { url = "https://files.pythonhosted.org/packages/f8/4f/a7cce98facca73c149ea4578981594aaa5fd841e956834931de503359336/sqlalchemy-2.0.48-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7cddca31edf8b0653090cbb54562ca027c421c58ddde2c0685f49ff56a1690e0", size = 3528611, upload-time = "2026-03-02T16:04:42.097Z" },
{ url = "https://files.pythonhosted.org/packages/fc/a1/9c4efa03300926601c19c18582531b45aededfb961ab3c3585f1e24f120b/sqlalchemy-2.0.46-py3-none-any.whl", hash = "sha256:f9c11766e7e7c0a2767dda5acb006a118640c9fc0a4104214b96269bfb78399e", size = 1937882, upload-time = "2026-01-21T18:22:10.456Z" }, { url = "https://files.pythonhosted.org/packages/cd/7d/5936c7a03a0b0cb0fa0cc425998821c6029756b0855a8f7ee70fba1de955/sqlalchemy-2.0.48-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7a936f1bb23d370b7c8cc079d5fce4c7d18da87a33c6744e51a93b0f9e97e9b3", size = 3472326, upload-time = "2026-03-02T15:57:54.423Z" },
{ url = "https://files.pythonhosted.org/packages/f4/33/cea7dfc31b52904efe3dcdc169eb4514078887dff1f5ae28a7f4c5d54b3c/sqlalchemy-2.0.48-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e004aa9248e8cb0a5f9b96d003ca7c1c0a5da8decd1066e7b53f59eb8ce7c62b", size = 3478453, upload-time = "2026-03-02T16:04:44.584Z" },
{ url = "https://files.pythonhosted.org/packages/c8/95/32107c4d13be077a9cae61e9ae49966a35dc4bf442a8852dd871db31f62e/sqlalchemy-2.0.48-cp314-cp314t-win32.whl", hash = "sha256:b8438ec5594980d405251451c5b7ea9aa58dda38eb7ac35fb7e4c696712ee24f", size = 2147209, upload-time = "2026-03-02T15:52:54.274Z" },
{ url = "https://files.pythonhosted.org/packages/d2/d7/1e073da7a4bc645eb83c76067284a0374e643bc4be57f14cc6414656f92c/sqlalchemy-2.0.48-cp314-cp314t-win_amd64.whl", hash = "sha256:d854b3970067297f3a7fbd7a4683587134aa9b3877ee15aa29eea478dc68f933", size = 2182198, upload-time = "2026-03-02T15:52:55.606Z" },
{ url = "https://files.pythonhosted.org/packages/46/2c/9664130905f03db57961b8980b05cab624afd114bf2be2576628a9f22da4/sqlalchemy-2.0.48-py3-none-any.whl", hash = "sha256:a66fe406437dd65cacd96a72689a3aaaecaebbcd62d81c5ac1c0fdbeac835096", size = 1940202, upload-time = "2026-03-02T15:52:43.285Z" },
] ]
[[package]] [[package]]
@@ -2126,78 +2130,86 @@ wheels = [
[[package]] [[package]]
name = "yarl" name = "yarl"
version = "1.22.0" version = "1.23.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "idna" }, { name = "idna" },
{ name = "multidict" }, { name = "multidict" },
{ name = "propcache" }, { name = "propcache" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" } sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/ea/f3/d67de7260456ee105dc1d162d43a019ecad6b91e2f51809d6cddaa56690e/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53", size = 139980, upload-time = "2025-10-06T14:10:14.601Z" }, { url = "https://files.pythonhosted.org/packages/9a/4b/a0a6e5d0ee8a2f3a373ddef8a4097d74ac901ac363eea1440464ccbe0898/yarl-1.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:16c6994ac35c3e74fb0ae93323bf8b9c2a9088d55946109489667c510a7d010e", size = 123796, upload-time = "2026-03-01T22:05:41.412Z" },
{ url = "https://files.pythonhosted.org/packages/01/88/04d98af0b47e0ef42597b9b28863b9060bb515524da0a65d5f4db160b2d5/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a", size = 93424, upload-time = "2025-10-06T14:10:16.115Z" }, { url = "https://files.pythonhosted.org/packages/67/b6/8925d68af039b835ae876db5838e82e76ec87b9782ecc97e192b809c4831/yarl-1.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4a42e651629dafb64fd5b0286a3580613702b5809ad3f24934ea87595804f2c5", size = 86547, upload-time = "2026-03-01T22:05:42.841Z" },
{ url = "https://files.pythonhosted.org/packages/18/91/3274b215fd8442a03975ce6bee5fe6aa57a8326b29b9d3d56234a1dca244/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c", size = 93821, upload-time = "2025-10-06T14:10:17.993Z" }, { url = "https://files.pythonhosted.org/packages/ae/50/06d511cc4b8e0360d3c94af051a768e84b755c5eb031b12adaaab6dec6e5/yarl-1.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c6b9461a2a8b47c65eef63bb1c76a4f1c119618ffa99ea79bc5bb1e46c5821b", size = 85854, upload-time = "2026-03-01T22:05:44.85Z" },
{ url = "https://files.pythonhosted.org/packages/61/3a/caf4e25036db0f2da4ca22a353dfeb3c9d3c95d2761ebe9b14df8fc16eb0/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601", size = 373243, upload-time = "2025-10-06T14:10:19.44Z" }, { url = "https://files.pythonhosted.org/packages/c4/f4/4e30b250927ffdab4db70da08b9b8d2194d7c7b400167b8fbeca1e4701ca/yarl-1.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2569b67d616eab450d262ca7cb9f9e19d2f718c70a8b88712859359d0ab17035", size = 98351, upload-time = "2026-03-01T22:05:46.836Z" },
{ url = "https://files.pythonhosted.org/packages/6e/9e/51a77ac7516e8e7803b06e01f74e78649c24ee1021eca3d6a739cb6ea49c/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a", size = 342361, upload-time = "2025-10-06T14:10:21.124Z" }, { url = "https://files.pythonhosted.org/packages/86/fc/4118c5671ea948208bdb1492d8b76bdf1453d3e73df051f939f563e7dcc5/yarl-1.23.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e9d9a4d06d3481eab79803beb4d9bd6f6a8e781ec078ac70d7ef2dcc29d1bea5", size = 92711, upload-time = "2026-03-01T22:05:48.316Z" },
{ url = "https://files.pythonhosted.org/packages/d4/f8/33b92454789dde8407f156c00303e9a891f1f51a0330b0fad7c909f87692/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df", size = 387036, upload-time = "2025-10-06T14:10:22.902Z" }, { url = "https://files.pythonhosted.org/packages/56/11/1ed91d42bd9e73c13dc9e7eb0dd92298d75e7ac4dd7f046ad0c472e231cd/yarl-1.23.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f514f6474e04179d3d33175ed3f3e31434d3130d42ec153540d5b157deefd735", size = 106014, upload-time = "2026-03-01T22:05:50.028Z" },
{ url = "https://files.pythonhosted.org/packages/d9/9a/c5db84ea024f76838220280f732970aa4ee154015d7f5c1bfb60a267af6f/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2", size = 397671, upload-time = "2025-10-06T14:10:24.523Z" }, { url = "https://files.pythonhosted.org/packages/ce/c9/74e44e056a23fbc33aca71779ef450ca648a5bc472bdad7a82339918f818/yarl-1.23.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fda207c815b253e34f7e1909840fd14299567b1c0eb4908f8c2ce01a41265401", size = 105557, upload-time = "2026-03-01T22:05:51.416Z" },
{ url = "https://files.pythonhosted.org/packages/11/c9/cd8538dc2e7727095e0c1d867bad1e40c98f37763e6d995c1939f5fdc7b1/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b", size = 377059, upload-time = "2025-10-06T14:10:26.406Z" }, { url = "https://files.pythonhosted.org/packages/66/fe/b1e10b08d287f518994f1e2ff9b6d26f0adeecd8dd7d533b01bab29a3eda/yarl-1.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34b6cf500e61c90f305094911f9acc9c86da1a05a7a3f5be9f68817043f486e4", size = 101559, upload-time = "2026-03-01T22:05:52.872Z" },
{ url = "https://files.pythonhosted.org/packages/a1/b9/ab437b261702ced75122ed78a876a6dec0a1b0f5e17a4ac7a9a2482d8abe/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273", size = 365356, upload-time = "2025-10-06T14:10:28.461Z" }, { url = "https://files.pythonhosted.org/packages/72/59/c5b8d94b14e3d3c2a9c20cb100119fd534ab5a14b93673ab4cc4a4141ea5/yarl-1.23.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7504f2b476d21653e4d143f44a175f7f751cd41233525312696c76aa3dbb23f", size = 100502, upload-time = "2026-03-01T22:05:54.954Z" },
{ url = "https://files.pythonhosted.org/packages/b2/9d/8e1ae6d1d008a9567877b08f0ce4077a29974c04c062dabdb923ed98e6fe/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a", size = 361331, upload-time = "2025-10-06T14:10:30.541Z" }, { url = "https://files.pythonhosted.org/packages/77/4f/96976cb54cbfc5c9fd73ed4c51804f92f209481d1fb190981c0f8a07a1d7/yarl-1.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:578110dd426f0d209d1509244e6d4a3f1a3e9077655d98c5f22583d63252a08a", size = 98027, upload-time = "2026-03-01T22:05:56.409Z" },
{ url = "https://files.pythonhosted.org/packages/ca/5a/09b7be3905962f145b73beb468cdd53db8aa171cf18c80400a54c5b82846/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", size = 382590, upload-time = "2025-10-06T14:10:33.352Z" }, { url = "https://files.pythonhosted.org/packages/63/6e/904c4f476471afdbad6b7e5b70362fb5810e35cd7466529a97322b6f5556/yarl-1.23.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:609d3614d78d74ebe35f54953c5bbd2ac647a7ddb9c30a5d877580f5e86b22f2", size = 95369, upload-time = "2026-03-01T22:05:58.141Z" },
{ url = "https://files.pythonhosted.org/packages/aa/7f/59ec509abf90eda5048b0bc3e2d7b5099dffdb3e6b127019895ab9d5ef44/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", size = 385316, upload-time = "2025-10-06T14:10:35.034Z" }, { url = "https://files.pythonhosted.org/packages/9d/40/acfcdb3b5f9d68ef499e39e04d25e141fe90661f9d54114556cf83be8353/yarl-1.23.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4966242ec68afc74c122f8459abd597afd7d8a60dc93d695c1334c5fd25f762f", size = 105565, upload-time = "2026-03-01T22:06:00.286Z" },
{ url = "https://files.pythonhosted.org/packages/e5/84/891158426bc8036bfdfd862fabd0e0fa25df4176ec793e447f4b85cf1be4/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", size = 374431, upload-time = "2025-10-06T14:10:37.76Z" }, { url = "https://files.pythonhosted.org/packages/5e/c6/31e28f3a6ba2869c43d124f37ea5260cac9c9281df803c354b31f4dd1f3c/yarl-1.23.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e0fd068364a6759bc794459f0a735ab151d11304346332489c7972bacbe9e72b", size = 99813, upload-time = "2026-03-01T22:06:01.712Z" },
{ url = "https://files.pythonhosted.org/packages/bb/49/03da1580665baa8bef5e8ed34c6df2c2aca0a2f28bf397ed238cc1bbc6f2/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", size = 81555, upload-time = "2025-10-06T14:10:39.649Z" }, { url = "https://files.pythonhosted.org/packages/08/1f/6f65f59e72d54aa467119b63fc0b0b1762eff0232db1f4720cd89e2f4a17/yarl-1.23.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:39004f0ad156da43e86aa71f44e033de68a44e5a31fc53507b36dd253970054a", size = 105632, upload-time = "2026-03-01T22:06:03.188Z" },
{ url = "https://files.pythonhosted.org/packages/9a/ee/450914ae11b419eadd067c6183ae08381cfdfcb9798b90b2b713bbebddda/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", size = 86965, upload-time = "2025-10-06T14:10:41.313Z" }, { url = "https://files.pythonhosted.org/packages/a3/c4/18b178a69935f9e7a338127d5b77d868fdc0f0e49becd286d51b3a18c61d/yarl-1.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e5723c01a56c5028c807c701aa66722916d2747ad737a046853f6c46f4875543", size = 101895, upload-time = "2026-03-01T22:06:04.651Z" },
{ url = "https://files.pythonhosted.org/packages/98/4d/264a01eae03b6cf629ad69bae94e3b0e5344741e929073678e84bf7a3e3b/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", size = 81205, upload-time = "2025-10-06T14:10:43.167Z" }, { url = "https://files.pythonhosted.org/packages/8f/54/f5b870b5505663911dba950a8e4776a0dbd51c9c54c0ae88e823e4b874a0/yarl-1.23.0-cp313-cp313-win32.whl", hash = "sha256:1b6b572edd95b4fa8df75de10b04bc81acc87c1c7d16bcdd2035b09d30acc957", size = 82356, upload-time = "2026-03-01T22:06:06.04Z" },
{ url = "https://files.pythonhosted.org/packages/88/fc/6908f062a2f77b5f9f6d69cecb1747260831ff206adcbc5b510aff88df91/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", size = 146209, upload-time = "2025-10-06T14:10:44.643Z" }, { url = "https://files.pythonhosted.org/packages/7a/84/266e8da36879c6edcd37b02b547e2d9ecdfea776be49598e75696e3316e1/yarl-1.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:baaf55442359053c7d62f6f8413a62adba3205119bcb6f49594894d8be47e5e3", size = 87515, upload-time = "2026-03-01T22:06:08.107Z" },
{ url = "https://files.pythonhosted.org/packages/65/47/76594ae8eab26210b4867be6f49129861ad33da1f1ebdf7051e98492bf62/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", size = 95966, upload-time = "2025-10-06T14:10:46.554Z" }, { url = "https://files.pythonhosted.org/packages/00/fd/7e1c66efad35e1649114fa13f17485f62881ad58edeeb7f49f8c5e748bf9/yarl-1.23.0-cp313-cp313-win_arm64.whl", hash = "sha256:fb4948814a2a98e3912505f09c9e7493b1506226afb1f881825368d6fb776ee3", size = 81785, upload-time = "2026-03-01T22:06:10.181Z" },
{ url = "https://files.pythonhosted.org/packages/ab/ce/05e9828a49271ba6b5b038b15b3934e996980dd78abdfeb52a04cfb9467e/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", size = 97312, upload-time = "2025-10-06T14:10:48.007Z" }, { url = "https://files.pythonhosted.org/packages/9c/fc/119dd07004f17ea43bb91e3ece6587759edd7519d6b086d16bfbd3319982/yarl-1.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:aecfed0b41aa72b7881712c65cf764e39ce2ec352324f5e0837c7048d9e6daaa", size = 130719, upload-time = "2026-03-01T22:06:11.708Z" },
{ url = "https://files.pythonhosted.org/packages/d1/c5/7dffad5e4f2265b29c9d7ec869c369e4223166e4f9206fc2243ee9eea727/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f", size = 361967, upload-time = "2025-10-06T14:10:49.997Z" }, { url = "https://files.pythonhosted.org/packages/e6/0d/9f2348502fbb3af409e8f47730282cd6bc80dec6630c1e06374d882d6eb2/yarl-1.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a41bcf68efd19073376eb8cf948b8d9be0af26256403e512bb18f3966f1f9120", size = 89690, upload-time = "2026-03-01T22:06:13.429Z" },
{ url = "https://files.pythonhosted.org/packages/50/b2/375b933c93a54bff7fc041e1a6ad2c0f6f733ffb0c6e642ce56ee3b39970/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0", size = 323949, upload-time = "2025-10-06T14:10:52.004Z" }, { url = "https://files.pythonhosted.org/packages/50/93/e88f3c80971b42cfc83f50a51b9d165a1dbf154b97005f2994a79f212a07/yarl-1.23.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cde9a2ecd91668bcb7f077c4966d8ceddb60af01b52e6e3e2680e4cf00ad1a59", size = 89851, upload-time = "2026-03-01T22:06:15.53Z" },
{ url = "https://files.pythonhosted.org/packages/66/50/bfc2a29a1d78644c5a7220ce2f304f38248dc94124a326794e677634b6cf/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e", size = 361818, upload-time = "2025-10-06T14:10:54.078Z" }, { url = "https://files.pythonhosted.org/packages/1c/07/61c9dd8ba8f86473263b4036f70fb594c09e99c0d9737a799dfd8bc85651/yarl-1.23.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5023346c4ee7992febc0068e7593de5fa2bf611848c08404b35ebbb76b1b0512", size = 95874, upload-time = "2026-03-01T22:06:17.553Z" },
{ url = "https://files.pythonhosted.org/packages/46/96/f3941a46af7d5d0f0498f86d71275696800ddcdd20426298e572b19b91ff/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708", size = 372626, upload-time = "2025-10-06T14:10:55.767Z" }, { url = "https://files.pythonhosted.org/packages/9e/e9/f9ff8ceefba599eac6abddcfb0b3bee9b9e636e96dbf54342a8577252379/yarl-1.23.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1009abedb49ae95b136a8904a3f71b342f849ffeced2d3747bf29caeda218c4", size = 88710, upload-time = "2026-03-01T22:06:19.004Z" },
{ url = "https://files.pythonhosted.org/packages/c1/42/8b27c83bb875cd89448e42cd627e0fb971fa1675c9ec546393d18826cb50/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f", size = 341129, upload-time = "2025-10-06T14:10:57.985Z" }, { url = "https://files.pythonhosted.org/packages/eb/78/0231bfcc5d4c8eec220bc2f9ef82cb4566192ea867a7c5b4148f44f6cbcd/yarl-1.23.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a8d00f29b42f534cc8aa3931cfe773b13b23e561e10d2b26f27a8d309b0e82a1", size = 101033, upload-time = "2026-03-01T22:06:21.203Z" },
{ url = "https://files.pythonhosted.org/packages/49/36/99ca3122201b382a3cf7cc937b95235b0ac944f7e9f2d5331d50821ed352/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d", size = 346776, upload-time = "2025-10-06T14:10:59.633Z" }, { url = "https://files.pythonhosted.org/packages/cd/9b/30ea5239a61786f18fd25797151a17fbb3be176977187a48d541b5447dd4/yarl-1.23.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:95451e6ce06c3e104556d73b559f5da6c34a069b6b62946d3ad66afcd51642ea", size = 100817, upload-time = "2026-03-01T22:06:22.738Z" },
{ url = "https://files.pythonhosted.org/packages/85/b4/47328bf996acd01a4c16ef9dcd2f59c969f495073616586f78cd5f2efb99/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8", size = 334879, upload-time = "2025-10-06T14:11:01.454Z" }, { url = "https://files.pythonhosted.org/packages/62/e2/a4980481071791bc83bce2b7a1a1f7adcabfa366007518b4b845e92eeee3/yarl-1.23.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531ef597132086b6cf96faa7c6c1dcd0361dd5f1694e5cc30375907b9b7d3ea9", size = 97482, upload-time = "2026-03-01T22:06:24.21Z" },
{ url = "https://files.pythonhosted.org/packages/c2/ad/b77d7b3f14a4283bffb8e92c6026496f6de49751c2f97d4352242bba3990/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", size = 350996, upload-time = "2025-10-06T14:11:03.452Z" }, { url = "https://files.pythonhosted.org/packages/e5/1e/304a00cf5f6100414c4b5a01fc7ff9ee724b62158a08df2f8170dfc72a2d/yarl-1.23.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88f9fb0116fbfcefcab70f85cf4b74a2b6ce5d199c41345296f49d974ddb4123", size = 95949, upload-time = "2026-03-01T22:06:25.697Z" },
{ url = "https://files.pythonhosted.org/packages/81/c8/06e1d69295792ba54d556f06686cbd6a7ce39c22307100e3fb4a2c0b0a1d/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", size = 356047, upload-time = "2025-10-06T14:11:05.115Z" }, { url = "https://files.pythonhosted.org/packages/68/03/093f4055ed4cae649ac53bca3d180bd37102e9e11d048588e9ab0c0108d0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e7b0460976dc75cb87ad9cc1f9899a4b97751e7d4e77ab840fc9b6d377b8fd24", size = 95839, upload-time = "2026-03-01T22:06:27.309Z" },
{ url = "https://files.pythonhosted.org/packages/4b/b8/4c0e9e9f597074b208d18cef227d83aac36184bfbc6eab204ea55783dbc5/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", size = 342947, upload-time = "2025-10-06T14:11:08.137Z" }, { url = "https://files.pythonhosted.org/packages/b9/28/4c75ebb108f322aa8f917ae10a8ffa4f07cae10a8a627b64e578617df6a0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:115136c4a426f9da976187d238e84139ff6b51a20839aa6e3720cd1026d768de", size = 90696, upload-time = "2026-03-01T22:06:29.048Z" },
{ url = "https://files.pythonhosted.org/packages/e0/e5/11f140a58bf4c6ad7aca69a892bff0ee638c31bea4206748fc0df4ebcb3a/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", size = 86943, upload-time = "2025-10-06T14:11:10.284Z" }, { url = "https://files.pythonhosted.org/packages/23/9c/42c2e2dd91c1a570402f51bdf066bfdb1241c2240ba001967bad778e77b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ead11956716a940c1abc816b7df3fa2b84d06eaed8832ca32f5c5e058c65506b", size = 100865, upload-time = "2026-03-01T22:06:30.525Z" },
{ url = "https://files.pythonhosted.org/packages/31/74/8b74bae38ed7fe6793d0c15a0c8207bbb819cf287788459e5ed230996cdd/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249", size = 93715, upload-time = "2025-10-06T14:11:11.739Z" }, { url = "https://files.pythonhosted.org/packages/74/05/1bcd60a8a0a914d462c305137246b6f9d167628d73568505fce3f1cb2e65/yarl-1.23.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:fe8f8f5e70e6dbdfca9882cd9deaac058729bcf323cf7a58660901e55c9c94f6", size = 96234, upload-time = "2026-03-01T22:06:32.692Z" },
{ url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857, upload-time = "2025-10-06T14:11:13.586Z" }, { url = "https://files.pythonhosted.org/packages/90/b2/f52381aac396d6778ce516b7bc149c79e65bfc068b5de2857ab69eeea3b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:a0e317df055958a0c1e79e5d2aa5a5eaa4a6d05a20d4b0c9c3f48918139c9fc6", size = 100295, upload-time = "2026-03-01T22:06:34.268Z" },
{ url = "https://files.pythonhosted.org/packages/46/b3/e20ef504049f1a1c54a814b4b9bed96d1ac0e0610c3b4da178f87209db05/yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4", size = 140520, upload-time = "2025-10-06T14:11:15.465Z" }, { url = "https://files.pythonhosted.org/packages/e5/e8/638bae5bbf1113a659b2435d8895474598afe38b4a837103764f603aba56/yarl-1.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f0fd84de0c957b2d280143522c4f91a73aada1923caee763e24a2b3fda9f8a5", size = 97784, upload-time = "2026-03-01T22:06:35.864Z" },
{ url = "https://files.pythonhosted.org/packages/e4/04/3532d990fdbab02e5ede063676b5c4260e7f3abea2151099c2aa745acc4c/yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683", size = 93504, upload-time = "2025-10-06T14:11:17.106Z" }, { url = "https://files.pythonhosted.org/packages/80/25/a3892b46182c586c202629fc2159aa13975d3741d52ebd7347fd501d48d5/yarl-1.23.0-cp313-cp313t-win32.whl", hash = "sha256:93a784271881035ab4406a172edb0faecb6e7d00f4b53dc2f55919d6c9688595", size = 88313, upload-time = "2026-03-01T22:06:37.39Z" },
{ url = "https://files.pythonhosted.org/packages/11/63/ff458113c5c2dac9a9719ac68ee7c947cb621432bcf28c9972b1c0e83938/yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b", size = 94282, upload-time = "2025-10-06T14:11:19.064Z" }, { url = "https://files.pythonhosted.org/packages/43/68/8c5b36aa5178900b37387937bc2c2fe0e9505537f713495472dcf6f6fccc/yarl-1.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dd00607bffbf30250fe108065f07453ec124dbf223420f57f5e749b04295e090", size = 94932, upload-time = "2026-03-01T22:06:39.579Z" },
{ url = "https://files.pythonhosted.org/packages/a7/bc/315a56aca762d44a6aaaf7ad253f04d996cb6b27bad34410f82d76ea8038/yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e", size = 372080, upload-time = "2025-10-06T14:11:20.996Z" }, { url = "https://files.pythonhosted.org/packages/c6/cc/d79ba8292f51f81f4dc533a8ccfb9fc6992cabf0998ed3245de7589dc07c/yarl-1.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ac09d42f48f80c9ee1635b2fcaa819496a44502737660d3c0f2ade7526d29144", size = 84786, upload-time = "2026-03-01T22:06:41.988Z" },
{ url = "https://files.pythonhosted.org/packages/3f/3f/08e9b826ec2e099ea6e7c69a61272f4f6da62cb5b1b63590bb80ca2e4a40/yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590", size = 338696, upload-time = "2025-10-06T14:11:22.847Z" }, { url = "https://files.pythonhosted.org/packages/90/98/b85a038d65d1b92c3903ab89444f48d3cee490a883477b716d7a24b1a78c/yarl-1.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912", size = 124455, upload-time = "2026-03-01T22:06:43.615Z" },
{ url = "https://files.pythonhosted.org/packages/e3/9f/90360108e3b32bd76789088e99538febfea24a102380ae73827f62073543/yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2", size = 387121, upload-time = "2025-10-06T14:11:24.889Z" }, { url = "https://files.pythonhosted.org/packages/39/54/bc2b45559f86543d163b6e294417a107bb87557609007c007ad889afec18/yarl-1.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474", size = 86752, upload-time = "2026-03-01T22:06:45.425Z" },
{ url = "https://files.pythonhosted.org/packages/98/92/ab8d4657bd5b46a38094cfaea498f18bb70ce6b63508fd7e909bd1f93066/yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da", size = 394080, upload-time = "2025-10-06T14:11:27.307Z" }, { url = "https://files.pythonhosted.org/packages/24/f9/e8242b68362bffe6fb536c8db5076861466fc780f0f1b479fc4ffbebb128/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719", size = 86291, upload-time = "2026-03-01T22:06:46.974Z" },
{ url = "https://files.pythonhosted.org/packages/f5/e7/d8c5a7752fef68205296201f8ec2bf718f5c805a7a7e9880576c67600658/yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784", size = 372661, upload-time = "2025-10-06T14:11:29.387Z" }, { url = "https://files.pythonhosted.org/packages/ea/d8/d1cb2378c81dd729e98c716582b1ccb08357e8488e4c24714658cc6630e8/yarl-1.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319", size = 99026, upload-time = "2026-03-01T22:06:48.459Z" },
{ url = "https://files.pythonhosted.org/packages/b6/2e/f4d26183c8db0bb82d491b072f3127fb8c381a6206a3a56332714b79b751/yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b", size = 364645, upload-time = "2025-10-06T14:11:31.423Z" }, { url = "https://files.pythonhosted.org/packages/0a/ff/7196790538f31debe3341283b5b0707e7feb947620fc5e8236ef28d44f72/yarl-1.23.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434", size = 92355, upload-time = "2026-03-01T22:06:50.306Z" },
{ url = "https://files.pythonhosted.org/packages/80/7c/428e5812e6b87cd00ee8e898328a62c95825bf37c7fa87f0b6bb2ad31304/yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694", size = 355361, upload-time = "2025-10-06T14:11:33.055Z" }, { url = "https://files.pythonhosted.org/packages/c1/56/25d58c3eddde825890a5fe6aa1866228377354a3c39262235234ab5f616b/yarl-1.23.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723", size = 106417, upload-time = "2026-03-01T22:06:52.1Z" },
{ url = "https://files.pythonhosted.org/packages/ec/2a/249405fd26776f8b13c067378ef4d7dd49c9098d1b6457cdd152a99e96a9/yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d", size = 381451, upload-time = "2025-10-06T14:11:35.136Z" }, { url = "https://files.pythonhosted.org/packages/51/8a/882c0e7bc8277eb895b31bce0138f51a1ba551fc2e1ec6753ffc1e7c1377/yarl-1.23.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039", size = 106422, upload-time = "2026-03-01T22:06:54.424Z" },
{ url = "https://files.pythonhosted.org/packages/67/a8/fb6b1adbe98cf1e2dd9fad71003d3a63a1bc22459c6e15f5714eb9323b93/yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd", size = 383814, upload-time = "2025-10-06T14:11:37.094Z" }, { url = "https://files.pythonhosted.org/packages/42/2b/fef67d616931055bf3d6764885990a3ac647d68734a2d6a9e1d13de437a2/yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52", size = 101915, upload-time = "2026-03-01T22:06:55.895Z" },
{ url = "https://files.pythonhosted.org/packages/d9/f9/3aa2c0e480fb73e872ae2814c43bc1e734740bb0d54e8cb2a95925f98131/yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da", size = 370799, upload-time = "2025-10-06T14:11:38.83Z" }, { url = "https://files.pythonhosted.org/packages/18/6a/530e16aebce27c5937920f3431c628a29a4b6b430fab3fd1c117b26ff3f6/yarl-1.23.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c", size = 100690, upload-time = "2026-03-01T22:06:58.21Z" },
{ url = "https://files.pythonhosted.org/packages/50/3c/af9dba3b8b5eeb302f36f16f92791f3ea62e3f47763406abf6d5a4a3333b/yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2", size = 82990, upload-time = "2025-10-06T14:11:40.624Z" }, { url = "https://files.pythonhosted.org/packages/88/08/93749219179a45e27b036e03260fda05190b911de8e18225c294ac95bbc9/yarl-1.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae", size = 98750, upload-time = "2026-03-01T22:06:59.794Z" },
{ url = "https://files.pythonhosted.org/packages/ac/30/ac3a0c5bdc1d6efd1b41fa24d4897a4329b3b1e98de9449679dd327af4f0/yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79", size = 88292, upload-time = "2025-10-06T14:11:42.578Z" }, { url = "https://files.pythonhosted.org/packages/d9/cf/ea424a004969f5d81a362110a6ac1496d79efdc6d50c2c4b2e3ea0fc2519/yarl-1.23.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e", size = 94685, upload-time = "2026-03-01T22:07:01.375Z" },
{ url = "https://files.pythonhosted.org/packages/df/0a/227ab4ff5b998a1b7410abc7b46c9b7a26b0ca9e86c34ba4b8d8bc7c63d5/yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33", size = 82888, upload-time = "2025-10-06T14:11:44.863Z" }, { url = "https://files.pythonhosted.org/packages/e2/b7/14341481fe568e2b0408bcf1484c652accafe06a0ade9387b5d3fd9df446/yarl-1.23.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85", size = 106009, upload-time = "2026-03-01T22:07:03.151Z" },
{ url = "https://files.pythonhosted.org/packages/06/5e/a15eb13db90abd87dfbefb9760c0f3f257ac42a5cac7e75dbc23bed97a9f/yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1", size = 146223, upload-time = "2025-10-06T14:11:46.796Z" }, { url = "https://files.pythonhosted.org/packages/0a/e6/5c744a9b54f4e8007ad35bce96fbc9218338e84812d36f3390cea616881a/yarl-1.23.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd", size = 100033, upload-time = "2026-03-01T22:07:04.701Z" },
{ url = "https://files.pythonhosted.org/packages/18/82/9665c61910d4d84f41a5bf6837597c89e665fa88aa4941080704645932a9/yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca", size = 95981, upload-time = "2025-10-06T14:11:48.845Z" }, { url = "https://files.pythonhosted.org/packages/0c/23/e3bfc188d0b400f025bc49d99793d02c9abe15752138dcc27e4eaf0c4a9e/yarl-1.23.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6", size = 106483, upload-time = "2026-03-01T22:07:06.231Z" },
{ url = "https://files.pythonhosted.org/packages/5d/9a/2f65743589809af4d0a6d3aa749343c4b5f4c380cc24a8e94a3c6625a808/yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53", size = 97303, upload-time = "2025-10-06T14:11:50.897Z" }, { url = "https://files.pythonhosted.org/packages/72/42/f0505f949a90b3f8b7a363d6cbdf398f6e6c58946d85c6d3a3bc70595b26/yarl-1.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe", size = 102175, upload-time = "2026-03-01T22:07:08.4Z" },
{ url = "https://files.pythonhosted.org/packages/b0/ab/5b13d3e157505c43c3b43b5a776cbf7b24a02bc4cccc40314771197e3508/yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c", size = 361820, upload-time = "2025-10-06T14:11:52.549Z" }, { url = "https://files.pythonhosted.org/packages/aa/65/b39290f1d892a9dd671d1c722014ca062a9c35d60885d57e5375db0404b5/yarl-1.23.0-cp314-cp314-win32.whl", hash = "sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169", size = 83871, upload-time = "2026-03-01T22:07:09.968Z" },
{ url = "https://files.pythonhosted.org/packages/fb/76/242a5ef4677615cf95330cfc1b4610e78184400699bdda0acb897ef5e49a/yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf", size = 323203, upload-time = "2025-10-06T14:11:54.225Z" }, { url = "https://files.pythonhosted.org/packages/a9/5b/9b92f54c784c26e2a422e55a8d2607ab15b7ea3349e28359282f84f01d43/yarl-1.23.0-cp314-cp314-win_amd64.whl", hash = "sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70", size = 89093, upload-time = "2026-03-01T22:07:11.501Z" },
{ url = "https://files.pythonhosted.org/packages/8c/96/475509110d3f0153b43d06164cf4195c64d16999e0c7e2d8a099adcd6907/yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face", size = 363173, upload-time = "2025-10-06T14:11:56.069Z" }, { url = "https://files.pythonhosted.org/packages/e0/7d/8a84dc9381fd4412d5e7ff04926f9865f6372b4c2fd91e10092e65d29eb8/yarl-1.23.0-cp314-cp314-win_arm64.whl", hash = "sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e", size = 83384, upload-time = "2026-03-01T22:07:13.069Z" },
{ url = "https://files.pythonhosted.org/packages/c9/66/59db471aecfbd559a1fd48aedd954435558cd98c7d0da8b03cc6c140a32c/yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b", size = 373562, upload-time = "2025-10-06T14:11:58.783Z" }, { url = "https://files.pythonhosted.org/packages/dd/8d/d2fad34b1c08aa161b74394183daa7d800141aaaee207317e82c790b418d/yarl-1.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679", size = 131019, upload-time = "2026-03-01T22:07:14.903Z" },
{ url = "https://files.pythonhosted.org/packages/03/1f/c5d94abc91557384719da10ff166b916107c1b45e4d0423a88457071dd88/yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486", size = 339828, upload-time = "2025-10-06T14:12:00.686Z" }, { url = "https://files.pythonhosted.org/packages/19/ff/33009a39d3ccf4b94d7d7880dfe17fb5816c5a4fe0096d9b56abceea9ac7/yarl-1.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412", size = 89894, upload-time = "2026-03-01T22:07:17.372Z" },
{ url = "https://files.pythonhosted.org/packages/5f/97/aa6a143d3afba17b6465733681c70cf175af89f76ec8d9286e08437a7454/yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138", size = 347551, upload-time = "2025-10-06T14:12:02.628Z" }, { url = "https://files.pythonhosted.org/packages/0c/f1/dab7ac5e7306fb79c0190766a3c00b4cb8d09a1f390ded68c85a5934faf5/yarl-1.23.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4", size = 89979, upload-time = "2026-03-01T22:07:19.361Z" },
{ url = "https://files.pythonhosted.org/packages/43/3c/45a2b6d80195959239a7b2a8810506d4eea5487dce61c2a3393e7fc3c52e/yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a", size = 334512, upload-time = "2025-10-06T14:12:04.871Z" }, { url = "https://files.pythonhosted.org/packages/aa/b1/08e95f3caee1fad6e65017b9f26c1d79877b502622d60e517de01e72f95d/yarl-1.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c", size = 95943, upload-time = "2026-03-01T22:07:21.266Z" },
{ url = "https://files.pythonhosted.org/packages/86/a0/c2ab48d74599c7c84cb104ebd799c5813de252bea0f360ffc29d270c2caa/yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529", size = 352400, upload-time = "2025-10-06T14:12:06.624Z" }, { url = "https://files.pythonhosted.org/packages/c0/cc/6409f9018864a6aa186c61175b977131f373f1988e198e031236916e87e4/yarl-1.23.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4", size = 88786, upload-time = "2026-03-01T22:07:23.129Z" },
{ url = "https://files.pythonhosted.org/packages/32/75/f8919b2eafc929567d3d8411f72bdb1a2109c01caaab4ebfa5f8ffadc15b/yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093", size = 357140, upload-time = "2025-10-06T14:12:08.362Z" }, { url = "https://files.pythonhosted.org/packages/76/40/cc22d1d7714b717fde2006fad2ced5efe5580606cb059ae42117542122f3/yarl-1.23.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94", size = 101307, upload-time = "2026-03-01T22:07:24.689Z" },
{ url = "https://files.pythonhosted.org/packages/cf/72/6a85bba382f22cf78add705d8c3731748397d986e197e53ecc7835e76de7/yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c", size = 341473, upload-time = "2025-10-06T14:12:10.994Z" }, { url = "https://files.pythonhosted.org/packages/8f/0d/476c38e85ddb4c6ec6b20b815bdd779aa386a013f3d8b85516feee55c8dc/yarl-1.23.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28", size = 100904, upload-time = "2026-03-01T22:07:26.287Z" },
{ url = "https://files.pythonhosted.org/packages/35/18/55e6011f7c044dc80b98893060773cefcfdbf60dfefb8cb2f58b9bacbd83/yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e", size = 89056, upload-time = "2025-10-06T14:12:13.317Z" }, { url = "https://files.pythonhosted.org/packages/72/32/0abe4a76d59adf2081dcb0397168553ece4616ada1c54d1c49d8936c74f8/yarl-1.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6", size = 97728, upload-time = "2026-03-01T22:07:27.906Z" },
{ url = "https://files.pythonhosted.org/packages/f9/86/0f0dccb6e59a9e7f122c5afd43568b1d31b8ab7dda5f1b01fb5c7025c9a9/yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27", size = 96292, upload-time = "2025-10-06T14:12:15.398Z" }, { url = "https://files.pythonhosted.org/packages/b7/35/7b30f4810fba112f60f5a43237545867504e15b1c7647a785fbaf588fac2/yarl-1.23.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277", size = 95964, upload-time = "2026-03-01T22:07:30.198Z" },
{ url = "https://files.pythonhosted.org/packages/48/b7/503c98092fb3b344a179579f55814b613c1fbb1c23b3ec14a7b008a66a6e/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", size = 85171, upload-time = "2025-10-06T14:12:16.935Z" }, { url = "https://files.pythonhosted.org/packages/2d/86/ed7a73ab85ef00e8bb70b0cb5421d8a2a625b81a333941a469a6f4022828/yarl-1.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4", size = 95882, upload-time = "2026-03-01T22:07:32.132Z" },
{ url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" }, { url = "https://files.pythonhosted.org/packages/19/90/d56967f61a29d8498efb7afb651e0b2b422a1e9b47b0ab5f4e40a19b699b/yarl-1.23.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a", size = 90797, upload-time = "2026-03-01T22:07:34.404Z" },
{ url = "https://files.pythonhosted.org/packages/72/00/8b8f76909259f56647adb1011d7ed8b321bcf97e464515c65016a47ecdf0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb", size = 101023, upload-time = "2026-03-01T22:07:35.953Z" },
{ url = "https://files.pythonhosted.org/packages/ac/e2/cab11b126fb7d440281b7df8e9ddbe4851e70a4dde47a202b6642586b8d9/yarl-1.23.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41", size = 96227, upload-time = "2026-03-01T22:07:37.594Z" },
{ url = "https://files.pythonhosted.org/packages/c2/9b/2c893e16bfc50e6b2edf76c1a9eb6cb0c744346197e74c65e99ad8d634d0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2", size = 100302, upload-time = "2026-03-01T22:07:39.334Z" },
{ url = "https://files.pythonhosted.org/packages/28/ec/5498c4e3a6d5f1003beb23405671c2eb9cdbf3067d1c80f15eeafe301010/yarl-1.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4", size = 98202, upload-time = "2026-03-01T22:07:41.717Z" },
{ url = "https://files.pythonhosted.org/packages/fe/c3/cd737e2d45e70717907f83e146f6949f20cc23cd4bf7b2688727763aa458/yarl-1.23.0-cp314-cp314t-win32.whl", hash = "sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4", size = 90558, upload-time = "2026-03-01T22:07:43.433Z" },
{ url = "https://files.pythonhosted.org/packages/e1/19/3774d162f6732d1cfb0b47b4140a942a35ca82bb19b6db1f80e9e7bdc8f8/yarl-1.23.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2", size = 97610, upload-time = "2026-03-01T22:07:45.773Z" },
{ url = "https://files.pythonhosted.org/packages/51/47/3fa2286c3cb162c71cdb34c4224d5745a1ceceb391b2bd9b19b668a8d724/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", size = 86041, upload-time = "2026-03-01T22:07:49.026Z" },
{ url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" },
] ]