fastapi-basekit 0.5.3__tar.gz → 0.5.5__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/PKG-INFO +1 -1
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/beanie/repository/base.py +145 -72
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/beanie/service/base.py +39 -3
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/sqlalchemy/repository/base.py +34 -9
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/sqlalchemy/service/base.py +10 -3
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit.egg-info/PKG-INFO +1 -1
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit.egg-info/SOURCES.txt +2 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/pyproject.toml +1 -1
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_beanie_aggregation_hooks.py +5 -3
- fastapi_basekit-0.5.5/tests/test_beanie_search_regex.py +53 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_beanie_service_regression.py +73 -0
- fastapi_basekit-0.5.5/tests/test_owner_filter_helper.py +65 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_sqlalchemy_service_regression.py +73 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/LICENSE +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/README.md +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/beanie/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/beanie/controller/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/beanie/controller/base.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/beanie/repository/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/beanie/service/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/controller/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/controller/base.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/permissions/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/permissions/base.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/sqlalchemy/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/sqlalchemy/controller/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/sqlalchemy/controller/base.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/sqlalchemy/repository/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/sqlalchemy/service/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/sqlalchemy/session.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/sqlmodel/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/sqlmodel/controller/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/sqlmodel/controller/base.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/sqlmodel/repository/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/sqlmodel/repository/base.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/sqlmodel/service/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/sqlmodel/service/base.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/cli/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/cli/main.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/exceptions/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/exceptions/api_exceptions.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/exceptions/domain.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/exceptions/handler.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/openapi.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/py.typed +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/schema/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/schema/base.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/schema/jwt.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/schema/schema.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/servicios/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/servicios/thrid/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/servicios/thrid/jwt.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/cookiecutter.json +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/hooks/post_gen_project.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/hooks/pre_gen_project.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/.env.example +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/.gitignore +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/Dockerfile +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/LICENSE +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/Makefile +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/README.md +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/alembic/env.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/alembic/script.py.mako +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/alembic.ini +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/v1/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/v1/endpoints/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/v1/endpoints/auth/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/v1/endpoints/auth/auth.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/v1/endpoints/user/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/v1/endpoints/user/user.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/v1/routers.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/config/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/config/database.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/config/settings.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/main.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/middleware/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/middleware/auth.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/middleware/permissions.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/models/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/models/auth.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/models/base.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/models/enums.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/models/types.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/permissions/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/permissions/user.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/repositories/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/repositories/user/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/repositories/user/repository.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/schemas/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/schemas/auth.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/schemas/base.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/schemas/user.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/scripts/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/scripts/init.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/scripts/init_admin.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/services/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/services/auth_service.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/services/dependency.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/services/user_service.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/utils/__init__.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/utils/exception_handlers.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/utils/security.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/docker-compose.yml +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/pytest.ini +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/requirements.txt +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit.egg-info/dependency_links.txt +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit.egg-info/entry_points.txt +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit.egg-info/requires.txt +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit.egg-info/top_level.txt +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/setup.cfg +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_action_param_leak.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_api_exceptions.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_base_response.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_base_service.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_beanie_aggregation_integration.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_beanie_keyset_pagination.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_beanie_repository_regression.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_beanie_service_mutable_defaults.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_controller_auto_permissions.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_crud_beanie_controller.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_crud_controller.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_crud_sqlmodel_repository_service.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_exception_handlers.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_filter_operators.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_format_response_schema_override.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_generics_optional_and_cache.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_generics_wiring.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_jwt_config_robustness.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_jwt_service.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_openapi_simplify.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_params_classification.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_params_coercion_matrix.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_params_coercion_unit.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_params_http_matrix_beanie.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_params_http_matrix_sqlalchemy.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_params_rich_type_coercion.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_path_filter_mixin_compat.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_post_process_list_hook.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_pulbot_cbv_pattern_beanie.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_repo_signature_aliases.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_sql_queryset_override.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_sqlalchemy_base_service_order.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_sqlalchemy_repository_regression.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_sqlmodel_filter_operators.py +0 -0
- {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_typed_filter_coercion.py +0 -0
{fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/beanie/repository/base.py
RENAMED
|
@@ -2,6 +2,8 @@ import logging
|
|
|
2
2
|
from typing import Any, Dict, Generic, List, Optional, Type, TypeVar, Union
|
|
3
3
|
from typing import get_args, get_origin
|
|
4
4
|
import re
|
|
5
|
+
import unicodedata
|
|
6
|
+
from functools import lru_cache
|
|
5
7
|
|
|
6
8
|
from bson import ObjectId
|
|
7
9
|
from pydantic import BaseModel
|
|
@@ -11,6 +13,35 @@ from beanie.operators import Or, RegEx
|
|
|
11
13
|
|
|
12
14
|
logger = logging.getLogger(__name__)
|
|
13
15
|
|
|
16
|
+
|
|
17
|
+
@lru_cache(maxsize=None)
|
|
18
|
+
def _accent_class(letter: str) -> str:
|
|
19
|
+
"""Clase regex que matchea `letter` con y sin tilde/diéresis, y en mayúscula."""
|
|
20
|
+
variants = {letter, letter.upper()}
|
|
21
|
+
for cp in range(0x00C0, 0x0100): # Latin-1: á é í ó ú ü ñ… (mantiene el patrón corto)
|
|
22
|
+
ch = chr(cp)
|
|
23
|
+
if unicodedata.normalize("NFD", ch)[0].lower() == letter:
|
|
24
|
+
variants.add(ch)
|
|
25
|
+
return "[" + "".join(sorted(variants)) + "]"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build_search_regex(term: str) -> str:
|
|
29
|
+
"""Patrón regex para búsqueda de texto (usar con `options="i"`).
|
|
30
|
+
|
|
31
|
+
- Todo lo que escribe el usuario se escapa: `(`, `[`, `*`… son literales y
|
|
32
|
+
no rompen la query ni cambian el significado del patrón.
|
|
33
|
+
- Cada letra ASCII matchea también sus variantes acentuadas: `a` ↔ `á`,
|
|
34
|
+
`n` ↔ `ñ`, `o` ↔ `Ó`. Así "descripcion" encuentra "descripción".
|
|
35
|
+
"""
|
|
36
|
+
parts = []
|
|
37
|
+
for ch in term:
|
|
38
|
+
base = unicodedata.normalize("NFD", ch)[0]
|
|
39
|
+
if base.isascii() and base.isalpha():
|
|
40
|
+
parts.append(_accent_class(base.lower()))
|
|
41
|
+
else:
|
|
42
|
+
parts.append(re.escape(ch))
|
|
43
|
+
return ".*" + "".join(parts) + ".*"
|
|
44
|
+
|
|
14
45
|
#: Tipo del Document Beanie que gestiona el repositorio. Parametrízalo al
|
|
15
46
|
#: declarar la subclase — ``class UserRepository(BaseRepository[User])`` — para
|
|
16
47
|
#: que ``get_by_id``/``create``/``update``/``list_all`` devuelvan el Document
|
|
@@ -119,75 +150,60 @@ class BaseRepository(Generic[ModelT]):
|
|
|
119
150
|
kwargs["projection"] = projection
|
|
120
151
|
return kwargs
|
|
121
152
|
|
|
122
|
-
def
|
|
123
|
-
|
|
124
|
-
search: Optional[str],
|
|
125
|
-
search_fields: List[str],
|
|
126
|
-
filters: dict = None,
|
|
127
|
-
order_by: Optional[List[tuple]] = None,
|
|
128
|
-
**kwargs,
|
|
129
|
-
) -> FindMany[Document]:
|
|
130
|
-
"""Versión personalizada que soporta campos Link."""
|
|
131
|
-
exprs = []
|
|
132
|
-
|
|
133
|
-
if search and search_fields:
|
|
134
|
-
exprs.append(
|
|
135
|
-
Or(
|
|
136
|
-
*[
|
|
137
|
-
RegEx(
|
|
138
|
-
getattr(self.model, f),
|
|
139
|
-
f".*{search}.*",
|
|
140
|
-
options="i",
|
|
141
|
-
)
|
|
142
|
-
for f in search_fields
|
|
143
|
-
]
|
|
144
|
-
)
|
|
145
|
-
)
|
|
146
|
-
|
|
147
|
-
# Obtener campos del modelo
|
|
153
|
+
def _is_link_field(self, field_name: str) -> bool:
|
|
154
|
+
"""Verifica si un campo del modelo es de tipo Link (o Optional[Link])."""
|
|
148
155
|
model_fields = (
|
|
149
156
|
self.model.model_fields
|
|
150
157
|
if hasattr(self.model, "model_fields")
|
|
151
158
|
else {}
|
|
152
159
|
)
|
|
160
|
+
field_info = model_fields.get(field_name)
|
|
161
|
+
if not field_info:
|
|
162
|
+
return False
|
|
153
163
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
field_info = model_fields.get(field_name)
|
|
157
|
-
if not field_info:
|
|
158
|
-
return False
|
|
159
|
-
|
|
160
|
-
field_type = field_info.annotation
|
|
161
|
-
origin = get_origin(field_type)
|
|
162
|
-
|
|
163
|
-
# Caso directo: Link[Model]
|
|
164
|
-
if origin is Link:
|
|
165
|
-
return True
|
|
166
|
-
|
|
167
|
-
# Caso Optional[Link[Model]] = Union[Link[Model], None]
|
|
168
|
-
# O cualquier Union que contenga Link
|
|
169
|
-
if origin is not None:
|
|
170
|
-
args = get_args(field_type)
|
|
171
|
-
for arg in args:
|
|
172
|
-
# Verificar si el argumento es Link
|
|
173
|
-
arg_origin = get_origin(arg)
|
|
174
|
-
if arg_origin is Link:
|
|
175
|
-
return True
|
|
164
|
+
field_type = field_info.annotation
|
|
165
|
+
origin = get_origin(field_type)
|
|
176
166
|
|
|
177
|
-
|
|
167
|
+
# Caso directo: Link[Model]
|
|
168
|
+
if origin is Link:
|
|
169
|
+
return True
|
|
178
170
|
|
|
179
|
-
|
|
171
|
+
# Caso Optional[Link[Model]] = Union[Link[Model], None]
|
|
172
|
+
# O cualquier Union que contenga Link
|
|
173
|
+
if origin is not None:
|
|
174
|
+
args = get_args(field_type)
|
|
175
|
+
for arg in args:
|
|
176
|
+
arg_origin = get_origin(arg)
|
|
177
|
+
if arg_origin is Link:
|
|
178
|
+
return True
|
|
180
179
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
return ObjectId(value)
|
|
188
|
-
except Exception:
|
|
189
|
-
return value
|
|
180
|
+
return False
|
|
181
|
+
|
|
182
|
+
@staticmethod
|
|
183
|
+
def _coerce_objectid(value: Any) -> Any:
|
|
184
|
+
"""str/UUID → ObjectId para queries Mongo sobre Link.id."""
|
|
185
|
+
if isinstance(value, ObjectId):
|
|
190
186
|
return value
|
|
187
|
+
if isinstance(value, str):
|
|
188
|
+
try:
|
|
189
|
+
return ObjectId(value)
|
|
190
|
+
except Exception:
|
|
191
|
+
return value
|
|
192
|
+
return value
|
|
193
|
+
|
|
194
|
+
def _resolve_filter_query_args(self, filters: dict = None) -> list:
|
|
195
|
+
"""Traduce un dict de `get_filters()` a los args posicionales que
|
|
196
|
+
acepta `Model.find()`/`Model.find_one()` — resuelve campos Link
|
|
197
|
+
(directos y el alias `<field>_id`), pasa claves Mongo crudas
|
|
198
|
+
(`"user.$id"`, `"$or"`, ...) como dict, y avisa (no falla en
|
|
199
|
+
silencio) si una clave no resuelve a nada del modelo.
|
|
200
|
+
|
|
201
|
+
Compartido por `build_filter_query` (listados) y `get_by_id`
|
|
202
|
+
(retrieve/update/delete escopeados) — mismo motor de resolución de
|
|
203
|
+
filtros para las dos rutas, así un `get_filters()` de seguridad
|
|
204
|
+
protege ambas por igual."""
|
|
205
|
+
exprs: list = []
|
|
206
|
+
raw_filters: Dict[str, Any] = {}
|
|
191
207
|
|
|
192
208
|
for k, v in (filters or {}).items():
|
|
193
209
|
# MongoDB-style keys (dot-notation like "user.$id" or operators like "$or")
|
|
@@ -198,8 +214,8 @@ class BaseRepository(Generic[ModelT]):
|
|
|
198
214
|
|
|
199
215
|
if hasattr(self.model, k):
|
|
200
216
|
field_attr = getattr(self.model, k)
|
|
201
|
-
if _is_link_field(k):
|
|
202
|
-
exprs.append(field_attr.id == _coerce_objectid(v))
|
|
217
|
+
if self._is_link_field(k):
|
|
218
|
+
exprs.append(field_attr.id == self._coerce_objectid(v))
|
|
203
219
|
else:
|
|
204
220
|
exprs.append(field_attr == v)
|
|
205
221
|
continue
|
|
@@ -210,32 +226,59 @@ class BaseRepository(Generic[ModelT]):
|
|
|
210
226
|
# el modelo y es un Link[X].
|
|
211
227
|
if k.endswith("_id"):
|
|
212
228
|
base = k[:-3]
|
|
213
|
-
if hasattr(self.model, base) and _is_link_field(base):
|
|
229
|
+
if hasattr(self.model, base) and self._is_link_field(base):
|
|
214
230
|
field_attr = getattr(self.model, base)
|
|
215
|
-
exprs.append(field_attr.id == _coerce_objectid(v))
|
|
231
|
+
exprs.append(field_attr.id == self._coerce_objectid(v))
|
|
216
232
|
continue
|
|
217
233
|
|
|
218
234
|
# Clave no resoluble (typo, campo inexistente, nesting mal escrito).
|
|
219
235
|
# Se descarta — pero se AVISA: si la clave venía de `get_filters`
|
|
220
236
|
# (scoping por tenant/owner), su desaparición silenciosa deja el
|
|
221
|
-
# listado
|
|
222
|
-
#
|
|
237
|
+
# listado/retrieve SIN ese filtro. El warning hace visible la
|
|
238
|
+
# pérdida en logs/Sentry en vez de fallar en silencio.
|
|
223
239
|
logger.warning(
|
|
224
|
-
"
|
|
225
|
-
"ningún campo de %s). Si era un filtro de
|
|
226
|
-
"queda SIN ese filtro.",
|
|
240
|
+
"_resolve_filter_query_args: filtro '%s' descartado (no "
|
|
241
|
+
"resuelve a ningún campo de %s). Si era un filtro de "
|
|
242
|
+
"scoping, la query queda SIN ese filtro.",
|
|
227
243
|
k,
|
|
228
244
|
getattr(self.model, "__name__", self.model),
|
|
229
245
|
)
|
|
230
246
|
|
|
231
247
|
# Raw MongoDB filters go first so Beanie processes them as a dict condition
|
|
232
|
-
|
|
248
|
+
return ([raw_filters] if raw_filters else []) + exprs
|
|
249
|
+
|
|
250
|
+
def build_filter_query(
|
|
251
|
+
self,
|
|
252
|
+
search: Optional[str],
|
|
253
|
+
search_fields: List[str],
|
|
254
|
+
filters: dict = None,
|
|
255
|
+
order_by: Optional[List[tuple]] = None,
|
|
256
|
+
**kwargs,
|
|
257
|
+
) -> FindMany[Document]:
|
|
258
|
+
"""Versión personalizada que soporta campos Link."""
|
|
259
|
+
exprs = []
|
|
260
|
+
|
|
261
|
+
if search and search_fields:
|
|
262
|
+
exprs.append(
|
|
263
|
+
Or(
|
|
264
|
+
*[
|
|
265
|
+
RegEx(
|
|
266
|
+
getattr(self.model, f),
|
|
267
|
+
build_search_regex(search),
|
|
268
|
+
options="i",
|
|
269
|
+
)
|
|
270
|
+
for f in search_fields
|
|
271
|
+
]
|
|
272
|
+
)
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
query_args: list = exprs + self._resolve_filter_query_args(filters)
|
|
233
276
|
query = self.model.find(*query_args, **self._get_query_kwargs(**kwargs))
|
|
234
|
-
|
|
277
|
+
|
|
235
278
|
# Apply ordering if provided
|
|
236
279
|
if order_by:
|
|
237
280
|
query = query.sort(order_by)
|
|
238
|
-
|
|
281
|
+
|
|
239
282
|
return query
|
|
240
283
|
|
|
241
284
|
async def paginate(
|
|
@@ -340,7 +383,7 @@ class BaseRepository(Generic[ModelT]):
|
|
|
340
383
|
|
|
341
384
|
if search and search_fields:
|
|
342
385
|
search_conditions = [
|
|
343
|
-
{field: {"$regex":
|
|
386
|
+
{field: {"$regex": build_search_regex(search), "$options": "i"}}
|
|
344
387
|
for field in search_fields
|
|
345
388
|
]
|
|
346
389
|
if search_conditions:
|
|
@@ -521,10 +564,40 @@ class BaseRepository(Generic[ModelT]):
|
|
|
521
564
|
async def get_by_id(
|
|
522
565
|
self,
|
|
523
566
|
obj_id: Union[str, ObjectId],
|
|
567
|
+
filters: Optional[Dict[str, Any]] = None,
|
|
524
568
|
**kwargs,
|
|
525
569
|
) -> Optional[ModelT]:
|
|
570
|
+
"""`filters` (opcional) — scoping de seguridad (típicamente
|
|
571
|
+
`Service.get_filters()`, ej. `{"user.$id": request.state.user.id}`).
|
|
572
|
+
Sin `filters`, comportamiento idéntico a antes (busca por id, sin
|
|
573
|
+
scope) — 100% retrocompatible. Con `filters`, un id que existe pero
|
|
574
|
+
no matchea el scope devuelve `None` (mismo resultado que "no
|
|
575
|
+
encontrado"), cerrando la clase de IDOR de "conozco el id de otro
|
|
576
|
+
tenant y lo pido directo".
|
|
577
|
+
|
|
578
|
+
El chequeo de scope corre SIEMPRE con `fetch_links=False` (find_one
|
|
579
|
+
plano, sobre el documento crudo — DBRef sin resolver), sea cual sea
|
|
580
|
+
el `fetch_links` que pida el caller para el fetch final. Motivo:
|
|
581
|
+
Beanie reescribe `find(..., fetch_links=True)` a una aggregation con
|
|
582
|
+
`$lookup`, y un filtro con notación de punto (`"user.$id"`,
|
|
583
|
+
`"user._id"`, etc.) puede matchear distinto — o no matchear nada —
|
|
584
|
+
según si corre ANTES o DESPUÉS del `$lookup` (mismo bug ya
|
|
585
|
+
documentado en `find_all_production_for_user` de pulbot-backend:
|
|
586
|
+
"fetch_links=True puede descartar resultados silenciosamente al
|
|
587
|
+
filtrar por campos dict anidados"). Combinar scope-filter +
|
|
588
|
+
fetch_links en una sola query es frágil; separarlos lo hace
|
|
589
|
+
determinístico sin importar qué `get_filters()` use cada service."""
|
|
526
590
|
if not isinstance(obj_id, ObjectId):
|
|
527
591
|
obj_id = ObjectId(obj_id)
|
|
592
|
+
|
|
593
|
+
if filters:
|
|
594
|
+
scope_exprs = self._resolve_filter_query_args(filters)
|
|
595
|
+
scoped = await self.model.find_one(
|
|
596
|
+
self.model.id == obj_id, *scope_exprs, fetch_links=False
|
|
597
|
+
)
|
|
598
|
+
if not scoped:
|
|
599
|
+
return None
|
|
600
|
+
|
|
528
601
|
return await self.model.find_one(
|
|
529
602
|
self.model.id == obj_id,
|
|
530
603
|
**self._get_query_kwargs(**kwargs),
|
|
@@ -82,9 +82,43 @@ class BaseService(Generic[ModelT]):
|
|
|
82
82
|
filters = filters or {}
|
|
83
83
|
return filters
|
|
84
84
|
|
|
85
|
+
def owner_filter(self, field: str, value: Any) -> Dict[str, Any]:
|
|
86
|
+
"""Filtro de scoping por un campo `Link` (ej. ownership por
|
|
87
|
+
`user`), correcto tanto en `list()` como en `retrieve`/`update`/
|
|
88
|
+
`delete` — sin que el service tenga que conocer por qué la clave
|
|
89
|
+
Mongo cambia entre esos dos caminos.
|
|
90
|
+
|
|
91
|
+
Por qué existen dos claves para "lo mismo": un campo `Link[User]`
|
|
92
|
+
se guarda como DBRef (`{"$ref": "users", "$id": ObjectId(...)}`).
|
|
93
|
+
- `retrieve`/`update`/`delete` van por `get_by_id`, que SIEMPRE
|
|
94
|
+
resuelve el scope con `fetch_links=False` (`find_one` plano sobre
|
|
95
|
+
el documento crudo) — ahí el DBRef está intacto, la clave correcta
|
|
96
|
+
es `"<field>.$id"`.
|
|
97
|
+
- `list()` (y cualquier action cuyo `get_kwargs_query()` pida
|
|
98
|
+
`fetch_links=True`) corre `Model.find(..., fetch_links=True)`,
|
|
99
|
+
que Beanie reescribe a una aggregation con `$lookup` — ahí el
|
|
100
|
+
`$lookup` REEMPLAZA el DBRef por el subdocumento resuelto, así
|
|
101
|
+
que la clave correcta pasa a ser `"<field>._id"`.
|
|
102
|
+
Usar la clave equivocada no tira error — devuelve 0 resultados en
|
|
103
|
+
silencio (bug real ya encontrado usando esto a mano en
|
|
104
|
+
`ToolService` de pulbot-backend, 2026-08-11). Este helper decide
|
|
105
|
+
por vos: usalo en vez de escribir `"<field>.$id"`/`"<field>._id"`
|
|
106
|
+
directo en tu `get_filters()`.
|
|
107
|
+
|
|
108
|
+
⚠️ Asume que tu action de lectura-por-id (`retrieve`/`update`/
|
|
109
|
+
`delete`) no fuerza `fetch_links=True` en el PROPIO chequeo de
|
|
110
|
+
scope — así es como quedó `get_by_id` del repo (siempre
|
|
111
|
+
`fetch_links=False` para el scope-check, independientemente del
|
|
112
|
+
`fetch_links` que pidas para el fetch final). Si tu repo está
|
|
113
|
+
parcheado para saltarse eso, este helper no aplica."""
|
|
114
|
+
suffix = "._id" if self.action == "list" else ".$id"
|
|
115
|
+
return {f"{field}{suffix}": value}
|
|
116
|
+
|
|
85
117
|
async def retrieve(self, id: str) -> ModelT:
|
|
86
118
|
kwargs = self.get_kwargs_query()
|
|
87
|
-
obj = await self.repository.get_by_id(
|
|
119
|
+
obj = await self.repository.get_by_id(
|
|
120
|
+
id, filters=self.get_filters(), **kwargs
|
|
121
|
+
)
|
|
88
122
|
if not obj:
|
|
89
123
|
raise NotFoundException(f"id={id} no encontrado")
|
|
90
124
|
return obj
|
|
@@ -241,7 +275,9 @@ class BaseService(Generic[ModelT]):
|
|
|
241
275
|
|
|
242
276
|
async def update(self, id: str, data: BaseModel) -> ModelT:
|
|
243
277
|
kwargs = self.get_kwargs_query()
|
|
244
|
-
obj = await self.repository.get_by_id(
|
|
278
|
+
obj = await self.repository.get_by_id(
|
|
279
|
+
id, filters=self.get_filters(), **kwargs
|
|
280
|
+
)
|
|
245
281
|
if not obj:
|
|
246
282
|
raise NotFoundException(f"id={id} no encontrado")
|
|
247
283
|
if isinstance(data, BaseModel):
|
|
@@ -250,7 +286,7 @@ class BaseService(Generic[ModelT]):
|
|
|
250
286
|
return updated
|
|
251
287
|
|
|
252
288
|
async def delete(self, id: str) -> str:
|
|
253
|
-
obj = await self.repository.get_by_id(id)
|
|
289
|
+
obj = await self.repository.get_by_id(id, filters=self.get_filters())
|
|
254
290
|
if not obj:
|
|
255
291
|
raise NotFoundException(f"id={id} no encontrado")
|
|
256
292
|
await self.repository.delete(obj)
|
{fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/sqlalchemy/repository/base.py
RENAMED
|
@@ -487,16 +487,37 @@ class BaseRepository(Generic[ModelT]):
|
|
|
487
487
|
|
|
488
488
|
return record
|
|
489
489
|
|
|
490
|
-
async def get(
|
|
491
|
-
|
|
490
|
+
async def get(
|
|
491
|
+
self,
|
|
492
|
+
record_id: Union[str, UUID],
|
|
493
|
+
filters: Optional[Dict[str, Any]] = None,
|
|
494
|
+
) -> Optional[ModelT]:
|
|
495
|
+
"""Obtiene un registro por su ID.
|
|
496
|
+
|
|
497
|
+
`filters` (opcional) — scoping de seguridad (típicamente
|
|
498
|
+
`Service.get_filters()`). Sin `filters`, usa `session.get()` (fast
|
|
499
|
+
path por PK) — comportamiento idéntico a antes, 100% retrocompatible.
|
|
500
|
+
Con `filters`, cae a un `SELECT ... WHERE id = :id AND <filtros>` —
|
|
501
|
+
un id que existe pero no matchea el scope devuelve `None` (mismo
|
|
502
|
+
resultado que "no encontrado"), cerrando la clase de IDOR de "conozco
|
|
503
|
+
el id de otro tenant y lo pido directo"."""
|
|
492
504
|
if not self.model:
|
|
493
505
|
raise ValueError("El modelo no está definido en el repositorio")
|
|
494
|
-
|
|
506
|
+
if not filters:
|
|
507
|
+
return await self.session.get(self.model, record_id)
|
|
508
|
+
conditions = [self.model.id == record_id] + self._build_conditions(
|
|
509
|
+
filters=filters
|
|
510
|
+
)
|
|
511
|
+
return await self._get_one(conditions=conditions)
|
|
495
512
|
|
|
496
|
-
async def get_by_id(
|
|
513
|
+
async def get_by_id(
|
|
514
|
+
self,
|
|
515
|
+
record_id: Union[str, UUID],
|
|
516
|
+
filters: Optional[Dict[str, Any]] = None,
|
|
517
|
+
) -> Optional[ModelT]:
|
|
497
518
|
"""Alias de `get(id)` — nombre unificado con el repo Beanie
|
|
498
519
|
(`get_by_id`) para que las lecturas por id sean portables entre ORMs."""
|
|
499
|
-
return await self.get(record_id)
|
|
520
|
+
return await self.get(record_id, filters=filters)
|
|
500
521
|
|
|
501
522
|
async def get_by_field(
|
|
502
523
|
self, field_name: str, value: Any
|
|
@@ -509,11 +530,15 @@ class BaseRepository(Generic[ModelT]):
|
|
|
509
530
|
self,
|
|
510
531
|
record_id: Union[str, UUID],
|
|
511
532
|
joins: Optional[List[str]] = None,
|
|
533
|
+
filters: Optional[Dict[str, Any]] = None,
|
|
512
534
|
) -> Optional[ModelT]:
|
|
513
|
-
"""Obtiene un registro por ID y carga relaciones dinámicamente.
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
535
|
+
"""Obtiene un registro por ID y carga relaciones dinámicamente.
|
|
536
|
+
|
|
537
|
+
`filters` (opcional) — mismo scoping de seguridad que `get()`."""
|
|
538
|
+
conditions = [self.model.id == record_id]
|
|
539
|
+
if filters:
|
|
540
|
+
conditions.extend(self._build_conditions(filters=filters))
|
|
541
|
+
return await self._get_one(conditions=conditions, joins=joins)
|
|
517
542
|
|
|
518
543
|
async def get_by_field_with_joins(
|
|
519
544
|
self,
|
{fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/sqlalchemy/service/base.py
RENAMED
|
@@ -122,9 +122,10 @@ class BaseService(Generic[ModelT]):
|
|
|
122
122
|
if joins is None:
|
|
123
123
|
joins = kwargs.get("joins")
|
|
124
124
|
|
|
125
|
-
|
|
125
|
+
filters = self.get_filters()
|
|
126
|
+
obj = await self.repository.get_with_joins(id, joins=joins, filters=filters)
|
|
126
127
|
if not obj:
|
|
127
|
-
obj = await self.repository.get(id)
|
|
128
|
+
obj = await self.repository.get(id, filters=filters)
|
|
128
129
|
if not obj:
|
|
129
130
|
raise NotFoundException(f"id={id} no encontrado")
|
|
130
131
|
return obj
|
|
@@ -227,6 +228,12 @@ class BaseService(Generic[ModelT]):
|
|
|
227
228
|
return created
|
|
228
229
|
|
|
229
230
|
async def update(self, id: str, data: BaseModel | Dict[str, Any]) -> ModelT:
|
|
231
|
+
# Scoping de seguridad ANTES de tocar nada — `repository.update`
|
|
232
|
+
# fetchea por PK cruda internamente (sin `get_filters()`), así que
|
|
233
|
+
# sin este check un id de otro tenant se podía editar directo.
|
|
234
|
+
scoped = await self.repository.get(id, filters=self.get_filters())
|
|
235
|
+
if not scoped:
|
|
236
|
+
raise NotFoundException(f"id={id} no encontrado")
|
|
230
237
|
update_data = (
|
|
231
238
|
data.model_dump(exclude_unset=True)
|
|
232
239
|
if isinstance(data, BaseModel)
|
|
@@ -277,7 +284,7 @@ class BaseService(Generic[ModelT]):
|
|
|
277
284
|
return True
|
|
278
285
|
|
|
279
286
|
async def delete(self, id: str) -> bool:
|
|
280
|
-
obj = await self.repository.get(id)
|
|
287
|
+
obj = await self.repository.get(id, filters=self.get_filters())
|
|
281
288
|
if not obj:
|
|
282
289
|
raise NotFoundException(message=f"id={id} no encontrado")
|
|
283
290
|
return await self.apply_delete(obj)
|
|
@@ -113,6 +113,7 @@ tests/test_beanie_aggregation_hooks.py
|
|
|
113
113
|
tests/test_beanie_aggregation_integration.py
|
|
114
114
|
tests/test_beanie_keyset_pagination.py
|
|
115
115
|
tests/test_beanie_repository_regression.py
|
|
116
|
+
tests/test_beanie_search_regex.py
|
|
116
117
|
tests/test_beanie_service_mutable_defaults.py
|
|
117
118
|
tests/test_beanie_service_regression.py
|
|
118
119
|
tests/test_controller_auto_permissions.py
|
|
@@ -127,6 +128,7 @@ tests/test_generics_wiring.py
|
|
|
127
128
|
tests/test_jwt_config_robustness.py
|
|
128
129
|
tests/test_jwt_service.py
|
|
129
130
|
tests/test_openapi_simplify.py
|
|
131
|
+
tests/test_owner_filter_helper.py
|
|
130
132
|
tests/test_params_classification.py
|
|
131
133
|
tests/test_params_coercion_matrix.py
|
|
132
134
|
tests/test_params_coercion_unit.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "fastapi-basekit"
|
|
7
|
-
version = "0.5.
|
|
7
|
+
version = "0.5.5"
|
|
8
8
|
description = "Utilities and base classes for FastAPI async projects (Beanie, SQLAlchemy or SQLModel)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -13,6 +13,8 @@ Covers:
|
|
|
13
13
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
14
14
|
|
|
15
15
|
import pytest
|
|
16
|
+
|
|
17
|
+
from fastapi_basekit.aio.beanie.repository.base import build_search_regex
|
|
16
18
|
from beanie import Document
|
|
17
19
|
from pydantic import Field
|
|
18
20
|
|
|
@@ -97,8 +99,8 @@ class TestBuildListPipeline:
|
|
|
97
99
|
assert len(pipeline) == 1
|
|
98
100
|
match = pipeline[0]["$match"]
|
|
99
101
|
assert "$or" in match
|
|
100
|
-
assert {"name": {"$regex": "
|
|
101
|
-
assert {"email": {"$regex": "
|
|
102
|
+
assert {"name": {"$regex": build_search_regex("ana"), "$options": "i"}} in match["$or"]
|
|
103
|
+
assert {"email": {"$regex": build_search_regex("ana"), "$options": "i"}} in match["$or"]
|
|
102
104
|
|
|
103
105
|
def test_filters_plus_search_combined_with_and(self):
|
|
104
106
|
repo = FakeRepo()
|
|
@@ -110,7 +112,7 @@ class TestBuildListPipeline:
|
|
|
110
112
|
match = pipeline[0]["$match"]
|
|
111
113
|
assert "$and" in match
|
|
112
114
|
assert {"is_active": True} in match["$and"]
|
|
113
|
-
assert {"$or": [{"name": {"$regex": "
|
|
115
|
+
assert {"$or": [{"name": {"$regex": build_search_regex("ana"), "$options": "i"}}]} in match["$and"]
|
|
114
116
|
|
|
115
117
|
def test_simple_order_by_appends_sort_stage(self):
|
|
116
118
|
repo = FakeRepo()
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""build_search_regex: escape del término y búsqueda sin acentos (Beanie)."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from fastapi_basekit.aio.beanie.repository.base import build_search_regex
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _matches(term: str, text: str) -> bool:
|
|
11
|
+
# Mismo significado que Mongo `$regex` con `$options: "i"`.
|
|
12
|
+
return re.search(build_search_regex(term), text, re.IGNORECASE) is not None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.mark.parametrize(
|
|
16
|
+
"text",
|
|
17
|
+
["Descripción", "descripcion", "DESCRIPCIÓN", "Descripcíon"],
|
|
18
|
+
)
|
|
19
|
+
def test_sin_tilde_encuentra_con_tilde_y_viceversa(text):
|
|
20
|
+
assert _matches("descripcion", text)
|
|
21
|
+
assert _matches("descripción", text)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_enie_y_n_son_equivalentes():
|
|
25
|
+
assert _matches("ano", "Año nuevo")
|
|
26
|
+
assert _matches("año", "ANO")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_mayusculas_acentuadas_del_termino():
|
|
30
|
+
assert _matches("ÑANDÚ", "el ñandu corre")
|
|
31
|
+
assert _matches("ñandú", "NANDU")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest.mark.parametrize("term", ["(", "[", "*", "+", "?", "\\", "a.*", "$", "^"])
|
|
35
|
+
def test_caracteres_especiales_son_literales(term):
|
|
36
|
+
# No deben romper la regex ni comportarse como metacaracteres.
|
|
37
|
+
assert re.compile(build_search_regex(term)) # compila
|
|
38
|
+
assert not _matches(term, "xyz")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_punto_asterisco_no_es_comodin():
|
|
42
|
+
assert not _matches("a.*", "abc")
|
|
43
|
+
assert _matches("a.*", "zza.*zz")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_parentesis_se_busca_literal():
|
|
47
|
+
assert _matches("(beta)", "versión (beta) 2")
|
|
48
|
+
assert not _matches("(beta)", "versión beta 2")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_termino_sin_acentos_no_cambia_el_resto_del_texto():
|
|
52
|
+
assert _matches("whats", "WhatsApp")
|
|
53
|
+
assert not _matches("telegram", "WhatsApp")
|
|
@@ -242,3 +242,76 @@ async def test_scoped_filter_merges_with_user_filter(seeded):
|
|
|
242
242
|
svc_t1 = ScopedItemService(repository=ItemRepo(), tenant="t1")
|
|
243
243
|
items, total = await svc_t1.list(filters={"active": True}, count=50)
|
|
244
244
|
assert {i.name for i in items} == {"Alpha"} # t1 + active
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# ---------------------------------------------------------------------------
|
|
248
|
+
# get_filters scoping en retrieve/update/delete (IDOR — CLAUDE.md prometía
|
|
249
|
+
# esto ["Si necesitas un objeto por id con scope, usa `service.retrieve(id)`"]
|
|
250
|
+
# pero `retrieve`/`update`/`delete` llamaban `repository.get_by_id(id)`
|
|
251
|
+
# directo, bypasseando `get_filters()` igual que un `get_by_id` crudo. Un
|
|
252
|
+
# `ScopedItemService` (tenant t2) podía leer/editar/borrar un Item de t1
|
|
253
|
+
# solo con adivinar/enumerar su id. Fix: `retrieve`/`update`/`delete` ahora
|
|
254
|
+
# pasan `filters=self.get_filters()` a `repository.get_by_id`.
|
|
255
|
+
# ---------------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
@pytest.mark.asyncio
|
|
258
|
+
async def test_retrieve_respects_scope_own_tenant_ok(seeded):
|
|
259
|
+
svc_t1 = ScopedItemService(repository=ItemRepo(), tenant="t1")
|
|
260
|
+
got = await svc_t1.retrieve(str(seeded[0].id)) # Alpha, tenant t1
|
|
261
|
+
assert got.name == "Alpha"
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@pytest.mark.asyncio
|
|
265
|
+
async def test_retrieve_cross_tenant_id_is_not_found(seeded):
|
|
266
|
+
"""El id de Gamma (t2) existe de verdad — pedido desde un service
|
|
267
|
+
scopeado a t1 debe comportarse como si no existiera (404), no leakear
|
|
268
|
+
el registro de otro tenant."""
|
|
269
|
+
svc_t1 = ScopedItemService(repository=ItemRepo(), tenant="t1")
|
|
270
|
+
with pytest.raises(NotFoundException):
|
|
271
|
+
await svc_t1.retrieve(str(seeded[2].id)) # Gamma, tenant t2
|
|
272
|
+
|
|
273
|
+
# El mismo id SÍ resuelve para el tenant dueño.
|
|
274
|
+
svc_t2 = ScopedItemService(repository=ItemRepo(), tenant="t2")
|
|
275
|
+
got = await svc_t2.retrieve(str(seeded[2].id))
|
|
276
|
+
assert got.name == "Gamma"
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@pytest.mark.asyncio
|
|
280
|
+
async def test_update_cross_tenant_id_is_not_found_and_not_modified(seeded):
|
|
281
|
+
svc_t1 = ScopedItemService(repository=ItemRepo(), tenant="t1")
|
|
282
|
+
with pytest.raises(NotFoundException):
|
|
283
|
+
await svc_t1.update(str(seeded[2].id), ItemUpdate(qty=999))
|
|
284
|
+
|
|
285
|
+
# No se modificó — lo confirma el dueño real (t2).
|
|
286
|
+
svc_t2 = ScopedItemService(repository=ItemRepo(), tenant="t2")
|
|
287
|
+
unchanged = await svc_t2.retrieve(str(seeded[2].id))
|
|
288
|
+
assert unchanged.qty == 9
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@pytest.mark.asyncio
|
|
292
|
+
async def test_delete_cross_tenant_id_is_not_found_and_not_deleted(seeded):
|
|
293
|
+
svc_t1 = ScopedItemService(repository=ItemRepo(), tenant="t1")
|
|
294
|
+
with pytest.raises(NotFoundException):
|
|
295
|
+
await svc_t1.delete(str(seeded[2].id))
|
|
296
|
+
|
|
297
|
+
# Sigue existiendo — lo confirma el dueño real (t2).
|
|
298
|
+
svc_t2 = ScopedItemService(repository=ItemRepo(), tenant="t2")
|
|
299
|
+
still_there = await svc_t2.retrieve(str(seeded[2].id))
|
|
300
|
+
assert still_there.name == "Gamma"
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@pytest.mark.asyncio
|
|
304
|
+
async def test_unscoped_service_retrieve_update_delete_unaffected(seeded):
|
|
305
|
+
"""Retrocompatibilidad: un service SIN override de `get_filters` (el
|
|
306
|
+
caso común, `get_filters` devuelve `{}`) sigue viendo/editando/borrando
|
|
307
|
+
cualquier id — cero cambio de comportamiento para el 99% de los
|
|
308
|
+
services existentes que no scopean por owner."""
|
|
309
|
+
svc = ItemService(repository=ItemRepo())
|
|
310
|
+
got = await svc.retrieve(str(seeded[2].id)) # Gamma, sin scoping
|
|
311
|
+
assert got.name == "Gamma"
|
|
312
|
+
|
|
313
|
+
updated = await svc.update(str(seeded[2].id), ItemUpdate(qty=42))
|
|
314
|
+
assert updated.qty == 42
|
|
315
|
+
|
|
316
|
+
res = await svc.delete(str(seeded[2].id))
|
|
317
|
+
assert res == "deleted"
|