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..7609ac3 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,5 +1,3 @@ -name: PySATL CI - on: push: branches: @@ -35,10 +33,10 @@ jobs: run: poetry install --with dev - name: Run Ruff lint - run: poetry run ruff check --output-format=github + run: poetry run ruff check --output-format=github --extend-exclude "migrations" - name: Check formatting with Ruff - run: poetry run ruff format --check + run: poetry run ruff format pysatl_knowledge --check - name: Mypy run: | 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/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/pyproject.toml b/pyproject.toml index 338d05c..8aefca7 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 = [ @@ -72,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/api/auth.py b/pysatl_knowledge/api/auth.py index 3e6d795..8140627 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_schema 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..9fde404 --- /dev/null +++ b/pysatl_knowledge/api/critical_value.py @@ -0,0 +1,78 @@ +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Path + +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 + + +router = APIRouter(prefix="/critical_values", tags=["Critical Values"]) + + +@router.get("", response_model=CriticalValueResponse) +async def get_critical_values( + 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), +): + 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: CriticalValuesService = Depends(get_cv_service) +): + """ + Создать новое критическое значение. + """ + try: + return await service.create_cv(data) + 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: CriticalValuesService = 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..9963a59 100644 --- a/pysatl_knowledge/core/config.py +++ b/pysatl_knowledge/core/config.py @@ -1,12 +1,20 @@ -from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic_settings import BaseSettings class Settings(BaseSettings): - DB_HOST: str - DB_PORT: int - DB_NAME: str - DB_USER: str - DB_PASS: str + app_name: str = "test" + secret_key: str = "supersecretkey" + access_token_expire_minutes: int = 30 + log_level: str = "INFO" + DB_HOST: str = "localhost" + DB_PORT: int = 5432 + DB_NAME: str = "test" + DB_USER: str = "test" + DB_PASS: str = "" + + class Config: + env_file = ".env" + extra = "ignore" @property def DATABASE_URL(self) -> str: @@ -19,7 +27,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/security/__init__.py b/pysatl_knowledge/core/security/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pysatl_knowledge/core/security/jwt_utils.py b/pysatl_knowledge/core/security/jwt_utils.py new file mode 100644 index 0000000..5f419cb --- /dev/null +++ b/pysatl_knowledge/core/security/jwt_utils.py @@ -0,0 +1,54 @@ +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.models import User +from pysatl_knowledge.repositories import UserRepository + + +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) + + +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") + 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 = 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: 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/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/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_schema.py b/pysatl_knowledge/schemas/auth_schema.py new file mode 100644 index 0000000..bb6d743 --- /dev/null +++ b/pysatl_knowledge/schemas/auth_schema.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_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 new file mode 100644 index 0000000..2775be6 --- /dev/null +++ b/pysatl_knowledge/services/auth_service.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from datetime import timedelta + +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 = 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 new file mode 100644 index 0000000..570ebcf --- /dev/null +++ b/pysatl_knowledge/services/critical_value_service.py @@ -0,0 +1,78 @@ +from typing import Optional + +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): + self.repository = CriticalValueRepository() + + async def get_cv_by_params( + self, + 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") + + 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, + ) + ) + + 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", + ) + 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]: + 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=exp.id, + criterion_code=exp.criterion_code, + sample_size=exp.sample_size, + iterations=exp.iterations, + result=exp.result, + status=exp.status, + ) 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()