├── src
├── __init__.py
├── auth
│ ├── __init__.py
│ ├── utils.py
│ ├── schemas.py
│ ├── models.py
│ ├── crud.py
│ ├── routers.py
│ └── base_config.py
├── chat
│ ├── __init__.py
│ ├── exceptions.py
│ ├── redis.py
│ ├── models.py
│ ├── schemas.py
│ ├── routers.py
│ └── utils.py
├── likes
│ ├── __init__.py
│ ├── schemas.py
│ ├── models.py
│ ├── routers.py
│ └── crud.py
├── matches
│ ├── __init__.py
│ ├── utils.py
│ ├── models.py
│ ├── routers.py
│ └── crud.py
├── mongodb
│ ├── __init__.py
│ └── mongodb.py
├── redis
│ ├── __init__.py
│ └── redis.py
├── questionnaire
│ ├── __init__.py
│ ├── schemas.py
│ ├── params_choice.py
│ ├── routers.py
│ ├── models.py
│ └── crud.py
├── admin
│ ├── utils.py
│ ├── views.py
│ ├── auth_provider.py
│ └── __init__.py
├── database.py
├── exceptions.py
├── main.py
└── config.py
├── tests
├── __init__.py
├── with_db
│ ├── __init__.py
│ ├── test_models.py
│ ├── test_likes.py
│ ├── test_chat.py
│ └── test_questionnaire.py
├── conftest.py
├── fixtures.py
├── test_matches.py
├── test_auth.py
└── test_acceptance.py
├── static
└── img
│ ├── logo.png
│ └── admin.png
├── .dockerignore
├── pytest.ini
├── migrations
├── README
├── script.py.mako
├── versions
│ ├── 84f11c7d251e_nullable_id_false.py
│ ├── afd4c92e479a_new_get_quest_rools.py
│ ├── 5c47a192ce1d_add_age_unqique_constraint_quest_user_.py
│ ├── aa83adadcdb2_add_new_auth.py
│ ├── 5478d7d7a63f_.py
│ ├── 51e208efbb6b_add_message.py
│ ├── 4f21deed5f82_add_to_id_to_message_make_text_nullable_.py
│ ├── fa5f9699e17b_new_quest_model.py
│ └── c347c3783bc4_init.py
└── env.py
├── .pre-commit-config.yaml
├── Dockerfile
├── data
└── reserved_keywords.txt
├── .env-example
├── README.md
├── templates
└── admin_login.html
├── docker-compose.yml
├── alembic.ini
├── .gitignore
├── pyproject.toml
└── .github
└── workflows
└── pr_check.yml
/src/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/auth/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/chat/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/likes/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/matches/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/mongodb/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/redis/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/with_db/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/questionnaire/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/static/img/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/waterstark/mir/HEAD/static/img/logo.png
--------------------------------------------------------------------------------
/static/img/admin.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/waterstark/mir/HEAD/static/img/admin.png
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | .git/
2 | pg_data/
3 | .env
4 | #.env-example
5 | .gitignore
6 | venv/
7 | jwt-private.pem
8 | jwt-public.pem
9 | **/*__pycache__*
--------------------------------------------------------------------------------
/pytest.ini:
--------------------------------------------------------------------------------
1 | [pytest]
2 | asyncio_mode = auto
3 | addopts = -p no:logging
4 | pythonpath = [".", "src"]
5 | filterwarnings =
6 | ignore::DeprecationWarning
--------------------------------------------------------------------------------
/migrations/README:
--------------------------------------------------------------------------------
1 | # для того, что бы сделать миграции со всеми нужными параметрами,
2 | нужно импортировать из файла params_choice списки, они интуитивно названы как и поля в модели,
3 | и подставить их в параметры Choice_type
4 | иначе alembic неправильно подставляет значения
--------------------------------------------------------------------------------
/src/chat/exceptions.py:
--------------------------------------------------------------------------------
1 | from uuid import UUID
2 |
3 |
4 | class NoMatchError(Exception):
5 | """Raised when somebody tries to chat with a user with whom he/she has no match."""
6 | def __init__(self, user1_id: UUID, user2_id: UUID):
7 | super().__init__(f"No match for users {user1_id} and {user2_id}")
8 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | repos:
2 | - repo: https://github.com/astral-sh/ruff-pre-commit
3 | rev: v0.0.282
4 | hooks:
5 | - id: ruff
6 | args: ["--config", "pyproject.toml", "--fix", "--exit-non-zero-on-fix"]
7 | - repo: https://github.com/psf/black
8 | rev: 22.10.0
9 | hooks:
10 | - id: black
11 |
--------------------------------------------------------------------------------
/src/admin/utils.py:
--------------------------------------------------------------------------------
1 | from passlib.context import CryptContext
2 |
3 | pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
4 |
5 |
6 | def verify_password(plain_password: str, hashed_password: str):
7 | return pwd_context.verify(plain_password, hashed_password)
8 |
9 |
10 | def get_password_hash(password: str):
11 | return pwd_context.hash(password)
12 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM python:3.10
2 |
3 | WORKDIR /opt/app
4 |
5 | ENV PYTHONDONTWRITEBYTECODE 1
6 | ENV PYTHONUNBUFFERED 1
7 |
8 | COPY pyproject.toml .
9 |
10 | RUN pip3 install poetry
11 | RUN poetry config virtualenvs.create false
12 | RUN poetry install --without dev
13 |
14 | COPY . .
15 |
16 | RUN openssl genrsa -out jwt-private.pem 2048
17 | RUN openssl rsa -in jwt-private.pem -outform PEM -pubout -out jwt-public.pem
18 |
--------------------------------------------------------------------------------
/src/likes/schemas.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | from pydantic import BaseModel, Field
4 | from pydantic.types import UUID4
5 |
6 |
7 | class UserLikeBase(BaseModel):
8 | liked_user_id: UUID4
9 | is_liked: bool = Field(default=False)
10 |
11 | class Config:
12 | orm_mode = True
13 |
14 |
15 | class UserLikeRequest(UserLikeBase):
16 | ...
17 |
18 |
19 | class UserLikeResponse(UserLikeBase):
20 | id: UUID4
21 | created_at: datetime.datetime
22 |
--------------------------------------------------------------------------------
/migrations/script.py.mako:
--------------------------------------------------------------------------------
1 | """${message}
2 |
3 | Revision ID: ${up_revision}
4 | Revises: ${down_revision | comma,n}
5 | Create Date: ${create_date}
6 |
7 | """
8 | from alembic import op
9 | import sqlalchemy as sa
10 | ${imports if imports else ""}
11 |
12 | # revision identifiers, used by Alembic.
13 | revision = ${repr(up_revision)}
14 | down_revision = ${repr(down_revision)}
15 | branch_labels = ${repr(branch_labels)}
16 | depends_on = ${repr(depends_on)}
17 |
18 |
19 | def upgrade() -> None:
20 | ${upgrades if upgrades else "pass"}
21 |
22 |
23 | def downgrade() -> None:
24 | ${downgrades if downgrades else "pass"}
25 |
--------------------------------------------------------------------------------
/src/admin/views.py:
--------------------------------------------------------------------------------
1 | from starlette_admin.contrib.sqla import ModelView
2 |
3 |
4 | class BaseView(ModelView):
5 | exclude_fields_from_edit = ("created_at",)
6 | exclude_fields_from_create = ("created_at",)
7 |
8 |
9 | class UserAuthView(BaseView):
10 | ...
11 |
12 |
13 | class UserQuestionnaireView(BaseView):
14 | ...
15 |
16 |
17 | class UserLikeView(BaseView):
18 | ...
19 |
20 |
21 | class BlackListUserView(BaseView):
22 | ...
23 |
24 |
25 | class UserSettingsView(BaseView):
26 | ...
27 |
28 |
29 | class MatchView(BaseView):
30 | ...
31 |
32 |
33 | class MessageView(BaseView):
34 | ...
35 |
--------------------------------------------------------------------------------
/data/reserved_keywords.txt:
--------------------------------------------------------------------------------
1 | all
2 | analyse
3 | analyze
4 | and
5 | any
6 | asc
7 | asymmetric
8 | both
9 | case
10 | cast
11 | check
12 | collate
13 | column
14 | constraint
15 | current_catalog
16 | current_date
17 | current_role
18 | current_time
19 | current_timestamp
20 | current_user
21 | default
22 | deferrable
23 | desc
24 | distinct
25 | do
26 | else
27 | end
28 | false
29 | foreign
30 | in
31 | initially
32 | lateral
33 | leading
34 | localtime
35 | localtimestamp
36 | not
37 | null
38 | only
39 | or
40 | placing
41 | primary
42 | references
43 | select
44 | session_user
45 | some
46 | symmetric
47 | table
48 | then
49 | trailing
50 | true
51 | unique
52 | user
53 | using
54 | variadic
55 | when
--------------------------------------------------------------------------------
/.env-example:
--------------------------------------------------------------------------------
1 | DEBUG=True
2 |
3 | DB_USER=postgres
4 | DB_PASS=postgres
5 | DB_NAME=postgres
6 | DB_HOST=192.168.0.10
7 | DB_PORT=5432
8 | CONTAINER_DB_PORT=5431
9 | TEST_DB_NAME=
10 |
11 | REDIS_HOST=192.168.0.17
12 | REDIS_PORT=6379
13 | CONTAINER_REDIS_PORT=6378
14 |
15 | MONGO_HOST=192.168.0.18
16 | MONGO_PORT=27017
17 | CONTAINER_MONGO_PORT=27016
18 | MONGO_DATABASE=mongodb
19 |
20 | SECRET_KEY=e95a3684b9982fcfd46eea716707f80cef515906eb49c4cb961dfde39a41ce21
21 |
22 | ACCESS_TOKEN_EXPIRES_IN=15
23 | REFRESH_TOKEN_EXPIRES_IN=5000
24 | ALGORITHM=RS256
25 |
26 | COOKIE_ACCESS_TOKEN_KEY=mir
27 | COOKIE_REFRESH_TOKEN_KEY=rsmir
28 |
29 | PGADMIN_DEFAULT_EMAIL=admin@mail.com
30 | PGADMIN_DEFAULT_PASSWORD=admin
31 |
--------------------------------------------------------------------------------
/src/database.py:
--------------------------------------------------------------------------------
1 | from collections.abc import AsyncGenerator
2 |
3 | from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
4 | from sqlalchemy.orm import DeclarativeBase
5 |
6 | from src.config import settings
7 | from src.mongodb.mongodb import Mongo
8 |
9 |
10 | class Base(DeclarativeBase):
11 | pass
12 |
13 |
14 | engine = create_async_engine(settings.db_url_postgresql, echo=False)
15 | async_session_maker = async_sessionmaker(
16 | engine,
17 | class_=AsyncSession,
18 | expire_on_commit=False,
19 | )
20 |
21 |
22 | async def get_async_session() -> AsyncGenerator[AsyncSession, None]:
23 | async with async_session_maker() as session:
24 | yield session
25 |
26 | mongo = Mongo()
27 |
--------------------------------------------------------------------------------
/src/matches/utils.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | from uuid import UUID
3 |
4 | from sqlalchemy.ext.asyncio import AsyncSession
5 |
6 | from src.likes.crud import get_like_by_user_ids
7 | from src.matches.crud import create_match
8 |
9 |
10 | async def create_match_after_like(
11 | session: AsyncSession,
12 | user1_id: UUID,
13 | user2_id: UUID,
14 | ):
15 | likes = await asyncio.gather(
16 | get_like_by_user_ids(session, user1_id, user2_id),
17 | get_like_by_user_ids(session, user2_id, user1_id),
18 | )
19 |
20 | if likes[0] is not None and likes[1] is not None \
21 | and likes[0].is_liked and likes[1].is_liked:
22 | await asyncio.create_task(
23 | create_match(session, user1_id, user2_id),
24 | )
25 |
--------------------------------------------------------------------------------
/src/redis/redis.py:
--------------------------------------------------------------------------------
1 | from typing import Any
2 |
3 | from redis.asyncio import Redis as AsyncRedis
4 |
5 | from src.config import settings
6 |
7 |
8 | class Redis:
9 | path_to_conf_file = "/etc/redis/redis.conf"
10 |
11 | def __init__(self):
12 | self.redis_client = AsyncRedis.from_url(
13 | url=settings.db_url_redis,
14 | db=0,
15 | decode_responses=True,
16 | )
17 |
18 | async def get(self, name: str):
19 | data: dict = await self.redis_client.get(name=name)
20 | if data:
21 | return data
22 | return None
23 |
24 | async def set(self, name: str, value: Any):
25 | await self.redis_client.set(name=name, value=value, ex=600)
26 |
27 | async def delete(self, name: str):
28 | await self.redis_client.delete(name)
29 |
30 | async def flush_db(self):
31 | await self.redis_client.flushdb(asynchronous=True)
32 |
33 |
34 | redis = Redis()
35 |
--------------------------------------------------------------------------------
/migrations/versions/84f11c7d251e_nullable_id_false.py:
--------------------------------------------------------------------------------
1 | """nullable_id=False
2 |
3 | Revision ID: 84f11c7d251e
4 | Revises: 5c47a192ce1d
5 | Create Date: 2023-12-20 15:06:18.977600
6 |
7 | """
8 | from alembic import op
9 | import sqlalchemy as sa
10 |
11 |
12 | # revision identifiers, used by Alembic.
13 | revision = '84f11c7d251e'
14 | down_revision = '5c47a192ce1d'
15 | branch_labels = None
16 | depends_on = None
17 |
18 |
19 | def upgrade() -> None:
20 | # ### commands auto generated by Alembic - please adjust! ###
21 | op.alter_column('user_questionnaire', 'user_id',
22 | existing_type=sa.UUID(),
23 | nullable=False)
24 | # ### end Alembic commands ###
25 |
26 |
27 | def downgrade() -> None:
28 | # ### commands auto generated by Alembic - please adjust! ###
29 | op.alter_column('user_questionnaire', 'user_id',
30 | existing_type=sa.UUID(),
31 | nullable=True)
32 | # ### end Alembic commands ###
33 |
--------------------------------------------------------------------------------
/migrations/versions/afd4c92e479a_new_get_quest_rools.py:
--------------------------------------------------------------------------------
1 | """new_get_quest_rools
2 |
3 | Revision ID: afd4c92e479a
4 | Revises: 84f11c7d251e
5 | Create Date: 2024-01-20 23:59:24.450554
6 |
7 | """
8 | from alembic import op
9 | import sqlalchemy as sa
10 |
11 |
12 | # revision identifiers, used by Alembic.
13 | revision = 'afd4c92e479a'
14 | down_revision = '5478d7d7a63f'
15 | branch_labels = None
16 | depends_on = None
17 |
18 |
19 | def upgrade() -> None:
20 | # ### commands auto generated by Alembic - please adjust! ###
21 | op.add_column('user_questionnaire', sa.Column('is_prem', sa.Boolean(), nullable=False))
22 | op.add_column('user_questionnaire', sa.Column('quest_lists_per_day', sa.Integer(), nullable=False))
23 | # ### end Alembic commands ###
24 |
25 |
26 | def downgrade() -> None:
27 | # ### commands auto generated by Alembic - please adjust! ###
28 | op.drop_column('user_questionnaire', 'quest_lists_per_day')
29 | op.drop_column('user_questionnaire', 'is_prem')
30 | # ### end Alembic commands ###
31 |
--------------------------------------------------------------------------------
/migrations/versions/5c47a192ce1d_add_age_unqique_constraint_quest_user_.py:
--------------------------------------------------------------------------------
1 | """Add age, unqique constraint quest, user_id=null
2 |
3 | Revision ID: 5c47a192ce1d
4 | Revises: 4f21deed5f82
5 | Create Date: 2023-12-12 18:10:52.693682
6 |
7 | """
8 | from alembic import op
9 | import sqlalchemy as sa
10 |
11 |
12 | # revision identifiers, used by Alembic.
13 | revision = '5c47a192ce1d'
14 | down_revision = '4f21deed5f82'
15 | branch_labels = None
16 | depends_on = None
17 |
18 |
19 | def upgrade() -> None:
20 | # ### commands auto generated by Alembic - please adjust! ###
21 | op.add_column('user_questionnaire', sa.Column('age', sa.Integer(), nullable=False))
22 | op.create_unique_constraint('_user_id_uc', 'user_questionnaire', ['user_id'])
23 | # ### end Alembic commands ###
24 |
25 |
26 | def downgrade() -> None:
27 | # ### commands auto generated by Alembic - please adjust! ###
28 | op.drop_constraint('_user_id_uc', 'user_questionnaire', type_='unique')
29 | op.drop_column('user_questionnaire', 'age')
30 | # ### end Alembic commands ###
31 |
--------------------------------------------------------------------------------
/src/questionnaire/schemas.py:
--------------------------------------------------------------------------------
1 | import uuid
2 | from datetime import date
3 |
4 | from pydantic import BaseModel
5 |
6 | from .params_choice import AlcoholType, Gender, Goal, SmokingType, SportType
7 |
8 |
9 | class UserBaseSchema(BaseModel):
10 | class Config:
11 | orm_mode = True
12 |
13 |
14 | class UserHobby(UserBaseSchema):
15 | hobby_name: str
16 |
17 |
18 | class CreateUserQuestionnaireSchema(UserBaseSchema):
19 | firstname: str
20 | lastname: str
21 | gender: Gender
22 | photo: str
23 | country: str
24 | city: str
25 | about: str
26 | hobbies: list[UserHobby]
27 | sport: SportType
28 | alcohol: AlcoholType
29 | smoking: SmokingType
30 | height: int
31 | goals: Goal
32 | birthday: date
33 |
34 |
35 | class ResponseUserQuestionnaireSchema(CreateUserQuestionnaireSchema):
36 | id: uuid.UUID | None = None
37 | user_id: uuid.UUID | None = None
38 |
39 |
40 | class ResponseQuestionnaireSchemaWithMatch(ResponseUserQuestionnaireSchema):
41 | is_match: bool = False
42 | match_id: uuid.UUID | None = None
43 |
--------------------------------------------------------------------------------
/migrations/versions/aa83adadcdb2_add_new_auth.py:
--------------------------------------------------------------------------------
1 | """add_new_auth
2 |
3 | Revision ID: aa83adadcdb2
4 | Revises: 84f11c7d251e
5 | Create Date: 2024-02-06 09:10:05.547341
6 |
7 | """
8 | from alembic import op
9 | import sqlalchemy as sa
10 |
11 |
12 | # revision identifiers, used by Alembic.
13 | revision = 'aa83adadcdb2'
14 | down_revision = '84f11c7d251e'
15 | branch_labels = None
16 | depends_on = None
17 |
18 |
19 | def upgrade() -> None:
20 | # ### commands auto generated by Alembic - please adjust! ###
21 | op.alter_column('auth_user', 'hashed_password',
22 | existing_type=sa.VARCHAR(length=1024),
23 | type_=sa.LargeBinary(),
24 | existing_nullable=False,
25 | postgresql_using='hashed_password::bytea')
26 | # ### end Alembic commands ###
27 |
28 |
29 | def downgrade() -> None:
30 | # ### commands auto generated by Alembic - please adjust! ###
31 | op.alter_column('auth_user', 'hashed_password',
32 | existing_type=sa.LargeBinary(),
33 | type_=sa.VARCHAR(length=1024),
34 | existing_nullable=False)
35 | # ### end Alembic commands ###
36 |
--------------------------------------------------------------------------------
/tests/with_db/test_models.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import uuid
3 | from pathlib import Path
4 |
5 | from sqlalchemy.ext.asyncio import AsyncSession
6 |
7 | from src.auth.models import AuthUser
8 | from src.auth.utils import hash_password
9 | from src.database import Base
10 |
11 |
12 | async def test_uuid(get_async_session: AsyncSession):
13 | new_user = {
14 | "email": "mail@server.com",
15 | "created_at": datetime.datetime.utcnow(),
16 | "hashed_password": hash_password("pass"),
17 | "is_active": False,
18 | "is_superuser": False,
19 | "is_verified": False,
20 | "is_delete": False,
21 | }
22 | # TODO: replace when crud appears
23 | async with get_async_session as db:
24 | created_user = AuthUser(**new_user)
25 | db.add(created_user)
26 | await db.commit()
27 | assert isinstance(created_user.id, uuid.UUID)
28 |
29 |
30 | async def test_table_names_and_columns():
31 | with Path("data/reserved_keywords.txt").open(encoding="utf-8") as f:
32 | reserved = set(f.read().splitlines())
33 |
34 | for table in Base.metadata.tables:
35 | assert table.lower() not in reserved
36 |
--------------------------------------------------------------------------------
/src/exceptions.py:
--------------------------------------------------------------------------------
1 | from fastapi import HTTPException, status
2 |
3 |
4 | class BaseProjectException(HTTPException):
5 | default_message = None
6 | status_code = None
7 |
8 | def __init__(self, detail: str | None = None):
9 | super().__init__(
10 | status_code=self.status_code,
11 | detail=detail if detail else self.default_message,
12 | )
13 |
14 |
15 | class NotFoundException(BaseProjectException):
16 | default_message = "Not Found"
17 | status_code = status.HTTP_404_NOT_FOUND
18 |
19 |
20 | class AlreadyExistsException(BaseProjectException):
21 | default_message = "Object already exists"
22 | status_code = status.HTTP_400_BAD_REQUEST
23 |
24 |
25 | class SelfLikeException(BaseProjectException):
26 | default_message = "Selfliking doesn't allowed"
27 | status_code = status.HTTP_400_BAD_REQUEST
28 |
29 |
30 | class SelfMatchException(BaseProjectException):
31 | default_message = "Selfmatching doesn't allowed"
32 | status_code = status.HTTP_400_BAD_REQUEST
33 |
34 |
35 | class PermissionDeniedException(BaseProjectException):
36 | default_message = "You don't have acces to object"
37 | status_code = status.HTTP_403_FORBIDDEN
38 |
--------------------------------------------------------------------------------
/src/matches/models.py:
--------------------------------------------------------------------------------
1 | import uuid
2 | from datetime import datetime
3 |
4 | from sqlalchemy import CheckConstraint, ForeignKey, UniqueConstraint
5 | from sqlalchemy.orm import Mapped, mapped_column, relationship
6 |
7 | from src.auth.models import AuthUser
8 | from src.database import Base
9 |
10 |
11 | class Match(Base):
12 | __tablename__ = "match"
13 | __table_args__ = (
14 | UniqueConstraint("user1_id", "user2_id", name="_match_uc"),
15 | CheckConstraint("NOT(user1_id = user2_id)", name="_match_cc"),
16 | )
17 | id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
18 | created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
19 |
20 | user1_id: Mapped[uuid.UUID] = mapped_column(
21 | ForeignKey("auth_user.id", ondelete="CASCADE"),
22 | )
23 | user1 = relationship(
24 | "AuthUser",
25 | backref="matches_to",
26 | primaryjoin=AuthUser.id == user1_id,
27 | )
28 | user2_id: Mapped[uuid.UUID] = mapped_column(
29 | ForeignKey("auth_user.id", ondelete="CASCADE"),
30 | )
31 | user2 = relationship(
32 | "AuthUser",
33 | backref="matches_from",
34 | primaryjoin=AuthUser.id == user2_id,
35 | )
36 |
--------------------------------------------------------------------------------
/src/questionnaire/params_choice.py:
--------------------------------------------------------------------------------
1 | from enum import Enum
2 |
3 |
4 | class Passion(str, Enum):
5 | trips = "Путешествия"
6 | photo = "Фотография"
7 | music = "Музыка"
8 |
9 |
10 | class Gender(str, Enum):
11 | male = "Male"
12 | female = "Female"
13 |
14 |
15 | class Goal(str, Enum):
16 | relationships = "Серьезные отношения"
17 | flirts = "Флирт"
18 | friendship = "Дружба"
19 | for_one_day = "Ha одну ночь"
20 | open_relationship = "Открытые отношения"
21 | new_experience = "Новый опыт"
22 | nothing_serious = "Ничего серьезного"
23 | communication = "Общение"
24 |
25 |
26 | class SmokingType(str, Enum):
27 | positive = "Курю"
28 | normal = "Нормально"
29 | negative = "Негативно"
30 |
31 |
32 | class AlcoholType(str, Enum):
33 | often = "Пью часто"
34 | sometimes = "Иногда выпиваю"
35 | negative = "He пью"
36 | hate = "Негативно"
37 |
38 |
39 | class SportType(str, Enum):
40 | often = "Часто занимаюсь"
41 | sometimes = "Иногда занимаюсь"
42 | negative = "He занимаюсь"
43 | hate = "He люблю спорт"
44 |
45 |
46 | class BodyType(str, Enum):
47 | skinny = "Худое"
48 | full_physique = "Полное"
49 | athletic = "Спортивное"
50 | average = "Среднее"
51 |
--------------------------------------------------------------------------------
/src/likes/models.py:
--------------------------------------------------------------------------------
1 | import uuid
2 | from datetime import datetime
3 |
4 | from sqlalchemy import Boolean, CheckConstraint, ForeignKey, UniqueConstraint
5 | from sqlalchemy.orm import Mapped, mapped_column, relationship
6 |
7 | from src.auth.models import AuthUser
8 | from src.database import Base
9 |
10 |
11 | class UserLike(Base):
12 | __tablename__ = "user_like"
13 | __table_args__ = (
14 | UniqueConstraint("user_id", "liked_user_id", name="_user_like_uc"),
15 | CheckConstraint("NOT(user_id = liked_user_id)", name="_user_like_cc"),
16 | )
17 |
18 | id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
19 | created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
20 | user_id: Mapped[uuid.UUID] = mapped_column(
21 | ForeignKey("auth_user.id", ondelete="CASCADE"),
22 | )
23 | liked_user_id: Mapped[uuid.UUID] = mapped_column(
24 | ForeignKey("auth_user.id", ondelete="CASCADE"),
25 | )
26 | is_liked: Mapped[bool] = mapped_column(Boolean, default=False)
27 |
28 | user: Mapped[AuthUser] = relationship(
29 | primaryjoin=AuthUser.id == user_id,
30 | )
31 | liked_user: Mapped[AuthUser] = relationship(
32 | primaryjoin=AuthUser.id == liked_user_id,
33 | )
34 |
--------------------------------------------------------------------------------
/src/matches/routers.py:
--------------------------------------------------------------------------------
1 | from typing import Annotated
2 | from uuid import UUID
3 |
4 | from fastapi import APIRouter, Depends, status
5 | from sqlalchemy.ext.asyncio import AsyncSession
6 |
7 | from src.auth.base_config import current_user
8 | from src.auth.models import AuthUser
9 | from src.database import get_async_session
10 | from src.matches.crud import get_questionnaires_by_user_matched, remove_match
11 | from src.questionnaire.schemas import (
12 | ResponseQuestionnaireSchemaWithMatch,
13 | )
14 |
15 | router = APIRouter(
16 | prefix="/matches",
17 | tags=["Match"],
18 | )
19 |
20 |
21 | @router.get(
22 | path="",
23 | response_model=list[ResponseQuestionnaireSchemaWithMatch],
24 | )
25 | async def get_matches(
26 | session: Annotated[AsyncSession, Depends(get_async_session)],
27 | user: Annotated[AuthUser, Depends(current_user)],
28 | ):
29 | return await get_questionnaires_by_user_matched(session=session, user=user)
30 |
31 |
32 | @router.delete(
33 | path="/{match_id}",
34 | status_code=status.HTTP_204_NO_CONTENT,
35 | )
36 | async def delete_match(
37 | session: Annotated[AsyncSession, Depends(get_async_session)],
38 | user: Annotated[AuthUser, Depends(current_user)],
39 | match_id: UUID,
40 | ):
41 | return await remove_match(session=session, user=user, match_id=match_id)
42 |
--------------------------------------------------------------------------------
/src/likes/routers.py:
--------------------------------------------------------------------------------
1 | from typing import Annotated
2 |
3 | from fastapi import APIRouter, Depends, HTTPException, status
4 | from sqlalchemy.ext.asyncio import AsyncSession
5 | from starlette.background import BackgroundTasks
6 |
7 | from src.auth.base_config import current_user
8 | from src.auth.models import AuthUser
9 | from src.database import get_async_session
10 | from src.likes.crud import add_like
11 | from src.likes.schemas import UserLikeRequest, UserLikeResponse
12 | from src.matches.utils import create_match_after_like
13 |
14 | likes_router = APIRouter(
15 | prefix="/likes",
16 | tags=["Like"],
17 | )
18 |
19 |
20 | @likes_router.post(
21 | "",
22 | status_code=status.HTTP_201_CREATED,
23 | response_model=UserLikeResponse,
24 | )
25 | async def like_user(
26 | user: Annotated[AuthUser, Depends(current_user)],
27 | user_like: UserLikeRequest,
28 | session: Annotated[AsyncSession, Depends(get_async_session)],
29 | bg_tasks: BackgroundTasks,
30 | ):
31 | like = await add_like(user, user_like, session)
32 |
33 | if like is None:
34 | raise HTTPException(
35 | status_code=status.HTTP_404_NOT_FOUND,
36 | detail="bad user id",
37 | )
38 |
39 | bg_tasks.add_task(create_match_after_like, session, user.id, user_like.liked_user_id)
40 |
41 | return UserLikeResponse.from_orm(like)
42 |
--------------------------------------------------------------------------------
/src/chat/redis.py:
--------------------------------------------------------------------------------
1 | import json
2 | import uuid
3 |
4 | from sqlalchemy.ext.asyncio import AsyncSession
5 |
6 | from src.auth.models import AuthUser
7 | from src.chat.schemas import WSMessageRequest
8 | from src.matches.crud import get_match_by_user_ids
9 | from src.matches.models import Match
10 | from src.redis.redis import redis as redis_client
11 |
12 |
13 | async def get_message_pk() -> uuid.UUID:
14 | """
15 | Return primary key for message.
16 | Can be got from redis or from python.
17 | Need cause we do not save messages to db when it is sent.
18 | """
19 | # TODO: replace with normal pk pick
20 | return uuid.uuid4()
21 |
22 |
23 | async def get_match(
24 | session: AsyncSession,
25 | user: AuthUser,
26 | ws_msg: WSMessageRequest,
27 | ) -> Match:
28 | """
29 | Getting a Match from the cache and adding it to the cache if it is not there.
30 | """
31 | match = await redis_client.get(f"match_{ws_msg.message.match_id}")
32 | if match is not None:
33 | match = json.loads(match)
34 | else:
35 | match = await get_match_by_user_ids(session, user.id, ws_msg.message.to_id)
36 | if match is None:
37 | return match
38 | dict_match = {
39 | "match_id": str(match.id),
40 | "user1_id": str(match.user1_id),
41 | "user2_id": str(match.user2_id),
42 | "created_at": str(match.created_at),
43 | }
44 | await redis_client.set(f"match_{ws_msg.message.match_id}", json.dumps(dict_match))
45 |
46 | return match
47 |
--------------------------------------------------------------------------------
/migrations/versions/5478d7d7a63f_.py:
--------------------------------------------------------------------------------
1 | """empty message
2 |
3 | Revision ID: 5478d7d7a63f
4 | Revises: 84f11c7d251e
5 | Create Date: 2024-02-04 14:54:29.320829
6 |
7 | """
8 | from alembic import op
9 | import sqlalchemy as sa
10 | from sqlalchemy.dialects import postgresql
11 |
12 | # revision identifiers, used by Alembic.
13 | revision = '5478d7d7a63f'
14 | down_revision = 'aa83adadcdb2'
15 | branch_labels = None
16 | depends_on = None
17 |
18 |
19 | def upgrade() -> None:
20 | # ### commands auto generated by Alembic - please adjust! ###
21 | op.add_column('message', sa.Column('created_at', sa.DateTime(), nullable=False))
22 | op.add_column('message', sa.Column('reply_to', sa.Uuid(), nullable=True))
23 | op.add_column('message', sa.Column('group_id', sa.Uuid(), nullable=True))
24 | op.add_column('message', sa.Column('media', sa.String(), nullable=True))
25 | op.create_foreign_key(None, 'message', 'message', ['reply_to'], ['id'], ondelete='SET NULL')
26 | op.drop_column('message', 'captions')
27 | # ### end Alembic commands ###
28 |
29 |
30 | def downgrade() -> None:
31 | # ### commands auto generated by Alembic - please adjust! ###
32 | op.add_column('message', sa.Column('captions', postgresql.ARRAY(sa.VARCHAR()), autoincrement=False, nullable=True))
33 | op.drop_constraint(None, 'message', type_='foreignkey')
34 | op.drop_column('message', 'media')
35 | op.drop_column('message', 'group_id')
36 | op.drop_column('message', 'reply_to')
37 | op.drop_column('message', 'created_at')
38 | # ### end Alembic commands ###
39 |
--------------------------------------------------------------------------------
/src/auth/utils.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime, timedelta
2 |
3 | import bcrypt
4 | import jwt
5 |
6 | from src.config import settings
7 |
8 |
9 | def encode_jwt(
10 | payload: dict,
11 | private_key: str = settings.PRIVATE_KEY_PATH.read_text(), # noqa: B008
12 | algorithm: str = settings.ALGORITHM,
13 | expire_minutes: int = settings.ACCESS_TOKEN_EXPIRES_IN,
14 | ) -> str:
15 | """Кодировка данных в JWT токен."""
16 | to_encode = payload.copy()
17 | now = datetime.utcnow()
18 | expire = now + timedelta(minutes=expire_minutes)
19 | to_encode.update(
20 | exp=expire,
21 | )
22 | return jwt.encode(
23 | to_encode,
24 | private_key,
25 | algorithm=algorithm,
26 | )
27 |
28 |
29 | def decode_jwt(
30 | token: str | bytes,
31 | public_key: str = settings.PUBLIC_KEY_PATH.read_text(), #noqa: B008
32 | algorithm: str = settings.ALGORITHM,
33 | ) -> dict:
34 | """Декодировка JWT токена в данные."""
35 | return jwt.decode(
36 | token,
37 | public_key,
38 | algorithms=[algorithm],
39 | )
40 |
41 |
42 | def hash_password(
43 | password: str,
44 | ) -> bytes:
45 | """Хеширование пароля."""
46 | salt = bcrypt.gensalt()
47 | return bcrypt.hashpw(
48 | password=password.encode(),
49 | salt=salt,
50 | )
51 |
52 |
53 | def validate_password(
54 | password: str,
55 | hashed_password: bytes,
56 | ) -> bool:
57 | """Проверка на совпадение хэшированного и нехэшированного пароля."""
58 | return bcrypt.checkpw(
59 | password=password.encode(),
60 | hashed_password=hashed_password,
61 | )
62 |
--------------------------------------------------------------------------------
/migrations/versions/51e208efbb6b_add_message.py:
--------------------------------------------------------------------------------
1 | """add_message
2 |
3 | Revision ID: 51e208efbb6b
4 | Revises: c347c3783bc4
5 | Create Date: 2023-09-28 21:42:34.011114
6 |
7 | """
8 | import sqlalchemy_utils
9 | from alembic import op
10 | import sqlalchemy as sa
11 | from sqlalchemy.dialects import postgresql
12 |
13 | from src.chat.schemas import MessageStatus
14 |
15 | # revision identifiers, used by Alembic.
16 | revision = "51e208efbb6b"
17 | down_revision = "c347c3783bc4"
18 | branch_labels = None
19 | depends_on = None
20 |
21 |
22 | def upgrade() -> None:
23 | # ### commands auto generated by Alembic - please adjust! ###
24 | op.create_table(
25 | "message",
26 | sa.Column("id", sa.Uuid(), nullable=False),
27 | sa.Column("match_id", sa.Uuid(), nullable=False),
28 | sa.Column("from_id", sa.Uuid(), nullable=False),
29 | sa.Column("text", sa.String(length=4096), nullable=False),
30 | sa.Column("created_at", sa.DateTime(), nullable=False),
31 | sa.Column(
32 | "status",
33 | sqlalchemy_utils.types.choice.ChoiceType(MessageStatus),
34 | nullable=False,
35 | ),
36 | sa.Column("captions", postgresql.ARRAY(sa.String()), nullable=True),
37 | sa.ForeignKeyConstraint(["from_id"], ["auth_user.id"], ondelete="RESTRICT"),
38 | sa.ForeignKeyConstraint(["match_id"], ["match.id"], ondelete="RESTRICT"),
39 | sa.PrimaryKeyConstraint("id"),
40 | )
41 | # ### end Alembic commands ###
42 |
43 |
44 | def downgrade() -> None:
45 | # ### commands auto generated by Alembic - please adjust! ###
46 | op.drop_table("message")
47 | # ### end Alembic commands ###
48 |
--------------------------------------------------------------------------------
/migrations/versions/4f21deed5f82_add_to_id_to_message_make_text_nullable_.py:
--------------------------------------------------------------------------------
1 | """Add to_id to message, make text nullable, change field name
2 |
3 | Revision ID: 4f21deed5f82
4 | Revises: 51e208efbb6b
5 | Create Date: 2023-10-07 23:29:47.123295
6 |
7 | """
8 | from alembic import op
9 | import sqlalchemy as sa
10 | from sqlalchemy.dialects import postgresql
11 |
12 | # revision identifiers, used by Alembic.
13 | revision = '4f21deed5f82'
14 | down_revision = '51e208efbb6b'
15 | branch_labels = None
16 | depends_on = None
17 |
18 |
19 | def upgrade() -> None:
20 | # ### commands auto generated by Alembic - please adjust! ###
21 | op.add_column('message', sa.Column('to_id', sa.Uuid(), nullable=False))
22 | op.add_column('message', sa.Column('updated_at', sa.DateTime(), nullable=False))
23 | op.alter_column(
24 | 'message', 'text',
25 | existing_type=sa.VARCHAR(length=4096),
26 | nullable=True
27 | )
28 | op.create_foreign_key(
29 | None, 'message',
30 | 'auth_user', ['to_id'], ['id'],
31 | ondelete='RESTRICT'
32 | )
33 | op.drop_column('message', 'created_at')
34 | # ### end Alembic commands ###
35 |
36 |
37 | def downgrade() -> None:
38 | # ### commands auto generated by Alembic - please adjust! ###
39 | op.add_column('message', sa.Column('created_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=False))
40 | op.drop_constraint(None, 'message', type_='foreignkey')
41 | op.alter_column('message', 'text',
42 | existing_type=sa.VARCHAR(length=4096),
43 | nullable=False)
44 | op.drop_column('message', 'updated_at')
45 | op.drop_column('message', 'to_id')
46 | # ### end Alembic commands ###
47 |
--------------------------------------------------------------------------------
/src/auth/schemas.py:
--------------------------------------------------------------------------------
1 | import uuid
2 | from datetime import datetime
3 |
4 | from pydantic import BaseModel, EmailStr, Field, ValidationError, root_validator
5 |
6 | from src.auth.models import AGE_MAX, AGE_MIN, RANGE_MAX, RANGE_MIN
7 |
8 |
9 | class UserSchema(BaseModel):
10 | id: uuid.UUID
11 | email: str
12 | is_active: bool = True
13 | is_superuser: bool = False
14 | is_verified: bool = False
15 |
16 | class Config:
17 | orm_mode = True
18 |
19 |
20 | class UserCreateInput(BaseModel):
21 | email: EmailStr
22 | password: str
23 |
24 |
25 | class BaseUserProfile(BaseModel):
26 | """Base profile model."""
27 |
28 | search_range_min: int = Field(ge=RANGE_MIN, le=RANGE_MAX)
29 | search_range_max: int = Field(ge=RANGE_MIN, le=RANGE_MAX)
30 | search_age_min: int = Field(ge=AGE_MIN, le=AGE_MAX)
31 | search_age_max: int = Field(ge=AGE_MIN, le=AGE_MAX)
32 |
33 | class Config:
34 | orm_mode = True
35 |
36 | @root_validator
37 | @classmethod
38 | def check_sum(cls, values: dict):
39 | range_min = values.get("search_range_min")
40 | range_max = values.get("search_range_max")
41 | age_min = values.get("search_age_min")
42 | age_max = values.get("search_age_max")
43 | if range_min > range_max:
44 | raise ValidationError("Min_range should me less than max_range.")
45 | if age_min > age_max:
46 | raise ValidationError("Min_age should me less than max_age.")
47 | return values
48 |
49 |
50 | class UserProfile(BaseUserProfile):
51 | """Profile model."""
52 |
53 | id: uuid.UUID
54 | user_id: uuid.UUID
55 | subscriber: datetime | None
56 |
57 |
58 | class UserProfileUpdate(BaseUserProfile):
59 | """Profile update model."""
60 |
61 | ...
62 |
--------------------------------------------------------------------------------
/migrations/versions/fa5f9699e17b_new_quest_model.py:
--------------------------------------------------------------------------------
1 | """new quest model
2 |
3 | Revision ID: fa5f9699e17b
4 | Revises: afd4c92e479a
5 | Create Date: 2024-02-14 13:12:34.794659
6 |
7 | """
8 | from alembic import op
9 | import sqlalchemy as sa
10 | import sqlalchemy_utils
11 | from src.questionnaire.params_choice import *
12 |
13 | # revision identifiers, used by Alembic.
14 | revision = 'fa5f9699e17b'
15 | down_revision = 'afd4c92e479a'
16 | branch_labels = None
17 | depends_on = None
18 |
19 |
20 | def upgrade() -> None:
21 | # ### commands auto generated by Alembic - please adjust! ###
22 | op.drop_column('user_questionnaire', 'age')
23 | op.add_column('user_questionnaire', sa.Column('sport', sqlalchemy_utils.types.choice.ChoiceType(SportType), nullable=False))
24 | op.add_column('user_questionnaire', sa.Column('smoking', sqlalchemy_utils.types.choice.ChoiceType(SmokingType), nullable=False))
25 | op.add_column('user_questionnaire', sa.Column('alcohol', sqlalchemy_utils.types.choice.ChoiceType(AlcoholType), nullable=False))
26 | op.add_column('user_questionnaire', sa.Column('birthday', sa.Date(), nullable=False))
27 | op.drop_column('user_questionnaire', 'body_type')
28 | # ### end Alembic commands ###
29 |
30 |
31 | def downgrade() -> None:
32 | # ### commands auto generated by Alembic - please adjust! ###
33 | op.drop_column('user_questionnaire', 'birthday')
34 | op.add_column('user_questionnaire', sa.Column('body_type', sa.VARCHAR(length=255), autoincrement=False, nullable=True))
35 | op.add_column('user_questionnaire', sa.Column('age', sa.Integer(), nullable=True))
36 | op.drop_column('user_questionnaire', 'alcohol')
37 | op.drop_column('user_questionnaire', 'smoking')
38 | op.drop_column('user_questionnaire', 'sport')
39 | # ### end Alembic commands ###
40 |
--------------------------------------------------------------------------------
/src/chat/models.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import uuid
3 |
4 | from sqlalchemy import DateTime, ForeignKey, String
5 | from sqlalchemy.orm import Mapped, mapped_column
6 | from sqlalchemy_utils import ChoiceType
7 |
8 | from src.chat.schemas import MessageStatus
9 | from src.database import Base
10 |
11 |
12 | class Message(Base):
13 | __tablename__ = "message"
14 |
15 | id: Mapped[uuid.UUID] = mapped_column(
16 | primary_key=True,
17 | default=uuid.uuid4,
18 | nullable=False,
19 | )
20 | match_id: Mapped[uuid.UUID] = mapped_column(
21 | ForeignKey("match.id", ondelete="RESTRICT"),
22 | nullable=False,
23 | )
24 | from_id: Mapped[uuid.UUID] = mapped_column(
25 | ForeignKey("auth_user.id", ondelete="RESTRICT"),
26 | nullable=False,
27 | )
28 | to_id: Mapped[uuid.UUID] = mapped_column(
29 | ForeignKey("auth_user.id", ondelete="RESTRICT"),
30 | nullable=False,
31 | )
32 | text: Mapped[str] = mapped_column(String(length=4096), nullable=True)
33 | created_at: Mapped[datetime.datetime] = mapped_column(
34 | DateTime,
35 | nullable=False,
36 | )
37 | updated_at: Mapped[datetime.datetime] = mapped_column(
38 | DateTime,
39 | default=datetime.datetime.utcnow,
40 | nullable=False,
41 | )
42 | status: Mapped[MessageStatus] = mapped_column(
43 | ChoiceType(MessageStatus),
44 | default=MessageStatus.SENT,
45 | nullable=False,
46 | )
47 | reply_to: Mapped[uuid.UUID] = mapped_column(
48 | ForeignKey("message.id", ondelete="SET NULL"),
49 | nullable=True,
50 | )
51 | group_id: Mapped[uuid.UUID] = mapped_column(
52 | nullable=True,
53 | )
54 | media: Mapped[str] = mapped_column(
55 | nullable=True,
56 | )
57 |
--------------------------------------------------------------------------------
/src/chat/schemas.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import uuid
3 | from enum import Enum
4 |
5 | from pydantic import BaseModel
6 |
7 |
8 | class MessageStatus(str, Enum):
9 | SENT = "SENT"
10 | DELIVERED = "DELIVERED"
11 | READ = "READ"
12 | DELETED = "DELETED"
13 |
14 | def __str__(self):
15 | return self
16 |
17 |
18 | class WSAction(str, Enum):
19 | CREATE = "CREATE"
20 | DELETE = "DELETE"
21 | UPDATE = "UPDATE"
22 |
23 |
24 | class WSStatus(str, Enum):
25 | OK = "OK"
26 | ERROR = "ERROR"
27 |
28 |
29 | class BaseMessage(BaseModel):
30 | match_id: uuid.UUID
31 | from_id: uuid.UUID
32 | to_id: uuid.UUID
33 |
34 | class Config:
35 | orm_mode = True
36 |
37 |
38 | class MessageCreateRequest(BaseMessage):
39 | text: str
40 | reply_to: uuid.UUID | None
41 | group_id: uuid.UUID | None
42 | media: str | None
43 |
44 |
45 | class MessageUpdateRequest(MessageCreateRequest):
46 | id: uuid.UUID
47 | status: MessageStatus
48 |
49 |
50 | class MessageDeleteRequest(BaseMessage):
51 | id: uuid.UUID
52 |
53 |
54 | class MessageResponse(BaseMessage):
55 | id: uuid.UUID
56 | text: str
57 | created_at: datetime.datetime
58 | updated_at: datetime.datetime
59 | status: MessageStatus
60 | reply_to: uuid.UUID | None
61 | group_id: uuid.UUID | None
62 | media: str | None
63 |
64 |
65 | class MessageDeleteResponse(BaseMessage):
66 | id: uuid.UUID
67 |
68 |
69 | class WSMessageRequest(BaseModel):
70 | action: WSAction
71 | message: MessageUpdateRequest | MessageCreateRequest | MessageDeleteRequest
72 |
73 | class Config:
74 | orm_mode = True
75 |
76 |
77 | class WSMessageResponse(BaseModel):
78 | action: WSAction
79 | status: WSStatus
80 | detail: str | None
81 | message: MessageResponse | MessageDeleteResponse | None
82 |
83 | class Config:
84 | orm_mode = True
85 |
--------------------------------------------------------------------------------
/src/mongodb/mongodb.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import uuid
3 |
4 | from motor.motor_asyncio import AsyncIOMotorClient
5 | from pymongo.results import DeleteResult, UpdateResult
6 |
7 | from src.chat.schemas import MessageCreateRequest, MessageResponse, MessageStatus
8 | from src.config import settings
9 |
10 |
11 | class Mongo:
12 | def __init__(self):
13 | self.client_mongo = AsyncIOMotorClient(
14 | settings.db_url_mongo,
15 | serverSelectionTimeoutMS=5000,
16 | uuidRepresentation="pythonLegacy",
17 | )
18 | self.mongo_client = self.client_mongo.database
19 | self.collection = self.mongo_client.message
20 |
21 | async def create_message(self, message: MessageCreateRequest) -> MessageResponse:
22 | # TODO: find out if we can force _id=uuid for all documents
23 | now = datetime.datetime.utcnow()
24 | result = await self.collection.insert_one({
25 | **message.dict(),
26 | "_id": uuid.uuid4(),
27 | "created_at": now,
28 | "updated_at": now,
29 | "status": MessageStatus.SENT,
30 | })
31 |
32 | return MessageResponse(
33 | **message.dict(),
34 | id=result.inserted_id,
35 | status=MessageStatus.SENT,
36 | created_at=now,
37 | updated_at=now,
38 | )
39 |
40 | async def get_message(self, message_id: uuid.UUID) -> dict | None:
41 | return await self.collection.find_one(filter=message_id)
42 |
43 | async def update_message(self, message: MessageResponse) -> UpdateResult:
44 | return await self.collection.update_one(
45 | {"_id": message.id},
46 | {"$set": {**message.dict(exclude={"id"})}},
47 | )
48 |
49 | async def delete_message(self, message_id: uuid) -> DeleteResult:
50 | return await self.collection.delete_one({"_id": message_id})
51 |
--------------------------------------------------------------------------------
/tests/conftest.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | from collections.abc import AsyncGenerator, Generator
3 | from typing import Any
4 |
5 | import pytest
6 | from async_asgi_testclient import TestClient
7 | from sqlalchemy import NullPool
8 | from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
9 |
10 | from src.config import settings
11 | from src.mongodb.mongodb import Mongo
12 | from src.redis.redis import redis as redis_client
13 |
14 | if settings.TEST_DB_NAME:
15 | settings.DB_NAME = settings.TEST_DB_NAME
16 |
17 | engine = create_async_engine(settings.test_db_url_postgresql, poolclass=NullPool)
18 | async_session_maker = async_sessionmaker(
19 | engine,
20 | class_=AsyncSession,
21 | expire_on_commit=False,
22 | )
23 |
24 | pytest_plugins = [
25 | "tests.fixtures",
26 | ]
27 |
28 |
29 | @pytest.fixture(autouse=True, scope="module")
30 | async def get_async_session() -> AsyncGenerator[AsyncSession, None]:
31 | from src.database import Base
32 |
33 | async with engine.begin() as conn:
34 | await conn.run_sync(Base.metadata.create_all)
35 | async with async_session_maker() as session:
36 | yield session
37 | async with engine.begin() as conn:
38 | await conn.run_sync(Base.metadata.drop_all)
39 | await redis_client.flush_db()
40 |
41 |
42 | @pytest.fixture(scope="session")
43 | def event_loop() -> Generator[asyncio.AbstractEventLoop, Any, None]:
44 | loop = asyncio.get_event_loop_policy().new_event_loop()
45 | yield loop
46 | loop.close()
47 |
48 |
49 | @pytest.fixture(scope="session")
50 | async def async_client() -> AsyncGenerator[TestClient, None]:
51 | from src.main import app
52 |
53 | async with TestClient(app) as client:
54 | yield client
55 |
56 |
57 | @pytest.fixture(scope="session")
58 | async def mongo() -> AsyncGenerator[Mongo, None]:
59 | from src.database import mongo
60 | return mongo
61 |
--------------------------------------------------------------------------------
/src/main.py:
--------------------------------------------------------------------------------
1 | from contextlib import asynccontextmanager
2 |
3 | from apscheduler.schedulers.asyncio import AsyncIOScheduler
4 | from fastapi import APIRouter, FastAPI
5 | from fastapi.middleware.cors import CORSMiddleware
6 | from starlette.routing import Mount
7 | from starlette.staticfiles import StaticFiles
8 |
9 | from src.admin import admin
10 | from src.auth.routers import auth_router, user_router
11 | from src.chat.routers import ws_router
12 | from src.likes.routers import likes_router
13 | from src.matches.routers import router as matches_router
14 | from src.questionnaire.crud import reset_quest_lists_per_day
15 | from src.questionnaire.routers import router as questionnaire_router
16 |
17 |
18 | @asynccontextmanager
19 | async def lifespan(app: FastAPI):
20 | scheduler = AsyncIOScheduler()
21 | scheduler.add_job(
22 | reset_quest_lists_per_day,
23 | "interval",
24 | seconds=5,
25 | )
26 | scheduler.start()
27 | yield
28 |
29 | app = FastAPI(
30 | title="social networking application",
31 | docs_url="/",
32 | lifespan=lifespan,
33 | routes=[
34 | Mount(
35 | "/static",
36 | app=StaticFiles(directory="static"),
37 | name="static",
38 | ),
39 | ],
40 | )
41 |
42 | # Настройка CORS
43 | origins = [
44 | "http://localhost",
45 | "http://localhost:3000",
46 | "http://localhost:8080",
47 | ]
48 |
49 | app.add_middleware(
50 | CORSMiddleware,
51 | allow_origins=origins,
52 | allow_credentials=True,
53 | allow_methods=["*"],
54 | allow_headers=["*"],
55 | )
56 |
57 | admin.mount_to(app)
58 |
59 | main_router = APIRouter(prefix="/api/v1")
60 |
61 | main_router.include_router(auth_router)
62 | main_router.include_router(user_router)
63 | main_router.include_router(likes_router)
64 | main_router.include_router(questionnaire_router)
65 | main_router.include_router(matches_router)
66 |
67 | # TODO: change to wss for production
68 | app.include_router(ws_router)
69 |
70 | app.include_router(main_router)
71 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # mir
2 |
3 | ## Dependencies
4 | * python 3.10/3.11/3.12
5 | * python-poetry
6 | * postgresql
7 | * mongodb
8 | * redis
9 |
10 |
11 | ## Запуск в докере
12 | Создать файл .env в корне проекта: скопировать содержимое из .env-example и настроить под себя, если надо
13 | ```sh
14 | cat .env-example > .env
15 | ```
16 | Поднять контейнеры
17 | ```sh
18 | docker compose up app
19 | ```
20 | После этого бэкенд будет доступен по адресу http://localhost:8000
21 |
22 | ## Локальная установка
23 | Активация виртуального окружения
24 | ```sh
25 | poetry shell
26 | ```
27 | Установить зависимости
28 | ```sh
29 | poetry install
30 | ```
31 | Создать файл .env в корне проекта: скопировать содержимое из .env-example и настроить под себя, если надо
32 | ```sh
33 | cat .env-example > .env
34 | ```
35 | Создать файлы с ключами RSA для выпуска токенов
36 |
37 | Создание приватного RSA ключа, размер 2048
38 | ```shell
39 | openssl genrsa -out jwt-private.pem 2048
40 | ```
41 | Создание публичного ключа при помощи приватного ключа
42 | ```shell
43 | openssl rsa -in jwt-private.pem -outform PEM -pubout -out jwt-public.pem
44 | ```
45 | Запустить сервисы (postgresql, mongodb и redis) или поднять их в контейнере с помощью:
46 | ```sh
47 | docker compose up -d --build --remove-orphans
48 | ```
49 | Сделать миграции
50 | ```sh
51 | alembic upgrade head
52 | ```
53 | Запустить проект
54 | ```sh
55 | uvicorn src.main:app --reload
56 | ```
57 |
58 | ## Запуск тестов в докере
59 | ```sh
60 | docker-compose up test_app
61 | ```
62 |
63 | ### Работа с pgAdmin
64 | 1. Вход в систему
65 | Логин - указать значение PGADMIN_DEFAULT_EMAIL из файла .env
66 | Пароль - указать значение PGADMIN_DEFAULT_PASSWORD из файла .env
67 | 2. Добавление базы
68 | - правой кнопкой по servers. Далее register -> server
69 | - В открывшемся окне (Вкладка Generals) указать имя
70 | - Вкладка connection, указать:
71 | host name - db
72 | Port - указать значение DB_PORT из файла .env
73 | Maintenance database - указать значение DB_NAME из файла .env
74 | Username - указать значение DB_USER из файла .env
75 | Password - указать значение DB_PASS из файла .env
76 |
--------------------------------------------------------------------------------
/src/auth/models.py:
--------------------------------------------------------------------------------
1 | import uuid
2 | from datetime import datetime
3 |
4 | from sqlalchemy import ForeignKey, Integer, String
5 | from sqlalchemy.orm import Mapped, mapped_column, relationship
6 |
7 | from src.database import Base
8 |
9 | AGE_MIN = 18
10 | AGE_MAX = 99
11 | RANGE_MIN = 0
12 | RANGE_MAX = 999
13 |
14 |
15 | class AuthUser(Base):
16 | __tablename__ = "auth_user"
17 |
18 | id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
19 | email: Mapped[str] = mapped_column(
20 | String(length=50),
21 | unique=True,
22 | index=True,
23 | nullable=False,
24 | )
25 | created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
26 | hashed_password: Mapped[bytes] = mapped_column(nullable=False)
27 | is_active: Mapped[bool] = mapped_column(default=True)
28 | is_superuser: Mapped[bool] = mapped_column(default=False)
29 | is_verified: Mapped[bool] = mapped_column(default=False)
30 | is_delete: Mapped[bool] = mapped_column(default=False)
31 |
32 | questionnaire = relationship("UserQuestionnaire", back_populates="user")
33 | settings = relationship("UserSettings", back_populates="user")
34 | matches = relationship(
35 | "Match",
36 | primaryjoin="or_(AuthUser.id==Match.user1_id, AuthUser.id==Match.user2_id)",
37 | viewonly=True,
38 | )
39 |
40 |
41 | class UserSettings(Base):
42 | __tablename__ = "user_settings"
43 |
44 | id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
45 | subscriber: Mapped[datetime] = mapped_column(nullable=True)
46 | last_seen: Mapped[datetime] = mapped_column(default=datetime.utcnow)
47 | search_range_min: Mapped[Integer] = mapped_column(
48 | Integer,
49 | default=RANGE_MIN,
50 | )
51 | search_range_max: Mapped[Integer] = mapped_column(
52 | Integer,
53 | default=RANGE_MAX,
54 | )
55 | search_age_min: Mapped[Integer] = mapped_column(
56 | Integer,
57 | default=AGE_MIN,
58 | )
59 | search_age_max: Mapped[Integer] = mapped_column(
60 | Integer,
61 | default=AGE_MAX,
62 | )
63 | user_id: Mapped[uuid.UUID] = mapped_column(
64 | ForeignKey("auth_user.id", ondelete="CASCADE"),
65 | nullable=True,
66 | )
67 | user = relationship("AuthUser", back_populates="settings")
68 |
--------------------------------------------------------------------------------
/src/config.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 |
3 | from pydantic import BaseSettings
4 |
5 | BASE_DIR = Path(__file__).parent.parent
6 |
7 |
8 | class Settings(BaseSettings):
9 | DEBUG: bool = True
10 |
11 | DB_HOST: str
12 |
13 | DB_PORT: str
14 |
15 | DB_NAME: str
16 |
17 | DB_USER: str
18 |
19 | DB_PASS: str
20 |
21 | TEST_DB_NAME: str
22 |
23 | MONGO_HOST: str
24 |
25 | MONGO_PORT: str
26 |
27 | MONGO_DATABASE: str
28 |
29 | REDIS_HOST: str
30 |
31 | REDIS_PORT: int
32 |
33 | SECRET_KEY: str
34 |
35 | PRIVATE_KEY_PATH: Path = BASE_DIR / "jwt-private.pem"
36 | PUBLIC_KEY_PATH: Path = BASE_DIR / "jwt-public.pem"
37 | ALGORITHM: str
38 |
39 | COOKIE_ACCESS_TOKEN_KEY: str
40 | COOKIE_REFRESH_TOKEN_KEY: str
41 |
42 | ACCESS_TOKEN_EXPIRES_IN: int
43 | REFRESH_TOKEN_EXPIRES_IN: int
44 |
45 | class Config:
46 | env_file = ".env"
47 |
48 | @property
49 | def db_url_postgresql(self) -> str:
50 | """Product db url."""
51 | return (
52 | f"postgresql+asyncpg://{self.DB_USER}:{self.DB_PASS}"
53 | f"@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}"
54 | )
55 |
56 | @property
57 | def test_db_url_postgresql(self) -> str:
58 | """Test db url.
59 | For local testing:
60 | - if you have TEST_DB_NAME, tests will use this db.
61 | For docker testing:
62 | - we do not need to provide TEST_DB_NAME, so that we do not need second
63 | docker-compose file. Postgres container can create DB by DB_NAME env var.
64 | The only difference in local and docker testing is one line in env file
65 | (hope, you have to copies of env file).
66 | """
67 | if self.TEST_DB_NAME:
68 | return (
69 | f"postgresql+asyncpg://{self.DB_USER}:{self.DB_PASS}"
70 | f"@{self.DB_HOST}:{self.DB_PORT}/{self.TEST_DB_NAME}"
71 | )
72 |
73 | return self.db_url_postgresql
74 |
75 | @property
76 | def db_url_mongo(self) -> str:
77 | """Product db url."""
78 | return f"mongodb://{self.MONGO_HOST}:{self.MONGO_PORT}"
79 |
80 | @property
81 | def db_url_redis(self) -> str:
82 | """Product db url."""
83 | return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}"
84 |
85 |
86 | settings = Settings()
87 |
--------------------------------------------------------------------------------
/src/chat/routers.py:
--------------------------------------------------------------------------------
1 | from typing import Annotated
2 |
3 | from fastapi import APIRouter, Depends
4 | from fastapi.websockets import WebSocket
5 | from orjson import JSONEncodeError, orjson
6 | from pydantic import ValidationError
7 | from starlette.websockets import WebSocketDisconnect
8 |
9 | from src.auth.models import AuthUser
10 | from src.chat.exceptions import NoMatchError
11 | from src.chat.schemas import WSAction, WSMessageRequest, WSStatus
12 | from src.chat.utils import (
13 | create_message,
14 | delete_message,
15 | get_user_from_ws_cookie,
16 | orjson_dumps,
17 | update_message,
18 | ws_manager,
19 | )
20 |
21 | ws_router = APIRouter(
22 | prefix="/chat",
23 | tags=["WebSocket chat"],
24 | )
25 |
26 |
27 | @ws_router.websocket("/ws")
28 | async def websocket_chat(
29 | ws: WebSocket,
30 | user: Annotated[AuthUser, Depends(get_user_from_ws_cookie)],
31 | ):
32 | if user is None:
33 | await ws.close()
34 | return
35 |
36 | await ws_manager.connect(ws, user)
37 |
38 | # TODO: recieve message updates
39 |
40 | while True:
41 | try:
42 | text_data = await ws.receive_text()
43 | data = orjson.loads(text_data)
44 | ws_msg = WSMessageRequest.parse_obj(data)
45 | match ws_msg.action:
46 | case WSAction.CREATE:
47 | await create_message(ws_msg, ws, user)
48 | case WSAction.DELETE:
49 | await delete_message(ws_msg, ws, user)
50 | case WSAction.UPDATE:
51 | await update_message(ws_msg, ws, user)
52 | except (RuntimeError, WebSocketDisconnect): # ws connection error
53 | await ws_manager.disconnect(ws, user.id)
54 | break
55 | except (ValidationError, JSONEncodeError): # pydantic schema parse error, unknown action, decode msg error
56 | await ws.send_text(orjson_dumps({
57 | "status": WSStatus.ERROR,
58 | "detail": "unknown action or bad message format",
59 | }))
60 | except NoMatchError as e:
61 | await ws.send_text(orjson_dumps({
62 | "status": WSStatus.ERROR,
63 | "detail": str(e),
64 | }))
65 | except Exception: # noqa: BLE001
66 | # TODO: log this shit
67 | await ws_manager.disconnect(ws, user.id)
68 | break
69 |
--------------------------------------------------------------------------------
/src/likes/crud.py:
--------------------------------------------------------------------------------
1 | from uuid import UUID
2 |
3 | from sqlalchemy import select
4 | from sqlalchemy.dialects.postgresql import insert
5 | from sqlalchemy.exc import SQLAlchemyError
6 | from sqlalchemy.ext.asyncio import AsyncSession
7 |
8 | from src.auth.models import AuthUser
9 | from src.exceptions import AlreadyExistsException, SelfLikeException
10 | from src.likes.models import UserLike
11 | from src.likes.schemas import UserLikeRequest
12 |
13 |
14 | async def add_like(user: AuthUser, user_like: UserLikeRequest, session: AsyncSession):
15 | stmt = (
16 | insert(UserLike)
17 | .values(
18 | {
19 | UserLike.user_id: user.id,
20 | UserLike.liked_user_id: user_like.liked_user_id,
21 | UserLike.is_liked: user_like.is_liked,
22 | },
23 | )
24 | .returning(UserLike)
25 | )
26 |
27 | try:
28 | like = (await session.execute(stmt)).scalar_one_or_none()
29 | await session.commit()
30 | return like
31 | except SQLAlchemyError:
32 | return None
33 |
34 |
35 | async def get_all_likes(session: AsyncSession) -> list[UserLike]:
36 | return (await session.execute(select(UserLike))).scalars().all()
37 |
38 |
39 | async def get_like_by_user_ids(
40 | session: AsyncSession,
41 | user_id: UUID,
42 | liked_user_id: UUID,
43 | ) -> UserLike | None:
44 | stmt = select(UserLike).where(
45 | UserLike.liked_user_id == liked_user_id,
46 | UserLike.user_id == user_id,
47 | )
48 | return (await session.execute(stmt)).scalar_one_or_none()
49 |
50 |
51 | async def get_like_by_id(session: AsyncSession, like_id: UUID) -> UserLike | None:
52 | return await session.get(UserLike, like_id)
53 |
54 |
55 | async def create_like(
56 | session: AsyncSession,
57 | like_data: UserLikeRequest,
58 | ) -> UserLike | None:
59 | await check_like_data(session, like_data)
60 | stmt = insert(UserLike).values(like_data.dict()).returning(UserLike)
61 |
62 | like = (await session.execute(stmt)).scalar_one_or_none()
63 | await session.commit()
64 | return like
65 |
66 |
67 | async def check_like_data(session: AsyncSession, like_data: UserLikeRequest):
68 | if like_data.liked_user_id == like_data.user_id:
69 | raise SelfLikeException
70 |
71 | likes = await get_all_likes(session)
72 | if [
73 | like
74 | for like in likes
75 | if like.user_id == like_data.user_id
76 | and like.liked_user_id == like_data.liked_user_id
77 | ]:
78 | raise AlreadyExistsException
79 |
80 |
81 | async def delete_like(session: AsyncSession, like: UserLike):
82 | await session.delete(like)
83 | await session.commit()
84 |
--------------------------------------------------------------------------------
/src/questionnaire/routers.py:
--------------------------------------------------------------------------------
1 | from typing import Annotated
2 | from uuid import UUID
3 |
4 | from fastapi import APIRouter, Depends, Path, status
5 | from sqlalchemy.ext.asyncio import AsyncSession
6 |
7 | from src.auth.base_config import current_user
8 | from src.auth.models import AuthUser
9 | from src.database import get_async_session
10 | from src.questionnaire import crud
11 | from src.questionnaire.schemas import (
12 | CreateUserQuestionnaireSchema,
13 | ResponseUserQuestionnaireSchema,
14 | )
15 |
16 | router = APIRouter(
17 | prefix="/questionnaire",
18 | tags=["Questionnaire"],
19 | )
20 |
21 |
22 | @router.post(
23 | "",
24 | response_model=ResponseUserQuestionnaireSchema,
25 | status_code=status.HTTP_201_CREATED,
26 | )
27 | async def create_questionnaire(
28 | user_profile: CreateUserQuestionnaireSchema,
29 | session: Annotated[AsyncSession, Depends(get_async_session)],
30 | user: Annotated[AuthUser, Depends(current_user)],
31 | ):
32 | return await crud.create_questionnaire(user_profile, session, user)
33 |
34 |
35 | @router.get(
36 | "/list/{page_number}",
37 | response_model=list[ResponseUserQuestionnaireSchema],
38 | status_code=status.HTTP_200_OK,
39 | )
40 | async def get_list_questionnaire(
41 | user: Annotated[AuthUser, Depends(current_user)],
42 | session: Annotated[AsyncSession, Depends(get_async_session)],
43 | page_number: Annotated[int, Path(ge=0)],
44 | ):
45 | return await crud.get_list_questionnaire(user, session, page_number)
46 |
47 |
48 | @router.get(
49 | "/get_my_quest",
50 | response_model=ResponseUserQuestionnaireSchema,
51 | status_code=status.HTTP_200_OK,
52 | )
53 | async def get_questionnaire(
54 | user: Annotated[AuthUser, Depends(current_user)],
55 | session: Annotated[AsyncSession, Depends(get_async_session)],
56 | ):
57 | return await crud.get_questionnaire(user.id, session)
58 |
59 |
60 | @router.patch(
61 | "/{quest_id}",
62 | response_model=ResponseUserQuestionnaireSchema,
63 | status_code=status.HTTP_200_OK,
64 | )
65 | async def update_quest(
66 | quest_id: UUID,
67 | update_value: CreateUserQuestionnaireSchema,
68 | session: Annotated[AsyncSession, Depends(get_async_session)],
69 | user: Annotated[AuthUser, Depends(current_user)],
70 | ):
71 | return await crud.update_questionnaire(quest_id, update_value, session, user)
72 |
73 |
74 | @router.delete(
75 | "/{quest_id}",
76 | status_code=status.HTTP_204_NO_CONTENT,
77 | )
78 | async def delete_quest(
79 | user: Annotated[AuthUser, Depends(current_user)],
80 | quest_id: UUID,
81 | session: Annotated[AsyncSession, Depends(get_async_session)],
82 | ):
83 | return await crud.delete_quest(user, quest_id, session)
84 |
--------------------------------------------------------------------------------
/migrations/env.py:
--------------------------------------------------------------------------------
1 | from logging.config import fileConfig
2 |
3 | from alembic import context
4 | from sqlalchemy import engine_from_config, pool
5 |
6 | from src.auth.models import AuthUser, UserSettings # noqa: F401
7 | from src.chat.models import Message
8 | from src.config import settings
9 | from src.database import Base
10 | from src.matches.models import Match
11 | from src.likes.models import UserLike
12 | from src.questionnaire.models import BlackListUser, UserQuestionnaire # noqa: F401
13 |
14 | # this is the Alembic Config object, which provides
15 | # access to the values within the .ini file in use.
16 | config = context.config
17 |
18 | section = config.config_ini_section
19 |
20 | config.set_section_option(
21 | section=section, name="DATABASE_URL", value=settings.db_url_postgresql
22 | )
23 |
24 | # Interpret the config file for Python logging.
25 | # This line sets up loggers basically.
26 | if config.config_file_name is not None:
27 | fileConfig(config.config_file_name)
28 |
29 | # add your model's MetaData object here
30 | # for 'autogenerate' support
31 | # from myapp import mymodel
32 | # target_metadata = mymodel.Base.metadata
33 | target_metadata = Base.metadata
34 |
35 |
36 | # other values from the config, defined by the needs of env.py,
37 | # can be acquired:
38 | # my_important_option = config.get_main_option("my_important_option")
39 | # ... etc.
40 |
41 |
42 | def run_migrations_offline() -> None:
43 | """Run migrations in 'offline' mode.
44 |
45 | This configures the context with just a URL
46 | and not an Engine, though an Engine is acceptable
47 | here as well. By skipping the Engine creation
48 | we don't even need a DBAPI to be available.
49 |
50 | Calls to context.execute() here emit the given string to the
51 | script output.
52 |
53 | """
54 | url = config.get_main_option("sqlalchemy.url")
55 | context.configure(
56 | url=url,
57 | target_metadata=target_metadata,
58 | literal_binds=True,
59 | dialect_opts={"paramstyle": "named"},
60 | )
61 |
62 | with context.begin_transaction():
63 | context.run_migrations()
64 |
65 |
66 | def run_migrations_online() -> None:
67 | """Run migrations in 'online' mode.
68 |
69 | In this scenario we need to create an Engine
70 | and associate a connection with the context.
71 |
72 | """
73 | connectable = engine_from_config(
74 | config.get_section(config.config_ini_section, {}),
75 | prefix="sqlalchemy.",
76 | poolclass=pool.NullPool,
77 | )
78 |
79 | with connectable.connect() as connection:
80 | context.configure(connection=connection, target_metadata=target_metadata)
81 |
82 | with context.begin_transaction():
83 | context.run_migrations()
84 |
85 |
86 | if context.is_offline_mode():
87 | run_migrations_offline()
88 | else:
89 | run_migrations_online()
90 |
--------------------------------------------------------------------------------
/templates/admin_login.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Логин
7 |
75 |
76 |
77 |
78 | {% if form_errors %}
79 |
{{ form_errors }}
80 | {% endif %}
81 |
93 |
94 |
95 |
--------------------------------------------------------------------------------
/src/auth/crud.py:
--------------------------------------------------------------------------------
1 | from fastapi import HTTPException, status
2 | from sqlalchemy import select, update
3 | from sqlalchemy.dialects.postgresql import insert
4 | from sqlalchemy.ext.asyncio import AsyncSession
5 |
6 | from src.auth import schemas
7 | from src.auth import utils as auth_utils
8 | from src.auth.models import AuthUser, UserSettings
9 | from src.auth.schemas import UserCreateInput, UserSchema
10 |
11 |
12 | async def get_user(
13 | email: str,
14 | session: AsyncSession,
15 | ) -> AuthUser:
16 | """Получение данных o пользователе из БД по email."""
17 | query = select(AuthUser).where(AuthUser.email == email)
18 | result = await session.execute(query)
19 | return result.scalar_one_or_none()
20 |
21 |
22 | async def create_user(
23 | user_data: UserCreateInput,
24 | session: AsyncSession,
25 | ) -> UserSchema:
26 | """Создание пользователя в БД."""
27 | if await get_user(user_data.email, session):
28 | raise HTTPException(
29 | status_code=status.HTTP_400_BAD_REQUEST,
30 | detail="register user already exists",
31 | )
32 | stmt = insert(AuthUser).values(
33 | {
34 | "email": user_data.email,
35 | "hashed_password": auth_utils.hash_password(user_data.password),
36 | },
37 | )
38 | await session.execute(stmt)
39 | user = await get_user(user_data.email, session)
40 | await create_user_profile(user=user, session=session)
41 | return UserSchema(
42 | id=user.id,
43 | email=user.email,
44 | is_active=user.is_active,
45 | is_superuser=user.is_superuser,
46 | is_verified=user.is_verified,
47 | )
48 |
49 |
50 | async def add_user(user: UserCreateInput, session: AsyncSession):
51 | stmt = (
52 | insert(AuthUser)
53 | .values(
54 | {
55 | AuthUser.email: user.email,
56 | AuthUser.hashed_password: user.password,
57 | },
58 | )
59 | .returning(AuthUser)
60 | )
61 |
62 | user = (await session.execute(stmt)).scalar_one_or_none()
63 | await session.commit()
64 | return user
65 |
66 |
67 | async def get_user_profile(
68 | user: AuthUser,
69 | session: AsyncSession,
70 | ) -> schemas.UserProfile:
71 | """Get user profile."""
72 | stmt = select(UserSettings).filter_by(user_id=user.id)
73 | profile = await session.execute(stmt)
74 | return schemas.UserProfile.validate(profile.scalars().first())
75 |
76 |
77 | async def update_user_profile(
78 | data: schemas.UserProfileUpdate,
79 | user: AuthUser,
80 | session: AsyncSession,
81 | ) -> schemas.UserProfile:
82 | """Update user profile."""
83 | stmt = (
84 | update(UserSettings)
85 | .filter_by(user_id=user.id)
86 | .values(data.dict())
87 | .returning(UserSettings)
88 | )
89 | profile = await session.execute(stmt)
90 | await session.commit()
91 | return schemas.UserProfile.validate(profile.scalars().first())
92 |
93 |
94 | async def create_user_profile(user: AuthUser, session: AsyncSession):
95 | """Create user profile."""
96 | stmt = insert(UserSettings).values(
97 | {
98 | UserSettings.user_id: user.id,
99 | },
100 | )
101 | await session.execute(stmt)
102 | await session.commit()
103 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3.8'
2 |
3 | services:
4 | app:
5 | build:
6 | context: .
7 | dockerfile: Dockerfile
8 | container_name: app_mir
9 | environment:
10 | - DEBUG=${DEBUG}
11 | - DB_USER=${DB_USER}
12 | - DB_PASS=${DB_PASS}
13 | - DB_NAME=${DB_NAME}
14 | - DB_HOST=${DB_HOST}
15 | - DB_PORT=${DB_PORT}
16 | - TEST_DB_NAME=${TEST_DB_NAME}
17 | - REDIS_HOST=${REDIS_HOST}
18 | - REDIS_PORT=${REDIS_PORT}
19 | - MONGO_HOST=${MONGO_HOST}
20 | - MONGO_PORT=${MONGO_PORT}
21 | - MONGO_DATABASE=${MONGO_DATABASE}
22 | - SECRET_KEY=${SECRET_KEY}
23 | - ACCESS_TOKEN_EXPIRES_IN=${ACCESS_TOKEN_EXPIRES_IN}
24 | - REFRESH_TOKEN_EXPIRES_IN=${REFRESH_TOKEN_EXPIRES_IN}
25 | - ALGORITHM=${ALGORITHM}
26 | - COOKIE_ACCESS_TOKEN_KEY=${COOKIE_ACCESS_TOKEN_KEY}
27 | - COOKIE_REFRESH_TOKEN_KEY=${COOKIE_REFRESH_TOKEN_KEY}
28 | ports:
29 | - '8000:8000'
30 | entrypoint: sh -c "alembic upgrade head && uvicorn src.main:app --host 0.0.0.0 --port 8000"
31 | networks:
32 | - mir_network
33 | depends_on:
34 | - postgresql_db
35 | - redis_app
36 | - mongo_db
37 |
38 | test_app:
39 | build:
40 | context: .
41 | dockerfile: Dockerfile
42 | container_name: test_app_mir
43 | environment:
44 | - DEBUG=${DEBUG}
45 | - DB_USER=${DB_USER}
46 | - DB_PASS=${DB_PASS}
47 | - DB_NAME=${DB_NAME}
48 | - DB_HOST=${DB_HOST}
49 | - DB_PORT=${DB_PORT}
50 | - TEST_DB_NAME=${TEST_DB_NAME}
51 | - REDIS_HOST=${REDIS_HOST}
52 | - REDIS_PORT=${REDIS_PORT}
53 | - MONGO_HOST=${MONGO_HOST}
54 | - MONGO_PORT=${MONGO_PORT}
55 | - MONGO_DATABASE=${MONGO_DATABASE}
56 | - SECRET_KEY=${SECRET_KEY}
57 | - ACCESS_TOKEN_EXPIRES_IN=${ACCESS_TOKEN_EXPIRES_IN}
58 | - REFRESH_TOKEN_EXPIRES_IN=${REFRESH_TOKEN_EXPIRES_IN}
59 | - ALGORITHM=${ALGORITHM}
60 | - COOKIE_ACCESS_TOKEN_KEY=${COOKIE_ACCESS_TOKEN_KEY}
61 | - COOKIE_REFRESH_TOKEN_KEY=${COOKIE_REFRESH_TOKEN_KEY}
62 | ports:
63 | - '8000:8000'
64 | entrypoint: sh -c "alembic upgrade head && pytest"
65 | networks:
66 | - mir_network
67 | depends_on:
68 | - postgresql_db
69 | - redis_app
70 | - mongo_db
71 |
72 | postgresql_db:
73 | container_name: postgresql_db_mir
74 | image: postgres:latest
75 | environment:
76 | - POSTGRES_USER=${DB_USER}
77 | - POSTGRES_PASSWORD=${DB_PASS}
78 | - POSTGRES_DB=${DB_NAME}
79 | ports:
80 | - ${CONTAINER_DB_PORT}:${DB_PORT}
81 | networks:
82 | mir_network:
83 | ipv4_address: 192.168.0.10
84 |
85 | redis_app:
86 | container_name: redis_mir
87 | image: redis:latest
88 | ports:
89 | - ${CONTAINER_REDIS_PORT}:${REDIS_PORT}
90 | healthcheck:
91 | test: [ "CMD-SHELL", "redis-cli", "ping"]
92 | interval: 5s
93 | timeout: 10s
94 | retries: 3
95 | networks:
96 | mir_network:
97 | ipv4_address: 192.168.0.17
98 |
99 | mongo_db:
100 | container_name: mongo_db_mir
101 | image: mongo:latest
102 | ports:
103 | - ${CONTAINER_MONGO_PORT}:${MONGO_PORT}
104 | networks:
105 | mir_network:
106 | ipv4_address: 192.168.0.18
107 |
108 | networks:
109 | mir_network:
110 | driver: bridge
111 | ipam:
112 | config:
113 | - subnet: 192.168.0.0/24
--------------------------------------------------------------------------------
/src/auth/routers.py:
--------------------------------------------------------------------------------
1 | from typing import Annotated
2 |
3 | from fastapi import APIRouter, Depends, HTTPException, Response, status
4 | from sqlalchemy.ext.asyncio import AsyncSession
5 |
6 | from src.auth import base_config as auth_handler
7 | from src.auth import crud, schemas
8 | from src.auth.base_config import current_user
9 | from src.auth.crud import create_user
10 | from src.auth.models import AuthUser
11 | from src.auth.schemas import UserCreateInput, UserSchema
12 | from src.database import get_async_session
13 |
14 | auth_router = APIRouter(
15 | prefix="/auth",
16 | tags=["Auth"],
17 | )
18 |
19 |
20 | @auth_router.post(
21 | "/register",
22 | response_model=UserSchema,
23 | status_code=status.HTTP_201_CREATED,
24 | )
25 | async def register(
26 | response: Response,
27 | user_data: UserCreateInput,
28 | session: Annotated[AsyncSession, Depends(get_async_session)],
29 | ) -> UserSchema:
30 | """Регистрация нового пользователя c выдачей ему access и refresh token."""
31 | user = await create_user(user_data, session)
32 | if not user:
33 | raise HTTPException(
34 | status_code=status.HTTP_400_BAD_REQUEST,
35 | detail="invalid user data",
36 | )
37 | auth_handler.create_tokens(user, response)
38 | return user
39 |
40 |
41 | @auth_router.post(
42 | "/login",
43 | status_code=status.HTTP_200_OK,
44 | )
45 | async def login(
46 | response: Response,
47 | user: Annotated[UserCreateInput, Depends(auth_handler.validate_auth_user)],
48 | ) -> dict:
49 | """Проверка и вход пользователя c выдачей ему access и refresh token."""
50 | auth_handler.create_tokens(user, response)
51 | return {"status_code": status.HTTP_200_OK}
52 |
53 |
54 | @auth_router.get(
55 | "/refresh",
56 | status_code=status.HTTP_200_OK,
57 | )
58 | async def refresh_token(
59 | response: Response,
60 | user: Annotated[AuthUser, Depends(auth_handler.check_user_refresh_token)],
61 | ) -> dict:
62 | """Обновление access_token при наличии действующего refresh_token."""
63 | auth_handler.create_tokens(user, response)
64 | return {"status_code": status.HTTP_200_OK}
65 |
66 |
67 | @auth_router.get(
68 | "/logout",
69 | status_code=status.HTTP_204_NO_CONTENT,
70 | )
71 | async def logout(
72 | response: Response,
73 | ) -> None:
74 | """Выход пользователя c удалением файлов куки из браузера."""
75 | return auth_handler.delete_all_tokens(response)
76 |
77 |
78 | user_router = APIRouter(
79 | prefix="/users",
80 | tags=["User"],
81 | )
82 |
83 |
84 | @user_router.get(
85 | "/me",
86 | response_model=schemas.UserProfile,
87 | status_code=status.HTTP_200_OK,
88 | )
89 | async def get_profile(
90 | user: Annotated[AuthUser, Depends(current_user)],
91 | session: Annotated[AsyncSession, Depends(get_async_session)],
92 | ) -> schemas.UserProfile:
93 | return await crud.get_user_profile(user=user, session=session)
94 |
95 |
96 | @user_router.patch(
97 | "/me",
98 | response_model=schemas.UserProfile,
99 | status_code=status.HTTP_200_OK,
100 | )
101 | async def update_profile(
102 | data: schemas.UserProfileUpdate,
103 | user: Annotated[AuthUser, Depends(current_user)],
104 | session: Annotated[AsyncSession, Depends(get_async_session)],
105 | ) -> schemas.UserProfile:
106 | """Update user profile."""
107 | return await crud.update_user_profile(
108 | data=data,
109 | user=user,
110 | session=session,
111 | )
112 |
--------------------------------------------------------------------------------
/src/admin/auth_provider.py:
--------------------------------------------------------------------------------
1 | from sqlalchemy import select
2 | from starlette import status
3 | from starlette.requests import Request
4 | from starlette.responses import RedirectResponse, Response
5 | from starlette.templating import Jinja2Templates
6 | from starlette_admin.auth import AdminUser, AuthProvider
7 | from starlette_admin.base import BaseAdmin
8 | from starlette_admin.exceptions import FormValidationError, LoginFailed
9 |
10 | from src.admin.utils import verify_password
11 | from src.auth.models import AuthUser
12 | from src.database import async_session_maker
13 |
14 | templates = Jinja2Templates(directory="templates")
15 |
16 |
17 | class EmailAndPasswordProvider(AuthProvider):
18 | async def login(
19 | self,
20 | email: str,
21 | password: str,
22 | request: Request,
23 | response: Response,
24 | ) -> Response:
25 | async with async_session_maker() as session:
26 | query = select(AuthUser).filter_by(email=email)
27 | result = await session.execute(query)
28 | user = result.scalar_one_or_none()
29 | if user:
30 | hashed_password = user.hashed_password
31 | if verify_password(password, hashed_password):
32 | request.session.update({"email": email})
33 | return response
34 | raise LoginFailed("Invalid email or password")
35 |
36 | async def is_authenticated(self, request: Request) -> bool:
37 | if request.session.get("email", None) is not None:
38 | request.state.user = request.session.get("email")
39 | return True
40 | return False
41 |
42 | def get_admin_user(self, request: Request) -> AdminUser:
43 | user = request.state.user
44 | photo_url = request.url_for("static", path="img/admin.png")
45 | return AdminUser(username=user, photo_url=photo_url)
46 |
47 | async def logout(self, request: Request, response: Response) -> Response:
48 | request.session.clear()
49 | return response
50 |
51 | async def render_login(
52 | self,
53 | request: Request,
54 | admin: BaseAdmin,
55 | ) -> Response:
56 | if request.method == "GET":
57 | return templates.TemplateResponse(
58 | name="admin_login.html",
59 | context={"request": request, "_is_login_path": True},
60 | )
61 | form = await request.form()
62 | try:
63 | return await self.login(
64 | form.get("email"),
65 | form.get("password"),
66 | request,
67 | RedirectResponse(
68 | request.query_params.get("next")
69 | or request.url_for(admin.route_name + ":index"),
70 | status_code=status.HTTP_303_SEE_OTHER,
71 | ),
72 | )
73 | except FormValidationError as errors:
74 | return templates.TemplateResponse(
75 | "admin_login.html",
76 | {
77 | "request": request,
78 | "form_errors": errors,
79 | "_is_login_path": True,
80 | },
81 | status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
82 | )
83 | except LoginFailed as error:
84 | return templates.TemplateResponse(
85 | "admin_login.html",
86 | {
87 | "request": request,
88 | "form_errors": error.msg,
89 | "_is_login_path": True,
90 | },
91 | status_code=status.HTTP_400_BAD_REQUEST,
92 | )
93 |
--------------------------------------------------------------------------------
/tests/with_db/test_likes.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import uuid
3 |
4 | from dirty_equals import IsDatetime, IsUUID
5 | from fastapi import status
6 | from httpx import AsyncClient, Response
7 | from sqlalchemy.ext.asyncio import AsyncSession
8 |
9 | from src.auth.models import AuthUser
10 | from src.matches.crud import get_match_by_user_ids
11 |
12 |
13 | async def test_like_user(
14 | async_client: AsyncClient,
15 | authorised_cookie: dict,
16 | user2: AuthUser,
17 | ):
18 | """Проверка корректного выставления лайка.
19 | """
20 | data = {"liked_user_id": str(user2.id), "is_liked": True}
21 |
22 | response: Response = await async_client.post("/api/v1/likes", json=data, cookies=authorised_cookie)
23 |
24 | assert response.status_code == status.HTTP_201_CREATED
25 | assert response.json() == {
26 | "id": IsUUID,
27 | "liked_user_id": str(user2.id),
28 | "created_at": IsDatetime(iso_string=True),
29 | "is_liked": True,
30 | }
31 |
32 |
33 | async def test_like_wrong_user(
34 | async_client: AsyncClient,
35 | authorised_cookie: dict,
36 | ):
37 | """Проверка того, что пользователь не может лайкнуть несуществующего пользователя.
38 | """
39 | data = {"liked_user_id": str(uuid.uuid4())}
40 |
41 | response: Response = await async_client.post("/api/v1/likes", json=data, cookies=authorised_cookie)
42 |
43 | assert response.status_code == status.HTTP_404_NOT_FOUND
44 | assert response.json().get("detail") == "bad user id"
45 |
46 |
47 | async def test_self_like(
48 | async_client: AsyncClient,
49 | authorised_cookie: dict,
50 | user: AuthUser,
51 | ):
52 | """Проверка того, что пользователь не может лайкнуть себя.
53 | """
54 | data = {"liked_user_id": str(user.id)}
55 |
56 | response: Response = await async_client.post("/api/v1/likes", json=data, cookies=authorised_cookie)
57 |
58 | assert response.status_code == status.HTTP_404_NOT_FOUND
59 | assert response.json().get("detail") == "bad user id"
60 |
61 |
62 | async def test_match_creation_after_double_like(
63 | async_client: AsyncClient,
64 | authorised_cookie_user2: dict,
65 | user2: AuthUser,
66 | authorised_cookie_user3: dict,
67 | user3: AuthUser,
68 | get_async_session: AsyncSession,
69 | ):
70 | """Проверка корректного создания match после взаимного лайка двух пользователей.
71 | """
72 | data1 = {"liked_user_id": str(user2.id), "is_liked": True}
73 | data2 = {"liked_user_id": str(user3.id), "is_liked": True}
74 |
75 | response1: Response = await async_client.post("/api/v1/likes", json=data1, cookies=authorised_cookie_user3)
76 | response2: Response = await async_client.post("/api/v1/likes", json=data2, cookies=authorised_cookie_user2)
77 |
78 | assert response1.status_code == status.HTTP_201_CREATED
79 | assert response2.status_code == status.HTTP_201_CREATED
80 |
81 | await asyncio.sleep(0.05)
82 | match = await get_match_by_user_ids(get_async_session, user1_id=user3.id, user2_id=user2.id)
83 |
84 | assert match is not None
85 |
86 |
87 | async def test_like_user_without_token(
88 | async_client: AsyncClient,
89 | user2: AuthUser,
90 | ):
91 | """Проверка выставления лайка без токена или c неправильным токеном.
92 | """
93 | data = {"liked_user_id": str(user2.id), "is_liked": True}
94 |
95 | response: Response = await async_client.post("/api/v1/likes", json=data, cookies={})
96 |
97 | assert response.status_code == status.HTTP_403_FORBIDDEN
98 |
99 | response: Response = await async_client.post(
100 | "/api/v1/likes",
101 | json=data,
102 | cookies={"mir": "some.kind.of.incorrect.cookies"},
103 | )
104 | assert response.status_code == status.HTTP_401_UNAUTHORIZED
105 |
--------------------------------------------------------------------------------
/alembic.ini:
--------------------------------------------------------------------------------
1 | # A generic, single database configuration.
2 |
3 | [alembic]
4 | # path to migration scripts
5 | script_location = migrations
6 |
7 | # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
8 | # Uncomment the line below if you want the files to be prepended with date and time
9 | # see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
10 | # for all available tokens
11 | # file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
12 |
13 | # sys.path path, will be prepended to sys.path if present.
14 | # defaults to the current working directory.
15 | prepend_sys_path = .
16 |
17 | # timezone to use when rendering the date within the migration file
18 | # as well as the filename.
19 | # If specified, requires the python-dateutil library that can be
20 | # installed by adding `alembic[tz]` to the pip requirements
21 | # string value is passed to dateutil.tz.gettz()
22 | # leave blank for localtime
23 | # timezone =
24 |
25 | # max length of characters to apply to the
26 | # "slug" field
27 | # truncate_slug_length = 40
28 |
29 | # set to 'true' to run the environment during
30 | # the 'revision' command, regardless of autogenerate
31 | # revision_environment = false
32 |
33 | # set to 'true' to allow .pyc and .pyo files without
34 | # a source .py file to be detected as revisions in the
35 | # versions/ directory
36 | # sourceless = false
37 |
38 | # version location specification; This defaults
39 | # to migrations/versions. When using multiple version
40 | # directories, initial revisions must be specified with --version-path.
41 | # The path separator used here should be the separator specified by "version_path_separator" below.
42 | # version_locations = %(here)s/bar:%(here)s/bat:migrations/versions
43 |
44 | # version path separator; As mentioned above, this is the character used to split
45 | # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
46 | # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
47 | # Valid values for version_path_separator are:
48 | #
49 | # version_path_separator = :
50 | # version_path_separator = ;
51 | # version_path_separator = space
52 | version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
53 |
54 | # set to 'true' to search source files recursively
55 | # in each "version_locations" directory
56 | # new in Alembic version 1.10
57 | # recursive_version_locations = false
58 |
59 | # the output encoding used when revision files
60 | # are written from script.py.mako
61 | # output_encoding = utf-8
62 |
63 | sqlalchemy.url = %(DATABASE_URL)s?async_fallback=True
64 |
65 | [post_write_hooks]
66 | # post_write_hooks defines scripts or Python functions that are run
67 | # on newly generated revision scripts. See the documentation for further
68 | # detail and examples
69 |
70 | # format using "black" - use the console_scripts runner, against the "black" entrypoint
71 | # hooks = black
72 | # black.type = console_scripts
73 | # black.entrypoint = black
74 | # black.options = -l 79 REVISION_SCRIPT_FILENAME
75 |
76 | # Logging configuration
77 | [loggers]
78 | keys = root,sqlalchemy,alembic
79 |
80 | [handlers]
81 | keys = console
82 |
83 | [formatters]
84 | keys = generic
85 |
86 | [logger_root]
87 | level = WARN
88 | handlers = console
89 | qualname =
90 |
91 | [logger_sqlalchemy]
92 | level = WARN
93 | handlers =
94 | qualname = sqlalchemy.engine
95 |
96 | [logger_alembic]
97 | level = INFO
98 | handlers =
99 | qualname = alembic
100 |
101 | [handler_console]
102 | class = StreamHandler
103 | args = (sys.stderr,)
104 | level = NOTSET
105 | formatter = generic
106 |
107 | [formatter_generic]
108 | format = %(levelname)-5.5s [%(name)s] %(message)s
109 | datefmt = %H:%M:%S
110 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 | .DS_Store
9 |
10 | # Distribution / packaging
11 | .Python
12 | build/
13 | develop-eggs/
14 | dist/
15 | downloads/
16 | eggs/
17 | .eggs/
18 | lib/
19 | lib64/
20 | parts/
21 | sdist/
22 | var/
23 | wheels/
24 | share/python-wheels/
25 | *.egg-info/
26 | .installed.cfg
27 | *.egg
28 | MANIFEST
29 |
30 | # PyInstaller
31 | # Usually these files are written by a python script from a template
32 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
33 | *.manifest
34 | *.spec
35 |
36 | # Installer logs
37 | pip-log.txt
38 | pip-delete-this-directory.txt
39 |
40 | # Unit test / coverage reports
41 | htmlcov/
42 | .tox/
43 | .nox/
44 | .coverage
45 | .coverage.*
46 | .cache
47 | nosetests.xml
48 | coverage.xml
49 | *.cover
50 | *.py,cover
51 | .hypothesis/
52 | .pytest_cache/
53 | cover/
54 |
55 | # Translations
56 | *.mo
57 | *.pot
58 |
59 | # Django stuff:
60 | *.log
61 | local_settings.py
62 | db.sqlite3
63 | db.sqlite3-journal
64 |
65 | # Flask stuff:
66 | instance/
67 | .webassets-cache
68 |
69 | # Scrapy stuff:
70 | .scrapy
71 |
72 | # Sphinx documentation
73 | docs/_build/
74 |
75 | # PyBuilder
76 | .pybuilder/
77 | target/
78 |
79 | # Jupyter Notebook
80 | .ipynb_checkpoints
81 |
82 | # IPython
83 | profile_default/
84 | ipython_config.py
85 |
86 | # pyenv
87 | # For a library or package, you might want to ignore these files since the code is
88 | # intended to run in multiple environments; otherwise, check them in:
89 | .python-version
90 |
91 | # pipenv
92 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
93 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
94 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
95 | # install all needed dependencies.
96 | Pipfile.lock
97 |
98 | # poetry
99 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
100 | # This is especially recommended for binary packages to ensure reproducibility, and is more
101 | # commonly ignored for libraries.
102 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
103 | #poetry.lock
104 |
105 | # pdm
106 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
107 | pdm.lock
108 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
109 | # in version control.
110 | # https://pdm.fming.dev/#use-with-ide
111 | .pdm.toml
112 |
113 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
114 | __pypackages__/
115 |
116 | # Celery stuff
117 | celerybeat-schedule
118 | celerybeat.pid
119 |
120 | # SageMath parsed files
121 | *.sage.py
122 |
123 | # Environments
124 | .env
125 | .venv
126 | env/
127 | venv/
128 | ENV/
129 | env.bak/
130 | venv.bak/
131 | jwt-private.pem
132 | jwt-public.pem
133 |
134 | # Spyder project settings
135 | .spyderproject
136 | .spyproject
137 |
138 | # Rope project settings
139 | .ropeproject
140 |
141 | # mkdocs documentation
142 | /site
143 |
144 | # mypy
145 | .mypy_cache/
146 | .dmypy.json
147 | dmypy.json
148 |
149 | # Pyre type checker
150 | .pyre/
151 |
152 | # pytype static type analyzer
153 | .pytype/
154 |
155 | # Cython debug symbols
156 | cython_debug/
157 |
158 |
159 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
160 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
161 | # and can be added to the global gitignore or merged into this file. For a more nuclear
162 | # option (not recommended) you can uncomment the following to ignore the entire idea folder.
163 | .idea/
164 | pg_data/
165 | test.db
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [tool.poetry]
2 | name = "mir"
3 | version = "0.1.0"
4 | description = ""
5 | authors = ["waterstark "]
6 | readme = "README.md"
7 |
8 | [tool.poetry.dependencies]
9 | python = "^3.10"
10 | fastapi = "^0.109.1"
11 | uvicorn = {extras = ["standard"], version = "^0.23.2"}
12 | alembic = "^1.11.1"
13 | sqlalchemy = {extras = ["asyncio"], version = "^2.0.17"}
14 | asyncpg = "^0.29.0"
15 | python-dotenv = "^1.0.0"
16 | redis = "^4.6.0"
17 | fastapi-users-db-sqlalchemy = "^6.0.0"
18 | sqlalchemy-utils = "^0.41.1"
19 | pytest = "^7.4.0"
20 | pytest-asyncio = "^0.21.1"
21 | httpx = "^0.24.1"
22 | dirty-equals = "^0.6.0"
23 | pre-commit = "^3.3.3"
24 | starlette-admin = "^0.11.1"
25 | passlib = "^1.7.4"
26 | starlette-i18n = "^1.0.0"
27 | itsdangerous = "^2.1.2"
28 | pymongo = "^4.5.0"
29 | motor = "^3.3.1"
30 | orjson = "^3.9.15"
31 | async-asgi-testclient = "^1.4.11"
32 | pydantic = "^1.9.1"
33 | apscheduler = "^3.10.4"
34 | schedule = "^1.2.1"
35 |
36 |
37 | [tool.poetry.group.dev.dependencies]
38 | ruff = "^0.0.278"
39 | pytest-cov = "^4.1.0"
40 |
41 | [build-system]
42 | requires = ["poetry-core"]
43 | build-backend = "poetry.core.masonry.api"
44 |
45 | [tool.ruff]
46 | line-length = 120
47 | select = [
48 | "F", # pyflakes
49 | "E", # pycodestyle errors
50 | "W", # pycodestyle warnings
51 | "C90", # mccabe
52 | "I", # isort
53 | "N", # pep8-naming
54 | "UP", # pyupgrade
55 | "YTT", # flake8-2020
56 | "S", # flake8-bandit
57 | "BLE", # flake8-blind-except
58 | "FBT003", # flake8-boolean-trap
59 | "B", # flake8-bugbear
60 | "A", # flake8-builtins
61 | "COM", # flake8-commas
62 | "C4", # flake8-comprehensions
63 | "T10", # flake8-debugger
64 | "ISC", # flake8-implicit-str-concat
65 | "G010", # Logging statement uses warn instead of warning
66 | "G201", # Logging .exception(...) should be used instead of .error(..., exc_info=True)
67 | "G202", # Logging statement has redundant exc_info
68 | "INP", # flake8-no-pep420
69 | "PIE", # flake8-pie
70 | "T20", # flake8-print
71 | "PYI", # flake8-pyi
72 | "PT", # flake8-pytest-style
73 | "Q", # flake8-quotes
74 | "RSE", # flake8-raise
75 | "RET", # flake8-return
76 | "SIM", # flake8-simplify
77 | "TCH", # flake8-type-checking
78 | "ARG", # flake8-unused-arguments
79 | "PTH", # flake8-use-pathlib
80 | "ERA", # flake8-eradicate
81 | "PGH", # pygrep-hooks
82 | "PLC0414", # Import alias does not rename original package
83 | "PLE", # Error
84 | "PLW", # Warning
85 | "TRY", # tryceratops
86 | "FLY", # flynt
87 | "RUF", # ruff-specific rules
88 | "ANN001", # missing type annotation for arguments
89 | "ANN002", # missing type annotation for *args
90 | "ANN003", # missing type annotation for **kwargs
91 | ]
92 | unfixable = [
93 | "ERA001", # eradicate: found commented out code (can be dangerous if fixed automatically)
94 | ]
95 | ignore = [
96 | "A002", # builtin shadowing in arguments
97 | "A003", # builtin shadowing in attributes
98 | "D203", # 1 blank line required before class docstring
99 | "ARG002", # Unused method argument
100 | "TRY003", # Avoid specifying long messages outside the exception class
101 | "TRY300", # Consider moving statement into the else clause
102 | "ARG001", # Unused first argument
103 | "PT019", # Fixture without value is injected as parameter, use @pytest.mark.usefixtures instead
104 | "SIM108", # Use ternary operator instead of if-else block (ternaries lie to coverage)
105 | ]
106 |
107 | extend-exclude = ["migrations"]
108 |
109 | [tool.ruff.per-file-ignores]
110 | "tests/*" = [
111 | "S", # ignore bandit security issues in tests
112 | "B018", # ignore useless expressions in tests
113 | "PT012", # ignore complex with pytest.raises clauses
114 | ]
115 |
--------------------------------------------------------------------------------
/.github/workflows/pr_check.yml:
--------------------------------------------------------------------------------
1 | name: Check PR
2 | on:
3 | pull_request:
4 | branches: [main]
5 | types: [opened, synchronize]
6 | paths:
7 | - "**.py"
8 | - "**.toml"
9 | - "**.lock"
10 |
11 | jobs:
12 | tests:
13 | strategy:
14 | matrix:
15 | POSTGRES_USER: [postgres]
16 | POSTGRES_PASSWORD: [postgres]
17 | POSTGRES_DB: [postgres]
18 | python-version: ["3.10"]
19 | poetry-version: ["1.5.1"]
20 | os: [ubuntu-22.04]
21 | runs-on: ${{ matrix.os }}
22 | services:
23 | mongo:
24 | image: mongo:7.0
25 | ports:
26 | - 27017:27017
27 | # options: --health-cmd "echo 'db.runCommand(\'ping\').ok' | mongo localhost:27017/productiondb --quiet" --health-interval 10s --health-timeout 5s --health-retries 5
28 | postgres:
29 | image: postgres:15
30 | env:
31 | POSTGRES_USER: ${{matrix.POSTGRES_USER}}
32 | POSTGRES_PASSWORD: ${{matrix.POSTGRES_PASSWORD}}
33 | POSTGRES_DB: ${{matrix.POSTGRES_DB}}
34 | ports:
35 | - 5432:5432
36 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
37 | redis:
38 | image: redis:latest
39 | ports:
40 | - 6379:6379
41 | options: >-
42 | --health-cmd "redis-cli ping"
43 | --health-interval 10s
44 | --health-timeout 5s
45 | --health-retries 6
46 | steps:
47 | - name: Checkout a Git repo
48 | uses: actions/checkout@v3
49 | - name: Cache deps
50 | uses: actions/cache@v1
51 | with:
52 | path: ~/.cache/pypoetry/
53 | key: python-deps-${{ hashFiles('**/poetry.lock') }}
54 | - name: Installing Python
55 | uses: actions/setup-python@v4
56 | with:
57 | python-version: ${{ matrix.python-version }}
58 | - name: Installing Poetry
59 | uses: abatilo/actions-poetry@v2
60 | with:
61 | poetry-version: ${{ matrix.poetry-version }}
62 | - name: Installing Deps
63 | run: poetry install
64 | - name: Creating Private RSA Tokens
65 | run: openssl genrsa -out jwt-private.pem 2048
66 | - name: Creating Public RSA Tokens
67 | run: openssl rsa -in jwt-private.pem -outform PEM -pubout -out jwt-public.pem
68 | - name: Tests
69 | run: poetry run pytest --cov=. --cov-report=term-missing:skip-covered --cov-branch --cov-report=xml tests
70 | env:
71 | DEBUG: True
72 | DB_HOST: localhost
73 | DB_PORT: 5432
74 | DB_NAME: ${{matrix.POSTGRES_DB}}
75 | DB_USER: ${{matrix.POSTGRES_USER}}
76 | DB_PASS: ${{matrix.POSTGRES_DB}}
77 | TEST_DB_NAME:
78 | MONGO_HOST: localhost
79 | MONGO_PORT: '27017'
80 | MONGO_DATABASE: mongodb
81 | REDIS_HOST: localhost
82 | REDIS_PORT: '6379'
83 | SECRET_KEY: ${{ secrets.SECRET_KEY }}
84 | ACCESS_TOKEN_EXPIRES_IN: 15
85 | REFRESH_TOKEN_EXPIRES_IN: 5000
86 | ALGORITHM: RS256
87 | COOKIE_ACCESS_TOKEN_KEY: mir
88 | COOKIE_REFRESH_TOKEN_KEY: rsmir
89 | - name: Upload coverage reports to Codecov
90 | uses: codecov/codecov-action@v3
91 | with:
92 | token: ${{ secrets.CODECOV_TOKEN }}
93 | files: ./coverage.xml
94 |
95 | lint:
96 | needs: tests
97 | strategy:
98 | matrix:
99 | python-version: ["3.10"]
100 | poetry-version: ["1.5.1"]
101 | os: [ubuntu-22.04]
102 | runs-on: ${{ matrix.os }}
103 | steps:
104 | - name: Checkout a Git repo
105 | uses: actions/checkout@v3
106 | - name: Cache deps
107 | uses: actions/cache@v1
108 | with:
109 | path: ~/.cache/pypoetry/
110 | key: python-deps-${{ hashFiles('**/poetry.lock') }}
111 | - name: Installing Python
112 | uses: actions/setup-python@v4
113 | with:
114 | python-version: ${{ matrix.python-version }}
115 | - name: Installing Poetry
116 | uses: abatilo/actions-poetry@v2
117 | with:
118 | poetry-version: ${{ matrix.poetry-version }}
119 | - name: Linter
120 | run: poetry run ruff check .
121 |
--------------------------------------------------------------------------------
/src/questionnaire/models.py:
--------------------------------------------------------------------------------
1 | import uuid
2 | from datetime import date, datetime
3 |
4 | from sqlalchemy import (
5 | CheckConstraint,
6 | Column,
7 | ForeignKey,
8 | Numeric,
9 | String,
10 | Table,
11 | UniqueConstraint,
12 | )
13 | from sqlalchemy.orm import Mapped, mapped_column, relationship
14 | from sqlalchemy_utils import ChoiceType
15 |
16 | from src.auth.models import AuthUser
17 | from src.database import Base
18 | from src.questionnaire.params_choice import AlcoholType, Gender, Goal, SmokingType, SportType
19 |
20 |
21 | class BlackListUser(Base):
22 | __tablename__ = "black_list_user"
23 | __table_args__ = (
24 | UniqueConstraint("blocked_by_id", "blocked_id", name="_black_list_uc"),
25 | CheckConstraint("NOT(blocked_by_id = blocked_id)", name="_black_list_cc"),
26 | )
27 | id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
28 | created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
29 |
30 | blocked_by_id: Mapped[uuid.UUID] = mapped_column(
31 | ForeignKey("auth_user.id", ondelete="CASCADE"),
32 | )
33 | blocked_by = relationship(
34 | "AuthUser",
35 | backref="blocked_list",
36 | primaryjoin=AuthUser.id == blocked_by_id,
37 | )
38 | blocked_id: Mapped[uuid.UUID] = mapped_column(
39 | ForeignKey("auth_user.id", ondelete="CASCADE"),
40 | )
41 | blocked = relationship(
42 | "AuthUser",
43 | backref="blocked_by_list",
44 | primaryjoin=AuthUser.id == blocked_id,
45 | )
46 |
47 |
48 | user_hobby = Table(
49 | "user_hobby",
50 | Base.metadata,
51 | Column(
52 | "user_id",
53 | ForeignKey("user_questionnaire.id", ondelete="CASCADE"),
54 | primary_key=True,
55 | ),
56 | Column(
57 | "hobby_id",
58 | ForeignKey("user_questionnaire_hobby.id", ondelete="CASCADE"),
59 | primary_key=True,
60 | ),
61 | )
62 |
63 |
64 | class UserQuestionnaire(Base):
65 | __tablename__ = "user_questionnaire"
66 | __table_args__ = (
67 | UniqueConstraint("user_id", name="_user_id_uc"),
68 | )
69 |
70 | id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
71 | created_at: Mapped[datetime] = mapped_column(
72 | default=datetime.utcnow,
73 | )
74 | is_prem: Mapped[bool] = mapped_column(default=False, nullable=False)
75 | quest_lists_per_day: Mapped[int] = mapped_column(default=0, nullable=False)
76 | firstname: Mapped[str] = mapped_column(String(length=256), nullable=True)
77 | lastname: Mapped[str] = mapped_column(String(length=256), nullable=True)
78 | gender: Mapped[str] = mapped_column(ChoiceType(Gender), nullable=True)
79 | photo: Mapped[str] = mapped_column(String, nullable=True)
80 | country: Mapped[str] = mapped_column(String, nullable=True) # апи + live search
81 | city: Mapped[str] = mapped_column(String, nullable=True) # апи + live search
82 | latitude: Mapped[Numeric] = mapped_column(Numeric(8, 5), nullable=True)
83 | longitude: Mapped[Numeric] = mapped_column(Numeric(8, 5), nullable=True)
84 | about: Mapped[str] = mapped_column(String, nullable=True)
85 | height: Mapped[int] = mapped_column(nullable=True)
86 | goals: Mapped[str] = mapped_column(ChoiceType(Goal), nullable=True)
87 | sport: Mapped[str] = mapped_column(ChoiceType(SportType), nullable=False)
88 | smoking: Mapped[str] = mapped_column(ChoiceType(SmokingType), nullable=False)
89 | alcohol: Mapped[str] = mapped_column(ChoiceType(AlcoholType), nullable=False)
90 | is_visible: Mapped[bool] = mapped_column(default=True, nullable=False)
91 | birthday: Mapped[date] = mapped_column(nullable=False)
92 | hobbies: Mapped[list["UserQuestionnaireHobby"]] = relationship(
93 | secondary=user_hobby,
94 | lazy="selectin",
95 | cascade="all,delete-orphan",
96 | single_parent=True,
97 | )
98 | user_id: Mapped[uuid.UUID] = mapped_column(
99 | ForeignKey("auth_user.id", ondelete="CASCADE"),
100 | nullable=False,
101 | )
102 | user = relationship("AuthUser", back_populates="questionnaire")
103 |
104 |
105 | class UserQuestionnaireHobby(Base):
106 | __tablename__ = "user_questionnaire_hobby"
107 |
108 | id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
109 | hobby_name: Mapped[str] = mapped_column(String(length=256), nullable=False)
110 |
--------------------------------------------------------------------------------
/src/matches/crud.py:
--------------------------------------------------------------------------------
1 | from collections.abc import Sequence
2 | from uuid import UUID
3 |
4 | from sqlalchemy import and_, insert, or_, select
5 | from sqlalchemy.ext.asyncio import AsyncSession
6 |
7 | from src.auth.models import AuthUser
8 | from src.exceptions import AlreadyExistsException, NotFoundException, PermissionDeniedException, SelfMatchException
9 | from src.likes.crud import delete_like, get_like_by_user_ids
10 | from src.matches.models import Match
11 | from src.questionnaire.models import UserQuestionnaire
12 |
13 |
14 | async def get_match_by_user_ids(
15 | session: AsyncSession,
16 | user1_id: UUID,
17 | user2_id: UUID,
18 | ) -> Match | None:
19 | """Getting a Match between user 1 and user 2 by their ID."""
20 | stmt = select(Match).where(or_(
21 | and_(
22 | Match.user1_id == user1_id,
23 | Match.user2_id == user2_id,
24 | ), and_(
25 | Match.user1_id == user2_id,
26 | Match.user2_id == user1_id,
27 | ),
28 | ))
29 | return (await session.execute(stmt)).scalar_one_or_none()
30 |
31 |
32 | async def get_questionnaires_by_user_matched(
33 | session: AsyncSession,
34 | user: AuthUser,
35 | ) -> list[UserQuestionnaire]:
36 | """Getting all the questionnaires for which the Match occurred."""
37 | query = (
38 | select(Match, UserQuestionnaire)
39 | .join(
40 | Match,
41 | or_(
42 | and_(Match.user1_id == user.id, Match.user2_id == UserQuestionnaire.user_id),
43 | and_(Match.user2_id == user.id, Match.user1_id == UserQuestionnaire.user_id),
44 | ),
45 | )
46 | )
47 | result = (await session.execute(query)).fetchall()
48 |
49 | await change_questionnaire_match_info(result)
50 |
51 | return [questionnaire for _, questionnaire in result]
52 |
53 |
54 | async def change_questionnaire_match_info(query_result: Sequence[tuple[Match, UserQuestionnaire]]) -> None:
55 | # TODO: Придумать что-то логичное вместо этого костыля, возможно перенести логику в likes
56 | """The function changes the value of is_match in the Questionnaire model to True."""
57 | for match, questionnaire in query_result:
58 | questionnaire.is_match = True
59 | questionnaire.match_id = match.id
60 |
61 |
62 | async def get_match_by_match_id(
63 | session: AsyncSession,
64 | match_id: UUID,
65 | ) -> Match | None:
66 | """Getting a Match by its Match_ID."""
67 | return await session.get(Match, match_id)
68 |
69 |
70 | async def get_matches_by_user(
71 | session: AsyncSession,
72 | user: AuthUser,
73 | ) -> Sequence[Match]:
74 | """Getting a list of all the user's Matches."""
75 | stmt = select(Match).where(
76 | or_(Match.user1_id == user.id, Match.user2_id == user.id),
77 | )
78 | return (await session.execute(stmt)).scalars().all()
79 |
80 |
81 | async def create_match(
82 | session: AsyncSession,
83 | user1_id: UUID,
84 | user2_id: UUID,
85 | ) -> Match:
86 | """Creating a Match."""
87 | await check_match_data(session, user1_id, user2_id)
88 | stmt = insert(Match).values({
89 | Match.user1_id: user1_id,
90 | Match.user2_id: user2_id,
91 | }).returning(Match)
92 |
93 | match = (await session.execute(stmt)).scalar_one_or_none()
94 | await session.commit()
95 | return match
96 |
97 |
98 | async def check_match_data(
99 | session: AsyncSession,
100 | user1_id: UUID,
101 | user2_id: UUID,
102 | ) -> None:
103 | """Checking for the lack of a Match with himself and for duplicate Matches."""
104 | if user1_id == user2_id:
105 | raise SelfMatchException
106 |
107 | query = select(Match).where(
108 | or_(
109 | and_(Match.user1_id == user1_id, Match.user2_id == user2_id),
110 | and_(Match.user2_id == user1_id, Match.user1_id == user2_id),
111 | ))
112 | matches = (await session.execute(query)).all()
113 |
114 | if matches:
115 | raise AlreadyExistsException
116 |
117 |
118 | async def remove_match(
119 | session: AsyncSession,
120 | user: AuthUser,
121 | match_id: UUID,
122 | ) -> None:
123 | """Deleting a Match from the database."""
124 | match = await get_match_by_match_id(session, match_id)
125 | if not match:
126 | raise NotFoundException(f"Match with id={match_id} doesn't found")
127 |
128 | if user.id not in (match.user1_id, match.user2_id):
129 | raise PermissionDeniedException
130 |
131 | liked_user_id = match.user1_id if user.id == match.user2_id else match.user2_id
132 | like = await get_like_by_user_ids(
133 | session,
134 | user_id=user.id,
135 | liked_user_id=liked_user_id,
136 | )
137 |
138 | await delete_match(session, match)
139 | await delete_like(session, like)
140 |
141 |
142 | async def delete_match(
143 | session: AsyncSession,
144 | match: Match,
145 | ) -> None:
146 | await session.delete(match)
147 | await session.commit()
148 |
--------------------------------------------------------------------------------
/src/questionnaire/crud.py:
--------------------------------------------------------------------------------
1 | from datetime import date
2 | from uuid import UUID
3 |
4 | from fastapi import HTTPException, status
5 | from sqlalchemy import and_, delete, select, update
6 | from sqlalchemy.exc import ProgrammingError
7 | from sqlalchemy.ext.asyncio import AsyncSession
8 |
9 | from src.auth.models import AuthUser
10 | from src.database import async_session_maker
11 | from src.likes.models import UserLike
12 | from src.questionnaire.models import UserQuestionnaire, UserQuestionnaireHobby
13 | from src.questionnaire.schemas import (
14 | CreateUserQuestionnaireSchema,
15 | ResponseUserQuestionnaireSchema,
16 | )
17 |
18 |
19 | async def get_list_questionnaire(
20 | user: AuthUser,
21 | session: AsyncSession,
22 | page_number: int,
23 | ):
24 | user_questionnaire = await get_questionnaire(user_id=user.id, session=session)
25 | if user_questionnaire.quest_lists_per_day >= 3:
26 | return []
27 |
28 | user_questionnaire.quest_lists_per_day += 1
29 | await session.commit()
30 |
31 | is_visible = True
32 | liked_user_ids = (
33 | select(UserLike.liked_user_id)
34 | .where(UserLike.user_id == user.id)
35 | )
36 | query = (
37 | select(UserQuestionnaire)
38 | .where(
39 | UserQuestionnaire.user_id != user.id,
40 | UserQuestionnaire.city == user_questionnaire.city,
41 | UserQuestionnaire.gender != user_questionnaire.gender,
42 | UserQuestionnaire.is_visible == is_visible,
43 | UserQuestionnaire.user_id.notin_(liked_user_ids),
44 | )
45 | .limit(5).offset(page_number)
46 | )
47 | result = await session.execute(query)
48 | return result.scalars().fetchall()
49 |
50 |
51 | async def create_questionnaire(
52 | user_profile: CreateUserQuestionnaireSchema,
53 | session: AsyncSession,
54 | user: AuthUser,
55 | ):
56 | select_user_questionnaire = await get_questionnaire(
57 | user_id=user.id,
58 | session=session,
59 | )
60 | if select_user_questionnaire:
61 | raise HTTPException(
62 | status_code=status.HTTP_400_BAD_REQUEST,
63 | detail="Объект уже существует в базе данных!!!{}".format(
64 | select_user_questionnaire.firstname,
65 | ),
66 | )
67 | user_profile_dict = {**user_profile.dict(exclude={"hobbies"})}
68 | questionnaire = UserQuestionnaire(user_id=user.id, **user_profile_dict)
69 | today = date.today()
70 | min_age = today.replace(year=today.year - 18)
71 | max_age = today.replace(year=today.year - 82)
72 | if questionnaire.birthday > min_age and questionnaire.birthday < max_age:
73 | raise HTTPException(
74 | status_code=status.HTTP_400_BAD_REQUEST,
75 | detail="Возрастное ограничение строго c 18 лет!",
76 | )
77 | hobbies = user_profile.hobbies
78 | for hobby in hobbies:
79 | hobby_obj = UserQuestionnaireHobby(hobby_name=hobby.hobby_name)
80 | questionnaire.hobbies.append(hobby_obj)
81 | session.add(questionnaire)
82 | await session.commit()
83 | return ResponseUserQuestionnaireSchema(**questionnaire.__dict__)
84 |
85 |
86 | async def update_questionnaire(
87 | quest_id: UUID,
88 | update_value: CreateUserQuestionnaireSchema,
89 | session: AsyncSession,
90 | user: AuthUser,
91 | ):
92 | update_value_dict = update_value.dict(exclude={"hobbies", "user_id"})
93 | stmt = select(UserQuestionnaire).where(
94 | and_(UserQuestionnaire.id == quest_id,
95 | UserQuestionnaire.user_id == user.id),
96 | )
97 | result = await session.execute(stmt)
98 | questionnaire = result.scalar_one_or_none()
99 | stmt = (
100 | update(UserQuestionnaire)
101 | .values(update_value_dict)
102 | .where(UserQuestionnaire.id == quest_id)
103 | .returning(UserQuestionnaire)
104 | )
105 | await session.execute(stmt)
106 | questionnaire.hobbies = []
107 | for hobby in update_value.hobbies:
108 | hobby_item = UserQuestionnaireHobby(hobby_name=hobby.hobby_name)
109 | questionnaire.hobbies.append(hobby_item)
110 | await session.commit()
111 | return ResponseUserQuestionnaireSchema(**questionnaire.__dict__)
112 |
113 |
114 | async def delete_quest(
115 | user: AuthUser,
116 | quest_id: UUID,
117 | session: AsyncSession,
118 | ):
119 | user_questionnaire = await get_questionnaire(user_id=user.id, session=session)
120 | if not user_questionnaire:
121 | raise HTTPException(
122 | status_code=status.HTTP_400_BAD_REQUEST,
123 | detail="Такой анкеты не существует",
124 | )
125 | if user_questionnaire.id == quest_id:
126 | query_questionnaire = delete(UserQuestionnaire).where(
127 | UserQuestionnaire.id == quest_id,
128 | )
129 | await session.execute(query_questionnaire)
130 | await session.commit()
131 | else:
132 | raise HTTPException(
133 | status_code=status.HTTP_400_BAD_REQUEST,
134 | detail=f"Нет доступа к данной анкете!!! {quest_id}",
135 | )
136 |
137 |
138 | async def get_questionnaire(user_id: UUID, session: AsyncSession):
139 | query = select(UserQuestionnaire).where(UserQuestionnaire.user_id == user_id)
140 | get_user = await session.execute(query)
141 | response = get_user.scalar()
142 | if response:
143 | return response
144 | return None
145 |
146 |
147 | async def reset_quest_lists_per_day():
148 | try:
149 | async with async_session_maker() as session:
150 | stmt = (
151 | update(UserQuestionnaire)
152 | .values(quest_lists_per_day=0)
153 | )
154 | await session.execute(stmt)
155 | await session.commit()
156 | except ProgrammingError:
157 | pass
158 |
--------------------------------------------------------------------------------
/src/chat/utils.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import datetime
3 | import uuid
4 | from collections.abc import AsyncGenerator
5 | from typing import Annotated, Any
6 |
7 | import orjson
8 | from fastapi import Depends, WebSocketException
9 | from pydantic import BaseModel, ValidationError
10 | from sqlalchemy.ext.asyncio import AsyncSession
11 | from starlette.websockets import WebSocket
12 |
13 | from src.auth.base_config import get_auth_user
14 | from src.auth.models import AuthUser
15 | from src.chat.exceptions import NoMatchError
16 | from src.chat.redis import get_match
17 | from src.chat.schemas import (
18 | MessageCreateRequest,
19 | MessageDeleteRequest,
20 | MessageDeleteResponse,
21 | MessageResponse,
22 | MessageUpdateRequest,
23 | WSAction,
24 | WSMessageRequest,
25 | WSMessageResponse,
26 | WSStatus,
27 | )
28 | from src.database import async_session_maker, get_async_session, mongo
29 |
30 |
31 | def orjson_dumps(data: Any, **kwargs: Any):
32 | if isinstance(data, BaseModel):
33 | data = data.dict()
34 | return orjson.dumps(data, **kwargs).decode("utf-8")
35 |
36 |
37 | async def get_user_from_ws_cookie(
38 | ws: WebSocket,
39 | session: Annotated[AsyncSession, Depends(get_async_session)],
40 | ) -> AsyncGenerator[AuthUser, None]:
41 | user = await get_auth_user(ws.cookies.get("mir"), session)
42 | if not user or not user.is_active:
43 | raise WebSocketException(404, "Invalid user")
44 | yield user
45 |
46 |
47 | class WebSocketConnectionManager:
48 | def __init__(self):
49 | self.active_connections: dict[uuid.UUID, WebSocket] = {}
50 |
51 | async def connect(self, ws: WebSocket, user: AuthUser) -> None:
52 | """
53 | Accept WebSocket and add connection for particular user to active_connections
54 | to insta-message them.
55 | """
56 |
57 | await ws.accept()
58 | self.active_connections[user.id] = ws
59 | # TODO: bg_task get last user's ~10-20 matches and save to redis
60 |
61 | async def disconnect(self, ws: WebSocket, user_id: uuid.UUID):
62 | self.active_connections.pop(user_id, None)
63 | await ws.close()
64 |
65 | def find_users_ws(self, id: uuid.UUID):
66 | return self.active_connections.get(id)
67 |
68 |
69 | async def send_insta_message(msg: str, to_id: uuid.UUID):
70 | ws = ws_manager.find_users_ws(to_id)
71 | if ws is not None:
72 | await ws.send_text(msg)
73 |
74 |
75 | async def send_ws_message(
76 | ws: WebSocket,
77 | msg: MessageResponse | MessageDeleteResponse,
78 | action: WSAction,
79 | status: WSStatus = WSStatus.OK,
80 | ):
81 | to_id = msg.to_id
82 | msg = orjson_dumps(WSMessageResponse(
83 | status=status,
84 | action=action,
85 | message=msg,
86 | ))
87 |
88 | await asyncio.gather(
89 | ws.send_text(msg),
90 | send_insta_message(msg, to_id),
91 | )
92 |
93 |
94 | async def create_message(ws_msg: WSMessageRequest, ws: WebSocket, user: AuthUser):
95 | if not isinstance(ws_msg.message, MessageCreateRequest):
96 | raise ValidationError
97 |
98 | # TODO: get user's matches from redis and check if he can send message to 'to_id'
99 | async with async_session_maker() as session:
100 | match = await get_match(session, user, ws_msg)
101 |
102 | if match is None:
103 | raise NoMatchError(user.id, ws_msg.message.to_id)
104 |
105 | msg = await mongo.create_message(ws_msg.message)
106 |
107 | await send_ws_message(ws, MessageResponse.parse_obj(msg), WSAction.CREATE)
108 | # TODO: manage/log exceptions from 'gather' result if occur
109 |
110 |
111 | async def delete_message(ws_msg: WSMessageRequest, ws: WebSocket, user: AuthUser):
112 | if not isinstance(ws_msg.message, MessageDeleteRequest):
113 | raise ValidationError
114 |
115 | # TODO: get user's matches from redis and check if he can delete message to 'to_id'
116 | async with async_session_maker() as session:
117 | match = await get_match(session, user, ws_msg)
118 |
119 | if match is None:
120 | raise NoMatchError(user.id, ws_msg.message.to_id)
121 |
122 | result = await mongo.delete_message(ws_msg.message.id)
123 |
124 | if not result.deleted_count:
125 | await ws.send_text(orjson_dumps(WSMessageResponse(
126 | status=WSStatus.ERROR,
127 | action=WSAction.DELETE,
128 | detail=f"unknown message id {ws_msg.message.id}",
129 | )))
130 | return
131 |
132 | await send_ws_message(ws, MessageDeleteResponse.parse_obj(ws_msg.message), WSAction.DELETE)
133 |
134 |
135 | async def update_message(ws_msg: WSMessageRequest, ws: WebSocket, user: AuthUser):
136 | if not isinstance(ws_msg.message, MessageUpdateRequest):
137 | raise ValidationError
138 |
139 | # TODO: get user's matches from redis and check if he can send message to 'to_id'
140 | async with async_session_maker() as session:
141 | match = await get_match(session, user, ws_msg)
142 |
143 | if match is None:
144 | raise NoMatchError(user.id, ws_msg.message.to_id)
145 |
146 | result = await mongo.get_message(ws_msg.message.id)
147 | if result is None:
148 | await ws.send_text(orjson_dumps({
149 | "status": WSStatus.ERROR,
150 | "action": WSAction.UPDATE,
151 | "detail": f"unknown message id {ws_msg.message.id}",
152 | }))
153 | return
154 |
155 | msg = MessageResponse(
156 | **ws_msg.message.dict(),
157 | created_at=result["created_at"],
158 | updated_at=datetime.datetime.utcnow(),
159 | )
160 |
161 | result = await mongo.update_message(msg)
162 | if not result.modified_count:
163 | await ws.send_text(orjson_dumps(WSMessageResponse(
164 | status=WSStatus.ERROR,
165 | action=WSAction.UPDATE,
166 | detail=f"error updating message id {msg.id}",
167 | )))
168 | return
169 |
170 | await send_ws_message(ws, msg, WSAction.UPDATE)
171 |
172 |
173 | ws_manager = WebSocketConnectionManager()
174 |
--------------------------------------------------------------------------------
/src/auth/base_config.py:
--------------------------------------------------------------------------------
1 | from typing import Annotated
2 |
3 | from fastapi import Depends, HTTPException, Response, status
4 | from fastapi.security import APIKeyCookie
5 | from jwt import InvalidTokenError
6 | from sqlalchemy.ext.asyncio import AsyncSession
7 |
8 | from src.auth import utils as auth_utils
9 | from src.auth.crud import get_user
10 | from src.auth.models import AuthUser
11 | from src.auth.schemas import UserCreateInput, UserSchema
12 | from src.config import settings
13 | from src.database import get_async_session
14 |
15 | cookies_access_scheme = APIKeyCookie(name=settings.COOKIE_ACCESS_TOKEN_KEY)
16 | cookies_refresh_scheme = APIKeyCookie(name=settings.COOKIE_REFRESH_TOKEN_KEY)
17 |
18 |
19 | async def validate_auth_user(
20 | user_login: UserCreateInput,
21 | session: Annotated[AsyncSession, Depends(get_async_session)],
22 | ) -> AuthUser:
23 | """Идентификация данных пользователя."""
24 | unauthenticated_exception = HTTPException(
25 | status_code=status.HTTP_400_BAD_REQUEST,
26 | detail="invalid username or password",
27 | )
28 | user = await get_user(user_login.email, session)
29 | _verify_user(
30 | user=user,
31 | user_password=user_login.password,
32 | custom_exception=unauthenticated_exception,
33 | )
34 | return user
35 |
36 |
37 | def _verify_user(
38 | user: AuthUser,
39 | user_password: str | bytes,
40 | custom_exception: HTTPException,
41 | ) -> None:
42 | if not user:
43 | raise custom_exception
44 | if not auth_utils.validate_password(
45 | password=user_password,
46 | hashed_password=user.hashed_password,
47 | ):
48 | raise custom_exception
49 | if not user.is_active:
50 | raise HTTPException(
51 | status_code=status.HTTP_403_FORBIDDEN,
52 | detail="user inactive",
53 | ) from None
54 |
55 |
56 | async def get_auth_user(
57 | access_token: Annotated[str, Depends(cookies_access_scheme)],
58 | session: Annotated[AsyncSession, Depends(get_async_session)],
59 | ) -> AuthUser:
60 | """
61 | Получение данных аутентифицированного пользователя.
62 | Функция проверяет подлинность пользователя и дает
63 | доступ к использованию закрытых эндпоинтов.
64 | """
65 | try:
66 | payload = auth_utils.decode_jwt(
67 | token=access_token,
68 | )
69 | user = await _check_token_data(payload, session)
70 | except InvalidTokenError:
71 | raise HTTPException(
72 | status_code=status.HTTP_401_UNAUTHORIZED,
73 | detail="invalid token error",
74 | ) from None
75 | return user
76 |
77 |
78 | async def check_user_refresh_token(
79 | refresh_token: Annotated[str, Depends(cookies_refresh_scheme)],
80 | session: Annotated[AsyncSession, Depends(get_async_session)],
81 | ) -> AuthUser:
82 | """Проверка refresh token на подлинность."""
83 | try:
84 | payload = auth_utils.decode_jwt(
85 | token=refresh_token,
86 | )
87 | user = await _check_token_data(payload, session)
88 | except InvalidTokenError:
89 | raise HTTPException(
90 | status_code=status.HTTP_400_BAD_REQUEST,
91 | detail="could not refresh access token",
92 | ) from None
93 | return user
94 |
95 |
96 | async def _check_token_data(
97 | payload: dict,
98 | session: AsyncSession,
99 | ) -> AuthUser:
100 | """Функция проверки данных токена."""
101 | if not payload.get("sub"):
102 | raise HTTPException(
103 | status_code=status.HTTP_401_UNAUTHORIZED,
104 | detail="could not refresh access token",
105 | )
106 | email: str = payload.get("email")
107 | user = await get_user(email, session)
108 | if not user:
109 | raise HTTPException(
110 | status_code=status.HTTP_401_UNAUTHORIZED,
111 | detail="the user no longer exists",
112 | )
113 | if not user.is_active:
114 | raise HTTPException(
115 | status_code=status.HTTP_403_FORBIDDEN,
116 | detail="user inactive",
117 | )
118 | return user
119 |
120 |
121 | def _create_token(
122 | type_token: str,
123 | expires_time: int,
124 | data: AuthUser,
125 | ) -> dict:
126 | """Функция создания токена по заданным параметрам."""
127 | jwt_payload = {
128 | "sub": str(data.id),
129 | "email": data.email,
130 | "type": type_token,
131 | }
132 | token = auth_utils.encode_jwt(jwt_payload, expire_minutes=expires_time)
133 | return {"type_token": type_token, "token": token}
134 |
135 |
136 | def create_access_token(
137 | user_data: Annotated[AuthUser, Depends(validate_auth_user)],
138 | ) -> dict:
139 | """Создание access_token."""
140 | return _create_token(
141 | type_token=settings.COOKIE_ACCESS_TOKEN_KEY,
142 | expires_time=settings.ACCESS_TOKEN_EXPIRES_IN,
143 | data=user_data,
144 | )
145 |
146 |
147 | def create_refresh_token(
148 | user_data: Annotated[AuthUser, Depends(validate_auth_user)],
149 | ) -> dict:
150 | """Создание refresh_token."""
151 | return _create_token(
152 | type_token=settings.COOKIE_REFRESH_TOKEN_KEY,
153 | expires_time=settings.REFRESH_TOKEN_EXPIRES_IN,
154 | data=user_data,
155 | )
156 |
157 |
158 | def create_tokens(
159 | user: AuthUser | UserSchema,
160 | response: Response,
161 | ) -> None:
162 | """Создание для пользователя всех токенов."""
163 | token_access = create_access_token(user)
164 | token_refresh = create_refresh_token(user)
165 |
166 | secure = not settings.DEBUG
167 |
168 | response.set_cookie(token_access["type_token"], token_access["token"], httponly=True, secure=secure)
169 | response.set_cookie(token_refresh["type_token"], token_refresh["token"], httponly=True, secure=secure)
170 |
171 |
172 | def delete_all_tokens(
173 | response: Response,
174 | ) -> None:
175 | """Удаление всех токенов из браузера пользователя."""
176 | response.delete_cookie(settings.COOKIE_ACCESS_TOKEN_KEY)
177 | response.delete_cookie(settings.COOKIE_REFRESH_TOKEN_KEY)
178 |
179 |
180 | current_user = get_auth_user
181 |
--------------------------------------------------------------------------------
/migrations/versions/c347c3783bc4_init.py:
--------------------------------------------------------------------------------
1 | """init
2 |
3 | Revision ID: c347c3783bc4
4 | Revises:
5 | Create Date: 2023-09-28 21:40:43.234471
6 |
7 | """
8 | import sqlalchemy_utils
9 | from alembic import op
10 | import sqlalchemy as sa
11 |
12 | from src.questionnaire.params_choice import Gender, Goal, BodyType
13 |
14 | # revision identifiers, used by Alembic.
15 | revision = "c347c3783bc4"
16 | down_revision = None
17 | branch_labels = None
18 | depends_on = None
19 |
20 |
21 | def upgrade() -> None:
22 | # ### commands auto generated by Alembic - please adjust! ###
23 | op.create_table(
24 | "auth_user",
25 | sa.Column("id", sa.Uuid(), nullable=False),
26 | sa.Column("email", sa.String(length=50), nullable=False),
27 | sa.Column("created_at", sa.DateTime(), nullable=False),
28 | sa.Column("hashed_password", sa.String(length=1024), nullable=False),
29 | sa.Column("is_active", sa.Boolean(), nullable=False),
30 | sa.Column("is_superuser", sa.Boolean(), nullable=False),
31 | sa.Column("is_verified", sa.Boolean(), nullable=False),
32 | sa.Column("is_delete", sa.Boolean(), nullable=False),
33 | sa.PrimaryKeyConstraint("id"),
34 | )
35 | op.create_index(op.f("ix_auth_user_email"), "auth_user", ["email"], unique=True)
36 | op.create_table(
37 | "user_questionnaire_hobby",
38 | sa.Column("id", sa.Uuid(), nullable=False),
39 | sa.Column("hobby_name", sa.String(length=256), nullable=False),
40 | sa.PrimaryKeyConstraint("id"),
41 | )
42 | op.create_table(
43 | "black_list_user",
44 | sa.Column("id", sa.Uuid(), nullable=False),
45 | sa.Column("created_at", sa.DateTime(), nullable=False),
46 | sa.Column("blocked_by_id", sa.Uuid(), nullable=False),
47 | sa.Column("blocked_id", sa.Uuid(), nullable=False),
48 | sa.CheckConstraint("NOT(blocked_by_id = blocked_id)", name="_black_list_cc"),
49 | sa.ForeignKeyConstraint(
50 | ["blocked_by_id"], ["auth_user.id"], ondelete="CASCADE"
51 | ),
52 | sa.ForeignKeyConstraint(["blocked_id"], ["auth_user.id"], ondelete="CASCADE"),
53 | sa.PrimaryKeyConstraint("id"),
54 | sa.UniqueConstraint("blocked_by_id", "blocked_id", name="_black_list_uc"),
55 | )
56 | op.create_table(
57 | "match",
58 | sa.Column("id", sa.Uuid(), nullable=False),
59 | sa.Column("created_at", sa.DateTime(), nullable=False),
60 | sa.Column("user1_id", sa.Uuid(), nullable=False),
61 | sa.Column("user2_id", sa.Uuid(), nullable=False),
62 | sa.CheckConstraint("NOT(user1_id = user2_id)", name="_match_cc"),
63 | sa.ForeignKeyConstraint(["user1_id"], ["auth_user.id"], ondelete="CASCADE"),
64 | sa.ForeignKeyConstraint(["user2_id"], ["auth_user.id"], ondelete="CASCADE"),
65 | sa.PrimaryKeyConstraint("id"),
66 | sa.UniqueConstraint("user1_id", "user2_id", name="_match_uc"),
67 | )
68 | op.create_table(
69 | "user_like",
70 | sa.Column("id", sa.Uuid(), nullable=False),
71 | sa.Column("created_at", sa.DateTime(), nullable=False),
72 | sa.Column("user_id", sa.Uuid(), nullable=False),
73 | sa.Column("liked_user_id", sa.Uuid(), nullable=False),
74 | sa.Column("is_liked", sa.Boolean(), nullable=False),
75 | sa.CheckConstraint("NOT(user_id = liked_user_id)", name="_user_like_cc"),
76 | sa.ForeignKeyConstraint(
77 | ["liked_user_id"], ["auth_user.id"], ondelete="CASCADE"
78 | ),
79 | sa.ForeignKeyConstraint(["user_id"], ["auth_user.id"], ondelete="CASCADE"),
80 | sa.PrimaryKeyConstraint("id"),
81 | sa.UniqueConstraint("user_id", "liked_user_id", name="_user_like_uc"),
82 | )
83 | op.create_table(
84 | "user_questionnaire",
85 | sa.Column("id", sa.Uuid(), nullable=False),
86 | sa.Column("created_at", sa.DateTime(), nullable=False),
87 | sa.Column("firstname", sa.String(length=256), nullable=True),
88 | sa.Column("lastname", sa.String(length=256), nullable=True),
89 | sa.Column(
90 | "gender", sqlalchemy_utils.types.choice.ChoiceType(Gender), nullable=True
91 | ),
92 | sa.Column("photo", sa.String(), nullable=True),
93 | sa.Column("country", sa.String(), nullable=True),
94 | sa.Column("city", sa.String(), nullable=True),
95 | sa.Column("latitude", sa.Numeric(precision=8, scale=5), nullable=True),
96 | sa.Column("longitude", sa.Numeric(precision=8, scale=5), nullable=True),
97 | sa.Column("about", sa.String(), nullable=True),
98 | sa.Column("height", sa.Integer(), nullable=True),
99 | sa.Column(
100 | "goals", sqlalchemy_utils.types.choice.ChoiceType(Goal), nullable=True
101 | ),
102 | sa.Column(
103 | "body_type",
104 | sqlalchemy_utils.types.choice.ChoiceType(BodyType),
105 | nullable=True,
106 | ),
107 | sa.Column("is_visible", sa.Boolean(), nullable=False),
108 | sa.Column("user_id", sa.Uuid(), nullable=True),
109 | sa.ForeignKeyConstraint(["user_id"], ["auth_user.id"], ondelete="CASCADE"),
110 | sa.PrimaryKeyConstraint("id"),
111 | )
112 | op.create_table(
113 | "user_settings",
114 | sa.Column("id", sa.Uuid(), nullable=False),
115 | sa.Column("subscriber", sa.DateTime(), nullable=True),
116 | sa.Column("last_seen", sa.DateTime(), nullable=False),
117 | sa.Column("search_range_min", sa.Integer(), nullable=False),
118 | sa.Column("search_range_max", sa.Integer(), nullable=False),
119 | sa.Column("search_age_min", sa.Integer(), nullable=False),
120 | sa.Column("search_age_max", sa.Integer(), nullable=False),
121 | sa.Column("user_id", sa.Uuid(), nullable=True),
122 | sa.ForeignKeyConstraint(["user_id"], ["auth_user.id"], ondelete="CASCADE"),
123 | sa.PrimaryKeyConstraint("id"),
124 | )
125 | op.create_table(
126 | "user_hobby",
127 | sa.Column("user_id", sa.Uuid(), nullable=False),
128 | sa.Column("hobby_id", sa.Uuid(), nullable=False),
129 | sa.ForeignKeyConstraint(
130 | ["hobby_id"], ["user_questionnaire_hobby.id"], ondelete="CASCADE"
131 | ),
132 | sa.ForeignKeyConstraint(
133 | ["user_id"], ["user_questionnaire.id"], ondelete="CASCADE"
134 | ),
135 | sa.PrimaryKeyConstraint("user_id", "hobby_id"),
136 | )
137 | # ### end Alembic commands ###
138 |
139 |
140 | def downgrade() -> None:
141 | # ### commands auto generated by Alembic - please adjust! ###
142 | op.drop_table("user_hobby")
143 | op.drop_table("user_settings")
144 | op.drop_table("user_questionnaire")
145 | op.drop_table("user_like")
146 | op.drop_table("match")
147 | op.drop_table("black_list_user")
148 | op.drop_table("user_questionnaire_hobby")
149 | op.drop_index(op.f("ix_auth_user_email"), table_name="auth_user")
150 | op.drop_table("auth_user")
151 | # ### end Alembic commands ###
152 |
--------------------------------------------------------------------------------
/src/admin/__init__.py:
--------------------------------------------------------------------------------
1 | from sqlalchemy.exc import IntegrityError
2 | from starlette import status
3 | from starlette.exceptions import HTTPException
4 | from starlette.middleware import Middleware
5 | from starlette.middleware.sessions import SessionMiddleware
6 | from starlette.requests import Request
7 | from starlette.responses import RedirectResponse, Response
8 | from starlette_admin import DropDown, I18nConfig
9 | from starlette_admin._types import RequestAction
10 | from starlette_admin.contrib.sqla import Admin
11 | from starlette_admin.exceptions import FormValidationError
12 | from starlette_admin.views import BaseModelView
13 |
14 | from src.admin.auth_provider import EmailAndPasswordProvider
15 | from src.admin.utils import get_password_hash
16 | from src.admin.views import (
17 | BlackListUserView,
18 | MatchView,
19 | MessageView,
20 | UserAuthView,
21 | UserLikeView,
22 | UserQuestionnaireView,
23 | UserSettingsView,
24 | )
25 | from src.auth.models import AuthUser, UserSettings
26 | from src.chat.models import Message
27 | from src.config import settings
28 | from src.database import engine
29 | from src.likes.models import UserLike
30 | from src.matches.models import Match
31 | from src.questionnaire.models import BlackListUser, UserQuestionnaire
32 |
33 |
34 | class CustomAdmin(Admin):
35 | async def render_form_response(
36 | self,
37 | request: Request,
38 | model: BaseModelView,
39 | action: RequestAction,
40 | ):
41 | action_template = (
42 | model.create_template
43 | if action == RequestAction.CREATE
44 | else model.edit_template
45 | )
46 | form = await request.form()
47 | dict_obj = await self.form_to_dict(
48 | request,
49 | form,
50 | model,
51 | action,
52 | )
53 |
54 | if "hashed_password" in dict_obj:
55 | password = dict_obj.pop("hashed_password")
56 | dict_obj["hashed_password"] = get_password_hash(password)
57 |
58 | try:
59 | if action == RequestAction.CREATE:
60 | obj = await model.create(request, dict_obj)
61 | else:
62 | pk = request.path_params.get("pk")
63 | obj = await model.edit(request, pk, dict_obj)
64 | except FormValidationError as exc:
65 | return self.templates.TemplateResponse(
66 | action_template,
67 | {
68 | "request": request,
69 | "model": model,
70 | "errors": exc.errors,
71 | "obj": dict_obj,
72 | },
73 | status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
74 | )
75 | except IntegrityError:
76 | return self.templates.TemplateResponse(
77 | action_template,
78 | {
79 | "request": request,
80 | "model": model,
81 | "errors": {
82 | "email": ("Пользователь c такими данными уже существует"),
83 | },
84 | "obj": dict_obj,
85 | },
86 | status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
87 | )
88 | pk = getattr(obj, model.pk_attr)
89 | url = request.url_for(
90 | self.route_name + ":list",
91 | identity=model.identity,
92 | )
93 |
94 | if form.get("_continue_editing", None) is not None:
95 | url = request.url_for(
96 | self.route_name + ":edit",
97 | identity=model.identity,
98 | pk=pk,
99 | )
100 | elif form.get("_add_another", None) is not None:
101 | if action == RequestAction.CREATE:
102 | url = request.url
103 | else:
104 | url = request.url_for(
105 | self.route_name + ":create",
106 | identity=model.identity,
107 | )
108 | return RedirectResponse(url, status_code=status.HTTP_303_SEE_OTHER)
109 |
110 | async def _render_create(self, request: Request) -> Response:
111 | request.state.action = RequestAction.CREATE
112 | identity = request.path_params.get("identity")
113 | model = self._find_model_from_identity(identity)
114 |
115 | if not model.is_accessible(request) or not model.can_create(request):
116 | raise HTTPException(status.HTTP_403_FORBIDDEN)
117 |
118 | if request.method == "GET":
119 | return self.templates.TemplateResponse(
120 | model.create_template,
121 | {"request": request, "model": model},
122 | )
123 |
124 | return await self.render_form_response(request, model, RequestAction.CREATE)
125 |
126 | async def _render_edit(self, request: Request) -> Response:
127 | request.state.action = RequestAction.EDIT
128 | identity = request.path_params.get("identity")
129 | model = self._find_model_from_identity(identity)
130 |
131 | if not model.is_accessible(request) or not model.can_edit(request):
132 | raise HTTPException(status.HTTP_403_FORBIDDEN)
133 |
134 | pk = request.path_params.get("pk")
135 | obj = await model.find_by_pk(request, pk)
136 |
137 | if obj is None:
138 | raise HTTPException(status.HTTP_404_NOT_FOUND)
139 |
140 | if request.method == "GET":
141 | return self.templates.TemplateResponse(
142 | model.edit_template,
143 | {
144 | "request": request,
145 | "model": model,
146 | "raw_obj": obj,
147 | "obj": await model.serialize(
148 | obj,
149 | request,
150 | RequestAction.EDIT,
151 | ),
152 | },
153 | )
154 |
155 | return await self.render_form_response(request, model, RequestAction.EDIT)
156 |
157 |
158 | admin = CustomAdmin(
159 | engine,
160 | title="Socnet App",
161 | base_url="/admin",
162 | statics_dir="static",
163 | i18n_config=I18nConfig(default_locale="ru"),
164 | logo_url="http://127.0.0.1:8000/static/img/logo.png", # Доработаю
165 | auth_provider=EmailAndPasswordProvider(
166 | allow_paths=["/statics/img/logo.png"],
167 | ),
168 | middlewares=[Middleware(SessionMiddleware, secret_key=settings.SECRET_KEY)],
169 | )
170 |
171 |
172 | admin.add_view(
173 | DropDown(
174 | "Пользователи",
175 | icon="fa-solid fa-users",
176 | views=[
177 | UserAuthView(
178 | AuthUser,
179 | label="Аутентификация",
180 | ),
181 | UserSettingsView(
182 | UserSettings,
183 | label="Настройки пользователя",
184 | ),
185 | ],
186 | ),
187 | )
188 | admin.add_view(
189 | DropDown(
190 | "Анкеты",
191 | icon="fa-solid fa-file-pen",
192 | views=[
193 | UserQuestionnaireView(
194 | UserQuestionnaire,
195 | label="Анкета пользователя",
196 | ),
197 | BlackListUserView(
198 | BlackListUser,
199 | label="Чёрный список",
200 | ),
201 | ],
202 | ),
203 | )
204 | admin.add_view(
205 | DropDown(
206 | "Взаимодействия",
207 | icon="fa-solid fa-heart",
208 | views=[
209 | MessageView(
210 | Message,
211 | label="Сообщения",
212 | ),
213 | UserLikeView(
214 | UserLike,
215 | label="Лайки",
216 | ),
217 | MatchView(
218 | Match,
219 | label="Совпадения",
220 | ),
221 | ],
222 | ),
223 | )
224 |
--------------------------------------------------------------------------------
/tests/fixtures.py:
--------------------------------------------------------------------------------
1 | from collections.abc import AsyncGenerator
2 | from datetime import datetime
3 |
4 | import pytest
5 | from async_asgi_testclient import TestClient
6 | from sqlalchemy.ext.asyncio import AsyncSession
7 |
8 | from src.auth.models import AuthUser
9 | from src.likes.models import UserLike
10 | from src.matches.models import Match
11 | from src.questionnaire.models import UserQuestionnaire, UserQuestionnaireHobby
12 |
13 | """Without that tests will die, sa cant work with date and pytest same time normaly(kostil)"""
14 | date_string = "2004-02-14"
15 | datetime_object = datetime.strptime(date_string, "%Y-%m-%d")
16 | date_object = datetime_object.date()
17 |
18 | user_data = {
19 | "email": "test_user@server.com",
20 | "password": "pass",
21 | }
22 |
23 | user2_data = {
24 | "email": "test_user2@server.com",
25 | "password": "pass",
26 | }
27 |
28 | user3_data = {
29 | "email": "test_user3@server.com",
30 | "password": "pass",
31 | }
32 |
33 | user_questionary_data = {
34 | "firstname": "Anton",
35 | "lastname": "Pupkin",
36 | "gender": "Male",
37 | "photo": "False",
38 | "country": "False",
39 | "city": "False",
40 | "about": "False",
41 | "goals": "Дружба",
42 | "height": 150,
43 | "sport": "He занимаюсь",
44 | "alcohol": "He пью",
45 | "smoking": "Курю",
46 | "birthday": date_object,
47 | }
48 |
49 | user3_questionary_data = {
50 | "firstname": "Anton",
51 | "lastname": "Pupkin",
52 | "gender": "Female",
53 | "photo": "False",
54 | "country": "False",
55 | "city": "False",
56 | "about": "False",
57 | "goals": "Дружба",
58 | "height": 150,
59 | "sport": "He занимаюсь",
60 | "alcohol": "He пью",
61 | "smoking": "Курю",
62 | "birthday": date_object,
63 | }
64 |
65 | hobbies_dict = {
66 | "hobbies": [
67 | {"hobby_name": "qwe"},
68 | {"hobby_name": "asd"},
69 | ],
70 | }
71 |
72 |
73 | @pytest.fixture(scope="module")
74 | async def user(async_client: TestClient) -> AuthUser:
75 | """Test user."""
76 | response = await async_client.post(
77 | "/api/v1/auth/register",
78 | json=user_data,
79 | )
80 | return AuthUser(**response.json())
81 |
82 |
83 | @pytest.fixture(scope="module")
84 | async def user2(async_client: TestClient) -> AuthUser:
85 | """Test user."""
86 | response = await async_client.post(
87 | "/api/v1/auth/register",
88 | json=user2_data,
89 | )
90 | return AuthUser(**response.json())
91 |
92 |
93 | @pytest.fixture(scope="module")
94 | async def user3(async_client: TestClient) -> AuthUser:
95 | """Test user."""
96 | response = await async_client.post(
97 | "/api/v1/auth/register",
98 | json=user3_data,
99 | )
100 | return AuthUser(**response.json())
101 |
102 |
103 | @pytest.fixture(scope="module")
104 | async def authorised_cookie(user: AuthUser, async_client: TestClient) -> dict:
105 | """Cookie of authorized user."""
106 | await async_client.post(
107 | "/api/v1/auth/login",
108 | json=user_data,
109 | )
110 | jwt = async_client.cookie_jar.get("mir")
111 | return {"mir": jwt}
112 |
113 |
114 | @pytest.fixture(scope="module")
115 | async def authorised_cookie_user2(user2: AuthUser, async_client: TestClient) -> dict:
116 | """Cookie of authorized user."""
117 | await async_client.post(
118 | "/api/v1/auth/login",
119 | json=user2_data,
120 | )
121 | jwt = async_client.cookie_jar.get("mir")
122 | return {"mir": jwt}
123 |
124 |
125 | @pytest.fixture(scope="module")
126 | async def authorised_cookie_user3(user3: AuthUser, async_client: TestClient) -> dict:
127 | """Cookie of authorized user."""
128 | await async_client.post(
129 | "/api/v1/auth/login",
130 | json=user3_data,
131 | )
132 | jwt = async_client.cookie_jar.get("mir")
133 | return {"mir": jwt}
134 |
135 |
136 | @pytest.fixture(scope="module")
137 | async def questionary(get_async_session: AsyncSession, user2: AuthUser) -> UserQuestionnaire:
138 | """User questionary."""
139 | user_questionary_data["user_id"] = user2.id
140 | async with get_async_session as db:
141 | questionnaire = UserQuestionnaire(**user_questionary_data)
142 | _hobbies: list = hobbies_dict["hobbies"]
143 | for hobby in _hobbies:
144 | hobby_obj = UserQuestionnaireHobby(hobby_name=hobby["hobby_name"])
145 | questionnaire.hobbies.append(hobby_obj)
146 | db.add(questionnaire)
147 | await db.commit()
148 | return questionnaire
149 |
150 |
151 | @pytest.fixture(scope="module")
152 | async def questionary_user3(get_async_session: AsyncSession, user3: AuthUser) -> UserQuestionnaire:
153 | """User questionary."""
154 | user3_questionary_data["user_id"] = user3.id
155 | async with get_async_session as db:
156 | questionary_user3 = UserQuestionnaire(**user3_questionary_data)
157 | _hobbies: list = hobbies_dict["hobbies"]
158 | for hobby in _hobbies:
159 | hobby_obj = UserQuestionnaireHobby(hobby_name=hobby["hobby_name"])
160 | questionary_user3.hobbies.append(hobby_obj)
161 | db.add(questionary_user3)
162 | await db.commit()
163 | return questionary_user3
164 |
165 |
166 | @pytest.fixture(scope="module")
167 | async def match(get_async_session: AsyncSession, user: AuthUser, user2: AuthUser) -> Match:
168 | async with get_async_session as db:
169 | match = Match(
170 | user1_id=user.id,
171 | user2_id=user2.id,
172 | )
173 | db.add(match)
174 | await db.commit()
175 | return match
176 |
177 |
178 | @pytest.fixture(scope="module")
179 | async def match1(get_async_session: AsyncSession, user2: AuthUser, user3: AuthUser) -> Match:
180 | async with get_async_session as db:
181 | match = Match(
182 | user1_id=user3.id,
183 | user2_id=user2.id,
184 | )
185 | db.add(match)
186 | await db.commit()
187 | return match
188 |
189 |
190 | @pytest.fixture(scope="module")
191 | async def match2(get_async_session: AsyncSession, user2: AuthUser, user3: AuthUser) -> Match:
192 | async with get_async_session as db:
193 | match = Match(
194 | user1_id=user2.id,
195 | user2_id=user3.id,
196 | )
197 | db.add(match)
198 | await db.commit()
199 | return match
200 |
201 |
202 | @pytest.fixture(scope="module")
203 | async def like1(get_async_session: AsyncSession, user: AuthUser, user2: AuthUser) -> UserLike:
204 | async with get_async_session as db:
205 | like = UserLike(
206 | user_id=user.id,
207 | liked_user_id=user2.id,
208 | )
209 | db.add(like)
210 | await db.commit()
211 | return like
212 |
213 |
214 | @pytest.fixture(scope="module")
215 | async def like2(get_async_session: AsyncSession, user: AuthUser, user2: AuthUser) -> UserLike:
216 | async with get_async_session as db:
217 | like = UserLike(
218 | user_id=user2.id,
219 | liked_user_id=user.id,
220 | )
221 | db.add(like)
222 | await db.commit()
223 | return like
224 |
225 |
226 | @pytest.fixture(scope="module")
227 | async def like3(get_async_session: AsyncSession, user3: AuthUser, user2: AuthUser) -> UserLike:
228 | async with get_async_session as db:
229 | like = UserLike(
230 | user_id=user2.id,
231 | liked_user_id=user3.id,
232 | )
233 | db.add(like)
234 | await db.commit()
235 | return like
236 |
237 |
238 | @pytest.fixture()
239 | async def new_async_client_func() -> AsyncGenerator[TestClient, None]:
240 | from src.main import app
241 |
242 | async with TestClient(app) as client:
243 | yield client
244 |
245 |
246 | @pytest.fixture(scope="module")
247 | async def new_async_client_module() -> AsyncGenerator[TestClient, None]:
248 | from src.main import app
249 |
250 | async with TestClient(app) as client:
251 | yield client
252 |
--------------------------------------------------------------------------------
/tests/test_matches.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from async_asgi_testclient import TestClient
3 | from dirty_equals import IsUUID
4 | from fastapi import status
5 | from sqlalchemy.ext.asyncio import AsyncSession
6 |
7 | from src.auth.models import AuthUser
8 | from src.likes.crud import get_like_by_id
9 | from src.likes.models import UserLike
10 | from src.main import app
11 | from src.matches.crud import get_match_by_match_id
12 | from src.matches.models import Match
13 | from src.questionnaire.models import UserQuestionnaire
14 |
15 |
16 | class TestMatch:
17 | """Тесты эндпоинтов matches/ и matches/{match_id}"""
18 |
19 | matches_url = app.url_path_for("get_matches")
20 |
21 | async def test_access_not_authenticated_matches_list(
22 | self,
23 | async_client: TestClient,
24 | ):
25 | """Проверка существования эндпоинта matches/ и наличия
26 | доступа к нему неавторизованного пользователя
27 | """
28 |
29 | response = await async_client.get(self.matches_url)
30 |
31 | assert (
32 | response.status_code != status.HTTP_404_NOT_FOUND
33 | ), f"Эндпоинт `{self.matches_url}` не найден."
34 | assert response.status_code == status.HTTP_401_UNAUTHORIZED, (
35 | "Проверьте, что GET-запрос неавторизованного пользователя к "
36 | f"`{self.matches_url}` возвращает код 401"
37 | )
38 |
39 | async def test_matches_list(
40 | self,
41 | authorised_cookie: dict,
42 | async_client: TestClient,
43 | user: AuthUser,
44 | user2: AuthUser,
45 | match: Match,
46 | match2: Match,
47 | questionary: UserQuestionnaire,
48 | ):
49 | """Проверка корректности работы эндпоинта matches/ при
50 | GET-запросе авторизованного пользователя
51 | """
52 | response = await async_client.get(
53 | self.matches_url,
54 | cookies=authorised_cookie,
55 | )
56 |
57 | assert response.status_code == status.HTTP_200_OK, (
58 | "Проверьте, что GET-запрос неавторизованного пользователя к "
59 | f"`{self.matches_url}` возвращает код 200"
60 | )
61 |
62 | response_json = response.json()
63 | assert isinstance(response_json, list), (
64 | "Проверьте, что GET-запрос авторизованного "
65 | f"пользователя к `{self.matches_url}` возвращает "
66 | "список анкет пользователей, c которыми есть match"
67 | )
68 |
69 | assert len(response_json) == 1, (
70 | "Проверьте, что GET-запрос авторизованного пользователя "
71 | f"к `{self.matches_url}` не возвращает анкеты пользователей,"
72 | "c которыми нет совпадения"
73 | )
74 |
75 | assert response.json()[0] == {
76 | "firstname": questionary.firstname,
77 | "lastname": questionary.lastname,
78 | "gender": questionary.gender,
79 | "photo": questionary.photo,
80 | "country": questionary.country,
81 | "city": questionary.city,
82 | "about": questionary.about,
83 | "birthday": "2004-02-14",
84 | "hobbies": [
85 | {
86 | "hobby_name": questionary.hobbies[0].hobby_name,
87 | },
88 | {
89 | "hobby_name": questionary.hobbies[1].hobby_name,
90 | },
91 | ],
92 | "height": questionary.height,
93 | "goals": questionary.goals,
94 | "sport": questionary.sport,
95 | "alcohol": questionary.alcohol,
96 | "smoking": questionary.smoking,
97 | "user_id": IsUUID,
98 | "id": IsUUID,
99 | "is_match": True,
100 |
101 | "match_id": IsUUID,
102 | }, (
103 | "Проверьте, что GET-запрос авторизованного пользователя "
104 | f"к `{self.matches_url}` возвращает анкету c корректными данными"
105 | )
106 |
107 | async def test_access_not_authenticated_match_delete(
108 | self,
109 | async_client: TestClient,
110 | user2: AuthUser,
111 | match: Match,
112 | ):
113 | """Проверка существования эндпоинта удаления match и
114 | доступа к нему неавторизованного пользователя
115 | """
116 | match_delete_url = app.url_path_for("delete_match", match_id=match.id)
117 | response = await async_client.delete(match_delete_url)
118 |
119 | assert (
120 | response.status_code != status.HTTP_404_NOT_FOUND
121 | ), f"Эндпоинт `{match_delete_url}` не найден."
122 | assert response.status_code == status.HTTP_403_FORBIDDEN, (
123 | "Проверьте, что GET-запрос неавторизованного пользователя к "
124 | f"`{match_delete_url}` возвращает код 403"
125 | )
126 |
127 | async def test_valid_match_delete(
128 | self,
129 | async_client: TestClient,
130 | user: AuthUser,
131 | match: Match,
132 | authorised_cookie: dict,
133 | like1: UserLike,
134 | like2: UserLike,
135 | get_async_session: AsyncSession,
136 | ):
137 | """Проверка валидного DELETE-запроса к эндпоинту matches/{match_id}"""
138 | match_delete_url = app.url_path_for("delete_match", match_id=match.id)
139 | response = await async_client.delete(
140 | match_delete_url,
141 | cookies=authorised_cookie,
142 | )
143 |
144 | assert response.status_code == status.HTTP_204_NO_CONTENT, (
145 | "Проверьте, что валидный DELETE-запрос авторизованного "
146 | f"пользователя к `{match_delete_url}` возвращает "
147 | "код 204"
148 | )
149 |
150 | assert await get_match_by_match_id(get_async_session, match.id) is None, (
151 | "Проверьте, что валидный DELETE-запрос авторизованного "
152 | f"пользователя к `{match_delete_url}` удаляет объект Match"
153 | )
154 |
155 | assert await get_like_by_id(get_async_session, like1.id) is None, (
156 | "Проверьте, что валидный DELETE-запрос авторизованного "
157 | f"пользователя к `{match_delete_url}` удаляет также лайк удалившего"
158 | "match пользователя"
159 | )
160 |
161 | assert await get_like_by_id(get_async_session, like2.id), (
162 | "Проверьте, что валидный DELETE-запрос авторизованного "
163 | f"пользователя к `{match_delete_url}` не удаляет лайк "
164 | "другого пользователя" # Хотя возможно и должен удалять, пока оставлю так.
165 | )
166 |
167 | # incorrect table state
168 | # this endpoint should not exist
169 | @pytest.mark.skip()
170 | async def test_invalid_match_delete(
171 | self,
172 | async_client: TestClient,
173 | user2: AuthUser,
174 | match1: Match,
175 | authorised_cookie: dict,
176 | like1: UserLike,
177 | get_async_session: AsyncSession,
178 | ):
179 | """Проверка невалидного DELETE-запроса к эндпоинту matches/{match_id}"""
180 | match_delete_url = app.url_path_for("match_delete", match_id=match1.id)
181 | response = await async_client.delete(
182 | match_delete_url,
183 | cookies=authorised_cookie,
184 | )
185 |
186 | assert response.status_code == status.HTTP_403_FORBIDDEN, (
187 | "Проверьте, что при попытке удалить не свой match "
188 | f"через DELETE-запрос к `{match_delete_url}`, "
189 | "возвращается код 403"
190 | )
191 |
192 | assert await get_match_by_match_id(get_async_session, match1.id), (
193 | "Проверьте, что DELETE-запрос некорректного "
194 | f"пользователя к `{match_delete_url}` не удаляет объект Match"
195 | )
196 |
197 | assert await get_like_by_id(get_async_session, like1.id), (
198 | "Проверьте, что DELETE-запрос некорректного "
199 | f"пользователя к `{match_delete_url}` не удаляет лайк пользователей"
200 | )
201 |
--------------------------------------------------------------------------------
/tests/with_db/test_chat.py:
--------------------------------------------------------------------------------
1 | import json
2 | import uuid
3 |
4 | import orjson
5 | import pytest
6 | from async_asgi_testclient import TestClient
7 | from dirty_equals import IsStr, IsUUID
8 |
9 | from src.auth.models import AuthUser
10 | from src.chat.schemas import MessageCreateRequest, MessageStatus, WSAction, WSStatus
11 | from src.chat.utils import orjson_dumps
12 | from src.matches.models import Match
13 | from src.mongodb.mongodb import Mongo
14 | from src.redis.redis import redis
15 |
16 |
17 | async def test_ws_msg_create(
18 | async_client: TestClient,
19 | new_async_client_module: TestClient,
20 | user: AuthUser,
21 | authorised_cookie: dict,
22 | authorised_cookie_user2: dict,
23 | user2: AuthUser,
24 | match: Match,
25 | ):
26 | msg = {"match_id": match.id, "text": "lol",
27 | "from_id": user.id, "to_id": user2.id}
28 |
29 | async with (
30 | async_client.websocket_connect("/chat/ws", cookies=authorised_cookie) as ws,
31 | new_async_client_module.websocket_connect("/chat/ws", cookies=authorised_cookie_user2) as ws2,
32 | ):
33 | await ws.send_text(orjson_dumps({
34 | "action": WSAction.CREATE,
35 | "message": msg,
36 | }))
37 | resp = orjson.loads(await ws.receive_text())
38 | resp2 = orjson.loads(await ws2.receive_text())
39 |
40 | assert resp["status"] == WSStatus.OK
41 | assert resp2["action"] == WSAction.CREATE
42 | assert resp2["message"] == {
43 | "id": IsUUID(),
44 | "match_id": IsUUID(),
45 | "from_id": user.id,
46 | "to_id": user2.id,
47 | "text": "lol",
48 | "status": str(MessageStatus.SENT),
49 | "created_at": IsStr(),
50 | "updated_at": IsStr(),
51 | "reply_to": None,
52 | "group_id": None,
53 | "media": None,
54 | }
55 |
56 | cache_match = await redis.get(f'match_{resp2["message"]["match_id"]}')
57 |
58 | assert json.loads(cache_match)["match_id"] == resp2["message"]["match_id"]
59 |
60 |
61 | async def test_ws_connect_without_token(async_client: TestClient):
62 | """App does not accept ws connection if there is no token."""
63 | await async_client.get("/api/v1/auth/logout")
64 | with pytest.raises(TypeError) as exc:
65 | async with async_client.websocket_connect("/chat/ws"):
66 | pass
67 | assert str(exc.value) == "'Message' object is not subscriptable"
68 |
69 |
70 | async def test_ws_msg_create_without_match(
71 | async_client: TestClient,
72 | user: AuthUser,
73 | authorised_cookie: dict,
74 | user3: AuthUser,
75 | ):
76 | msg = {"match_id": uuid.uuid4(), "text": "kek",
77 | "from_id": user.id, "to_id": user3.id}
78 |
79 | async with async_client.websocket_connect("/chat/ws", cookies=authorised_cookie) as ws:
80 | await ws.send_text(orjson_dumps({
81 | "action": WSAction.CREATE,
82 | "message": msg,
83 | }))
84 | resp = orjson.loads(await ws.receive_text())
85 |
86 | assert resp["status"] == WSStatus.ERROR
87 | assert resp["detail"] == f"No match for users {user.id} and {user3.id}"
88 |
89 |
90 | async def test_ws_unknown_action(
91 | async_client: TestClient,
92 | user: AuthUser,
93 | authorised_cookie: dict,
94 | user2: AuthUser,
95 | match: Match,
96 | ):
97 | msg = {"match_id": match.id, "text": "lol",
98 | "from_id": user.id, "to_id": user2.id}
99 |
100 | async with async_client.websocket_connect("/chat/ws", cookies=authorised_cookie) as ws:
101 | await ws.send_text(orjson_dumps({
102 | "user_id": user.id,
103 | }))
104 | await ws.send_text(orjson_dumps({
105 | "action": "DO SOME SHIT",
106 | "message": msg,
107 | }))
108 | resp = orjson.loads(await ws.receive_text())
109 |
110 | assert resp["status"] == WSStatus.ERROR
111 | assert resp["detail"] == "unknown action or bad message format"
112 |
113 |
114 | async def test_ws_message_delete(
115 | async_client: TestClient,
116 | user: AuthUser,
117 | authorised_cookie: dict,
118 | new_async_client_module: TestClient,
119 | authorised_cookie_user2: dict,
120 | user2: AuthUser,
121 | mongo: Mongo,
122 | match: Match,
123 | ):
124 | msg = {
125 | "match_id": uuid.uuid4(),
126 | "from_id": user.id,
127 | "to_id": user2.id,
128 | "text": "aaa",
129 | }
130 |
131 | result = await mongo.create_message(MessageCreateRequest(**msg))
132 |
133 | msg["id"] = result.id
134 | msg.pop("text")
135 |
136 | async with (
137 | async_client.websocket_connect("/chat/ws", cookies=authorised_cookie) as ws,
138 | new_async_client_module.websocket_connect("/chat/ws", cookies=authorised_cookie_user2) as ws2,
139 | ):
140 | await ws.send_text(orjson_dumps({
141 | "action": WSAction.DELETE,
142 | "message": msg,
143 | }))
144 | resp = orjson.loads(await ws.receive_text())
145 | resp2 = orjson.loads(await ws2.receive_text())
146 |
147 | assert resp["status"] == WSStatus.OK
148 | assert resp2["action"] == WSAction.DELETE
149 | assert resp2["message"]["id"] == str(result.id)
150 |
151 |
152 | async def test_ws_unknown_message_delete(
153 | async_client: TestClient,
154 | user: AuthUser,
155 | authorised_cookie: dict,
156 | user2: AuthUser,
157 | match: Match,
158 | ):
159 | msg = {
160 | "id": uuid.uuid4(),
161 | "match_id": uuid.uuid4(),
162 | "from_id": user.id,
163 | "to_id": user2.id,
164 | }
165 |
166 | async with async_client.websocket_connect("/chat/ws", cookies=authorised_cookie) as ws:
167 | await ws.send_text(orjson_dumps({
168 | "action": WSAction.DELETE,
169 | "message": msg,
170 | }))
171 | resp = orjson.loads(await ws.receive_text())
172 |
173 | assert resp["status"] == WSStatus.ERROR
174 | assert resp["detail"] == f"unknown message id {msg['id']}"
175 |
176 |
177 | async def test_ws_message_update(
178 | async_client: TestClient,
179 | user: AuthUser,
180 | authorised_cookie: dict,
181 | new_async_client_module: TestClient,
182 | authorised_cookie_user2: dict,
183 | user2: AuthUser,
184 | mongo: Mongo,
185 | match: Match,
186 | ):
187 | msg = {
188 | "match_id": uuid.uuid4(),
189 | "from_id": user.id,
190 | "to_id": user2.id,
191 | "text": "kek",
192 | "status": MessageStatus.SENT,
193 | }
194 |
195 | msg = await mongo.create_message(MessageCreateRequest(**msg))
196 | msg.text = "lol"
197 | msg.status = MessageStatus.READ
198 |
199 | async with (
200 | async_client.websocket_connect("/chat/ws", cookies=authorised_cookie) as ws,
201 | new_async_client_module.websocket_connect("/chat/ws", cookies=authorised_cookie_user2) as ws2,
202 | ):
203 | await ws.send_text(orjson_dumps({
204 | "action": WSAction.UPDATE,
205 | "message": msg.dict(exclude={"updated_at", "created_at"}),
206 | }))
207 | resp = orjson.loads(await ws.receive_text())
208 | resp2 = orjson.loads(await ws2.receive_text())
209 |
210 | assert resp["status"] == WSStatus.OK
211 | assert resp2["action"] == WSAction.UPDATE
212 | assert resp2["message"] == {
213 | "id": IsUUID(),
214 | "match_id": IsUUID(),
215 | "from_id": user.id,
216 | "to_id": user2.id,
217 | "text": "lol",
218 | "status": str(MessageStatus.READ),
219 | "created_at": IsStr(),
220 | "updated_at": IsStr(),
221 | "reply_to": None,
222 | "group_id": None,
223 | "media": None,
224 | }
225 |
226 |
227 | async def test_ws_unknown_message_update(
228 | async_client: TestClient,
229 | user: AuthUser,
230 | authorised_cookie: dict,
231 | user2: AuthUser,
232 | match: Match,
233 | ):
234 | msg = {
235 | "id": uuid.uuid4(),
236 | "match_id": uuid.uuid4(),
237 | "from_id": user.id,
238 | "to_id": user2.id,
239 | "text": "kek",
240 | "status": MessageStatus.READ,
241 | }
242 |
243 | async with async_client.websocket_connect("/chat/ws", cookies=authorised_cookie) as ws:
244 | await ws.send_text(orjson_dumps({
245 | "action": WSAction.UPDATE,
246 | "message": msg,
247 | }))
248 | resp = orjson.loads(await ws.receive_text())
249 |
250 | assert resp["status"] == WSStatus.ERROR
251 | assert resp["detail"] == f"unknown message id {msg['id']}"
252 |
--------------------------------------------------------------------------------
/tests/with_db/test_questionnaire.py:
--------------------------------------------------------------------------------
1 | from async_asgi_testclient import TestClient
2 | from dirty_equals import IsUUID
3 | from fastapi import status
4 | from sqlalchemy.ext.asyncio import AsyncSession
5 |
6 | from src.auth.models import AuthUser
7 | from src.likes.models import UserLike
8 | from src.questionnaire.crud import get_questionnaire
9 | from src.questionnaire.models import UserQuestionnaire
10 |
11 |
12 | async def test_create_questionnaire(
13 | async_client: TestClient,
14 | user: AuthUser,
15 | authorised_cookie: dict,
16 | ):
17 | questionnaire_data = {
18 | "firstname": "Антон",
19 | "lastname": "Суворов",
20 | "gender": "Male",
21 | "photo": "Фото",
22 | "country": "Россия",
23 | "city": "Питер",
24 | "about": "Мужичок",
25 | "hobbies": [{"hobby_name": "string"}],
26 | "height": 190,
27 | "sport": "He занимаюсь",
28 | "alcohol": "He пью",
29 | "smoking": "Курю",
30 | "goals": "Дружба",
31 | "birthday": "2004-02-14",
32 | }
33 | response = await async_client.post(
34 | "/api/v1/questionnaire",
35 | json=questionnaire_data,
36 | cookies=authorised_cookie,
37 | )
38 | assert response.status_code == status.HTTP_201_CREATED
39 | assert response.json() == {
40 | "id": IsUUID,
41 | "firstname": "Антон",
42 | "lastname": "Суворов",
43 | "gender": "Male",
44 | "photo": "Фото",
45 | "country": "Россия",
46 | "city": "Питер",
47 | "about": "Мужичок",
48 | "hobbies": [{"hobby_name": "string"}],
49 | "height": 190,
50 | "sport": "He занимаюсь",
51 | "alcohol": "He пью",
52 | "smoking": "Курю",
53 | "goals": "Дружба",
54 | "birthday": "2004-02-14",
55 | "user_id": IsUUID,
56 | }
57 | assert response.json()["user_id"] == str(user.id)
58 |
59 |
60 | async def test_create_questionnaire_bad_credentials(
61 | async_client: TestClient,
62 | user: AuthUser,
63 | authorised_cookie: dict,
64 | ):
65 | questionnaire_data = {
66 | "firstname": "Антон",
67 | "lastname": "Суворов",
68 | "gender": "Male",
69 | "photo": "Фото",
70 | "country": "Россия",
71 | "city": "Питер",
72 | "about": "Мужичок",
73 | "hobbies": [{"hobby_name": "string"}],
74 | "height": 190,
75 | "sport": "He занимаюсь",
76 | "alcohol": "He пью",
77 | "smoking": "Курю",
78 | "goals": "Дружба",
79 | "birthday": "2004-02-14",
80 | }
81 | response = await async_client.post(
82 | "/api/v1/questionnaire",
83 | json=questionnaire_data,
84 | cookies=authorised_cookie,
85 | )
86 | assert response.status_code == status.HTTP_400_BAD_REQUEST
87 |
88 | questionary = await async_client.get(
89 | "/api/v1/questionnaire/get_my_quest",
90 | cookies=authorised_cookie,
91 | )
92 | assert (
93 | response.json()["detail"]
94 | == f"Объект уже существует в базе данных!!!{questionary.json()['firstname']}"
95 | )
96 |
97 |
98 | async def test_get_quest_authenticated_user(
99 | async_client: TestClient,
100 | user: AuthUser,
101 | authorised_cookie: dict,
102 | ):
103 | response = await async_client.get(
104 | "/api/v1/questionnaire/get_my_quest",
105 | cookies=authorised_cookie,
106 | )
107 |
108 | assert response.status_code == status.HTTP_200_OK
109 | assert response.json() == {
110 | "firstname": "Антон",
111 | "lastname": "Суворов",
112 | "gender": "Male",
113 | "photo": "Фото",
114 | "country": "Россия",
115 | "city": "Питер",
116 | "about": "Мужичок",
117 | "hobbies": [{"hobby_name": "string"}],
118 | "height": 190,
119 | "sport": "He занимаюсь",
120 | "alcohol": "He пью",
121 | "smoking": "Курю",
122 | "goals": "Дружба",
123 | "birthday": "2004-02-14",
124 | "user_id": IsUUID,
125 | "id": IsUUID,
126 | }
127 |
128 |
129 | async def test_logic_for_reusing_questionnaires(
130 | async_client: TestClient,
131 | user2: AuthUser,
132 | authorised_cookie_user2: dict,
133 | like3: UserLike,
134 | questionary: UserQuestionnaire,
135 | questionary_user3: UserQuestionnaire,
136 | ):
137 | response = await async_client.get(
138 | "/api/v1/questionnaire/list/0",
139 | cookies=authorised_cookie_user2,
140 | )
141 | assert response.status_code == status.HTTP_200_OK
142 | assert response.json() == []
143 |
144 |
145 | async def test_update_quest(
146 | async_client: TestClient,
147 | questionary: UserQuestionnaire,
148 | user2: AuthUser,
149 | authorised_cookie_user2: dict,
150 | ):
151 | updated_data = {
152 | "firstname": "Антон",
153 | "lastname": "Суворов",
154 | "gender": "Male",
155 | "photo": "Фото",
156 | "country": "Россия",
157 | "city": "Питер",
158 | "about": "Мужичок",
159 | "hobbies": [{"hobby_name": "string"}],
160 | "height": 190,
161 | "sport": "He занимаюсь",
162 | "alcohol": "He пью",
163 | "smoking": "Курю",
164 | "goals": "Дружба",
165 | "birthday": "2004-02-14",
166 | }
167 |
168 | response = await async_client.patch(
169 | f"/api/v1/questionnaire/{questionary.id}",
170 | json=updated_data,
171 | cookies=authorised_cookie_user2,
172 | )
173 | assert response.status_code == status.HTTP_200_OK
174 | assert response.json() == {
175 | "id": IsUUID,
176 | "firstname": "Антон",
177 | "lastname": "Суворов",
178 | "gender": "Male",
179 | "photo": "Фото",
180 | "country": "Россия",
181 | "city": "Питер",
182 | "about": "Мужичок",
183 | "hobbies": [{"hobby_name": "string"}],
184 | "height": 190,
185 | "sport": "He занимаюсь",
186 | "alcohol": "He пью",
187 | "smoking": "Курю",
188 | "goals": "Дружба",
189 | "birthday": "2004-02-14",
190 | "user_id": IsUUID,
191 | }
192 | assert response.json()["user_id"] == str(user2.id)
193 |
194 |
195 | async def test_delete_quest(
196 | async_client: TestClient,
197 | questionary: UserQuestionnaire,
198 | user2: AuthUser,
199 | authorised_cookie_user2: dict,
200 | get_async_session: AsyncSession,
201 | ):
202 | response = await async_client.delete(
203 | f"/api/v1/questionnaire/{questionary.id}",
204 | cookies=authorised_cookie_user2,
205 | )
206 | assert response.status_code == status.HTTP_204_NO_CONTENT
207 | response_check = await get_questionnaire(
208 | user_id=user2.id,
209 | session=get_async_session,
210 | )
211 | assert response_check is None
212 |
213 |
214 | async def test_create_questionnaire_without_token(
215 | async_client: TestClient,
216 | user: AuthUser,
217 | ):
218 | questionnaire_data = {
219 | "firstname": "string",
220 | "lastname": "string",
221 | "gender": "Female",
222 | "photo": "string",
223 | "country": "string",
224 | "city": "string",
225 | "about": "string",
226 | "hobbies": [
227 | {
228 | "hobby_name": "qwewasd",
229 | },
230 | {
231 | "hobby_name": "asidpas",
232 | },
233 | ],
234 | "height": 0,
235 | "sport": "He занимаюсь",
236 | "alcohol": "He пью",
237 | "smoking": "Курю",
238 | "birthday": "2004-02-14",
239 | }
240 |
241 | """Without cookies."""
242 |
243 | response = await async_client.post(
244 | "/api/v1/questionnaire",
245 | json=questionnaire_data,
246 | cookies={},
247 | )
248 | assert response.status_code == status.HTTP_403_FORBIDDEN
249 |
250 | """Incorrect cookies."""
251 |
252 | response = await async_client.post(
253 | "/api/v1/questionnaire",
254 | json=questionnaire_data,
255 | cookies={"mir": "some.kind.of.incorrect.cookies"},
256 | )
257 | assert response.status_code == status.HTTP_401_UNAUTHORIZED
258 |
259 |
260 | async def test_get_questionnaires_without_token(
261 | async_client: TestClient,
262 | ):
263 | response = await async_client.get(
264 | "/api/v1/questionnaire/get_my_quest",
265 | cookies={},
266 | )
267 | assert response.status_code == status.HTTP_403_FORBIDDEN
268 |
269 | response = await async_client.get(
270 | "/api/v1/questionnaire/list/0",
271 | cookies={},
272 | )
273 | assert response.status_code == status.HTTP_403_FORBIDDEN
274 |
275 |
276 | async def test_update_or_delete_quest_without_token(
277 | async_client: TestClient,
278 | questionary: UserQuestionnaire,
279 | user2: AuthUser,
280 | ):
281 | updated_data = {
282 | "firstname": "string",
283 | "lastname": "string",
284 | "gender": "Female",
285 | "photo": "string",
286 | "country": "string",
287 | "city": "string",
288 | "about": "string",
289 | "hobbies": [
290 | {
291 | "hobby_name": "qwewasd",
292 | },
293 | {
294 | "hobby_name": "asidpas",
295 | },
296 | ],
297 | "height": 0,
298 | "sport": "He занимаюсь",
299 | "alcohol": "He пью",
300 | "smoking": "Курю",
301 | "birthday": "2004-02-14",
302 | }
303 |
304 | response = await async_client.patch(
305 | f"/api/v1/questionnaire/{questionary.id}",
306 | json=updated_data,
307 | cookies={},
308 | )
309 | assert response.status_code == status.HTTP_403_FORBIDDEN
310 |
311 | response = await async_client.delete(
312 | f"/api/v1/questionnaire/{questionary.id}",
313 | cookies={},
314 | )
315 | assert response.status_code == status.HTTP_403_FORBIDDEN
316 |
317 | async def test_age_validation(
318 | async_client: TestClient,
319 | user: AuthUser,
320 | authorised_cookie: dict,
321 | ):
322 | questionnaire_data = {
323 | "firstname": "Антон",
324 | "lastname": "Суворов",
325 | "gender": "Male",
326 | "photo": "Фото",
327 | "country": "Россия",
328 | "city": "Питер",
329 | "about": "Мужичок",
330 | "hobbies": [{"hobby_name": "string"}],
331 | "height": 190,
332 | "sport": "He занимаюсь",
333 | "alcohol": "He пью",
334 | "smoking": "Курю",
335 | "goals": "Дружба",
336 | "birthday": "2020-02-14",
337 | }
338 | response = await async_client.post(
339 | "/api/v1/questionnaire",
340 | json=questionnaire_data,
341 | cookies=authorised_cookie,
342 | )
343 | assert response.status_code == status.HTTP_400_BAD_REQUEST
344 | questionnaire_data1 = {
345 | "firstname": "Антон",
346 | "lastname": "Суворов",
347 | "gender": "Male",
348 | "photo": "Фото",
349 | "country": "Россия",
350 | "city": "Питер",
351 | "about": "Мужичок",
352 | "hobbies": [{"hobby_name": "string"}],
353 | "height": 190,
354 | "sport": "He занимаюсь",
355 | "alcohol": "He пью",
356 | "smoking": "Курю",
357 | "goals": "Дружба",
358 | "birthday": "1900-02-14",
359 | }
360 | response = await async_client.post(
361 | "/api/v1/questionnaire",
362 | json=questionnaire_data1,
363 | cookies=authorised_cookie,
364 | )
365 | assert response.status_code == status.HTTP_400_BAD_REQUEST
366 |
--------------------------------------------------------------------------------
/tests/test_auth.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | from datetime import datetime, timedelta
3 | from typing import TYPE_CHECKING
4 | from unittest import mock
5 |
6 | import pytest
7 | from async_asgi_testclient import TestClient
8 | from dirty_equals import IsInt, IsUUID
9 | from fastapi import status
10 | from sqlalchemy.ext.asyncio import AsyncSession
11 |
12 | from src.auth import utils as auth_utils
13 | from src.auth.crud import get_user_profile
14 | from src.auth.models import AuthUser
15 | from src.main import app
16 |
17 | if TYPE_CHECKING:
18 | from src.auth.schemas import UserProfile
19 |
20 |
21 | class TestUser:
22 | """Тесты на пользователя."""
23 |
24 | async def test_user_registration(self, async_client: TestClient):
25 | """Тест - создание пользователя."""
26 | user_data = {
27 | "email": "user@mail.ru",
28 | "password": "password",
29 | }
30 | response = await async_client.post(
31 | "/api/v1/auth/register",
32 | json=user_data,
33 | )
34 | assert response.status_code == status.HTTP_201_CREATED
35 | assert response.json() == {
36 | "id": IsUUID,
37 | "email": user_data.get("email"),
38 | "is_active": True,
39 | "is_superuser": False,
40 | "is_verified": False,
41 | }
42 |
43 | async def test_user_registration_with_same_email(
44 | self,
45 | async_client: TestClient,
46 | ):
47 | """Тест - создание уже существующего пользователя."""
48 | response = await async_client.post(
49 | "/api/v1/auth/register",
50 | json={
51 | "email": "user@mail.ru",
52 | "password": "password",
53 | },
54 | )
55 | assert response.status_code == status.HTTP_400_BAD_REQUEST
56 |
57 | async def test_user_registration_with_incorrect_data(
58 | self,
59 | async_client: TestClient,
60 | ):
61 | """Тест - создание пользователя: некорректные данные."""
62 | wrong_data = {
63 | "email": "not_email",
64 | "password": "password",
65 | }
66 | response = await async_client.post(
67 | "/api/v1/auth/register",
68 | json=wrong_data,
69 | )
70 | assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
71 |
72 | async def test_cookies_after_register(
73 | self,
74 | async_client: TestClient,
75 | ) -> None:
76 | """Тест - проверка кук после создания пользователя."""
77 | cookies_data = auth_utils.decode_jwt(async_client.cookie_jar["mir"].value)
78 | assert cookies_data == {
79 | "email": "user@mail.ru",
80 | "exp": IsInt,
81 | "sub": IsUUID,
82 | "type": "mir",
83 | }
84 | expires = (datetime.utcnow() + timedelta(minutes=14)).timestamp()
85 | assert int(cookies_data["exp"]) > int(expires)
86 |
87 | async def test_login_wrong_password(
88 | self,
89 | async_client: TestClient,
90 | ):
91 | """Тест - вход c неправильным паролем."""
92 | async_client.cookie_jar.clear()
93 | response = await async_client.post(
94 | "/api/v1/auth/login",
95 | json={
96 | "email": "user@mail.ru",
97 | "password": "wrong_password",
98 | },
99 | )
100 | assert response.status_code == status.HTTP_400_BAD_REQUEST
101 | assert async_client.cookie_jar.get("mir") is None
102 |
103 | async def test_login(
104 | self,
105 | async_client: TestClient,
106 | ):
107 | """Тест - авторизация зарегистрированного пользователя."""
108 | async_client.cookie_jar.clear()
109 | response = await async_client.post(
110 | "/api/v1/auth/login",
111 | json={
112 | "email": "user@mail.ru",
113 | "password": "password",
114 | },
115 | )
116 | assert response.status_code == status.HTTP_200_OK
117 | assert auth_utils.decode_jwt(async_client.cookie_jar["mir"].value) == {
118 | "email": "user@mail.ru",
119 | "exp": IsInt,
120 | "sub": IsUUID,
121 | "type": "mir",
122 | }
123 |
124 | async def test_refresh_token(
125 | self,
126 | async_client: TestClient,
127 | ) -> None:
128 | """Тест - перевыпуска access_token по refresh_token."""
129 | old_access_token = async_client.cookie_jar["mir"].value
130 | old_refresh_token = async_client.cookie_jar["rsmir"].value
131 |
132 | await asyncio.sleep(1)
133 |
134 | response = await async_client.get(
135 | "/api/v1/auth/refresh",
136 | cookies={"rsmir": old_refresh_token},
137 | )
138 | assert response.status_code == status.HTTP_200_OK
139 | assert response.cookies["mir"] != old_access_token
140 | assert response.cookies["rsmir"] != old_refresh_token
141 |
142 | async def test_refresh_token_without_token(
143 | self,
144 | async_client: TestClient,
145 | ) -> None:
146 | """Тест обновления токенов без refresh_token."""
147 |
148 | response = await async_client.get(
149 | "/api/v1/auth/refresh",
150 | cookies={"rsmir": ""},
151 | )
152 | assert response.status_code == status.HTTP_403_FORBIDDEN
153 | assert response.json() == {
154 | "detail": "Not authenticated",
155 | }
156 |
157 | async def test_refresh_token_with_incorrect_token(
158 | self,
159 | async_client: TestClient,
160 | ) -> None:
161 | """Тест обновления токенов c некорректным токеном."""
162 | response = await async_client.get(
163 | "/api/v1/auth/refresh",
164 | cookies={"rsmir": "some.incorrect.refresh.token"},
165 | )
166 | assert response.status_code == status.HTTP_400_BAD_REQUEST
167 | assert response.json() == {
168 | "detail": "could not refresh access token",
169 | }
170 |
171 | async def test_logout(
172 | self,
173 | async_client: TestClient,
174 | ):
175 | """Тест - logout авторизованного пользователя."""
176 | response = await async_client.get(
177 | "/api/v1/auth/logout",
178 | )
179 | assert response.status_code == status.HTTP_204_NO_CONTENT
180 | assert response.cookies.get("mir") is None
181 | assert response.cookies.get("rsmir") is None
182 |
183 | async def test_expired_access_token(
184 | self,
185 | async_client: TestClient,
186 | ):
187 | """Тест - просроченный токен доступа."""
188 | with mock.patch("src.config.settings.ACCESS_TOKEN_EXPIRES_IN", new=0.01):
189 | await async_client.post(
190 | "/api/v1/auth/login",
191 | json={
192 | "email": "user@mail.ru",
193 | "password": "password",
194 | },
195 | )
196 | await asyncio.sleep(0.5)
197 | response = await async_client.get(
198 | "/api/v1/users/me",
199 | )
200 | assert response.status_code == status.HTTP_401_UNAUTHORIZED
201 |
202 | async def test_expired_refresh_token(
203 | self,
204 | async_client: TestClient,
205 | ):
206 | """Тест - просроченный рефреш токен."""
207 | with mock.patch("src.config.settings.REFRESH_TOKEN_EXPIRES_IN", new=0.01):
208 | await async_client.post(
209 | "/api/v1/auth/login",
210 | json={
211 | "email": "user@mail.ru",
212 | "password": "password",
213 | },
214 | )
215 | await asyncio.sleep(0.5)
216 | response = await async_client.get(
217 | "/api/v1/auth/refresh",
218 | )
219 | assert response.status_code == status.HTTP_400_BAD_REQUEST
220 |
221 |
222 | class TestUserProfile:
223 | """Тесты профиля пользователя."""
224 |
225 | async def test_get_user_profile(
226 | self,
227 | user: AuthUser,
228 | authorised_cookie: dict,
229 | async_client: TestClient,
230 | get_async_session: AsyncSession,
231 | ):
232 | """Тест - получение профиля пользователя."""
233 | profile: UserProfile = await get_user_profile(
234 | user=user,
235 | session=get_async_session,
236 | )
237 | response = await async_client.get(
238 | app.url_path_for("get_profile"),
239 | cookies=authorised_cookie,
240 | )
241 | assert response.status_code == status.HTTP_200_OK
242 | assert response.json() == {
243 | "id": str(profile.id),
244 | "user_id": str(profile.user_id),
245 | "subscriber": profile.subscriber,
246 | "search_range_min": profile.search_range_min,
247 | "search_range_max": profile.search_range_max,
248 | "search_age_min": profile.search_age_min,
249 | "search_age_max": profile.search_age_max,
250 | }
251 |
252 | async def test_get_user_profile_without_token(
253 | self,
254 | async_client: TestClient,
255 | ):
256 | """Тест - получение профиля пользователя без токена."""
257 | response = await async_client.get(
258 | app.url_path_for("get_profile"),
259 | cookies={},
260 | )
261 | assert response.status_code == status.HTTP_403_FORBIDDEN
262 |
263 | """Тест - получение профиля пользователя c неправильным токеном."""
264 | response = await async_client.get(
265 | app.url_path_for("get_profile"),
266 | cookies={"mir": "some.kind.of.incorrect.cookies"},
267 | )
268 | assert response.status_code == status.HTTP_401_UNAUTHORIZED
269 |
270 | async def test_update_user_profile(
271 | self,
272 | user: AuthUser,
273 | authorised_cookie: dict,
274 | async_client: TestClient,
275 | get_async_session: AsyncSession,
276 | ):
277 | """Тест - обновление профиля пользователя."""
278 | data = {
279 | "search_range_min": 0,
280 | "search_range_max": 100,
281 | "search_age_min": 18,
282 | "search_age_max": 80,
283 | }
284 | response = await async_client.patch(
285 | app.url_path_for("get_profile"),
286 | cookies=authorised_cookie,
287 | json=data,
288 | )
289 | assert response.status_code == status.HTTP_200_OK
290 | profile: UserProfile = await get_user_profile(
291 | user=user,
292 | session=get_async_session,
293 | )
294 | assert response.json() == {
295 | "id": str(profile.id),
296 | "user_id": str(profile.user_id),
297 | "subscriber": profile.subscriber,
298 | "search_range_min": data.get("search_range_min"),
299 | "search_range_max": data.get("search_range_max"),
300 | "search_age_min": data.get("search_age_min"),
301 | "search_age_max": data.get("search_age_max"),
302 | }
303 |
304 | async def test_update_user_profile_without_token(
305 | self,
306 | async_client: TestClient,
307 | ):
308 | """Тест - обновление профиля пользователя без токена."""
309 | data = {
310 | "search_range_min": 0,
311 | "search_range_max": 100,
312 | "search_age_min": 18,
313 | "search_age_max": 80,
314 | }
315 | response = await async_client.patch(
316 | app.url_path_for("get_profile"),
317 | json=data,
318 | cookies={},
319 | )
320 | assert response.status_code == status.HTTP_403_FORBIDDEN
321 |
322 | """Тест - обновление профиля пользователя c неправильным токеном."""
323 | response = await async_client.patch(
324 | app.url_path_for("get_profile"),
325 | json=data,
326 | cookies={"mir": "some.kind.of.incorrect.cookies"},
327 | )
328 | assert response.status_code == status.HTTP_401_UNAUTHORIZED
329 |
330 | @pytest.mark.parametrize(
331 | ("age_min", "age_max", "range_min", "range_max"),
332 | [
333 | (17, 60, 0, 999),
334 | (18, 100, 0, 999),
335 | (18, 60, -1, 999),
336 | (18, 60, 0, 1000),
337 | (18, 60, 999, 0),
338 | (60, 18, 0, 999),
339 | ],
340 | )
341 | async def test_update_profile_with_wrong_data(
342 | self,
343 | age_min: int,
344 | age_max: int,
345 | range_min: int,
346 | range_max: int,
347 | authorised_cookie: dict,
348 | async_client: TestClient,
349 | ):
350 | """Тест - обновление профиля пользователя."""
351 | wrong_data = {
352 | "search_range_min": range_min,
353 | "search_range_max": range_max,
354 | "search_age_min": age_min,
355 | "search_age_max": age_max,
356 | }
357 | response = await async_client.patch(
358 | app.url_path_for("get_profile"),
359 | cookies=authorised_cookie,
360 | json=wrong_data,
361 | )
362 | assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
363 |
--------------------------------------------------------------------------------
/tests/test_acceptance.py:
--------------------------------------------------------------------------------
1 | import orjson
2 | from async_asgi_testclient import TestClient
3 | from dirty_equals import IsStr, IsUUID
4 | from fastapi import status
5 |
6 | from src.chat.schemas import MessageStatus, WSAction, WSStatus
7 | from src.chat.utils import orjson_dumps
8 |
9 |
10 | class TestAcceptance:
11 | """Тесты на поведение пользователя."""
12 |
13 | async def test_acceptance(self, async_client: TestClient):
14 | """1. Регистрация двух пользователей."""
15 | """2. Логины двух пользователей."""
16 | """3. Создание анкет двух пользователей."""
17 | """4. Взаимные лайки двух пользователей."""
18 | """5. Проверка матча."""
19 |
20 | """Регистрация двух пользователей."""
21 |
22 | user_1_data = {
23 | "email": "user1@mail.ru",
24 | "password": "password",
25 | }
26 | response = await async_client.post(
27 | "/api/v1/auth/register",
28 | json=user_1_data,
29 | )
30 | assert response.status_code == status.HTTP_201_CREATED
31 | created_user_1_id = response.json()["id"]
32 | assert response.json() == {
33 | "id": created_user_1_id,
34 | "email": user_1_data.get("email"),
35 | "is_active": True,
36 | "is_superuser": False,
37 | "is_verified": False,
38 | }
39 | user_2_data = {
40 | "email": "user2@mail.ru",
41 | "password": "password",
42 | }
43 | response = await async_client.post(
44 | "/api/v1/auth/register",
45 | json=user_2_data,
46 | )
47 | assert response.status_code == status.HTTP_201_CREATED
48 | created_user_2_id = response.json()["id"]
49 | assert response.json() == {
50 | "id": created_user_2_id,
51 | "email": user_2_data.get("email"),
52 | "is_active": True,
53 | "is_superuser": False,
54 | "is_verified": False,
55 | }
56 |
57 | """Логин пользователя 1."""
58 | response = await async_client.post(
59 | "/api/v1/auth/login",
60 | json=user_1_data,
61 | )
62 | assert response.status_code == status.HTTP_200_OK
63 |
64 | created_user_1_jwt = async_client.cookie_jar["mir"].value
65 |
66 | """Логин пользователя 2."""
67 | response = await async_client.post(
68 | "/api/v1/auth/login",
69 | json=user_2_data,
70 | )
71 | assert response.status_code == status.HTTP_200_OK
72 |
73 | created_user_2_jwt = async_client.cookie_jar["mir"].value
74 |
75 | """Создание двух анкет."""
76 |
77 | questionnaire_1_data = {
78 | "firstname": "Антон",
79 | "lastname": "Суворов",
80 | "gender": "Male",
81 | "photo": "Фото",
82 | "country": "Россия",
83 | "city": "Питер",
84 | "about": "Мужичок",
85 | "hobbies": [{"hobby_name": "string"}],
86 | "height": 190,
87 | "sport": "He занимаюсь",
88 | "alcohol": "He пью",
89 | "smoking": "Курю",
90 | "goals": "Дружба",
91 | "birthday": "2004-02-14",
92 | }
93 |
94 | response = await async_client.post(
95 | "/api/v1/questionnaire",
96 | json=questionnaire_1_data,
97 | cookies={"mir": created_user_1_jwt},
98 | )
99 | assert response.status_code == status.HTTP_201_CREATED
100 | assert response.json() == {
101 | "id": IsUUID,
102 | "firstname": questionnaire_1_data["firstname"],
103 | "lastname": questionnaire_1_data["lastname"],
104 | "gender": questionnaire_1_data["gender"],
105 | "photo": questionnaire_1_data["photo"],
106 | "country": questionnaire_1_data["country"],
107 | "city": questionnaire_1_data["city"],
108 | "about": questionnaire_1_data["about"],
109 | "hobbies": questionnaire_1_data["hobbies"],
110 | "height": questionnaire_1_data["height"],
111 | "goals": questionnaire_1_data["goals"],
112 | "sport": questionnaire_1_data["sport"],
113 | "alcohol": questionnaire_1_data["alcohol"],
114 | "smoking": questionnaire_1_data["smoking"],
115 | "birthday": questionnaire_1_data["birthday"],
116 | "user_id": created_user_1_id,
117 | }
118 | questionnaire_2_data = {
119 | "firstname": "Антон",
120 | "lastname": "Суворов",
121 | "gender": "Female",
122 | "photo": "Фото",
123 | "country": "Россия",
124 | "city": "Питер",
125 | "about": "Мужичок",
126 | "hobbies": [{"hobby_name": "string"}],
127 | "height": 190,
128 | "sport": "He занимаюсь",
129 | "alcohol": "He пью",
130 | "smoking": "Курю",
131 | "goals": "Дружба",
132 | "birthday": "2004-02-14",
133 | }
134 | response = await async_client.post(
135 | "/api/v1/questionnaire",
136 | json=questionnaire_2_data,
137 | cookies={"mir": created_user_2_jwt},
138 | )
139 | assert response.status_code == status.HTTP_201_CREATED
140 | assert response.json() == {
141 | "id": IsUUID,
142 | "firstname": questionnaire_2_data["firstname"],
143 | "lastname": questionnaire_2_data["lastname"],
144 | "gender": questionnaire_2_data["gender"],
145 | "photo": questionnaire_2_data["photo"],
146 | "country": questionnaire_2_data["country"],
147 | "city": questionnaire_2_data["city"],
148 | "about": questionnaire_2_data["about"],
149 | "hobbies": questionnaire_2_data["hobbies"],
150 | "height": questionnaire_2_data["height"],
151 | "goals": questionnaire_2_data["goals"],
152 | "sport": questionnaire_2_data["sport"],
153 | "alcohol": questionnaire_2_data["alcohol"],
154 | "smoking": questionnaire_2_data["smoking"],
155 | "birthday": questionnaire_2_data["birthday"],
156 | "user_id": created_user_2_id,
157 | }
158 |
159 | """Проверка анкет первым пользователем."""
160 |
161 | response = await async_client.get(
162 | "/api/v1/questionnaire/list/0",
163 | cookies={"mir": created_user_1_jwt},
164 | )
165 | assert response.status_code == status.HTTP_200_OK
166 | assert response.json() == [{
167 | "id": IsUUID,
168 | "firstname": questionnaire_2_data["firstname"],
169 | "lastname": questionnaire_2_data["lastname"],
170 | "gender": questionnaire_2_data["gender"],
171 | "photo": questionnaire_2_data["photo"],
172 | "country": questionnaire_2_data["country"],
173 | "city": questionnaire_2_data["city"],
174 | "about": questionnaire_2_data["about"],
175 | "hobbies": questionnaire_2_data["hobbies"],
176 | "height": questionnaire_2_data["height"],
177 | "goals": questionnaire_2_data["goals"],
178 | "sport": questionnaire_2_data["sport"],
179 | "alcohol": questionnaire_2_data["alcohol"],
180 | "smoking": questionnaire_2_data["smoking"],
181 | "birthday": questionnaire_2_data["birthday"],
182 | "user_id": created_user_2_id,
183 | }]
184 |
185 | response = await async_client.get(
186 | "/api/v1/questionnaire/list/0",
187 | cookies={"mir": created_user_1_jwt},
188 | )
189 | assert response.status_code == status.HTTP_200_OK
190 | assert response.json() == [{
191 | "id": IsUUID,
192 | "firstname": questionnaire_2_data["firstname"],
193 | "lastname": questionnaire_2_data["lastname"],
194 | "gender": questionnaire_2_data["gender"],
195 | "photo": questionnaire_2_data["photo"],
196 | "country": questionnaire_2_data["country"],
197 | "city": questionnaire_2_data["city"],
198 | "about": questionnaire_2_data["about"],
199 | "hobbies": questionnaire_2_data["hobbies"],
200 | "height": questionnaire_2_data["height"],
201 | "goals": questionnaire_2_data["goals"],
202 | "sport": questionnaire_2_data["sport"],
203 | "alcohol": questionnaire_2_data["alcohol"],
204 | "smoking": questionnaire_2_data["smoking"],
205 | "birthday": questionnaire_2_data["birthday"],
206 | "user_id": created_user_2_id,
207 | }]
208 |
209 | response = await async_client.get(
210 | "/api/v1/questionnaire/list/0",
211 | cookies={"mir": created_user_1_jwt},
212 | )
213 | assert response.status_code == status.HTTP_200_OK
214 | assert response.json() == [{
215 | "id": IsUUID,
216 | "firstname": questionnaire_2_data["firstname"],
217 | "lastname": questionnaire_2_data["lastname"],
218 | "gender": questionnaire_2_data["gender"],
219 | "photo": questionnaire_2_data["photo"],
220 | "country": questionnaire_2_data["country"],
221 | "city": questionnaire_2_data["city"],
222 | "about": questionnaire_2_data["about"],
223 | "hobbies": questionnaire_2_data["hobbies"],
224 | "height": questionnaire_2_data["height"],
225 | "goals": questionnaire_2_data["goals"],
226 | "sport": questionnaire_2_data["sport"],
227 | "alcohol": questionnaire_2_data["alcohol"],
228 | "smoking": questionnaire_2_data["smoking"],
229 | "birthday": questionnaire_2_data["birthday"],
230 | "user_id": created_user_2_id,
231 | }]
232 |
233 | response = await async_client.get(
234 | "/api/v1/questionnaire/list/0",
235 | cookies={"mir": created_user_1_jwt},
236 | )
237 | assert response.status_code == status.HTTP_200_OK
238 | assert response.json() == []
239 |
240 | response = await async_client.get(
241 | "/api/v1/questionnaire/list/0",
242 | cookies={"mir": created_user_1_jwt},
243 | )
244 | assert response.status_code == status.HTTP_200_OK
245 | assert response.json() == []
246 |
247 | """Первый пользователь лайкает второго."""
248 |
249 | like_1 = {
250 | "liked_user_id": created_user_2_id,
251 | "is_liked": True,
252 | }
253 |
254 | response = await async_client.post(
255 | "/api/v1/likes",
256 | json=like_1,
257 | cookies={"mir": created_user_1_jwt},
258 | )
259 | assert response.status_code == status.HTTP_201_CREATED
260 | assert response.json() == {
261 | "created_at": IsStr,
262 | "id": IsUUID,
263 | "liked_user_id": created_user_2_id,
264 | "is_liked": True,
265 | }
266 |
267 | """Проверка анкет вторым пользователем."""
268 |
269 | response = await async_client.get(
270 | "/api/v1/questionnaire/list/0",
271 | cookies={"mir": created_user_2_jwt},
272 | )
273 | assert response.status_code == status.HTTP_200_OK
274 | assert response.json() == [{
275 | "id": IsUUID,
276 | "firstname": questionnaire_1_data["firstname"],
277 | "lastname": questionnaire_1_data["lastname"],
278 | "gender": questionnaire_1_data["gender"],
279 | "photo": questionnaire_1_data["photo"],
280 | "country": questionnaire_1_data["country"],
281 | "city": questionnaire_1_data["city"],
282 | "about": questionnaire_1_data["about"],
283 | "hobbies": questionnaire_1_data["hobbies"],
284 | "height": questionnaire_1_data["height"],
285 | "goals": questionnaire_1_data["goals"],
286 | "sport": questionnaire_1_data["sport"],
287 | "alcohol": questionnaire_1_data["alcohol"],
288 | "smoking": questionnaire_1_data["smoking"],
289 | "birthday": questionnaire_1_data["birthday"],
290 | "user_id": created_user_1_id,
291 | }]
292 |
293 | """Второй пользователь лайкает первого."""
294 |
295 | like_2 = {
296 | "liked_user_id": created_user_1_id,
297 | "is_liked": True,
298 | }
299 |
300 | response = await async_client.post(
301 | "/api/v1/likes",
302 | json=like_2,
303 | cookies={"mir": created_user_2_jwt},
304 | )
305 | assert response.status_code == status.HTTP_201_CREATED
306 | assert response.json() == {
307 | "created_at": IsStr,
308 | "id": IsUUID,
309 | "liked_user_id": created_user_1_id,
310 | "is_liked": True,
311 | }
312 |
313 | """Проверка матча вторым пользователем."""
314 |
315 | response = await async_client.get(
316 | "/api/v1/matches",
317 | cookies={"mir": created_user_2_jwt},
318 | )
319 | assert response.status_code == status.HTTP_200_OK
320 | assert response.json() == [{
321 | "id": IsUUID,
322 | "firstname": questionnaire_1_data["firstname"],
323 | "lastname": questionnaire_1_data["lastname"],
324 | "gender": questionnaire_1_data["gender"],
325 | "photo": questionnaire_1_data["photo"],
326 | "country": questionnaire_1_data["country"],
327 | "city": questionnaire_1_data["city"],
328 | "about": questionnaire_1_data["about"],
329 | "hobbies": questionnaire_1_data["hobbies"],
330 | "height": questionnaire_1_data["height"],
331 | "goals": questionnaire_1_data["goals"],
332 | "sport": questionnaire_1_data["sport"],
333 | "alcohol": questionnaire_1_data["alcohol"],
334 | "smoking": questionnaire_1_data["smoking"],
335 | "birthday": questionnaire_1_data["birthday"],
336 | "user_id": created_user_1_id,
337 | "is_match": True,
338 | "match_id": IsUUID,
339 | }]
340 |
341 | """Проверка анкет вторым пользователем после матча."""
342 |
343 | response = await async_client.get(
344 | "/api/v1/questionnaire/list/0",
345 | )
346 | assert response.status_code == status.HTTP_200_OK
347 | assert response.json() == []
348 |
349 |
350 |
351 |
352 | async def test_acceptance_with_chat(self, async_client: TestClient):
353 | """Тесты на чат между пользователями (пользователи взяты из предыдущего теста)."""
354 |
355 | """1. Логины двух пользователей."""
356 | """2. Получение различных id."""
357 | """3. Переписка (Работает только при подключении MongoDB)."""
358 |
359 | """Логин пользователя 1."""
360 | response = await async_client.post(
361 | "/api/v1/auth/login",
362 | json={
363 | "email": "user1@mail.ru",
364 | "password": "password",
365 | },
366 | )
367 | assert response.status_code == status.HTTP_200_OK
368 | user1_cookie = {"mir": async_client.cookie_jar["mir"].value}
369 |
370 | """Получаем id матча."""
371 |
372 | response = await async_client.get(
373 | "/api/v1/matches",
374 | )
375 | created_match_id = response.json()[0]["id"]
376 |
377 | """Получаем id первого пользователя."""
378 |
379 | response = await async_client.get(
380 | "/api/v1/users/me",
381 | )
382 | created_user_1_id = response.json()["user_id"]
383 |
384 | """Логин пользователя 2."""
385 | response = await async_client.post(
386 | "/api/v1/auth/login",
387 | json={
388 | "email": "user2@mail.ru",
389 | "password": "password",
390 | },
391 | )
392 | assert response.status_code == status.HTTP_200_OK
393 | user2_cookie = {"mir": async_client.cookie_jar["mir"].value}
394 | """Получаем id Второго пользователя."""
395 |
396 | response = await async_client.get(
397 | "/api/v1/users/me",
398 | )
399 | created_user_2_id = response.json()["user_id"]
400 |
401 | """Создание сообщений первым пользователем."""
402 |
403 | msg = {"match_id": created_match_id, "text": "Hi, lets meet up?",
404 | "from_id": created_user_1_id, "to_id": created_user_2_id}
405 |
406 | async with async_client.websocket_connect("/chat/ws", cookies=user1_cookie) as ws:
407 | await ws.send_text(orjson_dumps({
408 | "action": WSAction.CREATE,
409 | "message": msg,
410 | }))
411 | resp = orjson.loads(await ws.receive_text())
412 |
413 | assert resp["status"] == WSStatus.OK
414 | assert resp["message"] == {
415 | "id": IsUUID(),
416 | "match_id": created_match_id,
417 | "from_id": created_user_1_id,
418 | "to_id": created_user_2_id,
419 | "text": "Hi, lets meet up?",
420 | "status": str(MessageStatus.SENT),
421 | "created_at": IsStr(),
422 | "updated_at": IsStr(),
423 | "reply_to": None,
424 | "group_id": None,
425 | "media": None,
426 | }
427 |
428 | """Создание сообщений первым пользователем."""
429 |
430 | msg = {"match_id": created_match_id, "text": "Ok)))",
431 | "from_id": created_user_2_id, "to_id": created_user_1_id}
432 |
433 | async with async_client.websocket_connect("/chat/ws", cookies=user2_cookie) as ws:
434 | await ws.send_text(orjson_dumps({
435 | "action": WSAction.CREATE,
436 | "message": msg,
437 | }))
438 | resp = orjson.loads(await ws.receive_text())
439 |
440 | assert resp["status"] == WSStatus.OK
441 | assert resp["message"] == {
442 | "id": IsUUID(),
443 | "match_id": created_match_id,
444 | "from_id": created_user_2_id,
445 | "to_id": created_user_1_id,
446 | "text": "Ok)))",
447 | "status": str(MessageStatus.SENT),
448 | "created_at": IsStr(),
449 | "updated_at": IsStr(),
450 | "reply_to": None,
451 | "group_id": None,
452 | "media": None,
453 | }
454 |
--------------------------------------------------------------------------------