diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..3717fca --- /dev/null +++ b/.flake8 @@ -0,0 +1,12 @@ +[flake8] +max-line-length = 90 +extend-ignore = E501, E203, W503 +exclude = + .git, + __pycache__, + .tox, + .eggs, + *.egg, + .venv, + venv, + alembic/ \ No newline at end of file diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..cc4bfb1 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,89 @@ +name: miniCRM workflow + +on: + push: + branches: + - '**' + +jobs: + lint: + name: 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 isort(check only) + run: | + python -m isort . --check-only + + - name: Test with flake8 + run: | + flake8 . --count --statistics --show-source + + 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 + + - 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 + + 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/.gitignore b/.gitignore index b7faf40..cb72d74 100644 --- a/.gitignore +++ b/.gitignore @@ -205,3 +205,8 @@ cython_debug/ marimo/_static/ marimo/_lsp/ __marimo__/ + +minicrm.db + +logs/ +*.log 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/README.md b/README.md index 0cc1976..b214549 100644 --- a/README.md +++ b/README.md @@ -1,2 +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/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..dcb3d72 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,95 @@ +import asyncio +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 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/97e3968a1bf2_init_migration.py b/alembic/versions/97e3968a1bf2_init_migration.py new file mode 100644 index 0000000..c8b1443 --- /dev/null +++ b/alembic/versions/97e3968a1bf2_init_migration.py @@ -0,0 +1,87 @@ +"""Init migration + +Revision ID: 97e3968a1bf2 +Revises: +Create Date: 2025-12-24 15:20:15.991785 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +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 + + +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(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.UniqueConstraint('username') + ) + 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'), + 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='status_deal_enum'), nullable=False), + sa.Column('price', sa.Float(), 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'), + sa.ForeignKeyConstraint(['manager_id'], ['users.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('comments', + 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('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'), + 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/__init__.py b/app/__init__.py index e69de29..aea851a 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -0,0 +1,4 @@ +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/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/base.py b/app/core/base.py new file mode 100644 index 0000000..dfa3570 --- /dev/null +++ b/app/core/base.py @@ -0,0 +1,7 @@ +"""Импорты класса Base и всех моделей для Alembic.""" + +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/core/config.py b/app/core/config.py new file mode 100644 index 0000000..98ce597 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,20 @@ +from pydantic_settings import BaseSettings + + +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 + first_superuser_role: str | None = None + + model_config = { + "env_file": ".env", + "extra": "ignore", + } + + +settings = Settings() diff --git a/app/core/database.py b/app/core/database.py new file mode 100644 index 0000000..3f46b43 --- /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=settings.debug, future=True) + +sync_session = async_sessionmaker(engine, expire_on_commit=False) + + +class Base(DeclarativeBase): + pass + + +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 new file mode 100644 index 0000000..3f0c6fd --- /dev/null +++ b/app/core/init_db.py @@ -0,0 +1,77 @@ +import contextlib +import logging +from typing import Literal + +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.manager import get_user_db, get_user_manager +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, + role: Literal["admin", "manager"] = "manager", +): + 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_manager: + try: + await user_manager.create( + UserCreate( + username=username, + email=email, + password=password, + is_superuser=is_superuser, + role=role, + ) + ) + logger.info(f"Пользователь {username} создан") + except UserAlreadyExists: + logger.warning(f"Пользователь {username} уже сужествует") + except Exception as e: + logger.error(f"Ошибка при создании пользователя {username}: {e}") + + +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 + ): + 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..78004f0 --- /dev/null +++ b/app/core/logging.py @@ -0,0 +1,28 @@ +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/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..ef6586f --- /dev/null +++ b/app/crud/clients.py @@ -0,0 +1,101 @@ +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.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, + validate_unique_phone_client, +) + +logger = logging.getLogger(__name__) + + +class CRUDClient: + + async def get_all_clients(self, session: AsyncSession, user: User): + try: + 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("Получен список клиентов") + return clients + except SQLAlchemyError as e: + logger.error(f"Ошибка приполучении списка клиентов: {e}") + raise + + async def get_client(self, client_id: int, session: AsyncSession, user: 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: + 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: + 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() + 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: + 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/crud/comment.py b/app/crud/comment.py new file mode 100644 index 0000000..f6fe185 --- /dev/null +++ b/app/crud/comment.py @@ -0,0 +1,98 @@ +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, CommentUpdateSchema +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: int, 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( + 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(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 new file mode 100644 index 0000000..854c496 --- /dev/null +++ b/app/crud/deals.py @@ -0,0 +1,101 @@ +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.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), selectinload(Deal.comments) + ) + 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), + selectinload(Deal.client), + selectinload(Deal.comments).selectinload(Comment.author), + ) + ) + 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() + await session.refresh( + new_deal, + attribute_names=[ + "manager", + "client", + "comments", + ], + ) + 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.error(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/crud/users.py b/app/crud/users.py new file mode 100644 index 0000000..1956b98 --- /dev/null +++ b/app/crud/users.py @@ -0,0 +1,45 @@ +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.clients import Client +from app.users.models import User + +logger = logging.getLogger(__name__) + + +class CRUDUser: + 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 + + 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/main.py b/app/main.py index e69de29..fed0052 100644 --- a/app/main.py +++ b/app/main.py @@ -0,0 +1,30 @@ +import logging +from contextlib import asynccontextmanager + +from fastapi import FastAPI + +from app.core.config import settings +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 + +setup_logging() + +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + logging.info("Приложение miniCRM запущено") + yield + logging.info("Приложение miniCRM остановлено") + + +app = FastAPI(title=settings.app_title, lifespan=lifespan) + +app.include_router(user_router) +app.include_router(client_router) +app.include_router(deal_router) +app.include_router(comment_router) diff --git a/app/models/clients.py b/app/models/clients.py new file mode 100644 index 0000000..cfcd566 --- /dev/null +++ b/app/models/clients.py @@ -0,0 +1,28 @@ +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.models.deals import Deal + from app.users.models import User + + +class Client(Base): + __tablename__ = "clients" + + 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, unique=True) + phone: Mapped[str | None] = mapped_column(String(255), nullable=True, unique=True) + manager_id: Mapped[int | None] = mapped_column( + ForeignKey( + "users.id", + ondelete="SET NULL", + ), + nullable=True, + ) + manager: Mapped["User"] = relationship(back_populates="clients", lazy="selectin") + deals: Mapped[list["Deal"]] = relationship(back_populates="client", lazy="selectin") diff --git a/app/models/comments.py b/app/models/comments.py new file mode 100644 index 0000000..98b0bab --- /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) + 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") + 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/models/deals.py b/app/models/deals.py new file mode 100644 index 0000000..3aacdca --- /dev/null +++ b/app/models/deals.py @@ -0,0 +1,52 @@ +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.models.comments import Comment + 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) + 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, 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( + 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", lazy="selectin") + client_id: Mapped[int] = mapped_column( + 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" + ) diff --git a/app/routers/clients.py b/app/routers/clients.py new file mode 100644 index 0000000..d7bab8a --- /dev/null +++ b/app/routers/clients.py @@ -0,0 +1,113 @@ +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.manager import current_superuser, current_user +from app.users.models import User + +client_router = APIRouter( + prefix="/clients", + tags=[ + "Клиенты", + ], +) +crud_client = CRUDClient() + + +@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_client.get_all_clients(session, current_user) + return clients + + +@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_client.get_client(client_id, session, current_user) + if not client: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Клиент не найден" + ) + return client + + +@client_router.post( + "", + response_model=ClientReadSchema, + dependencies=[Depends(current_user)], + summary="Добавление клиента", +) +async def create_client( + data: ClientCreateSchema, + session: AsyncSession = Depends(get_session), +) -> ClientReadSchema: + try: + client = await crud_client.create_client(data, session) + return client + except Exception as e: + raise e + + +@client_router.patch( + "/{client_id}", + response_model=ClientReadSchema, + summary="Обновление клиента", + description="Передавайте только те поля, которые нужно изменить", +) +async def update_client( + client_id: int, + data: ClientUpdateSchema, + session: AsyncSession = Depends(get_session), + user: User = Depends(current_user), +) -> ClientReadSchema: + 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_client.update_client(client, data, session) + return upd_client + except Exception: + raise HTTPException( + status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Ошибка при изменении клиента" + ) + + +@client_router.delete( + "/{client_id}", + summary="Удаление клиента", + description="Доступно только администратору", +) +async def delete_client( + client_id: int, + session: AsyncSession = Depends(get_session), + user: User = Depends(current_superuser), +) -> 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_client.delete_client(client, session) + return {"detail": "Клиент удалён"} + except Exception: + raise HTTPException( + status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Ошибка при удалении клиента" + ) diff --git a/app/routers/comments.py b/app/routers/comments.py new file mode 100644 index 0000000..7b61447 --- /dev/null +++ b/app/routers/comments.py @@ -0,0 +1,92 @@ +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, + CommentReadSchema, + CommentUpdateSchema, +) +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( + "/{comment_id}", + response_model=CommentReadSchema, + summary="Изменение ккомментария", +) +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( + "/{comment_id}", + summary="Удаление комментария", + description="Доступно только администратору", +) +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}") diff --git a/app/routers/deals.py b/app/routers/deals.py new file mode 100644 index 0000000..da606d9 --- /dev/null +++ b/app/routers/deals.py @@ -0,0 +1,121 @@ +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) + 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 new file mode 100644 index 0000000..9f5e767 --- /dev/null +++ b/app/routers/users.py @@ -0,0 +1,63 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_session +from app.crud.users import CRUDUser +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=["Пользователи"], +) + +crud = CRUDUser() + + +@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", + 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]: + 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", +) +user_router.include_router( + fastapi_users.get_register_router(UserRead, UserCreate), + prefix="/auth", + dependencies=[Depends(current_superuser)], +) +user_router.include_router( + fastapi_users.get_users_router(UserRead, UserUpdate), prefix="/users" +) diff --git a/app/schemas/clients.py b/app/schemas/clients.py new file mode 100644 index 0000000..e62ab97 --- /dev/null +++ b/app/schemas/clients.py @@ -0,0 +1,36 @@ +from pydantic import BaseModel, ConfigDict, EmailStr, Field + + +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 = Field(..., description="Полное имя клиента") + 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/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 new file mode 100644 index 0000000..44857fa --- /dev/null +++ b/app/schemas/deals.py @@ -0,0 +1,56 @@ +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + +from app.models.deals import StatusDeal +from app.schemas.comments import CommentReadSchema + + +class ManagerShortSchema(BaseModel): + id: int + username: str + + +class ClientShortSchema(BaseModel): + id: int + full_name: str + + +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 + comments: list | None = None + + model_config = ConfigDict(from_attributes=True) + + +class DealReadSchema(DealBaseSchema): + + id: int + created_at: datetime + updated_at: datetime + client: ClientShortSchema + manager: ManagerShortSchema | None + comments: list[CommentReadSchema] | None = None + + model_config = ConfigDict(from_attributes=True) 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..1cdf826 --- /dev/null +++ b/app/users/manager.py @@ -0,0 +1,62 @@ +from fastapi import Depends, Request +from fastapi_users import ( + BaseUserManager, + FastAPIUsers, + IntegerIDMixin, + InvalidPasswordException, +) +from fastapi_users.authentication import ( + AuthenticationBackend, + BearerTransport, + JWTStrategy, +) +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 + +bearer_transport = 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_transport, 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_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) + + +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/users/models.py b/app/users/models.py new file mode 100644 index 0000000..604bdde --- /dev/null +++ b/app/users/models.py @@ -0,0 +1,38 @@ +import enum +from typing import TYPE_CHECKING + +from fastapi_users_db_sqlalchemy import SQLAlchemyBaseUserTable +from sqlalchemy import Enum, String +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.models.comments import Comment + from app.models.deals import Deal + + +class UserRole(str, enum.Enum): + manager = "manager" + admin = "admin" + + +class User(SQLAlchemyBaseUserTable[int], Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(primary_key=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, name="user_role_enum"), default=UserRole.manager, nullable=False + ) + clients: Mapped[list["Client"]] = relationship( + back_populates="manager", lazy="selectin" + ) + 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 new file mode 100644 index 0000000..1d5ff46 --- /dev/null +++ b/app/users/schemas.py @@ -0,0 +1,37 @@ +from fastapi_users import schemas +from pydantic import ConfigDict + +from app.schemas.clients import ClientReadSchema +from app.users.models import UserRole + + +class UserRead(schemas.BaseUser[int]): + id: int + email: str + username: str + first_name: str | None = None + last_name: str | None = None + phone: str | None = None + role: UserRole + clients: list[ClientReadSchema] | None = None + + model_config = ConfigDict(from_attributes=True) + + +class UserCreate(schemas.BaseUserCreate): + email: str + username: str + first_name: str | None = None + last_name: str | None = None + phone: str | None = None + role: UserRole = UserRole.manager + + +class UserUpdate(schemas.BaseUserUpdate): + email: str + username: str + first_name: str | None = None + last_name: str | None = None + phone: str | None = None + role: UserRole = UserRole.manager + clients: list | None = None diff --git a/app/validators/__init__.py b/app/validators/__init__.py new file mode 100644 index 0000000..e69de29 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/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..1890346 --- /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: + image: dmsn/minicrm:prod + 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: 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 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3d47a6f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,19 @@ +[tool.black] +line-length = 90 +target-version = ['py311'] +extend-exclude = ''' +( + \.venv + | venv + | build + | dist + | alembic +) +''' +[tool.isort] +profile = "black" +line_length = 90 +multi_line_output = 3 +skip_gitignore = true +skip_glob = ["**/alembic/*"] +src_paths = ["app"] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index d947460..f7af835 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,16 +1,51 @@ 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 +asyncpg==0.31.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 +psycopg2-binary==2.9.11 +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