├── worker ├── __init__.py ├── consumer.py ├── user.py └── kafka.py ├── migrations ├── versions │ ├── .gitkeep │ ├── 0932ab8ca14a_19_added_validtation_column_to_param.py │ ├── fc911d58459b_add_is_public_field_to_param.py │ ├── 5a6490c55c81_add_visible_in_user_response_field_to_.py │ └── f8c57101c0f6_init.py ├── README ├── script.py.mako └── env.py ├── tests ├── test_worker │ ├── __init__.py │ └── test_worker.py ├── test_routes │ ├── test_source.py │ ├── test_category.py │ ├── test_param.py │ ├── test_users_get.py │ ├── test_user_get.py │ ├── test_user_update.py │ └── test_user_post_then_get.py └── conftest.py ├── userdata_api ├── schemas │ ├── __init__.py │ ├── types │ │ ├── __init__.py │ │ └── scope.py │ ├── response_model.py │ ├── admin.py │ ├── base.py │ ├── source.py │ ├── category.py │ ├── param.py │ └── user.py ├── utils │ ├── __init__.py │ ├── utils.py │ ├── admin.py │ └── user.py ├── models │ ├── __init__.py │ ├── base.py │ └── db.py ├── __init__.py ├── routes │ ├── __init__.py │ ├── base.py │ ├── admin.py │ ├── exc_handlers.py │ ├── source.py │ ├── user.py │ ├── category.py │ └── param.py ├── __main__.py └── exceptions.py ├── cleanupdb.sh ├── requirements.dev.txt ├── requirements.txt ├── logging_dev.conf ├── pyproject.toml ├── Dockerfile ├── logging_prod.conf ├── logging_test.conf ├── settings.py ├── CONTRIBUTING.md ├── Makefile ├── flake8.conf ├── LICENSE ├── .gitignore ├── .github └── workflows │ ├── checks.yml │ └── build_and_publish.yml ├── alembic.ini └── README.md /worker/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /migrations/versions/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_worker/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /userdata_api/schemas/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /userdata_api/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /userdata_api/schemas/types/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /userdata_api/models/__init__.py: -------------------------------------------------------------------------------- 1 | from . import db 2 | 3 | 4 | __all__ = ["db"] 5 | -------------------------------------------------------------------------------- /userdata_api/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | __version__ = os.getenv('APP_VERSION', 'dev') 5 | -------------------------------------------------------------------------------- /userdata_api/routes/__init__.py: -------------------------------------------------------------------------------- 1 | from . import exc_handlers 2 | 3 | 4 | __all__ = ["exc_handlers"] 5 | -------------------------------------------------------------------------------- /cleanupdb.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | alembic downgrade head-"$(alembic heads | wc -l | sed 's/ //g')" 4 | alembic upgrade head -------------------------------------------------------------------------------- /requirements.dev.txt: -------------------------------------------------------------------------------- 1 | autoflake 2 | black 3 | httpx 4 | isort 5 | pytest 6 | pytest-cov 7 | pytest-mock 8 | auth-lib-profcomff[testing] -------------------------------------------------------------------------------- /userdata_api/schemas/response_model.py: -------------------------------------------------------------------------------- 1 | from userdata_api.schemas.base import Base 2 | 3 | 4 | class StatusResponseModel(Base): 5 | status: str 6 | message: str 7 | ru: str 8 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | alembic 2 | auth-lib-profcomff[fastapi] 3 | fastapi 4 | fastapi-sqlalchemy 5 | gunicorn 6 | logging-profcomff 7 | psycopg2-binary 8 | pydantic[dotenv] 9 | SQLAlchemy 10 | uvicorn 11 | pydantic-settings 12 | event_schema_profcomff 13 | confluent_kafka 14 | -------------------------------------------------------------------------------- /userdata_api/utils/utils.py: -------------------------------------------------------------------------------- 1 | import random 2 | import string 3 | 4 | 5 | def random_string(length: int = 12): 6 | """ 7 | Сгенерировать рандомную строку 8 | :param length: длина строки(по умолчанию 12) 9 | :return: Сгенериированную строку 10 | """ 11 | return "".join([random.choice(string.ascii_lowercase) for _ in range(length)]) 12 | -------------------------------------------------------------------------------- /logging_dev.conf: -------------------------------------------------------------------------------- 1 | [loggers] 2 | keys=root 3 | 4 | [handlers] 5 | keys=all 6 | 7 | [formatters] 8 | keys=main 9 | 10 | [logger_root] 11 | level=DEBUG 12 | handlers=all 13 | 14 | [handler_all] 15 | class=StreamHandler 16 | formatter=main 17 | level=DEBUG 18 | args=(sys.stdout,) 19 | 20 | [formatter_main] 21 | format=%(asctime)s %(levelname)-8s %(name)-15s %(message)s 22 | -------------------------------------------------------------------------------- /userdata_api/schemas/admin.py: -------------------------------------------------------------------------------- 1 | from .base import Base 2 | 3 | 4 | class UserCardGet(Base): 5 | user_id: int 6 | full_name: str | None = None 7 | student_card_number: str | None = None 8 | union_card_number: str | None = None 9 | is_union_member: str 10 | 11 | 12 | class UserCardUpdate(Base): 13 | full_name: str | None = None 14 | student_card_number: str | None = None 15 | -------------------------------------------------------------------------------- /userdata_api/schemas/base.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, ConfigDict 2 | 3 | 4 | class Base(BaseModel): 5 | def __repr__(self) -> str: 6 | attrs = [] 7 | for k, v in self.__class__.schema().items(): 8 | attrs.append(f"{k}={v}") 9 | return "{}({})".format(self.__class__.__name__, ', '.join(attrs)) 10 | 11 | model_config = ConfigDict(from_attributes=True, extra="ignore") 12 | -------------------------------------------------------------------------------- /userdata_api/schemas/source.py: -------------------------------------------------------------------------------- 1 | from pydantic import conint, constr 2 | 3 | from .base import Base 4 | 5 | 6 | class SourcePost(Base): 7 | name: constr(min_length=1) 8 | trust_level: conint(gt=0, lt=11) 9 | 10 | 11 | class SourcePatch(Base): 12 | name: constr(min_length=1) | None = None 13 | trust_level: conint(gt=0, lt=11) | None = None 14 | 15 | 16 | class SourceGet(SourcePost): 17 | id: int 18 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 120 3 | target-version = ['py311'] 4 | skip-string-normalization = true 5 | 6 | [tool.isort] 7 | line_length = 120 8 | multi_line_output = 3 9 | profile = "black" 10 | lines_after_imports = 2 11 | include_trailing_comma = true 12 | 13 | [tool.pytest.ini_options] 14 | minversion = "7.0" 15 | python_files = "*.py" 16 | testpaths = [ 17 | "tests" 18 | ] 19 | pythonpath = [ 20 | "." 21 | ] 22 | log_cli=true 23 | log_level=0 24 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM tiangolo/uvicorn-gunicorn-fastapi:python3.11 2 | ARG APP_VERSION=dev 3 | ENV APP_VERSION=${APP_VERSION} 4 | ENV APP_NAME=userdata_api 5 | ENV APP_MODULE=${APP_NAME}.routes.base:app 6 | 7 | COPY ./requirements.txt /app/ 8 | COPY ./logging_prod.conf /app/ 9 | COPY ./logging_test.conf /app/ 10 | RUN pip install -U -r /app/requirements.txt 11 | 12 | COPY ./alembic.ini /alembic.ini 13 | COPY ./migrations /migrations/ 14 | COPY ./settings.py /app/settings.py 15 | 16 | COPY ./${APP_NAME} /app/${APP_NAME} 17 | COPY ./worker /app/worker 18 | -------------------------------------------------------------------------------- /userdata_api/schemas/category.py: -------------------------------------------------------------------------------- 1 | from pydantic import constr 2 | 3 | from .base import Base 4 | from .param import ParamGet 5 | from .types.scope import Scope 6 | 7 | 8 | class CategoryPost(Base): 9 | name: constr(min_length=1) 10 | read_scope: Scope | None = None 11 | update_scope: Scope | None = None 12 | 13 | 14 | class CategoryPatch(Base): 15 | name: constr(min_length=1) | None = None 16 | read_scope: Scope | None = None 17 | update_scope: Scope | None = None 18 | 19 | 20 | class CategoryGet(CategoryPost): 21 | id: int 22 | params: list[ParamGet] | None = None 23 | -------------------------------------------------------------------------------- /migrations/versions/0932ab8ca14a_19_added_validtation_column_to_param.py: -------------------------------------------------------------------------------- 1 | """19 Added validtation column to Param 2 | 3 | Revision ID: 0932ab8ca14a 4 | Revises: f8c57101c0f6 5 | Create Date: 2024-07-24 01:07:25.199873 6 | 7 | """ 8 | 9 | import sqlalchemy as sa 10 | from alembic import op 11 | 12 | 13 | revision = '0932ab8ca14a' 14 | down_revision = 'f8c57101c0f6' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | op.add_column('param', sa.Column('validation', sa.String(), nullable=True)) 21 | 22 | 23 | def downgrade(): 24 | op.drop_column('param', 'validation') 25 | -------------------------------------------------------------------------------- /migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /logging_prod.conf: -------------------------------------------------------------------------------- 1 | [loggers] 2 | keys=root,gunicorn.error,gunicorn.access 3 | 4 | [handlers] 5 | keys=all 6 | 7 | [formatters] 8 | keys=json 9 | 10 | [logger_root] 11 | level=INFO 12 | handlers=all 13 | 14 | [logger_gunicorn.error] 15 | level=INFO 16 | handlers=all 17 | propagate=0 18 | qualname=gunicorn.error 19 | formatter=json 20 | 21 | [logger_gunicorn.access] 22 | level=INFO 23 | handlers=all 24 | propagate=0 25 | qualname=gunicorn.access 26 | formatter=json 27 | 28 | [handler_all] 29 | class=StreamHandler 30 | formatter=json 31 | level=INFO 32 | args=(sys.stdout,) 33 | 34 | [formatter_json] 35 | class=logger.formatter.JSONLogFormatter 36 | -------------------------------------------------------------------------------- /logging_test.conf: -------------------------------------------------------------------------------- 1 | [loggers] 2 | keys=root,gunicorn.error,gunicorn.access 3 | 4 | [handlers] 5 | keys=all 6 | 7 | [formatters] 8 | keys=json 9 | 10 | [logger_root] 11 | level=DEBUG 12 | handlers=all 13 | formatter=json 14 | 15 | [logger_gunicorn.error] 16 | level=DEBUG 17 | handlers=all 18 | propagate=0 19 | qualname=gunicorn.error 20 | formatter=json 21 | 22 | [logger_gunicorn.access] 23 | level=DEBUG 24 | handlers=all 25 | propagate=0 26 | qualname=gunicorn.access 27 | formatter=json 28 | 29 | [handler_all] 30 | class=StreamHandler 31 | formatter=json 32 | level=DEBUG 33 | args=(sys.stdout,) 34 | 35 | [formatter_json] 36 | class=logger.formatter.JSONLogFormatter 37 | -------------------------------------------------------------------------------- /userdata_api/__main__.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | import uvicorn 4 | 5 | 6 | def get_args(): 7 | parser = argparse.ArgumentParser() 8 | subparsers = parser.add_subparsers(dest='command') 9 | 10 | start = subparsers.add_parser("start") 11 | start.add_argument('--instance', type=str, required=True) 12 | 13 | return parser.parse_args() 14 | 15 | 16 | if __name__ == '__main__': 17 | args = get_args() 18 | match args.instance: 19 | case "api": 20 | from userdata_api.routes.base import app 21 | 22 | uvicorn.run(app) 23 | case "worker": 24 | from worker.consumer import process 25 | 26 | process() 27 | -------------------------------------------------------------------------------- /migrations/versions/fc911d58459b_add_is_public_field_to_param.py: -------------------------------------------------------------------------------- 1 | """add_is_public_field_to_param 2 | 3 | Revision ID: fc911d58459b 4 | Revises: 5a6490c55c81 5 | Create Date: 2025-03-11 21:38:50.699014 6 | 7 | """ 8 | 9 | import sqlalchemy as sa 10 | from alembic import op 11 | 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = 'fc911d58459b' 15 | down_revision = '5a6490c55c81' 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | def upgrade(): 21 | op.add_column('param', sa.Column('is_public', sa.Boolean(), nullable=False, server_default=sa.false())) 22 | 23 | 24 | def downgrade(): 25 | # ### commands auto generated by Alembic - please adjust! ### 26 | op.drop_column('param', 'is_public') 27 | # ### end Alembic commands ### 28 | -------------------------------------------------------------------------------- /userdata_api/schemas/param.py: -------------------------------------------------------------------------------- 1 | from pydantic import constr 2 | 3 | from userdata_api.models.db import ViewType 4 | 5 | from .base import Base 6 | 7 | 8 | class ParamPost(Base): 9 | is_public: bool = False 10 | visible_in_user_response: bool = True 11 | name: constr(min_length=1) 12 | is_required: bool 13 | changeable: bool 14 | type: ViewType 15 | validation: constr(min_length=1) | None = None 16 | 17 | 18 | class ParamPatch(Base): 19 | is_public: bool = False 20 | visible_in_user_response: bool = True 21 | name: constr(min_length=1) | None = None 22 | is_required: bool | None = None 23 | changeable: bool | None = None 24 | type: ViewType | None = None 25 | validation: constr(min_length=1) | None = None 26 | 27 | 28 | class ParamGet(ParamPost): 29 | id: int 30 | category_id: int 31 | -------------------------------------------------------------------------------- /settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | from functools import lru_cache 3 | 4 | from pydantic import ConfigDict, PostgresDsn 5 | from pydantic_settings import BaseSettings 6 | 7 | 8 | class Settings(BaseSettings): 9 | """Application settings""" 10 | 11 | DB_DSN: PostgresDsn = 'postgresql://postgres@localhost:5432/postgres' 12 | 13 | KAFKA_DSN: str | None = None 14 | KAFKA_LOGIN: str | None = None 15 | KAFKA_PASSWORD: str | None = None 16 | KAFKA_TOPICS: list[str] | None = None 17 | KAFKA_GROUP_ID: str | None = None 18 | 19 | ROOT_PATH: str = '/' + os.getenv("APP_NAME", "") 20 | 21 | CORS_ALLOW_ORIGINS: list[str] = ['*'] 22 | CORS_ALLOW_CREDENTIALS: bool = True 23 | CORS_ALLOW_METHODS: list[str] = ['*'] 24 | CORS_ALLOW_HEADERS: list[str] = ['*'] 25 | model_config = ConfigDict(case_sensitive=True, env_file=".env", extra="allow") 26 | 27 | 28 | @lru_cache 29 | def get_settings() -> Settings: 30 | settings = Settings() 31 | return settings 32 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Что нужно для запуска 2 | 3 | 1. python3.11. Установка описана [тут](https://www.python.org/downloads/) 4 | 5 | 2. Docker. Как установить docker описано [тут](https://docs.docker.com/engine/install/) 6 | 7 | 3. PostgreSQL. Запустить команду 8 | ```console 9 | docker run -d -p 5432:5432 -e POSTGRES_HOST_AUTH_METHOD=trust --name db-userdata_api postgres:15 10 | ``` 11 | 12 | 4. Опционально Kafka Cluster(если планиурется запускать userdata worker). Запуск описан [тут](https://github.com/profcomff/db-kafka) 13 | 14 | ## Какие переменные нужны для запуска 15 | - `DB_DSN=postgresql://postgres@localhost:5432/postgres` 16 | 17 | ### Опционально, если нужно запустить Kafka Worker 18 | - `KAFKA_DSN=loacalhost:9092` 19 | 20 | - `KAFKA_TOPICS='["dev-user-login"]'` 21 | 22 | - `KAFKA_GROUP_ID=dev-userdata` 23 | 24 | 25 | ## Codestyle 26 | 27 | - Black. Как пользоваться описано [тут](https://black.readthedocs.io/en/stable/) 28 | 29 | - Также применяем [isort](https://pycqa.github.io/isort/) 30 | 31 | -------------------------------------------------------------------------------- /userdata_api/schemas/user.py: -------------------------------------------------------------------------------- 1 | from pydantic import constr, field_validator 2 | 3 | from .base import Base 4 | 5 | 6 | class UserInfo(Base): 7 | category: str 8 | param: str 9 | value: str | None = None 10 | 11 | 12 | class ExtendedUserInfo(UserInfo): 13 | user_id: int 14 | 15 | 16 | class UserInfoGet(Base): 17 | items: list[UserInfo] 18 | 19 | @classmethod 20 | @field_validator("items") 21 | def unique_validator(cls, v): 22 | _iter_category = (row.category for row in v) 23 | if len(frozenset(_iter_category)) != len(tuple(_iter_category)): 24 | raise ValueError("Category list is not unique") 25 | _iter_params = (row.param for row in v) 26 | if len(frozenset(_iter_params)) != len(tuple(_iter_params)): 27 | raise ValueError("Category list is not unique") 28 | return v 29 | 30 | 31 | class UsersInfoGet(Base): 32 | items: list[ExtendedUserInfo] 33 | 34 | 35 | class UserInfoUpdate(UserInfoGet): 36 | source: constr(min_length=1) 37 | -------------------------------------------------------------------------------- /migrations/versions/5a6490c55c81_add_visible_in_user_response_field_to_.py: -------------------------------------------------------------------------------- 1 | """Add visible_in_user_response field to Param table 2 | 3 | Revision ID: 5a6490c55c81 4 | Revises: 0932ab8ca14a 5 | Create Date: 2024-09-03 10:54:14.554987 6 | 7 | """ 8 | 9 | import sqlalchemy as sa 10 | from alembic import op 11 | 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = '5a6490c55c81' 15 | down_revision = '0932ab8ca14a' 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | def upgrade(): 21 | # ### commands auto generated by Alembic - please adjust! ### 22 | op.add_column('param', sa.Column('visible_in_user_response', sa.Boolean(), nullable=True)) 23 | op.execute(f'UPDATE "param" SET visible_in_user_response = True') 24 | op.alter_column('param', 'visible_in_user_response', nullable=False) 25 | # ### end Alembic commands ### 26 | 27 | 28 | def downgrade(): 29 | # ### commands auto generated by Alembic - please adjust! ### 30 | op.drop_column('param', 'visible_in_user_response') 31 | # ### end Alembic commands ### 32 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | run: 2 | source ./venv/bin/activate && uvicorn --reload --log-config logging_dev.conf userdata_api.routes.base:app 3 | 4 | configure: venv 5 | source ./venv/bin/activate && pip install -r requirements.dev.txt -r requirements.txt 6 | 7 | venv: 8 | python3.11 -m venv venv 9 | 10 | format: 11 | source ./venv/bin/activate && autoflake -r --in-place --remove-all-unused-imports ./userdata_api 12 | source ./venv/bin/activate && isort ./userdata_api 13 | source ./venv/bin/activate && black ./userdata_api 14 | source ./venv/bin/activate && autoflake -r --in-place --remove-all-unused-imports ./tests 15 | source ./venv/bin/activate && isort ./tests 16 | source ./venv/bin/activate && black ./tests 17 | source ./venv/bin/activate && autoflake -r --in-place --remove-all-unused-imports ./migrations 18 | source ./venv/bin/activate && isort ./migrations 19 | source ./venv/bin/activate && black ./migrations 20 | 21 | db: 22 | docker run -d -p 5432:5432 -e POSTGRES_HOST_AUTH_METHOD=trust --name db-userdata_api postgres:15 23 | 24 | migrate: 25 | source ./venv/bin/activate && alembic upgrade head 26 | -------------------------------------------------------------------------------- /worker/consumer.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Any 3 | 4 | import pydantic 5 | from event_schema.auth import UserLogin, UserLoginKey 6 | from sqlalchemy import create_engine 7 | from sqlalchemy.orm import sessionmaker 8 | 9 | from settings import get_settings 10 | from worker.kafka import KafkaConsumer 11 | 12 | from .user import patch_user_info 13 | 14 | 15 | log = logging.getLogger(__name__) 16 | settings = get_settings() 17 | consumer = KafkaConsumer() 18 | 19 | _engine = create_engine(str(settings.DB_DSN), pool_pre_ping=True, isolation_level="AUTOCOMMIT") 20 | _Session = sessionmaker(bind=_engine) 21 | 22 | 23 | def process_models(key: Any, value: Any) -> tuple[UserLoginKey | None, UserLogin | None]: 24 | try: 25 | return UserLoginKey.model_validate(key), UserLogin.model_validate(value) 26 | except pydantic.ValidationError: 27 | log.error(f"Validation error occurred, {key=}, {value=}", exc_info=False) 28 | return None, None 29 | 30 | 31 | def process_message(message: tuple[Any, Any]) -> None: 32 | processed_k, processed_v = process_models(*message) 33 | if not (processed_k and processed_v): 34 | return 35 | patch_user_info(processed_v, processed_k.user_id, session=_Session()) 36 | 37 | 38 | def process(): 39 | for message in consumer.listen(): 40 | process_message(message) 41 | -------------------------------------------------------------------------------- /flake8.conf: -------------------------------------------------------------------------------- 1 | [flake8] 2 | select = 3 | E, W, # pep8 errors and warnings 4 | F, # pyflakes 5 | C9, # McCabe 6 | N8, # Naming Conventions 7 | #B, S, # bandit 8 | #C, # commas 9 | #D, # docstrings 10 | #P, # string-format 11 | #Q, # quotes 12 | 13 | ignore = 14 | E122, # continuation line missing indentation or outdented 15 | E123, # closing bracket does not match indentation of opening bracket's line 16 | E127, # continuation line over-indented for visual indent 17 | E131, # continuation line unaligned for hanging 18 | E203, # whitespace before ':' 19 | E225, # missing whitespace around operator 20 | E226, # missing whitespace around arithmetic operator 21 | E24, # multiple spaces after ',' or tab after ',' 22 | E275, # missing whitespace after keyword 23 | E305, # expected 2 blank lines after end of function or class 24 | E306, # expected 1 blank line before a nested definition 25 | E402, # module level import not at top of file 26 | E722, # do not use bare except, specify exception instead 27 | E731, # do not assign a lambda expression, use a def 28 | E741, # do not use variables named 'l', 'O', or 'I' 29 | 30 | F722, # syntax error in forward annotation 31 | 32 | W503, # line break before binary operator 33 | W504, # line break after binary operator 34 | 35 | max-line-length = 120 -------------------------------------------------------------------------------- /userdata_api/routes/base.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | from fastapi.middleware.cors import CORSMiddleware 3 | from fastapi_sqlalchemy import DBSessionMiddleware 4 | 5 | from settings import get_settings 6 | from userdata_api import __version__ 7 | 8 | from .admin import admin 9 | from .category import category 10 | from .param import param 11 | from .source import source 12 | from .user import user 13 | 14 | 15 | settings = get_settings() 16 | app = FastAPI( 17 | title='Сервис пользовательских данных', 18 | description='Серверная часть сервиса хранения и управления информации о пользователе', 19 | version=__version__, 20 | # Отключаем нелокальную документацию 21 | root_path=settings.ROOT_PATH if __version__ != 'dev' else '', 22 | docs_url=None if __version__ != 'dev' else '/docs', 23 | redoc_url=None, 24 | ) 25 | 26 | 27 | app.add_middleware( 28 | DBSessionMiddleware, 29 | db_url=str(settings.DB_DSN), 30 | engine_args={"pool_pre_ping": True, "isolation_level": "AUTOCOMMIT"}, 31 | ) 32 | 33 | app.add_middleware( 34 | CORSMiddleware, 35 | allow_origins=settings.CORS_ALLOW_ORIGINS, 36 | allow_credentials=settings.CORS_ALLOW_CREDENTIALS, 37 | allow_methods=settings.CORS_ALLOW_METHODS, 38 | allow_headers=settings.CORS_ALLOW_HEADERS, 39 | ) 40 | 41 | app.include_router(source) 42 | app.include_router(category) 43 | app.include_router(param) 44 | app.include_router(user) 45 | app.include_router(admin) 46 | -------------------------------------------------------------------------------- /userdata_api/routes/admin.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from auth_lib.fastapi import UnionAuth 4 | from fastapi import APIRouter, Depends 5 | 6 | from userdata_api.schemas.admin import UserCardGet, UserCardUpdate 7 | from userdata_api.schemas.response_model import StatusResponseModel 8 | from userdata_api.utils.admin import get_user_info, patch_user_info 9 | 10 | 11 | admin = APIRouter(prefix="/admin", tags=["Admin"]) 12 | 13 | 14 | @admin.get("/user/{user_id}", response_model=UserCardGet) 15 | async def get_user_card( 16 | user_id: int, 17 | user: dict[str, Any] = Depends(UnionAuth(scopes=["userdata.info.admin"], allow_none=False, auto_error=True)), 18 | ): 19 | """ 20 | Получает профсоюзную информацию пользователя. 21 | 22 | Скоупы: `["userdata.info.admin"]` 23 | """ 24 | 25 | return await get_user_info(user_id, user) 26 | 27 | 28 | @admin.patch("/user/{user_id}", response_model=StatusResponseModel) 29 | async def update_user_card( 30 | new_info: UserCardUpdate, 31 | user_id: int, 32 | user: dict[str, Any] = Depends(UnionAuth(scopes=["userdata.info.admin"], allow_none=False, auto_error=True)), 33 | ) -> StatusResponseModel: 34 | """ 35 | Обновить данные в профсоюзной информации пользователя. 36 | 37 | Скоупы: `["userdata.info.admin"]` 38 | 39 | - **user_id**: id пользователя. 40 | """ 41 | 42 | await patch_user_info(new_info, user_id, user) 43 | return StatusResponseModel(status="Success", message="User patch succeeded", ru="Изменение успешно") 44 | -------------------------------------------------------------------------------- /userdata_api/exceptions.py: -------------------------------------------------------------------------------- 1 | class UserDataApiError(Exception): 2 | def __init__(self, error_en: str, error_ru: str) -> None: 3 | self.en = error_en 4 | self.ru = error_ru 5 | super().__init__(error_en) 6 | 7 | 8 | class ObjectNotFound(UserDataApiError): 9 | def __init__(self, obj: type, obj_id_or_name: int | str): 10 | super().__init__( 11 | f"Object {obj.__name__} {obj_id_or_name} not found", 12 | f"Объект {obj.__name__} с идиентификатором {obj_id_or_name} не найден", 13 | ) 14 | 15 | 16 | class AlreadyExists(UserDataApiError): 17 | def __init__(self, obj: type, obj_id_or_name: int | str): 18 | super().__init__( 19 | f"Object {obj.__name__} {obj_id_or_name} already exists", 20 | f"Объект {obj.__name__} с идентфикатором {obj_id_or_name} уже существует", 21 | ) 22 | 23 | 24 | class InvalidValidation(UserDataApiError): 25 | def __init__(self, obj: type, field_name: str): 26 | super().__init__( 27 | f"Invalid validation for field {field_name} in object {obj.__name__}", 28 | f"Некорректная валидация для поля {field_name} в объекте {obj.__name__} ", 29 | ) 30 | 31 | 32 | class InvalidRegex(UserDataApiError): 33 | def __init__(self, obj: type, field_name: str): 34 | super().__init__( 35 | f"Invalid regex for field {field_name} in object {obj.__name__}", 36 | f"Некорректное регулярное выражение для поля {field_name} в объекте {obj.__name__} ", 37 | ) 38 | 39 | 40 | class Forbidden(UserDataApiError): 41 | pass 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2022, Профком студентов физфака МГУ 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /userdata_api/schemas/types/scope.py: -------------------------------------------------------------------------------- 1 | import string 2 | from typing import Any 3 | 4 | from pydantic import GetCoreSchemaHandler, GetJsonSchemaHandler 5 | from pydantic.json_schema import JsonSchemaValue 6 | from pydantic_core import core_schema 7 | 8 | 9 | class Scope: 10 | """ 11 | Класс для валидации строки скоупа 12 | Скоуп должен быть строкой 13 | Скоуп должен быть не пустой строкой 14 | Скоуп не может начинаться с точки или заканчиваться ей 15 | Скоуп должен состоять только из букв, точек и подчеркиваний 16 | """ 17 | 18 | @classmethod 19 | def __get_pydantic_core_schema__( 20 | cls, 21 | source: type[Any], 22 | handler: GetCoreSchemaHandler, 23 | ) -> core_schema.CoreSchema: 24 | return core_schema.general_after_validator_function(cls._validate, core_schema.str_schema()) 25 | 26 | @classmethod 27 | def __get_pydantic_json_schema__( 28 | cls, core_schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler 29 | ) -> JsonSchemaValue: 30 | field_schema = handler(core_schema) 31 | field_schema.update(type='string', format='scope') 32 | return field_schema 33 | 34 | @classmethod 35 | def _validate(cls, __input_value: str, _: core_schema.ValidationInfo) -> str: 36 | if __input_value == "": 37 | raise ValueError("Empty string are not allowed") 38 | __input_value = str(__input_value).strip().lower() 39 | if __input_value[0] == "." or __input_value[-1] == ".": 40 | raise ValueError("Dot can not be leading or closing") 41 | if len(set(__input_value) - set(string.ascii_lowercase + "._")) > 0: 42 | raise ValueError("Only letters, dot and underscore allowed") 43 | return __input_value 44 | -------------------------------------------------------------------------------- /userdata_api/routes/exc_handlers.py: -------------------------------------------------------------------------------- 1 | import starlette 2 | from starlette.responses import JSONResponse 3 | 4 | from ..exceptions import AlreadyExists, Forbidden, InvalidRegex, InvalidValidation, ObjectNotFound 5 | from ..schemas.response_model import StatusResponseModel 6 | from .base import app 7 | 8 | 9 | @app.exception_handler(ObjectNotFound) 10 | async def not_found_handler(req: starlette.requests.Request, exc: ObjectNotFound): 11 | return JSONResponse( 12 | content=StatusResponseModel(status="Error", message=exc.en, ru=exc.ru).model_dump(), status_code=404 13 | ) 14 | 15 | 16 | @app.exception_handler(Forbidden) 17 | async def forbidden_handler(req: starlette.requests.Request, exc: Forbidden): 18 | return JSONResponse( 19 | content=StatusResponseModel(status="Forbidden", message=exc.en, ru=exc.ru).model_dump(), status_code=403 20 | ) 21 | 22 | 23 | @app.exception_handler(AlreadyExists) 24 | async def already_exists_handler(req: starlette.requests.Request, exc: AlreadyExists): 25 | return JSONResponse( 26 | content=StatusResponseModel(status="Already exists", message=exc.en, ru=exc.ru).model_dump(), status_code=409 27 | ) 28 | 29 | 30 | @app.exception_handler(InvalidValidation) 31 | async def invalid_validation_handler(req: starlette.requests.Request, exc: InvalidValidation): 32 | return JSONResponse( 33 | content=StatusResponseModel(status="Invalid validation", message=exc.en, ru=exc.ru).model_dump(), 34 | status_code=422, 35 | ) 36 | 37 | 38 | @app.exception_handler(InvalidRegex) 39 | async def invalid_regex_handler(req: starlette.requests.Request, exc: InvalidRegex): 40 | return JSONResponse( 41 | content=StatusResponseModel(status="Invalid regex", message=exc.en, ru=exc.ru).model_dump(), status_code=422 42 | ) 43 | -------------------------------------------------------------------------------- /worker/user.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import sqlalchemy.orm 4 | from event_schema.auth import UserLogin 5 | from sqlalchemy import not_ 6 | 7 | from userdata_api.models.db import Category, Info, Param, Source 8 | 9 | 10 | log = logging.getLogger(__name__) 11 | 12 | 13 | def patch_user_info(new: UserLogin, user_id: int, *, session: sqlalchemy.orm.Session) -> None: 14 | for item in new.items: 15 | param = ( 16 | session.query(Param) 17 | .join(Category) 18 | .filter( 19 | Param.name == item.param, 20 | Category.name == item.category, 21 | not_(Param.is_deleted), 22 | not_(Category.is_deleted), 23 | ) 24 | .one_or_none() 25 | ) 26 | if not param: 27 | session.rollback() 28 | log.error(f"Param {item.param=} not found") 29 | return 30 | info = ( 31 | session.query(Info) 32 | .join(Source) 33 | .filter( 34 | Info.param_id == param.id, 35 | Info.owner_id == user_id, 36 | Source.name == new.source, 37 | not_(Info.is_deleted), 38 | ) 39 | .one_or_none() 40 | ) 41 | if not info and item.value is None: 42 | continue 43 | if not info: 44 | source = Source.query(session=session).filter(Source.name == new.source).one_or_none() 45 | if not source: 46 | session.rollback() 47 | log.warning(f"Source {new.source=} not found") 48 | return 49 | Info.create( 50 | session=session, 51 | owner_id=user_id, 52 | param_id=param.id, 53 | source_id=source.id, 54 | value=item.value, 55 | ) 56 | continue 57 | if item.value is not None: 58 | info.value = item.value 59 | session.flush() 60 | continue 61 | if item.value is None: 62 | info.is_deleted = True 63 | session.flush() 64 | continue 65 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /migrations/env.py: -------------------------------------------------------------------------------- 1 | from logging.config import fileConfig 2 | 3 | from alembic import context 4 | from sqlalchemy import engine_from_config, pool 5 | 6 | from settings import get_settings 7 | from userdata_api.models.base import Base 8 | 9 | 10 | # this is the Alembic Config object, which provides 11 | # access to the values within the .ini file in use. 12 | config = context.config 13 | settings = get_settings() 14 | 15 | # Interpret the config file for Python logging. 16 | # This line sets up loggers basically. 17 | if config.config_file_name is not None: 18 | fileConfig(config.config_file_name) 19 | 20 | # add your model's MetaData object here 21 | # for 'autogenerate' support 22 | # from myapp import mymodel 23 | # target_metadata = mymodel.Base.metadata 24 | target_metadata = Base.metadata 25 | 26 | # other values from the config, defined by the needs of env.py, 27 | # can be acquired: 28 | # my_important_option = config.get_main_option("my_important_option") 29 | # ... etc. 30 | 31 | 32 | def run_migrations_offline(): 33 | """Run migrations in 'offline' mode. 34 | 35 | This configures the context with just a URL 36 | and not an Engine, though an Engine is acceptable 37 | here as well. By skipping the Engine creation 38 | we don't even need a DBAPI to be available. 39 | 40 | Calls to context.execute() here emit the given string to the 41 | script output. 42 | 43 | """ 44 | url = config.get_main_option("sqlalchemy.url") 45 | context.configure( 46 | url=url, 47 | target_metadata=target_metadata, 48 | literal_binds=True, 49 | dialect_opts={"paramstyle": "named"}, 50 | ) 51 | 52 | with context.begin_transaction(): 53 | context.run_migrations() 54 | 55 | 56 | def run_migrations_online(): 57 | """Run migrations in 'online' mode. 58 | 59 | In this scenario we need to create an Engine 60 | and associate a connection with the context. 61 | 62 | """ 63 | configuration = config.get_section(config.config_ini_section) 64 | configuration['sqlalchemy.url'] = str(settings.DB_DSN) 65 | connectable = engine_from_config( 66 | configuration, 67 | prefix="sqlalchemy.", 68 | poolclass=pool.NullPool, 69 | ) 70 | 71 | with connectable.connect() as connection: 72 | context.configure(connection=connection, target_metadata=target_metadata) 73 | 74 | with context.begin_transaction(): 75 | context.run_migrations() 76 | 77 | 78 | if context.is_offline_mode(): 79 | run_migrations_offline() 80 | else: 81 | run_migrations_online() 82 | -------------------------------------------------------------------------------- /.github/workflows/checks.yml: -------------------------------------------------------------------------------- 1 | name: Python tests 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | test: 8 | name: Unit tests 9 | runs-on: ubuntu-latest 10 | services: 11 | postgres: 12 | image: postgres:15 13 | env: 14 | POSTGRES_HOST_AUTH_METHOD: trust 15 | options: >- 16 | --health-cmd pg_isready 17 | --health-interval 10s 18 | --health-timeout 5s 19 | --health-retries 5 20 | -p 5432:5432 21 | steps: 22 | - name: Checkout 23 | uses: actions/checkout@v4 24 | - uses: actions/setup-python@v4 25 | with: 26 | python-version: "3.11" 27 | - name: Install dependencies 28 | run: | 29 | python -m ensurepip 30 | python -m pip install --upgrade pip 31 | pip install -r requirements.txt -r requirements.dev.txt 32 | - name: Migrate DB 33 | run: | 34 | DB_DSN=postgresql://postgres@localhost:5432/postgres alembic upgrade head 35 | - name: Build coverage file 36 | id: pytest 37 | run: | 38 | DB_DSN=postgresql://postgres@localhost:5432/postgres pytest --junitxml=pytest.xml --cov-report=term-missing:skip-covered --cov=userdata_api tests/ | tee pytest-coverage.txt 39 | exit ${PIPESTATUS[0]} 40 | - name: Print report 41 | if: always() 42 | run: | 43 | cat pytest-coverage.txt 44 | - name: Pytest coverage comment 45 | uses: MishaKav/pytest-coverage-comment@main 46 | with: 47 | pytest-coverage-path: ./pytest-coverage.txt 48 | title: Coverage Report 49 | badge-title: Code Coverage 50 | hide-badge: false 51 | hide-report: false 52 | create-new-comment: false 53 | hide-comment: false 54 | report-only-changed-files: false 55 | remove-link-from-badge: false 56 | unique-id-for-comment: python3.11 57 | junitxml-path: ./pytest.xml 58 | junitxml-title: Summary 59 | - name: Fail on pytest errors 60 | if: steps.pytest.outcome == 'failure' 61 | run: exit 1 62 | 63 | linting: 64 | runs-on: ubuntu-latest 65 | steps: 66 | - uses: actions/checkout@v4 67 | - uses: actions/setup-python@v4 68 | with: 69 | python-version: 3.11 70 | - uses: isort/isort-action@master 71 | with: 72 | requirementsFiles: "requirements.txt requirements.dev.txt" 73 | - uses: psf/black@stable 74 | - name: Comment if linting failed 75 | if: failure() 76 | uses: thollander/actions-comment-pull-request@v2 77 | with: 78 | message: | 79 | :poop: Code linting failed, use `black` and `isort` to fix it. 80 | -------------------------------------------------------------------------------- /tests/test_routes/test_source.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from userdata_api.exceptions import ObjectNotFound 4 | from userdata_api.models.db import * 5 | from userdata_api.schemas.source import SourceGet 6 | from userdata_api.utils.utils import random_string 7 | 8 | 9 | @pytest.mark.authenticated("userdata.source.create") 10 | def test_create(client, dbsession): 11 | name = f"test{random_string()}" 12 | response = client.post("/source", json={"name": name, "trust_level": 12}) 13 | assert response.status_code == 422 14 | response = client.post("/source", json={"name": name, "trust_level": 8}) 15 | assert response.status_code == 200 16 | q = Source.get(response.json()["id"], session=dbsession) 17 | assert q 18 | assert response.json()["name"] == q.name == name 19 | assert response.json()["trust_level"] == q.trust_level == 8 20 | assert response.json()["id"] == q.id 21 | dbsession.delete(q) 22 | dbsession.flush() 23 | 24 | 25 | @pytest.mark.authenticated() 26 | def test_get(client, dbsession, source): 27 | _source = source() 28 | response = client.get(f"/source/{_source.id}") 29 | assert response.status_code == 200 30 | assert response.json()["name"] == _source.name 31 | assert response.json()["trust_level"] == _source.trust_level 32 | assert response.json()["id"] == _source.id 33 | 34 | 35 | @pytest.mark.authenticated() 36 | def test_get_all(client, dbsession, source): 37 | source1 = source() 38 | source2 = source() 39 | response = client.get(f"/source") 40 | assert response.status_code == 200 41 | assert SourceGet.from_orm(source1).dict() in response.json() 42 | assert SourceGet.from_orm(source2).dict() in response.json() 43 | 44 | 45 | @pytest.mark.authenticated("userdata.source.update") 46 | def test_update(client, dbsession, source): 47 | _source = source() 48 | response = client.patch(f"/source/{_source.id}", json={"name": f"{_source.name}updated", "trust_level": 7}) 49 | assert response.status_code == 200 50 | assert response.json()["name"] == f"{_source.name}updated" 51 | assert response.json()["trust_level"] == 7 52 | dbsession.expire_all() 53 | q = Source.get(_source.id, session=dbsession) 54 | assert q 55 | assert response.json()["name"] == q.name 56 | assert response.json()["trust_level"] == q.trust_level 57 | assert response.json()["id"] == q.id 58 | 59 | 60 | @pytest.mark.authenticated("userdata.source.delete") 61 | def test_delete(client, dbsession, source): 62 | _source = source() 63 | response = client.delete(f"/source/{_source.id}") 64 | assert response.status_code == 200 65 | response = client.get(f"/source/{_source.id}") 66 | assert response.status_code == 404 67 | with pytest.raises(ObjectNotFound): 68 | Source.get(_source.id, session=dbsession) 69 | assert Source.get(_source.id, with_deleted=True, session=dbsession) 70 | -------------------------------------------------------------------------------- /userdata_api/models/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | 5 | from sqlalchemy import Integer, not_ 6 | from sqlalchemy.exc import NoResultFound 7 | from sqlalchemy.orm import Mapped, Query, Session, as_declarative, declared_attr, mapped_column 8 | 9 | from userdata_api.exceptions import ObjectNotFound 10 | 11 | 12 | @as_declarative() 13 | class Base: 14 | """Base class for all database entities""" 15 | 16 | @classmethod 17 | @declared_attr 18 | def __tablename__(cls) -> str: 19 | """Generate database table name automatically. 20 | Convert CamelCase class name to snake_case db table name. 21 | """ 22 | return re.sub(r"(? str: 25 | attrs = [] 26 | for c in self.__table__.columns: 27 | attrs.append(f"{c.name}={getattr(self, c.name)}") 28 | return "{}({})".format(self.__class__.__name__, ', '.join(attrs)) 29 | 30 | 31 | class BaseDbModel(Base): 32 | __abstract__ = True 33 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 34 | 35 | @classmethod 36 | def create(cls, *, session: Session, **kwargs) -> BaseDbModel: 37 | obj = cls(**kwargs) 38 | session.add(obj) 39 | session.flush() 40 | return obj 41 | 42 | @classmethod 43 | def query(cls, *, with_deleted: bool = False, session: Session) -> Query: 44 | """Создает запрос с софт делитами, возвращает Query""" 45 | objs = session.query(cls) 46 | if not with_deleted and hasattr(cls, "is_deleted"): 47 | objs = objs.filter(not_(cls.is_deleted)) 48 | return objs 49 | 50 | @classmethod 51 | def get(cls, id: int, *, with_deleted=False, session: Session) -> BaseDbModel: 52 | """Get object with soft deletes""" 53 | objs = session.query(cls) 54 | if not with_deleted and hasattr(cls, "is_deleted"): 55 | objs = objs.filter(not_(cls.is_deleted)) 56 | try: 57 | return objs.filter(cls.id == id).one() 58 | except NoResultFound: 59 | raise ObjectNotFound(cls, id) 60 | 61 | @classmethod 62 | def update(cls, id: int, *, session: Session, **kwargs) -> BaseDbModel: 63 | obj = cls.get(id, session=session) 64 | for k, v in kwargs.items(): 65 | setattr(obj, k, v) 66 | session.flush() 67 | return obj 68 | 69 | @classmethod 70 | def delete(cls, id: int, *, session: Session) -> None: 71 | """Soft delete object if possible, else hard delete""" 72 | obj = cls.get(id, session=session) 73 | if hasattr(obj, "is_deleted"): 74 | obj.is_deleted = True 75 | else: 76 | session.delete(obj) 77 | session.flush() 78 | 79 | @property 80 | def _col_names(self): 81 | return list(self.__table__.columns.keys()) 82 | 83 | def dict(self: BaseDbModel): 84 | res = {} 85 | for attr_name in dir(self): 86 | if attr_name not in self._col_names: 87 | continue 88 | res[attr_name] = getattr(self, attr_name) 89 | return res 90 | -------------------------------------------------------------------------------- /migrations/versions/f8c57101c0f6_init.py: -------------------------------------------------------------------------------- 1 | """Init 2 | 3 | Revision ID: f8c57101c0f6 4 | Revises: 5 | Create Date: 2023-05-09 12:48:25.550608 6 | 7 | """ 8 | 9 | import sqlalchemy as sa 10 | from alembic import op 11 | 12 | 13 | revision = 'f8c57101c0f6' 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | op.create_table( 21 | 'category', 22 | sa.Column('name', sa.String(), nullable=False), 23 | sa.Column('read_scope', sa.String(), nullable=True), 24 | sa.Column('update_scope', sa.String(), nullable=True), 25 | sa.Column('create_ts', sa.DateTime(), nullable=False), 26 | sa.Column('modify_ts', sa.DateTime(), nullable=False), 27 | sa.Column('is_deleted', sa.Boolean(), nullable=False), 28 | sa.Column('id', sa.Integer(), nullable=False), 29 | sa.PrimaryKeyConstraint('id'), 30 | ) 31 | op.create_table( 32 | 'source', 33 | sa.Column('name', sa.String(), nullable=False), 34 | sa.Column('trust_level', sa.Integer(), nullable=False), 35 | sa.Column('create_ts', sa.DateTime(), nullable=False), 36 | sa.Column('modify_ts', sa.DateTime(), nullable=False), 37 | sa.Column('is_deleted', sa.Boolean(), nullable=False), 38 | sa.Column('id', sa.Integer(), nullable=False), 39 | sa.PrimaryKeyConstraint('id'), 40 | sa.UniqueConstraint('name'), 41 | ) 42 | op.create_table( 43 | 'param', 44 | sa.Column('name', sa.String(), nullable=False), 45 | sa.Column('category_id', sa.Integer(), nullable=False), 46 | sa.Column('is_required', sa.Boolean(), nullable=False), 47 | sa.Column('changeable', sa.Boolean(), nullable=False), 48 | sa.Column('type', sa.Enum('ALL', 'LAST', 'MOST_TRUSTED', name='viewtype', native_enum=False), nullable=False), 49 | sa.Column('create_ts', sa.DateTime(), nullable=False), 50 | sa.Column('modify_ts', sa.DateTime(), nullable=False), 51 | sa.Column('is_deleted', sa.Boolean(), nullable=False), 52 | sa.Column('id', sa.Integer(), nullable=False), 53 | sa.ForeignKeyConstraint( 54 | ['category_id'], 55 | ['category.id'], 56 | ), 57 | sa.PrimaryKeyConstraint('id'), 58 | ) 59 | op.create_table( 60 | 'info', 61 | sa.Column('param_id', sa.Integer(), nullable=False), 62 | sa.Column('source_id', sa.Integer(), nullable=False), 63 | sa.Column('owner_id', sa.Integer(), nullable=False), 64 | sa.Column('value', sa.String(), nullable=False), 65 | sa.Column('create_ts', sa.DateTime(), nullable=False), 66 | sa.Column('modify_ts', sa.DateTime(), nullable=False), 67 | sa.Column('is_deleted', sa.Boolean(), nullable=False), 68 | sa.Column('id', sa.Integer(), nullable=False), 69 | sa.ForeignKeyConstraint( 70 | ['param_id'], 71 | ['param.id'], 72 | ), 73 | sa.ForeignKeyConstraint( 74 | ['source_id'], 75 | ['source.id'], 76 | ), 77 | sa.PrimaryKeyConstraint('id'), 78 | ) 79 | 80 | 81 | def downgrade(): 82 | op.drop_table('info') 83 | op.drop_table('param') 84 | op.drop_table('source') 85 | op.drop_table('category') 86 | -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = migrations 6 | 7 | # template used to generate migration files 8 | # file_template = %%(rev)s_%%(slug)s 9 | 10 | # sys.path path, will be prepended to sys.path if present. 11 | # defaults to the current working directory. 12 | prepend_sys_path = . 13 | 14 | # timezone to use when rendering the date within the migration file 15 | # as well as the filename. 16 | # If specified, requires the python-dateutil library that can be 17 | # installed by adding `alembic[tz]` to the pip requirements 18 | # string value is passed to dateutil.tz.gettz() 19 | # leave blank for localtime 20 | # timezone = 21 | 22 | # max length of characters to apply to the 23 | # "slug" field 24 | # truncate_slug_length = 40 25 | 26 | # set to 'true' to run the environment during 27 | # the 'revision' command, regardless of autogenerate 28 | # revision_environment = false 29 | 30 | # set to 'true' to allow .pyc and .pyo files without 31 | # a source .py file to be detected as revisions in the 32 | # versions/ directory 33 | # sourceless = false 34 | 35 | # version location specification; This defaults 36 | # to migrations/versions. When using multiple version 37 | # directories, initial revisions must be specified with --version-path. 38 | # The path separator used here should be the separator specified by "version_path_separator" below. 39 | # version_locations = %(here)s/bar:%(here)s/bat:migrations/versions 40 | 41 | # version path separator; As mentioned above, this is the character used to split 42 | # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. 43 | # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. 44 | # Valid values for version_path_separator are: 45 | # 46 | # version_path_separator = : 47 | # version_path_separator = ; 48 | # version_path_separator = space 49 | version_path_separator = os # Use os.pathsep. Default configuration used for new projects. 50 | 51 | # the output encoding used when revision files 52 | # are written from script.py.mako 53 | # output_encoding = utf-8 54 | 55 | sqlalchemy.url = driver://user:pass@localhost/dbname 56 | 57 | 58 | [post_write_hooks] 59 | # post_write_hooks defines scripts or Python functions that are run 60 | # on newly generated revision scripts. See the documentation for further 61 | # detail and examples 62 | 63 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 64 | # hooks = black 65 | # black.type = console_scripts 66 | # black.entrypoint = black 67 | # black.options = -l 79 REVISION_SCRIPT_FILENAME 68 | 69 | # Logging configuration 70 | [loggers] 71 | keys = root,sqlalchemy,alembic 72 | 73 | [handlers] 74 | keys = console 75 | 76 | [formatters] 77 | keys = generic 78 | 79 | [logger_root] 80 | level = WARN 81 | handlers = console 82 | qualname = 83 | 84 | [logger_sqlalchemy] 85 | level = WARN 86 | handlers = 87 | qualname = sqlalchemy.engine 88 | 89 | [logger_alembic] 90 | level = INFO 91 | handlers = 92 | qualname = alembic 93 | 94 | [handler_console] 95 | class = StreamHandler 96 | args = (sys.stderr,) 97 | level = NOTSET 98 | formatter = generic 99 | 100 | [formatter_generic] 101 | format = %(levelname)-5.5s [%(name)s] %(message)s 102 | datefmt = %H:%M:%S 103 | -------------------------------------------------------------------------------- /tests/test_worker/test_worker.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import sqlalchemy.exc 3 | from event_schema.auth import UserLogin 4 | 5 | from userdata_api.models.db import Category, Info, Param, Source 6 | from userdata_api.utils.utils import random_string 7 | from worker.user import patch_user_info 8 | 9 | 10 | @pytest.fixture() 11 | def category(dbsession): 12 | name = f"test{random_string()}" 13 | dbsession.add( 14 | _cat := Category( 15 | name=name, read_scope=f"testscope.{random_string()}", update_scope=f"testscope.{random_string()}" 16 | ) 17 | ) 18 | dbsession.commit() 19 | yield _cat 20 | dbsession.delete(_cat) 21 | dbsession.commit() 22 | 23 | 24 | @pytest.fixture() 25 | def param(dbsession, category): 26 | time_ = f"test{random_string()}" 27 | dbsession.add( 28 | _par := Param(name=f"test{time_}", category_id=category.id, type="last", changeable=True, is_required=True) 29 | ) 30 | dbsession.commit() 31 | yield _par 32 | dbsession.delete(_par) 33 | dbsession.commit() 34 | 35 | 36 | @pytest.fixture() 37 | def source(dbsession): 38 | time_ = f"test{random_string()}" 39 | __source = Source(name=f"test{time_}", trust_level=8) 40 | dbsession.add(__source) 41 | dbsession.commit() 42 | yield __source 43 | dbsession.delete(__source) 44 | dbsession.commit() 45 | 46 | 47 | @pytest.fixture() 48 | def info(param, source, dbsession): 49 | time_ = f"test{random_string()}" 50 | __info = Info(value=f"test{time_}", source_id=source.id, param_id=param.id, owner_id=1) 51 | dbsession.add(__info) 52 | dbsession.commit() 53 | yield __info 54 | try: 55 | dbsession.delete(__info) 56 | dbsession.commit() 57 | except sqlalchemy.exc.Any: 58 | pass 59 | 60 | 61 | def test_create(param, source, dbsession): 62 | with pytest.raises(sqlalchemy.exc.NoResultFound): 63 | dbsession.query(Info).filter(Info.param_id == param.id, Info.source_id == source.id, Info.value == "test").one() 64 | patch_user_info( 65 | UserLogin.model_validate( 66 | {"items": [{"category": param.category.name, "param": param.name, "value": "test"}], "source": source.name} 67 | ), 68 | 1, 69 | session=dbsession, 70 | ) 71 | info = ( 72 | dbsession.query(Info).filter(Info.param_id == param.id, Info.source_id == source.id, Info.value == "test").one() 73 | ) 74 | assert info 75 | dbsession.delete(info) 76 | dbsession.commit() 77 | 78 | 79 | def test_update(info, dbsession): 80 | assert info.value != "updated" 81 | patch_user_info( 82 | UserLogin.model_validate( 83 | { 84 | "items": [{"category": info.category.name, "param": info.param.name, "value": "updated"}], 85 | "source": info.source.name, 86 | } 87 | ), 88 | 1, 89 | session=dbsession, 90 | ) 91 | 92 | dbsession.expire(info) 93 | assert info.value == "updated" 94 | 95 | 96 | def test_delete(info, dbsession): 97 | assert info.is_deleted is False 98 | patch_user_info( 99 | UserLogin.model_validate( 100 | { 101 | "items": [{"category": info.category.name, "param": info.param.name, "value": None}], 102 | "source": info.source.name, 103 | } 104 | ), 105 | 1, 106 | session=dbsession, 107 | ) 108 | 109 | dbsession.expire(info) 110 | assert info.is_deleted is True 111 | -------------------------------------------------------------------------------- /userdata_api/routes/source.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from auth_lib.fastapi import UnionAuth 4 | from fastapi import APIRouter, Depends, Request 5 | from fastapi_sqlalchemy import db 6 | from pydantic.type_adapter import TypeAdapter 7 | 8 | from userdata_api.exceptions import AlreadyExists 9 | from userdata_api.models.db import Source 10 | from userdata_api.schemas.response_model import StatusResponseModel 11 | from userdata_api.schemas.source import SourceGet, SourcePatch, SourcePost 12 | 13 | 14 | source = APIRouter(prefix="/source", tags=["Source"]) 15 | 16 | 17 | @source.post("", response_model=SourceGet) 18 | async def create_source( 19 | request: Request, 20 | source_inp: SourcePost, 21 | _: dict[str, Any] = Depends(UnionAuth(scopes=["userdata.source.create"], allow_none=False, auto_error=True)), 22 | ) -> SourceGet: 23 | """ 24 | Создать источник данных 25 | 26 | Scopes: `["userdata.source.create"]` 27 | \f 28 | :param request: https://fastapi.tiangolo.com/advanced/using-request-directly/ 29 | :param source_inp: Моделька для создания 30 | :param _: Аутентификация 31 | :return: SourceGet - созданный источник 32 | """ 33 | source = Source.query(session=db.session).filter(Source.name == source_inp.name).all() 34 | if source: 35 | raise AlreadyExists(Source, source_inp.name) 36 | return SourceGet.model_validate(Source.create(session=db.session, **source_inp.dict())) 37 | 38 | 39 | @source.get("/{id}", response_model=SourceGet) 40 | async def get_source(id: int) -> SourceGet: 41 | """ 42 | Получить источник данных 43 | \f 44 | :param id: Айди источника 45 | :return: SourceGet - полученный источник 46 | """ 47 | return SourceGet.model_validate(Source.get(id, session=db.session)) 48 | 49 | 50 | @source.get("", response_model=list[SourceGet]) 51 | async def get_sources() -> list[SourceGet]: 52 | """ 53 | Получить все источники данных 54 | \f 55 | :return: list[SourceGet] - список источников данных 56 | """ 57 | type_adapter = TypeAdapter(list[SourceGet]) 58 | return type_adapter.validate_python(Source.query(session=db.session).all()) 59 | 60 | 61 | @source.patch("/{id}", response_model=SourceGet) 62 | async def patch_source( 63 | request: Request, 64 | id: int, 65 | source_inp: SourcePatch, 66 | _: dict[str, Any] = Depends(UnionAuth(scopes=["userdata.source.update"], allow_none=False, auto_error=True)), 67 | ) -> SourceGet: 68 | """ 69 | Обновить источник данных 70 | 71 | Scopes: `["userdata.source.update"]` 72 | \f 73 | :param request: https://fastapi.tiangolo.com/advanced/using-request-directly/ 74 | :param id: Айди обновляемого источника 75 | :param source_inp: Моделька для обновления 76 | :param _: Аутентификация 77 | :return: SourceGet - обновленный источник данных 78 | """ 79 | return SourceGet.model_validate(Source.update(id, session=db.session, **source_inp.dict(exclude_unset=True))) 80 | 81 | 82 | @source.delete("/{id}", response_model=StatusResponseModel) 83 | async def delete_source( 84 | request: Request, 85 | id: int, 86 | _: dict[str, Any] = Depends(UnionAuth(scopes=["userdata.source.delete"], allow_none=False, auto_error=True)), 87 | ) -> StatusResponseModel: 88 | """ 89 | Удалить источник данных 90 | 91 | Scopes: `["userdata.source.delete"]` 92 | \f 93 | :param request: https://fastapi.tiangolo.com/advanced/using-request-directly/ 94 | :param id: Айди удаляемого источника 95 | :param _: Аутентфиикация 96 | :return: None 97 | """ 98 | Source.delete(id, session=db.session) 99 | return StatusResponseModel(status="Success", message="Source deleted", ru="Источник удален") 100 | -------------------------------------------------------------------------------- /userdata_api/utils/admin.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from fastapi_sqlalchemy import db 4 | 5 | from userdata_api.exceptions import ObjectNotFound 6 | from userdata_api.models.db import Info, Param 7 | from userdata_api.schemas.admin import UserCardGet, UserCardUpdate 8 | from userdata_api.schemas.user import UserInfo, UserInfoUpdate 9 | 10 | from .user import patch_user_info as user_patch 11 | 12 | 13 | async def patch_user_info(new: UserCardUpdate, user_id: int, user: dict[str, int | list[dict[str, str | int]]]) -> None: 14 | """ 15 | Обновить информацию о пользователе в соотетствии с переданным токеном. 16 | 17 | Метод обновляет только информацию из источников `admin`. 18 | 19 | Для обновления от имени админа нужен скоуп `userdata.info.admin` 20 | 21 | :param new: модель запроса, в ней то, на что будет изменена информация о пользователе 22 | :param user_id: Айди пользователя 23 | :param user: Сессия пользователя выполняющего запрос 24 | :return: get_user_info для текущего пользователя с переданными правами 25 | """ 26 | update_info = [] 27 | if new.full_name is not None: 28 | update_info.append(UserInfo(category="Личная информация", param="Полное имя", value=new.full_name)) 29 | if new.student_card_number is not None: 30 | update_info.append( 31 | UserInfo(category="Учёба", param="Номер студенческого билета", value=new.student_card_number) 32 | ) 33 | if update_info: 34 | update_request = UserInfoUpdate(items=update_info, source="admin") 35 | await user_patch(update_request, user_id, user) 36 | 37 | 38 | async def get_user_info(user_id: int, user: dict[str, int | list[dict[str, str | int]]]) -> UserCardGet: 39 | """ 40 | Получить профсоюзную информацию пользователя для админки. 41 | 42 | :param user_id: Айди пользователя, информацию о котором запрашиваем 43 | :param user: Сессия пользователя, выполняющего запрос (должен иметь права администратора) 44 | :return: Словарь с данными пользователя: 45 | - user_id: ID пользователя 46 | - full_name: Полное имя (из параметра "Полное имя") 47 | - student_card_number: Номер студенческого билета (из параметра "Номер студенческого билета") 48 | - union_card_number: Номер профсоюзного билета (из параметра "Номер профсоюзного билета") 49 | - is_union_member: Статус мэтчинга (из параметра "Членство в профсоюзе") 50 | - last_check_timestamp: Дата последней проверки 51 | """ 52 | users = db.session.query(Info).filter(Info.owner_id == user_id).first() 53 | if not users: 54 | raise ObjectNotFound(Info, user_id) 55 | full_name = ( 56 | db.session.query(Info) 57 | .join(Info.param) 58 | .filter(Info.owner_id == user_id, Param.name == "Полное имя") 59 | .one_or_none() 60 | ) 61 | is_union_member = ( 62 | db.session.query(Info) 63 | .join(Info.param) 64 | .filter(Info.owner_id == user_id, Param.name == "Членство в профсоюзе") 65 | .one_or_none() 66 | ) 67 | student_card_number = ( 68 | db.session.query(Info) 69 | .join(Info.param) 70 | .filter(Info.owner_id == user_id, Param.name == "Номер студенческого билета") 71 | .one_or_none() 72 | ) 73 | union_card_number = ( 74 | db.session.query(Info) 75 | .join(Info.param) 76 | .filter(Info.owner_id == user_id, Param.name == "Номер профсоюзного билета") 77 | .one_or_none() 78 | ) 79 | result = { 80 | "user_id": user_id, 81 | "full_name": full_name.value if full_name else None, 82 | "student_card_number": student_card_number.value if student_card_number else None, 83 | "union_card_number": union_card_number.value if union_card_number else None, 84 | "is_union_member": is_union_member.value if is_union_member else "false", 85 | } 86 | return result 87 | -------------------------------------------------------------------------------- /userdata_api/routes/user.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from auth_lib.fastapi import UnionAuth 4 | from fastapi import APIRouter, Depends, Query 5 | 6 | from userdata_api.schemas.response_model import StatusResponseModel 7 | from userdata_api.schemas.user import UserInfoGet, UserInfoUpdate, UsersInfoGet 8 | from userdata_api.utils.user import get_user_info as get 9 | from userdata_api.utils.user import get_users_info_batch as get_users 10 | from userdata_api.utils.user import patch_user_info as patch 11 | 12 | 13 | user = APIRouter(prefix="/user", tags=["User"]) 14 | 15 | 16 | @user.get("/{id}", response_model=UserInfoGet) 17 | async def get_user_info( 18 | id: int, 19 | user: dict[str, Any] = Depends(UnionAuth(scopes=[], allow_none=False, auto_error=True)), 20 | ) -> UserInfoGet: 21 | """ 22 | Получить информацию о пользователе 23 | \f 24 | :param id: Айди овнера информации(пользователя) 25 | :additional_data: список невидимых по дефолту параметров 26 | :param user: Аутентфикация 27 | :return: Словарь, ключи - категории на которые хватило прав(овнеру не нужны права, он получает всё). 28 | Значения - словари, ключи которых - имена параметров, 29 | внутри соответствующих категорий, значния - значения этих параметров у конкретного пользователя 30 | Например: 31 | {student: {card: 123, group: 342}, 32 | profcomff: {card: 1231231, is_member: True}, 33 | ... 34 | } 35 | """ 36 | 37 | return UserInfoGet.model_validate(await get(id, user)) 38 | 39 | 40 | @user.post("/{id}", response_model=StatusResponseModel) 41 | async def update_user( 42 | new_info: UserInfoUpdate, 43 | id: int, 44 | user: dict[str, Any] = Depends(UnionAuth(scopes=[], allow_none=False, auto_error=True)), 45 | ) -> StatusResponseModel: 46 | """ 47 | Обновить информацию о пользователе. 48 | Объект - пользователь, информацию которого обновляют 49 | Субъект - пользователь, который обновляет - источник 50 | 51 | Если не указать параметр внутри категории, то ничего не обновится, если указать что-то, 52 | то либо создастся новая запись(в случае, если она отсутствовала у данного источника), либо отредактируется 53 | старая. Если в значении параметра указан None, то соответствующая информациия удаляется из данного источника 54 | 55 | Обновлять через эту ручку можно только от имени источников admin и user. 56 | 57 | Чтобы обновить от имени админиа, надо иметь скоуп `userdata.info.admin` 58 | Чтобы обновить неизменяемую информацию надо обладать скоупом `userdata.info.update` 59 | Для обновления своей информации(источник `user`) не нужны скоупы на обновление соответствующих категорий 60 | Для обновления чужой информации от имени админа(источник `admin`) 61 | нужны скоупы на обновление всех указанных в теле запроса категорий пользовательских данных данных 62 | \f 63 | :param request: Запрос из fastapi 64 | :param user_id: Айди объекта обновленя 65 | :param _: Модель запроса 66 | :param user: 67 | :return: 68 | """ 69 | await patch(new_info, id, user) 70 | return StatusResponseModel(status="Success", message="User patch succeeded", ru="Изменение успешно") 71 | 72 | 73 | @user.get("", response_model=UsersInfoGet, response_model_exclude_unset=True) 74 | async def get_users_info( 75 | users: list[int] = Query(), 76 | categories: list[int] = Query(), 77 | user: dict[str, Any] = Depends(UnionAuth(scopes=["userdata.info.admin"], allow_none=False, auto_error=True)), 78 | additional_data: list[int] = Query(default=[]), 79 | ) -> UsersInfoGet: 80 | """ 81 | Получить информацию о пользователях. 82 | :param users: список id юзеров, про которых нужно вернуть информацию 83 | :param categories: список id категорий, параметры которых нужно вернуть 84 | :return: список данных о пользователях и данных категориях 85 | """ 86 | return UsersInfoGet.model_validate(await get_users(users, categories, user, additional_data)) 87 | -------------------------------------------------------------------------------- /tests/test_routes/test_category.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import pytest 4 | 5 | from userdata_api.models.db import * 6 | from userdata_api.utils.utils import random_string 7 | 8 | 9 | @pytest.mark.authenticated("userdata.category.create") 10 | def test_create_with_scopes(client, dbsession): 11 | name = f"test{random_string()}" 12 | name2 = f"test.{random_string()}" 13 | name3 = f"test.{random_string()}.test" 14 | response = client.post("/category", json={"name": name, "read_scope": name2, "update_scope": name3}) 15 | assert response.status_code == 200 16 | category = dbsession.query(Category).filter(Category.name == name).one() 17 | assert category.name == name 18 | assert category.update_scope == name3 19 | assert category.read_scope == name2 20 | dbsession.delete(category) 21 | dbsession.commit() 22 | 23 | 24 | @pytest.mark.authenticated("userdata.category.create") 25 | def test_create_with_no_scopes(client, dbsession): 26 | time = datetime.utcnow() 27 | response = client.post( 28 | "/category", 29 | json={ 30 | "name": f"test{time}", 31 | }, 32 | ) 33 | assert response.status_code == 200 34 | category = dbsession.query(Category).filter(Category.name == f"test{time}").one() 35 | assert category.name == f"test{time}" 36 | assert not category.read_scope and not category.update_scope 37 | dbsession.delete(category) 38 | dbsession.commit() 39 | 40 | 41 | def test_get(client, dbsession, category): 42 | _category = category() 43 | response = client.get(f"/category/{_category.id}") 44 | assert response.status_code == 200 45 | assert response.json()["id"] == _category.id 46 | assert response.json()["read_scope"] == _category.read_scope 47 | assert response.json()["update_scope"] == _category.update_scope 48 | assert response.json()["name"] == _category.name 49 | 50 | 51 | def test_get_all(client, dbsession, category): 52 | category1 = category() 53 | category2 = category() 54 | category1.dict() 55 | response = client.get(f"/category") 56 | assert response.status_code == 200 57 | assert { 58 | "id": category1.id, 59 | "name": category1.name, 60 | "read_scope": category1.read_scope, 61 | "update_scope": category1.update_scope, 62 | } in response.json() 63 | assert { 64 | "id": category2.id, 65 | "name": category2.name, 66 | "read_scope": category2.read_scope, 67 | "update_scope": category2.update_scope, 68 | } in response.json() 69 | 70 | 71 | @pytest.mark.authenticated("userdata.category.update") 72 | def test_update(client, dbsession, category): 73 | _category = category() 74 | old_name = _category.name 75 | old_update_scope = _category.update_scope 76 | response = client.patch( 77 | f"/category/{_category.id}", 78 | json={ 79 | "name": f"{_category.name}updated", 80 | "read_scope": "updated", 81 | }, 82 | ) 83 | assert response.status_code == 200 84 | dbsession.expire_all() 85 | assert _category.name == f"{old_name}updated" 86 | assert _category.read_scope == "updated" 87 | assert _category.update_scope == old_update_scope 88 | 89 | 90 | @pytest.mark.authenticated("userdata.category.delete") 91 | def test_delete(client, dbsession, category): 92 | _category = category() 93 | response = client.delete(f"/category/{_category.id}") 94 | assert response.status_code == 200 95 | _cat_upd: Category = Category.query(session=dbsession).filter(Category.id == _category.id).one_or_none() 96 | assert not _cat_upd 97 | _cat_upd: Category = ( 98 | Category.query(session=dbsession, with_deleted=True).filter(Category.id == _category.id).one_or_none() 99 | ) 100 | assert _cat_upd 101 | response = client.get(f"/category/{_category.id}") 102 | assert response.status_code == 404 103 | -------------------------------------------------------------------------------- /worker/kafka.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | from typing import Any, Iterator 4 | 5 | from confluent_kafka import Consumer 6 | 7 | from settings import get_settings 8 | from userdata_api import __version__ 9 | 10 | 11 | log = logging.getLogger(__name__) 12 | 13 | 14 | class KafkaConsumer: 15 | __dsn: str = get_settings().KAFKA_DSN # Kafka cluster URL 16 | __devel: bool = True if __version__ == "dev" else False # True if run locally else False 17 | __conf: dict[str, str] = {} # Connect configuration 18 | __login: str | None = get_settings().KAFKA_LOGIN 19 | __password: str | None = get_settings().KAFKA_PASSWORD 20 | __group_id: str | None = get_settings().KAFKA_GROUP_ID # Consumer group id 21 | __topics: list[str] = get_settings().KAFKA_TOPICS # Kafka topics to listen 22 | _consumer: Consumer 23 | 24 | def __configurate(self) -> None: 25 | if self.__devel: 26 | self.__conf = { 27 | "bootstrap.servers": self.__dsn, 28 | 'group.id': self.__group_id, 29 | 'session.timeout.ms': 6000, 30 | 'auto.offset.reset': 'earliest', 31 | 'enable.auto.offset.store': False, 32 | 'stats_cb': KafkaConsumer._stats_cb, 33 | 'statistics.interval.ms': 1000, 34 | "auto.commit.interval.ms": 100, 35 | } 36 | else: 37 | self.__conf = { 38 | 'bootstrap.servers': self.__dsn, 39 | 'sasl.mechanisms': "PLAIN", 40 | 'security.protocol': "SASL_PLAINTEXT", 41 | 'sasl.username': self.__login, 42 | 'sasl.password': self.__password, 43 | 'group.id': self.__group_id, 44 | 'session.timeout.ms': 6000, 45 | 'auto.offset.reset': 'earliest', 46 | 'enable.auto.offset.store': False, 47 | 'stats_cb': KafkaConsumer._stats_cb, 48 | 'statistics.interval.ms': 1000, 49 | "auto.commit.interval.ms": 100, 50 | } 51 | 52 | @staticmethod 53 | def _stats_cb(stats_json_str: str): 54 | stats_json = json.loads(stats_json_str) 55 | log.info('\nKAFKA Stats: {}\n'.format(stats_json)) 56 | 57 | @staticmethod 58 | def _on_assign(consumer, partitions): 59 | log.info(f'Assignment: {partitions}') 60 | 61 | def connect(self) -> None: 62 | self._consumer = Consumer(self.__conf) 63 | self._consumer.subscribe(self.__topics, on_assign=KafkaConsumer._on_assign) 64 | 65 | def reconnect(self): 66 | del self._consumer 67 | self.connect() 68 | 69 | def __init__(self): 70 | self.__configurate() 71 | self.connect() 72 | 73 | def close(self): 74 | self._consumer.close() 75 | 76 | def _listen(self) -> Iterator[tuple[Any, Any]]: 77 | try: 78 | while True: 79 | msg = self._consumer.poll(timeout=1.0) 80 | if msg is None: 81 | log.debug("Message is None") 82 | continue 83 | if msg.error(): 84 | log.error(f"Message {msg=} reading triggered: {msg.error()}, Retrying...") 85 | continue 86 | log.info('%% %s [%d] at offset %d\n' % (msg.topic(), msg.partition(), msg.offset())) 87 | try: 88 | yield json.loads(msg.key()), json.loads(msg.value()) 89 | except json.JSONDecodeError: 90 | log.error("Json decode error occurred", exc_info=True) 91 | self._consumer.store_offsets(msg) 92 | finally: 93 | log.info("Consumer closed") 94 | self.close() 95 | 96 | def listen(self) -> Iterator[tuple[Any, Any]]: 97 | while 1: 98 | try: 99 | yield from self._listen() 100 | except Exception: 101 | log.error("Error occurred", exc_info=True) 102 | self.reconnect() 103 | except KeyboardInterrupt: 104 | log.warning("Worker stopped by user") 105 | exit(0) 106 | -------------------------------------------------------------------------------- /userdata_api/routes/category.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | from auth_lib.fastapi import UnionAuth 4 | from fastapi import APIRouter, Depends, Query, Request 5 | from fastapi_sqlalchemy import db 6 | from pydantic.type_adapter import TypeAdapter 7 | 8 | from userdata_api.exceptions import AlreadyExists 9 | from userdata_api.models.db import Category 10 | from userdata_api.schemas.category import CategoryGet, CategoryPatch, CategoryPost 11 | from userdata_api.schemas.response_model import StatusResponseModel 12 | 13 | 14 | category = APIRouter(prefix="/category", tags=["Category"]) 15 | 16 | 17 | @category.post( 18 | "", 19 | response_model=CategoryGet, 20 | ) 21 | async def create_category( 22 | request: Request, 23 | category_inp: CategoryPost, 24 | _: dict[str, str] = Depends(UnionAuth(scopes=["userdata.category.create"], allow_none=False, auto_error=True)), 25 | ) -> CategoryGet: 26 | """ 27 | Создать категорию пользовательских данных. Получить категорию можно будет со скоупами, имена которых в category_inp.scopes 28 | Ручка обновит документацию 29 | 30 | Scopes: `["userdata.category.create"]` 31 | \f 32 | :param request: https://fastapi.tiangolo.com/advanced/using-request-directly/ 33 | :param category_inp: Принимаемая моделька 34 | :param _: Аутентификация 35 | :return: CategoryGet 36 | """ 37 | if Category.query(session=db.session).filter(Category.name == category_inp.name).all(): 38 | raise AlreadyExists(Category, category_inp.name) 39 | category = Category.create(session=db.session, **category_inp.dict()) 40 | return CategoryGet.model_validate(category) 41 | 42 | 43 | @category.get("/{id}", response_model=CategoryGet) 44 | async def get_category(id: int) -> CategoryGet: 45 | """ 46 | Получить категорию 47 | \f 48 | :param id: Айди категории 49 | :param _: Аутентфикация 50 | :return: Категорию со списком скоупов, которые нужны для получения пользовательских данных этой категории 51 | """ 52 | category = Category.get(id, session=db.session) 53 | return CategoryGet.model_validate(category) 54 | 55 | 56 | @category.get("", response_model=list[CategoryGet], response_model_exclude_none=True) 57 | async def get_categories(query: list[Literal["param"]] = Query(default=[])) -> list[CategoryGet]: 58 | """ 59 | Получить все категории 60 | \f 61 | :param query: Лист query параметров. 62 | Если ничего не указано то вернет просто список категорий 63 | Параметр 'param' - если указан, то в каждой категории будет список ее параметров 64 | :param _: Аутентифиуация 65 | :return: Список категорий. В каждой ноде списка - информация о скоупах, которые нужны для получения пользовательских данных этой категории 66 | """ 67 | result = [] 68 | for category in Category.query(session=db.session).all(): 69 | to_append = category.dict() 70 | if "param" in query: 71 | to_append["params"] = [] 72 | for param in category.params: 73 | to_append["params"].append(param.dict()) 74 | result.append(to_append) 75 | 76 | type_adapter = TypeAdapter(list[CategoryGet]) 77 | return type_adapter.validate_python(result) 78 | 79 | 80 | @category.patch("/{id}", response_model=CategoryGet) 81 | async def patch_category( 82 | request: Request, 83 | id: int, 84 | category_inp: CategoryPatch, 85 | _: dict[str, str] = Depends(UnionAuth(scopes=["userdata.category.update"], allow_none=False, auto_error=True)), 86 | ) -> CategoryGet: 87 | """ 88 | Обновить категорию 89 | 90 | Scopes: `["userdata.category.update"]` 91 | \f 92 | :param request: https://fastapi.tiangolo.com/advanced/using-request-directly/ 93 | :param id: Айди обновляемой категории 94 | :param category_inp: Моделька обновления 95 | :param _: Аутентификация 96 | :return: CategoryGet - обновленную категорию 97 | """ 98 | category: Category = Category.get(id, session=db.session) 99 | return CategoryGet.model_validate(Category.update(id, session=db.session, **category_inp.dict(exclude_unset=True))) 100 | 101 | 102 | @category.delete("/{id}", response_model=StatusResponseModel) 103 | async def delete_category( 104 | request: Request, 105 | id: int, 106 | _: dict[str, str] = Depends(UnionAuth(scopes=["userdata.category.delete"], allow_none=False, auto_error=True)), 107 | ) -> StatusResponseModel: 108 | """ 109 | Удалить категорию 110 | 111 | Scopes: `["userdata.category.delete"]` 112 | \f 113 | :param request: https://fastapi.tiangolo.com/advanced/using-request-directly/ 114 | :param id: Айди удаляемой категории 115 | :param _: Аутентификация 116 | :return: None 117 | """ 118 | _: Category = Category.get(id, session=db.session) 119 | Category.delete(id, session=db.session) 120 | return StatusResponseModel(status="Success", message="Category deleted", ru="Категория удалена") 121 | -------------------------------------------------------------------------------- /userdata_api/routes/param.py: -------------------------------------------------------------------------------- 1 | from re import compile 2 | from re import error as ReError 3 | from typing import Any 4 | 5 | from auth_lib.fastapi import UnionAuth 6 | from fastapi import APIRouter, Depends, Request 7 | from fastapi_sqlalchemy import db 8 | from pydantic.type_adapter import TypeAdapter 9 | 10 | from userdata_api.exceptions import AlreadyExists, InvalidRegex, ObjectNotFound 11 | from userdata_api.models.db import Category, Param 12 | from userdata_api.schemas.param import ParamGet, ParamPatch, ParamPost 13 | from userdata_api.schemas.response_model import StatusResponseModel 14 | 15 | 16 | param = APIRouter(prefix="/category/{category_id}/param", tags=["Param"]) 17 | 18 | 19 | @param.post("", response_model=ParamGet) 20 | async def create_param( 21 | request: Request, 22 | category_id: int, 23 | param_inp: ParamPost, 24 | _: dict[str, Any] = Depends(UnionAuth(scopes=["userdata.param.create"], allow_none=False, auto_error=True)), 25 | ) -> ParamGet: 26 | """ 27 | Создать поле внутри категории. Ответ на пользовательские данные будет такой {..., category: {...,param: '', ...}} 28 | 29 | Scopes: `["userdata.param.create"]` 30 | \f 31 | :param request: https://fastapi.tiangolo.com/advanced/using-request-directly/ 32 | :param category_id: Айди котегории в которой создавать параметр 33 | :param param_inp: Модель для создания 34 | :param _: Аутентификация 35 | :return: ParamGet - созданный параметр 36 | """ 37 | Category.get(category_id, session=db.session) 38 | if Param.query(session=db.session).filter(Param.category_id == category_id, Param.name == param_inp.name).all(): 39 | raise AlreadyExists(Param, param_inp.name) 40 | if param_inp.validation: 41 | try: 42 | compile(param_inp.validation) 43 | except ReError: 44 | raise InvalidRegex(Param, "validation") 45 | return ParamGet.model_validate(Param.create(session=db.session, **param_inp.dict(), category_id=category_id)) 46 | 47 | 48 | @param.get("/{id}", response_model=ParamGet) 49 | async def get_param(id: int, category_id: int) -> ParamGet: 50 | """ 51 | Получить параметр по айди 52 | \f 53 | :param id: Айди параметра 54 | :param category_id: айди категории в которой этот параметр находиится 55 | :return: ParamGet - полученный параметр 56 | """ 57 | res = Param.query(session=db.session).filter(Param.id == id, Param.category_id == category_id).one_or_none() 58 | if not res: 59 | raise ObjectNotFound(Param, id) 60 | return ParamGet.model_validate(res) 61 | 62 | 63 | @param.get("", response_model=list[ParamGet]) 64 | async def get_params(category_id: int) -> list[ParamGet]: 65 | """ 66 | Получить все параметры категории 67 | \f 68 | :param category_id: Айди категории 69 | :return: list[ParamGet] - список полученных параметров 70 | """ 71 | type_adapter = TypeAdapter(list[ParamGet]) 72 | return type_adapter.validate_python(Param.query(session=db.session).filter(Param.category_id == category_id).all()) 73 | 74 | 75 | @param.patch("/{id}", response_model=ParamGet) 76 | async def patch_param( 77 | request: Request, 78 | id: int, 79 | category_id: int, 80 | param_inp: ParamPatch, 81 | _: dict[str, Any] = Depends(UnionAuth(scopes=["userdata.param.update"], allow_none=False, auto_error=True)), 82 | ) -> ParamGet: 83 | """ 84 | Обновить параметр внутри категории 85 | 86 | Scopes: `["userdata.param.update"]` 87 | \f 88 | :param request: https://fastapi.tiangolo.com/advanced/using-request-directly/ 89 | :param id: Айди обновляемого параметра 90 | :param category_id: Адйи категории в которой находится параметр 91 | :param param_inp: Модель для создания параметра 92 | :param _: Аутентификация 93 | :return: ParamGet - Обновленный параметр 94 | """ 95 | if category_id: 96 | Category.get(category_id, session=db.session) 97 | if param_inp.validation: 98 | try: 99 | compile(param_inp.validation) 100 | except ReError: 101 | raise InvalidRegex(Param, "validation") 102 | if category_id: 103 | return ParamGet.from_orm( 104 | Param.update(id, session=db.session, **param_inp.dict(exclude_unset=True), category_id=category_id) 105 | ) 106 | return ParamGet.model_validate(Param.update(id, session=db.session, **param_inp.dict(exclude_unset=True))) 107 | 108 | 109 | @param.delete("/{id}", response_model=StatusResponseModel) 110 | async def delete_param( 111 | request: Request, 112 | id: int, 113 | category_id: int, 114 | _: dict[str, Any] = Depends(UnionAuth(scopes=["userdata.param.delete"], allow_none=False, auto_error=True)), 115 | ) -> StatusResponseModel: 116 | """ 117 | Удалить параметр внутри категории 118 | 119 | Scopes: `["userdata.param.delete"]` 120 | \f 121 | :param request: https://fastapi.tiangolo.com/advanced/using-request-directly/ 122 | :param id: Айди удаляемого параметра 123 | :param category_id: Айди категории в которой находится удлаляемый параметр 124 | :param _: Аутентификация 125 | :return: None 126 | """ 127 | res: Param = Param.query(session=db.session).filter(Param.id == id, Param.category_id == category_id).one_or_none() 128 | if not res: 129 | raise ObjectNotFound(Param, id) 130 | res.is_deleted = True 131 | db.session.commit() 132 | return StatusResponseModel(status="Success", message="Param deleted", ru="Параметр удален") 133 | -------------------------------------------------------------------------------- /tests/test_routes/test_param.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from userdata_api.exceptions import ObjectNotFound 4 | from userdata_api.models.db import * 5 | from userdata_api.schemas.param import ParamGet 6 | from userdata_api.utils.utils import random_string 7 | 8 | 9 | @pytest.mark.authenticated("userdata.param.create") 10 | def test_create_with_scopes(client, dbsession, category): 11 | _category = category() 12 | name = f"test{random_string()}" 13 | response = client.post( 14 | f"/category/{_category.id}/param", 15 | json={"name": name, "category_id": _category.id, "type": "last", "changeable": "true", "is_required": "true"}, 16 | ) 17 | assert response.status_code == 200 18 | assert response.json()["id"] 19 | assert response.json()["name"] == name 20 | assert response.json()["category_id"] == _category.id 21 | assert response.json()["type"] == "last" 22 | assert response.json()["changeable"] == True 23 | assert response.json()["is_required"] == True 24 | param = Param.get(response.json()["id"], session=dbsession) 25 | assert param 26 | assert param.name == name 27 | assert param.id == response.json()["id"] 28 | assert param.type == "last" 29 | assert param.changeable == True 30 | assert param.category_id == _category.id 31 | assert param.category == _category 32 | dbsession.delete(param) 33 | dbsession.flush() 34 | 35 | 36 | @pytest.mark.authenticated("userdata.param.create") 37 | def test_create_with_validation(client, dbsession, category): 38 | _category = category() 39 | name = f"test{random_string()}" 40 | validation = "^test_[0-9]{3}$" 41 | response = client.post( 42 | f"/category/{_category.id}/param", 43 | json={ 44 | "name": name, 45 | "category_id": _category.id, 46 | "type": "last", 47 | "changeable": "true", 48 | "is_required": "true", 49 | "validation": validation, 50 | }, 51 | ) 52 | assert response.status_code == 200 53 | assert response.json()["id"] 54 | assert response.json()["name"] == name 55 | assert response.json()["category_id"] == _category.id 56 | assert response.json()["type"] == "last" 57 | assert response.json()["changeable"] == True 58 | assert response.json()["is_required"] == True 59 | assert response.json()["validation"] == validation 60 | param = Param.get(response.json()["id"], session=dbsession) 61 | assert param 62 | assert param.name == name 63 | assert param.id == response.json()["id"] 64 | assert param.type == "last" 65 | assert param.changeable == True 66 | assert param.category_id == _category.id 67 | assert param.category == _category 68 | assert param.validation == validation 69 | dbsession.delete(param) 70 | dbsession.flush() 71 | 72 | 73 | @pytest.mark.authenticated("userdata.param.create") 74 | def test_create_with_uncompilable_validation(client, category): 75 | _category = category() 76 | name = f"test{random_string()}" 77 | validation = '[][' 78 | response = client.post( 79 | f"/category/{_category.id}/param", 80 | json={ 81 | "name": name, 82 | "category_id": _category.id, 83 | "type": "last", 84 | "changeable": "true", 85 | "is_required": "true", 86 | "validation": validation, 87 | }, 88 | ) 89 | assert response.status_code == 422 90 | 91 | 92 | @pytest.mark.authenticated() 93 | def test_get(client, dbsession, param): 94 | _param = param() 95 | response = client.get(f"/category/{_param.category_id}/param/{_param.id}") 96 | assert response.status_code == 200 97 | assert response.json()["name"] == _param.name 98 | assert response.json()["type"] == "last" 99 | assert response.json()["category_id"] == _param.category_id 100 | assert response.json()["changeable"] == _param.changeable 101 | assert response.json()["id"] == _param.id 102 | 103 | 104 | @pytest.mark.authenticated() 105 | def test_get_all(client, dbsession, param_no_scopes): 106 | param1 = param_no_scopes() 107 | param2 = param_no_scopes() 108 | assert param1.category_id == param2.category_id 109 | response = client.get(f"/category/{param2.category_id}/param") 110 | assert response.status_code == 200 111 | assert ParamGet.from_orm(param1).dict() in response.json() 112 | assert ParamGet.from_orm(param2).dict() in response.json() 113 | 114 | 115 | @pytest.mark.authenticated("userdata.param.update") 116 | def test_update(client, dbsession, param): 117 | _param = param() 118 | response = client.patch( 119 | f"/category/{_param.category_id}/param/{_param.id}", json={"name": f"{_param.name}updated", "type": "all"} 120 | ) 121 | assert response.status_code == 200 122 | assert response.json()["name"] == f"{_param.name}updated" 123 | assert response.json()["type"] == "all" 124 | assert response.json()["changeable"] == _param.changeable 125 | assert response.json()["id"] == _param.id 126 | assert response.json()["category_id"] == _param.category_id 127 | dbsession.expire_all() 128 | q: Param = Param.get(_param.id, session=dbsession) 129 | assert q 130 | assert response.json()["name"] == q.name 131 | assert response.json()["type"] == q.type 132 | assert response.json()["changeable"] == q.changeable 133 | assert response.json()["id"] == q.id 134 | assert response.json()["category_id"] == q.category_id 135 | 136 | 137 | @pytest.mark.authenticated("userdata.param.delete") 138 | def test_delete(client, dbsession, param): 139 | _param = param() 140 | response = client.delete(f"/category/{_param.category_id}/param/{_param.id}") 141 | assert response.status_code == 200 142 | with pytest.raises(ObjectNotFound): 143 | query1 = Param.get(_param.id, session=dbsession) 144 | query2 = Param.get(_param.id, with_deleted=True, session=dbsession) 145 | assert query2 146 | -------------------------------------------------------------------------------- /userdata_api/models/db.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import datetime 4 | from enum import Enum 5 | from typing import Final 6 | 7 | from sqlalchemy import Boolean, DateTime 8 | from sqlalchemy import Enum as DbEnum 9 | from sqlalchemy import ForeignKey, Integer, String 10 | from sqlalchemy.ext.hybrid import hybrid_property 11 | from sqlalchemy.orm import Mapped, mapped_column, relationship 12 | 13 | from userdata_api.models.base import BaseDbModel 14 | 15 | 16 | class ViewType(str, Enum): 17 | """ 18 | Тип отображения пользоватльских данных в ответе `GET /user/{user_id}` 19 | ALL: {category: {param: [val1, val2, ...]}} 20 | LAST: {category: {param: last_modified_value}} 21 | MOST_TRUSTED: {category: {param: most_trusted_value}} 22 | """ 23 | 24 | ALL: Final[str] = "all" 25 | LAST: Final[str] = "last" 26 | MOST_TRUSTED: Final[str] = "most_trusted" 27 | 28 | 29 | class Category(BaseDbModel): 30 | """ 31 | Категория - объеденение параметров пользовательских данных. 32 | Если параметром может быть, например, номер студенческого и номер профсоюзного, 33 | то категорией, их объединяющей, может быть "студенческая информация" или "документы" 34 | """ 35 | 36 | name: Mapped[str] = mapped_column(String) 37 | read_scope: Mapped[str] = mapped_column(String, nullable=True) 38 | update_scope: Mapped[str] = mapped_column(String, nullable=True) 39 | create_ts: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) 40 | modify_ts: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) 41 | is_deleted: Mapped[bool] = mapped_column(Boolean, default=False) 42 | 43 | params: Mapped[list[Param]] = relationship( 44 | "Param", 45 | foreign_keys="Param.category_id", 46 | back_populates="category", 47 | primaryjoin="and_(Category.id==Param.category_id, not_(Param.is_deleted))", 48 | lazy="joined", 49 | ) 50 | 51 | 52 | class Param(BaseDbModel): 53 | """ 54 | Параметр - находится внутри категории, 55 | к нему можно задавать значение у конкретного пользователя. 56 | Например, параметрами может являться почта и номер телефона, 57 | а параметры эти могут лежать в категории "контакты" 58 | """ 59 | 60 | is_public: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) 61 | visible_in_user_response: Mapped[bool] = mapped_column(Boolean, default=True) 62 | name: Mapped[str] = mapped_column(String) 63 | category_id: Mapped[int] = mapped_column(Integer, ForeignKey(Category.id)) 64 | is_required: Mapped[bool] = mapped_column(Boolean, default=False) 65 | changeable: Mapped[bool] = mapped_column(Boolean, default=True) 66 | type: Mapped[ViewType] = mapped_column(DbEnum(ViewType, native_enum=False)) 67 | validation: Mapped[str] = mapped_column(String, nullable=True) 68 | create_ts: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) 69 | modify_ts: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) 70 | is_deleted: Mapped[bool] = mapped_column(Boolean, default=False) 71 | 72 | category: Mapped[Category] = relationship( 73 | "Category", 74 | foreign_keys="Param.category_id", 75 | back_populates="params", 76 | primaryjoin="and_(Param.category_id==Category.id, not_(Category.is_deleted))", 77 | lazy="joined", 78 | ) 79 | 80 | values: Mapped[list[Info]] = relationship( 81 | "Info", 82 | foreign_keys="Info.param_id", 83 | back_populates="param", 84 | primaryjoin="and_(Param.id==Info.param_id, not_(Info.is_deleted))", 85 | lazy="joined", 86 | ) 87 | 88 | @property 89 | def pytype(self) -> type[str | list[str]]: 90 | return list[str] if self.type == ViewType.ALL else str 91 | 92 | 93 | class Source(BaseDbModel): 94 | """ 95 | Источник данных - субъект изменения польщовательских данных - тот, кто меняет данные 96 | В HTTP методах доступно только два источника - user/admin 97 | Субъект может менять только данные, созданные собой же. 98 | У источника есть уровень доверия, который влияет на вид ответа `GET /user/{user_id}` 99 | """ 100 | 101 | name: Mapped[str] = mapped_column(String, unique=True) 102 | trust_level: Mapped[int] = mapped_column(Integer, default=0, nullable=False) 103 | create_ts: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) 104 | modify_ts: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) 105 | is_deleted: Mapped[bool] = mapped_column(Boolean, default=False) 106 | 107 | values: Mapped[Info] = relationship( 108 | "Info", 109 | foreign_keys="Info.source_id", 110 | back_populates="source", 111 | primaryjoin="and_(Source.id==Info.source_id, not_(Info.is_deleted))", 112 | lazy="joined", 113 | ) 114 | 115 | 116 | class Info(BaseDbModel): 117 | """ 118 | Значения параметров для конкретных польщзователей 119 | Если, например, телефон - параметр, то здесь указывается его значение для 120 | польщзователя(owner_id) - объекта изменения пользовательских данных 121 | """ 122 | 123 | param_id: Mapped[int] = mapped_column(Integer, ForeignKey(Param.id)) 124 | source_id: Mapped[int] = mapped_column(Integer, ForeignKey(Source.id)) 125 | owner_id: Mapped[int] = mapped_column(Integer, nullable=False) 126 | value: Mapped[str] = mapped_column(String, nullable=False) 127 | create_ts: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) 128 | modify_ts: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) 129 | is_deleted: Mapped[bool] = mapped_column(Boolean, default=False) 130 | 131 | param: Mapped[Param] = relationship( 132 | "Param", 133 | foreign_keys="Info.param_id", 134 | back_populates="values", 135 | primaryjoin="and_(Info.param_id==Param.id, not_(Param.is_deleted))", 136 | lazy="joined", 137 | ) 138 | 139 | source: Mapped[Source] = relationship( 140 | "Source", 141 | foreign_keys="Info.source_id", 142 | back_populates="values", 143 | primaryjoin="and_(Info.source_id==Source.id, not_(Source.is_deleted))", 144 | lazy="joined", 145 | ) 146 | 147 | @hybrid_property 148 | def category(self) -> Category: 149 | return self.param.category 150 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi.testclient import TestClient 3 | from sqlalchemy import create_engine 4 | from sqlalchemy.orm import sessionmaker 5 | 6 | from settings import get_settings 7 | from userdata_api.models.db import * 8 | from userdata_api.routes.base import app 9 | from userdata_api.utils.utils import random_string 10 | 11 | 12 | @pytest.fixture 13 | def client(auth_mock): 14 | yield TestClient(app) 15 | 16 | 17 | @pytest.fixture(scope='session') 18 | def dbsession(): 19 | settings = get_settings() 20 | engine = create_engine(str(settings.DB_DSN)) 21 | TestingSessionLocal = sessionmaker(bind=engine) 22 | yield TestingSessionLocal() 23 | 24 | 25 | @pytest.fixture 26 | def category(dbsession): 27 | """ 28 | Вызов фабрики создает категорию с двумя рандомными скоупами и возвращщает ее 29 | ``` 30 | def test(category): 31 | category1 = category() 32 | category2 = category() 33 | ``` 34 | """ 35 | categories = [] 36 | 37 | def _category(): 38 | nonlocal categories 39 | name = f"test{random_string()}" 40 | __category = Category( 41 | name=name, read_scope=f"testscope.{random_string()}", update_scope=f"testscope.{random_string()}" 42 | ) 43 | dbsession.add(__category) 44 | dbsession.commit() 45 | categories.append(__category) 46 | return __category 47 | 48 | yield _category 49 | dbsession.expire_all() 50 | dbsession.commit() 51 | for row in categories: 52 | dbsession.delete(row) 53 | dbsession.commit() 54 | 55 | 56 | @pytest.fixture 57 | def param(dbsession, category): 58 | """ 59 | ``` 60 | Вызов фабрики создает параметр в категории с двумя рандомными скоупами и возвращает его 61 | В рамках одного теста параметры создаются в разных категориях - для каждого параметра своя категория 62 | def test(param): 63 | param1 = param() 64 | param2 = param() 65 | ``` 66 | """ 67 | params = [] 68 | 69 | def _param(): 70 | _category = category() 71 | nonlocal params 72 | time_ = f"test{random_string()}" 73 | __param = Param(name=f"test{time_}", category_id=_category.id, type="last", changeable=True, is_required=True) 74 | dbsession.add(__param) 75 | dbsession.commit() 76 | params.append(__param) 77 | return __param 78 | 79 | yield _param 80 | for row in params: 81 | dbsession.delete(row) 82 | dbsession.commit() 83 | 84 | 85 | @pytest.fixture 86 | def source(dbsession): 87 | """ 88 | Вызов фабрики создает источник в и возвращает его 89 | ``` 90 | def test(source): 91 | source1 = source() 92 | source2 = source() 93 | ``` 94 | """ 95 | sources = [] 96 | 97 | def _source(): 98 | nonlocal sources 99 | time_ = f"test{random_string()}" 100 | __source = Source(name=f"test{time_}", trust_level=8) 101 | dbsession.add(__source) 102 | dbsession.commit() 103 | sources.append(__source) 104 | return __source 105 | 106 | yield _source 107 | for row in sources: 108 | dbsession.delete(row) 109 | dbsession.commit() 110 | 111 | 112 | @pytest.fixture() 113 | def admin_source(dbsession): 114 | _source = Source(name="admin", trust_level=10) 115 | dbsession.add(_source) 116 | dbsession.commit() 117 | yield _source 118 | dbsession.delete(_source) 119 | dbsession.commit() 120 | 121 | 122 | @pytest.fixture 123 | def category_no_scopes(dbsession): 124 | """ 125 | Вызов фабрики создает категорию без скоупов и возвращает ее 126 | ``` 127 | def test(category_no_scopes): 128 | category_no_scopes1 = category_no_scopes() 129 | category_no_scopes2 = category_no_scopes() 130 | ``` 131 | """ 132 | categories = [] 133 | 134 | def _category_no_scopes(): 135 | nonlocal categories 136 | name = f"test{random_string()}" 137 | __category = Category(name=name, read_scope=None, update_scope=None) 138 | dbsession.add(__category) 139 | dbsession.commit() 140 | categories.append(__category) 141 | return __category 142 | 143 | yield _category_no_scopes 144 | dbsession.expire_all() 145 | for row in categories: 146 | dbsession.delete(row) 147 | dbsession.commit() 148 | 149 | 150 | @pytest.fixture 151 | def param_no_scopes(dbsession, category_no_scopes): 152 | """ 153 | Вызов фабрики создает параметр в категории без скоупов и возвращает его 154 | 155 | Все созданные параметры в рамках одного теста принадлежат одной категории 156 | ``` 157 | def test(param_no_scopes): 158 | param_no_scopes1 = param_no_scopes() 159 | param_no_scopes2 = param_no_scopes() 160 | ``` 161 | """ 162 | params = [] 163 | _category = category_no_scopes() 164 | 165 | def _param_no_scopes(): 166 | nonlocal _category 167 | nonlocal params 168 | time_ = f"test{random_string()}" 169 | __param = Param(name=f"test{time_}", category_id=_category.id, type="last", changeable=True, is_required=True) 170 | dbsession.add(__param) 171 | dbsession.commit() 172 | params.append(__param) 173 | return __param 174 | 175 | yield _param_no_scopes 176 | for row in params: 177 | dbsession.delete(row) 178 | dbsession.commit() 179 | 180 | 181 | @pytest.fixture 182 | def info_no_scopes(dbsession, source, param_no_scopes): 183 | """ 184 | Вызов фабрики создает информацию для параметра без скоупов и для источника source() и возвращает ее 185 | 186 | Все сущности info принадлежат разным параметрам, которые принадлежат одной категории. 187 | 188 | Источники для всех сущностей разные. 189 | ``` 190 | def test(info_no_scopes): 191 | info_no_scopes1 = info_no_scopes() 192 | info_no_scopes2 = info_no_scopes() 193 | ``` 194 | """ 195 | infos = [] 196 | 197 | def _info_no_scopes(): 198 | nonlocal infos 199 | _source = source() 200 | _param = param_no_scopes() 201 | time_ = f"test{random_string()}" 202 | __info = Info(value=f"test{time_}", source_id=_source.id, param_id=_param.id, owner_id=0) 203 | dbsession.add(__info) 204 | dbsession.commit() 205 | infos.append(__info) 206 | return __info 207 | 208 | yield _info_no_scopes 209 | for row in infos: 210 | dbsession.delete(row) 211 | dbsession.commit() 212 | -------------------------------------------------------------------------------- /.github/workflows/build_and_publish.yml: -------------------------------------------------------------------------------- 1 | name: Build, publish and deploy docker 2 | 3 | on: 4 | push: 5 | branches: [ 'main' ] 6 | tags: 7 | - 'v*' 8 | 9 | 10 | env: 11 | REGISTRY: ghcr.io 12 | IMAGE_NAME: ${{ github.repository }} 13 | 14 | jobs: 15 | build-and-push-image: 16 | name: Build and push 17 | runs-on: ubuntu-latest 18 | permissions: 19 | contents: read 20 | packages: write 21 | 22 | steps: 23 | - name: Checkout repository 24 | uses: actions/checkout@v4 25 | 26 | - name: Log in to the Container registry 27 | uses: docker/login-action@v2 28 | with: 29 | registry: ${{ env.REGISTRY }} 30 | username: ${{ github.actor }} 31 | password: ${{ secrets.GITHUB_TOKEN }} 32 | 33 | - name: Extract metadata (tags, labels) for Docker 34 | id: meta 35 | uses: docker/metadata-action@v4 36 | with: 37 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 38 | tags: | 39 | type=ref,event=tag,enable=${{ startsWith(github.ref, 'refs/tags/v') }} 40 | type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} 41 | type=raw,value=test,enable=true 42 | 43 | - name: Build and push Docker image 44 | uses: docker/build-push-action@v4 45 | with: 46 | context: . 47 | push: true 48 | tags: ${{ steps.meta.outputs.tags }} 49 | labels: ${{ steps.meta.outputs.labels }} 50 | build-args: | 51 | APP_VERSION=${{ github.ref_name }} 52 | 53 | deploy-testing: 54 | name: Deploy Testing 55 | needs: build-and-push-image 56 | runs-on: [ self-hosted, Linux, testing ] 57 | environment: 58 | name: Testing 59 | url: https://api.test.profcomff.com/userdata 60 | env: 61 | API_CONTAINER_NAME: com_profcomff_api_userdata_test 62 | WORKER_CONTAINER_NAME: com_profcomff_worker_userdata_test 63 | permissions: 64 | packages: read 65 | 66 | steps: 67 | - name: Pull new version 68 | run: docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:test 69 | 70 | - name: Migrate DB 71 | run: | 72 | docker run \ 73 | --rm \ 74 | --network=web \ 75 | --env DB_DSN=${{ secrets.DB_DSN }} \ 76 | --name ${{ env.API_CONTAINER_NAME }}_migration \ 77 | --workdir="/" \ 78 | ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:test \ 79 | alembic upgrade head 80 | 81 | - name: Run new version API 82 | id: run_test_api 83 | run: | 84 | docker stop ${{ env.API_CONTAINER_NAME }} || true && docker rm ${{ env.API_CONTAINER_NAME }} || true 85 | docker run \ 86 | --detach \ 87 | --restart always \ 88 | --network=web \ 89 | --env DB_DSN='${{ secrets.DB_DSN }}' \ 90 | --env ROOT_PATH='/userdata' \ 91 | --env AUTH_URL='https://api.test.profcomff.com/auth' \ 92 | --env GUNICORN_CMD_ARGS='--log-config logging_test.conf' \ 93 | --name ${{ env.API_CONTAINER_NAME }} \ 94 | ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:test 95 | 96 | - name: Run new version worker 97 | id: run_test_worker 98 | run: | 99 | docker stop ${{ env.WORKER_CONTAINER_NAME }} || true && docker rm ${{ env.WORKER_CONTAINER_NAME }} || true 100 | docker run \ 101 | --detach \ 102 | --restart always \ 103 | --network=kafka \ 104 | --env DB_DSN='${{ secrets.DB_DSN }}' \ 105 | --env KAFKA_DSN='${{ secrets.KAFKA_DSN }}' \ 106 | --env KAFKA_LOGIN='${{ secrets.KAFKA_LOGIN }}' \ 107 | --env KAFKA_PASSWORD='${{ secrets.KAFKA_PASSWORD }}' \ 108 | --env KAFKA_GROUP_ID='${{ vars.KAFKA_GROUP_ID }}' \ 109 | --env KAFKA_TOPICS='${{ vars.KAFKA_TOPICS }}' \ 110 | --name ${{ env.WORKER_CONTAINER_NAME }} \ 111 | ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:test python -m userdata_api start --instance worker 112 | 113 | 114 | deploy-production: 115 | name: Deploy Production 116 | needs: build-and-push-image 117 | if: startsWith(github.ref, 'refs/tags/v') 118 | runs-on: [ self-hosted, Linux, production ] 119 | environment: 120 | name: Production 121 | url: https://api.profcomff.com/userdata 122 | env: 123 | API_CONTAINER_NAME: com_profcomff_api_userdata 124 | WORKER_CONTAINER_NAME: com_profcomff_worker_userdata 125 | permissions: 126 | packages: read 127 | 128 | steps: 129 | - name: Pull new version 130 | run: docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest 131 | 132 | - name: Migrate DB 133 | run: | 134 | docker run \ 135 | --rm \ 136 | --network=web \ 137 | --env DB_DSN=${{ secrets.DB_DSN }} \ 138 | --name ${{ env.API_CONTAINER_NAME }}_migration \ 139 | --workdir="/" \ 140 | ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest \ 141 | alembic upgrade head 142 | 143 | - name: Run new version API 144 | id: run_prod_api 145 | run: | 146 | docker stop ${{ env.API_CONTAINER_NAME }} || true && docker rm ${{ env.API_CONTAINER_NAME }} || true 147 | docker run \ 148 | --detach \ 149 | --restart always \ 150 | --network=web \ 151 | --env DB_DSN='${{ secrets.DB_DSN }}' \ 152 | --env ROOT_PATH='/userdata' \ 153 | --env GUNICORN_CMD_ARGS='--log-config logging_prod.conf' \ 154 | --env AUTH_URL='https://api.profcomff.com/auth' \ 155 | --name ${{ env.API_CONTAINER_NAME }} \ 156 | ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest 157 | 158 | - name: Run new version worker 159 | id: run_prod_worker 160 | run: | 161 | docker stop ${{ env.WORKER_CONTAINER_NAME }} || true && docker rm ${{ env.WORKER_CONTAINER_NAME }} || true 162 | docker run \ 163 | --detach \ 164 | --restart always \ 165 | --network=kafka \ 166 | --env DB_DSN='${{ secrets.DB_DSN }}' \ 167 | --env KAFKA_DSN='${{ secrets.KAFKA_DSN }}' \ 168 | --env KAFKA_LOGIN='${{ secrets.KAFKA_LOGIN }}' \ 169 | --env KAFKA_PASSWORD='${{ secrets.KAFKA_PASSWORD }}' \ 170 | --env KAFKA_GROUP_ID='${{ vars.KAFKA_GROUP_ID }}' \ 171 | --env KAFKA_TOPICS='${{ vars.KAFKA_TOPICS }}' \ 172 | --name ${{ env.WORKER_CONTAINER_NAME }} \ 173 | ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest python -m userdata_api start --instance worker 174 | -------------------------------------------------------------------------------- /tests/test_routes/test_users_get.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from userdata_api.models.db import Info, Param 4 | from userdata_api.utils.utils import random_string 5 | 6 | 7 | @pytest.mark.authenticated("userdata.info.admin") 8 | def test_get(client, dbsession, category_no_scopes, source): 9 | source = source() 10 | category1 = category_no_scopes() 11 | category2 = category_no_scopes() 12 | category3 = category_no_scopes() 13 | param1 = Param( 14 | name=f"test{random_string()}", category_id=category1.id, type="last", changeable=True, is_required=True 15 | ) 16 | param2 = Param( 17 | name=f"test{random_string()}", category_id=category1.id, type="last", changeable=True, is_required=True 18 | ) 19 | param3 = Param( 20 | name=f"test{random_string()}", category_id=category2.id, type="last", changeable=True, is_required=True 21 | ) 22 | param4 = Param( 23 | name=f"test{random_string()}", category_id=category3.id, type="last", changeable=True, is_required=True 24 | ) 25 | dbsession.add_all([param1, param2, param3, param4]) 26 | dbsession.flush() 27 | info1 = Info(value=f"test{random_string()}", source_id=source.id, param_id=param1.id, owner_id=0) 28 | info2 = Info(value=f"test{random_string()}", source_id=source.id, param_id=param2.id, owner_id=1) 29 | info3 = Info(value=f"test{random_string()}", source_id=source.id, param_id=param3.id, owner_id=0) 30 | info4 = Info(value=f"test{random_string()}", source_id=source.id, param_id=param4.id, owner_id=1) 31 | dbsession.add_all([info1, info2, info3, info4]) 32 | dbsession.commit() 33 | response = client.get(f"/user", params={"users": [0, 1], "categories": [category1.id, category2.id, category3.id]}) 34 | assert response.status_code == 200 35 | assert {"user_id": 1, "category": category1.name, "param": info2.param.name, "value": info2.value} in list( 36 | response.json()["items"] 37 | ) 38 | assert {"user_id": 1, "category": category3.name, "param": info4.param.name, "value": info4.value} in list( 39 | response.json()["items"] 40 | ) 41 | assert {"user_id": 0, "category": category1.name, "param": info1.param.name, "value": info1.value} in list( 42 | response.json()["items"] 43 | ) 44 | assert {"user_id": 0, "category": category2.name, "param": info3.param.name, "value": info3.value} in list( 45 | response.json()["items"] 46 | ) 47 | dbsession.delete(info1) 48 | dbsession.delete(info2) 49 | dbsession.delete(info3) 50 | dbsession.delete(info4) 51 | dbsession.flush() 52 | dbsession.delete(param1) 53 | dbsession.delete(param2) 54 | dbsession.delete(param3) 55 | dbsession.delete(param4) 56 | dbsession.flush() 57 | dbsession.delete(category1) 58 | dbsession.delete(category2) 59 | dbsession.delete(category3) 60 | dbsession.commit() 61 | 62 | 63 | @pytest.mark.authenticated("userdata.info.admin") 64 | def test_get_some_users(client, dbsession, category_no_scopes, source): 65 | source = source() 66 | category1 = category_no_scopes() 67 | param1 = Param( 68 | name=f"test{random_string()}", category_id=category1.id, type="last", changeable=True, is_required=True 69 | ) 70 | dbsession.add_all([param1]) 71 | dbsession.flush() 72 | info1 = Info(value=f"test{random_string()}", source_id=source.id, param_id=param1.id, owner_id=1) 73 | info2 = Info(value=f"test{random_string()}", source_id=source.id, param_id=param1.id, owner_id=2) 74 | info3 = Info(value=f"test{random_string()}", source_id=source.id, param_id=param1.id, owner_id=3) 75 | dbsession.add_all([info1, info2, info3]) 76 | dbsession.commit() 77 | response = client.get(f"/user", params={"users": [1, 2], "categories": [category1.id]}) 78 | assert response.status_code == 200 79 | assert {"user_id": 1, "category": category1.name, "param": param1.name, "value": info1.value} in list( 80 | response.json()["items"] 81 | ) 82 | assert {"user_id": 2, "category": category1.name, "param": param1.name, "value": info2.value} in list( 83 | response.json()["items"] 84 | ) 85 | assert {"user_id": 3, "category": category1.name, "param": param1.name, "value": info3.value} not in list( 86 | response.json()["items"] 87 | ) 88 | response = client.get(f"/user", params={"users": [3], "categories": [category1.id]}) 89 | assert response.status_code == 200 90 | assert len(response.json()["items"]) == 1 91 | assert {"user_id": 3, "category": category1.name, "param": param1.name, "value": info3.value} in list( 92 | response.json()["items"] 93 | ) 94 | dbsession.delete(info1) 95 | dbsession.delete(info2) 96 | dbsession.delete(info3) 97 | dbsession.flush() 98 | dbsession.delete(param1) 99 | dbsession.flush() 100 | dbsession.delete(category1) 101 | dbsession.commit() 102 | 103 | 104 | @pytest.mark.authenticated("userdata.info.admin") 105 | def test_get_some_categories(client, dbsession, category_no_scopes, source): 106 | source = source() 107 | category1 = category_no_scopes() 108 | category2 = category_no_scopes() 109 | category3 = category_no_scopes() 110 | param1 = Param( 111 | name=f"test{random_string()}", category_id=category1.id, type="last", changeable=True, is_required=True 112 | ) 113 | param2 = Param( 114 | name=f"test{random_string()}", category_id=category2.id, type="last", changeable=True, is_required=True 115 | ) 116 | param3 = Param( 117 | name=f"test{random_string()}", category_id=category3.id, type="last", changeable=True, is_required=True 118 | ) 119 | dbsession.add_all([param1, param2, param3]) 120 | dbsession.flush() 121 | info1 = Info(value=f"test{random_string()}", source_id=source.id, param_id=param1.id, owner_id=1) 122 | info2 = Info(value=f"test{random_string()}", source_id=source.id, param_id=param2.id, owner_id=1) 123 | info3 = Info(value=f"test{random_string()}", source_id=source.id, param_id=param3.id, owner_id=1) 124 | dbsession.add_all([info1, info2, info3]) 125 | dbsession.commit() 126 | response = client.get(f"/user", params={"users": [1], "categories": [category1.id, category2.id]}) 127 | assert response.status_code == 200 128 | assert {"user_id": 1, "category": category1.name, "param": info1.param.name, "value": info1.value} in list( 129 | response.json()["items"] 130 | ) 131 | assert {"user_id": 1, "category": category2.name, "param": info2.param.name, "value": info2.value} in list( 132 | response.json()["items"] 133 | ) 134 | assert {"user_id": 1, "category": category3.name, "param": info3.param.name, "value": info3.value} not in list( 135 | response.json()["items"] 136 | ) 137 | 138 | response = client.get(f"/user", params={"users": [1], "categories": [category3.id]}) 139 | assert {"user_id": 1, "category": category3.name, "param": info3.param.name, "value": info3.value} in list( 140 | response.json()["items"] 141 | ) 142 | 143 | dbsession.delete(info1) 144 | dbsession.delete(info2) 145 | dbsession.delete(info3) 146 | dbsession.flush() 147 | dbsession.delete(param1) 148 | dbsession.delete(param2) 149 | dbsession.delete(param3) 150 | dbsession.flush() 151 | dbsession.delete(category1) 152 | dbsession.delete(category2) 153 | dbsession.delete(category3) 154 | dbsession.commit() 155 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Сервис пользовательских данных 2 | 3 | Серверная часть сервиса хранения и управления информации о пользователе 4 | 5 | [](https://easycode.profcomff.com/templates/docker-fastapi/workspace?mode=manual¶m.Repository+URL=https://github.com/profcomff/userdata-api.git¶m.Working+directory=userdata-api) 6 | 7 | ![Auth Schema](https://github.com/profcomff/auth-api/assets/5656720/ab2730be-054a-454c-ab76-5475e615bb64) 8 | 9 | ## Функционал 10 | 1. Управление категориями, параметрами, источниками данных. 11 | 2. Управление правами доступа к информации 12 | 3. Получение/изменение пользовательской информации через HTTP 13 | 4. Потоковое изменение пользовательской информации на основе данных передаваемых OAuth методами входа 14 | 15 | - Про понятия использоованные в этом пункте можно почитать ниже(см. Основные абстракции) 16 | 17 | ## Запуск 18 | 19 | 1. Перейдите в папку проекта 20 | 21 | 2. Создайте виртуальное окружение командой и активируйте его: 22 | ```console 23 | foo@bar:~$ python3 -m venv venv 24 | foo@bar:~$ source ./venv/bin/activate # На MacOS и Linux 25 | foo@bar:~$ venv\Scripts\activate # На Windows 26 | ``` 27 | 28 | 3. Установите библиотеки 29 | ```console 30 | foo@bar:~$ pip install -r requirements.txt 31 | foo@bar:~$ pip install -r requirements.dev.txt 32 | ``` 33 | 4. Запускайте приложение! 34 | ```console 35 | foo@bar:~$ python -m userdata_api start --instance api -- запустит АПИ 36 | foo@bar:~$ python -m userdata_api start --instance worker -- запустит Kafka worker 37 | ``` 38 | 39 | Приложение состоит из двух частей - АПИ и Kafka worker'а. 40 | 41 | АПИ нужно для управления структурой пользовательских данных - 42 | контроль над категориями данных, параметрами, источниками данных. 43 | Также, в АПИ пользовательские данные может слать 44 | сам пользователь(владелец этих данных), а также админ 45 | 46 | Kafka worker нужен для того, чтобы разгребать поступающие от OAuth 47 | методов авторизации AuthAPI пользовательские данные 48 | 49 | ## ENV-variables description 50 | 51 | :star2: Все параметры для Kafka являются необязательными 52 | 53 | - `DB_DSN=postgresql://postgres@localhost:5432/postgres` – Данные для подключения к БД 54 | - `KAFKA_DSN` - URL для подключение к Kafka 55 | - `KAFKA_LOGIN` - логин для подключения к Kafka 56 | - `KAFKA_PASSWORD` - пароль для подключения к Kafka 57 | - `KAFKA_TOPICS` - Kafka топики, из которых читается информация 58 | - `KAFKA_GROUP_ID` - Группа, от имени которой происходит чтение топиков 59 | - Остальные общие для всех АПИ параметры описаны [тут](https://docs.profcomff.com/tvoy-ff/backend/settings.html) 60 | 61 | ## Основные абстракции 62 | 63 | - Параметр - поле для пользовательской информации(например: Email, Телефон, Адрес, Курс обучения, Номер студенческого билета) 64 | 65 | - Категория - объединение нескольких параметров по общему признаку. Например: Email, Телефон - _личная информация_; Курс обучения, Номер студенческого билета - _учеба_. Категория имеет `read_scope` - право на её чтение другими пользователями и `update_scope` - право на её изменение дургими пользователями 66 | 67 | - Источник - откуда пришла информация о пользователе. Наприимер: VK, GitHub, LKMSU, User(он сам её добавил), Admin(админ её добавил). Источник имеет `trust_level`: то есть информации из одних источников мы можем доверять больше, чем другим 68 | 69 | - Информация - само значение поля. Имеет ссылку на параметр и источник. Соответственно, значение одного параметра у пользователя может быть списком, так как информация для этого параметра пришла из различных источников 70 | 71 | ### Пример 72 | 73 | Пользователь с `id=1` 74 | 75 | | Категория | Параметр | Источник | Значение | 76 | |---|---|---|---| 77 | | Учёба | Курс обучения | User | 4 | 78 | | Учёба | Курс обучения | LKMSU | 4 | 79 | | Учёба | Номер студенческого билета | LKMSU | 42424242 | 80 | | Личная информация | Email | User | email1@real.email | 81 | | Личная информация | Email | Admin | email2@real.email | 82 | | Личная информация | Email | VK | email3@vk.real.email | 83 | | Личная информация | Email | Yandex | email4@ya.real.email | 84 | | Личная информация | Email | GitHub | email5@github.email | 85 | | Личная информация | Телефон | User | +79094242 | 86 | 87 | 88 | ## Сценарий использования 89 | 90 | ### Создать категорию 91 | 92 | 1. Дёрнуть ручку `POST /category`. Вы передаете 93 | ```json 94 | { 95 | "name": "", // Имя категории 96 | "read_scope": "", // Скоуп на чтение 97 | "update_scope": "" // Скоуп на запись 98 | } 99 | ``` 100 | 2. Сооздать в Auth API нужные scopes(если передали не `null`) 101 | 102 | ### Создать параметр 103 | 104 | 1. Дернуть ручку `POST /category/{category_id}/param`. Передать 105 | ```json 106 | { 107 | "name": "", // Имя параметра 108 | "is_required": bool, // Обязателен ли он 109 | "changeable": bool, // Изменяем ли он после установки 110 | "type": "all || last || most_trusted" // Какой значение параметра из множества, задаваемого источником, будет возвращаться 111 | } 112 | ``` 113 | 114 | ### Получить список категорий 115 | 116 | 1. Дернуть ручку `GET /category`. Если нужна иинформация о параметрах, которые есть в каждой из категорий, то дернуть ручку `GET /category?query=param` 117 | 118 | ### Обновить информацию о пользователе 119 | 120 | 1. Дернуть ручку `POST /user/{user_id}`, передать туда 121 | ```json 122 | { 123 | "items": [ // Список новых значений 124 | { 125 | "category": "", // Имя категории в которой находится параметр 126 | "param": "", // Имя изменяемого параметра 127 | "value": "" // Новое значение. Если раньше значения не существовало, то оно будет создано. Если передать null, то значение будет удалено. 128 | } 129 | ], 130 | "source": "string" // Источник информации. По http доступно только user и admin 131 | } 132 | ``` 133 | - Информацию меняется только в пределах источника. То есть админ не может поменять инфоормацию, переданную пользователем. Он может только создать новую или поменять информацию из источника admin 134 | 135 | - Пользователь может создать любую информацию о себе 136 | 137 | - Пользователь может обновить/удалить только _изменяемую_ информацию 138 | 139 | - Неизменяемая информация изменяема при наличии scope `userdata.info.update` 140 | 141 | - Обновить информацию о другом пользователе из конкретноой категории можно только при наличии права на изменение в этой категории (см. `category.update_scope`) 142 | 143 | ### Получить информацию о пользователе/пользователях 144 | 145 | #### Получить информацию о пользователе по id 146 | 147 | Дернуть ручку `GET /user/{user_id}`. 148 | 149 | - Пользователь может получить всю информацию о себе 150 | 151 | #### Получить информацию о нескольких пользователях об указанных категориях 152 | 153 | Дернуть ручку `GET /user?users=...?categories=...`. 154 | 155 | - Ручка закрыта за скоупом `userdata.info.admin` 156 | 157 | - Query параметры `users` и `categories` обязательные 158 | 159 | В обеих `GET /user/` ручках информация будет возвращаться таким образом: 160 | 161 | - В соответствии с переданными scopes 162 | - Можно получить только информацию из тех категорий на которые у него есть права (см `category.read_scope`) 163 | - С поставленными `param.type`. 164 | 165 | - Если поставлен `all` то вернется информация соотв. данному параметру из всех источников 166 | - `last` - вернется последняя обновленная информация 167 | - `most_trusted` - вернется информация из самого доверенного источника 168 | 169 | ## Contributing 170 | 171 | - Основная [информация](https://docs.profcomff.com/tvoy-ff/backend/index.html) по разработке наших приложений 172 | 173 | - [Ссылка](https://github.com/profcomff/userdata-api/blob/main/CONTRIBUTING.md) на страницу с информацией по разработке userdata-api 174 | -------------------------------------------------------------------------------- /tests/test_routes/test_user_get.py: -------------------------------------------------------------------------------- 1 | from time import sleep 2 | 3 | import pytest 4 | 5 | from userdata_api.models.db import * 6 | from userdata_api.utils.utils import random_string 7 | 8 | 9 | @pytest.mark.authenticated("test.scope", user_id=0) 10 | def test_get(client, dbsession, source, info_no_scopes): 11 | info1: Info = info_no_scopes() 12 | info1.category.read_scope = "test.scope" 13 | dbsession.commit() 14 | response = client.get(f"/user/{info1.owner_id}") 15 | assert response.status_code == 200 16 | assert info1.category.name == response.json()["items"][0]["category"] 17 | assert info1.param.name == response.json()["items"][0]["param"] 18 | assert info1.value == response.json()["items"][0]["value"] 19 | dbsession.delete(info1) 20 | dbsession.commit() 21 | 22 | 23 | @pytest.mark.authenticated(user_id=1) 24 | def test_get_no_all_scopes(client, dbsession, source, info_no_scopes): 25 | info1: Info = info_no_scopes() 26 | info1.category.read_scope = "test.scope" 27 | dbsession.commit() 28 | response = client.get(f"/user/{info1.owner_id}") 29 | assert response.status_code == 200 30 | assert info1.category.name not in response.json() 31 | dbsession.delete(info1) 32 | dbsession.commit() 33 | 34 | 35 | @pytest.mark.authenticated() 36 | def test_get_a_few(client, dbsession, category_no_scopes, source): 37 | source = source() 38 | category1 = category_no_scopes() 39 | category2 = category_no_scopes() 40 | category3 = category_no_scopes() 41 | param1 = Param( 42 | name=f"test{random_string()}", category_id=category1.id, type="last", changeable=True, is_required=True 43 | ) 44 | param2 = Param( 45 | name=f"test{random_string()}", category_id=category1.id, type="last", changeable=True, is_required=True 46 | ) 47 | param3 = Param( 48 | name=f"test{random_string()}", category_id=category2.id, type="last", changeable=True, is_required=True 49 | ) 50 | param4 = Param( 51 | name=f"test{random_string()}", category_id=category3.id, type="last", changeable=True, is_required=True 52 | ) 53 | dbsession.add_all([param1, param2, param3, param4]) 54 | dbsession.flush() 55 | info1 = Info(value=f"test{random_string()}", source_id=source.id, param_id=param1.id, owner_id=0) 56 | info2 = Info(value=f"test{random_string()}", source_id=source.id, param_id=param2.id, owner_id=0) 57 | info3 = Info(value=f"test{random_string()}", source_id=source.id, param_id=param3.id, owner_id=0) 58 | info4 = Info(value=f"test{random_string()}", source_id=source.id, param_id=param4.id, owner_id=0) 59 | dbsession.add_all([info1, info2, info3, info4]) 60 | dbsession.commit() 61 | response = client.get(f"/user/{info1.owner_id}") 62 | assert response.status_code == 200 63 | assert {"category": category1.name, "param": info1.param.name, "value": info1.value} in list( 64 | response.json()["items"] 65 | ) 66 | assert {"category": category1.name, "param": info2.param.name, "value": info2.value} in list( 67 | response.json()["items"] 68 | ) 69 | assert {"category": category2.name, "param": info3.param.name, "value": info3.value} in list( 70 | response.json()["items"] 71 | ) 72 | assert {"category": category3.name, "param": info4.param.name, "value": info4.value} in list( 73 | response.json()["items"] 74 | ) 75 | assert len(list(response.json()["items"])) == 4 76 | dbsession.delete(info1) 77 | dbsession.delete(info2) 78 | dbsession.delete(info3) 79 | dbsession.delete(info4) 80 | dbsession.flush() 81 | dbsession.delete(param1) 82 | dbsession.delete(param2) 83 | dbsession.delete(param3) 84 | dbsession.delete(param4) 85 | dbsession.flush() 86 | dbsession.delete(category1) 87 | dbsession.delete(category2) 88 | dbsession.delete(category3) 89 | dbsession.commit() 90 | 91 | 92 | @pytest.mark.authenticated() 93 | def test_get_a_few_with_trust_level(client, dbsession, category_no_scopes, source): 94 | source1 = source() 95 | source2 = source() 96 | category1 = category_no_scopes() 97 | category2 = category_no_scopes() 98 | category3 = category_no_scopes() 99 | param1 = Param( 100 | name=f"test{random_string()}", category_id=category1.id, type="all", changeable=True, is_required=True 101 | ) 102 | param2 = Param( 103 | name=f"test{random_string()}", category_id=category1.id, type="all", changeable=True, is_required=True 104 | ) 105 | param3 = Param( 106 | name=f"test{random_string()}", category_id=category2.id, type="last", changeable=True, is_required=True 107 | ) 108 | param4 = Param( 109 | name=f"test{random_string()}", category_id=category3.id, type="most_trusted", changeable=True, is_required=True 110 | ) 111 | dbsession.add_all([param1, param2, param3, param4]) 112 | dbsession.flush() 113 | source2.trust_level = 9 114 | dbsession.commit() 115 | 116 | info1 = Info(value=f"test{random_string()}", source_id=source1.id, param_id=param1.id, owner_id=0) 117 | info2 = Info(value=f"test{random_string()}", source_id=source2.id, param_id=param1.id, owner_id=0) 118 | info3 = Info(value=f"test{random_string()}", source_id=source1.id, param_id=param1.id, owner_id=0) 119 | 120 | info4 = Info(value=f"test{random_string()}", source_id=source1.id, param_id=param2.id, owner_id=0) 121 | 122 | info5 = Info(value=f"test{random_string()}", source_id=source1.id, param_id=param3.id, owner_id=0) 123 | info6 = Info(value=f"test{random_string()}", source_id=source2.id, param_id=param3.id, owner_id=0) 124 | dbsession.add_all([info1, info2, info3, info4, info5, info6]) 125 | dbsession.commit() 126 | info7 = Info(value=f"test{random_string()}", source_id=source1.id, param_id=param3.id, owner_id=0) 127 | 128 | info8 = Info(value=f"test{random_string()}", source_id=source1.id, param_id=param4.id, owner_id=0) 129 | info9 = Info(value=f"test{random_string()}", source_id=source2.id, param_id=param4.id, owner_id=0) 130 | info10 = Info(value=f"test{random_string()}", source_id=source1.id, param_id=param4.id, owner_id=0) 131 | dbsession.add_all([info7, info8, info9, info10]) 132 | dbsession.commit() 133 | response = client.get(f"/user/{info1.owner_id}") 134 | assert response.status_code == 200 135 | assert {"category": category1.name, "param": param1.name, "value": info1.value} in list(response.json()["items"]) 136 | assert {"category": category1.name, "param": param1.name, "value": info2.value} in list(response.json()["items"]) 137 | assert {"category": category1.name, "param": param1.name, "value": info3.value} in list(response.json()["items"]) 138 | assert {"category": category1.name, "param": param2.name, "value": info4.value} in list(response.json()["items"]) 139 | assert {"category": category2.name, "param": param3.name, "value": info7.value} in list(response.json()["items"]) 140 | assert {"category": category3.name, "param": param4.name, "value": info9.value} in list(response.json()["items"]) 141 | assert len(response.json()["items"]) == 6 142 | dbsession.delete(info1) 143 | dbsession.delete(info2) 144 | dbsession.delete(info3) 145 | dbsession.delete(info4) 146 | dbsession.delete(info5) 147 | dbsession.delete(info6) 148 | dbsession.delete(info7) 149 | dbsession.delete(info8) 150 | dbsession.delete(info9) 151 | dbsession.delete(info10) 152 | dbsession.flush() 153 | dbsession.delete(param1) 154 | dbsession.delete(param2) 155 | dbsession.delete(param3) 156 | dbsession.delete(param4) 157 | dbsession.flush() 158 | dbsession.delete(category1) 159 | dbsession.delete(category2) 160 | dbsession.delete(category3) 161 | dbsession.commit() 162 | 163 | 164 | @pytest.mark.authenticated() 165 | def test_get_last_most_trusted(client, dbsession, category_no_scopes, source): 166 | source1 = source() 167 | source2 = source() 168 | category1 = category_no_scopes() 169 | param1 = Param( 170 | name=f"test{random_string()}", category_id=category1.id, type="most_trusted", changeable=True, is_required=True 171 | ) 172 | dbsession.add(param1) 173 | dbsession.flush() 174 | source2.trust_level = 9 175 | dbsession.commit() 176 | info1 = Info(value=f"test{random_string()}", source_id=source1.id, param_id=param1.id, owner_id=0) 177 | info2 = Info(value=f"test{random_string()}", source_id=source2.id, param_id=param1.id, owner_id=0) 178 | dbsession.add_all([info1, info2]) 179 | dbsession.commit() 180 | sleep(0.1) 181 | info3 = Info(value=f"test{random_string()}", source_id=source1.id, param_id=param1.id, owner_id=0) 182 | info4 = Info(value=f"test{random_string()}", source_id=source2.id, param_id=param1.id, owner_id=0) 183 | dbsession.add_all([info3, info4]) 184 | dbsession.commit() 185 | response = client.get(f"/user/{info1.owner_id}") 186 | assert response.status_code == 200 187 | assert {"category": category1.name, "param": param1.name, "value": info4.value} in list(response.json()["items"]) 188 | assert len(response.json()["items"]) == 1 189 | dbsession.delete(info1) 190 | dbsession.delete(info2) 191 | dbsession.delete(info3) 192 | dbsession.delete(info4) 193 | dbsession.flush() 194 | dbsession.delete(param1) 195 | dbsession.flush() 196 | dbsession.delete(category1) 197 | dbsession.commit() 198 | -------------------------------------------------------------------------------- /userdata_api/utils/user.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from re import search 4 | 5 | from fastapi_sqlalchemy import db 6 | from sqlalchemy import not_, or_ 7 | 8 | from userdata_api.exceptions import Forbidden, InvalidValidation, ObjectNotFound 9 | from userdata_api.models.db import Category, Info, Param, Source, ViewType 10 | from userdata_api.schemas.user import UserInfoGet, UserInfoUpdate, UsersInfoGet 11 | 12 | 13 | async def patch_user_info(new: UserInfoUpdate, user_id: int, user: dict[str, int | list[dict[str, str | int]]]) -> None: 14 | """ 15 | Обновить информацию о пользователе в соотетствии с переданным токеном. 16 | 17 | Метод обновляет только информацию из источников `admin`, `user` или `dwh`. 18 | 19 | Для обновления от имени админа нужен скоуп `userdata.info.admin` 20 | 21 | Для обновления информации из dwh нужен скоуп `userdata.info.dwh` 22 | 23 | Для обновления от иимени пользователя необходима владениие ининформацией 24 | 25 | Обноввляет только инормацую созданную самим источником 26 | 27 | Для удаления информации передать None в соответствущем словаре из списка new.items 28 | 29 | :param new: модель запроса, в ней то на что будет изменена информация о пользователе 30 | :param user_id: Айди пользователя 31 | :param user: Сессия пользователя выполняющего запрос 32 | :return: get_user_info для текущего пользователя с переданными правами 33 | """ 34 | scope_names = tuple(scope["name"] for scope in user["session_scopes"]) 35 | if new.source == "admin" and "userdata.info.admin" not in scope_names: 36 | raise Forbidden( 37 | "Admin source requires 'userdata.info.admin' scope", 38 | "Источник 'администратор' требует право 'userdata.info.admin'", 39 | ) 40 | if new.source == "dwh" and "userdata.info.dwh" not in scope_names: 41 | raise Forbidden( 42 | "Dwh source requires 'userdata.info.dwh' scope", 43 | "Источник 'dwh' требует право 'userdata.info.dwh'", 44 | ) 45 | if new.source != "admin" and new.source != "user" and new.source != "dwh": 46 | raise Forbidden( 47 | "HTTP protocol applying only 'admin', 'user' or 'dwh' source", 48 | "Данный источник информации не обновляется через HTTP", 49 | ) 50 | if new.source == "user" and user["id"] != user_id: 51 | raise Forbidden("'user' source requires information own", "Требуется владение информацией") 52 | for item in new.items: 53 | param = ( 54 | db.session.query(Param) 55 | .join(Category) 56 | .filter( 57 | Param.name == item.param, 58 | Category.name == item.category, 59 | not_(Param.is_deleted), 60 | not_(Category.is_deleted), 61 | ) 62 | .one_or_none() 63 | ) 64 | if not param: 65 | raise ObjectNotFound(Param, item.param) 66 | if ( 67 | param.category.update_scope is not None 68 | and param.category.update_scope not in scope_names 69 | and not (new.source == "user" and user["id"] == user_id) 70 | ): 71 | db.session.rollback() 72 | raise Forbidden( 73 | f"Updating category {param.category.name=} requires {param.category.update_scope=} scope", 74 | f"Обновление категории {param.category.name=} требует {param.category.update_scope=} права", 75 | ) 76 | info = ( 77 | db.session.query(Info) 78 | .join(Source) 79 | .filter( 80 | Info.param_id == param.id, 81 | Info.owner_id == user_id, 82 | Source.name == new.source, 83 | not_(Info.is_deleted), 84 | ) 85 | .one_or_none() 86 | ) 87 | if not info and item.value is None: 88 | continue 89 | if not info: 90 | source = Source.query(session=db.session).filter(Source.name == new.source).one_or_none() 91 | if not source: 92 | raise ObjectNotFound(Source, new.source) 93 | if param.validation is not None and search(param.validation, item.value) is None: 94 | raise InvalidValidation(Info, "value") 95 | Info.create( 96 | session=db.session, 97 | owner_id=user_id, 98 | param_id=param.id, 99 | source_id=source.id, 100 | value=item.value, 101 | ) 102 | continue 103 | if item.value is None: 104 | info.is_deleted = True 105 | db.session.flush() 106 | continue 107 | if not param.changeable and "userdata.info.update" not in scope_names: 108 | db.session.rollback() 109 | raise Forbidden( 110 | f"Param {param.name=} change requires 'userdata.info.update' scope", 111 | f"Изменение {param.name=} параметра требует 'userdata.info.update' права", 112 | ) 113 | if param.validation is not None and search(param.validation, item.value) is None: 114 | raise InvalidValidation(Info, "value") 115 | info.value = item.value 116 | db.session.flush() 117 | 118 | 119 | async def get_users_info( 120 | user_ids: list[int], 121 | category_ids: list[int] | None, 122 | user: dict[str, int | list[dict[str, str | int]]], 123 | additional_data: list[int] | None = None, 124 | ) -> list[dict[str, str | None]]: 125 | """. 126 | Возвращает информацию о данных пользователей в указанных категориях 127 | 128 | :param user_ids: Список айди юзеров 129 | :param category_ids: Список айди необходимых категорий, если None, то мы запрашиваем информацию только обо одном пользователе user_ids[0] обо всех досутпных категориях 130 | :param user: Сессия выполняющего запрос данных 131 | :return: Список словарей содержащих id пользователя, категорию, параметр категории и значение этого параметра у пользователя 132 | """ 133 | if additional_data is None: 134 | additional_data = [] 135 | is_single_user = category_ids is None 136 | scope_names = [scope["name"] for scope in user["session_scopes"]] 137 | param_dict: dict[Param, dict[int, list[Info] | Info | None] | None] = {} 138 | query: list[Info] = ( 139 | Info.query(session=db.session) 140 | .join(Param) 141 | .join(Category) 142 | .filter( 143 | Info.owner_id.in_(user_ids), 144 | not_(Param.is_deleted), 145 | not_(Category.is_deleted), 146 | not_(Info.is_deleted), 147 | or_( 148 | Param.visible_in_user_response, 149 | Param.id.in_(additional_data), 150 | ), 151 | ) 152 | ) 153 | if not is_single_user: 154 | query = query.filter(Param.category_id.in_(category_ids)) 155 | infos = query.all() 156 | if not infos: 157 | raise ObjectNotFound(Info, user_ids) 158 | result = [] 159 | for info in infos: 160 | if ( 161 | info.category.read_scope 162 | and info.category.read_scope not in scope_names 163 | and (not is_single_user or info.owner_id != user["id"]) 164 | and not info.param.is_public 165 | ): 166 | continue 167 | if info.param not in param_dict: 168 | param_dict[info.param] = {} 169 | if info.owner_id not in param_dict[info.param]: 170 | param_dict[info.param][info.owner_id] = [] if info.param.type == ViewType.ALL else None 171 | if info.param.type == ViewType.ALL: 172 | param_dict[info.param][info.owner_id].append(info) 173 | elif param_dict[info.param][info.owner_id] is None or ( 174 | (info.param.type == ViewType.LAST and info.create_ts > param_dict[info.param][info.owner_id].create_ts) 175 | or ( 176 | info.param.type == ViewType.MOST_TRUSTED 177 | and ( 178 | param_dict[info.param][info.owner_id].source.trust_level < info.source.trust_level 179 | or ( 180 | param_dict[info.param][info.owner_id].source.trust_level <= info.source.trust_level 181 | and info.create_ts > param_dict[info.param][info.owner_id].create_ts 182 | ) 183 | ) 184 | ) 185 | ): 186 | """ 187 | Сюда он зайдет либо если параметру не соответствует никакой информации, 188 | либо если встретил более релевантную. 189 | 190 | Если у параметра отображение по доверию, то более релевантная 191 | - строго больше индекс доверия/такой же индекс доверия, 192 | но информация более поздняя по времени 193 | 194 | Если у параметра отображение по времени то более релевантная - более позднаяя 195 | """ 196 | param_dict[info.param][info.owner_id] = info 197 | result = [] 198 | for param, user_dict in param_dict.items(): 199 | for owner_id, item in user_dict.items(): 200 | if isinstance(item, list): 201 | result.extend( 202 | [ 203 | { 204 | "user_id": owner_id, 205 | "category": _item.category.name, 206 | "param": param.name, 207 | "value": _item.value, 208 | } 209 | for _item in item 210 | ] 211 | ) 212 | else: 213 | result.append( 214 | { 215 | "user_id": owner_id, 216 | "category": item.category.name, 217 | "param": param.name, 218 | "value": item.value, 219 | } 220 | ) 221 | return result 222 | 223 | 224 | async def get_users_info_batch( 225 | user_ids: list[int], 226 | category_ids: list[int], 227 | user: dict[str, int | list[dict[str, str | int]]], 228 | additional_data: list[int], 229 | ) -> UsersInfoGet: 230 | """. 231 | Возвращает информацию о данных пользователей в указанных категориях 232 | 233 | :param user_ids: Список айди юзеров 234 | :param category_ids: Список айди необходимых категорий 235 | :param user: Сессия выполняющего запрос данных 236 | :return: Список словарей содержащих id пользователя, категорию, параметр категории и значение этого параметра у пользователя 237 | """ 238 | 239 | return UsersInfoGet(items=await get_users_info(user_ids, category_ids, user, additional_data)) 240 | 241 | 242 | async def get_user_info(user_id: int, user: dict[str, int | list[dict[str, str | int]]]) -> UserInfoGet: 243 | """Возвращает информауию о пользователе в соотетствии с переданным токеном. 244 | 245 | Пользователь может прочитать любую информацию о себе 246 | 247 | Токен с доступом к read_scope категории может получить доступ к данным категории у любых пользователей 248 | 249 | :param user_id: Айди пользователя 250 | :param user: Сессия выполняющего запрос данных 251 | :return: Список словарей содержащих категорию, параметр категории и значение этого параметра у пользователя 252 | """ 253 | 254 | result = await get_users_info([user_id], None, user) 255 | for value in result: 256 | del value["user_id"] 257 | return UserInfoGet(items=result) 258 | -------------------------------------------------------------------------------- /tests/test_routes/test_user_update.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from userdata_api.models.db import * 4 | from userdata_api.utils.utils import random_string 5 | 6 | 7 | @pytest.mark.authenticated("test.cat_update.first", "test.cat_update.second", "userdata.info.admin", user_id=1) 8 | def test_main_scenario(dbsession, client, param, admin_source): 9 | param1 = param() 10 | param2 = param() 11 | param3 = Param( 12 | name=f"test{random_string()}", category_id=param1.category_id, type="all", changeable=True, is_required=True 13 | ) 14 | dbsession.add(param3) 15 | source1 = admin_source 16 | param1.category.update_scope = "test.cat_update.first" 17 | param2.category.update_scope = "test.cat_update.second" 18 | dbsession.flush() 19 | info1 = Info(value=f"test{random_string()}", source_id=source1.id, param_id=param1.id, owner_id=0) 20 | info2_val = f"test{random_string()}" 21 | info2 = Info(value=info2_val, source_id=source1.id, param_id=param2.id, owner_id=0) 22 | 23 | dbsession.add_all([info1, info2]) 24 | dbsession.commit() 25 | client.get("/user/0") 26 | response = client.post( 27 | f"/user/0", 28 | json={ 29 | "source": source1.name, 30 | "items": [ 31 | {"category": param1.category.name, "param": param1.name, "value": "first_updated"}, 32 | {"category": param3.category.name, "param": param3.name, "value": "second_updated"}, 33 | ], 34 | }, 35 | ) 36 | assert response.status_code == 200 37 | dbsession.expire_all() 38 | assert info1.value == "first_updated" 39 | assert not info2.is_deleted 40 | assert info2.value == info2_val 41 | first: Info = ( 42 | dbsession.query(Info) 43 | .filter(Info.param_id == param1.id, Info.owner_id == 0, Info.is_deleted == False) 44 | .one_or_none() 45 | ) 46 | second: Info = ( 47 | dbsession.query(Info) 48 | .filter(Info.param_id == param3.id, Info.owner_id == 0, Info.is_deleted == False) 49 | .one_or_none() 50 | ) 51 | assert first.value == "first_updated" 52 | assert second.value == "second_updated" 53 | response = client.post( 54 | f"/user/0", 55 | json={ 56 | "source": source1.name, 57 | "items": [{"category": param2.category.name, "param": param2.name, "value": None}], 58 | }, 59 | ) 60 | assert response.status_code == 200 61 | dbsession.expire_all() 62 | assert info2.is_deleted 63 | third = ( 64 | dbsession.query(Info) 65 | .filter(Info.param_id == param2.id, Info.owner_id == 0, Info.is_deleted == False) 66 | .one_or_none() 67 | ) 68 | assert not third 69 | first_param = [ 70 | info.dict() for info in dbsession.query(Info).filter(Info.param_id == param1.id, Info.owner_id == 0).all() 71 | ] 72 | second_param = [ 73 | info.dict() for info in dbsession.query(Info).filter(Info.param_id == param2.id, Info.owner_id == 0).all() 74 | ] 75 | third_param = [ 76 | info.dict() for info in dbsession.query(Info).filter(Info.param_id == param3.id, Info.owner_id == 0).all() 77 | ] 78 | assert first_param and second_param and third_param 79 | for info in first_param: 80 | dbsession.query(Info).filter(Info.id == info["id"]).delete() 81 | for info in second_param: 82 | dbsession.query(Info).filter(Info.id == info["id"]).delete() 83 | for info in third_param: 84 | dbsession.query(Info).filter(Info.id == info["id"]).delete() 85 | dbsession.commit() 86 | dbsession.delete(param3) 87 | 88 | 89 | @pytest.mark.authenticated("test.cat_update.first", "test.cat_update.second", user_id=1) 90 | def test_forbidden_admin(dbsession, client, param, admin_source): 91 | param1 = param() 92 | param2 = param() 93 | param3 = Param( 94 | name=f"test{random_string()}", category_id=param1.category_id, type="all", changeable=True, is_required=True 95 | ) 96 | dbsession.add(param3) 97 | param1.category.update_scope = "test.cat_update.first" 98 | param2.category.update_scope = "test.cat_update.second" 99 | info1 = Info(value=f"test{random_string()}", source_id=admin_source.id, param_id=param1.id, owner_id=0) 100 | info2 = Info(value=f"test{random_string()}", source_id=admin_source.id, param_id=param2.id, owner_id=0) 101 | dbsession.add_all([info1, info2]) 102 | dbsession.commit() 103 | client.get("/user/0") 104 | response = client.post( 105 | f"/user/0", 106 | json={ 107 | "source": admin_source.name, 108 | "items": [ 109 | {"category": param1.category.name, "param": param1.name, "value": "first_updated"}, 110 | {"category": param3.category.name, "param": param3.name, "value": "second_updated"}, 111 | ], 112 | }, 113 | ) 114 | dbsession.expire_all() 115 | assert response.status_code == 403 116 | assert not info1.is_deleted 117 | assert not info2.is_deleted 118 | dbsession.delete(info1) 119 | dbsession.delete(info2) 120 | dbsession.delete(param3) 121 | dbsession.commit() 122 | 123 | 124 | @pytest.mark.authenticated(user_id=0) 125 | def test_user_update_existing_info(dbsession, client, param, admin_source, source): 126 | param1 = param() 127 | user_source = source() 128 | user_source.name = "user" 129 | param1.category.update_scope = "test.cat_update.first" 130 | admin_info = f"test{random_string()}" 131 | info1 = Info(value=admin_info, source_id=admin_source.id, param_id=param1.id, owner_id=0) 132 | dbsession.add(info1) 133 | dbsession.commit() 134 | client.get("/user/0") 135 | response = client.post( 136 | f"/user/0", 137 | json={ 138 | "source": "user", 139 | "items": [{"category": param1.category.name, "param": param1.name, "value": "first_updated"}], 140 | }, 141 | ) 142 | dbsession.expire_all() 143 | assert response.status_code == 200 144 | assert not info1.is_deleted 145 | assert ( 146 | dbsession.query(Info) 147 | .filter(Info.param_id == param1.id, Info.owner_id == 0, Info.source_id == admin_source.id) 148 | .one() 149 | .value 150 | == admin_info 151 | ) 152 | new = ( 153 | dbsession.query(Info) 154 | .join(Source) 155 | .filter(Info.param_id == param1.id, Info.owner_id == 0, Source.name == "user") 156 | .one() 157 | ) 158 | assert new.value == "first_updated" 159 | dbsession.delete(info1) 160 | dbsession.delete(new) 161 | dbsession.commit() 162 | 163 | 164 | @pytest.mark.authenticated("test.cat_update.first", "test.cat_update.second", "userdata.info.admin", user_id=1) 165 | def test_category_not_found(dbsession, client, param, admin_source): 166 | param1 = param() 167 | param1.category.update_scope = "test.cat_update.first" 168 | info1 = Info(value=f"test{random_string()}", source_id=admin_source.id, param_id=param1.id, owner_id=0) 169 | dbsession.add(info1) 170 | dbsession.commit() 171 | client.get("/user/0") 172 | response = client.post( 173 | f"/user/0", 174 | json={ 175 | "source": "admin", 176 | "items": [{"category": param1.category.name + "404", "param": param1.name, "value": "first_updated"}], 177 | }, 178 | ) 179 | dbsession.expire_all() 180 | assert response.status_code == 404 181 | assert not info1.is_deleted 182 | dbsession.delete(info1) 183 | dbsession.commit() 184 | 185 | 186 | @pytest.mark.authenticated("test.cat_update.first", "test.cat_update.second", "userdata.info.admin", user_id=1) 187 | def test_param_not_found(dbsession, client, param, admin_source): 188 | param1 = param() 189 | param1.category.update_scope = "test.cat_update.first" 190 | info1 = Info(value=f"test{random_string()}", source_id=admin_source.id, param_id=param1.id, owner_id=0) 191 | dbsession.add(info1) 192 | dbsession.commit() 193 | client.get("/user/0") 194 | response = client.post( 195 | f"/user/0", 196 | json={ 197 | "source": "admin", 198 | "items": [{"category": param1.category.name, "param": param1.name + "404", "value": "first_updated"}], 199 | }, 200 | ) 201 | dbsession.expire_all() 202 | assert response.status_code == 404 203 | assert not info1.is_deleted 204 | dbsession.delete(info1) 205 | dbsession.commit() 206 | 207 | 208 | @pytest.mark.authenticated("test.cat_update.first", "test.cat_update.second", "userdata.info.admin", user_id=1) 209 | def test_param_and_cat_not_found(dbsession, client, param, admin_source): 210 | param1 = param() 211 | param1.category.update_scope = "test.cat_update.first" 212 | info1 = Info(value=f"test{random_string()}", source_id=admin_source.id, param_id=param1.id, owner_id=0) 213 | dbsession.add(info1) 214 | dbsession.commit() 215 | client.get("/user/0") 216 | response = client.post( 217 | f"/user/0", 218 | json={ 219 | "source": "admin", 220 | "items": [ 221 | {"category": param1.category.name + "404", "param": param1.name + "404", "value": "first_updated"} 222 | ], 223 | }, 224 | ) 225 | dbsession.expire_all() 226 | assert response.status_code == 404 227 | assert not info1.is_deleted 228 | dbsession.delete(info1) 229 | dbsession.commit() 230 | 231 | 232 | @pytest.mark.authenticated("test.cat_update.first", user_id=0) 233 | def test_update_not_changeable(dbsession, client, param, source): 234 | param1 = param() 235 | source = source() 236 | source.name = "user" 237 | param1.category.update_scope = "test.cat_update.first" 238 | param1.changeable = False 239 | info1 = Info(value=f"test{random_string()}", source_id=source.id, param_id=param1.id, owner_id=0) 240 | dbsession.add(info1) 241 | dbsession.commit() 242 | client.get("/user/0") 243 | response = client.post( 244 | f"/user/0", 245 | json={ 246 | "source": "user", 247 | "items": [{"category": param1.category.name, "param": param1.name, "value": "first_updated"}], 248 | }, 249 | ) 250 | dbsession.expire_all() 251 | assert response.status_code == 403 252 | assert not info1.is_deleted 253 | dbsession.delete(info1) 254 | dbsession.commit() 255 | 256 | 257 | @pytest.mark.authenticated("test.cat_update.first", user_id=0) 258 | def test_update_not_changeable_from_admin(dbsession, client, param, admin_source): 259 | param1 = param() 260 | param1.category.update_scope = "test.cat_update.first" 261 | param1.changeable = False 262 | info1 = Info(value=f"test{random_string()}", source_id=admin_source.id, param_id=param1.id, owner_id=0) 263 | dbsession.add(info1) 264 | dbsession.commit() 265 | client.get("/user/0") 266 | response = client.post( 267 | f"/user/0", 268 | json={ 269 | "source": "admin", 270 | "items": [{"category": param1.category.name, "param": param1.name, "value": "first_updated"}], 271 | }, 272 | ) 273 | dbsession.expire_all() 274 | assert response.status_code == 403 275 | assert not info1.is_deleted 276 | dbsession.delete(info1) 277 | dbsession.commit() 278 | 279 | 280 | @pytest.mark.authenticated(user_id=0) 281 | def test_param_not_changeable_no_update_scope(dbsession, client, param, source): 282 | param1 = param() 283 | source = source() 284 | source.name = "user" 285 | param1.category.update_scope = "test.cat_update.first" 286 | param1.changeable = False 287 | info1 = Info(value=f"test{random_string()}", source_id=source.id, param_id=param1.id, owner_id=0) 288 | dbsession.add(info1) 289 | dbsession.commit() 290 | client.get("/user/0") 291 | response = client.post( 292 | f"/user/0", 293 | json={ 294 | "source": "user", 295 | "items": [{"category": param1.category.name, "param": param1.name, "value": "first_updated"}], 296 | }, 297 | ) 298 | dbsession.expire_all() 299 | assert response.status_code == 403 300 | assert not info1.is_deleted 301 | dbsession.delete(info1) 302 | dbsession.commit() 303 | 304 | 305 | @pytest.mark.authenticated("userdata.info.update", user_id=0) 306 | def test_param_not_changeable_with_scope(dbsession, client, param, source): 307 | param1 = param() 308 | source = source() 309 | source.name = "user" 310 | param1.category.update_scope = "test.cat_update.first" 311 | param1.changeable = False 312 | info1 = Info(value=f"test{random_string()}", source_id=source.id, param_id=param1.id, owner_id=0) 313 | dbsession.add(info1) 314 | dbsession.commit() 315 | client.get("/user/0") 316 | response = client.post( 317 | f"/user/0", 318 | json={ 319 | "source": "user", 320 | "items": [{"category": param1.category.name, "param": param1.name, "value": "first_updated"}], 321 | }, 322 | ) 323 | dbsession.expire_all() 324 | assert response.status_code == 200 325 | assert info1.value == "first_updated" 326 | dbsession.delete(info1) 327 | dbsession.commit() 328 | 329 | 330 | @pytest.mark.authenticated(user_id=1) 331 | def test_user_source_requires_information_own(dbsession, client, param, source): 332 | param1 = param() 333 | source = source() 334 | source.name = "user" 335 | param1.category.update_scope = "test.cat_update.first" 336 | param1.changeable = False 337 | info1_value = f"test{random_string()}" 338 | info1 = Info(value=info1_value, source_id=source.id, param_id=param1.id, owner_id=0) 339 | dbsession.add(info1) 340 | dbsession.commit() 341 | client.get("/user/0") 342 | response = client.post( 343 | f"/user/0", 344 | json={ 345 | "source": "user", 346 | "items": [{"category": param1.category.name, "param": param1.name, "value": "first_updated"}], 347 | }, 348 | ) 349 | dbsession.expire_all() 350 | assert response.status_code == 403 351 | assert info1.value == info1_value 352 | dbsession.delete(info1) 353 | dbsession.commit() 354 | 355 | 356 | @pytest.mark.authenticated(user_id=1) 357 | def test_not_available_sources(dbsession, client, param, source): 358 | param1 = param() 359 | source = source() 360 | source.name = "user" 361 | dbsession.commit() 362 | client.get("/user/0") 363 | response = client.post( 364 | f"/user/0", 365 | json={ 366 | "source": "not_user", 367 | "items": [{"category": param1.category.name, "param": param1.name, "value": "first_updated"}], 368 | }, 369 | ) 370 | dbsession.expire_all() 371 | assert response.status_code == 403 372 | 373 | 374 | @pytest.mark.authenticated("test.cat_update.first", "userdata.info.admin", user_id=1) 375 | def test_create_new(dbsession, client, param, source, admin_source): 376 | param = param() 377 | source = source() 378 | source.name = "user" 379 | param.category.update_scope = "test.cat_update.first" 380 | info1 = Info(value="user_info", source_id=source.id, param_id=param.id, owner_id=0) 381 | dbsession.add(info1) 382 | dbsession.commit() 383 | client.get("/user/0") 384 | response = client.post( 385 | f"/user/0", 386 | json={ 387 | "source": "admin", 388 | "items": [{"category": param.category.name, "param": param.name, "value": "admin_info"}], 389 | }, 390 | ) 391 | dbsession.expire_all() 392 | assert response.status_code == 200 393 | info_new = ( 394 | dbsession.query(Info) 395 | .filter( 396 | Info.param_id == param.id, Info.owner_id == 0, Info.source_id == admin_source.id, Info.is_deleted == False 397 | ) 398 | .one() 399 | ) 400 | assert not info1.is_deleted 401 | assert info_new.value == "admin_info" 402 | dbsession.delete(info_new) 403 | dbsession.delete(info1) 404 | 405 | 406 | @pytest.mark.authenticated("test.cat_update.first", "userdata.info.admin", user_id=1) 407 | def test_delete(dbsession, client, param, admin_source): 408 | param = param() 409 | info1 = Info(value="admin_info", source_id=admin_source.id, param_id=param.id, owner_id=0) 410 | param.category.update_scope = "test.cat_update.first" 411 | dbsession.add(info1) 412 | dbsession.commit() 413 | client.get("/user/0") 414 | response = client.post( 415 | f"/user/0", 416 | json={"source": "admin", "items": [{"category": param.category.name, "param": param.name, "value": None}]}, 417 | ) 418 | dbsession.expire_all() 419 | assert response.status_code == 200 420 | assert info1.is_deleted 421 | dbsession.delete(info1) 422 | 423 | 424 | @pytest.mark.authenticated("userdata.info.admin", user_id=1) 425 | def test_delete_forbidden_by_category_scope(dbsession, client, param, admin_source): 426 | param = param() 427 | info1 = Info(value="admin_info", source_id=admin_source.id, param_id=param.id, owner_id=0) 428 | param.category.update_scope = "test.cat_update.first" 429 | dbsession.add(info1) 430 | dbsession.commit() 431 | client.get("/user/0") 432 | response = client.post( 433 | f"/user/0", 434 | json={ 435 | "source": "admin", 436 | "items": [{"category": param.category.name, "param": param.name, "value": "first_updated"}], 437 | }, 438 | ) 439 | dbsession.expire_all() 440 | assert response.status_code == 403 441 | assert not info1.is_deleted 442 | dbsession.delete(info1) 443 | -------------------------------------------------------------------------------- /tests/test_routes/test_user_post_then_get.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from userdata_api.models.db import Info 4 | 5 | 6 | @pytest.mark.authenticated("test.cat_update.first", "userdata.info.admin", "test.cat_read.first", user_id=1) 7 | def test_create_new(dbsession, client, param, source, admin_source): 8 | param = param() 9 | source = source() 10 | source.name = "user" 11 | param.category.update_scope = "test.cat_update.first" 12 | param.category.read_scope = "test.cat_read.first" 13 | param.type = "all" 14 | info1 = Info(value="user_info", source_id=source.id, param_id=param.id, owner_id=0) 15 | dbsession.add(info1) 16 | dbsession.commit() 17 | client.get("/user/0") 18 | response_upd = client.post( 19 | f"/user/0", 20 | json={ 21 | "source": "admin", 22 | "items": [{"category": param.category.name, "param": param.name, "value": "admin_info"}], 23 | }, 24 | ) 25 | dbsession.expire_all() 26 | assert response_upd.status_code == 200 27 | response_get = client.get("/user/0") 28 | assert response_upd.json() == {'status': 'Success', 'message': 'User patch succeeded', 'ru': 'Изменение успешно'} 29 | assert {"category": param.category.name, "param": param.name, "value": "admin_info"} in list( 30 | response_get.json()["items"] 31 | ) 32 | assert {"category": param.category.name, "param": param.name, "value": "user_info"} in list( 33 | response_get.json()["items"] 34 | ) 35 | assert len(response_get.json()["items"]) == 2 36 | info_new = ( 37 | dbsession.query(Info) 38 | .filter( 39 | Info.param_id == param.id, Info.owner_id == 0, Info.source_id == admin_source.id, Info.is_deleted == False 40 | ) 41 | .one() 42 | ) 43 | dbsession.delete(info_new) 44 | dbsession.delete(info1) 45 | dbsession.commit() 46 | 47 | 48 | @pytest.mark.authenticated("test.cat_update.first", "userdata.info.admin", "test.cat_read.first", user_id=1) 49 | def test_delete(dbsession, client, param, admin_source): 50 | param = param() 51 | param.type = "all" 52 | info1 = Info(value="admin_info", source_id=admin_source.id, param_id=param.id, owner_id=0) 53 | param.category.update_scope = "test.cat_update.first" 54 | param.category.read_scope = "test.cat_read.first" 55 | dbsession.add(info1) 56 | dbsession.commit() 57 | response_old = client.get("/user/0") 58 | assert len(response_old.json()["items"]) == 1 59 | response_upd = client.post( 60 | f"/user/0", 61 | json={"source": "admin", "items": [{"category": param.category.name, "param": param.name, "value": None}]}, 62 | ) 63 | dbsession.expire_all() 64 | response_get = client.get("/user/0") 65 | assert response_upd.status_code == 200 66 | assert response_upd.json() == {'status': 'Success', 'message': 'User patch succeeded', 'ru': 'Изменение успешно'} 67 | assert response_get.status_code == 404 68 | dbsession.delete(info1) 69 | dbsession.commit() 70 | 71 | 72 | @pytest.mark.authenticated("test.cat_update.first", "userdata.info.admin", "test.cat_read.first", user_id=1) 73 | def test_update(dbsession, client, param, admin_source): 74 | param = param() 75 | param.type = "all" 76 | info1 = Info(value="admin_info", source_id=admin_source.id, param_id=param.id, owner_id=0) 77 | param.category.update_scope = "test.cat_update.first" 78 | param.category.read_scope = "test.cat_read.first" 79 | dbsession.add(info1) 80 | dbsession.commit() 81 | response_old = client.get("/user/0") 82 | assert {"category": param.category.name, "param": param.name, "value": "admin_info"} in list( 83 | response_old.json()["items"] 84 | ) 85 | assert len(response_old.json()["items"]) == 1 86 | response_upd = client.post( 87 | f"/user/0", 88 | json={ 89 | "source": "admin", 90 | "items": [{"category": param.category.name, "param": param.name, "value": "new"}], 91 | }, 92 | ) 93 | dbsession.expire_all() 94 | response_get = client.get("/user/0") 95 | assert response_upd.status_code == 200 96 | assert response_get.status_code == 200 97 | assert response_upd.json() == {'status': 'Success', 'message': 'User patch succeeded', 'ru': 'Изменение успешно'} 98 | assert {"category": param.category.name, "param": param.name, "value": "new"} in list(response_get.json()["items"]) 99 | assert len(response_get.json()["items"]) == 1 100 | dbsession.delete(info1) 101 | dbsession.commit() 102 | 103 | 104 | @pytest.mark.authenticated("test.cat_update.first", "userdata.info.admin", "test.cat_read.first", user_id=1) 105 | def test_update_not_changeable(dbsession, client, param, admin_source): 106 | param = param() 107 | param.type = "all" 108 | param.changeable = False 109 | info1 = Info(value="admin_info", source_id=admin_source.id, param_id=param.id, owner_id=0) 110 | param.category.update_scope = "test.cat_update.first" 111 | param.category.read_scope = "test.cat_read.first" 112 | dbsession.add(info1) 113 | dbsession.commit() 114 | response_old = client.get("/user/0") 115 | assert {"category": param.category.name, "param": param.name, "value": "admin_info"} in list( 116 | response_old.json()["items"] 117 | ) 118 | assert len(response_old.json()["items"]) == 1 119 | response_upd = client.post( 120 | f"/user/0", 121 | json={"source": "admin", "items": [{"category": param.category.name, "param": param.name, "value": "new"}]}, 122 | ) 123 | dbsession.expire_all() 124 | response_get = client.get("/user/0") 125 | assert response_upd.status_code == 403 126 | assert {"category": param.category.name, "param": param.name, "value": "admin_info"} in list( 127 | response_get.json()["items"] 128 | ) 129 | assert len(response_get.json()["items"]) == 1 130 | dbsession.delete(info1) 131 | dbsession.commit() 132 | 133 | 134 | @pytest.mark.authenticated( 135 | "test.cat_update.first", "userdata.info.admin", "test.cat_read.first", "userdata.info.update", user_id=1 136 | ) 137 | def test_update_not_changeable_with_scopes(dbsession, client, param, admin_source): 138 | param = param() 139 | param.type = "all" 140 | param.changeable = False 141 | info1 = Info(value="admin_info", source_id=admin_source.id, param_id=param.id, owner_id=0) 142 | param.category.update_scope = "test.cat_update.first" 143 | param.category.read_scope = "test.cat_read.first" 144 | dbsession.add(info1) 145 | dbsession.commit() 146 | response_old = client.get("/user/0") 147 | assert {"category": param.category.name, "param": param.name, "value": "admin_info"} in list( 148 | response_old.json()["items"] 149 | ) 150 | assert len(response_old.json()["items"]) == 1 151 | response_upd = client.post( 152 | f"/user/0", 153 | json={"source": "admin", "items": [{"category": param.category.name, "param": param.name, "value": "new"}]}, 154 | ) 155 | dbsession.expire_all() 156 | response_get = client.get("/user/0") 157 | assert response_get.status_code == 200 158 | assert response_upd.status_code == 200 159 | assert response_upd.json() == {'status': 'Success', 'message': 'User patch succeeded', 'ru': 'Изменение успешно'} 160 | assert {"category": param.category.name, "param": param.name, "value": "new"} in list(response_get.json()["items"]) 161 | assert len(response_get.json()["items"]) == 1 162 | dbsession.delete(info1) 163 | dbsession.commit() 164 | 165 | 166 | @pytest.mark.authenticated("test.cat_update.first", "userdata.info.admin", "test.cat_read.first", user_id=1) 167 | def test_create_new_no_category(dbsession, client, param, admin_source): 168 | param = param() 169 | param.type = "all" 170 | param.category.update_scope = "test.cat_update.first" 171 | param.category.read_scope = "test.cat_read.first" 172 | dbsession.commit() 173 | response_old = client.get("/user/0") 174 | assert response_old.status_code == 404 175 | response_upd = client.post( 176 | f"/user/0", 177 | json={"source": "admin", "items": [{"category": param.category.name, "param": param.name, "value": "new"}]}, 178 | ) 179 | dbsession.expire_all() 180 | response_get = client.get("/user/0") 181 | assert response_get.status_code == 200 182 | assert response_upd.status_code == 200 183 | assert response_upd.json() == {'status': 'Success', 'message': 'User patch succeeded', 'ru': 'Изменение успешно'} 184 | assert {"category": param.category.name, "param": param.name, "value": "new"} in list(response_get.json()["items"]) 185 | assert len(response_get.json()["items"]) == 1 186 | info_new = ( 187 | dbsession.query(Info) 188 | .filter( 189 | Info.param_id == param.id, Info.owner_id == 0, Info.source_id == admin_source.id, Info.is_deleted == False 190 | ) 191 | .one() 192 | ) 193 | dbsession.delete(info_new) 194 | dbsession.commit() 195 | 196 | 197 | @pytest.mark.authenticated("test.cat_update.first", "userdata.info.admin", user_id=1) 198 | def test_update_no_read_scope(dbsession, client, param, admin_source): 199 | param = param() 200 | param.type = "all" 201 | info1 = Info(value="admin_info", source_id=admin_source.id, param_id=param.id, owner_id=0) 202 | param.category.update_scope = "test.cat_update.first" 203 | param.category.read_scope = "test.cat_read.first" 204 | dbsession.add(info1) 205 | dbsession.commit() 206 | response_upd = client.post( 207 | f"/user/0", 208 | json={"source": "admin", "items": [{"category": param.category.name, "param": param.name, "value": "new"}]}, 209 | ) 210 | dbsession.expire_all() 211 | response_get = client.get("/user/0") 212 | assert response_upd.status_code == 200 213 | assert response_get.status_code == 200 214 | assert response_upd.json() == {'status': 'Success', 'message': 'User patch succeeded', 'ru': 'Изменение успешно'} 215 | assert response_get.json() == {"items": []} 216 | assert info1.value == "new" 217 | dbsession.delete(info1) 218 | dbsession.commit() 219 | 220 | 221 | @pytest.mark.authenticated(user_id=0) 222 | def test_update_from_user_source(dbsession, client, param, source): 223 | param = param() 224 | param.type = "all" 225 | _source = source() 226 | _source.name = "user" 227 | info1 = Info(value="user_info", source_id=_source.id, param_id=param.id, owner_id=0) 228 | param.category.update_scope = "test.cat_update.first" 229 | param.category.read_scope = "test.cat_read.first" 230 | dbsession.add(info1) 231 | dbsession.commit() 232 | response_old = client.get("/user/0") 233 | assert {"category": param.category.name, "param": param.name, "value": "user_info"} in list( 234 | response_old.json()["items"] 235 | ) 236 | assert len(response_old.json()["items"]) == 1 237 | response_upd = client.post( 238 | f"/user/0", 239 | json={ 240 | "source": "user", 241 | "items": [{"category": param.category.name, "param": param.name, "value": "new_user_info"}], 242 | }, 243 | ) 244 | dbsession.expire_all() 245 | response_get = client.get("/user/0") 246 | assert response_upd.status_code == 200 247 | assert response_upd.json() == {'status': 'Success', 'message': 'User patch succeeded', 'ru': 'Изменение успешно'} 248 | assert response_get.status_code == 200 249 | assert {"category": param.category.name, "param": param.name, "value": "new_user_info"} in list( 250 | response_get.json()["items"] 251 | ) 252 | assert len(response_get.json()["items"]) == 1 253 | assert info1.value == "new_user_info" 254 | dbsession.delete(info1) 255 | dbsession.commit() 256 | 257 | 258 | @pytest.mark.authenticated(user_id=0) 259 | def test_update_from_user_source_not_changeable(dbsession, client, param, source): 260 | param = param() 261 | param.type = "all" 262 | param.changeable = False 263 | _source = source() 264 | _source.name = "user" 265 | info1 = Info(value="user_info", source_id=_source.id, param_id=param.id, owner_id=0) 266 | param.category.update_scope = "test.cat_update.first" 267 | param.category.read_scope = "test.cat_read.first" 268 | dbsession.add(info1) 269 | dbsession.commit() 270 | response_old = client.get("/user/0") 271 | assert {"category": param.category.name, "param": param.name, "value": "user_info"} in list( 272 | response_old.json()["items"] 273 | ) 274 | assert len(response_old.json()["items"]) == 1 275 | response_upd = client.post( 276 | f"/user/0", 277 | json={ 278 | "source": "user", 279 | "items": [{"category": param.category.name, "param": param.name, "value": "new_user_info"}], 280 | }, 281 | ) 282 | dbsession.expire_all() 283 | response_get = client.get("/user/0") 284 | assert response_upd.status_code == 403 285 | assert response_get.status_code == 200 286 | assert {"category": param.category.name, "param": param.name, "value": "user_info"} in list( 287 | response_old.json()["items"] 288 | ) 289 | assert len(response_old.json()["items"]) == 1 290 | assert info1.value == "user_info" 291 | dbsession.delete(info1) 292 | dbsession.commit() 293 | 294 | 295 | @pytest.mark.authenticated(user_id=0) 296 | def test_create_new_with_validation(dbsession, client, param, source): 297 | param = param() 298 | source = source() 299 | source.name = "user" 300 | param.type = "all" 301 | param.validation = "^validation_[1-3]{3}$" 302 | dbsession.commit() 303 | response_upd = client.post( 304 | f"/user/0", 305 | json={ 306 | "source": source.name, 307 | "items": [{"category": param.category.name, "param": param.name, "value": "validation_123"}], 308 | }, 309 | ) 310 | dbsession.expire_all() 311 | assert response_upd.status_code == 200 312 | response_get = client.get("/user/0") 313 | assert response_upd.json() == {'status': 'Success', 'message': 'User patch succeeded', 'ru': 'Изменение успешно'} 314 | assert {"category": param.category.name, "param": param.name, "value": "validation_123"} in list( 315 | response_get.json()["items"] 316 | ) 317 | assert len(response_get.json()["items"]) == 1 318 | info_new = ( 319 | dbsession.query(Info) 320 | .filter(Info.param_id == param.id, Info.owner_id == 0, Info.source_id == source.id, Info.is_deleted == False) 321 | .one() 322 | ) 323 | dbsession.delete(info_new) 324 | dbsession.commit() 325 | 326 | 327 | @pytest.mark.authenticated(user_id=0) 328 | def test_update_with_validation(dbsession, client, param, source): 329 | param = param() 330 | source = source() 331 | source.name = "user" 332 | param.type = "all" 333 | param.validation = "^validation_[1-3]{3}$" 334 | info1 = Info(value="validation_111", source_id=source.id, param_id=param.id, owner_id=0) 335 | dbsession.add(info1) 336 | dbsession.commit() 337 | response_upd = client.post( 338 | f"/user/0", 339 | json={ 340 | "source": source.name, 341 | "items": [{"category": param.category.name, "param": param.name, "value": "validation_222"}], 342 | }, 343 | ) 344 | dbsession.expire_all() 345 | assert response_upd.status_code == 200 346 | response_get = client.get("/user/0") 347 | assert response_upd.json() == {'status': 'Success', 'message': 'User patch succeeded', 'ru': 'Изменение успешно'} 348 | assert {"category": param.category.name, "param": param.name, "value": "validation_222"} in list( 349 | response_get.json()["items"] 350 | ) 351 | assert len(response_get.json()["items"]) == 1 352 | info_new = ( 353 | dbsession.query(Info) 354 | .filter(Info.param_id == param.id, Info.owner_id == 0, Info.source_id == source.id, Info.is_deleted == False) 355 | .one() 356 | ) 357 | dbsession.delete(info_new) 358 | dbsession.commit() 359 | 360 | 361 | @pytest.mark.authenticated(user_id=0) 362 | def test_create_new_with_failing_validation(dbsession, client, param, source): 363 | param = param() 364 | source = source() 365 | source.name = "user" 366 | param.type = "all" 367 | param.validation = "^validation_[1-3]{3}$" 368 | dbsession.commit() 369 | response_upd = client.post( 370 | f"/user/0", 371 | json={ 372 | "source": source.name, 373 | "items": [{"category": param.category.name, "param": param.name, "value": "validation_000"}], 374 | }, 375 | ) 376 | dbsession.expire_all() 377 | assert response_upd.status_code == 422 378 | response_get = client.get("/user/0") 379 | assert response_get.status_code == 404 380 | 381 | 382 | @pytest.mark.authenticated(user_id=0) 383 | def test_update_with_failing_validation(dbsession, client, param, source): 384 | param = param() 385 | source = source() 386 | source.name = "user" 387 | param.type = "all" 388 | param.validation = "^validation_[1-3]{3}$" 389 | info = Info(value="validation_111", source_id=source.id, param_id=param.id, owner_id=0) 390 | dbsession.add(info) 391 | dbsession.commit() 392 | response_upd = client.post( 393 | f"/user/0", 394 | json={ 395 | "source": source.name, 396 | "items": [{"category": param.category.name, "param": param.name, "value": "validation_000"}], 397 | }, 398 | ) 399 | dbsession.expire_all() 400 | assert response_upd.status_code == 422 401 | response_get = client.get("/user/0") 402 | assert {"category": param.category.name, "param": param.name, "value": "validation_111"} in list( 403 | response_get.json()["items"] 404 | ) 405 | assert len(response_get.json()["items"]) == 1 406 | dbsession.delete(info) 407 | dbsession.commit() 408 | --------------------------------------------------------------------------------