From cb106a7de948d31dde43f90d099719ba0bced491 Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Thu, 4 Dec 2025 11:35:15 +0300 Subject: [PATCH 01/34] =?UTF-8?q?=D0=A1=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=20da?= =?UTF-8?q?tabase,=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .flake8 | 11 +++++++++++ .gitignore | 2 ++ app/core/config.py | 12 ++++++++++++ app/core/database.py | 17 +++++++++++++++++ app/main.py | 10 ++++++++++ pyproject.toml | 18 ++++++++++++++++++ requirements.txt | 12 ++++++++++++ 7 files changed, 82 insertions(+) create mode 100644 .flake8 create mode 100644 app/core/config.py create mode 100644 app/core/database.py create mode 100644 pyproject.toml diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..5bcac43 --- /dev/null +++ b/.flake8 @@ -0,0 +1,11 @@ +[flake8] +max-line-length = 90 +extend-ignore = E501, E203, W503 +exclude = + .git, + __pycache__, + .tox, + .eggs, + *.egg, + .venv, + venv, \ No newline at end of file diff --git a/.gitignore b/.gitignore index b7faf40..066a0aa 100644 --- a/.gitignore +++ b/.gitignore @@ -205,3 +205,5 @@ cython_debug/ marimo/_static/ marimo/_lsp/ __marimo__/ + +minicrm.db diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..e35f4b3 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,12 @@ +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + app_title: str + database_url: str + + class Config: + env_file = ".env" + + +settings = Settings() diff --git a/app/core/database.py b/app/core/database.py new file mode 100644 index 0000000..6f646bb --- /dev/null +++ b/app/core/database.py @@ -0,0 +1,17 @@ +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from sqlalchemy.orm import DeclarativeBase + +from app.core.config import settings + +engine = create_async_engine(settings.database_url, echo=True) + +sync_session = async_sessionmaker(engine, expire_on_commit=False) + + +class Model(DeclarativeBase): + pass + + +async def get_session(): + async with sync_session as session: + yield session diff --git a/app/main.py b/app/main.py index e69de29..e0a92c7 100644 --- a/app/main.py +++ b/app/main.py @@ -0,0 +1,10 @@ +import uvicorn +from fastapi import FastAPI + +from app.core.config import settings + +app = FastAPI(title=settings.app_title) + + +if __name__ == "__main__": + uvicorn.run("app.main:app", reload=True) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e0c19d3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,18 @@ +[tool.black] +line-length = 90 +target-version = ['py311'] +extend-exclude = ''' +( + \.venv + | venv + | build + | dist +) +''' +[tool.isort] +profile = "black" +line_length = 90 +multi_line_output = 3 +skip_gitignore = true +skip_glob = ["**/migrations/*", "**/settings/*"] +src_paths = ["app"] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index d947460..62fa290 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,15 +2,27 @@ aiosqlite==0.21.0 annotated-doc==0.0.4 annotated-types==0.7.0 anyio==4.12.0 +black==25.11.0 click==8.3.1 fastapi==0.123.5 +flake8==7.3.0 greenlet==3.2.4 h11==0.16.0 httptools==0.7.1 idna==3.11 +isort==7.0.0 +mccabe==0.7.0 +mypy_extensions==1.1.0 +packaging==25.0 +pathspec==0.12.1 +platformdirs==4.5.0 +pycodestyle==2.14.0 pydantic==2.12.5 +pydantic-settings==2.12.0 pydantic_core==2.41.5 +pyflakes==3.4.0 python-dotenv==1.2.1 +pytokens==0.3.0 PyYAML==6.0.3 SQLAlchemy==2.0.44 starlette==0.50.0 From 6782149466deffdbcffd95a2061d04c1f5b955d5 Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Thu, 4 Dec 2025 20:04:19 +0300 Subject: [PATCH 02/34] =?UTF-8?q?id=20=D0=B8=20tablename=20=D1=81=D0=BE?= =?UTF-8?q?=D0=B7=D0=B4=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20=D0=B0=D0=B2=D1=82?= =?UTF-8?q?=D0=BE=D0=BC=D0=B0=D1=82=D0=B8=D1=87=D0=B5=D1=81=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/database.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/app/core/database.py b/app/core/database.py index 6f646bb..95a0e6c 100644 --- a/app/core/database.py +++ b/app/core/database.py @@ -1,5 +1,5 @@ from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine -from sqlalchemy.orm import DeclarativeBase +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, declared_attr from app.core.config import settings @@ -12,6 +12,16 @@ class Model(DeclarativeBase): pass +class PreBase(Model): + __abstract__ == True # type: ignore + + @declared_attr + def __tablename__(cls)->str: + return cls.__name__.lower() + + id: Mapped[int] = mapped_column(primary_key=True) + + async def get_session(): async with sync_session as session: yield session From 4f36bb40c84b47b4e3ef12c4f019394e360c9cd8 Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Thu, 4 Dec 2025 21:14:56 +0300 Subject: [PATCH 03/34] users midels, schemas --- app/core/__init__.py | 0 app/core/database.py | 8 ++++---- app/models/users.py | 23 +++++++++++++++++++++++ app/routers/users.py | 0 app/schemas/users.py | 31 +++++++++++++++++++++++++++++++ 5 files changed, 58 insertions(+), 4 deletions(-) create mode 100644 app/core/__init__.py create mode 100644 app/models/users.py create mode 100644 app/routers/users.py create mode 100644 app/schemas/users.py diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/database.py b/app/core/database.py index 95a0e6c..3dea4e1 100644 --- a/app/core/database.py +++ b/app/core/database.py @@ -1,5 +1,5 @@ from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine -from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, declared_attr +from sqlalchemy.orm import DeclarativeBase, Mapped, declared_attr, mapped_column from app.core.config import settings @@ -13,12 +13,12 @@ class Model(DeclarativeBase): class PreBase(Model): - __abstract__ == True # type: ignore + __abstract__ = True @declared_attr - def __tablename__(cls)->str: + def __tablename__(cls) -> str: return cls.__name__.lower() - + id: Mapped[int] = mapped_column(primary_key=True) diff --git a/app/models/users.py b/app/models/users.py new file mode 100644 index 0000000..e20f893 --- /dev/null +++ b/app/models/users.py @@ -0,0 +1,23 @@ +import enum + +from fastapi_users_db_sqlalchemy import SQLAlchemyBaseUserTable +from sqlalchemy import Enum +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.database import Model + + +class UserRole(str, enum.Enum): + user = "user" + admin = "admin" + + +class User(SQLAlchemyBaseUserTable[int], Model): + + username: Mapped[str] = mapped_column(nullable=False) + first_name: Mapped[str | None] = mapped_column(nullable=True) + last_name: Mapped[str | None] = mapped_column(nullable=True) + phone: Mapped[int | None] = mapped_column(nullable=True) + role: Mapped[UserRole] = mapped_column( + Enum(UserRole), default=UserRole.user, nullable=False + ) diff --git a/app/routers/users.py b/app/routers/users.py new file mode 100644 index 0000000..e69de29 diff --git a/app/schemas/users.py b/app/schemas/users.py new file mode 100644 index 0000000..1de216b --- /dev/null +++ b/app/schemas/users.py @@ -0,0 +1,31 @@ +from fastapi_users import schemas + +from app.models.users import UserRole + + +class UserRead(schemas.BaseUser[int]): + id: int + email: str + username: str + first_name: str | None = None + last_name: str | None = None + phone: int | None = None + role: UserRole + + +class UserCreate(schemas.BaseUserCreate): + email: str + username: str + first_name: str | None = None + last_name: str | None = None + phone: int | None = None + role: UserRole = UserRole.user + + +class UserUpdate(schemas.BaseUserUpdate): + email: str + username: str + first_name: str | None = None + last_name: str | None = None + phone: int | None = None + role: UserRole = UserRole.user From 6f2958471b45b09c8e06782e3b0f726287f4609a Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Sat, 6 Dec 2025 23:17:07 +0300 Subject: [PATCH 04/34] user_routers --- app/core/config.py | 1 + app/main.py | 3 ++ app/routers/users.py | 19 ++++++++ app/users/__init__.py | 0 app/users/manager.py | 56 ++++++++++++++++++++++ app/{models/users.py => users/models.py} | 5 +- app/{schemas/users.py => users/schemas.py} | 2 +- app/users/utils.py | 10 ++++ 8 files changed, 92 insertions(+), 4 deletions(-) create mode 100644 app/users/__init__.py create mode 100644 app/users/manager.py rename app/{models/users.py => users/models.py} (87%) rename app/{schemas/users.py => users/schemas.py} (94%) create mode 100644 app/users/utils.py diff --git a/app/core/config.py b/app/core/config.py index e35f4b3..0c78d53 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -4,6 +4,7 @@ class Settings(BaseSettings): app_title: str database_url: str + secret: str class Config: env_file = ".env" diff --git a/app/main.py b/app/main.py index e0a92c7..9a10f92 100644 --- a/app/main.py +++ b/app/main.py @@ -2,9 +2,12 @@ from fastapi import FastAPI from app.core.config import settings +from app.routers.users import user_router app = FastAPI(title=settings.app_title) +app.include_router(user_router) + if __name__ == "__main__": uvicorn.run("app.main:app", reload=True) diff --git a/app/routers/users.py b/app/routers/users.py index e69de29..284bb78 100644 --- a/app/routers/users.py +++ b/app/routers/users.py @@ -0,0 +1,19 @@ +from fastapi import APIRouter + +from app.users.manager import auth_backend, fastapi_users +from app.users.schemas import UserCreate, UserRead, UserUpdate + +user_router = APIRouter( + prefix="/users", + tags=["Пользователи"], +) + +user_router.include_router( + fastapi_users.get_auth_router(auth_backend), + prefix="/auth/jwt", +) +user_router.include_router( + fastapi_users.get_register_router(UserRead, UserCreate), + prefix="/auth", +) +user_router.include_router(fastapi_users.get_users_router(UserRead, UserUpdate)) diff --git a/app/users/__init__.py b/app/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/users/manager.py b/app/users/manager.py new file mode 100644 index 0000000..40b7f73 --- /dev/null +++ b/app/users/manager.py @@ -0,0 +1,56 @@ +from fastapi import Depends, Request +from fastapi_users import ( + BaseUserManager, + FastAPIUsers, + IntegerIDMixin, + InvalidPasswordException, +) +from fastapi_users.authentication import ( + AuthenticationBackend, + BearerTransport, + JWTStrategy, +) + +from app.core.config import settings +from app.users.models import User +from app.users.schemas import UserCreate +from app.users.utils import get_user_db + +bearer_tramsport = BearerTransport(tokenUrl="auth/jwt/login") + + +def get_jwt_strategy() -> JWTStrategy: + return JWTStrategy(secret=settings.secret, lifetime_seconds=3600) + + +auth_backend = AuthenticationBackend( + name="jwt", transport=bearer_tramsport, get_strategy=get_jwt_strategy +) + + +class UserManager(IntegerIDMixin, BaseUserManager[User, int]): + async def validate_password(self, password: str, user: User | UserCreate) -> None: + if len(password) < 3: + raise InvalidPasswordException( + reason="Пароль должен содержать не менее 3 символов" + ) + if user.email in password: + raise InvalidPasswordException( + reason="Пароль не должен содержать адрес электронной почты" + ) + + async def on_after_register(self, user: User, request: Request | None): + print(f"Пользователь {user.username} зарегистрирован") + + +async def get_user_manager(user_db=Depends(get_user_db)): + yield UserManager(user_db) + + +fastapi_users = FastAPIUsers[User, int]( + get_user_manager, + [auth_backend], +) + +current_user = fastapi_users.current_user(active=True) +current_superuser = fastapi_users.current_user(active=True, superuser=True) diff --git a/app/models/users.py b/app/users/models.py similarity index 87% rename from app/models/users.py rename to app/users/models.py index e20f893..d606f5f 100644 --- a/app/models/users.py +++ b/app/users/models.py @@ -4,15 +4,14 @@ from sqlalchemy import Enum from sqlalchemy.orm import Mapped, mapped_column -from app.core.database import Model - class UserRole(str, enum.Enum): user = "user" admin = "admin" -class User(SQLAlchemyBaseUserTable[int], Model): +class User(SQLAlchemyBaseUserTable[int]): + __tablename__ = "user" username: Mapped[str] = mapped_column(nullable=False) first_name: Mapped[str | None] = mapped_column(nullable=True) diff --git a/app/schemas/users.py b/app/users/schemas.py similarity index 94% rename from app/schemas/users.py rename to app/users/schemas.py index 1de216b..409d4a0 100644 --- a/app/schemas/users.py +++ b/app/users/schemas.py @@ -1,6 +1,6 @@ from fastapi_users import schemas -from app.models.users import UserRole +from app.users.models import UserRole class UserRead(schemas.BaseUser[int]): diff --git a/app/users/utils.py b/app/users/utils.py new file mode 100644 index 0000000..29cf5bf --- /dev/null +++ b/app/users/utils.py @@ -0,0 +1,10 @@ +from fastapi import Depends +from fastapi_users_db_sqlalchemy import SQLAlchemyUserDatabase +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_session +from app.users.models import User + + +async def get_user_db(session: AsyncSession = Depends(get_session)): + yield SQLAlchemyUserDatabase(session, User) From 581cc2117c3f99426acceab15e7d6f433c25ea76 Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Sun, 7 Dec 2025 13:11:01 +0300 Subject: [PATCH 05/34] =?UTF-8?q?=D0=A1=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD?= =?UTF-8?q?=D1=8B=20=D0=BF=D0=B5=D1=80=D0=B2=D1=8B=D0=B5=20=D0=BC=D0=B8?= =?UTF-8?q?=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D0=B8=20alembic,=20=D0=B0=D0=B2?= =?UTF-8?q?=D1=82=D1=82=D0=BE=D0=BC=D0=B0=D1=82=D0=B8=D1=87=D0=B5=D1=81?= =?UTF-8?q?=D0=BA=D0=BE=D0=B5=20=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=D0=B5=20=D1=81=D1=83=D0=BF=D0=B5=D0=B5=D1=80=D1=8E=D0=B7?= =?UTF-8?q?=D0=B5=D1=80=D0=B0=20=D0=BF=D1=80=D0=B8=20=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D1=80=D1=82=D0=B5=20=D0=BF=D1=80=D0=B8=D0=BB=D0=BE=D0=B6=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- alembic.ini | 147 ++++++++++++++++++ alembic/README | 1 + alembic/env.py | 96 ++++++++++++ alembic/script.py.mako | 28 ++++ .../versions/a0d75653863e_first_migration.py | 49 ++++++ app/core/base.py | 4 + app/core/config.py | 3 + app/core/database.py | 6 +- app/core/init_db.py | 48 ++++++ app/main.py | 14 +- app/routers/users.py | 10 +- app/users/models.py | 7 +- requirements.txt | 34 ---- 13 files changed, 404 insertions(+), 43 deletions(-) create mode 100644 alembic.ini create mode 100644 alembic/README create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako create mode 100644 alembic/versions/a0d75653863e_first_migration.py create mode 100644 app/core/base.py create mode 100644 app/core/init_db.py delete mode 100644 requirements.txt diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..18ac9b4 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,147 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/README b/alembic/README new file mode 100644 index 0000000..e0d0858 --- /dev/null +++ b/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration with an async dbapi. \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..2d9cb50 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,96 @@ +import asyncio +import os +from logging.config import fileConfig + +from dotenv import load_dotenv +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +from alembic import context + +from app.core.base import Base + +load_dotenv(".env") + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config +config.set_main_option("sqlalchemy.url", os.environ["DATABASE_URL"]) + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + """In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode.""" + + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/a0d75653863e_first_migration.py b/alembic/versions/a0d75653863e_first_migration.py new file mode 100644 index 0000000..e05be22 --- /dev/null +++ b/alembic/versions/a0d75653863e_first_migration.py @@ -0,0 +1,49 @@ +"""First Migration + +Revision ID: a0d75653863e +Revises: +Create Date: 2025-12-07 12:07:13.042732 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "a0d75653863e" +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "users", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("username", sa.String(), nullable=False), + sa.Column("first_name", sa.String(), nullable=True), + sa.Column("last_name", sa.String(), nullable=True), + sa.Column("phone", sa.Integer(), nullable=True), + sa.Column("role", sa.Enum("user", "admin", name="userrole"), nullable=False), + sa.Column("email", sa.String(length=320), nullable=False), + sa.Column("hashed_password", sa.String(length=1024), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False), + sa.Column("is_superuser", sa.Boolean(), nullable=False), + sa.Column("is_verified", sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f("ix_users_email"), table_name="users") + op.drop_table("users") + # ### end Alembic commands ### diff --git a/app/core/base.py b/app/core/base.py new file mode 100644 index 0000000..d937574 --- /dev/null +++ b/app/core/base.py @@ -0,0 +1,4 @@ +"""Импорты класса Base и всех моделей для Alembic.""" + +from app.core.database import Base # noqa +from app.users.models import User # noqa diff --git a/app/core/config.py b/app/core/config.py index 0c78d53..cdf8f2c 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -5,6 +5,9 @@ class Settings(BaseSettings): app_title: str database_url: str secret: str + first_superuser_username: str | None = None + first_superuser_email: str | None = None + first_superuser_password: str | None = None class Config: env_file = ".env" diff --git a/app/core/database.py b/app/core/database.py index 3dea4e1..6206f04 100644 --- a/app/core/database.py +++ b/app/core/database.py @@ -8,11 +8,11 @@ sync_session = async_sessionmaker(engine, expire_on_commit=False) -class Model(DeclarativeBase): +class Base(DeclarativeBase): pass -class PreBase(Model): +class PreBase(Base): __abstract__ = True @declared_attr @@ -23,5 +23,5 @@ def __tablename__(cls) -> str: async def get_session(): - async with sync_session as session: + async with sync_session() as session: yield session diff --git a/app/core/init_db.py b/app/core/init_db.py new file mode 100644 index 0000000..f9265a9 --- /dev/null +++ b/app/core/init_db.py @@ -0,0 +1,48 @@ +import contextlib + +from fastapi_users.exceptions import UserAlreadyExists +from pydantic import EmailStr + +from app.core.config import settings +from app.core.database import get_session +from app.users.utils import get_user_db +from app.users.manager import get_user_manager +from app.users.schemas import UserCreate + + +get_session_context = contextlib.asynccontextmanager(get_session) +get_user_db_context = contextlib.asynccontextmanager(get_user_db) +get_user_manager_context = contextlib.asynccontextmanager(get_user_manager) + + +async def create_user( + username: str, email: EmailStr, password: str, is_superuser: bool = False +): + try: + async with get_session_context() as session: + async with get_user_db_context(session) as user_db: + async with get_user_manager_context(user_db) as user_mannager: + await user_mannager.create( + UserCreate( + username=username, + email=email, + password=password, + is_superuser=is_superuser, + ) + ) + except UserAlreadyExists: + pass + + +async def create_first_superuser(): + if ( + settings.first_superuser_username is not None + and settings.first_superuser_email is not None + and settings.first_superuser_password is not None + ): + await create_user( + username=settings.first_superuser_username, + email=settings.first_superuser_email, + password=settings.first_superuser_password, + is_superuser=True, + ) diff --git a/app/main.py b/app/main.py index 9a10f92..2e10a26 100644 --- a/app/main.py +++ b/app/main.py @@ -1,10 +1,22 @@ +from contextlib import asynccontextmanager + import uvicorn from fastapi import FastAPI from app.core.config import settings from app.routers.users import user_router -app = FastAPI(title=settings.app_title) +from app.core.init_db import create_first_superuser + + +@asynccontextmanager +async def lifespan(app: FastAPI): + await create_first_superuser() + print('SuperUser создан или уже существует') + yield + + +app = FastAPI(title=settings.app_title, lifespan=lifespan) app.include_router(user_router) diff --git a/app/routers/users.py b/app/routers/users.py index 284bb78..40108e5 100644 --- a/app/routers/users.py +++ b/app/routers/users.py @@ -1,4 +1,6 @@ -from fastapi import APIRouter +from typing import Annotated + +from fastapi import APIRouter, Depends from app.users.manager import auth_backend, fastapi_users from app.users.schemas import UserCreate, UserRead, UserUpdate @@ -13,7 +15,9 @@ prefix="/auth/jwt", ) user_router.include_router( - fastapi_users.get_register_router(UserRead, UserCreate), + fastapi_users.get_register_router(UserRead, Annotated[UserCreate, Depends()]), prefix="/auth", ) -user_router.include_router(fastapi_users.get_users_router(UserRead, UserUpdate)) +user_router.include_router( + fastapi_users.get_users_router(UserRead, Annotated[UserUpdate, Depends()]) +) diff --git a/app/users/models.py b/app/users/models.py index d606f5f..ac13f99 100644 --- a/app/users/models.py +++ b/app/users/models.py @@ -4,15 +4,18 @@ from sqlalchemy import Enum from sqlalchemy.orm import Mapped, mapped_column +from app.core.database import Base + class UserRole(str, enum.Enum): user = "user" admin = "admin" -class User(SQLAlchemyBaseUserTable[int]): - __tablename__ = "user" +class User(SQLAlchemyBaseUserTable[int], Base): + __tablename__ = "users" + id: Mapped[int] = mapped_column(primary_key=True) username: Mapped[str] = mapped_column(nullable=False) first_name: Mapped[str | None] = mapped_column(nullable=True) last_name: Mapped[str | None] = mapped_column(nullable=True) diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 62fa290..0000000 --- a/requirements.txt +++ /dev/null @@ -1,34 +0,0 @@ -aiosqlite==0.21.0 -annotated-doc==0.0.4 -annotated-types==0.7.0 -anyio==4.12.0 -black==25.11.0 -click==8.3.1 -fastapi==0.123.5 -flake8==7.3.0 -greenlet==3.2.4 -h11==0.16.0 -httptools==0.7.1 -idna==3.11 -isort==7.0.0 -mccabe==0.7.0 -mypy_extensions==1.1.0 -packaging==25.0 -pathspec==0.12.1 -platformdirs==4.5.0 -pycodestyle==2.14.0 -pydantic==2.12.5 -pydantic-settings==2.12.0 -pydantic_core==2.41.5 -pyflakes==3.4.0 -python-dotenv==1.2.1 -pytokens==0.3.0 -PyYAML==6.0.3 -SQLAlchemy==2.0.44 -starlette==0.50.0 -typing-inspection==0.4.2 -typing_extensions==4.15.0 -uvicorn==0.38.0 -uvloop==0.22.1 -watchfiles==1.1.1 -websockets==15.0.1 From 583f2b762f3472f3684218cb19c697e18ab32618 Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Mon, 8 Dec 2025 20:09:09 +0300 Subject: [PATCH 06/34] logging users --- .gitignore | 3 ++ app/core/config.py | 1 + app/core/database.py | 2 +- app/core/init_db.py | 65 ++++++++++++++++++++++++++++++++------------ app/core/logging.py | 29 ++++++++++++++++++++ app/main.py | 10 ++++++- app/routers/users.py | 4 +-- 7 files changed, 93 insertions(+), 21 deletions(-) create mode 100644 app/core/logging.py diff --git a/.gitignore b/.gitignore index 066a0aa..cb72d74 100644 --- a/.gitignore +++ b/.gitignore @@ -207,3 +207,6 @@ marimo/_lsp/ __marimo__/ minicrm.db + +logs/ +*.log diff --git a/app/core/config.py b/app/core/config.py index cdf8f2c..ce49c59 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -8,6 +8,7 @@ class Settings(BaseSettings): first_superuser_username: str | None = None first_superuser_email: str | None = None first_superuser_password: str | None = None + first_superuser_role: str | None = None class Config: env_file = ".env" diff --git a/app/core/database.py b/app/core/database.py index 6206f04..efbc026 100644 --- a/app/core/database.py +++ b/app/core/database.py @@ -3,7 +3,7 @@ from app.core.config import settings -engine = create_async_engine(settings.database_url, echo=True) +engine = create_async_engine(settings.database_url) sync_session = async_sessionmaker(engine, expire_on_commit=False) diff --git a/app/core/init_db.py b/app/core/init_db.py index f9265a9..a6c8366 100644 --- a/app/core/init_db.py +++ b/app/core/init_db.py @@ -1,4 +1,6 @@ import contextlib +import logging +from typing import Literal from fastapi_users.exceptions import UserAlreadyExists from pydantic import EmailStr @@ -10,39 +12,68 @@ from app.users.schemas import UserCreate +logger = logging.getLogger(__name__) + get_session_context = contextlib.asynccontextmanager(get_session) get_user_db_context = contextlib.asynccontextmanager(get_user_db) get_user_manager_context = contextlib.asynccontextmanager(get_user_manager) async def create_user( - username: str, email: EmailStr, password: str, is_superuser: bool = False + username: str, + email: EmailStr, + password: str, + is_superuser: bool = False, + role: Literal["admin", "user"] = "user", ): try: async with get_session_context() as session: async with get_user_db_context(session) as user_db: - async with get_user_manager_context(user_db) as user_mannager: - await user_mannager.create( - UserCreate( - username=username, - email=email, - password=password, - is_superuser=is_superuser, + async with get_user_manager_context(user_db) as user_manager: + try: + await user_manager.create( + UserCreate( + username=username, + email=email, + password=password, + is_superuser=is_superuser, + role=role, + ) ) - ) - except UserAlreadyExists: - pass + logger.info(f"Пользователь {username} создан") + except UserAlreadyExists: + logger.warning(f"Пользователь {username} уже сужествует") + except Exception as e: + logger.error(f"Ошибка при создании пользователя {username}: {e}") -async def create_first_superuser(): +async def create_first_superuser() -> None: + """Создание первого суперпользователя при запуске приложения""" + first_superuser_data = { + "username": settings.first_superuser_username, + "email": settings.first_superuser_email, + "password": settings.first_superuser_password, + "role": settings.first_superuser_role, + } if ( settings.first_superuser_username is not None and settings.first_superuser_email is not None and settings.first_superuser_password is not None + and settings.first_superuser_role is not None ): - await create_user( - username=settings.first_superuser_username, - email=settings.first_superuser_email, - password=settings.first_superuser_password, - is_superuser=True, + try: + await create_user( + username=settings.first_superuser_username, + email=settings.first_superuser_email, + password=settings.first_superuser_password, + is_superuser=True, + role=settings.first_superuser_role, + ) + logger.info("Первый суперюзер создан или уже существует") + except Exception as e: + logger.error(f"Ошибка при создании первого суперюзера: {e}") + else: + missing_value = [k for k, v in first_superuser_data.items() if not v] + logger.warning( + f'Невозможно создать первого суперпользователя, отсутствуют данные: {", ".join(missing_value)}' ) diff --git a/app/core/logging.py b/app/core/logging.py new file mode 100644 index 0000000..d0eefd7 --- /dev/null +++ b/app/core/logging.py @@ -0,0 +1,29 @@ +import logging +from logging.handlers import RotatingFileHandler +from pathlib import Path + + +LOG_DIR = Path("logs") +LOG_DIR.mkdir(exist_ok=True) + +LOG_FILE = LOG_DIR / "app.log" + + +def setup_logging() -> None: + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s: - |%(levelname)s| %(name)s | %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + handlers=[ + logging.StreamHandler(), + RotatingFileHandler( + filename=LOG_FILE, + maxBytes=1024 * 1024 * 5, + backupCount=5, + encoding="utf-8", + ), + ], + ) + + +logging.getLogger("uvicorn.access").setLevel(logging.WARNING) diff --git a/app/main.py b/app/main.py index 2e10a26..852befa 100644 --- a/app/main.py +++ b/app/main.py @@ -1,4 +1,5 @@ from contextlib import asynccontextmanager +import logging import uvicorn from fastapi import FastAPI @@ -7,13 +8,20 @@ from app.routers.users import user_router from app.core.init_db import create_first_superuser +from app.core.logging import setup_logging + + +setup_logging() + +logger = logging.getLogger(__name__) @asynccontextmanager async def lifespan(app: FastAPI): + logging.info("Приложение miniCRM запущено") await create_first_superuser() - print('SuperUser создан или уже существует') yield + logging.info("Приложение miniCRM остановлено") app = FastAPI(title=settings.app_title, lifespan=lifespan) diff --git a/app/routers/users.py b/app/routers/users.py index 40108e5..51d8eaa 100644 --- a/app/routers/users.py +++ b/app/routers/users.py @@ -6,7 +6,6 @@ from app.users.schemas import UserCreate, UserRead, UserUpdate user_router = APIRouter( - prefix="/users", tags=["Пользователи"], ) @@ -19,5 +18,6 @@ prefix="/auth", ) user_router.include_router( - fastapi_users.get_users_router(UserRead, Annotated[UserUpdate, Depends()]) + fastapi_users.get_users_router(UserRead, Annotated[UserUpdate, Depends()]), + prefix="/users", ) From fac3ea58cf628dc3dbb50ed2dccfe2046118c332 Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Mon, 8 Dec 2025 20:38:37 +0300 Subject: [PATCH 07/34] clients --- app/models/clients.py | 10 ++++++++++ app/routers/clients.py | 0 app/schemas/clients.py | 0 3 files changed, 10 insertions(+) create mode 100644 app/models/clients.py create mode 100644 app/routers/clients.py create mode 100644 app/schemas/clients.py diff --git a/app/models/clients.py b/app/models/clients.py new file mode 100644 index 0000000..17d856a --- /dev/null +++ b/app/models/clients.py @@ -0,0 +1,10 @@ +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.database import Base + + +class Client(Base): + id: Mapped[int] = mapped_column(primary_key=True) + full_name: Mapped[str | None] = mapped_column(nullable=False) + email: Mapped[str | None] = mapped_column(nullable=True) + phone: Mapped[int | None] = mapped_column(nullable=True) diff --git a/app/routers/clients.py b/app/routers/clients.py new file mode 100644 index 0000000..e69de29 diff --git a/app/schemas/clients.py b/app/schemas/clients.py new file mode 100644 index 0000000..e69de29 From b0c3eb619cdfef5e3f5c2ca6e89cc9851b0d9989 Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Wed, 10 Dec 2025 12:00:12 +0300 Subject: [PATCH 08/34] User Client --- .flake8 | 3 ++- alembic/env.py | 3 +-- ...gration.py => 3530c3a8f1a4_user_client.py} | 19 +++++++++++---- app/__init__.py | 2 ++ app/core/base.py | 1 + app/core/database.py | 12 +--------- app/core/init_db.py | 3 +-- app/core/logging.py | 1 - app/main.py | 6 ++--- app/models/clients.py | 24 +++++++++++++++---- app/users/models.py | 7 +++++- pyproject.toml | 2 +- 12 files changed, 52 insertions(+), 31 deletions(-) rename alembic/versions/{a0d75653863e_first_migration.py => 3530c3a8f1a4_user_client.py} (71%) diff --git a/.flake8 b/.flake8 index 5bcac43..3717fca 100644 --- a/.flake8 +++ b/.flake8 @@ -8,4 +8,5 @@ exclude = .eggs, *.egg, .venv, - venv, \ No newline at end of file + venv, + alembic/ \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py index 2d9cb50..dcb3d72 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -2,13 +2,12 @@ import os from logging.config import fileConfig +from alembic import context from dotenv import load_dotenv from sqlalchemy import pool from sqlalchemy.engine import Connection from sqlalchemy.ext.asyncio import async_engine_from_config -from alembic import context - from app.core.base import Base load_dotenv(".env") diff --git a/alembic/versions/a0d75653863e_first_migration.py b/alembic/versions/3530c3a8f1a4_user_client.py similarity index 71% rename from alembic/versions/a0d75653863e_first_migration.py rename to alembic/versions/3530c3a8f1a4_user_client.py index e05be22..ca9f403 100644 --- a/alembic/versions/a0d75653863e_first_migration.py +++ b/alembic/versions/3530c3a8f1a4_user_client.py @@ -1,8 +1,8 @@ -"""First Migration +"""User Client -Revision ID: a0d75653863e +Revision ID: 3530c3a8f1a4 Revises: -Create Date: 2025-12-07 12:07:13.042732 +Create Date: 2025-12-10 11:58:20.987526 """ @@ -13,7 +13,7 @@ # revision identifiers, used by Alembic. -revision: str = "a0d75653863e" +revision: str = "3530c3a8f1a4" down_revision: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -38,12 +38,23 @@ def upgrade() -> None: sa.PrimaryKeyConstraint("id"), ) op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True) + op.create_table( + "clients", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("full_name", sa.String(length=255), nullable=True), + sa.Column("email", sa.String(length=255), nullable=True), + sa.Column("phone", sa.String(length=255), nullable=True), + sa.Column("manager_id", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(["manager_id"], ["users.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) # ### end Alembic commands ### def downgrade() -> None: """Downgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("clients") op.drop_index(op.f("ix_users_email"), table_name="users") op.drop_table("users") # ### end Alembic commands ### diff --git a/app/__init__.py b/app/__init__.py index e69de29..88a168a 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -0,0 +1,2 @@ +from .models.clients import Client # noqa +from .users.models import User # noqa diff --git a/app/core/base.py b/app/core/base.py index d937574..d288b08 100644 --- a/app/core/base.py +++ b/app/core/base.py @@ -1,4 +1,5 @@ """Импорты класса Base и всех моделей для Alembic.""" from app.core.database import Base # noqa +from app.models.clients import Client # noqa from app.users.models import User # noqa diff --git a/app/core/database.py b/app/core/database.py index efbc026..eab6c26 100644 --- a/app/core/database.py +++ b/app/core/database.py @@ -1,5 +1,5 @@ from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine -from sqlalchemy.orm import DeclarativeBase, Mapped, declared_attr, mapped_column +from sqlalchemy.orm import DeclarativeBase from app.core.config import settings @@ -12,16 +12,6 @@ class Base(DeclarativeBase): pass -class PreBase(Base): - __abstract__ = True - - @declared_attr - def __tablename__(cls) -> str: - return cls.__name__.lower() - - id: Mapped[int] = mapped_column(primary_key=True) - - async def get_session(): async with sync_session() as session: yield session diff --git a/app/core/init_db.py b/app/core/init_db.py index a6c8366..2612b91 100644 --- a/app/core/init_db.py +++ b/app/core/init_db.py @@ -7,10 +7,9 @@ from app.core.config import settings from app.core.database import get_session -from app.users.utils import get_user_db from app.users.manager import get_user_manager from app.users.schemas import UserCreate - +from app.users.utils import get_user_db logger = logging.getLogger(__name__) diff --git a/app/core/logging.py b/app/core/logging.py index d0eefd7..78004f0 100644 --- a/app/core/logging.py +++ b/app/core/logging.py @@ -2,7 +2,6 @@ from logging.handlers import RotatingFileHandler from pathlib import Path - LOG_DIR = Path("logs") LOG_DIR.mkdir(exist_ok=True) diff --git a/app/main.py b/app/main.py index 852befa..d3ef36d 100644 --- a/app/main.py +++ b/app/main.py @@ -1,15 +1,13 @@ -from contextlib import asynccontextmanager import logging +from contextlib import asynccontextmanager import uvicorn from fastapi import FastAPI from app.core.config import settings -from app.routers.users import user_router - from app.core.init_db import create_first_superuser from app.core.logging import setup_logging - +from app.routers.users import user_router setup_logging() diff --git a/app/models/clients.py b/app/models/clients.py index 17d856a..c82bfc0 100644 --- a/app/models/clients.py +++ b/app/models/clients.py @@ -1,10 +1,26 @@ -from sqlalchemy.orm import Mapped, mapped_column +from typing import TYPE_CHECKING + +from sqlalchemy import ForeignKey, String +from sqlalchemy.orm import Mapped, mapped_column, relationship from app.core.database import Base +if TYPE_CHECKING: + from app.users.models import User + class Client(Base): + __tablename__ = "clients" + id: Mapped[int] = mapped_column(primary_key=True) - full_name: Mapped[str | None] = mapped_column(nullable=False) - email: Mapped[str | None] = mapped_column(nullable=True) - phone: Mapped[int | None] = mapped_column(nullable=True) + full_name: Mapped[str | None] = mapped_column(String(255)) + email: Mapped[str | None] = mapped_column(String(255), nullable=True) + phone: Mapped[str | None] = mapped_column(String(255), nullable=True) + manager_id: Mapped[int | None] = mapped_column( + ForeignKey( + "users.id", + ondelete="SET NULL", + ), + nullable=True, + ) + manager: Mapped["User"] = relationship(back_populates="clients") diff --git a/app/users/models.py b/app/users/models.py index ac13f99..77e8427 100644 --- a/app/users/models.py +++ b/app/users/models.py @@ -1,11 +1,15 @@ import enum +from typing import TYPE_CHECKING from fastapi_users_db_sqlalchemy import SQLAlchemyBaseUserTable from sqlalchemy import Enum -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.orm import Mapped, mapped_column, relationship from app.core.database import Base +if TYPE_CHECKING: + from app.models.clients import Client + class UserRole(str, enum.Enum): user = "user" @@ -23,3 +27,4 @@ class User(SQLAlchemyBaseUserTable[int], Base): role: Mapped[UserRole] = mapped_column( Enum(UserRole), default=UserRole.user, nullable=False ) + clients: Mapped[list["Client"]] = relationship(back_populates="manager") diff --git a/pyproject.toml b/pyproject.toml index e0c19d3..f44d9ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,5 +14,5 @@ profile = "black" line_length = 90 multi_line_output = 3 skip_gitignore = true -skip_glob = ["**/migrations/*", "**/settings/*"] +skip_glob = ["**/alembic/*"] src_paths = ["app"] \ No newline at end of file From 38a2c83625fc40fda24a05b4e1902d7779d16340 Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Thu, 11 Dec 2025 11:58:01 +0300 Subject: [PATCH 09/34] ClientSchemas --- app/models/clients.py | 2 +- app/schemas/clients.py | 37 +++++++++++++++++++++++++++++++++++++ app/users/schemas.py | 3 +++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/app/models/clients.py b/app/models/clients.py index c82bfc0..332f625 100644 --- a/app/models/clients.py +++ b/app/models/clients.py @@ -13,7 +13,7 @@ class Client(Base): __tablename__ = "clients" id: Mapped[int] = mapped_column(primary_key=True) - full_name: Mapped[str | None] = mapped_column(String(255)) + full_name: Mapped[str] = mapped_column(String(255)) email: Mapped[str | None] = mapped_column(String(255), nullable=True) phone: Mapped[str | None] = mapped_column(String(255), nullable=True) manager_id: Mapped[int | None] = mapped_column( diff --git a/app/schemas/clients.py b/app/schemas/clients.py index e69de29..d600ff6 100644 --- a/app/schemas/clients.py +++ b/app/schemas/clients.py @@ -0,0 +1,37 @@ +from pydantic import BaseModel, ConfigDict, EmailStr + + +class ManagerShortSchema(BaseModel): + id: int + email: str + username: str + first_name: str | None = None + last_name: str | None = None + + model_config = ConfigDict(from_attributes=True) + + +class ClientBaseSchema(BaseModel): + full_name: str + email: EmailStr | None = None + phone: str | None = None + manager_id: int | None = None + + +class ClientCreateSchema(ClientBaseSchema): + pass + + +class ClientUpdateSchema(BaseModel): + + full_name: str | None = None + email: EmailStr | None = None + phone: str | None = None + manager_id: int | None = None + + +class ClientReadSchema(ClientBaseSchema): + id: int + manager: ManagerShortSchema | None + + model_config = ConfigDict(from_attributes=True) diff --git a/app/users/schemas.py b/app/users/schemas.py index 409d4a0..3a1347b 100644 --- a/app/users/schemas.py +++ b/app/users/schemas.py @@ -11,6 +11,7 @@ class UserRead(schemas.BaseUser[int]): last_name: str | None = None phone: int | None = None role: UserRole + clients: list | None = None class UserCreate(schemas.BaseUserCreate): @@ -20,6 +21,7 @@ class UserCreate(schemas.BaseUserCreate): last_name: str | None = None phone: int | None = None role: UserRole = UserRole.user + clients: list | None = None class UserUpdate(schemas.BaseUserUpdate): @@ -29,3 +31,4 @@ class UserUpdate(schemas.BaseUserUpdate): last_name: str | None = None phone: int | None = None role: UserRole = UserRole.user + clients: list | None = None From 8bd2ab7a4f620f10b86c83ec63cfcf056370605a Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Thu, 11 Dec 2025 16:13:21 +0300 Subject: [PATCH 10/34] crud client + routers client --- app/crud/__init__.py | 0 app/crud/clients.py | 73 ++++++++++++++++++++++++++++++++++++++++++ app/main.py | 3 ++ app/routers/clients.py | 67 ++++++++++++++++++++++++++++++++++++++ app/schemas/clients.py | 2 +- 5 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 app/crud/__init__.py create mode 100644 app/crud/clients.py diff --git a/app/crud/__init__.py b/app/crud/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/crud/clients.py b/app/crud/clients.py new file mode 100644 index 0000000..d32cce3 --- /dev/null +++ b/app/crud/clients.py @@ -0,0 +1,73 @@ +import logging +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.exc import SQLAlchemyError + +from app.models.clients import Client +from app.schemas.clients import ClientCreateSchema, ClientUpdateSchema + + +logger = logging.getLogger(__name__) + + +class CRUDClient: + async def get_all_clients(self, session: AsyncSession): + try: + query = select(Client) + result = await session.execute(query) + clients = result.scalars().all() + logger.info('Получен список клиентов') + return clients + except SQLAlchemyError as e: + logger.error(f'Ошибка приполучении списка клиентов: {e}') + raise + + async def get_client(self, client_id: int, session: AsyncSession): + try: + query = select(Client).where(Client.id == client_id) + result = await session.execute(query) + client = result.scalars().first() + if client: + logger.info(f'Получен клиент id = {client_id}') + else: + logger.warning(f'Клиеент id = {client_id} не найден') + return client + except SQLAlchemyError as e: + logger.error(f'Ошибка приполучении клиента: {e}') + raise + + async def create_client(self, data: ClientCreateSchema, session: AsyncSession): + try: + new_client = Client(**data.model_dump()) + session.add(new_client) + await session.flush() + await session.commit() + logger.info(f'Создан клиент {new_client.full_name} id={new_client.id}') + return new_client + except SQLAlchemyError as e: + logger.error(f'Ошибка при создании клиента: {e}') + raise + + async def update_client(self, client: Client, data: ClientUpdateSchema, session: AsyncSession): + try: + update_data = data.model_dump(exclude_unset=True) + for key, value in update_data.items(): + setattr(client, key, value) + + session.add(client) + await session.commit() + await session.refresh(client) + logger.info(f'Изменён клиент id={client.id}') + return client + except SQLAlchemyError as e: + logger.error(f'Ошибка при изменении клиента id={client.id}: {e}') + raise + + async def delete_client(self, client: Client, session: AsyncSession): + try: + await session.delete(client) + await session.commit() + logger.info(f'Клиент {client.full_name} id = {client.id} удален') + except SQLAlchemyError as e: + logger.error(f'Ошибка при удалении клиента id={client.id}: {e}') + raise diff --git a/app/main.py b/app/main.py index d3ef36d..37fd67d 100644 --- a/app/main.py +++ b/app/main.py @@ -8,6 +8,8 @@ from app.core.init_db import create_first_superuser from app.core.logging import setup_logging from app.routers.users import user_router +from app.routers.clients import client_router + setup_logging() @@ -25,6 +27,7 @@ async def lifespan(app: FastAPI): app = FastAPI(title=settings.app_title, lifespan=lifespan) app.include_router(user_router) +app.include_router(client_router) if __name__ == "__main__": diff --git a/app/routers/clients.py b/app/routers/clients.py index e69de29..a876b71 100644 --- a/app/routers/clients.py +++ b/app/routers/clients.py @@ -0,0 +1,67 @@ +from typing import Annotated +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_session +from app.crud.clients import CRUDClient +from app.schemas.clients import ClientReadSchema, ClientCreateSchema, ClientUpdateSchema + + +client_router = APIRouter( + prefix='/clients', + tags=['Клиенты',] +) +crud = CRUDClient() + + +@client_router.get('/all') +async def get_all_clients(session: AsyncSession = Depends(get_session)) -> list[ClientReadSchema]: + clients = await crud.get_all_clients(session) + return clients + + +@client_router.get('/{client_id}') +async def get_client(client_id: int, session: AsyncSession = Depends(get_session)) -> ClientReadSchema: + client = await crud.get_client(client_id, session) + if not client: + raise HTTPException(404, 'Клиент не найден') + return client + + +@client_router.post('') +async def create_client( + data: Annotated[ClientCreateSchema, Depends()], session: AsyncSession = Depends(get_session) +) -> ClientCreateSchema: + try: + client = await crud.create_client(data, session) + return client + except Exception: + raise HTTPException(500, 'Ошибка при добавлении клиента') + + +@client_router.patch('/{client_id}') +async def update_client( + client_id: int, data: Annotated[ClientUpdateSchema, Depends()], session: AsyncSession = Depends(get_session) +) -> ClientReadSchema: + client = await crud.get_client(client_id, session) + if not client: + raise HTTPException(404, 'Клиент не найден') + try: + upd_client = await crud.update_client(client, data, session) + return upd_client + except Exception: + raise HTTPException(500, 'Ошибка при изменении клиента') + + +@client_router.delete('/{client_id}') +async def delete_cliennt( + client_id: int, session: AsyncSession = Depends(get_session) +): + client = await crud.get_client(client_id, session) + if not client: + raise HTTPException(404, 'Клиент не найден') + try: + await crud.delete_client(client, session) + return {"message": "Клиент удалён"} + except Exception: + raise HTTPException(500, 'Ошибка при удалении клиента') diff --git a/app/schemas/clients.py b/app/schemas/clients.py index d600ff6..509dc4e 100644 --- a/app/schemas/clients.py +++ b/app/schemas/clients.py @@ -24,7 +24,7 @@ class ClientCreateSchema(ClientBaseSchema): class ClientUpdateSchema(BaseModel): - full_name: str | None = None + full_name: str email: EmailStr | None = None phone: str | None = None manager_id: int | None = None From 7e9d556822cde7d167527b17f43ac2bcf78848d1 Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Thu, 11 Dec 2025 16:14:54 +0300 Subject: [PATCH 11/34] isort black flake8 --- app/crud/clients.py | 30 +++++++++++++------------ app/main.py | 3 +-- app/routers/clients.py | 51 ++++++++++++++++++++++++------------------ app/schemas/clients.py | 2 +- 4 files changed, 47 insertions(+), 39 deletions(-) diff --git a/app/crud/clients.py b/app/crud/clients.py index d32cce3..ebd9398 100644 --- a/app/crud/clients.py +++ b/app/crud/clients.py @@ -1,12 +1,12 @@ import logging + from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.ext.asyncio import AsyncSession from app.models.clients import Client from app.schemas.clients import ClientCreateSchema, ClientUpdateSchema - logger = logging.getLogger(__name__) @@ -16,10 +16,10 @@ async def get_all_clients(self, session: AsyncSession): query = select(Client) result = await session.execute(query) clients = result.scalars().all() - logger.info('Получен список клиентов') + logger.info("Получен список клиентов") return clients except SQLAlchemyError as e: - logger.error(f'Ошибка приполучении списка клиентов: {e}') + logger.error(f"Ошибка приполучении списка клиентов: {e}") raise async def get_client(self, client_id: int, session: AsyncSession): @@ -28,12 +28,12 @@ async def get_client(self, client_id: int, session: AsyncSession): result = await session.execute(query) client = result.scalars().first() if client: - logger.info(f'Получен клиент id = {client_id}') + logger.info(f"Получен клиент id = {client_id}") else: - logger.warning(f'Клиеент id = {client_id} не найден') + logger.warning(f"Клиеент id = {client_id} не найден") return client except SQLAlchemyError as e: - logger.error(f'Ошибка приполучении клиента: {e}') + logger.error(f"Ошибка приполучении клиента: {e}") raise async def create_client(self, data: ClientCreateSchema, session: AsyncSession): @@ -42,13 +42,15 @@ async def create_client(self, data: ClientCreateSchema, session: AsyncSession): session.add(new_client) await session.flush() await session.commit() - logger.info(f'Создан клиент {new_client.full_name} id={new_client.id}') + logger.info(f"Создан клиент {new_client.full_name} id={new_client.id}") return new_client except SQLAlchemyError as e: - logger.error(f'Ошибка при создании клиента: {e}') + logger.error(f"Ошибка при создании клиента: {e}") raise - async def update_client(self, client: Client, data: ClientUpdateSchema, session: AsyncSession): + async def update_client( + self, client: Client, data: ClientUpdateSchema, session: AsyncSession + ): try: update_data = data.model_dump(exclude_unset=True) for key, value in update_data.items(): @@ -57,17 +59,17 @@ async def update_client(self, client: Client, data: ClientUpdateSchema, session: session.add(client) await session.commit() await session.refresh(client) - logger.info(f'Изменён клиент id={client.id}') + logger.info(f"Изменён клиент id={client.id}") return client except SQLAlchemyError as e: - logger.error(f'Ошибка при изменении клиента id={client.id}: {e}') + logger.error(f"Ошибка при изменении клиента id={client.id}: {e}") raise async def delete_client(self, client: Client, session: AsyncSession): try: await session.delete(client) await session.commit() - logger.info(f'Клиент {client.full_name} id = {client.id} удален') + logger.info(f"Клиент {client.full_name} id = {client.id} удален") except SQLAlchemyError as e: - logger.error(f'Ошибка при удалении клиента id={client.id}: {e}') + logger.error(f"Ошибка при удалении клиента id={client.id}: {e}") raise diff --git a/app/main.py b/app/main.py index 37fd67d..fe740bc 100644 --- a/app/main.py +++ b/app/main.py @@ -7,9 +7,8 @@ from app.core.config import settings from app.core.init_db import create_first_superuser from app.core.logging import setup_logging -from app.routers.users import user_router from app.routers.clients import client_router - +from app.routers.users import user_router setup_logging() diff --git a/app/routers/clients.py b/app/routers/clients.py index a876b71..7b86d86 100644 --- a/app/routers/clients.py +++ b/app/routers/clients.py @@ -1,67 +1,74 @@ from typing import Annotated + from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_session from app.crud.clients import CRUDClient -from app.schemas.clients import ClientReadSchema, ClientCreateSchema, ClientUpdateSchema - +from app.schemas.clients import ClientCreateSchema, ClientReadSchema, ClientUpdateSchema client_router = APIRouter( - prefix='/clients', - tags=['Клиенты',] + prefix="/clients", + tags=[ + "Клиенты", + ], ) crud = CRUDClient() -@client_router.get('/all') -async def get_all_clients(session: AsyncSession = Depends(get_session)) -> list[ClientReadSchema]: +@client_router.get("/all") +async def get_all_clients( + session: AsyncSession = Depends(get_session), +) -> list[ClientReadSchema]: clients = await crud.get_all_clients(session) return clients -@client_router.get('/{client_id}') -async def get_client(client_id: int, session: AsyncSession = Depends(get_session)) -> ClientReadSchema: +@client_router.get("/{client_id}") +async def get_client( + client_id: int, session: AsyncSession = Depends(get_session) +) -> ClientReadSchema: client = await crud.get_client(client_id, session) if not client: - raise HTTPException(404, 'Клиент не найден') + raise HTTPException(404, "Клиент не найден") return client -@client_router.post('') +@client_router.post("") async def create_client( - data: Annotated[ClientCreateSchema, Depends()], session: AsyncSession = Depends(get_session) + data: Annotated[ClientCreateSchema, Depends()], + session: AsyncSession = Depends(get_session), ) -> ClientCreateSchema: try: client = await crud.create_client(data, session) return client except Exception: - raise HTTPException(500, 'Ошибка при добавлении клиента') + raise HTTPException(500, "Ошибка при добавлении клиента") -@client_router.patch('/{client_id}') +@client_router.patch("/{client_id}") async def update_client( - client_id: int, data: Annotated[ClientUpdateSchema, Depends()], session: AsyncSession = Depends(get_session) + client_id: int, + data: Annotated[ClientUpdateSchema, Depends()], + session: AsyncSession = Depends(get_session), ) -> ClientReadSchema: client = await crud.get_client(client_id, session) if not client: - raise HTTPException(404, 'Клиент не найден') + raise HTTPException(404, "Клиент не найден") try: upd_client = await crud.update_client(client, data, session) return upd_client except Exception: - raise HTTPException(500, 'Ошибка при изменении клиента') + raise HTTPException(500, "Ошибка при изменении клиента") -@client_router.delete('/{client_id}') -async def delete_cliennt( - client_id: int, session: AsyncSession = Depends(get_session) -): +@client_router.delete("/{client_id}") +async def delete_cliennt(client_id: int, session: AsyncSession = Depends(get_session)): client = await crud.get_client(client_id, session) if not client: - raise HTTPException(404, 'Клиент не найден') + raise HTTPException(404, "Клиент не найден") try: await crud.delete_client(client, session) return {"message": "Клиент удалён"} except Exception: - raise HTTPException(500, 'Ошибка при удалении клиента') + raise HTTPException(500, "Ошибка при удалении клиента") diff --git a/app/schemas/clients.py b/app/schemas/clients.py index 509dc4e..4b003ea 100644 --- a/app/schemas/clients.py +++ b/app/schemas/clients.py @@ -24,7 +24,7 @@ class ClientCreateSchema(ClientBaseSchema): class ClientUpdateSchema(BaseModel): - full_name: str + full_name: str email: EmailStr | None = None phone: str | None = None manager_id: int | None = None From 870ecd9cbf4a060b47cf828116c15ae897638b68 Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Thu, 11 Dec 2025 22:03:50 +0300 Subject: [PATCH 12/34] user_mannager --- alembic/versions/102dd2a4a5f4_user_client.py | 57 +++++++++++++++++++ alembic/versions/26033c6d880e_user_client.py | 32 +++++++++++ alembic/versions/3530c3a8f1a4_user_client.py | 60 -------------------- app/core/init_db.py | 4 +- app/crud/clients.py | 10 +++- app/crud/users.py | 22 +++++++ app/routers/clients.py | 9 ++- app/routers/users.py | 22 +++++-- app/users/manager.py | 13 ++++- app/users/models.py | 6 +- app/users/schemas.py | 4 +- app/users/utils.py | 10 ---- 12 files changed, 159 insertions(+), 90 deletions(-) create mode 100644 alembic/versions/102dd2a4a5f4_user_client.py create mode 100644 alembic/versions/26033c6d880e_user_client.py delete mode 100644 alembic/versions/3530c3a8f1a4_user_client.py create mode 100644 app/crud/users.py delete mode 100644 app/users/utils.py diff --git a/alembic/versions/102dd2a4a5f4_user_client.py b/alembic/versions/102dd2a4a5f4_user_client.py new file mode 100644 index 0000000..4d67dca --- /dev/null +++ b/alembic/versions/102dd2a4a5f4_user_client.py @@ -0,0 +1,57 @@ +"""User Client + +Revision ID: 102dd2a4a5f4 +Revises: +Create Date: 2025-12-11 19:18:27.801787 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '102dd2a4a5f4' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('users', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('username', sa.String(), nullable=False), + sa.Column('first_name', sa.String(), nullable=True), + sa.Column('last_name', sa.String(), nullable=True), + sa.Column('phone', sa.Integer(), nullable=True), + sa.Column('role', sa.Enum('manager', 'admin', name='userrole'), nullable=False), + sa.Column('email', sa.String(length=320), nullable=False), + sa.Column('hashed_password', sa.String(length=1024), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('is_superuser', sa.Boolean(), nullable=False), + sa.Column('is_verified', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) + op.create_table('clients', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('full_name', sa.String(length=255), nullable=False), + sa.Column('email', sa.String(length=255), nullable=True), + sa.Column('phone', sa.String(length=255), nullable=True), + sa.Column('manager_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['manager_id'], ['users.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('clients') + op.drop_index(op.f('ix_users_email'), table_name='users') + op.drop_table('users') + # ### end Alembic commands ### diff --git a/alembic/versions/26033c6d880e_user_client.py b/alembic/versions/26033c6d880e_user_client.py new file mode 100644 index 0000000..4defd59 --- /dev/null +++ b/alembic/versions/26033c6d880e_user_client.py @@ -0,0 +1,32 @@ +"""User Client + +Revision ID: 26033c6d880e +Revises: 102dd2a4a5f4 +Create Date: 2025-12-11 21:02:37.042921 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '26033c6d880e' +down_revision: Union[str, Sequence[str], None] = '102dd2a4a5f4' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/alembic/versions/3530c3a8f1a4_user_client.py b/alembic/versions/3530c3a8f1a4_user_client.py deleted file mode 100644 index ca9f403..0000000 --- a/alembic/versions/3530c3a8f1a4_user_client.py +++ /dev/null @@ -1,60 +0,0 @@ -"""User Client - -Revision ID: 3530c3a8f1a4 -Revises: -Create Date: 2025-12-10 11:58:20.987526 - -""" - -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = "3530c3a8f1a4" -down_revision: Union[str, Sequence[str], None] = None -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.create_table( - "users", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("username", sa.String(), nullable=False), - sa.Column("first_name", sa.String(), nullable=True), - sa.Column("last_name", sa.String(), nullable=True), - sa.Column("phone", sa.Integer(), nullable=True), - sa.Column("role", sa.Enum("user", "admin", name="userrole"), nullable=False), - sa.Column("email", sa.String(length=320), nullable=False), - sa.Column("hashed_password", sa.String(length=1024), nullable=False), - sa.Column("is_active", sa.Boolean(), nullable=False), - sa.Column("is_superuser", sa.Boolean(), nullable=False), - sa.Column("is_verified", sa.Boolean(), nullable=False), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True) - op.create_table( - "clients", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("full_name", sa.String(length=255), nullable=True), - sa.Column("email", sa.String(length=255), nullable=True), - sa.Column("phone", sa.String(length=255), nullable=True), - sa.Column("manager_id", sa.Integer(), nullable=True), - sa.ForeignKeyConstraint(["manager_id"], ["users.id"], ondelete="SET NULL"), - sa.PrimaryKeyConstraint("id"), - ) - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table("clients") - op.drop_index(op.f("ix_users_email"), table_name="users") - op.drop_table("users") - # ### end Alembic commands ### diff --git a/app/core/init_db.py b/app/core/init_db.py index 2612b91..ba97990 100644 --- a/app/core/init_db.py +++ b/app/core/init_db.py @@ -9,7 +9,7 @@ from app.core.database import get_session from app.users.manager import get_user_manager from app.users.schemas import UserCreate -from app.users.utils import get_user_db +from app.users.manager import get_user_db logger = logging.getLogger(__name__) @@ -23,7 +23,7 @@ async def create_user( email: EmailStr, password: str, is_superuser: bool = False, - role: Literal["admin", "user"] = "user", + role: Literal["admin", "manager"] = "manager", ): try: async with get_session_context() as session: diff --git a/app/crud/clients.py b/app/crud/clients.py index ebd9398..957feba 100644 --- a/app/crud/clients.py +++ b/app/crud/clients.py @@ -3,6 +3,7 @@ from sqlalchemy import select from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload from app.models.clients import Client from app.schemas.clients import ClientCreateSchema, ClientUpdateSchema @@ -11,9 +12,12 @@ class CRUDClient: - async def get_all_clients(self, session: AsyncSession): + + async def get_all_clients(self, session: AsyncSession, user): try: - query = select(Client) + query = select(Client).options(selectinload(Client.manager)) + if user.role == "manager": + query = query.where(Client.manager_id == user.id) result = await session.execute(query) clients = result.scalars().all() logger.info("Получен список клиентов") @@ -24,7 +28,7 @@ async def get_all_clients(self, session: AsyncSession): async def get_client(self, client_id: int, session: AsyncSession): try: - query = select(Client).where(Client.id == client_id) + query = select(Client).where(Client.id == client_id).options(selectinload(Client.manager)) result = await session.execute(query) client = result.scalars().first() if client: diff --git a/app/crud/users.py b/app/crud/users.py new file mode 100644 index 0000000..84f80bf --- /dev/null +++ b/app/crud/users.py @@ -0,0 +1,22 @@ +import logging + +from sqlalchemy import select +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.users.models import User + +logger = logging.getLogger(__name__) + + +class CRUDUser: + try: + async def get_all_users(self, session: AsyncSession): + query = select(User) + result = await session.execute(query) + users = result.scalars().all() + logger.info('Получен список пользователей') + return users + except SQLAlchemyError as e: + logger.error(f"Ошибка приполучении списка пользователей: {e}") + raise diff --git a/app/routers/clients.py b/app/routers/clients.py index 7b86d86..5a9048e 100644 --- a/app/routers/clients.py +++ b/app/routers/clients.py @@ -6,6 +6,8 @@ from app.core.database import get_session from app.crud.clients import CRUDClient from app.schemas.clients import ClientCreateSchema, ClientReadSchema, ClientUpdateSchema +from app.users.models import User +from app.users.manager import current_user client_router = APIRouter( prefix="/clients", @@ -19,8 +21,11 @@ @client_router.get("/all") async def get_all_clients( session: AsyncSession = Depends(get_session), + current_user: User = Depends(current_user) ) -> list[ClientReadSchema]: - clients = await crud.get_all_clients(session) + clients = await crud.get_all_clients(session, current_user) + if not clients: + raise HTTPException(404, "Нет ниодного клиента") return clients @@ -69,6 +74,6 @@ async def delete_cliennt(client_id: int, session: AsyncSession = Depends(get_ses raise HTTPException(404, "Клиент не найден") try: await crud.delete_client(client, session) - return {"message": "Клиент удалён"} + return {"detail": "Клиент удалён"} except Exception: raise HTTPException(500, "Ошибка при удалении клиента") diff --git a/app/routers/users.py b/app/routers/users.py index 51d8eaa..1876115 100644 --- a/app/routers/users.py +++ b/app/routers/users.py @@ -1,23 +1,35 @@ -from typing import Annotated - from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession +from app.core.database import get_session +from app.crud.users import CRUDUser from app.users.manager import auth_backend, fastapi_users from app.users.schemas import UserCreate, UserRead, UserUpdate +from app.users.manager import current_superuser + user_router = APIRouter( tags=["Пользователи"], ) +crud = CRUDUser() + + +@user_router.get('/users/all') +async def get_all_users(session: AsyncSession = Depends(get_session), ) -> list[UserRead]: + users = await crud.get_all_users(session) + return users + user_router.include_router( fastapi_users.get_auth_router(auth_backend), prefix="/auth/jwt", ) user_router.include_router( - fastapi_users.get_register_router(UserRead, Annotated[UserCreate, Depends()]), + fastapi_users.get_register_router(UserRead, UserCreate), prefix="/auth", + dependencies=[Depends(current_superuser)], ) user_router.include_router( - fastapi_users.get_users_router(UserRead, Annotated[UserUpdate, Depends()]), - prefix="/users", + fastapi_users.get_users_router(UserRead, UserUpdate), + prefix="/users" ) diff --git a/app/users/manager.py b/app/users/manager.py index 40b7f73..7de5fa8 100644 --- a/app/users/manager.py +++ b/app/users/manager.py @@ -10,13 +10,16 @@ BearerTransport, JWTStrategy, ) +from sqlalchemy.ext.asyncio import AsyncSession + +from fastapi_users_db_sqlalchemy import SQLAlchemyUserDatabase from app.core.config import settings from app.users.models import User from app.users.schemas import UserCreate -from app.users.utils import get_user_db +from app.core.database import get_session -bearer_tramsport = BearerTransport(tokenUrl="auth/jwt/login") +bearer_transport = BearerTransport(tokenUrl="auth/jwt/login") def get_jwt_strategy() -> JWTStrategy: @@ -24,7 +27,7 @@ def get_jwt_strategy() -> JWTStrategy: auth_backend = AuthenticationBackend( - name="jwt", transport=bearer_tramsport, get_strategy=get_jwt_strategy + name="jwt", transport=bearer_transport, get_strategy=get_jwt_strategy ) @@ -43,6 +46,10 @@ async def on_after_register(self, user: User, request: Request | None): print(f"Пользователь {user.username} зарегистрирован") +async def get_user_db(session: AsyncSession = Depends(get_session)): + yield SQLAlchemyUserDatabase(session, User) + + async def get_user_manager(user_db=Depends(get_user_db)): yield UserManager(user_db) diff --git a/app/users/models.py b/app/users/models.py index 77e8427..d746d4e 100644 --- a/app/users/models.py +++ b/app/users/models.py @@ -12,7 +12,7 @@ class UserRole(str, enum.Enum): - user = "user" + manager = "manager" admin = "admin" @@ -25,6 +25,6 @@ class User(SQLAlchemyBaseUserTable[int], Base): last_name: Mapped[str | None] = mapped_column(nullable=True) phone: Mapped[int | None] = mapped_column(nullable=True) role: Mapped[UserRole] = mapped_column( - Enum(UserRole), default=UserRole.user, nullable=False + Enum(UserRole), default=UserRole.manager, nullable=False ) - clients: Mapped[list["Client"]] = relationship(back_populates="manager") + clients: Mapped[list["Client"] | None] = relationship(back_populates="manager", lazy="selectin") diff --git a/app/users/schemas.py b/app/users/schemas.py index 3a1347b..8fa93b8 100644 --- a/app/users/schemas.py +++ b/app/users/schemas.py @@ -20,7 +20,7 @@ class UserCreate(schemas.BaseUserCreate): first_name: str | None = None last_name: str | None = None phone: int | None = None - role: UserRole = UserRole.user + role: UserRole = UserRole.manager clients: list | None = None @@ -30,5 +30,5 @@ class UserUpdate(schemas.BaseUserUpdate): first_name: str | None = None last_name: str | None = None phone: int | None = None - role: UserRole = UserRole.user + role: UserRole = UserRole.manager clients: list | None = None diff --git a/app/users/utils.py b/app/users/utils.py deleted file mode 100644 index 29cf5bf..0000000 --- a/app/users/utils.py +++ /dev/null @@ -1,10 +0,0 @@ -from fastapi import Depends -from fastapi_users_db_sqlalchemy import SQLAlchemyUserDatabase -from sqlalchemy.ext.asyncio import AsyncSession - -from app.core.database import get_session -from app.users.models import User - - -async def get_user_db(session: AsyncSession = Depends(get_session)): - yield SQLAlchemyUserDatabase(session, User) From f949f68f37e51ae8d021339347c1e7a0747a8876 Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Sat, 13 Dec 2025 16:02:11 +0300 Subject: [PATCH 13/34] =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=B0=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5=D0=BB?= =?UTF-8?q?=D0=B5=D0=B9,=20=D0=B2=D0=B0=D0=BB=D0=B8=D0=B4=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=BF=D1=80=D0=B8=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B8=20=D0=BA=D0=BB=D0=B8=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/crud/clients.py | 13 ++++++++-- app/crud/users.py | 31 ++++++++++++++++++----- app/routers/clients.py | 47 ++++++++++++++++++++--------------- app/routers/users.py | 27 +++++++++++++++++--- app/users/schemas.py | 6 ++++- app/validators/__init__.py | 0 app/validators/client.py | 50 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 142 insertions(+), 32 deletions(-) create mode 100644 app/validators/__init__.py create mode 100644 app/validators/client.py diff --git a/app/crud/clients.py b/app/crud/clients.py index 957feba..00b2898 100644 --- a/app/crud/clients.py +++ b/app/crud/clients.py @@ -7,6 +7,7 @@ from app.models.clients import Client from app.schemas.clients import ClientCreateSchema, ClientUpdateSchema +from app.validators.client import validate_unique_email_client, validate_unique_phone_client, validate_unique_full_name_client logger = logging.getLogger(__name__) @@ -26,9 +27,11 @@ async def get_all_clients(self, session: AsyncSession, user): logger.error(f"Ошибка приполучении списка клиентов: {e}") raise - async def get_client(self, client_id: int, session: AsyncSession): + async def get_client(self, client_id: int, session: AsyncSession, user): try: query = select(Client).where(Client.id == client_id).options(selectinload(Client.manager)) + if user.role == "manager": + query = query.where(Client.manager_id == user.id) result = await session.execute(query) client = result.scalars().first() if client: @@ -42,6 +45,12 @@ async def get_client(self, client_id: int, session: AsyncSession): async def create_client(self, data: ClientCreateSchema, session: AsyncSession): try: + if data.email: + await validate_unique_email_client(session, data.email) + if data.phone: + await validate_unique_phone_client(session, data.phone) + if data.full_name: + await validate_unique_full_name_client(session, data.full_name) new_client = Client(**data.model_dump()) session.add(new_client) await session.flush() @@ -53,7 +62,7 @@ async def create_client(self, data: ClientCreateSchema, session: AsyncSession): raise async def update_client( - self, client: Client, data: ClientUpdateSchema, session: AsyncSession + self, client: Client, data: ClientUpdateSchema, session: AsyncSession, ): try: update_data = data.model_dump(exclude_unset=True) diff --git a/app/crud/users.py b/app/crud/users.py index 84f80bf..b032b2c 100644 --- a/app/crud/users.py +++ b/app/crud/users.py @@ -3,20 +3,39 @@ from sqlalchemy import select from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload +from app.models.clients import Client from app.users.models import User logger = logging.getLogger(__name__) class CRUDUser: - try: - async def get_all_users(self, session: AsyncSession): - query = select(User) + async def get_user_by_id(swlf, session: AsyncSession, user_id: int) -> User | None: + query = select(User).where(User.id == user_id) + result = await session.execute(query) + user = result.scalars().all() + return user + + async def get_all_users(self, session: AsyncSession): + try: + query = select(User).options(selectinload(User.clients)) result = await session.execute(query) users = result.scalars().all() logger.info('Получен список пользователей') return users - except SQLAlchemyError as e: - logger.error(f"Ошибка приполучении списка пользователей: {e}") - raise + except SQLAlchemyError as e: + logger.error(f"Ошибка приполучении списка пользователей: {e}") + raise + + async def get_all_user_clients(self, session: AsyncSession, user_id): + try: + query = select(Client).where(Client.manager_id == user_id).options(selectinload(Client.manager)) + result = await session.execute(query) + clients = result.scalars().all() + logger.info("Получен список клиентов менеджера") + return clients + except SQLAlchemyError as e: + logger.error(f"Ошибка приполучении списка клиентов менеджера: {e}") + raise diff --git a/app/routers/clients.py b/app/routers/clients.py index 5a9048e..3c2a1fe 100644 --- a/app/routers/clients.py +++ b/app/routers/clients.py @@ -1,13 +1,13 @@ from typing import Annotated -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_session from app.crud.clients import CRUDClient from app.schemas.clients import ClientCreateSchema, ClientReadSchema, ClientUpdateSchema from app.users.models import User -from app.users.manager import current_user +from app.users.manager import current_user, current_superuser client_router = APIRouter( prefix="/clients", @@ -18,28 +18,29 @@ crud = CRUDClient() -@client_router.get("/all") +@client_router.get("/all", dependencies=[Depends(current_user)]) async def get_all_clients( session: AsyncSession = Depends(get_session), current_user: User = Depends(current_user) ) -> list[ClientReadSchema]: clients = await crud.get_all_clients(session, current_user) if not clients: - raise HTTPException(404, "Нет ниодного клиента") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Нет ниодного клиента") return clients -@client_router.get("/{client_id}") +@client_router.get("/{client_id}", dependencies=[Depends(current_user)]) async def get_client( - client_id: int, session: AsyncSession = Depends(get_session) + client_id: int, session: AsyncSession = Depends(get_session), + current_user: User = Depends(current_user) ) -> ClientReadSchema: - client = await crud.get_client(client_id, session) + client = await crud.get_client(client_id, session, current_user) if not client: - raise HTTPException(404, "Клиент не найден") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Клиент не найден") return client -@client_router.post("") +@client_router.post("", dependencies=[Depends(current_user)]) async def create_client( data: Annotated[ClientCreateSchema, Depends()], session: AsyncSession = Depends(get_session), @@ -47,33 +48,39 @@ async def create_client( try: client = await crud.create_client(data, session) return client + except Exception as e: + raise e except Exception: - raise HTTPException(500, "Ошибка при добавлении клиента") + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Ошибка при добавлении клиента") -@client_router.patch("/{client_id}") +@client_router.patch("/{client_id}", dependencies=[Depends(current_user)]) async def update_client( client_id: int, data: Annotated[ClientUpdateSchema, Depends()], - session: AsyncSession = Depends(get_session), + session: AsyncSession = Depends(get_session) ) -> ClientReadSchema: - client = await crud.get_client(client_id, session) + client = await crud.get_client(client_id, session, current_user) if not client: - raise HTTPException(404, "Клиент не найден") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Клиент не найден") try: upd_client = await crud.update_client(client, data, session) return upd_client except Exception: - raise HTTPException(500, "Ошибка при изменении клиента") + raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Ошибка при изменении клиента") -@client_router.delete("/{client_id}") -async def delete_cliennt(client_id: int, session: AsyncSession = Depends(get_session)): - client = await crud.get_client(client_id, session) +@client_router.delete("/{client_id}", dependencies=[Depends(current_superuser)]) +async def delete_cliennt( + client_id: int, + session: AsyncSession = Depends(get_session), + user: User = Depends(current_superuser) +): + client = await crud.get_client(client_id, session, user) if not client: - raise HTTPException(404, "Клиент не найден") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Клиент не найден") try: await crud.delete_client(client, session) return {"detail": "Клиент удалён"} except Exception: - raise HTTPException(500, "Ошибка при удалении клиента") + raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Ошибка при удалении клиента") diff --git a/app/routers/users.py b/app/routers/users.py index 1876115..82d1bd3 100644 --- a/app/routers/users.py +++ b/app/routers/users.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_session @@ -6,6 +6,7 @@ from app.users.manager import auth_backend, fastapi_users from app.users.schemas import UserCreate, UserRead, UserUpdate from app.users.manager import current_superuser +from app.schemas.clients import ClientReadSchema user_router = APIRouter( @@ -15,11 +16,31 @@ crud = CRUDUser() -@user_router.get('/users/all') -async def get_all_users(session: AsyncSession = Depends(get_session), ) -> list[UserRead]: +@user_router.get( + '/users/all', + dependencies=[Depends(current_superuser)] +) +async def get_all_users(session: AsyncSession = Depends(get_session)) -> list[UserRead]: users = await crud.get_all_users(session) return users + +@user_router.get( + '/users/{user_id}/clients', + dependencies=[Depends(current_superuser)] +) +async def get_all_user_clients( + user_id: int, + session: AsyncSession = Depends(get_session) +) -> list[ClientReadSchema]: + user = await crud.get_user_by_id(session, user_id) + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Пользователь не найден") + clients = await crud.get_all_user_clients(session, user_id) + if not clients: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="У менеджера нет клиентов") + return clients + user_router.include_router( fastapi_users.get_auth_router(auth_backend), prefix="/auth/jwt", diff --git a/app/users/schemas.py b/app/users/schemas.py index 8fa93b8..108766c 100644 --- a/app/users/schemas.py +++ b/app/users/schemas.py @@ -1,6 +1,8 @@ from fastapi_users import schemas +from pydantic import ConfigDict from app.users.models import UserRole +from app.schemas.clients import ClientReadSchema class UserRead(schemas.BaseUser[int]): @@ -11,7 +13,9 @@ class UserRead(schemas.BaseUser[int]): last_name: str | None = None phone: int | None = None role: UserRole - clients: list | None = None + clients: list[ClientReadSchema] | None = None + + model_config = ConfigDict(from_attributes=True) class UserCreate(schemas.BaseUserCreate): diff --git a/app/validators/__init__.py b/app/validators/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/validators/client.py b/app/validators/client.py new file mode 100644 index 0000000..90a6388 --- /dev/null +++ b/app/validators/client.py @@ -0,0 +1,50 @@ +from fastapi import HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.clients import Client + + +async def validate_unique_email_client( + session: AsyncSession, + email: str +) -> None: + "Проверка уникальности email клиента" + result = await session.execute( + select(Client).where(Client.email == email) + ) + if result.scalars().first(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Клиент с таким email уже существует' + ) + + +async def validate_unique_phone_client( + session: AsyncSession, + phone: str +) -> None: + "Проверка уникальности телефона клиента" + result = await session.execute( + select(Client).where(Client.phone == phone) + ) + if result.scalars().first(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Клиент с таким телефоном уже существует' + ) + + +async def validate_unique_full_name_client( + session: AsyncSession, + full_name: str +) -> None: + "Проверка уникальности имени клиента" + result = await session.execute( + select(Client).where(Client.full_name == full_name) + ) + if result.scalars().first(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Клиент с таким именем уже существует' + ) From a1dfc04852e8d9b3ccf4a8730bee7c8f2d3b5f7d Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Sun, 14 Dec 2025 12:18:02 +0300 Subject: [PATCH 14/34] =?UTF-8?q?=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB=D1=8C=20?= =?UTF-8?q?=D1=81=D0=B4=D0=B5=D0=BB=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- alembic/versions/102dd2a4a5f4_user_client.py | 57 ------------ ...0e_user_client.py => 676e8b97681b_init.py} | 13 +-- alembic/versions/b8566135f7ca_init.py | 90 +++++++++++++++++++ app/core/base.py | 1 + app/core/init_db.py | 3 +- app/crud/clients.py | 17 +++- app/crud/deals.py | 0 app/crud/users.py | 8 +- app/models/clients.py | 2 + app/models/deals.py | 48 ++++++++++ app/routers/clients.py | 42 ++++++--- app/routers/deals.py | 0 app/routers/users.py | 31 +++---- app/schemas/deals.py | 0 app/users/manager.py | 5 +- app/users/models.py | 8 +- app/users/schemas.py | 2 +- app/validators/client.py | 50 ----------- app/validators/clients.py | 35 ++++++++ app/validators/deals.py | 0 pyproject.toml | 1 + 21 files changed, 257 insertions(+), 156 deletions(-) delete mode 100644 alembic/versions/102dd2a4a5f4_user_client.py rename alembic/versions/{26033c6d880e_user_client.py => 676e8b97681b_init.py} (73%) create mode 100644 alembic/versions/b8566135f7ca_init.py create mode 100644 app/crud/deals.py create mode 100644 app/models/deals.py create mode 100644 app/routers/deals.py create mode 100644 app/schemas/deals.py delete mode 100644 app/validators/client.py create mode 100644 app/validators/clients.py create mode 100644 app/validators/deals.py diff --git a/alembic/versions/102dd2a4a5f4_user_client.py b/alembic/versions/102dd2a4a5f4_user_client.py deleted file mode 100644 index 4d67dca..0000000 --- a/alembic/versions/102dd2a4a5f4_user_client.py +++ /dev/null @@ -1,57 +0,0 @@ -"""User Client - -Revision ID: 102dd2a4a5f4 -Revises: -Create Date: 2025-12-11 19:18:27.801787 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = '102dd2a4a5f4' -down_revision: Union[str, Sequence[str], None] = None -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('users', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('username', sa.String(), nullable=False), - sa.Column('first_name', sa.String(), nullable=True), - sa.Column('last_name', sa.String(), nullable=True), - sa.Column('phone', sa.Integer(), nullable=True), - sa.Column('role', sa.Enum('manager', 'admin', name='userrole'), nullable=False), - sa.Column('email', sa.String(length=320), nullable=False), - sa.Column('hashed_password', sa.String(length=1024), nullable=False), - sa.Column('is_active', sa.Boolean(), nullable=False), - sa.Column('is_superuser', sa.Boolean(), nullable=False), - sa.Column('is_verified', sa.Boolean(), nullable=False), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) - op.create_table('clients', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('full_name', sa.String(length=255), nullable=False), - sa.Column('email', sa.String(length=255), nullable=True), - sa.Column('phone', sa.String(length=255), nullable=True), - sa.Column('manager_id', sa.Integer(), nullable=True), - sa.ForeignKeyConstraint(['manager_id'], ['users.id'], ondelete='SET NULL'), - sa.PrimaryKeyConstraint('id') - ) - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('clients') - op.drop_index(op.f('ix_users_email'), table_name='users') - op.drop_table('users') - # ### end Alembic commands ### diff --git a/alembic/versions/26033c6d880e_user_client.py b/alembic/versions/676e8b97681b_init.py similarity index 73% rename from alembic/versions/26033c6d880e_user_client.py rename to alembic/versions/676e8b97681b_init.py index 4defd59..430ac83 100644 --- a/alembic/versions/26033c6d880e_user_client.py +++ b/alembic/versions/676e8b97681b_init.py @@ -1,10 +1,11 @@ -"""User Client +"""init -Revision ID: 26033c6d880e -Revises: 102dd2a4a5f4 -Create Date: 2025-12-11 21:02:37.042921 +Revision ID: 676e8b97681b +Revises: b8566135f7ca +Create Date: 2025-12-14 12:14:00.853282 """ + from typing import Sequence, Union from alembic import op @@ -12,8 +13,8 @@ # revision identifiers, used by Alembic. -revision: str = '26033c6d880e' -down_revision: Union[str, Sequence[str], None] = '102dd2a4a5f4' +revision: str = "676e8b97681b" +down_revision: Union[str, Sequence[str], None] = "b8566135f7ca" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None diff --git a/alembic/versions/b8566135f7ca_init.py b/alembic/versions/b8566135f7ca_init.py new file mode 100644 index 0000000..7b47194 --- /dev/null +++ b/alembic/versions/b8566135f7ca_init.py @@ -0,0 +1,90 @@ +"""init + +Revision ID: b8566135f7ca +Revises: +Create Date: 2025-12-14 12:09:44.852044 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "b8566135f7ca" +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "users", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("username", sa.String(), nullable=False), + sa.Column("first_name", sa.String(), nullable=True), + sa.Column("last_name", sa.String(), nullable=True), + sa.Column("phone", sa.Integer(), nullable=True), + sa.Column("role", sa.Enum("manager", "admin", name="userrole"), nullable=False), + sa.Column("email", sa.String(length=320), nullable=False), + sa.Column("hashed_password", sa.String(length=1024), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False), + sa.Column("is_superuser", sa.Boolean(), nullable=False), + sa.Column("is_verified", sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True) + op.create_table( + "clients", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("full_name", sa.String(length=255), nullable=False), + sa.Column("email", sa.String(length=255), nullable=True), + sa.Column("phone", sa.String(length=255), nullable=True), + sa.Column("manager_id", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(["manager_id"], ["users.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "deals", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("description", sa.String(), nullable=True), + sa.Column( + "status", + sa.Enum("new", "in_progress", "success", "canceled", name="statusdeal"), + nullable=False, + ), + sa.Column("price", sa.Float(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column("manager_id", sa.Integer(), nullable=True), + sa.Column("client_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(["client_id"], ["clients.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["manager_id"], ["users.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("deals") + op.drop_table("clients") + op.drop_index(op.f("ix_users_email"), table_name="users") + op.drop_table("users") + # ### end Alembic commands ### diff --git a/app/core/base.py b/app/core/base.py index d288b08..9dfe36f 100644 --- a/app/core/base.py +++ b/app/core/base.py @@ -2,4 +2,5 @@ from app.core.database import Base # noqa from app.models.clients import Client # noqa +from app.models.deals import Deal # noqa from app.users.models import User # noqa diff --git a/app/core/init_db.py b/app/core/init_db.py index ba97990..3f0c6fd 100644 --- a/app/core/init_db.py +++ b/app/core/init_db.py @@ -7,9 +7,8 @@ from app.core.config import settings from app.core.database import get_session -from app.users.manager import get_user_manager +from app.users.manager import get_user_db, get_user_manager from app.users.schemas import UserCreate -from app.users.manager import get_user_db logger = logging.getLogger(__name__) diff --git a/app/crud/clients.py b/app/crud/clients.py index 00b2898..5e2c354 100644 --- a/app/crud/clients.py +++ b/app/crud/clients.py @@ -7,7 +7,11 @@ from app.models.clients import Client from app.schemas.clients import ClientCreateSchema, ClientUpdateSchema -from app.validators.client import validate_unique_email_client, validate_unique_phone_client, validate_unique_full_name_client +from app.validators.clients import ( + validate_unique_email_client, + validate_unique_full_name_client, + validate_unique_phone_client, +) logger = logging.getLogger(__name__) @@ -29,7 +33,11 @@ async def get_all_clients(self, session: AsyncSession, user): async def get_client(self, client_id: int, session: AsyncSession, user): try: - query = select(Client).where(Client.id == client_id).options(selectinload(Client.manager)) + query = ( + select(Client) + .where(Client.id == client_id) + .options(selectinload(Client.manager)) + ) if user.role == "manager": query = query.where(Client.manager_id == user.id) result = await session.execute(query) @@ -62,7 +70,10 @@ async def create_client(self, data: ClientCreateSchema, session: AsyncSession): raise async def update_client( - self, client: Client, data: ClientUpdateSchema, session: AsyncSession, + self, + client: Client, + data: ClientUpdateSchema, + session: AsyncSession, ): try: update_data = data.model_dump(exclude_unset=True) diff --git a/app/crud/deals.py b/app/crud/deals.py new file mode 100644 index 0000000..e69de29 diff --git a/app/crud/users.py b/app/crud/users.py index b032b2c..1956b98 100644 --- a/app/crud/users.py +++ b/app/crud/users.py @@ -23,7 +23,7 @@ async def get_all_users(self, session: AsyncSession): query = select(User).options(selectinload(User.clients)) result = await session.execute(query) users = result.scalars().all() - logger.info('Получен список пользователей') + logger.info("Получен список пользователей") return users except SQLAlchemyError as e: logger.error(f"Ошибка приполучении списка пользователей: {e}") @@ -31,7 +31,11 @@ async def get_all_users(self, session: AsyncSession): async def get_all_user_clients(self, session: AsyncSession, user_id): try: - query = select(Client).where(Client.manager_id == user_id).options(selectinload(Client.manager)) + query = ( + select(Client) + .where(Client.manager_id == user_id) + .options(selectinload(Client.manager)) + ) result = await session.execute(query) clients = result.scalars().all() logger.info("Получен список клиентов менеджера") diff --git a/app/models/clients.py b/app/models/clients.py index 332f625..d528f6c 100644 --- a/app/models/clients.py +++ b/app/models/clients.py @@ -6,6 +6,7 @@ from app.core.database import Base if TYPE_CHECKING: + from app.models.deals import Deal from app.users.models import User @@ -24,3 +25,4 @@ class Client(Base): nullable=True, ) manager: Mapped["User"] = relationship(back_populates="clients") + deals: Mapped["Deal"] = relationship(back_populates="deals", lazy="selectin") diff --git a/app/models/deals.py b/app/models/deals.py new file mode 100644 index 0000000..53e19c4 --- /dev/null +++ b/app/models/deals.py @@ -0,0 +1,48 @@ +import enum +from datetime import datetime +from typing import TYPE_CHECKING + +from sqlalchemy import DateTime, Enum, Float, ForeignKey, String, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.database import Base + +if TYPE_CHECKING: + from app.models.clients import Client + from app.users.models import User + + +class StatusDeal(str, enum.Enum): + new = "Новая" + in_progress = "В процессе" + success = "Выполнена" + canceled = "Отменена" + + +class Deal(Base): + __tablename__ = "deals" + + id: Mapped[int] = mapped_column(primary_key=True, nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(String, nullable=True) + status: Mapped[StatusDeal] = mapped_column( + Enum(StatusDeal), default=StatusDeal.new, nullable=False + ) + price: Mapped[float] = mapped_column(Float, default=0, nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + manager_id: Mapped[int | None] = mapped_column( + ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + manager: Mapped["User"] = relationship(back_populates="deals") + client_id: Mapped[int] = mapped_column( + ForeignKey("clients.id", ondelete="CASCADE"), nullable=False + ) + client: Mapped["Client"] = relationship(back_populates="deals") diff --git a/app/routers/clients.py b/app/routers/clients.py index 3c2a1fe..433d192 100644 --- a/app/routers/clients.py +++ b/app/routers/clients.py @@ -6,8 +6,8 @@ from app.core.database import get_session from app.crud.clients import CRUDClient from app.schemas.clients import ClientCreateSchema, ClientReadSchema, ClientUpdateSchema +from app.users.manager import current_superuser, current_user from app.users.models import User -from app.users.manager import current_user, current_superuser client_router = APIRouter( prefix="/clients", @@ -21,22 +21,27 @@ @client_router.get("/all", dependencies=[Depends(current_user)]) async def get_all_clients( session: AsyncSession = Depends(get_session), - current_user: User = Depends(current_user) + current_user: User = Depends(current_user), ) -> list[ClientReadSchema]: clients = await crud.get_all_clients(session, current_user) if not clients: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Нет ниодного клиента") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Нет ниодного клиента" + ) return clients @client_router.get("/{client_id}", dependencies=[Depends(current_user)]) async def get_client( - client_id: int, session: AsyncSession = Depends(get_session), - current_user: User = Depends(current_user) + client_id: int, + session: AsyncSession = Depends(get_session), + current_user: User = Depends(current_user), ) -> ClientReadSchema: client = await crud.get_client(client_id, session, current_user) if not client: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Клиент не найден") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Клиент не найден" + ) return client @@ -51,36 +56,47 @@ async def create_client( except Exception as e: raise e except Exception: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Ошибка при добавлении клиента") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Ошибка при добавлении клиента", + ) @client_router.patch("/{client_id}", dependencies=[Depends(current_user)]) async def update_client( client_id: int, data: Annotated[ClientUpdateSchema, Depends()], - session: AsyncSession = Depends(get_session) + session: AsyncSession = Depends(get_session), ) -> ClientReadSchema: client = await crud.get_client(client_id, session, current_user) if not client: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Клиент не найден") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Клиент не найден" + ) try: upd_client = await crud.update_client(client, data, session) return upd_client except Exception: - raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Ошибка при изменении клиента") + raise HTTPException( + status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Ошибка при изменении клиента" + ) @client_router.delete("/{client_id}", dependencies=[Depends(current_superuser)]) async def delete_cliennt( client_id: int, session: AsyncSession = Depends(get_session), - user: User = Depends(current_superuser) + user: User = Depends(current_superuser), ): client = await crud.get_client(client_id, session, user) if not client: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Клиент не найден") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Клиент не найден" + ) try: await crud.delete_client(client, session) return {"detail": "Клиент удалён"} except Exception: - raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Ошибка при удалении клиента") + raise HTTPException( + status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Ошибка при удалении клиента" + ) diff --git a/app/routers/deals.py b/app/routers/deals.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routers/users.py b/app/routers/users.py index 82d1bd3..33404f7 100644 --- a/app/routers/users.py +++ b/app/routers/users.py @@ -3,11 +3,9 @@ from app.core.database import get_session from app.crud.users import CRUDUser -from app.users.manager import auth_backend, fastapi_users -from app.users.schemas import UserCreate, UserRead, UserUpdate -from app.users.manager import current_superuser from app.schemas.clients import ClientReadSchema - +from app.users.manager import auth_backend, current_superuser, fastapi_users +from app.users.schemas import UserCreate, UserRead, UserUpdate user_router = APIRouter( tags=["Пользователи"], @@ -16,31 +14,29 @@ crud = CRUDUser() -@user_router.get( - '/users/all', - dependencies=[Depends(current_superuser)] -) +@user_router.get("/users/all", dependencies=[Depends(current_superuser)]) async def get_all_users(session: AsyncSession = Depends(get_session)) -> list[UserRead]: users = await crud.get_all_users(session) return users -@user_router.get( - '/users/{user_id}/clients', - dependencies=[Depends(current_superuser)] -) +@user_router.get("/users/{user_id}/clients", dependencies=[Depends(current_superuser)]) async def get_all_user_clients( - user_id: int, - session: AsyncSession = Depends(get_session) + user_id: int, session: AsyncSession = Depends(get_session) ) -> list[ClientReadSchema]: user = await crud.get_user_by_id(session, user_id) if not user: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Пользователь не найден") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Пользователь не найден" + ) clients = await crud.get_all_user_clients(session, user_id) if not clients: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="У менеджера нет клиентов") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="У менеджера нет клиентов" + ) return clients + user_router.include_router( fastapi_users.get_auth_router(auth_backend), prefix="/auth/jwt", @@ -51,6 +47,5 @@ async def get_all_user_clients( dependencies=[Depends(current_superuser)], ) user_router.include_router( - fastapi_users.get_users_router(UserRead, UserUpdate), - prefix="/users" + fastapi_users.get_users_router(UserRead, UserUpdate), prefix="/users" ) diff --git a/app/schemas/deals.py b/app/schemas/deals.py new file mode 100644 index 0000000..e69de29 diff --git a/app/users/manager.py b/app/users/manager.py index 7de5fa8..1cdf826 100644 --- a/app/users/manager.py +++ b/app/users/manager.py @@ -10,14 +10,13 @@ BearerTransport, JWTStrategy, ) -from sqlalchemy.ext.asyncio import AsyncSession - from fastapi_users_db_sqlalchemy import SQLAlchemyUserDatabase +from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import settings +from app.core.database import get_session from app.users.models import User from app.users.schemas import UserCreate -from app.core.database import get_session bearer_transport = BearerTransport(tokenUrl="auth/jwt/login") diff --git a/app/users/models.py b/app/users/models.py index d746d4e..c19fe64 100644 --- a/app/users/models.py +++ b/app/users/models.py @@ -9,6 +9,7 @@ if TYPE_CHECKING: from app.models.clients import Client + from app.models.deals import Deal class UserRole(str, enum.Enum): @@ -27,4 +28,9 @@ class User(SQLAlchemyBaseUserTable[int], Base): role: Mapped[UserRole] = mapped_column( Enum(UserRole), default=UserRole.manager, nullable=False ) - clients: Mapped[list["Client"] | None] = relationship(back_populates="manager", lazy="selectin") + clients: Mapped[list["Client"] | None] = relationship( + back_populates="manager", lazy="selectin" + ) + deals: Mapped[list["Deal"] | None] = relationship( + back_populates="mannager", lazy="selectin" + ) diff --git a/app/users/schemas.py b/app/users/schemas.py index 108766c..df3d90d 100644 --- a/app/users/schemas.py +++ b/app/users/schemas.py @@ -1,8 +1,8 @@ from fastapi_users import schemas from pydantic import ConfigDict -from app.users.models import UserRole from app.schemas.clients import ClientReadSchema +from app.users.models import UserRole class UserRead(schemas.BaseUser[int]): diff --git a/app/validators/client.py b/app/validators/client.py deleted file mode 100644 index 90a6388..0000000 --- a/app/validators/client.py +++ /dev/null @@ -1,50 +0,0 @@ -from fastapi import HTTPException, status -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.models.clients import Client - - -async def validate_unique_email_client( - session: AsyncSession, - email: str -) -> None: - "Проверка уникальности email клиента" - result = await session.execute( - select(Client).where(Client.email == email) - ) - if result.scalars().first(): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail='Клиент с таким email уже существует' - ) - - -async def validate_unique_phone_client( - session: AsyncSession, - phone: str -) -> None: - "Проверка уникальности телефона клиента" - result = await session.execute( - select(Client).where(Client.phone == phone) - ) - if result.scalars().first(): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail='Клиент с таким телефоном уже существует' - ) - - -async def validate_unique_full_name_client( - session: AsyncSession, - full_name: str -) -> None: - "Проверка уникальности имени клиента" - result = await session.execute( - select(Client).where(Client.full_name == full_name) - ) - if result.scalars().first(): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail='Клиент с таким именем уже существует' - ) diff --git a/app/validators/clients.py b/app/validators/clients.py new file mode 100644 index 0000000..2457092 --- /dev/null +++ b/app/validators/clients.py @@ -0,0 +1,35 @@ +from fastapi import HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.clients import Client + + +async def validate_unique_email_client(session: AsyncSession, email: str) -> None: + "Проверка уникальности email клиента" + result = await session.execute(select(Client).where(Client.email == email)) + if result.scalars().first(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Клиент с таким email уже существует", + ) + + +async def validate_unique_phone_client(session: AsyncSession, phone: str) -> None: + "Проверка уникальности телефона клиента" + result = await session.execute(select(Client).where(Client.phone == phone)) + if result.scalars().first(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Клиент с таким телефоном уже существует", + ) + + +async def validate_unique_full_name_client(session: AsyncSession, full_name: str) -> None: + "Проверка уникальности имени клиента" + result = await session.execute(select(Client).where(Client.full_name == full_name)) + if result.scalars().first(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Клиент с таким именем уже существует", + ) diff --git a/app/validators/deals.py b/app/validators/deals.py new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml index f44d9ca..3d47a6f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ extend-exclude = ''' | venv | build | dist + | alembic ) ''' [tool.isort] From 7ed858d7c12d9af4bb2eccfd309ea1b33557245e Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Tue, 16 Dec 2025 11:36:48 +0300 Subject: [PATCH 15/34] =?UTF-8?q?CRUD,=20=D1=80=D0=BE=D1=83=D1=82=D0=B5?= =?UTF-8?q?=D1=80=D1=8B=20=D0=B8=20=D1=81=D1=85=D0=B5=D0=BC=D1=8B=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D1=81=D0=B4=D0=B5=D0=BB=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../versions/4193470d3df3_deal_user_client.py | 32 +++++ app/crud/clients.py | 5 +- app/crud/deals.py | 84 ++++++++++++ app/main.py | 2 + app/models/clients.py | 4 +- app/models/deals.py | 4 +- app/routers/clients.py | 69 ++++++---- app/routers/deals.py | 125 ++++++++++++++++++ app/routers/users.py | 16 ++- app/schemas/clients.py | 7 +- app/schemas/deals.py | 57 ++++++++ app/users/models.py | 2 +- 12 files changed, 365 insertions(+), 42 deletions(-) create mode 100644 alembic/versions/4193470d3df3_deal_user_client.py diff --git a/alembic/versions/4193470d3df3_deal_user_client.py b/alembic/versions/4193470d3df3_deal_user_client.py new file mode 100644 index 0000000..5027ade --- /dev/null +++ b/alembic/versions/4193470d3df3_deal_user_client.py @@ -0,0 +1,32 @@ +"""Deal User Client + +Revision ID: 4193470d3df3 +Revises: 676e8b97681b +Create Date: 2025-12-15 13:00:00.509937 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '4193470d3df3' +down_revision: Union[str, Sequence[str], None] = '676e8b97681b' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/app/crud/clients.py b/app/crud/clients.py index 5e2c354..6a01bbe 100644 --- a/app/crud/clients.py +++ b/app/crud/clients.py @@ -7,6 +7,7 @@ from app.models.clients import Client from app.schemas.clients import ClientCreateSchema, ClientUpdateSchema +from app.users.models import User from app.validators.clients import ( validate_unique_email_client, validate_unique_full_name_client, @@ -18,7 +19,7 @@ class CRUDClient: - async def get_all_clients(self, session: AsyncSession, user): + async def get_all_clients(self, session: AsyncSession, user: User): try: query = select(Client).options(selectinload(Client.manager)) if user.role == "manager": @@ -31,7 +32,7 @@ async def get_all_clients(self, session: AsyncSession, user): logger.error(f"Ошибка приполучении списка клиентов: {e}") raise - async def get_client(self, client_id: int, session: AsyncSession, user): + async def get_client(self, client_id: int, session: AsyncSession, user: User): try: query = ( select(Client) diff --git a/app/crud/deals.py b/app/crud/deals.py index e69de29..2932d7a 100644 --- a/app/crud/deals.py +++ b/app/crud/deals.py @@ -0,0 +1,84 @@ +import logging + +from sqlalchemy import select +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.models.deals import Deal +from app.schemas.deals import DealCreateSchema, DealUpdateSchema +from app.users.models import User + +logger = logging.getLogger(__name__) + + +class CRUDDeal: + + async def get_all_deals(self, session: AsyncSession, user: User): + try: + query = select(Deal).options(selectinload(Deal.manager)) + if user.role == "manager": + query = query.where(Deal.manager_id == user.id) + result = await session.execute(query) + deals = result.scalars().all() + logger.info("Получен список сделок") + return deals + except SQLAlchemyError as e: + logger.error(f"Ошибка приполучении списка сделок: {e}") + raise + + async def get_deal(self, deal_id: int, session: AsyncSession, user: User): + try: + query = ( + select(Deal).where(Deal.id == deal_id).options(selectinload(Deal.manager)) + ) + if user.role == "manager": + query = query.where(Deal.manager_id == user.id) + result = await session.execute(query) + deal = result.scalars().first() + if deal: + logger.info(f"Получена сделка id = {deal_id}") + else: + logger.warning(f"сделка id = {deal_id} не найдена") + return deal + except SQLAlchemyError as e: + logger.error(f"Ошибка приполучении сделки: {e}") + raise + + async def create_deal(self, data: DealCreateSchema, session: AsyncSession): + try: + new_deal = Deal(**data.model_dump()) + session.add(new_deal) + await session.flush() + await session.commit() + logger.info(f"Создана сделка {new_deal.name} id={new_deal.id}") + return new_deal + except SQLAlchemyError as e: + logger.error(f"Ошибка при создании сделки: {e}") + raise + + async def update_deal( + self, deal: Deal, data: DealUpdateSchema, session: AsyncSession + ): + try: + update_data = data.model_dump(exclude_unset=True) + for key, value in update_data.items(): + setattr(deal, key, value) + + session.add(deal) + await session.commit() + await session.refresh(deal) + logger.info(f"Сделка id={deal.id} изменена") + return deal + except SQLAlchemyError as e: + logger.info(f"Ошибка при изменении сделки id={deal.id}: {e}") + raise + + async def delete_deal(self, deal: Deal, session: AsyncSession): + try: + await session.delete(deal) + await session.commit() + logger.info(f"Сделка {deal.name} id = {deal.id} удалена") + except SQLAlchemyError as e: + logger.error(f"Ошибка при удалении сделки id={deal.id}: {e}") + raise diff --git a/app/main.py b/app/main.py index fe740bc..49428de 100644 --- a/app/main.py +++ b/app/main.py @@ -8,6 +8,7 @@ from app.core.init_db import create_first_superuser from app.core.logging import setup_logging from app.routers.clients import client_router +from app.routers.deals import deal_router from app.routers.users import user_router setup_logging() @@ -27,6 +28,7 @@ async def lifespan(app: FastAPI): app.include_router(user_router) app.include_router(client_router) +app.include_router(deal_router) if __name__ == "__main__": diff --git a/app/models/clients.py b/app/models/clients.py index d528f6c..b86ea47 100644 --- a/app/models/clients.py +++ b/app/models/clients.py @@ -24,5 +24,5 @@ class Client(Base): ), nullable=True, ) - manager: Mapped["User"] = relationship(back_populates="clients") - deals: Mapped["Deal"] = relationship(back_populates="deals", lazy="selectin") + manager: Mapped["User"] = relationship(back_populates="clients", lazy="selectin") + deals: Mapped[list["Deal"]] = relationship(back_populates="client", lazy="selectin") diff --git a/app/models/deals.py b/app/models/deals.py index 53e19c4..0ca8b9b 100644 --- a/app/models/deals.py +++ b/app/models/deals.py @@ -41,8 +41,8 @@ class Deal(Base): manager_id: Mapped[int | None] = mapped_column( ForeignKey("users.id", ondelete="SET NULL"), nullable=True ) - manager: Mapped["User"] = relationship(back_populates="deals") + manager: Mapped["User"] = relationship(back_populates="deals", lazy="selectin") client_id: Mapped[int] = mapped_column( ForeignKey("clients.id", ondelete="CASCADE"), nullable=False ) - client: Mapped["Client"] = relationship(back_populates="deals") + client: Mapped["Client"] = relationship(back_populates="deals", lazy="selectin") diff --git a/app/routers/clients.py b/app/routers/clients.py index 433d192..d7bab8a 100644 --- a/app/routers/clients.py +++ b/app/routers/clients.py @@ -1,5 +1,3 @@ -from typing import Annotated - from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession @@ -15,29 +13,32 @@ "Клиенты", ], ) -crud = CRUDClient() +crud_client = CRUDClient() -@client_router.get("/all", dependencies=[Depends(current_user)]) +@client_router.get( + "/all", + response_model=list[ClientReadSchema], + summary="Получение списка всех клиентов", + description="Администратор видит всех клиентов, менеджер только своих", +) async def get_all_clients( session: AsyncSession = Depends(get_session), current_user: User = Depends(current_user), ) -> list[ClientReadSchema]: - clients = await crud.get_all_clients(session, current_user) - if not clients: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Нет ниодного клиента" - ) + clients = await crud_client.get_all_clients(session, current_user) return clients -@client_router.get("/{client_id}", dependencies=[Depends(current_user)]) +@client_router.get( + "/{client_id}", response_model=ClientReadSchema, summary="Получение клиента по id" +) async def get_client( client_id: int, session: AsyncSession = Depends(get_session), current_user: User = Depends(current_user), ) -> ClientReadSchema: - client = await crud.get_client(client_id, session, current_user) + client = await crud_client.get_client(client_id, session, current_user) if not client: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Клиент не найден" @@ -45,36 +46,42 @@ async def get_client( return client -@client_router.post("", dependencies=[Depends(current_user)]) +@client_router.post( + "", + response_model=ClientReadSchema, + dependencies=[Depends(current_user)], + summary="Добавление клиента", +) async def create_client( - data: Annotated[ClientCreateSchema, Depends()], + data: ClientCreateSchema, session: AsyncSession = Depends(get_session), -) -> ClientCreateSchema: +) -> ClientReadSchema: try: - client = await crud.create_client(data, session) + client = await crud_client.create_client(data, session) return client except Exception as e: raise e - except Exception: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Ошибка при добавлении клиента", - ) -@client_router.patch("/{client_id}", dependencies=[Depends(current_user)]) +@client_router.patch( + "/{client_id}", + response_model=ClientReadSchema, + summary="Обновление клиента", + description="Передавайте только те поля, которые нужно изменить", +) async def update_client( client_id: int, - data: Annotated[ClientUpdateSchema, Depends()], + data: ClientUpdateSchema, session: AsyncSession = Depends(get_session), + user: User = Depends(current_user), ) -> ClientReadSchema: - client = await crud.get_client(client_id, session, current_user) + client = await crud_client.get_client(client_id, session, user) if not client: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Клиент не найден" ) try: - upd_client = await crud.update_client(client, data, session) + upd_client = await crud_client.update_client(client, data, session) return upd_client except Exception: raise HTTPException( @@ -82,19 +89,23 @@ async def update_client( ) -@client_router.delete("/{client_id}", dependencies=[Depends(current_superuser)]) -async def delete_cliennt( +@client_router.delete( + "/{client_id}", + summary="Удаление клиента", + description="Доступно только администратору", +) +async def delete_client( client_id: int, session: AsyncSession = Depends(get_session), user: User = Depends(current_superuser), -): - client = await crud.get_client(client_id, session, user) +) -> dict: + client = await crud_client.get_client(client_id, session, user) if not client: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Клиент не найден" ) try: - await crud.delete_client(client, session) + await crud_client.delete_client(client, session) return {"detail": "Клиент удалён"} except Exception: raise HTTPException( diff --git a/app/routers/deals.py b/app/routers/deals.py index e69de29..beeb8b9 100644 --- a/app/routers/deals.py +++ b/app/routers/deals.py @@ -0,0 +1,125 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_session +from app.crud.deals import CRUDDeal +from app.schemas.deals import DealCreateSchema, DealReadSchema, DealUpdateSchema +from app.users.manager import current_superuser, current_user +from app.users.models import User + +deal_router = APIRouter( + prefix="/deals", + tags=[ + "Сделки", + ], +) + +crud_deal = CRUDDeal() + + +@deal_router.get( + "/all", + response_model=list[DealReadSchema], + summary="Получение списка всех сдеелок", + description="Администратор видит все сделки, менеджер только свои", +) +async def get_all_deals( + session: AsyncSession = Depends(get_session), + current_user: User = Depends(current_user), +) -> list[DealReadSchema]: + deals = await crud_deal.get_all_deals(session, current_user) + if not deals: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Нет ниодной сделки" + ) + return deals + + +@deal_router.get( + "/{deal_id}", + dependencies=[Depends(current_user)], + response_model=DealReadSchema, + summary="Получение сделки по id", +) +async def get_deal( + deal_id: int, + session: AsyncSession = Depends(get_session), + user: User = Depends(current_user), +) -> DealReadSchema: + try: + deal = await crud_deal.get_deal(deal_id, session, user) + if not deal: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Сделка не найдена" + ) + return deal + except Exception as e: + raise e + + +@deal_router.post( + "", + response_model=DealReadSchema, + dependencies=[Depends(current_user)], + summary="Добавление сделки", +) +async def create_deal( + data: DealCreateSchema, session: AsyncSession = Depends(get_session) +) -> DealReadSchema: + try: + deal = await crud_deal.create_deal(data, session) + return deal + except Exception as e: + raise e + + +@deal_router.patch( + "/{deal_id}", + response_model=DealReadSchema, + dependencies=[Depends(current_user)], + summary="Обновление сделки", + description="Передавайте только те поля, которые нужно изменить", +) +async def update_deal( + deal_id: int, + data: DealUpdateSchema, + session: AsyncSession = Depends(get_session), + user: User = Depends(current_user), +) -> DealReadSchema: + deal = await crud_deal.get_deal(deal_id, session, user) + if not deal: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Сдедка не найдена" + ) + try: + upd_deal = await crud_deal.update_deal(deal, data, session) + return upd_deal + except Exception as e: + return e + except Exception: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Ошибка при изменении сделки", + ) + + +@deal_router.delete( + "/{deal_id}", summary="Удаление сделки", description="Доступно только администратору" +) +async def delete_deal( + deal_id: int, + session: AsyncSession = Depends(get_session), + user: User = Depends(current_superuser), +) -> dict: + deal = await crud_deal.get_deal(deal_id, session, user) + if not deal: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Сдедка не найдена" + ) + try: + await crud_deal.delete_deal(deal, session) + return {"detail": "Сделка удалена"} + except Exception: + raise HTTPException( + status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Ошибка при удалении сделки" + ) diff --git a/app/routers/users.py b/app/routers/users.py index 33404f7..9f5e767 100644 --- a/app/routers/users.py +++ b/app/routers/users.py @@ -14,13 +14,25 @@ crud = CRUDUser() -@user_router.get("/users/all", dependencies=[Depends(current_superuser)]) +@user_router.get( + "/users/all", + response_model=list[UserRead], + dependencies=[Depends(current_superuser)], + summary="Получение списка всех пользователей", + description="Доступно только администтратору", +) async def get_all_users(session: AsyncSession = Depends(get_session)) -> list[UserRead]: users = await crud.get_all_users(session) return users -@user_router.get("/users/{user_id}/clients", dependencies=[Depends(current_superuser)]) +@user_router.get( + "/users/{user_id}/clients", + response_model=list[ClientReadSchema], + dependencies=[Depends(current_superuser)], + summary="Получение списка клиентов у конкретного пользователя", + description="Доступно только Администратору", +) async def get_all_user_clients( user_id: int, session: AsyncSession = Depends(get_session) ) -> list[ClientReadSchema]: diff --git a/app/schemas/clients.py b/app/schemas/clients.py index 4b003ea..e62ab97 100644 --- a/app/schemas/clients.py +++ b/app/schemas/clients.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel, ConfigDict, EmailStr +from pydantic import BaseModel, ConfigDict, EmailStr, Field class ManagerShortSchema(BaseModel): @@ -12,7 +12,7 @@ class ManagerShortSchema(BaseModel): class ClientBaseSchema(BaseModel): - full_name: str + full_name: str = Field(..., description="Полное имя клиента") email: EmailStr | None = None phone: str | None = None manager_id: int | None = None @@ -23,8 +23,7 @@ class ClientCreateSchema(ClientBaseSchema): class ClientUpdateSchema(BaseModel): - - full_name: str + full_name: str | None = None email: EmailStr | None = None phone: str | None = None manager_id: int | None = None diff --git a/app/schemas/deals.py b/app/schemas/deals.py index e69de29..0ce8a2f 100644 --- a/app/schemas/deals.py +++ b/app/schemas/deals.py @@ -0,0 +1,57 @@ +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + +from app.models.deals import StatusDeal + + +class ManagerShortSchema(BaseModel): + id: int + username: str + first_name: str | None = None + last_name: str | None = None + + model_config = ConfigDict(from_attributes=True) + + +class ClientShortSchema(BaseModel): + id: int + full_name: str + + model_config = ConfigDict(from_attributes=True) + + +class DealBaseSchema(BaseModel): + + name: str + description: str | None = None + status: StatusDeal + price: float + + +class DealCreateSchema(DealBaseSchema): + + status: StatusDeal = StatusDeal.new + manager_id: int | None = None + client_id: int + + +class DealUpdateSchema(BaseModel): + + name: str | None = None + description: str | None = None + status: StatusDeal | None = None + price: float | None = None + manager_id: int | None = None + client_id: int | None = None + + +class DealReadSchema(DealBaseSchema): + + id: int + created_at: datetime + updated_at: datetime + manager: ManagerShortSchema | None + client: ClientShortSchema + + model_config = ConfigDict(from_attributes=True) diff --git a/app/users/models.py b/app/users/models.py index c19fe64..21da52e 100644 --- a/app/users/models.py +++ b/app/users/models.py @@ -32,5 +32,5 @@ class User(SQLAlchemyBaseUserTable[int], Base): back_populates="manager", lazy="selectin" ) deals: Mapped[list["Deal"] | None] = relationship( - back_populates="mannager", lazy="selectin" + back_populates="manager", lazy="selectin" ) From 720ba65a41814bf2a694b32ec3351f2ce4bd577b Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Tue, 16 Dec 2025 15:24:40 +0300 Subject: [PATCH 16/34] =?UTF-8?q?=D0=9C=D0=BE=D0=B4=D0=B5=D0=BB=D1=8C=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=B0=D1=80=D0=B8=D0=B5=D0=B2=20=D0=BA=20=D1=81=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D0=BA=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../1c60027ca0d4_comment_migration.py | 32 ++++++++++++++ .../ad70e7fc227c_comment_migration_2.py | 42 +++++++++++++++++++ app/__init__.py | 6 ++- app/core/base.py | 1 + app/{validators/deals.py => crud/comment.py} | 0 app/models/comments.py | 35 ++++++++++++++++ app/models/deals.py | 4 ++ 7 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 alembic/versions/1c60027ca0d4_comment_migration.py create mode 100644 alembic/versions/ad70e7fc227c_comment_migration_2.py rename app/{validators/deals.py => crud/comment.py} (100%) create mode 100644 app/models/comments.py diff --git a/alembic/versions/1c60027ca0d4_comment_migration.py b/alembic/versions/1c60027ca0d4_comment_migration.py new file mode 100644 index 0000000..d396e2c --- /dev/null +++ b/alembic/versions/1c60027ca0d4_comment_migration.py @@ -0,0 +1,32 @@ +"""Comment migration + +Revision ID: 1c60027ca0d4 +Revises: 4193470d3df3 +Create Date: 2025-12-16 15:17:36.848213 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '1c60027ca0d4' +down_revision: Union[str, Sequence[str], None] = '4193470d3df3' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/alembic/versions/ad70e7fc227c_comment_migration_2.py b/alembic/versions/ad70e7fc227c_comment_migration_2.py new file mode 100644 index 0000000..c5653e4 --- /dev/null +++ b/alembic/versions/ad70e7fc227c_comment_migration_2.py @@ -0,0 +1,42 @@ +"""Comment migration 2 + +Revision ID: ad70e7fc227c +Revises: 1c60027ca0d4 +Create Date: 2025-12-16 15:20:19.605999 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'ad70e7fc227c' +down_revision: Union[str, Sequence[str], None] = '1c60027ca0d4' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('comments', + sa.Column('id', sa.Integer(), nullable=True), + sa.Column('text', sa.String(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('author_id', sa.Integer(), nullable=False), + sa.Column('delal_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['author_id'], ['users.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['delal_id'], ['deals.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('comments') + # ### end Alembic commands ### diff --git a/app/__init__.py b/app/__init__.py index 88a168a..aea851a 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,2 +1,4 @@ -from .models.clients import Client # noqa -from .users.models import User # noqa +from app.models.clients import Client # noqa +from app.models.comments import Comment # noqa +from app.models.deals import Deal # noqa +from app.users.models import User # noqa diff --git a/app/core/base.py b/app/core/base.py index 9dfe36f..dfa3570 100644 --- a/app/core/base.py +++ b/app/core/base.py @@ -2,5 +2,6 @@ from app.core.database import Base # noqa from app.models.clients import Client # noqa +from app.models.comments import Comment # noqa from app.models.deals import Deal # noqa from app.users.models import User # noqa diff --git a/app/validators/deals.py b/app/crud/comment.py similarity index 100% rename from app/validators/deals.py rename to app/crud/comment.py diff --git a/app/models/comments.py b/app/models/comments.py new file mode 100644 index 0000000..4462e8b --- /dev/null +++ b/app/models/comments.py @@ -0,0 +1,35 @@ +from datetime import datetime +from typing import TYPE_CHECKING + +from sqlalchemy import DateTime, ForeignKey, String, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.database import Base + +if TYPE_CHECKING: + from app.models.deals import Deal + from app.users.models import User + + +class Comment(Base): + __tablename__ = "comments" + + id: Mapped[int] = mapped_column(primary_key=True, nullable=True) + text: Mapped[str] = mapped_column(String, nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + author_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + author: Mapped["User"] = relationship(back_populates="comments", lazy="selectin") + delal_id: Mapped[int] = mapped_column( + ForeignKey("deals.id", ondelete="CASCADE"), nullable=False + ) + deal: Mapped["Deal"] = relationship(back_populates="comments", lazy="selectin") diff --git a/app/models/deals.py b/app/models/deals.py index 0ca8b9b..85481c3 100644 --- a/app/models/deals.py +++ b/app/models/deals.py @@ -9,6 +9,7 @@ if TYPE_CHECKING: from app.models.clients import Client + from app.models.comments import Comment from app.users.models import User @@ -46,3 +47,6 @@ class Deal(Base): ForeignKey("clients.id", ondelete="CASCADE"), nullable=False ) client: Mapped["Client"] = relationship(back_populates="deals", lazy="selectin") + comments: Mapped[list["Comment"]] = relationship( + back_populates="deal", lazy="selectin", cascade="all, delete-orphan" + ) From 34403647554df2c9b4c907fc0f9750fb1a34f316 Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Wed, 17 Dec 2025 12:46:08 +0300 Subject: [PATCH 17/34] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BA=D0=BE=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D0=B0=D1=80=D0=B8=D1=8F,=20=D0=BF=D0=BE=D0=BB=D1=83?= =?UTF-8?q?=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BE=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D0=B0=D1=80=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../1c60027ca0d4_comment_migration.py | 32 ------- .../versions/4193470d3df3_deal_user_client.py | 32 ------- alembic/versions/676e8b97681b_init.py | 33 ------- .../ad70e7fc227c_comment_migration_2.py | 42 --------- alembic/versions/b8566135f7ca_init.py | 90 ------------------- alembic/versions/d78d4bb54f19_init.py | 84 +++++++++++++++++ app/crud/clients.py | 1 + app/crud/comment.py | 68 ++++++++++++++ app/crud/deals.py | 23 ++++- app/main.py | 2 + app/models/comments.py | 2 +- app/routers/comments.py | 70 +++++++++++++++ app/routers/deals.py | 4 - app/schemas/comments.py | 33 +++++++ app/schemas/deals.py | 13 ++- app/users/models.py | 4 + app/users/schemas.py | 1 - 17 files changed, 289 insertions(+), 245 deletions(-) delete mode 100644 alembic/versions/1c60027ca0d4_comment_migration.py delete mode 100644 alembic/versions/4193470d3df3_deal_user_client.py delete mode 100644 alembic/versions/676e8b97681b_init.py delete mode 100644 alembic/versions/ad70e7fc227c_comment_migration_2.py delete mode 100644 alembic/versions/b8566135f7ca_init.py create mode 100644 alembic/versions/d78d4bb54f19_init.py create mode 100644 app/routers/comments.py create mode 100644 app/schemas/comments.py diff --git a/alembic/versions/1c60027ca0d4_comment_migration.py b/alembic/versions/1c60027ca0d4_comment_migration.py deleted file mode 100644 index d396e2c..0000000 --- a/alembic/versions/1c60027ca0d4_comment_migration.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Comment migration - -Revision ID: 1c60027ca0d4 -Revises: 4193470d3df3 -Create Date: 2025-12-16 15:17:36.848213 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = '1c60027ca0d4' -down_revision: Union[str, Sequence[str], None] = '4193470d3df3' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - pass - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - pass - # ### end Alembic commands ### diff --git a/alembic/versions/4193470d3df3_deal_user_client.py b/alembic/versions/4193470d3df3_deal_user_client.py deleted file mode 100644 index 5027ade..0000000 --- a/alembic/versions/4193470d3df3_deal_user_client.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Deal User Client - -Revision ID: 4193470d3df3 -Revises: 676e8b97681b -Create Date: 2025-12-15 13:00:00.509937 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = '4193470d3df3' -down_revision: Union[str, Sequence[str], None] = '676e8b97681b' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - pass - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - pass - # ### end Alembic commands ### diff --git a/alembic/versions/676e8b97681b_init.py b/alembic/versions/676e8b97681b_init.py deleted file mode 100644 index 430ac83..0000000 --- a/alembic/versions/676e8b97681b_init.py +++ /dev/null @@ -1,33 +0,0 @@ -"""init - -Revision ID: 676e8b97681b -Revises: b8566135f7ca -Create Date: 2025-12-14 12:14:00.853282 - -""" - -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = "676e8b97681b" -down_revision: Union[str, Sequence[str], None] = "b8566135f7ca" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - pass - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - pass - # ### end Alembic commands ### diff --git a/alembic/versions/ad70e7fc227c_comment_migration_2.py b/alembic/versions/ad70e7fc227c_comment_migration_2.py deleted file mode 100644 index c5653e4..0000000 --- a/alembic/versions/ad70e7fc227c_comment_migration_2.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Comment migration 2 - -Revision ID: ad70e7fc227c -Revises: 1c60027ca0d4 -Create Date: 2025-12-16 15:20:19.605999 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = 'ad70e7fc227c' -down_revision: Union[str, Sequence[str], None] = '1c60027ca0d4' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('comments', - sa.Column('id', sa.Integer(), nullable=True), - sa.Column('text', sa.String(), nullable=True), - sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), - sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), - sa.Column('author_id', sa.Integer(), nullable=False), - sa.Column('delal_id', sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(['author_id'], ['users.id'], ondelete='CASCADE'), - sa.ForeignKeyConstraint(['delal_id'], ['deals.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id') - ) - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('comments') - # ### end Alembic commands ### diff --git a/alembic/versions/b8566135f7ca_init.py b/alembic/versions/b8566135f7ca_init.py deleted file mode 100644 index 7b47194..0000000 --- a/alembic/versions/b8566135f7ca_init.py +++ /dev/null @@ -1,90 +0,0 @@ -"""init - -Revision ID: b8566135f7ca -Revises: -Create Date: 2025-12-14 12:09:44.852044 - -""" - -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = "b8566135f7ca" -down_revision: Union[str, Sequence[str], None] = None -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.create_table( - "users", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("username", sa.String(), nullable=False), - sa.Column("first_name", sa.String(), nullable=True), - sa.Column("last_name", sa.String(), nullable=True), - sa.Column("phone", sa.Integer(), nullable=True), - sa.Column("role", sa.Enum("manager", "admin", name="userrole"), nullable=False), - sa.Column("email", sa.String(length=320), nullable=False), - sa.Column("hashed_password", sa.String(length=1024), nullable=False), - sa.Column("is_active", sa.Boolean(), nullable=False), - sa.Column("is_superuser", sa.Boolean(), nullable=False), - sa.Column("is_verified", sa.Boolean(), nullable=False), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True) - op.create_table( - "clients", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("full_name", sa.String(length=255), nullable=False), - sa.Column("email", sa.String(length=255), nullable=True), - sa.Column("phone", sa.String(length=255), nullable=True), - sa.Column("manager_id", sa.Integer(), nullable=True), - sa.ForeignKeyConstraint(["manager_id"], ["users.id"], ondelete="SET NULL"), - sa.PrimaryKeyConstraint("id"), - ) - op.create_table( - "deals", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("name", sa.String(length=255), nullable=False), - sa.Column("description", sa.String(), nullable=True), - sa.Column( - "status", - sa.Enum("new", "in_progress", "success", "canceled", name="statusdeal"), - nullable=False, - ), - sa.Column("price", sa.Float(), nullable=False), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - server_default=sa.text("(CURRENT_TIMESTAMP)"), - nullable=False, - ), - sa.Column( - "updated_at", - sa.DateTime(timezone=True), - server_default=sa.text("(CURRENT_TIMESTAMP)"), - nullable=False, - ), - sa.Column("manager_id", sa.Integer(), nullable=True), - sa.Column("client_id", sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(["client_id"], ["clients.id"], ondelete="CASCADE"), - sa.ForeignKeyConstraint(["manager_id"], ["users.id"], ondelete="SET NULL"), - sa.PrimaryKeyConstraint("id"), - ) - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table("deals") - op.drop_table("clients") - op.drop_index(op.f("ix_users_email"), table_name="users") - op.drop_table("users") - # ### end Alembic commands ### diff --git a/alembic/versions/d78d4bb54f19_init.py b/alembic/versions/d78d4bb54f19_init.py new file mode 100644 index 0000000..3053584 --- /dev/null +++ b/alembic/versions/d78d4bb54f19_init.py @@ -0,0 +1,84 @@ +"""init + +Revision ID: d78d4bb54f19 +Revises: +Create Date: 2025-12-17 09:53:11.826265 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'd78d4bb54f19' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('users', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('username', sa.String(), nullable=False), + sa.Column('first_name', sa.String(), nullable=True), + sa.Column('last_name', sa.String(), nullable=True), + sa.Column('phone', sa.Integer(), nullable=True), + sa.Column('role', sa.Enum('manager', 'admin', name='userrole'), nullable=False), + sa.Column('email', sa.String(length=320), nullable=False), + sa.Column('hashed_password', sa.String(length=1024), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('is_superuser', sa.Boolean(), nullable=False), + sa.Column('is_verified', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) + op.create_table('clients', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('full_name', sa.String(length=255), nullable=False), + sa.Column('email', sa.String(length=255), nullable=True), + sa.Column('phone', sa.String(length=255), nullable=True), + sa.Column('manager_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['manager_id'], ['users.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('deals', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('description', sa.String(), nullable=True), + sa.Column('status', sa.Enum('new', 'in_progress', 'success', 'canceled', name='statusdeal'), nullable=False), + sa.Column('price', sa.Float(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('manager_id', sa.Integer(), nullable=True), + sa.Column('client_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['client_id'], ['clients.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['manager_id'], ['users.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('comments', + sa.Column('id', sa.Integer(), nullable=True), + sa.Column('text', sa.String(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('author_id', sa.Integer(), nullable=False), + sa.Column('deal_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['author_id'], ['users.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['deal_id'], ['deals.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('comments') + op.drop_table('deals') + op.drop_table('clients') + op.drop_index(op.f('ix_users_email'), table_name='users') + op.drop_table('users') + # ### end Alembic commands ### diff --git a/app/crud/clients.py b/app/crud/clients.py index 6a01bbe..ef6586f 100644 --- a/app/crud/clients.py +++ b/app/crud/clients.py @@ -64,6 +64,7 @@ async def create_client(self, data: ClientCreateSchema, session: AsyncSession): session.add(new_client) await session.flush() await session.commit() + await session.refresh(new_client, attribute_names=["manager"]) logger.info(f"Создан клиент {new_client.full_name} id={new_client.id}") return new_client except SQLAlchemyError as e: diff --git a/app/crud/comment.py b/app/crud/comment.py index e69de29..91c0baf 100644 --- a/app/crud/comment.py +++ b/app/crud/comment.py @@ -0,0 +1,68 @@ +import logging + +from sqlalchemy import select +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.models.comments import Comment +from app.models.deals import Deal +from app.schemas.comments import CommentCreateSchema +from app.users.models import User, UserRole + +logger = logging.getLogger(__name__) + + +class CRUDComment: + + async def get_comments_deal(self, deal_id: int, session: AsyncSession): + try: + query = ( + select(Comment) + .where(Comment.deal_id == deal_id) + .order_by(Comment.created_at.asc()) + ) + result = await session.execute(query) + comment = result.scalars().all() + return comment + except SQLAlchemyError as e: + logger.error(f"Ошибка приполучении комментария: {e}") + raise + + async def create_comment( + self, deal_id, data: CommentCreateSchema, session: AsyncSession, user: User + ): + try: + if user.role not in (UserRole.manager, UserRole.admin): + raise PermissionError("Недостаточно прав для добавления комментария") + deal = await session.get( + Deal, + deal_id, + options=[ + selectinload(Deal.comments).selectinload(Comment.author), + selectinload(Deal.manager), + selectinload(Deal.client), + ], + ) + if not deal: + raise ValueError("Сделка не найдена") + new_comment = Comment( + text=data.text, + author_id=user.id, + deal_id=deal_id, + ) + session.add(new_comment) + await session.flush() + await session.commit() + await session.refresh(new_comment.deal, attribute_names=["comments"]) + logger.info(f"Добавлен новый комментарий к сделке: {new_comment.deal}") + return new_comment + except SQLAlchemyError as e: + logger.error(f"Ошибка при добавлении комментария: {e}") + raise + + async def update_comment(): + pass + + async def delete_comment(): + pass diff --git a/app/crud/deals.py b/app/crud/deals.py index 2932d7a..9a65aa0 100644 --- a/app/crud/deals.py +++ b/app/crud/deals.py @@ -5,6 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload +from app.models.comments import Comment from app.models.deals import Deal from app.schemas.deals import DealCreateSchema, DealUpdateSchema from app.users.models import User @@ -16,7 +17,9 @@ class CRUDDeal: async def get_all_deals(self, session: AsyncSession, user: User): try: - query = select(Deal).options(selectinload(Deal.manager)) + query = select(Deal).options( + selectinload(Deal.manager), selectinload(Deal.comments) + ) if user.role == "manager": query = query.where(Deal.manager_id == user.id) result = await session.execute(query) @@ -30,7 +33,13 @@ async def get_all_deals(self, session: AsyncSession, user: User): async def get_deal(self, deal_id: int, session: AsyncSession, user: User): try: query = ( - select(Deal).where(Deal.id == deal_id).options(selectinload(Deal.manager)) + select(Deal) + .where(Deal.id == deal_id) + .options( + selectinload(Deal.manager), + selectinload(Deal.client), + selectinload(Deal.comments).selectinload(Comment.author), + ) ) if user.role == "manager": query = query.where(Deal.manager_id == user.id) @@ -39,7 +48,7 @@ async def get_deal(self, deal_id: int, session: AsyncSession, user: User): if deal: logger.info(f"Получена сделка id = {deal_id}") else: - logger.warning(f"сделка id = {deal_id} не найдена") + logger.warning(f"Сделка id = {deal_id} не найдена") return deal except SQLAlchemyError as e: logger.error(f"Ошибка приполучении сделки: {e}") @@ -51,6 +60,14 @@ async def create_deal(self, data: DealCreateSchema, session: AsyncSession): session.add(new_deal) await session.flush() await session.commit() + await session.refresh( + new_deal, + attribute_names=[ + "manager", + "client", + "comment", + ], + ) logger.info(f"Создана сделка {new_deal.name} id={new_deal.id}") return new_deal except SQLAlchemyError as e: diff --git a/app/main.py b/app/main.py index 49428de..7825a6e 100644 --- a/app/main.py +++ b/app/main.py @@ -8,6 +8,7 @@ from app.core.init_db import create_first_superuser from app.core.logging import setup_logging from app.routers.clients import client_router +from app.routers.comments import comment_router from app.routers.deals import deal_router from app.routers.users import user_router @@ -29,6 +30,7 @@ async def lifespan(app: FastAPI): app.include_router(user_router) app.include_router(client_router) app.include_router(deal_router) +app.include_router(comment_router) if __name__ == "__main__": diff --git a/app/models/comments.py b/app/models/comments.py index 4462e8b..24e0212 100644 --- a/app/models/comments.py +++ b/app/models/comments.py @@ -29,7 +29,7 @@ class Comment(Base): ForeignKey("users.id", ondelete="CASCADE"), nullable=False ) author: Mapped["User"] = relationship(back_populates="comments", lazy="selectin") - delal_id: Mapped[int] = mapped_column( + deal_id: Mapped[int] = mapped_column( ForeignKey("deals.id", ondelete="CASCADE"), nullable=False ) deal: Mapped["Deal"] = relationship(back_populates="comments", lazy="selectin") diff --git a/app/routers/comments.py b/app/routers/comments.py new file mode 100644 index 0000000..a2d9580 --- /dev/null +++ b/app/routers/comments.py @@ -0,0 +1,70 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_session +from app.crud.comment import CRUDComment +from app.schemas.comments import ( + CommentCreateSchema, + CommentReadSchema +) +from app.users.manager import current_superuser, current_user +from app.users.models import User + +comment_router = APIRouter( + prefix="/deals/{deal_id}/comments", + tags=[ + "Комментарии", + ], +) + +comment_crud = CRUDComment() + + +@comment_router.get( + "", response_model=list[CommentReadSchema], summary="Получить комментарии сдделки" +) +async def get_comments_deal( + deal_id: int, + session: AsyncSession = Depends(get_session), +) -> list[CommentReadSchema]: + comments = await comment_crud.get_comments_deal(deal_id, session) + return comments + + +@comment_router.post( + "", + response_model=CommentReadSchema, + dependencies=[Depends(current_user)], + summary="Добавление ккомментария", +) +async def create_comment( + deal_id: int, + data: CommentCreateSchema, + session: AsyncSession = Depends(get_session), + user: User = Depends(current_user), +) -> CommentReadSchema: + try: + comment = await comment_crud.create_comment(deal_id, data, session, user) + return comment + except Exception as e: + raise e + + +@comment_router.patch( + "", + response_model=CommentReadSchema, + dependencies=[Depends(current_user)], + summary="Измеенеение ккомментария", +) +async def update_comment() -> CommentReadSchema: + pass + + +@comment_router.delete( + "", + dependencies=[Depends(current_superuser)], + summary="Удаление ккомментария", + description="Доступно только администратору", +) +async def delete_commennt() -> dict: + pass diff --git a/app/routers/deals.py b/app/routers/deals.py index beeb8b9..da606d9 100644 --- a/app/routers/deals.py +++ b/app/routers/deals.py @@ -28,10 +28,6 @@ async def get_all_deals( current_user: User = Depends(current_user), ) -> list[DealReadSchema]: deals = await crud_deal.get_all_deals(session, current_user) - if not deals: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Нет ниодной сделки" - ) return deals diff --git a/app/schemas/comments.py b/app/schemas/comments.py new file mode 100644 index 0000000..e555530 --- /dev/null +++ b/app/schemas/comments.py @@ -0,0 +1,33 @@ +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +class AuthorCommentSchema(BaseModel): + id: int + username: str + + model_config = ConfigDict(from_attributes=True) + + +class CommentBaseSchema(BaseModel): + + text: str + + +class CommentCreateSchema(CommentBaseSchema): + pass + + +class CommentUpdateSchema(BaseModel): + text: str | None = None + + +class CommentReadSchema(CommentBaseSchema): + + id: int + created_at: datetime + updated_at: datetime + author: AuthorCommentSchema + + model_config = ConfigDict(from_attributes=True) diff --git a/app/schemas/deals.py b/app/schemas/deals.py index 0ce8a2f..44857fa 100644 --- a/app/schemas/deals.py +++ b/app/schemas/deals.py @@ -3,23 +3,18 @@ from pydantic import BaseModel, ConfigDict from app.models.deals import StatusDeal +from app.schemas.comments import CommentReadSchema class ManagerShortSchema(BaseModel): id: int username: str - first_name: str | None = None - last_name: str | None = None - - model_config = ConfigDict(from_attributes=True) class ClientShortSchema(BaseModel): id: int full_name: str - model_config = ConfigDict(from_attributes=True) - class DealBaseSchema(BaseModel): @@ -44,6 +39,9 @@ class DealUpdateSchema(BaseModel): price: float | None = None manager_id: int | None = None client_id: int | None = None + comments: list | None = None + + model_config = ConfigDict(from_attributes=True) class DealReadSchema(DealBaseSchema): @@ -51,7 +49,8 @@ class DealReadSchema(DealBaseSchema): id: int created_at: datetime updated_at: datetime - manager: ManagerShortSchema | None client: ClientShortSchema + manager: ManagerShortSchema | None + comments: list[CommentReadSchema] | None = None model_config = ConfigDict(from_attributes=True) diff --git a/app/users/models.py b/app/users/models.py index 21da52e..91ba5ea 100644 --- a/app/users/models.py +++ b/app/users/models.py @@ -9,6 +9,7 @@ if TYPE_CHECKING: from app.models.clients import Client + from app.models.comments import Comment from app.models.deals import Deal @@ -34,3 +35,6 @@ class User(SQLAlchemyBaseUserTable[int], Base): deals: Mapped[list["Deal"] | None] = relationship( back_populates="manager", lazy="selectin" ) + comments: Mapped[list["Comment"] | None] = relationship( + back_populates="author", lazy="selectin" + ) diff --git a/app/users/schemas.py b/app/users/schemas.py index df3d90d..5be8ff2 100644 --- a/app/users/schemas.py +++ b/app/users/schemas.py @@ -25,7 +25,6 @@ class UserCreate(schemas.BaseUserCreate): last_name: str | None = None phone: int | None = None role: UserRole = UserRole.manager - clients: list | None = None class UserUpdate(schemas.BaseUserUpdate): From dadbccd11b24d091836c0b5271ba208e755acdfb Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Tue, 23 Dec 2025 11:40:20 +0300 Subject: [PATCH 18/34] =?UTF-8?q?=D0=98=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=B8=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=B0=D1=80=D0=B8=D0=B5=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/crud/comment.py | 40 ++++++++++++++++++++++++++++----- app/crud/deals.py | 2 +- app/routers/comments.py | 50 +++++++++++++++++++++++++++++++++-------- 3 files changed, 76 insertions(+), 16 deletions(-) diff --git a/app/crud/comment.py b/app/crud/comment.py index 91c0baf..8d53901 100644 --- a/app/crud/comment.py +++ b/app/crud/comment.py @@ -7,7 +7,7 @@ from app.models.comments import Comment from app.models.deals import Deal -from app.schemas.comments import CommentCreateSchema +from app.schemas.comments import CommentCreateSchema, CommentUpdateSchema from app.users.models import User, UserRole logger = logging.getLogger(__name__) @@ -30,7 +30,7 @@ async def get_comments_deal(self, deal_id: int, session: AsyncSession): raise async def create_comment( - self, deal_id, data: CommentCreateSchema, session: AsyncSession, user: User + self, deal_id: int, data: CommentCreateSchema, session: AsyncSession, user: User ): try: if user.role not in (UserRole.manager, UserRole.admin): @@ -61,8 +61,36 @@ async def create_comment( logger.error(f"Ошибка при добавлении комментария: {e}") raise - async def update_comment(): - pass + async def update_comment( + self, comment_id: int, data: CommentUpdateSchema, session: AsyncSession, user: User + ): + try: + comment = await session.get(Comment, comment_id) + if not comment: + raise ValueError("Комментарий не найден") + if comment.author_id != user.id: + raise PermissionError("Нельзя редактировать чужой комментарий") + comment.text = data.text + await session.commit() + await session.refresh(comment) + logger.info(f"Комментарий id = {comment_id} изменён") + return comment + except SQLAlchemyError as e: + logger.error(f"Ошибка при изменении комментария id={comment_id}: {e}") + raise - async def delete_comment(): - pass + async def delete_comment( + self, comment_id: int, session: AsyncSession, user: User + ): + try: + comment = await session.get(Comment, comment_id) + if not comment: + raise ValueError("Комментарий не найден") + if user.role != UserRole.admin: + raise PermissionError("Удалять комментарии может только администратор") + await session.delete(comment) + await session.commit() + logger.info(f"Комментарий id={comment_id} удалён") + except SQLAlchemyError as e: + logger.error(f"Ошибка при удалении комментария id={comment_id}: {e}") + raise diff --git a/app/crud/deals.py b/app/crud/deals.py index 9a65aa0..7b21c1f 100644 --- a/app/crud/deals.py +++ b/app/crud/deals.py @@ -88,7 +88,7 @@ async def update_deal( logger.info(f"Сделка id={deal.id} изменена") return deal except SQLAlchemyError as e: - logger.info(f"Ошибка при изменении сделки id={deal.id}: {e}") + logger.error(f"Ошибка при изменении сделки id={deal.id}: {e}") raise async def delete_deal(self, deal: Deal, session: AsyncSession): diff --git a/app/routers/comments.py b/app/routers/comments.py index a2d9580..9498285 100644 --- a/app/routers/comments.py +++ b/app/routers/comments.py @@ -1,10 +1,11 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_session from app.crud.comment import CRUDComment from app.schemas.comments import ( CommentCreateSchema, + CommentUpdateSchema, CommentReadSchema ) from app.users.manager import current_superuser, current_user @@ -51,20 +52,51 @@ async def create_comment( @comment_router.patch( - "", + "/{comment_id}", response_model=CommentReadSchema, - dependencies=[Depends(current_user)], summary="Измеенеение ккомментария", ) -async def update_comment() -> CommentReadSchema: - pass +async def update_comment( + deal_id: int, + comment_id: int, + data: CommentUpdateSchema, + session: AsyncSession = Depends(get_session), + user: User = Depends(current_user) +) -> CommentReadSchema: + try: + comment = await comment_crud.update_comment( + comment_id, data, session, user + ) + return comment + except ValueError as e: + raise HTTPException( + status.HTTP_404_NOT_FOUND, detail=f"{e}" + ) + except PermissionError as e: + raise HTTPException( + status.HTTP_403_FORBIDDEN, detail=f"{e}" + ) @comment_router.delete( - "", - dependencies=[Depends(current_superuser)], + "/{comment_id}", summary="Удаление ккомментария", description="Доступно только администратору", ) -async def delete_commennt() -> dict: - pass +async def delete_commennt( + deal_id: int, + comment_id: int, + session: AsyncSession = Depends(get_session), + user: User = Depends(current_superuser) +) -> dict: + try: + await comment_crud.delete_comment(comment_id, session, user) + return {"detail": "Комментарий удалён"} + except ValueError as e: + raise HTTPException( + status.HTTP_404_NOT_FOUND, detail=f"{e}" + ) + except PermissionError as e: + raise HTTPException( + status.HTTP_403_FORBIDDEN, detail=f"{e}" + ) From ca074fab56721c607bd68ceb658d52aed44c3a46 Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Wed, 24 Dec 2025 11:22:39 +0300 Subject: [PATCH 19/34] GithubActions --- .github/workflows/main.yaml | 35 +++++++++++++++++++++++ requirements.txt | 55 +++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 .github/workflows/main.yaml create mode 100644 requirements.txt diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml new file mode 100644 index 0000000..264b7fd --- /dev/null +++ b/.github/workflows/main.yaml @@ -0,0 +1,35 @@ +name: miniCRM workflow + +on: [push] + +jobs: + lint: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: 3.11 + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r . requirements.txt + + - name: Test with black(check only) + run: | + python -m black . --check + + - name: Test with flake8-isort(check only) + run: | + python -m isort . --check-only + + - name: Test with flake8 + run: | + flake8 . --count --statistics --show-source + + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f31c39c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,55 @@ +aiosqlite==0.21.0 +alembic==1.17.2 +annotated-doc==0.0.4 +annotated-types==0.7.0 +anyio==4.12.0 +argon2-cffi==23.1.0 +argon2-cffi-bindings==25.1.0 +bcrypt==4.3.0 +black==25.11.0 +certifi==2025.11.12 +cffi==2.0.0 +click==8.3.1 +cryptography==46.0.3 +dnspython==2.8.0 +email-validator==2.3.0 +fastapi==0.123.5 +fastapi-users==15.0.1 +fastapi-users-db-sqlalchemy==7.0.0 +flake8==7.3.0 +greenlet==3.2.4 +h11==0.16.0 +httpcore==1.0.9 +httptools==0.7.1 +httpx==0.28.1 +httpx-oauth==0.16.1 +idna==3.11 +isort==7.0.0 +makefun==1.16.0 +Mako==1.3.10 +MarkupSafe==3.0.3 +mccabe==0.7.0 +mypy_extensions==1.1.0 +packaging==25.0 +pathspec==0.12.1 +platformdirs==4.5.0 +pwdlib==0.2.1 +pycodestyle==2.14.0 +pycparser==2.23 +pydantic==2.12.5 +pydantic-settings==2.12.0 +pydantic_core==2.41.5 +pyflakes==3.4.0 +PyJWT==2.10.1 +python-dotenv==1.2.1 +python-multipart==0.0.20 +pytokens==0.3.0 +PyYAML==6.0.3 +SQLAlchemy==2.0.44 +starlette==0.50.0 +typing-inspection==0.4.2 +typing_extensions==4.15.0 +uvicorn==0.38.0 +uvloop==0.22.1 +watchfiles==1.1.1 +websockets==15.0.1 From 362e53f819716d616dbb4ae8a1dd4b7279ac032b Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Wed, 24 Dec 2025 11:25:57 +0300 Subject: [PATCH 20/34] GithubActions --- .github/workflows/main.yaml | 2 +- app/crud/comment.py | 10 ++++++---- app/routers/comments.py | 26 ++++++++------------------ 3 files changed, 15 insertions(+), 23 deletions(-) diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 264b7fd..3bdd580 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -18,7 +18,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r . requirements.txt + pip install -r requirements.txt - name: Test with black(check only) run: | diff --git a/app/crud/comment.py b/app/crud/comment.py index 8d53901..f6fe185 100644 --- a/app/crud/comment.py +++ b/app/crud/comment.py @@ -62,7 +62,11 @@ async def create_comment( raise async def update_comment( - self, comment_id: int, data: CommentUpdateSchema, session: AsyncSession, user: User + self, + comment_id: int, + data: CommentUpdateSchema, + session: AsyncSession, + user: User, ): try: comment = await session.get(Comment, comment_id) @@ -79,9 +83,7 @@ async def update_comment( logger.error(f"Ошибка при изменении комментария id={comment_id}: {e}") raise - async def delete_comment( - self, comment_id: int, session: AsyncSession, user: User - ): + async def delete_comment(self, comment_id: int, session: AsyncSession, user: User): try: comment = await session.get(Comment, comment_id) if not comment: diff --git a/app/routers/comments.py b/app/routers/comments.py index 9498285..0fb8f5f 100644 --- a/app/routers/comments.py +++ b/app/routers/comments.py @@ -5,8 +5,8 @@ from app.crud.comment import CRUDComment from app.schemas.comments import ( CommentCreateSchema, + CommentReadSchema, CommentUpdateSchema, - CommentReadSchema ) from app.users.manager import current_superuser, current_user from app.users.models import User @@ -61,21 +61,15 @@ async def update_comment( comment_id: int, data: CommentUpdateSchema, session: AsyncSession = Depends(get_session), - user: User = Depends(current_user) + user: User = Depends(current_user), ) -> CommentReadSchema: try: - comment = await comment_crud.update_comment( - comment_id, data, session, user - ) + comment = await comment_crud.update_comment(comment_id, data, session, user) return comment except ValueError as e: - raise HTTPException( - status.HTTP_404_NOT_FOUND, detail=f"{e}" - ) + raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"{e}") except PermissionError as e: - raise HTTPException( - status.HTTP_403_FORBIDDEN, detail=f"{e}" - ) + raise HTTPException(status.HTTP_403_FORBIDDEN, detail=f"{e}") @comment_router.delete( @@ -87,16 +81,12 @@ async def delete_commennt( deal_id: int, comment_id: int, session: AsyncSession = Depends(get_session), - user: User = Depends(current_superuser) + user: User = Depends(current_superuser), ) -> dict: try: await comment_crud.delete_comment(comment_id, session, user) return {"detail": "Комментарий удалён"} except ValueError as e: - raise HTTPException( - status.HTTP_404_NOT_FOUND, detail=f"{e}" - ) + raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"{e}") except PermissionError as e: - raise HTTPException( - status.HTTP_403_FORBIDDEN, detail=f"{e}" - ) + raise HTTPException(status.HTTP_403_FORBIDDEN, detail=f"{e}") From 0555b6a563152eba8b251944f3ff991d2db0ba4c Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Wed, 24 Dec 2025 11:28:31 +0300 Subject: [PATCH 21/34] GithubActions --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 0cc1976..808469a 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,4 @@ # minicrm API мини CRM приложеение + +[![miniCRM workflow](https://github.com/dmsnback/minicrm/actions/workflows/main.yml/badge.svg)](https://github.com/dmsnback/minicrm/actions/workflows/main.yml) From b1c765a6ce97810979f52ea9ef31401ad0879644 Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Wed, 24 Dec 2025 15:46:44 +0300 Subject: [PATCH 22/34] =?UTF-8?q?=D0=B1=D0=B4=20postgresql?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- ...init.py => 97e3968a1bf2_init_migration.py} | 37 ++++++++++--------- app/core/config.py | 1 + app/core/database.py | 2 +- app/crud/deals.py | 2 +- app/models/clients.py | 4 +- app/models/comments.py | 2 +- app/models/deals.py | 4 +- app/users/models.py | 20 +++++----- app/users/schemas.py | 6 +-- requirements.txt | 2 + 11 files changed, 43 insertions(+), 39 deletions(-) rename alembic/versions/{d78d4bb54f19_init.py => 97e3968a1bf2_init_migration.py} (76%) diff --git a/README.md b/README.md index 808469a..d918a7f 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ # minicrm API мини CRM приложеение -[![miniCRM workflow](https://github.com/dmsnback/minicrm/actions/workflows/main.yml/badge.svg)](https://github.com/dmsnback/minicrm/actions/workflows/main.yml) +[![miniCRM workflow](https://github.com/dmsnback/minicrm/actions/workflows/main.yml/badge.svg)](https://github.com/dmsnback/minicrm/actions/workflows/main.yaml) diff --git a/alembic/versions/d78d4bb54f19_init.py b/alembic/versions/97e3968a1bf2_init_migration.py similarity index 76% rename from alembic/versions/d78d4bb54f19_init.py rename to alembic/versions/97e3968a1bf2_init_migration.py index 3053584..c8b1443 100644 --- a/alembic/versions/d78d4bb54f19_init.py +++ b/alembic/versions/97e3968a1bf2_init_migration.py @@ -1,8 +1,8 @@ -"""init +"""Init migration -Revision ID: d78d4bb54f19 +Revision ID: 97e3968a1bf2 Revises: -Create Date: 2025-12-17 09:53:11.826265 +Create Date: 2025-12-24 15:20:15.991785 """ from typing import Sequence, Union @@ -12,7 +12,7 @@ # revision identifiers, used by Alembic. -revision: str = 'd78d4bb54f19' +revision: str = '97e3968a1bf2' down_revision: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -23,17 +23,18 @@ def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### op.create_table('users', sa.Column('id', sa.Integer(), nullable=False), - sa.Column('username', sa.String(), nullable=False), - sa.Column('first_name', sa.String(), nullable=True), - sa.Column('last_name', sa.String(), nullable=True), - sa.Column('phone', sa.Integer(), nullable=True), - sa.Column('role', sa.Enum('manager', 'admin', name='userrole'), nullable=False), + sa.Column('username', sa.String(length=150), nullable=False), + sa.Column('first_name', sa.String(length=150), nullable=True), + sa.Column('last_name', sa.String(length=150), nullable=True), + sa.Column('phone', sa.String(length=20), nullable=True), + sa.Column('role', sa.Enum('manager', 'admin', name='user_role_enum'), nullable=False), sa.Column('email', sa.String(length=320), nullable=False), sa.Column('hashed_password', sa.String(length=1024), nullable=False), sa.Column('is_active', sa.Boolean(), nullable=False), sa.Column('is_superuser', sa.Boolean(), nullable=False), sa.Column('is_verified', sa.Boolean(), nullable=False), - sa.PrimaryKeyConstraint('id') + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('username') ) op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) op.create_table('clients', @@ -43,16 +44,18 @@ def upgrade() -> None: sa.Column('phone', sa.String(length=255), nullable=True), sa.Column('manager_id', sa.Integer(), nullable=True), sa.ForeignKeyConstraint(['manager_id'], ['users.id'], ondelete='SET NULL'), - sa.PrimaryKeyConstraint('id') + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('email'), + sa.UniqueConstraint('phone') ) op.create_table('deals', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=255), nullable=False), sa.Column('description', sa.String(), nullable=True), - sa.Column('status', sa.Enum('new', 'in_progress', 'success', 'canceled', name='statusdeal'), nullable=False), + sa.Column('status', sa.Enum('new', 'in_progress', 'success', 'canceled', name='status_deal_enum'), nullable=False), sa.Column('price', sa.Float(), nullable=False), - sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), - sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), sa.Column('manager_id', sa.Integer(), nullable=True), sa.Column('client_id', sa.Integer(), nullable=False), sa.ForeignKeyConstraint(['client_id'], ['clients.id'], ondelete='CASCADE'), @@ -60,10 +63,10 @@ def upgrade() -> None: sa.PrimaryKeyConstraint('id') ) op.create_table('comments', - sa.Column('id', sa.Integer(), nullable=True), + sa.Column('id', sa.Integer(), nullable=False), sa.Column('text', sa.String(), nullable=True), - sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), - sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), sa.Column('author_id', sa.Integer(), nullable=False), sa.Column('deal_id', sa.Integer(), nullable=False), sa.ForeignKeyConstraint(['author_id'], ['users.id'], ondelete='CASCADE'), diff --git a/app/core/config.py b/app/core/config.py index ce49c59..28f5690 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -5,6 +5,7 @@ class Settings(BaseSettings): app_title: str database_url: str secret: str + debug: bool first_superuser_username: str | None = None first_superuser_email: str | None = None first_superuser_password: str | None = None diff --git a/app/core/database.py b/app/core/database.py index eab6c26..3f46b43 100644 --- a/app/core/database.py +++ b/app/core/database.py @@ -3,7 +3,7 @@ from app.core.config import settings -engine = create_async_engine(settings.database_url) +engine = create_async_engine(settings.database_url, echo=settings.debug, future=True) sync_session = async_sessionmaker(engine, expire_on_commit=False) diff --git a/app/crud/deals.py b/app/crud/deals.py index 7b21c1f..854c496 100644 --- a/app/crud/deals.py +++ b/app/crud/deals.py @@ -65,7 +65,7 @@ async def create_deal(self, data: DealCreateSchema, session: AsyncSession): attribute_names=[ "manager", "client", - "comment", + "comments", ], ) logger.info(f"Создана сделка {new_deal.name} id={new_deal.id}") diff --git a/app/models/clients.py b/app/models/clients.py index b86ea47..cfcd566 100644 --- a/app/models/clients.py +++ b/app/models/clients.py @@ -15,8 +15,8 @@ class Client(Base): id: Mapped[int] = mapped_column(primary_key=True) full_name: Mapped[str] = mapped_column(String(255)) - email: Mapped[str | None] = mapped_column(String(255), nullable=True) - phone: Mapped[str | None] = mapped_column(String(255), nullable=True) + email: Mapped[str | None] = mapped_column(String(255), nullable=True, unique=True) + phone: Mapped[str | None] = mapped_column(String(255), nullable=True, unique=True) manager_id: Mapped[int | None] = mapped_column( ForeignKey( "users.id", diff --git a/app/models/comments.py b/app/models/comments.py index 24e0212..98b0bab 100644 --- a/app/models/comments.py +++ b/app/models/comments.py @@ -14,7 +14,7 @@ class Comment(Base): __tablename__ = "comments" - id: Mapped[int] = mapped_column(primary_key=True, nullable=True) + id: Mapped[int] = mapped_column(primary_key=True) text: Mapped[str] = mapped_column(String, nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False diff --git a/app/models/deals.py b/app/models/deals.py index 85481c3..3aacdca 100644 --- a/app/models/deals.py +++ b/app/models/deals.py @@ -23,11 +23,11 @@ class StatusDeal(str, enum.Enum): class Deal(Base): __tablename__ = "deals" - id: Mapped[int] = mapped_column(primary_key=True, nullable=False) + id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[str | None] = mapped_column(String, nullable=True) status: Mapped[StatusDeal] = mapped_column( - Enum(StatusDeal), default=StatusDeal.new, nullable=False + Enum(StatusDeal, name="status_deal_enum"), default=StatusDeal.new, nullable=False ) price: Mapped[float] = mapped_column(Float, default=0, nullable=False) created_at: Mapped[datetime] = mapped_column( diff --git a/app/users/models.py b/app/users/models.py index 91ba5ea..604bdde 100644 --- a/app/users/models.py +++ b/app/users/models.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING from fastapi_users_db_sqlalchemy import SQLAlchemyBaseUserTable -from sqlalchemy import Enum +from sqlalchemy import Enum, String from sqlalchemy.orm import Mapped, mapped_column, relationship from app.core.database import Base @@ -22,19 +22,17 @@ class User(SQLAlchemyBaseUserTable[int], Base): __tablename__ = "users" id: Mapped[int] = mapped_column(primary_key=True) - username: Mapped[str] = mapped_column(nullable=False) - first_name: Mapped[str | None] = mapped_column(nullable=True) - last_name: Mapped[str | None] = mapped_column(nullable=True) - phone: Mapped[int | None] = mapped_column(nullable=True) + username: Mapped[str] = mapped_column(String(150), unique=True, nullable=False) + first_name: Mapped[str | None] = mapped_column(String(150), nullable=True) + last_name: Mapped[str | None] = mapped_column(String(150), nullable=True) + phone: Mapped[str | None] = mapped_column(String(20), nullable=True) role: Mapped[UserRole] = mapped_column( - Enum(UserRole), default=UserRole.manager, nullable=False + Enum(UserRole, name="user_role_enum"), default=UserRole.manager, nullable=False ) - clients: Mapped[list["Client"] | None] = relationship( + clients: Mapped[list["Client"]] = relationship( back_populates="manager", lazy="selectin" ) - deals: Mapped[list["Deal"] | None] = relationship( - back_populates="manager", lazy="selectin" - ) - comments: Mapped[list["Comment"] | None] = relationship( + deals: Mapped[list["Deal"]] = relationship(back_populates="manager", lazy="selectin") + comments: Mapped[list["Comment"]] = relationship( back_populates="author", lazy="selectin" ) diff --git a/app/users/schemas.py b/app/users/schemas.py index 5be8ff2..1d5ff46 100644 --- a/app/users/schemas.py +++ b/app/users/schemas.py @@ -11,7 +11,7 @@ class UserRead(schemas.BaseUser[int]): username: str first_name: str | None = None last_name: str | None = None - phone: int | None = None + phone: str | None = None role: UserRole clients: list[ClientReadSchema] | None = None @@ -23,7 +23,7 @@ class UserCreate(schemas.BaseUserCreate): username: str first_name: str | None = None last_name: str | None = None - phone: int | None = None + phone: str | None = None role: UserRole = UserRole.manager @@ -32,6 +32,6 @@ class UserUpdate(schemas.BaseUserUpdate): username: str first_name: str | None = None last_name: str | None = None - phone: int | None = None + phone: str | None = None role: UserRole = UserRole.manager clients: list | None = None diff --git a/requirements.txt b/requirements.txt index f31c39c..f7af835 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,7 @@ annotated-types==0.7.0 anyio==4.12.0 argon2-cffi==23.1.0 argon2-cffi-bindings==25.1.0 +asyncpg==0.31.0 bcrypt==4.3.0 black==25.11.0 certifi==2025.11.12 @@ -33,6 +34,7 @@ mypy_extensions==1.1.0 packaging==25.0 pathspec==0.12.1 platformdirs==4.5.0 +psycopg2-binary==2.9.11 pwdlib==0.2.1 pycodestyle==2.14.0 pycparser==2.23 From 5f8c955073a1a3d315c372aea40dcd290b958614 Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Thu, 25 Dec 2025 12:45:30 +0300 Subject: [PATCH 23/34] =?UTF-8?q?dockerfile=20=D0=B8=20docker-compose?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 10 ++++++++++ app/core/config.py | 6 ++++-- app/main.py | 7 ------- create_superuser.py | 11 +++++++++++ docker-compose.yaml | 24 ++++++++++++++++++++++++ 5 files changed, 49 insertions(+), 9 deletions(-) create mode 100644 Dockerfile create mode 100644 create_superuser.py create mode 100644 docker-compose.yaml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4afc5ff --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.11-slim +LABEL maintainer="Dmitry Titenkov " +LABEL version="1.0" +LABEL description="API for miniCRM" +RUN mkdir /app +COPY requirements.txt /app +RUN pip3 install -r /app/requirements.txt --no-cache-dir -vvv +COPY . /app +WORKDIR /app +CMD [ "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload" ] \ No newline at end of file diff --git a/app/core/config.py b/app/core/config.py index 28f5690..98ce597 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -11,8 +11,10 @@ class Settings(BaseSettings): first_superuser_password: str | None = None first_superuser_role: str | None = None - class Config: - env_file = ".env" + model_config = { + "env_file": ".env", + "extra": "ignore", + } settings = Settings() diff --git a/app/main.py b/app/main.py index 7825a6e..fed0052 100644 --- a/app/main.py +++ b/app/main.py @@ -1,11 +1,9 @@ import logging from contextlib import asynccontextmanager -import uvicorn from fastapi import FastAPI from app.core.config import settings -from app.core.init_db import create_first_superuser from app.core.logging import setup_logging from app.routers.clients import client_router from app.routers.comments import comment_router @@ -20,7 +18,6 @@ @asynccontextmanager async def lifespan(app: FastAPI): logging.info("Приложение miniCRM запущено") - await create_first_superuser() yield logging.info("Приложение miniCRM остановлено") @@ -31,7 +28,3 @@ async def lifespan(app: FastAPI): app.include_router(client_router) app.include_router(deal_router) app.include_router(comment_router) - - -if __name__ == "__main__": - uvicorn.run("app.main:app", reload=True) diff --git a/create_superuser.py b/create_superuser.py new file mode 100644 index 0000000..1373de8 --- /dev/null +++ b/create_superuser.py @@ -0,0 +1,11 @@ +import asyncio + +from app.core.init_db import create_first_superuser + + +async def main(): + await create_first_superuser() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..5476eef --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,24 @@ +version: '3.8' + +services: + db: + image: postgres:15.0-alpine + volumes: + - postgres_data:/var/lib/postgresql/data/ + ports: + - "5432:5432" + env_file: + - ./.env + backend: + build: . + restart: always + depends_on: + - db + env_file: + - ./.env + ports: + - "8000:8000" + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + +volumes: + postgres_data: From a84792b1e0c503956e1a48dc300a01079166c3f0 Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Fri, 26 Dec 2025 10:53:41 +0300 Subject: [PATCH 24/34] push to dockerhub --- .github/workflows/main.yaml | 28 ++++++++++++++++++++++++++-- docker-compose.yaml | 2 +- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 3bdd580..26045c0 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -1,6 +1,8 @@ name: miniCRM workflow -on: [push] +on: + push: + branches: [ dev ] jobs: lint: @@ -24,7 +26,7 @@ jobs: run: | python -m black . --check - - name: Test with flake8-isort(check only) + - name: Test with isort(check only) run: | python -m isort . --check-only @@ -32,4 +34,26 @@ jobs: run: | flake8 . --count --statistics --show-source + build_and_push_to_docker_hub: + name: miniCRM Push Docker Image to Docker Hub + runs-on: ubuntu-latest + needs: lint + steps: + - name: Check out the repo + uses: actions/checkout@v4 + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + - name: Push to Docker Hub + uses: docker/build-push-action@v5 + with: + push: true + tags: | + dmsn/minicrm:dev + dmsn/minicrm:dev-${{ github.sha::7 }} diff --git a/docker-compose.yaml b/docker-compose.yaml index 5476eef..2a9c7c6 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -10,7 +10,7 @@ services: env_file: - ./.env backend: - build: . + image: dmsn/minicrm:latest restart: always depends_on: - db From c2a74a4d532256c71d870b19c70e0f18eb35faf8 Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Fri, 26 Dec 2025 10:59:41 +0300 Subject: [PATCH 25/34] push to dockerhub --- .github/workflows/main.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 26045c0..5b419b7 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -56,4 +56,3 @@ jobs: push: true tags: | dmsn/minicrm:dev - dmsn/minicrm:dev-${{ github.sha::7 }} From 5f01821d3f651d96cf40489bf20e488ac5652cd0 Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Fri, 26 Dec 2025 11:18:58 +0300 Subject: [PATCH 26/34] push to dockerhub_dev_and_main --- .github/workflows/main.yaml | 36 +++++++++++++++++++++++++++++++++--- docker-compose.yaml | 2 +- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 5b419b7..7d582ae 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -2,7 +2,8 @@ name: miniCRM workflow on: push: - branches: [ dev ] + branches: + - '**' jobs: lint: @@ -34,10 +35,13 @@ jobs: run: | flake8 . --count --statistics --show-source - build_and_push_to_docker_hub: - name: miniCRM Push Docker Image to Docker Hub + push_branch_dev_to_docker_hub: + name: Build and Push Docker (dev) runs-on: ubuntu-latest needs: lint + + if: github.ref == 'refs/heads/dev' + steps: - name: Check out the repo uses: actions/checkout@v4 @@ -56,3 +60,29 @@ jobs: push: true tags: | dmsn/minicrm:dev + + push_branch_main_to_docker_hub: + name: Build and Push Docker (prod) + runs-on: ubuntu-latest + needs: lint + + if: github.ref == 'refs/heads/main' + + steps: + - name: Check out the repo + uses: actions/checkout@v4 + + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + - name: Push to Docker Hub + uses: docker/build-push-action@v5 + with: + push: true + tags: | + dmsn/minicrm:prod diff --git a/docker-compose.yaml b/docker-compose.yaml index 2a9c7c6..6cb5ca5 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -10,7 +10,7 @@ services: env_file: - ./.env backend: - image: dmsn/minicrm:latest + image: dmsn/minicrm:${{IMAGE_TAG}} restart: always depends_on: - db From 9c0250e413b1d1fc773845bbe302730d7b3bdbbc Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Fri, 26 Dec 2025 11:31:42 +0300 Subject: [PATCH 27/34] push to dockerhub_dev_and_main --- .github/workflows/{main.yaml => main.yml} | 0 README.md | 4 +++- 2 files changed, 3 insertions(+), 1 deletion(-) rename .github/workflows/{main.yaml => main.yml} (100%) diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yml similarity index 100% rename from .github/workflows/main.yaml rename to .github/workflows/main.yml diff --git a/README.md b/README.md index d918a7f..902b02a 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,6 @@ # minicrm API мини CRM приложеение -[![miniCRM workflow](https://github.com/dmsnback/minicrm/actions/workflows/main.yml/badge.svg)](https://github.com/dmsnback/minicrm/actions/workflows/main.yaml) +[![miniCRM lint](https://github.com/dmsnback/minicrm/actions/workflows/main.yml/badge.svg)](https://github.com/dmsnback/minicrm/actions/workflows/main.yml) +[![miniCRM Docker dev](https://github.com/dmsnback/minicrm/actions/workflows/main.yml/badge.svg?branch=dev)](https://github.com/dmsnback/minicrm/actions/workflows/main.yml) +[![miniCRM Docker prod](https://github.com/dmsnback/minicrm/actions/workflows/main.yml/badge.svg?branch=main)](https://github.com/dmsnback/minicrm/actions/workflows/main.yml) From e3be13b3efe854fdd17c878bb72ca8c97f7c06cd Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Fri, 26 Dec 2025 11:41:51 +0300 Subject: [PATCH 28/34] push to dockerhub_dev_and_main --- .github/workflows/main.yml | 1 + README.md | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 7d582ae..cc4bfb1 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -7,6 +7,7 @@ on: jobs: lint: + name: Lint runs-on: ubuntu-latest steps: diff --git a/README.md b/README.md index 902b02a..0b7dc36 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # minicrm API мини CRM приложеение -[![miniCRM lint](https://github.com/dmsnback/minicrm/actions/workflows/main.yml/badge.svg)](https://github.com/dmsnback/minicrm/actions/workflows/main.yml) -[![miniCRM Docker dev](https://github.com/dmsnback/minicrm/actions/workflows/main.yml/badge.svg?branch=dev)](https://github.com/dmsnback/minicrm/actions/workflows/main.yml) -[![miniCRM Docker prod](https://github.com/dmsnback/minicrm/actions/workflows/main.yml/badge.svg?branch=main)](https://github.com/dmsnback/minicrm/actions/workflows/main.yml) +[![miniCRM Lint](https://github.com/dmsnback/minicrm/actions/workflows/main.yml/badge.svg)](https://github.com/dmsnback/minicrm/actions/workflows/main.yml) +[![miniCRM Docker (dev)](https://github.com/dmsnback/minicrm/actions/workflows/main.yml/badge.svg?branch=dev)](https://github.com/dmsnback/minicrm/actions/workflows/main.yml) +[![miniCRM Docker (prod)](https://github.com/dmsnback/minicrm/actions/workflows/main.yml/badge.svg?branch=main)](https://github.com/dmsnback/minicrm/actions/workflows/main.yml) From 37fced12a7b910cd3affd8153b72bc9b5a4d9486 Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Fri, 26 Dec 2025 11:53:26 +0300 Subject: [PATCH 29/34] push to dockerhub_dev_and_main --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0b7dc36..689f65a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # minicrm API мини CRM приложеение -[![miniCRM Lint](https://github.com/dmsnback/minicrm/actions/workflows/main.yml/badge.svg)](https://github.com/dmsnback/minicrm/actions/workflows/main.yml) -[![miniCRM Docker (dev)](https://github.com/dmsnback/minicrm/actions/workflows/main.yml/badge.svg?branch=dev)](https://github.com/dmsnback/minicrm/actions/workflows/main.yml) -[![miniCRM Docker (prod)](https://github.com/dmsnback/minicrm/actions/workflows/main.yml/badge.svg?branch=main)](https://github.com/dmsnback/minicrm/actions/workflows/main.yml) +[![miniCRM Lint](https://img.shields.io/github/actions/workflow/status/dmsnback/minicrm/main.yml?branch=**&style=flat-square&label=Lint%20)](https://github.com/dmsnback/minicrm/actions/workflows/main.yml) +[![miniCRM Docker Dev](https://img.shields.io/github/actions/workflow/status/dmsnback/minicrm/main.yml?branch=dev&style=flat-square&label=Docker%20Dev)](https://github.com/dmsnback/minicrm/actions/workflows/main.yml) +[![miniCRM Docker Prod](https://img.shields.io/github/actions/workflow/status/dmsnback/minicrm/main.yml?branch=main&style=flat-square&label=Docker%20Prod)](https://github.com/dmsnback/minicrm/actions/workflows/main.yml) From 4482fd2e9286e8cbcd531d76e0193b301f2d32bb Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Fri, 26 Dec 2025 11:59:03 +0300 Subject: [PATCH 30/34] push to dockerhub_dev_and_main --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 689f65a..447939c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # minicrm API мини CRM приложеение -[![miniCRM Lint](https://img.shields.io/github/actions/workflow/status/dmsnback/minicrm/main.yml?branch=**&style=flat-square&label=Lint%20)](https://github.com/dmsnback/minicrm/actions/workflows/main.yml) -[![miniCRM Docker Dev](https://img.shields.io/github/actions/workflow/status/dmsnback/minicrm/main.yml?branch=dev&style=flat-square&label=Docker%20Dev)](https://github.com/dmsnback/minicrm/actions/workflows/main.yml) -[![miniCRM Docker Prod](https://img.shields.io/github/actions/workflow/status/dmsnback/minicrm/main.yml?branch=main&style=flat-square&label=Docker%20Prod)](https://github.com/dmsnback/minicrm/actions/workflows/main.yml) +[![miniCRM Lint](https://img.shields.io/github/actions/workflow/status/dmsnback/minicrm/main.yml?branch=main&style=flat-square&label=miniCRM%20DLint)](https://github.com/dmsnback/minicrm/actions/workflows/main.yml) +[![miniCRM Docker Dev](https://img.shields.io/github/actions/workflow/status/dmsnback/minicrm/main.yml?branch=dev&style=flat-square&label=miniCRM%20DDocker%20Dev)](https://github.com/dmsnback/minicrm/actions/workflows/main.yml) +[![miniCRM Docker Prod](https://img.shields.io/github/actions/workflow/status/dmsnback/minicrm/main.yml?branch=main&style=flat-square&label=miniCRM%20Docker%20Prod)](https://github.com/dmsnback/minicrm/actions/workflows/main.yml) From 7d6e799d798892ca678e1753fb927e09a607771c Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Fri, 26 Dec 2025 12:00:20 +0300 Subject: [PATCH 31/34] push to dockerhub_dev_and_main --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 447939c..3e3071f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # minicrm API мини CRM приложеение -[![miniCRM Lint](https://img.shields.io/github/actions/workflow/status/dmsnback/minicrm/main.yml?branch=main&style=flat-square&label=miniCRM%20DLint)](https://github.com/dmsnback/minicrm/actions/workflows/main.yml) -[![miniCRM Docker Dev](https://img.shields.io/github/actions/workflow/status/dmsnback/minicrm/main.yml?branch=dev&style=flat-square&label=miniCRM%20DDocker%20Dev)](https://github.com/dmsnback/minicrm/actions/workflows/main.yml) +[![miniCRM Lint](https://img.shields.io/github/actions/workflow/status/dmsnback/minicrm/main.yml?branch=main&style=flat-square&label=miniCRM%20Lint)](https://github.com/dmsnback/minicrm/actions/workflows/main.yml) +[![miniCRM Docker Dev](https://img.shields.io/github/actions/workflow/status/dmsnback/minicrm/main.yml?branch=dev&style=flat-square&label=miniCRM%20Docker%20Dev)](https://github.com/dmsnback/minicrm/actions/workflows/main.yml) [![miniCRM Docker Prod](https://img.shields.io/github/actions/workflow/status/dmsnback/minicrm/main.yml?branch=main&style=flat-square&label=miniCRM%20Docker%20Prod)](https://github.com/dmsnback/minicrm/actions/workflows/main.yml) From 5735440bb42dbdbd949b48681123af3fb16842e7 Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Fri, 26 Dec 2025 22:47:55 +0300 Subject: [PATCH 32/34] push to dockerhub_dev --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 3e3071f..a9456b6 100644 --- a/README.md +++ b/README.md @@ -3,4 +3,3 @@ API мини CRM приложеение [![miniCRM Lint](https://img.shields.io/github/actions/workflow/status/dmsnback/minicrm/main.yml?branch=main&style=flat-square&label=miniCRM%20Lint)](https://github.com/dmsnback/minicrm/actions/workflows/main.yml) [![miniCRM Docker Dev](https://img.shields.io/github/actions/workflow/status/dmsnback/minicrm/main.yml?branch=dev&style=flat-square&label=miniCRM%20Docker%20Dev)](https://github.com/dmsnback/minicrm/actions/workflows/main.yml) -[![miniCRM Docker Prod](https://img.shields.io/github/actions/workflow/status/dmsnback/minicrm/main.yml?branch=main&style=flat-square&label=miniCRM%20Docker%20Prod)](https://github.com/dmsnback/minicrm/actions/workflows/main.yml) From c700c56fd4040e5637e64b4cd80dafe4852c51cc Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Sun, 28 Dec 2025 14:26:19 +0300 Subject: [PATCH 33/34] readme.md --- README.md | 163 +++++++++++++++++++++++++++++++++++++++- app/routers/comments.py | 6 +- docker-compose.yaml | 2 +- env.example | 11 +++ 4 files changed, 177 insertions(+), 5 deletions(-) create mode 100644 env.example diff --git a/README.md b/README.md index a9456b6..b214549 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,166 @@ -# minicrm +# miniCRM + API мини CRM приложеение [![miniCRM Lint](https://img.shields.io/github/actions/workflow/status/dmsnback/minicrm/main.yml?branch=main&style=flat-square&label=miniCRM%20Lint)](https://github.com/dmsnback/minicrm/actions/workflows/main.yml) [![miniCRM Docker Dev](https://img.shields.io/github/actions/workflow/status/dmsnback/minicrm/main.yml?branch=dev&style=flat-square&label=miniCRM%20Docker%20Dev)](https://github.com/dmsnback/minicrm/actions/workflows/main.yml) +[![miniCRM Docker Prod](https://img.shields.io/github/actions/workflow/status/dmsnback/minicrm/main.yml?branch=main&style=flat-square&label=miniCRM%20Docker%20Prod)](https://github.com/dmsnback/minicrm/actions/workflows/main.yml) + + +- [Описание](#Описание) +- [Технологии](#Технологии) +- [Таблица эндпоинтов](#Таблица) +- [Шаблон заполнения .env-файла](#Шаблон) +- [Запуск проекта на локальной машине](#Запуск) +- [Автор](#Автор) + + +### Описание + +MiniCRM — это backend-сервис для управления клиентами, сделками и комментариями, реализованный на FastAPI с асинхронной работой через PostgreSQL. + +Проект ориентирован на практическую разработку API с авторизацией, ролями пользователей и правами доступа. + +REST API для управления: + +- Клиентами (CRUD) +- Сделками (CRUD, статусы, фильтрация по менеджерам) +- Комментариями к сделкам +- Пользователями с ролями (admin / manager) +- Авторизацией через JWT + +Приложение написано с использованием **асинхронного FastAPI**, **SQLAlchemy**, **PostgreSQL** и **FastAPI Users**. + + +> [Вернуться в начало](#Начало) + + +### Технологии + + +[![Python](https://img.shields.io/badge/Python-1000?style=for-the-badge&logo=python&logoColor=ffffff&labelColor=000000&color=000000)](https://www.python.org) +[![FastAPI](https://img.shields.io/badge/FastAPI-1000?style=for-the-badge&logo=fastapi&logoColor=ffffff&labelColor=000000&color=000000)](https://fastapi.tiangolo.com) +[![FastAPI Users](https://img.shields.io/badge/FastAPI_Users-1000?style=for-the-badge&logoColor=ffffff&labelColor=000000&color=000000)](https://fastapi-users.github.io/fastapi-users/latest/) +[![SQLAlchemy](https://img.shields.io/badge/SQLAlchemy-1000?style=for-the-badge&logo=sqlalchemy&logoColor=ffffff&labelColor=000000&color=000000)](https://www.sqlalchemy.org) +[![Pydantic](https://img.shields.io/badge/Pydantic_V2-1000?style=for-the-badge&logo=Pydantic&logoColor=ffffff&labelColor=000000&color=000000)](https://docs.pydantic.dev/latest/) +[![Docker](https://img.shields.io/badge/Docker-1000?style=for-the-badge&logo=docker&logoColor=ffffff&labelColor=000000&color=000000)](https://www.docker.com) +[![Postgres](https://img.shields.io/badge/Postgres-1000?style=for-the-badge&logo=postgresql&logoColor=ffffff&labelColor=000000&color=000000)](https://www.postgresql.org) +[![GitHub Actions](https://img.shields.io/badge/github%20actions-%232671E5.svg?style=for-the-badge&logo=githubactions&logoColor=ffffff&labelColor=000000&color=000000)](https://github.com/features/actions) + +> [Вернуться в начало](#Начало) + + +### Таблица эндпоинтов + +**Авторизация** + +|Метод|URL|Описание|Доступ| +|:-:|:-:|:-:|:-:| +|POST|/auth/register|Регистрация новых менеджеров|Админ| +|POST|/auth/jwt/login|получение JWT токена|Админ
Менеджер| + +**Пользователи** + +|Метод|URL|Описание|Доступ| +|:-:|:-:|:-:|:-:| +|GET|/users/all|Список всеех менеджеров|Админ| +|GET|/users/{user_id}/clients|Получение списка клиентов менеджра по id|Админ| +|GET|/users/me|Получение текущего юзера|Админ
Менеджер| +|PATCH|/users/{id}|Редактирование юзера|Админ| +|DELETE|/users/{id}|Удаление юзера|Админ| + +**Клиенты** + +|Метод|URL|Описание|Доступ| +|:-:|:-:|:-:|:-:| +|GET|/clients/all|Список всех клиентов
(Админ видит всех, менеджер - своих)|Админ
Менеджер| +|GET|/clients/{clients_id}|Получение клиента по id|Админ
Менеджер| +|PATCH|/clients/{clients_id}|Редактирование клиента|Админ
Менеджер| +|DELETE|/clients/{clients_id}|Удаление клиентв|Админ| +|POST|/clients|Добавление нового клиента|Админ
Менеджер| + +**Сделки** + +|Метод|URL|Описание|Доступ| +|:-:|:-:|:-:|:-:| +|GET|/deals/all|Список всех сделок
(Админ видит вс своих)|Админ
Менеджер| +|GET|/deals/{deals_id}|Получение сделки по id|Админ
Менеджер| +|PATCH|/deals/{deals_id}|Редактирование сделки|Админ
Менеджер| +|DELETE|/deals/{deals_id}|Удаление сделки|Админ| +|POST|/deals|Добавление новой сделки|Админ
Менеджер| + +**Комментарии** + +|Метод|URL|Описание|Доступ| +|:-:|:-:|:-:|:-:| +|GET|/comments/{deal_id}/comments|Получить все комментарии сделки|Админ
Менеджер| +|POST|/comments/{deal_id}/comments|Добавление комментария к сделке|Админ
Менеджер| +|PATCH|/comments/{deal_id}/comments/{comment_id}|Редактирование комментария|Админ
Менеджер| +|DELETE|/comments/{deal_id}/comments/{comment_id}|Удаление комментария|Админ| + +> [Вернуться в начало](#Начало) + + + +### Шаблон заполнения .env-файла + +> `env.example` с дефолтнными значениями расположен в корневой папке + +```python +POSTGRES_DB = minicrmdb # Имя базы дданнных +POSTGRES_USER = postgres # Имя юзера PostgreSQL +POSTGRES_PASSWORD = yourpassword # Пароль юзера PostgreSQL +DATABASE_URL = postgresql+asyncpg://postgres:yourpassword@db:5432/minicrmdb # Указываем адрес БД +DEBUG = False # Включеение/Выключение режима отладки +APP_TITLE = МиниCRM приложение # Название приложения +SECRET = SUPERSECRETKEY # Секретный ключ для подписания JWT токенов +FIRST_SUPERUSER_USERNAME = superadmin # Указываеем usernsme для суперюзера +FIRST_SUPERUSER_EMAIL = superadmin@mail.com # Указываеем почту для суперюзера +FIRST_SUPERUSER_PASSWORD = superadmin # Указываеем пароль для суперюзера +FIRST_SUPERUSER_ROLE = admin # Указываеем роль для суперюзера + +``` + +> [Вернуться в начало](#Начало) + + + +### Запуск проекта на локальной машине + +- Склонируйте репозиторий + +```python +git clone git@github.com:dmsnback/minicrm.git +``` + +- Запускаем Docker контейнер + +```python +docker-compose up -d --build +``` + +- Выполняем миграции + +```python +docker-compose exec backend alembic upgrade head +``` + +- Создаём суперюзера + +```python +docker-compose exec backend python create_superuser.py +``` + +> __Документация к API будет доступна по адресу:__ + +[http://localhost:8000/docs/](http://localhost:8000/docs/) + +> [Вернуться в начало](#Начало) + + + +### Автор + +- [Титенков Дмитрий](https://github.com/dmsnback) + +> [Вернуться в начало](#Начало) diff --git a/app/routers/comments.py b/app/routers/comments.py index 0fb8f5f..7b61447 100644 --- a/app/routers/comments.py +++ b/app/routers/comments.py @@ -22,7 +22,7 @@ @comment_router.get( - "", response_model=list[CommentReadSchema], summary="Получить комментарии сдделки" + "", response_model=list[CommentReadSchema], summary="Получить комментарии сделки" ) async def get_comments_deal( deal_id: int, @@ -54,7 +54,7 @@ async def create_comment( @comment_router.patch( "/{comment_id}", response_model=CommentReadSchema, - summary="Измеенеение ккомментария", + summary="Изменение ккомментария", ) async def update_comment( deal_id: int, @@ -74,7 +74,7 @@ async def update_comment( @comment_router.delete( "/{comment_id}", - summary="Удаление ккомментария", + summary="Удаление комментария", description="Доступно только администратору", ) async def delete_commennt( diff --git a/docker-compose.yaml b/docker-compose.yaml index 6cb5ca5..4d545c8 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -10,7 +10,7 @@ services: env_file: - ./.env backend: - image: dmsn/minicrm:${{IMAGE_TAG}} + image: dmsn/minicrm:dev restart: always depends_on: - db diff --git a/env.example b/env.example new file mode 100644 index 0000000..7060e75 --- /dev/null +++ b/env.example @@ -0,0 +1,11 @@ +POSTGRES_DB = minicrmdb +POSTGRES_USER = postgres +POSTGRES_PASSWORD = yourpassword +DATABASE_URL = postgresql+asyncpg://postgres:yourpassword@db:5432/minicrmdb +DEBUG = False +APP_TITLE = МиниCRM приложение +SECRET = SUPERSECRETKEY +FIRST_SUPERUSER_USERNAME = superadmin +FIRST_SUPERUSER_EMAIL = superadmin@mail.com +FIRST_SUPERUSER_PASSWORD = superadmin +FIRST_SUPERUSER_ROLE = admin From bb135aad3bb74d39aa55f8223a5163f26010be1d Mon Sep 17 00:00:00 2001 From: Dmitry Titenkov Date: Sun, 28 Dec 2025 14:35:14 +0300 Subject: [PATCH 34/34] prod --- docker-compose.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index 4d545c8..1890346 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -10,7 +10,7 @@ services: env_file: - ./.env backend: - image: dmsn/minicrm:dev + image: dmsn/minicrm:prod restart: always depends_on: - db