From 04e4b8ea7d436891dacd21afb6c3460436232b4b Mon Sep 17 00:00:00 2001 From: AlexandrKudrya Date: Mon, 28 Jul 2025 18:30:44 +0300 Subject: [PATCH 1/5] Pydantic Schemas --- pysatl_knowledge/schemas/auth.py | 11 +++++++++ pysatl_knowledge/schemas/critical_value.py | 28 ++++++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 pysatl_knowledge/schemas/auth.py create mode 100644 pysatl_knowledge/schemas/critical_value.py diff --git a/pysatl_knowledge/schemas/auth.py b/pysatl_knowledge/schemas/auth.py new file mode 100644 index 0000000..bb6d743 --- /dev/null +++ b/pysatl_knowledge/schemas/auth.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel + + +class LoginRequest(BaseModel): + username: str + password: str + + +class LoginResponse(BaseModel): + access_token: str + token_type: str = "bearer" diff --git a/pysatl_knowledge/schemas/critical_value.py b/pysatl_knowledge/schemas/critical_value.py new file mode 100644 index 0000000..bbee474 --- /dev/null +++ b/pysatl_knowledge/schemas/critical_value.py @@ -0,0 +1,28 @@ +from typing import Optional + +from pydantic import BaseModel, confloat, conint, constr + + +class CriticalValueBase(BaseModel): + code: type[str] = constr(min_length=1) + size: type[int] = conint(gt=0) + sl: type[float] = confloat(ge=0.0, le=1.0) + lower_value: float + upper_value: Optional[float] = None + + +class CriticalValueCreate(CriticalValueBase): + pass + + +class CriticalValueResponse(CriticalValueBase): + id: int + status: str + created_by: Optional[int] = None + + class Config: + from_attributes = True + + +class CriticalValueVerify(BaseModel): + status: str From 145b2627704b07c4d6d4459339588e9f39ee92e1 Mon Sep 17 00:00:00 2001 From: AlexandrKudrya Date: Mon, 28 Jul 2025 18:31:02 +0300 Subject: [PATCH 2/5] Services --- pysatl_knowledge/services/auth_service.py | 17 +++++ .../services/critical_value_service.py | 64 +++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 pysatl_knowledge/services/auth_service.py create mode 100644 pysatl_knowledge/services/critical_value_service.py diff --git a/pysatl_knowledge/services/auth_service.py b/pysatl_knowledge/services/auth_service.py new file mode 100644 index 0000000..1443894 --- /dev/null +++ b/pysatl_knowledge/services/auth_service.py @@ -0,0 +1,17 @@ +# app/services/auth_service.py +from __future__ import annotations + +from datetime import timedelta + +from pysatl_knowledge.core.moke_db import MOCK_USERS +from pysatl_knowledge.core.security import create_access_token +from pysatl_knowledge.schemas.auth import LoginResponse + + +class AuthService: + async def login(self, username: str, password: str) -> LoginResponse | None: + user = MOCK_USERS.get(username) + if not user or user["password"] != password: + return None + token = create_access_token({"sub": username}, expires_delta=timedelta(minutes=30)) + return LoginResponse(access_token=token) diff --git a/pysatl_knowledge/services/critical_value_service.py b/pysatl_knowledge/services/critical_value_service.py new file mode 100644 index 0000000..8984fc7 --- /dev/null +++ b/pysatl_knowledge/services/critical_value_service.py @@ -0,0 +1,64 @@ +from typing import Optional + +from pysatl_knowledge.schemas.critical_value import CriticalValueCreate, CriticalValueResponse + + +class CriticalValuesService: + def __init__(self): + # В будущем: сюда можно передать session или репозиторий + pass + + async def get_cv_by_params( + self, + code: str, + size: int, + sl: float, + ) -> CriticalValueResponse: + # TODO + + return CriticalValueResponse( + id=1, + code=code, + size=size, + sl=sl, + lower_value=-1.96, + upper_value=1.96, + status="created", + created_by=1, + ) + + async def create_cv(self, data: CriticalValueCreate, created_by: int) -> CriticalValueResponse: + """ + Создает новый эксперимент (заглушка) + """ + + # TODO + return CriticalValueResponse( + id=1, + code=data.code, + size=data.size, + sl=data.sl, + lower_value=data.lower_value, + upper_value=data.upper_value, + status="created", + created_by=created_by, + ) + + async def verify_cv(self, experiment_id: int, status: str) -> Optional[CriticalValueResponse]: + """ + Верифицирует или отклоняет эксперимент (заглушка) + """ + + # TODO + if experiment_id != 42: + return None + return CriticalValueResponse( + id=1, + code="code", + size=42, + sl=0.1, + lower_value=-1.96, + upper_value=1.96, + status=status, + created_by=1, + ) From 1c589d5a7a774ca3d6671084bda086ff72c19154 Mon Sep 17 00:00:00 2001 From: AlexandrKudrya Date: Mon, 28 Jul 2025 18:31:46 +0300 Subject: [PATCH 3/5] Api without DB --- pyproject.toml | 5 +- pysatl_knowledge/api/auth.py | 24 ++++++++-- pysatl_knowledge/api/critical_value.py | 58 +++++++++++++++++++++++ pysatl_knowledge/core/config.py | 11 +++-- pysatl_knowledge/core/logging_config.py | 22 +++++++++ pysatl_knowledge/core/moke_db.py | 7 +++ pysatl_knowledge/core/security.py | 46 ++++++++++++++++++ pysatl_knowledge/main.py | 16 ++++--- pysatl_knowledge/services/dependencies.py | 10 ++++ 9 files changed, 184 insertions(+), 15 deletions(-) create mode 100644 pysatl_knowledge/api/critical_value.py create mode 100644 pysatl_knowledge/core/logging_config.py create mode 100644 pysatl_knowledge/core/moke_db.py create mode 100644 pysatl_knowledge/core/security.py create mode 100644 pysatl_knowledge/services/dependencies.py diff --git a/pyproject.toml b/pyproject.toml index 338d05c..f44817b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,9 @@ dependencies = [ "pydantic>=2.7.0", "python-dotenv>=1.0.1", "loguru>=0.7.2", - "httpx>=0.27.0" + "httpx>=0.27.0", + "python-multipart (>=0.0.20,<0.0.21)", + "pydantic-settings (>=2.10.1,<3.0.0)" ] [build-system] @@ -61,6 +63,7 @@ lines_after_imports=2 skip_glob = ["**/.env*", "**/env/*", "**/.venv/*", "**/docs/*", "**/user_data/*"] [tool.ruff] +ignore = ["B008"] line-length = 100 extend-exclude = [".env", ".venv"] exclude = [ diff --git a/pysatl_knowledge/api/auth.py b/pysatl_knowledge/api/auth.py index 3e6d795..954db79 100644 --- a/pysatl_knowledge/api/auth.py +++ b/pysatl_knowledge/api/auth.py @@ -1,9 +1,23 @@ -from fastapi import APIRouter +from fastapi import APIRouter, Depends, HTTPException +from fastapi.security import OAuth2PasswordRequestForm +from pysatl_knowledge.schemas.auth import LoginResponse +from pysatl_knowledge.services.auth_service import AuthService +from pysatl_knowledge.services.dependencies import get_auth_service -router = APIRouter(prefix="/hello", tags=["Hello"]) +router = APIRouter(prefix="/auth", tags=["Authentication"]) -@router.get("") -async def say_hello(): - return {"message": "Hello World!"} + +@router.post("/login", response_model=LoginResponse) +async def login( + form_data: OAuth2PasswordRequestForm = Depends(), + service: AuthService = Depends(get_auth_service), +): + """ + Получить JWT токен. + """ + token = await service.login(form_data.username, form_data.password) + if not token: + raise HTTPException(status_code=401, detail="Invalid credentials") + return token diff --git a/pysatl_knowledge/api/critical_value.py b/pysatl_knowledge/api/critical_value.py new file mode 100644 index 0000000..27f1f24 --- /dev/null +++ b/pysatl_knowledge/api/critical_value.py @@ -0,0 +1,58 @@ +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Path, Query + +from pysatl_knowledge.core.security import get_current_admin, get_current_user +from pysatl_knowledge.schemas.critical_value import ( + CriticalValueCreate, + CriticalValueResponse, + CriticalValueVerify, +) +from pysatl_knowledge.services.dependencies import get_cv_service + + +router = APIRouter(prefix="/critical_values", tags=["Critical Values"]) + + +@router.get("", response_model=CriticalValueResponse) +async def get_critical_values( + code: Optional[str] = Query(None), + size: Optional[int] = Query(None), + sl: Optional[float] = Query(None), + service: CriticalValueCreate = Depends(get_cv_service), +): + """ + Получить список критических значений с фильтрацией по code/size/sl. + """ + return await service.get_cv_by_params(code=code, size=size, sl=sl) + + +@router.post("", response_model=CriticalValueResponse) +async def create_critical_value( + data: CriticalValueCreate, + service: CriticalValueCreate = Depends(get_cv_service), + current_user=Depends(get_current_user), +): + """ + Создать новое критическое значение. + """ + try: + return await service.create_cv(data, created_by=current_user.id) + except ValueError as e: + raise HTTPException(status_code=422, detail=str(e)) + + +@router.patch("/{critical_value_id}/verify", response_model=CriticalValueResponse) +async def verify_critical_value( + data: CriticalValueVerify, + critical_value_id: int = Path(..., ge=1), + service: CriticalValueCreate = Depends(get_cv_service), + current_admin=Depends(get_current_admin), +): + """ + Верифицировать или отклонить критическое значение (только для admin). + """ + result = await service.verify_cv(critical_value_id, data.status) + if not result: + raise HTTPException(status_code=404, detail="Critical value not found") + return result diff --git a/pysatl_knowledge/core/config.py b/pysatl_knowledge/core/config.py index d9549ca..6c8368c 100644 --- a/pysatl_knowledge/core/config.py +++ b/pysatl_knowledge/core/config.py @@ -1,13 +1,20 @@ -from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic_settings import BaseSettings class Settings(BaseSettings): + app_name: str = "test" + secret_key: str = "supersecretkey" + access_token_expire_minutes: int = 30 + log_level: str = "INFO" + database_url: str = "mock://user:pass@localhost:5432/dbname" DB_HOST: str DB_PORT: int DB_NAME: str DB_USER: str DB_PASS: str + class Config: + env_file = ".env" @property def DATABASE_URL(self) -> str: return ( @@ -19,7 +26,5 @@ def DATABASE_URL(self) -> str: f"{self.DB_NAME}" ) - model_config = SettingsConfigDict(env_file=".env") - settings = Settings() diff --git a/pysatl_knowledge/core/logging_config.py b/pysatl_knowledge/core/logging_config.py new file mode 100644 index 0000000..7f36388 --- /dev/null +++ b/pysatl_knowledge/core/logging_config.py @@ -0,0 +1,22 @@ +import os +import sys + +from loguru import logger + + +LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO") + +# Убираем стандартные хендлеры +logger.remove() + +logger.add( + sys.stdout, + colorize=False, + format="{level: <8}: {message}", + level=LOG_LEVEL, + enqueue=True, +) + + +def get_logger(): + return logger diff --git a/pysatl_knowledge/core/moke_db.py b/pysatl_knowledge/core/moke_db.py new file mode 100644 index 0000000..622ffbd --- /dev/null +++ b/pysatl_knowledge/core/moke_db.py @@ -0,0 +1,7 @@ +# app/core/mock_db.py + +# ключ: username, значение: словарь с данными пользователя +MOCK_USERS = { + "admin": {"id": 1, "username": "admin", "password": "admin", "role": "admin"}, + "user": {"id": 2, "username": "user", "password": "user", "role": "user"}, +} diff --git a/pysatl_knowledge/core/security.py b/pysatl_knowledge/core/security.py new file mode 100644 index 0000000..61b179e --- /dev/null +++ b/pysatl_knowledge/core/security.py @@ -0,0 +1,46 @@ +# app/core/security.py +from datetime import datetime, timedelta +from typing import Optional + +import jwt +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer + +from pysatl_knowledge.core.config import settings +from pysatl_knowledge.core.moke_db import MOCK_USERS + + +SECRET_KEY = settings.secret_key +ACCESS_TOKEN_EXPIRE_MINUTES = settings.access_token_expire_minutes +ALGORITHM = "HS256" + + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login") + + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): + to_encode = data.copy() + expire = datetime.utcnow() + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)) + to_encode.update({"exp": expire}) + return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + + +async def get_current_user(token: str = Depends(oauth2_scheme)): + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + username: str = payload.get("sub") + if username is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") + except jwt.PyJWTError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") + + user = MOCK_USERS.get(username) + if user is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found") + return user + + +async def get_current_admin(user: dict = Depends(get_current_user)): + if user["role"] != "admin": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not enough privileges") + return user diff --git a/pysatl_knowledge/main.py b/pysatl_knowledge/main.py index 2645d1d..074be3c 100644 --- a/pysatl_knowledge/main.py +++ b/pysatl_knowledge/main.py @@ -1,13 +1,17 @@ +from contextlib import asynccontextmanager + from fastapi import FastAPI -from pysatl_knowledge.api import auth +from pysatl_knowledge.api import auth, critical_value +from pysatl_knowledge.core.config import settings -app = FastAPI(title="PySATL-Knowledge") +@asynccontextmanager +async def lifespan(app: FastAPI): + yield -app.include_router(auth.router) +app = FastAPI(title=settings.app_name, lifespan=lifespan) -@app.get("/") -async def root(): - return {"message": "Root endpoint working!"} +app.include_router(auth.router) +app.include_router(critical_value.router) diff --git a/pysatl_knowledge/services/dependencies.py b/pysatl_knowledge/services/dependencies.py new file mode 100644 index 0000000..627525e --- /dev/null +++ b/pysatl_knowledge/services/dependencies.py @@ -0,0 +1,10 @@ +from pysatl_knowledge.services.auth_service import AuthService +from pysatl_knowledge.services.critical_value_service import CriticalValuesService + + +def get_cv_service() -> CriticalValuesService: + return CriticalValuesService() + + +def get_auth_service() -> AuthService: + return AuthService() From 8842f2a6a7364b8235aae5c53083798de9ddf2ab Mon Sep 17 00:00:00 2001 From: AlexandrKudrya Date: Wed, 30 Jul 2025 13:54:04 +0300 Subject: [PATCH 4/5] Utils: minor CI fixes and Docker DB --- .env.example | 5 +++++ .github/workflows/ci.yaml | 14 ++++++-------- .pre-commit-config.yaml | 1 + docker-compose.yml | 21 +++++++++++++++++++++ pyproject.toml | 2 +- pysatl_knowledge/core/config.py | 3 ++- pysatl_knowledge/core/moke_db.py | 7 ------- 7 files changed, 36 insertions(+), 17 deletions(-) create mode 100644 docker-compose.yml delete mode 100644 pysatl_knowledge/core/moke_db.py diff --git a/.env.example b/.env.example index 4b31b67..001ecc9 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,8 @@ +APP_NAME='Critical Value API v1' +SECRET_KEY=supersecretkey +ACCESS_TOKEN_EXPIRE_MINUTES=30 +LOG_LEVEL=INFO + DB_HOST=localhost DB_PORT=5432 DB_NAME=db_name diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 483fa2a..895c899 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -10,7 +10,6 @@ on: jobs: build-linux: - runs-on: ubuntu-latest strategy: matrix: @@ -34,15 +33,15 @@ jobs: - name: Install dependencies with Poetry run: poetry install --with dev - - name: Run Ruff lint - run: poetry run ruff check --output-format=github + - name: Run Ruff lint (ignore migrations) + run: poetry run ruff check pysatl_knowledge --extend-exclude "migrations" --output-format=github - - name: Check formatting with Ruff - run: poetry run ruff format --check + - name: Check formatting with Ruff (ignore migrations) + run: poetry run ruff format pysatl_knowledge --extend-exclude "migrations" --check - - name: Mypy + - name: Mypy (ignore migrations) run: | - poetry run mypy pysatl_knowledge tests + poetry run mypy pysatl_knowledge - name: Run tests run: poetry run pytest --random-order @@ -57,7 +56,6 @@ jobs: env: COVERALLS_REPO_TOKEN: ${{ secrets.coverallsToken }} run: | - # Allow failure for coveralls poetry run coveralls || true - name: Remove poetry.lock before checking for changes diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c135076..92495e6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,6 +6,7 @@ repos: hooks: - id: flake8 additional_dependencies: [Flake8-pyproject] + exclude: migrations # stages: [push] - repo: https://github.com/pre-commit/mirrors-mypy diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1ee25f4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +version: "3.9" + +services: + db: + image: postgres:15 + container_name: knowledge-db + ports: + - "${DB_PORT}:5432" + environment: + POSTGRES_USER: ${DB_USER} + POSTGRES_PASSWORD: ${DB_PASS} + POSTGRES_DB: ${DB_NAME} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"] + interval: 5s + retries: 5 + +volumes: + postgres_data: diff --git a/pyproject.toml b/pyproject.toml index f44817b..8aefca7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,7 +75,7 @@ extend-select = [ "C90", "B", "F", "E", "W", "UP", "I", "A", "TID", "YTT", "S", "PTH", "ASYNC", "NPY" ] extend-ignore = [ - "E241", "E272", "E221", "B007", "B904", "S603", "S607", "S608", "NPY002" + "E241", "E272", "E221", "B007", "B904", "S603", "S607", "S608", "NPY002", "UP007" ] [tool.mypy] diff --git a/pysatl_knowledge/core/config.py b/pysatl_knowledge/core/config.py index 6c8368c..f57f0dd 100644 --- a/pysatl_knowledge/core/config.py +++ b/pysatl_knowledge/core/config.py @@ -6,7 +6,6 @@ class Settings(BaseSettings): secret_key: str = "supersecretkey" access_token_expire_minutes: int = 30 log_level: str = "INFO" - database_url: str = "mock://user:pass@localhost:5432/dbname" DB_HOST: str DB_PORT: int DB_NAME: str @@ -15,6 +14,8 @@ class Settings(BaseSettings): class Config: env_file = ".env" + extra = "ignore" + @property def DATABASE_URL(self) -> str: return ( diff --git a/pysatl_knowledge/core/moke_db.py b/pysatl_knowledge/core/moke_db.py deleted file mode 100644 index 622ffbd..0000000 --- a/pysatl_knowledge/core/moke_db.py +++ /dev/null @@ -1,7 +0,0 @@ -# app/core/mock_db.py - -# ключ: username, значение: словарь с данными пользователя -MOCK_USERS = { - "admin": {"id": 1, "username": "admin", "password": "admin", "role": "admin"}, - "user": {"id": 2, "username": "user", "password": "user", "role": "user"}, -} From 751d9d9068732f02280696fd20a1fe0a09c1152b Mon Sep 17 00:00:00 2001 From: AlexandrKudrya Date: Wed, 30 Jul 2025 13:54:46 +0300 Subject: [PATCH 5/5] No Notification MVP --- .github/workflows/ci.yaml | 16 +-- ...4c3_create_users_and_experiments_tables.py | 4 +- poetry.lock | 57 +++++++--- pysatl_knowledge/api/auth.py | 2 +- pysatl_knowledge/api/critical_value.py | 50 ++++++--- pysatl_knowledge/core/config.py | 10 +- pysatl_knowledge/core/security/__init__.py | 0 .../{security.py => security/jwt_utils.py} | 20 +++- pysatl_knowledge/core/security/pass_utils.py | 12 ++ pysatl_knowledge/models/__init__.py | 2 +- ...{experiment.py => critical_value_model.py} | 2 +- pysatl_knowledge/repositories/__init__.py | 2 +- ...sitory.py => critical_value_repository.py} | 57 ++++++---- .../schemas/{auth.py => auth_schema.py} | 0 pysatl_knowledge/schemas/critical_value.py | 28 ----- .../schemas/critical_value_schema.py | 24 ++++ pysatl_knowledge/services/auth_service.py | 19 +++- .../services/critical_value_service.py | 104 ++++++++++-------- 18 files changed, 254 insertions(+), 155 deletions(-) create mode 100644 pysatl_knowledge/core/security/__init__.py rename pysatl_knowledge/core/{security.py => security/jwt_utils.py} (74%) create mode 100644 pysatl_knowledge/core/security/pass_utils.py rename pysatl_knowledge/models/{experiment.py => critical_value_model.py} (95%) rename pysatl_knowledge/repositories/{experiment_repository.py => critical_value_repository.py} (64%) rename pysatl_knowledge/schemas/{auth.py => auth_schema.py} (100%) delete mode 100644 pysatl_knowledge/schemas/critical_value.py create mode 100644 pysatl_knowledge/schemas/critical_value_schema.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 895c899..7609ac3 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,5 +1,3 @@ -name: PySATL CI - on: push: branches: @@ -10,6 +8,7 @@ on: jobs: build-linux: + runs-on: ubuntu-latest strategy: matrix: @@ -33,15 +32,15 @@ jobs: - name: Install dependencies with Poetry run: poetry install --with dev - - name: Run Ruff lint (ignore migrations) - run: poetry run ruff check pysatl_knowledge --extend-exclude "migrations" --output-format=github + - name: Run Ruff lint + run: poetry run ruff check --output-format=github --extend-exclude "migrations" - - name: Check formatting with Ruff (ignore migrations) - run: poetry run ruff format pysatl_knowledge --extend-exclude "migrations" --check + - name: Check formatting with Ruff + run: poetry run ruff format pysatl_knowledge --check - - name: Mypy (ignore migrations) + - name: Mypy run: | - poetry run mypy pysatl_knowledge + poetry run mypy pysatl_knowledge tests - name: Run tests run: poetry run pytest --random-order @@ -56,6 +55,7 @@ jobs: env: COVERALLS_REPO_TOKEN: ${{ secrets.coverallsToken }} run: | + # Allow failure for coveralls poetry run coveralls || true - name: Remove poetry.lock before checking for changes diff --git a/migrations/versions/9a61883b84c3_create_users_and_experiments_tables.py b/migrations/versions/9a61883b84c3_create_users_and_experiments_tables.py index 1d91969..1d0643b 100644 --- a/migrations/versions/9a61883b84c3_create_users_and_experiments_tables.py +++ b/migrations/versions/9a61883b84c3_create_users_and_experiments_tables.py @@ -1,14 +1,14 @@ """Create users and experiments tables Revision ID: 9a61883b84c3 -Revises: +Revises: Create Date: 2025-07-28 20:58:42.261092 """ from typing import Sequence, Union -from alembic import op import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. diff --git a/poetry.lock b/poetry.lock index df298ab..837e08a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -78,7 +78,7 @@ description = "Timeout context manager for asyncio programs" optional = false python-versions = ">=3.8" groups = ["main"] -markers = "python_version < \"3.11.0\"" +markers = "python_version == \"3.10\"" files = [ {file = "async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c"}, {file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"}, @@ -173,7 +173,7 @@ description = "Backport of asyncio.Runner, a context manager that controls event optional = false python-versions = "<3.11,>=3.8" groups = ["dev"] -markers = "python_version < \"3.11\"" +markers = "python_version == \"3.10\"" files = [ {file = "backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5"}, {file = "backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162"}, @@ -673,7 +673,7 @@ description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" groups = ["main", "dev"] -markers = "python_version < \"3.11\"" +markers = "python_version == \"3.10\"" files = [ {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, @@ -992,9 +992,6 @@ files = [ {file = "importlib_resources-6.5.2.tar.gz", hash = "sha256:185f87adef5bcc288449d98fb4fba07cea78bc036455dd44c5fc4a2fe78fed2c"}, ] -[package.dependencies] -zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""} - [package.extras] check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] cover = ["pytest-cov"] @@ -1135,9 +1132,6 @@ files = [ {file = "markdown-3.7.tar.gz", hash = "sha256:2ae2471477cfd02dbbf038d5d9bc226d40def84b4fe2986e49b59b6b472bbed2"}, ] -[package.dependencies] -importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} - [package.extras] docs = ["mdx-gh-links (>=0.2)", "mkdocs (>=1.5)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"] testing = ["coverage", "pyyaml"] @@ -1282,7 +1276,6 @@ files = [ click = ">=7.0" colorama = {version = ">=0.4", markers = "platform_system == \"Windows\""} ghp-import = ">=1.0" -importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} jinja2 = ">=2.11.1" markdown = ">=3.3.6" markupsafe = ">=2.0.1" @@ -1311,7 +1304,6 @@ files = [ ] [package.dependencies] -importlib-metadata = {version = ">=4.3", markers = "python_version < \"3.10\""} mergedeep = ">=1.3.4" platformdirs = ">=2.2.0" pyyaml = ">=5.1" @@ -1698,6 +1690,30 @@ files = [ [package.dependencies] typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" +[[package]] +name = "pydantic-settings" +version = "2.10.1" +description = "Settings management using Pydantic" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796"}, + {file = "pydantic_settings-2.10.1.tar.gz", hash = "sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee"}, +] + +[package.dependencies] +pydantic = ">=2.7.0" +python-dotenv = ">=0.21.0" +typing-inspection = ">=0.4.0" + +[package.extras] +aws-secrets-manager = ["boto3 (>=1.35.0)", "boto3-stubs[secretsmanager]"] +azure-key-vault = ["azure-identity (>=1.16.0)", "azure-keyvault-secrets (>=4.8.0)"] +gcp-secret-manager = ["google-cloud-secret-manager (>=2.23.1)"] +toml = ["tomli (>=2.0.1)"] +yaml = ["pyyaml (>=6.0.1)"] + [[package]] name = "pygments" version = "2.19.2" @@ -1803,7 +1819,6 @@ files = [ [package.dependencies] backports-asyncio-runner = {version = ">=1.1,<2", markers = "python_version < \"3.11\""} pytest = ">=8.2,<9" -typing-extensions = {version = ">=4.12", markers = "python_version < \"3.10\""} [package.extras] docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] @@ -1891,6 +1906,18 @@ files = [ [package.extras] cli = ["click (>=5.0)"] +[[package]] +name = "python-multipart" +version = "0.0.20" +description = "A streaming multipart parser for Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104"}, + {file = "python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13"}, +] + [[package]] name = "pyyaml" version = "6.0.2" @@ -2303,7 +2330,7 @@ files = [ {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, ] -markers = {main = "python_version < \"3.11\"", dev = "python_full_version <= \"3.11.0a6\""} +markers = {main = "python_version == \"3.10\"", dev = "python_full_version <= \"3.11.0a6\""} [[package]] name = "typing-extensions" @@ -2781,5 +2808,5 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" -python-versions = ">=3.9,<3.13" -content-hash = "b835f19f384254e378aada762dd260f13c8d654bd5243099b3c1bc655c8a8ed5" +python-versions = ">=3.10,<3.13" +content-hash = "1f560dd39eb573d941cdb5e19bf63ceb4644307c532281000ab5b460ada28394" diff --git a/pysatl_knowledge/api/auth.py b/pysatl_knowledge/api/auth.py index 954db79..8140627 100644 --- a/pysatl_knowledge/api/auth.py +++ b/pysatl_knowledge/api/auth.py @@ -1,7 +1,7 @@ from fastapi import APIRouter, Depends, HTTPException from fastapi.security import OAuth2PasswordRequestForm -from pysatl_knowledge.schemas.auth import LoginResponse +from pysatl_knowledge.schemas.auth_schema import LoginResponse from pysatl_knowledge.services.auth_service import AuthService from pysatl_knowledge.services.dependencies import get_auth_service diff --git a/pysatl_knowledge/api/critical_value.py b/pysatl_knowledge/api/critical_value.py index 27f1f24..9fde404 100644 --- a/pysatl_knowledge/api/critical_value.py +++ b/pysatl_knowledge/api/critical_value.py @@ -1,13 +1,14 @@ from typing import Optional -from fastapi import APIRouter, Depends, HTTPException, Path, Query +from fastapi import APIRouter, Depends, HTTPException, Path -from pysatl_knowledge.core.security import get_current_admin, get_current_user -from pysatl_knowledge.schemas.critical_value import ( +from pysatl_knowledge.core.security.jwt_utils import get_current_admin, get_current_user +from pysatl_knowledge.schemas.critical_value_schema import ( CriticalValueCreate, CriticalValueResponse, CriticalValueVerify, ) +from pysatl_knowledge.services.critical_value_service import CriticalValuesService from pysatl_knowledge.services.dependencies import get_cv_service @@ -16,28 +17,47 @@ @router.get("", response_model=CriticalValueResponse) async def get_critical_values( - code: Optional[str] = Query(None), - size: Optional[int] = Query(None), - sl: Optional[float] = Query(None), + criterion_code: str, + size: int, + iterations: int, + service: CriticalValuesService = Depends(get_cv_service), +): + return ( + await service.get_cv_by_params( + criterion_code=criterion_code, + sample_size=size, + iterations=iterations, + status="verified", + ) + )[0] + + +@router.get("/all", response_model=list[CriticalValueResponse]) +async def get_all_critical_values( + criterion_code: Optional[str] = None, + size: Optional[int] = None, + iterations: Optional[int] = None, + status: Optional[str] = None, + current_user=Depends(get_current_user), service: CriticalValueCreate = Depends(get_cv_service), ): - """ - Получить список критических значений с фильтрацией по code/size/sl. - """ - return await service.get_cv_by_params(code=code, size=size, sl=sl) + return await service.get_cv_by_params( + criterion_code=criterion_code, + sample_size=size, + iterations=iterations, + status=status, + ) @router.post("", response_model=CriticalValueResponse) async def create_critical_value( - data: CriticalValueCreate, - service: CriticalValueCreate = Depends(get_cv_service), - current_user=Depends(get_current_user), + data: CriticalValueCreate, service: CriticalValuesService = Depends(get_cv_service) ): """ Создать новое критическое значение. """ try: - return await service.create_cv(data, created_by=current_user.id) + return await service.create_cv(data) except ValueError as e: raise HTTPException(status_code=422, detail=str(e)) @@ -46,7 +66,7 @@ async def create_critical_value( async def verify_critical_value( data: CriticalValueVerify, critical_value_id: int = Path(..., ge=1), - service: CriticalValueCreate = Depends(get_cv_service), + service: CriticalValuesService = Depends(get_cv_service), current_admin=Depends(get_current_admin), ): """ diff --git a/pysatl_knowledge/core/config.py b/pysatl_knowledge/core/config.py index f57f0dd..9963a59 100644 --- a/pysatl_knowledge/core/config.py +++ b/pysatl_knowledge/core/config.py @@ -6,11 +6,11 @@ class Settings(BaseSettings): secret_key: str = "supersecretkey" access_token_expire_minutes: int = 30 log_level: str = "INFO" - DB_HOST: str - DB_PORT: int - DB_NAME: str - DB_USER: str - DB_PASS: str + DB_HOST: str = "localhost" + DB_PORT: int = 5432 + DB_NAME: str = "test" + DB_USER: str = "test" + DB_PASS: str = "" class Config: env_file = ".env" diff --git a/pysatl_knowledge/core/security/__init__.py b/pysatl_knowledge/core/security/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pysatl_knowledge/core/security.py b/pysatl_knowledge/core/security/jwt_utils.py similarity index 74% rename from pysatl_knowledge/core/security.py rename to pysatl_knowledge/core/security/jwt_utils.py index 61b179e..5f419cb 100644 --- a/pysatl_knowledge/core/security.py +++ b/pysatl_knowledge/core/security/jwt_utils.py @@ -1,4 +1,3 @@ -# app/core/security.py from datetime import datetime, timedelta from typing import Optional @@ -7,7 +6,8 @@ from fastapi.security import OAuth2PasswordBearer from pysatl_knowledge.core.config import settings -from pysatl_knowledge.core.moke_db import MOCK_USERS +from pysatl_knowledge.models import User +from pysatl_knowledge.repositories import UserRepository SECRET_KEY = settings.secret_key @@ -25,7 +25,14 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) -async def get_current_user(token: str = Depends(oauth2_scheme)): +def get_user_repository() -> UserRepository: + return UserRepository() + + +async def get_current_user( + token: str = Depends(oauth2_scheme), + user_repo: UserRepository = Depends(get_user_repository), +) -> User: try: payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) username: str = payload.get("sub") @@ -34,13 +41,14 @@ async def get_current_user(token: str = Depends(oauth2_scheme)): except jwt.PyJWTError: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") - user = MOCK_USERS.get(username) + user = await user_repo.find_by_username(username) if user is None: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found") + return user -async def get_current_admin(user: dict = Depends(get_current_user)): - if user["role"] != "admin": +async def get_current_admin(user: User = Depends(get_current_user)): + if user.role != "admin": raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not enough privileges") return user diff --git a/pysatl_knowledge/core/security/pass_utils.py b/pysatl_knowledge/core/security/pass_utils.py new file mode 100644 index 0000000..7ab9aa8 --- /dev/null +++ b/pysatl_knowledge/core/security/pass_utils.py @@ -0,0 +1,12 @@ +from passlib.context import CryptContext + + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password: str) -> str: + return pwd_context.hash(password) diff --git a/pysatl_knowledge/models/__init__.py b/pysatl_knowledge/models/__init__.py index 1592ffb..509de53 100644 --- a/pysatl_knowledge/models/__init__.py +++ b/pysatl_knowledge/models/__init__.py @@ -1,2 +1,2 @@ -from .experiment import Experiment # noqa: F401 +from .critical_value_model import CriticalValueModel # noqa: F401 from .user import User # noqa: F401 diff --git a/pysatl_knowledge/models/experiment.py b/pysatl_knowledge/models/critical_value_model.py similarity index 95% rename from pysatl_knowledge/models/experiment.py rename to pysatl_knowledge/models/critical_value_model.py index f205034..6eada94 100644 --- a/pysatl_knowledge/models/experiment.py +++ b/pysatl_knowledge/models/critical_value_model.py @@ -4,7 +4,7 @@ from pysatl_knowledge.models.base import Base -class Experiment(Base): +class CriticalValueModel(Base): """ORM model for experiments in the knowledge base.""" __tablename__ = "experiments" diff --git a/pysatl_knowledge/repositories/__init__.py b/pysatl_knowledge/repositories/__init__.py index 489060c..684faac 100644 --- a/pysatl_knowledge/repositories/__init__.py +++ b/pysatl_knowledge/repositories/__init__.py @@ -1,2 +1,2 @@ -from .experiment_repository import ExperimentRepository # noqa: F401 +from .critical_value_repository import CriticalValueRepository # noqa: F401 from .user_repository import UserRepository # noqa: F401 diff --git a/pysatl_knowledge/repositories/experiment_repository.py b/pysatl_knowledge/repositories/critical_value_repository.py similarity index 64% rename from pysatl_knowledge/repositories/experiment_repository.py rename to pysatl_knowledge/repositories/critical_value_repository.py index 4b160cb..b3c9be9 100644 --- a/pysatl_knowledge/repositories/experiment_repository.py +++ b/pysatl_knowledge/repositories/critical_value_repository.py @@ -1,38 +1,51 @@ +from typing import Optional + from sqlalchemy.future import select from pysatl_knowledge.core.database import async_session -from pysatl_knowledge.models import Experiment +from pysatl_knowledge.models import CriticalValueModel -class ExperimentRepository: +class CriticalValueRepository: async def find_by_params( - self, criterion_code: str, sample_size: int, iterations: int - ) -> list[Experiment]: + self, + criterion_code: Optional[str] = None, + sample_size: Optional[int] = None, + iterations: Optional[int] = None, + status: Optional[str] = None, + ) -> list[CriticalValueModel]: """ Find experiments by criterion code, sample size, and iterations. :param criterion_code: The code of the criterion to filter experiments. :param sample_size: The sample size of the experiments to filter. :param iterations: The number of iterations of the experiments to filter. + :param status: The status of the experiments to filter. :return: A list of Experiment objects that match the given parameters. """ async with async_session() as session: - result = await session.execute( - select(Experiment).where( - Experiment.criterion_code == criterion_code, - Experiment.sample_size == sample_size, - Experiment.iterations == iterations, - ) - ) + query = select(CriticalValueModel) + if criterion_code is not None: + query = query.where(CriticalValueModel.criterion_code == criterion_code) + if sample_size is not None: + query = query.where(CriticalValueModel.sample_size == sample_size) + if iterations is not None: + query = query.where(CriticalValueModel.iterations == iterations) + if status is not None: + query = query.where(CriticalValueModel.status == status) + + result = await session.execute(query) return result.scalars().all() - async def find_by_id(self, cv_id: int) -> Experiment | None: + async def find_by_id(self, cv_id: int) -> CriticalValueModel | None: """ Find an experiment by its ID. :param id: The ID of the experiment to find. :return: An Experiment object if found, otherwise None. """ async with async_session() as session: - result = await session.execute(select(Experiment).where(Experiment.id == cv_id)) + result = await session.execute( + select(CriticalValueModel).where(CriticalValueModel.id == cv_id) + ) return result.scalar_one_or_none() async def get_status( @@ -47,15 +60,15 @@ async def get_status( """ async with async_session() as session: result = await session.execute( - select(Experiment.status).where( - Experiment.criterion_code == criterion_code, - Experiment.sample_size == sample_size, - Experiment.iterations == iterations, + select(CriticalValueModel.status).where( + CriticalValueModel.criterion_code == criterion_code, + CriticalValueModel.sample_size == sample_size, + CriticalValueModel.iterations == iterations, ) ) return result.scalar_one_or_none() - async def create(self, experiment: Experiment) -> Experiment: + async def create(self, experiment: CriticalValueModel) -> CriticalValueModel: """ Create a new experiment in the database. :param experiment: The Experiment object to create. @@ -67,7 +80,7 @@ async def create(self, experiment: Experiment) -> Experiment: await session.refresh(experiment) return experiment - async def update(self, experiment: Experiment) -> Experiment: + async def update(self, experiment: CriticalValueModel) -> CriticalValueModel: """ Update an existing experiment in the database. :param experiment: The Experiment object to update. @@ -79,7 +92,9 @@ async def update(self, experiment: Experiment) -> Experiment: await session.refresh(experiment) return experiment - async def update_status(self, experiment: Experiment, new_status: str) -> Experiment: + async def update_status( + self, experiment: CriticalValueModel, new_status: str + ) -> CriticalValueModel: """ Update the status of an existing experiment. :param experiment: The Experiment object to update. @@ -89,7 +104,7 @@ async def update_status(self, experiment: Experiment, new_status: str) -> Experi experiment.status = new_status return await self.update(experiment) - async def delete(self, experiment: Experiment) -> None: + async def delete(self, experiment: CriticalValueModel) -> None: """ Delete an experiment from the database. :param experiment: The Experiment object to delete. diff --git a/pysatl_knowledge/schemas/auth.py b/pysatl_knowledge/schemas/auth_schema.py similarity index 100% rename from pysatl_knowledge/schemas/auth.py rename to pysatl_knowledge/schemas/auth_schema.py diff --git a/pysatl_knowledge/schemas/critical_value.py b/pysatl_knowledge/schemas/critical_value.py deleted file mode 100644 index bbee474..0000000 --- a/pysatl_knowledge/schemas/critical_value.py +++ /dev/null @@ -1,28 +0,0 @@ -from typing import Optional - -from pydantic import BaseModel, confloat, conint, constr - - -class CriticalValueBase(BaseModel): - code: type[str] = constr(min_length=1) - size: type[int] = conint(gt=0) - sl: type[float] = confloat(ge=0.0, le=1.0) - lower_value: float - upper_value: Optional[float] = None - - -class CriticalValueCreate(CriticalValueBase): - pass - - -class CriticalValueResponse(CriticalValueBase): - id: int - status: str - created_by: Optional[int] = None - - class Config: - from_attributes = True - - -class CriticalValueVerify(BaseModel): - status: str diff --git a/pysatl_knowledge/schemas/critical_value_schema.py b/pysatl_knowledge/schemas/critical_value_schema.py new file mode 100644 index 0000000..5caf6b6 --- /dev/null +++ b/pysatl_knowledge/schemas/critical_value_schema.py @@ -0,0 +1,24 @@ +from pydantic import BaseModel + + +class CriticalValueBase(BaseModel): + criterion_code: str + sample_size: int + iterations: int + result: float + + +class CriticalValueCreate(CriticalValueBase): + pass + + +class CriticalValueResponse(CriticalValueBase): + id: int + status: str + + class Config: + from_attributes = True + + +class CriticalValueVerify(BaseModel): + status: str diff --git a/pysatl_knowledge/services/auth_service.py b/pysatl_knowledge/services/auth_service.py index 1443894..2775be6 100644 --- a/pysatl_knowledge/services/auth_service.py +++ b/pysatl_knowledge/services/auth_service.py @@ -1,17 +1,24 @@ -# app/services/auth_service.py from __future__ import annotations from datetime import timedelta -from pysatl_knowledge.core.moke_db import MOCK_USERS -from pysatl_knowledge.core.security import create_access_token -from pysatl_knowledge.schemas.auth import LoginResponse +from pysatl_knowledge.core.security.jwt_utils import create_access_token +from pysatl_knowledge.core.security.pass_utils import verify_password +from pysatl_knowledge.repositories import UserRepository +from pysatl_knowledge.schemas.auth_schema import LoginResponse class AuthService: + def __init__(self): + self.repository = UserRepository() + async def login(self, username: str, password: str) -> LoginResponse | None: - user = MOCK_USERS.get(username) - if not user or user["password"] != password: + # Находим пользователя + user = await self.repository.find_by_username(username) + if not user: + return None + if not verify_password(password, user.password_hash): return None + token = create_access_token({"sub": username}, expires_delta=timedelta(minutes=30)) return LoginResponse(access_token=token) diff --git a/pysatl_knowledge/services/critical_value_service.py b/pysatl_knowledge/services/critical_value_service.py index 8984fc7..570ebcf 100644 --- a/pysatl_knowledge/services/critical_value_service.py +++ b/pysatl_knowledge/services/critical_value_service.py @@ -1,64 +1,78 @@ from typing import Optional -from pysatl_knowledge.schemas.critical_value import CriticalValueCreate, CriticalValueResponse +from fastapi import HTTPException + +from pysatl_knowledge.models.critical_value_model import CriticalValueModel +from pysatl_knowledge.repositories import CriticalValueRepository +from pysatl_knowledge.schemas.critical_value_schema import ( + CriticalValueCreate, + CriticalValueResponse, +) class CriticalValuesService: def __init__(self): - # В будущем: сюда можно передать session или репозиторий - pass + self.repository = CriticalValueRepository() async def get_cv_by_params( self, - code: str, - size: int, - sl: float, - ) -> CriticalValueResponse: - # TODO - - return CriticalValueResponse( - id=1, - code=code, - size=size, - sl=sl, - lower_value=-1.96, - upper_value=1.96, - status="created", - created_by=1, + criterion_code: Optional[str] = None, + sample_size: Optional[int] = None, + iterations: Optional[int] = None, + status: Optional[str] = None, + ) -> list[CriticalValueResponse]: + critical_values = await self.repository.find_by_params( + criterion_code=criterion_code, + sample_size=sample_size, + iterations=iterations, + status=status, ) + if not critical_values: + raise HTTPException(status_code=404, detail="No critical values found") - async def create_cv(self, data: CriticalValueCreate, created_by: int) -> CriticalValueResponse: - """ - Создает новый эксперимент (заглушка) - """ + return list( + map( + lambda cv: CriticalValueResponse( + id=cv.id, + criterion_code=cv.criterion_code, + sample_size=cv.sample_size, + iterations=cv.iterations, + result=cv.result, + status=cv.status, + ), + critical_values, + ) + ) - # TODO - return CriticalValueResponse( - id=1, - code=data.code, - size=data.size, - sl=data.sl, - lower_value=data.lower_value, - upper_value=data.upper_value, + async def create_cv(self, data: CriticalValueCreate) -> CriticalValueResponse: + exp = CriticalValueModel( + criterion_code=data.criterion_code, + sample_size=data.sample_size, + iterations=data.iterations, + result=data.result, status="created", - created_by=created_by, + ) + exp = await self.repository.create(exp) + return CriticalValueResponse( + id=exp.id, + criterion_code=exp.criterion_code, + sample_size=exp.sample_size, + iterations=exp.iterations, + result=exp.result, + status=exp.status, ) async def verify_cv(self, experiment_id: int, status: str) -> Optional[CriticalValueResponse]: - """ - Верифицирует или отклоняет эксперимент (заглушка) - """ - - # TODO - if experiment_id != 42: + exp = await self.repository.find_by_id(experiment_id) + if not exp: return None + + exp = await self.repository.update_status(exp, status) return CriticalValueResponse( - id=1, - code="code", - size=42, - sl=0.1, - lower_value=-1.96, - upper_value=1.96, - status=status, - created_by=1, + id=exp.id, + criterion_code=exp.criterion_code, + sample_size=exp.sample_size, + iterations=exp.iterations, + result=exp.result, + status=exp.status, )