├── 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 |
82 |

Вход

83 |
84 | 85 | 86 |
87 |
88 | 89 | 90 |
91 | 92 |
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 | --------------------------------------------------------------------------------