Merge app brach to frontend #1

Merged
IgorVolochay merged 42 commits from app into frontend 2026-08-13 06:14:20 +00:00
23 changed files with 1286 additions and 80 deletions
+6
View File
@@ -0,0 +1,6 @@
DISABLE_DOCS=true
MONGO_HOST=127.0.0.1
MONGO_PORT=27017
MONGO_USER=user
MONGO_PASS=pass
+67
View File
@@ -0,0 +1,67 @@
name: app-actions
on:
workflow_dispatch:
push:
paths:
- '**.py'
branches:
- main
- app
pull_request:
branches:
- main
jobs:
mypy:
runs-on: ubuntu-latest
continue-on-error: true
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: 3.9
architecture: x64
- name: Install dependencies
run: |
pip install mypy
pip install -r app/requirements.txt
- name: Run mypy
run: mypy --ignore-missing-imports ./app
pytest:
runs-on: ubuntu-latest
env:
MONGO_HOST: ${{ secrets.MONGO_HOST }}
MONGO_PORT: ${{ secrets.MONGO_PORT }}
MONGO_USER: ${{ secrets.MONGO_USER }}
MONGO_PASS: ${{ secrets.MONGO_PASS }}
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: 3.9
architecture: x64
- name: Setup MongoDB
run: docker run --name mongodb -d -p ${{ secrets.MONGO_PORT }}:27017 -e MONGO_INITDB_ROOT_USERNAME=${{ secrets.MONGO_USER }} -e MONGO_INITDB_ROOT_PASSWORD=${{ secrets.MONGO_PASS }} mongodb/mongodb-community-server
- name: Install dependencies
run: |
pip install pytest==8.3.4 pytest-asyncio==0.25.3 httpx==0.28.1
pip install -r app/requirements.txt
- name: Setup moderated base cards
working-directory: ./app/tools
run: python3 _add_base_cards.py -a 2 -f data/base_cards.json
- name: Run pytest
run: pytest -vs
-22
View File
@@ -1,22 +0,0 @@
name: mypy-test
on:
push:
paths:
- '**.py'
jobs:
mypy:
runs-on: ubuntu-latest
steps:
- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: 3.9
architecture: x64
- name: Checkout
uses: actions/checkout@v3
- name: Install mypy
run: pip install mypy
- name: Run mypy
run: mypy --ignore-missing-imports ./app
+2 -25
View File
@@ -2,6 +2,7 @@
__pycache__/
*.py[cod]
*$py.class
.idea/
# C extensions
*.so
@@ -170,28 +171,4 @@ cython_debug/
# PyPI configuration file
.pypirc
# React
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
app/.env
+10 -2
View File
@@ -9,15 +9,23 @@ Telegram mini-app where you have to choose one of two things.
git clone https://github.com/IgorVolochay/thisORthat
```
### Manual setup:
2. The project is written in Python3.9. Make sure you have it on your system. Go to the project folder, create a virtual environment and download pip requirements:
```bash
cd ./thisORthat
python3.9 -m venv venv
source ./venv/bin/activate
pip3 install -r requirements.txt
pip3 install -r ./app/requirements.txt
```
3. Installing MongoDB database. You can use the [official manual](https://www.mongodb.com/docs/manual/installation/) to install MongoDB manually, or use a [Docker image](https://hub.docker.com/r/mongodb/mongodb-community-server) to run the container:
```bash
docker run --name mongodb -d -p 27017:27017 mongodb/mongodb-community-server
docker run --name mongodb -d -p 27017:27017 -e MONGO_INITDB_ROOT_USERNAME=user -e MONGO_INITDB_ROOT_PASSWORD=pass mongodb/mongodb-community-server
```
### Docker Compose setup:
2. Use docker-compose to automatically build the entire project. For correct build, it is better to use docker-compose version 1.29.2:
```bash
docker-compose up --build
```
+11
View File
@@ -0,0 +1,11 @@
FROM python:3.9.21-alpine
WORKDIR /app
COPY . .
RUN pip3 install -r requirements.txt
EXPOSE 5000
CMD ["python3", "main.py"]
+188 -1
View File
@@ -1,4 +1,191 @@
import os
import uvicorn
import asyncio
from dotenv import load_dotenv
from fastapi import FastAPI, Depends, Response, status
from schemas.api_schemas import *
from schemas.base_schemas import *
from mongo_worker import MongoWorker
from tools.base_moderation import moderate_text
load_dotenv()
disable_docs = os.getenv("DISABLE_DOCS", "true").lower() == "true"
app: FastAPI = FastAPI(title="This OR That",
summary="OpenAPI schema for \"This OR That\" project!",
version="0.1",
contact={"GitHub": "https://github.com/IgorVolochay/thisORthat"},
docs_url=None if disable_docs else "/docs",
redoc_url=None if disable_docs else "/redoc",
openapi_url=None if disable_docs else "/openapi.json")
mongo_worker = MongoWorker()
@app.get("/check_user", status_code=200)
async def check_user(user_id: NonNegativeInt,
mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse:
result = mongo.check_user(user_id)
return BaseResponse(result=result)
@app.get("/get_user", status_code=200)
async def get_user(user_id: NonNegativeInt,
response: Response,
mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse:
if mongo.check_user(user_id):
result = mongo.get_user(user_id)
return BaseResponse(result=result)
else:
response.status_code = status.HTTP_404_NOT_FOUND
return BaseResponse(result="User doesn't exist", error=True)
@app.post("/add_user", status_code=201)
async def add_user(new_user: AddUserBody,
response: Response,
mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse:
if not mongo.check_user(new_user.user_id):
result = mongo.add_user(new_user.user_id,
new_user.username,
new_user.first_name,
new_user.last_name,
new_user.photo_url)
return BaseResponse(result=result)
else:
response.status_code = status.HTTP_409_CONFLICT
return BaseResponse(result="User already exist", error=True)
@app.get("/get_card", status_code=200)
async def get_card(card_id: NonNegativeInt,
response: Response,
mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse:
card = mongo.get_card(card_id)
if card:
return BaseResponse(result=card)
else:
response.status_code = status.HTTP_404_NOT_FOUND
return BaseResponse(result="There is no card with this card_id", error=True)
@app.get("/get_random_cards", status_code=200)
async def get_random_cards(user_id: NonNegativeInt,
response: Response,
mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse:
cards_visited = mongo.get_visited_cards(user_id)
if cards_visited.error:
response.status_code = status.HTTP_401_UNAUTHORIZED
return cards_visited
elif not cards_visited.result.cards_visited:
random_cards = mongo.get_random_cards(10, True)
if random_cards:
return BaseResponse(result=random_cards)
else:
response.status_code = status.HTTP_404_NOT_FOUND
return BaseResponse(result="No active cards", error=True)
result: list[Card] = list()
trys = 3
while len(result) < 10 and trys != 0:
random_cards = mongo.get_random_cards(10, True)
if not random_cards:
response.status_code = status.HTTP_404_NOT_FOUND
return BaseResponse(result="No active cards", error=True)
filtered_cards, filtered_cards_id = mongo.filter_cards(random_cards, cards_visited.result.cards_visited)
trys -= 1
if not filtered_cards:
continue
else:
result.extend(filtered_cards)
cards_visited.result.cards_visited.update(filtered_cards_id)
if not result:
response.status_code = status.HTTP_404_NOT_FOUND
return BaseResponse(result="No active cards fo this user", error=True)
else:
return BaseResponse(result=result)
@app.post("/add_card", status_code=201)
async def add_card(new_card: AddCardBody,
response: Response,
mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse:
if moderate_text(new_card.choice_A) and moderate_text(new_card.choice_B):
card = mongo.add_card_by_api(new_card.choice_A,
new_card.choice_B,
new_card.author_id)
return BaseResponse(result=card)
else:
response.status_code = status.HTTP_400_BAD_REQUEST
return BaseResponse(result="Card has not passed base moderation", error=True)
@app.patch("/select_choice", status_code=200)
async def select_choice(choice_data: SelectChoice,
response: Response,
mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse:
check_visited = mongo.get_visited_cards(choice_data.user_id)
if check_visited.error:
response.status_code = status.HTTP_404_NOT_FOUND
return check_visited
elif not check_visited.error and choice_data.card_id in check_visited.result.cards_visited:
response.status_code = status.HTTP_403_FORBIDDEN
return BaseResponse(result="Card already visited!", error=True)
else:
select_choice_result = mongo.select_choice(choice_data.card_id, choice_data.choice)
if select_choice_result.error:
response.status_code = status.HTTP_404_NOT_FOUND
return select_choice_result
else:
update_visited_result = mongo.update_visited_cards(choice_data.user_id, choice_data.card_id)
return BaseResponse(result="Select choice complite!")
@app.patch("/like_card", status_code=200)
async def like_card(like_data: ReactionCard,
response: Response,
mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse:
result = mongo.like_card(like_data.card_id, like_data.user_id)
if not result.error and result.result:
return BaseResponse(result="Added like to card")
else:
response.status_code = status.HTTP_404_NOT_FOUND
return result
@app.patch("/dislike_card", status_code=200)
async def dislike_card(dislike_data: ReactionCard,
response: Response,
mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse:
result = mongo.dislike_card(dislike_data.card_id, dislike_data.user_id)
if not result.error and result.result:
return BaseResponse(result="Added dislike to card")
else:
response.status_code = status.HTTP_404_NOT_FOUND
return result
@app.post("/comment", status_code=201)
async def comment(comment_info: AddCommentBody,
response: Response,
mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse:
if not moderate_text(comment_info.comment_text):
response.status_code = status.HTTP_400_BAD_REQUEST
return BaseResponse(result="Comment has not passed base moderation", error=True)
result = mongo.add_comment(comment_info.author_id, comment_info.card_id, comment_info.comment_text)
if result.error and result.result in ["User doesn't exist", "Card doesn't exist"]:
response.status_code = status.HTTP_404_NOT_FOUND
return result
elif result.error:
response.status_code = status.HTTP_400_BAD_REQUEST
return result
else:
return result
async def main():
config = uvicorn.Config("main:app", port=5000, log_level="debug")
server = uvicorn.Server(config)
await server.serve()
if __name__ == "__main__":
print("Hello World!")
asyncio.run(main())
+197 -28
View File
@@ -1,45 +1,214 @@
import os
import pymongo
from datetime import datetime
from dotenv import load_dotenv
from typing import Optional
from bson import ObjectId
from schemas.base_schemas import *
from schemas.api_schemas import *
class MongoWorker:
def __init__(self):
self.client = pymongo.MongoClient(host="127.0.0.1", port=27017)
load_dotenv()
self.client = pymongo.MongoClient(host = os.getenv('MONGO_HOST'),
port = int(os.getenv('MONGO_PORT')),
username = os.getenv('MONGO_USER'),
password = os.getenv('MONGO_PASS'))
self.db = self.client["data"]
self.users_data = self.db["users_data"]
self.game_data = self.db["game_data"]
self.users_data = self.db["users"]
self.visited_data = self.db["visited"]
self.counters = self.db["counters"]
self.game_data = self.db["cards"]
self.comments_data = self.db["comments"]
def get_mongodb_info(self) -> None:
print(self.client.list_database_names())
print(self.db.list_collection_names())
def find_user(self, user_id: int) -> dict:
result = self.users_data.find_one({"user_id": user_id})
return result
def check_user(self, user_id: int) -> bool:
if self.users_data.find_one({"user_id": user_id}):
return True
else:
return False
def add_user(self, user_id: int, username: str, first_name: str, last_name: str, photo_path: str) -> ObjectId:
def add_user(self, user_id: int, username: str, first_name: str, last_name: str, photo_url: str) -> User:
new_user = User(user_id=user_id,
username=username,
first_name=first_name,
last_name=last_name,
photo_url=photo_url,
registration_date=datetime.now().isoformat())
try:
res = self.users_data.insert_one({"user_id": user_id,
"username": username,
"first_name": first_name,
"last_name": last_name,
"photo_path": photo_path,
"activity": 0,
"liked_post_ids": [],
"disliked_post_ids": [],
"comments_ids": [],
"registration_date": datetime.now().isoformat()
})
return res.inserted_id
self.users_data.insert_one(new_user.model_dump())
return new_user
except Exception as exception:
return new_user
def get_user(self, user_id: int) -> User:
return User.model_validate(self.users_data.find_one({"user_id": user_id}))
def get_and_update_counter(self, counter_name: str) -> int:
counter = self.counters.find_one_and_update(
{"counter_name": counter_name},
{"$inc": {"counter": 1}},
upsert=True,
return_document=True)
return counter["counter"]
def get_visited_cards(self, user_id: int) -> BaseResponse:
document = self.visited_data.find_one({"user_id": user_id})
if not document:
check_user = self.check_user(user_id)
if check_user:
return BaseResponse(result=Visited(user_id=user_id,
cards_visited=set()))
else:
return BaseResponse(result="User doesn't exist", error=True)
else:
return BaseResponse(result=Visited.model_validate(document))
def filter_cards(self, random_cards: list[Card], cards_visited: set) -> tuple[list[Card], list[int]]:
filtered_cards = [card for card in random_cards if card.card_id not in cards_visited]
filtered_cards_id = [filtered_card.card_id for filtered_card in filtered_cards]
return filtered_cards, filtered_cards_id
def update_visited_cards(self, user_id: int, visited_card_id: int) -> Visited:
update_visited = self.visited_data.find_one_and_update({"user_id": user_id},
{"$addToSet": {"cards_visited": visited_card_id}},
upsert=True,
return_document=True)
return Visited.model_validate(update_visited)
def add_card_by_api(self, choice_A: str, choice_B: str, author_id: int) -> Card:
new_card = Card(card_id=self.get_and_update_counter(counter_name="card"),
choice_A=choice_A,
choice_B=choice_B,
author_id=author_id,
creation_date=datetime.now().isoformat())
try:
self.game_data.insert_one(new_card.model_dump())
return new_card
except Exception as exception:
print(exception)
return new_card
def add_card_by_base_model(self, new_card: Card) -> Optional[Card]:
new_card.card_id = self.get_and_update_counter(counter_name="card")
try:
self.game_data.insert_one(new_card.model_dump())
return new_card
except Exception as exception:
print(exception)
return new_card
def get_card(self, card_id: int) -> Optional[Card]:
document = self.game_data.find_one({"card_id": card_id})
if document:
return Card.model_validate(document)
else:
return None
def get_random_cards(self, amount: int, active_status: bool) -> Optional[list[Card]]:
pipeline = [{"$match": {"active_status": active_status}},
{"$sample": {"size": amount}}]
raw_items = list(self.game_data.aggregate(pipeline))
if __name__ == "__main__":
mongo = MongoWorker()
mongo.get_mongodb_info()
print(mongo.add_user(123, "VolochayIgor", "Igor", "Volochay", "path/to/img"))
print(mongo.find_user(123))
if raw_items:
validated_items = [Card.model_validate(item) for item in raw_items]
return validated_items
else:
return None
def select_choice(self, card_id: int, choice: str) -> BaseResponse:
if choice == "A":
count_choice = "count_choice_A"
elif choice == "B":
count_choice = "count_choice_B"
else:
return BaseResponse(result="Wrong choice", error=True)
result = self.game_data.find_one_and_update({"card_id": card_id},
{"$inc": {"count_total": 1, count_choice: 1}})
if not result:
return BaseResponse(result="Card doesn't exist", error=True)
else:
return BaseResponse(result=result, error=False)
def check_user_reactions(self, user_id: int, card_id: int) -> BaseResponse:
user_info: User = self.get_user(user_id)
liked_card_ids: list = user_info.liked_card_ids
disliked_card_ids: list = user_info.disliked_card_ids
if card_id in liked_card_ids:
return BaseResponse(result="Card already liked", error=True)
elif card_id in disliked_card_ids:
return BaseResponse(result="Card already disliked", error=True)
else:
return BaseResponse(result="No reactions", error=False)
def like_card(self, card_id: int, user_id: int) -> BaseResponse:
if self.check_user(user_id):
user_reaction = self.check_user_reactions(user_id, card_id)
if user_reaction.error:
return user_reaction
update_card_info = self.game_data.find_one_and_update({"card_id": card_id},
{"$inc": {"count_likes": 1}})
if not update_card_info:
return BaseResponse(result="Card doesn't exist", error=True)
add_card_to_user = self.users_data.update_one({'user_id': user_id},
{'$push': {'liked_card_ids': card_id}})
if not add_card_to_user:
return BaseResponse(result="User doesn't exist", error=True)
else:
return BaseResponse(result=True, error=False)
else:
return BaseResponse(result="User doesn't exist", error=True)
def dislike_card(self, card_id: int, user_id: int) -> BaseResponse:
if self.check_user(user_id):
user_reaction = self.check_user_reactions(user_id, card_id)
if user_reaction.error:
return user_reaction
update_card_info = self.game_data.find_one_and_update({"card_id": card_id},
{"$inc": {"count_dislikes": 1}})
if not update_card_info:
return BaseResponse(result="Card doesn't exist", error=True)
add_card_to_user = self.users_data.update_one({'user_id': user_id},
{'$push': {'disliked_card_ids': card_id}})
if not add_card_to_user:
return BaseResponse(result="User doesn't exist", error=True)
else:
return BaseResponse(result=True, error=False)
else:
return BaseResponse(result="User doesn't exist", error=True)
def add_comment(self, user_id: int, card_id: int, comment_text: str) -> BaseResponse:
if self.check_user(user_id):
if self.get_card(card_id):
new_comment = Comment(comment_id=self.get_and_update_counter(counter_name="comment"),
author_id=user_id,
card_id=card_id,
commet_text=comment_text,
creation_date=datetime.now().isoformat())
result = self.comments_data.insert_one(new_comment.model_dump())
if result:
update_user_comments = self.users_data.find_one_and_update({"user_id": user_id},
{"$addToSet": {"comments_ids": new_comment.comment_id}})
if update_user_comments:
return BaseResponse(result=new_comment)
else:
return BaseResponse(result="Difficulty adding comment_id to user", error=True)
else:
return BaseResponse(result="Add comment error", error=True)
else:
return BaseResponse(result="Card doesn't exist", error=True)
else:
return BaseResponse(result="User doesn't exist", error=True)
+4
View File
@@ -0,0 +1,4 @@
fastapi==0.115.7
pymongo==4.10.1
python-dotenv==1.0.1
uvicorn==0.34.0
View File
+38
View File
@@ -0,0 +1,38 @@
import typing
from pydantic import BaseModel, NonNegativeInt
class BaseResponse(BaseModel):
result: typing.Any
error: bool = False
class AddUserBody(BaseModel):
user_id: NonNegativeInt
username: str
first_name: str
last_name: str
photo_url: str
class AddCardBody(BaseModel):
choice_A: str
choice_B: str
author_id: NonNegativeInt
class SelectChoice(BaseModel):
user_id: NonNegativeInt
card_id: NonNegativeInt
choice: typing.Literal["A", "B"]
class ReactionCard(BaseModel):
user_id: NonNegativeInt
card_id: NonNegativeInt
class AddCommentBody(BaseModel):
author_id: NonNegativeInt
card_id: NonNegativeInt
comment_text: str
+48
View File
@@ -0,0 +1,48 @@
from pydantic import BaseModel
class User(BaseModel):
user_id: int
username: str
first_name: str
last_name: str
photo_url: str
activity: int = 0
liked_card_ids: list[int] = list()
disliked_card_ids: list[int] = list()
comments_ids: list[int] = list()
registration_date: str
class Visited(BaseModel):
user_id: int
cards_visited: set[int]
class Card(BaseModel):
card_id: int
choice_A: str
choice_B: str
count_choice_A: int = 0
count_choice_B: int = 0
count_total: int = 0
count_likes: int = 0
count_dislikes: int = 0
comments: list[int] = list()
author_id: int
creation_date: str
moderation_date: str = "Not moderated"
active_status: bool = False
class Comment(BaseModel):
comment_id: int
author_id: int
card_id: int
commet_text: str
creation_date: str
View File
+186
View File
@@ -0,0 +1,186 @@
import random
import asyncio
import pytest
from httpx import AsyncClient, ASGITransport
from main import app
from schemas.base_schemas import *
from schemas.api_schemas import *
EXIST_AUTHOR = random.randint(100000000, 1000000000)
NON_EXIST_CARD_ID = 1000
# ---------- /add_card ----------
@pytest.mark.asyncio
async def test_add_card_valid():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
payload = {
"choice_A": "Option A",
"choice_B": "Option B",
"author_id": EXIST_AUTHOR
}
response = await client.post("/add_card", json=payload)
print(f"\nINPUT: endpoint=/add_card | payload={payload}\nOUTPUT: status={response.status_code} | json={response.json()}")
assert response.status_code in (200, 201)
base_resp = BaseResponse.model_validate(response.json())
assert base_resp.error is False
card = Card.model_validate(base_resp.result)
assert card.choice_A == payload["choice_A"]
assert card.choice_B == payload["choice_B"]
assert card.author_id == payload["author_id"]
@pytest.mark.asyncio
async def test_add_card_missing_field():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
payload = {
#choice_A
"choice_B": "Option B",
"author_id": EXIST_AUTHOR
}
response = await client.post("/add_card", json=payload)
print(f"\nINPUT: endpoint=/add_card | payload (missing field)={payload}\nOUTPUT: status={response.status_code} | json={response.json()}")
assert response.status_code == 422
@pytest.mark.asyncio
async def test_add_card_wrong_type():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
payload = {
"choice_A": 123,
"choice_B": "Option B",
"author_id": EXIST_AUTHOR
}
response = await client.post("/add_card", json=payload)
print(f"\nINPUT: endpoint=/add_card | payload (wrong type)={payload}\nOUTPUT: status={response.status_code} | json={response.json()}")
assert response.status_code == 422
@pytest.mark.asyncio
async def test_add_card_empty_strings():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
payload = {
"choice_A": "",
"choice_B": "",
"author_id": EXIST_AUTHOR
}
response = await client.post("/add_card", json=payload)
print(f"\nINPUT: endpoint=/add_card | payload (empty strings)={payload}\nOUTPUT: status={response.status_code} | json={response.json()}")
assert response.status_code == 400
@pytest.mark.asyncio
async def test_add_card_long_strings():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
long_str = "A" * 5000 # long string
payload = {
"choice_A": long_str,
"choice_B": long_str,
"author_id": EXIST_AUTHOR
}
response = await client.post("/add_card", json=payload)
print(f"\nINPUT: endpoint=/add_card | payload with long strings (length={len(long_str)})\nOUTPUT: status={response.status_code} | json={response.json()}")
assert response.status_code == 400
@pytest.mark.asyncio
async def test_add_card_negative_author_id():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
payload = {
"choice_A": "Option A",
"choice_B": "Option B",
"author_id": -10 # Negative id
}
response = await client.post("/add_card", json=payload)
print(f"\nINPUT: endpoint=/add_card | payload (negative author_id)={payload}\nOUTPUT: status={response.status_code} | json={response.json()}")
assert response.status_code == 422
@pytest.mark.asyncio
async def test_add_card_malformed_json():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
malformed_json = '{"choice_A": "Option A", "choice_B": "Option B", "author_id": 123' # broken json
response = await client.post(
"/add_card",
data=malformed_json,
headers={"Content-Type": "application/json"}
)
print(f"\nINPUT: endpoint=/add_card | payload (malformed JSON)={malformed_json}\nOUTPUT: status={response.status_code} | json={response.json() if response.content else 'No JSON'}")
assert response.status_code == 422
@pytest.mark.asyncio
async def test_async_card_creation():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
tasks = []
num_cards = 8
for i in range(num_cards):
payload = {
"choice_A": f"Async Option A {i}",
"choice_B": f"Async Option B {i}",
"author_id": EXIST_AUTHOR
}
tasks.append(client.post("/add_card", json=payload))
responses = await asyncio.gather(*tasks)
card_ids = []
for idx, response in enumerate(responses):
print(f"\nAsync creation {idx}: status={response.status_code}, response={response.json()}")
assert response.status_code in (200, 201)
base_resp = BaseResponse.model_validate(response.json())
assert base_resp.error is False
card = Card.model_validate(base_resp.result)
card_ids.append(card.card_id)
assert card.choice_A == f"Async Option A {idx}"
assert card.choice_B == f"Async Option B {idx}"
assert card.author_id == EXIST_AUTHOR
assert len(set(card_ids)) == num_cards
# ---------- /get_card ----------
@pytest.mark.asyncio
async def test_get_card_valid():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
payload = {
"choice_A": "GetTest A",
"choice_B": "GetTest B",
"author_id": EXIST_AUTHOR
}
create_resp = await client.post("/add_card", json=payload)
base_create = BaseResponse.model_validate(create_resp.json())
card = Card.model_validate(base_create.result)
card_id = card.card_id
response = await client.get("/get_card", params={"card_id": card_id})
print(f"\nINPUT: endpoint=/get_card | params={{'card_id': {card_id}}}\nOUTPUT: status={response.status_code} | json={response.json()}")
assert response.status_code == 200
base_resp = BaseResponse.model_validate(response.json())
assert base_resp.error is False
card_from_get = Card.model_validate(base_resp.result)
assert card_from_get.card_id == card_id
@pytest.mark.asyncio
async def test_get_card_nonexistent():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
response = await client.get("/get_card", params={"card_id": NON_EXIST_CARD_ID})
print(f"\nINPUT: endpoint=/get_card | params={{'card_id': {NON_EXIST_CARD_ID}}}\nOUTPUT: status={response.status_code} | json={response.json()}")
assert response.status_code == 404
@pytest.mark.asyncio
async def test_get_card_missing_param():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
response = await client.get("/get_card")
print(f"\nINPUT: endpoint=/get_card (missing card_id param)\nOUTPUT: status={response.status_code} | json={response.json() if response.content else 'No content'}")
assert response.status_code == 422
@pytest.mark.asyncio
async def test_get_card_wrong_type():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
response = await client.get("/get_card", params={"card_id": "abc"})
print(f"\nINPUT: endpoint=/get_card | params={{'card_id': 'abc'}}\nOUTPUT: status={response.status_code} | json={response.json()}")
assert response.status_code == 422
@pytest.mark.asyncio
async def test_get_card_negative():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
response = await client.get("/get_card", params={"card_id": -10})
print(f"\nINPUT: endpoint=/get_card | params={{'card_id': -10}}\nOUTPUT: status={response.status_code} | json={response.json()}")
assert response.status_code == 422
+154
View File
@@ -0,0 +1,154 @@
import random
import pytest
from main import app
from schemas.api_schemas import *
from schemas.base_schemas import *
from httpx import AsyncClient, ASGITransport
EXIST_USER = random.randint(100000000, 1000000000)
NON_EXIST_USER = random.randint(100000000, 1000000000)
# TEST ADD USERS UTILS #
@pytest.mark.asyncio
async def test_add_user_non_full_data():
async with AsyncClient(transport=ASGITransport(app=app),
base_url='http://test') as client:
end_point = "/add_user"
data = {
"user_id": EXIST_USER,
"username": "TestUsername",
}
raw_response = await client.post(url=end_point,json=data)
print(f"\nINPUT: endpiont={end_point} | params={data}\nOUTPUT: status={raw_response.status_code} | json={raw_response.json()}")
assert raw_response.status_code == 422
@pytest.mark.asyncio
async def test_add_user_negative_int_id():
async with AsyncClient(transport=ASGITransport(app=app),
base_url='http://test') as client:
end_point = "/add_user"
data = {
"user_id": -1,
"username": "TestUsername",
"first_name": "FName",
"last_name": "LName",
"photo_url": "http://test.test/photo.jpg"
}
raw_response = await client.post(url=end_point,json=data)
print(f"\nINPUT: endpiont={end_point} | params={data}\nOUTPUT: status={raw_response.status_code} | json={raw_response.json()}")
assert raw_response.status_code == 422
@pytest.mark.asyncio
async def test_add_new_user():
async with AsyncClient(transport=ASGITransport(app=app),
base_url='http://test') as client:
end_point = "/add_user"
data = {
"user_id": EXIST_USER,
"username": "TestUsername",
"first_name": "FName",
"last_name": "LName",
"photo_url": "http://test.test/photo.jpg"
}
raw_response = await client.post(url=end_point,json=data)
print(f"\nINPUT: endpiont={end_point} | params={data}\nOUTPUT: status={raw_response.status_code} | json={raw_response.json()}")
assert raw_response.status_code == 201
response = BaseResponse.model_validate(raw_response.json())
assert response.error == False
assert User.model_validate(response.result)
@pytest.mark.asyncio
async def test_add_already_exist_user():
async with AsyncClient(transport=ASGITransport(app=app),
base_url='http://test') as client:
end_point = "/add_user"
data = {
"user_id": EXIST_USER,
"username": "TestUsername",
"first_name": "FName",
"last_name": "LName",
"photo_url": "http://test.test/photo.jpg"
}
raw_response = await client.post(url=end_point,json=data)
print(f"\nINPUT: endpiont={end_point} | params={data}\nOUTPUT: status={raw_response.status_code} | json={raw_response.json()}")
assert raw_response.status_code == 409
response = BaseResponse.model_validate(raw_response.json())
assert response.error == True
assert response.result == "User already exist"
# TEST CHECK USERS UTILS #
@pytest.mark.asyncio
async def test_check_non_exist_user():
async with AsyncClient(transport=ASGITransport(app=app),
base_url='http://test') as client:
end_point = "/check_user"
params = {"user_id": NON_EXIST_USER}
raw_response = await client.get(url=end_point, params=params)
print(f"\nINPUT: endpiont={end_point} | params={params}\nOUTPUT: status={raw_response.status_code} | json={raw_response.json()}")
assert raw_response.status_code == 200
response = BaseResponse.model_validate(raw_response.json())
assert response.error == False
assert response.result == False
@pytest.mark.asyncio
async def test_check_exist_user():
async with AsyncClient(transport=ASGITransport(app=app),
base_url='http://test') as client:
end_point = "/check_user"
params = {"user_id": EXIST_USER}
raw_response = await client.get(url=end_point, params=params)
print(f"\nINPUT: endpiont={end_point} | params={params}\nOUTPUT: status={raw_response.status_code} | json={raw_response.json()}")
assert raw_response.status_code == 200
response = BaseResponse.model_validate(raw_response.json())
assert response.error == False
assert response.result == True
# TEST GET USERS UTILS #
@pytest.mark.asyncio
async def test_get_non_exist_user():
async with AsyncClient(transport=ASGITransport(app=app),
base_url='http://test') as client:
end_point = "/get_user"
params = {"user_id": NON_EXIST_USER}
raw_response = await client.get(url=end_point, params=params)
print(f"\nINPUT: endpiont={end_point} | params={params}\nOUTPUT: status={raw_response.status_code} | json={raw_response.json()}")
assert raw_response.status_code == 404
response = BaseResponse.model_validate(raw_response.json())
assert response.error == True
assert response.result == "User doesn't exist"
@pytest.mark.asyncio
async def test_get_exist_user():
async with AsyncClient(transport=ASGITransport(app=app),
base_url='http://test') as client:
end_point = "/get_user"
params = {"user_id": EXIST_USER}
raw_response = await client.get(url=end_point, params=params)
print(f"\nINPUT: endpiont={end_point} | params={params}\nOUTPUT: status={raw_response.status_code} | json={raw_response.json()}")
assert raw_response.status_code == 200
response = BaseResponse.model_validate(raw_response.json())
assert response.error == False
assert User.model_validate(response.result)
+92
View File
@@ -0,0 +1,92 @@
import random
import asyncio
import pytest
from httpx import AsyncClient, ASGITransport
from main import app
from schemas.base_schemas import *
from schemas.api_schemas import *
EXIST_USER = random.randint(100000000, 1000000000)
EXIST_AUTHOR = random.randint(100000000, 1000000000)
NO_ACTIVE_CARDS_STATUS = False
ACTIVE_CARDS_LESS_THAN_TEN = False
# ------------- /add_user ---------------
@pytest.mark.asyncio
async def test_add_new_user():
async with AsyncClient(transport=ASGITransport(app=app),
base_url='http://test') as client:
end_point = "/add_user"
data = {
"user_id": EXIST_USER,
"username": "TestUsername",
"first_name": "FName",
"last_name": "LName",
"photo_url": "http://test.test/photo.jpg"
}
raw_response = await client.post(url=end_point,json=data)
print(f"\nINPUT: endpiont={end_point} | params={data}\nOUTPUT: status={raw_response.status_code} | json={raw_response.json()}")
assert raw_response.status_code == 201
response = BaseResponse.model_validate(raw_response.json())
assert response.error == False
assert User.model_validate(response.result)
# ---------- /get_random_cards ----------
@pytest.mark.asyncio
async def test_get_random_cards_valid():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
params = {"user_id": EXIST_USER}
response = await client.get("/get_random_cards", params=params)
print(f"\nINPUT: endpoint=/get_random_cards\nOUTPUT: status={response.status_code} | json={response.json()}")
if response.status_code == 404 and BaseResponse.model_validate(response.json()).result == "No active cards":
global NO_ACTIVE_CARDS_STATUS
NO_ACTIVE_CARDS_STATUS = True
pytest.skip(reason="No active cards in MongoDB")
else:
assert response.status_code == 200
result = response.json().get("result")
assert isinstance(result, list)
if len(result) == 10:
for card in result:
assert "choice_A" in card
assert "choice_B" in card
assert "author_id" in card
assert "card_id" in card
else:
global ACTIVE_CARDS_LESS_THAN_TEN
ACTIVE_CARDS_LESS_THAN_TEN = True
pytest.skip(reason="The number of active cards is less than 10 in MongoDB")
@pytest.mark.asyncio
async def test_get_random_cards_randomness():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
params = {"user_id": EXIST_USER}
response1 = await client.get("/get_random_cards", params=params)
response2 = await client.get("/get_random_cards", params=params)
result1 = response1.json().get("result")
result2 = response2.json().get("result")
print(f"\nINPUT: endpoint=/get_random_cards (двойной вызов)\nOUTPUT 1: {result1}\nOUTPUT 2: {result2}")
if len(result1) == 10 and len(result2) == 10:
assert result1 != result2
@pytest.mark.asyncio
async def test_get_random_cards_parallel_requests():
if NO_ACTIVE_CARDS_STATUS:
pytest.skip(reason="No active cards in MongoDB")
elif ACTIVE_CARDS_LESS_THAN_TEN:
pytest.skip(reason="The number of active cards is less than 10 in MongoDB")
else:
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
params = {"user_id": EXIST_USER}
tasks = [client.get("/get_random_cards", params=params) for _ in range(5)]
responses = await asyncio.gather(*tasks)
for resp in responses:
print(f"\nParallel call: status={resp.status_code} | json={resp.json()}")
assert resp.status_code == 200
result = resp.json().get("result")
assert isinstance(result, list)
assert len(result) == 10
View File
+72
View File
@@ -0,0 +1,72 @@
import argparse
import sys
import json
from datetime import datetime
sys.path.append('..')
from mongo_worker import MongoWorker
from schemas.base_schemas import Card
def create_cards(num_cards=10, author_id=1):
cards_list = []
for card_id in range(1, num_cards + 1):
print(f"Creating Card {card_id}")
card = Card(
card_id=card_id,
choice_A=input("Choice A: "),
choice_B=input("Choice B: "),
author_id=author_id,
creation_date=datetime.now().isoformat(),
moderation_date=datetime.now().isoformat(),
active_status=True
)
cards_list.append(card)
return cards_list
def read_json(json_file):
try:
with open(json_file, 'r', encoding='utf-8') as f:
data = json.load(f)
return [Card(**card) for card in data]
except Exception as e:
print(f"Error reading JSON file: {e}")
return []
def write_json(cards_list, json_file):
try:
with open(json_file, 'w', encoding='utf-8') as f:
json.dump([card.model_dump() for card in cards_list], f, ensure_ascii=False, indent=4)
print(f"Successfully saved {len(cards_list)} cards to {json_file}")
except Exception as e:
print(f"Error writing to JSON file: {e}")
def add_cards_to_mongodb(cards_list):
mongo = MongoWorker()
for card in cards_list:
mongo.add_card_by_base_model(card)
print(f"Successfully added {len(cards_list)} cards to MongoDB")
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument('-a', '--action', type=int, required=True, choices=[0, 1, 2],
help="0 - Manual input & save to MongoDB, 1 - Manual input & save to JSON, 2 - Read JSON & save to MongoDB")
parser.add_argument('-f', '--file', type=str, default="cards.json", help="JSON file name for reading/writing")
parser.add_argument('-n', '--num', type=int, default=10, help="Number of cards to create")
parser.add_argument('-u', '--user', type=int, default=1, help="Author ID")
args = parser.parse_args()
if args.action == 0:
cards = create_cards(args.num, args.user)
add_cards_to_mongodb(cards)
elif args.action == 1:
cards = create_cards(args.num, args.user)
write_json(cards, args.file)
elif args.action == 2:
cards = read_json(args.file)
if cards:
add_cards_to_mongodb(cards)
else:
print("No valid cards found in JSON file.")
+24
View File
@@ -0,0 +1,24 @@
import re
from tools.data.dirty_words import dirty_words_set
def is_not_empty(text: str) -> bool:
"""Checks that the text is not empty."""
return bool(text.strip())
def has_no_links(text: str) -> bool:
"""Checks that there are no links in the text."""
url_pattern = re.compile(r'https?://\S+|www\.\S+')
return not bool(url_pattern.search(text))
def has_no_dirty_words(text: str) -> bool:
"""Checks that there are no forbidden words in the text."""
words = set(re.findall(r'\w+', text.lower()))
return not bool(words & dirty_words_set)
def text_lenght(text: str) -> bool:
"""Check max text lenght"""
return len(text) <= 150 # TODO: update in future
def moderate_text(text: str) -> bool:
return is_not_empty(text) and has_no_links(text) and has_no_dirty_words(text) and text_lenght(text)
+152
View File
@@ -0,0 +1,152 @@
[
{
"card_id": 1,
"choice_A": "Котики",
"choice_B": "Собачки",
"count_choice_A": 0,
"count_choice_B": 0,
"count_total": 0,
"count_likes": 0,
"count_dislikes": 0,
"comments": [],
"author_id": 1,
"creation_date": "2025-03-04T16:56:05.290110",
"moderation_date": "2025-03-04T16:56:05.290143",
"active_status": true
},
{
"card_id": 2,
"choice_A": "Чай",
"choice_B": "Кофе",
"count_choice_A": 0,
"count_choice_B": 0,
"count_total": 0,
"count_likes": 0,
"count_dislikes": 0,
"comments": [],
"author_id": 1,
"creation_date": "2025-03-04T16:56:11.108762",
"moderation_date": "2025-03-04T16:56:11.108793",
"active_status": true
},
{
"card_id": 3,
"choice_A": "Отпуск в горах",
"choice_B": "Отпуск на море",
"count_choice_A": 0,
"count_choice_B": 0,
"count_total": 0,
"count_likes": 0,
"count_dislikes": 0,
"comments": [],
"author_id": 1,
"creation_date": "2025-03-04T16:56:25.062542",
"moderation_date": "2025-03-04T16:56:25.062586",
"active_status": true
},
{
"card_id": 4,
"choice_A": "День",
"choice_B": "Ночь",
"count_choice_A": 0,
"count_choice_B": 0,
"count_total": 0,
"count_likes": 0,
"count_dislikes": 0,
"comments": [],
"author_id": 1,
"creation_date": "2025-03-04T16:56:34.148692",
"moderation_date": "2025-03-04T16:56:34.148725",
"active_status": true
},
{
"card_id": 5,
"choice_A": "Работать в офисе",
"choice_B": "Работать удалённо",
"count_choice_A": 0,
"count_choice_B": 0,
"count_total": 0,
"count_likes": 0,
"count_dislikes": 0,
"comments": [],
"author_id": 1,
"creation_date": "2025-03-04T16:57:00.723472",
"moderation_date": "2025-03-04T16:57:00.723518",
"active_status": true
},
{
"card_id": 6,
"choice_A": "Заниматься спортом",
"choice_B": "Играть в видеоигры",
"count_choice_A": 0,
"count_choice_B": 0,
"count_total": 0,
"count_likes": 0,
"count_dislikes": 0,
"comments": [],
"author_id": 1,
"creation_date": "2025-03-04T16:57:31.524721",
"moderation_date": "2025-03-04T16:57:31.524754",
"active_status": true
},
{
"card_id": 7,
"choice_A": "Гулять",
"choice_B": "Сидеть дома",
"count_choice_A": 0,
"count_choice_B": 0,
"count_total": 0,
"count_likes": 0,
"count_dislikes": 0,
"comments": [],
"author_id": 1,
"creation_date": "2025-03-04T16:57:52.022380",
"moderation_date": "2025-03-04T16:57:52.022413",
"active_status": true
},
{
"card_id": 8,
"choice_A": "Посмотреть фильм",
"choice_B": "Почитать книжку",
"count_choice_A": 0,
"count_choice_B": 0,
"count_total": 0,
"count_likes": 0,
"count_dislikes": 0,
"comments": [],
"author_id": 1,
"creation_date": "2025-03-04T16:58:15.032749",
"moderation_date": "2025-03-04T16:58:15.032762",
"active_status": true
},
{
"card_id": 9,
"choice_A": "Зима",
"choice_B": "Лето",
"count_choice_A": 0,
"count_choice_B": 0,
"count_total": 0,
"count_likes": 0,
"count_dislikes": 0,
"comments": [],
"author_id": 1,
"creation_date": "2025-03-04T16:58:24.800416",
"moderation_date": "2025-03-04T16:58:24.800450",
"active_status": true
},
{
"card_id": 10,
"choice_A": "Лучший друг/подруга",
"choice_B": "Любимый человек",
"count_choice_A": 0,
"count_choice_B": 0,
"count_total": 0,
"count_likes": 0,
"count_dislikes": 0,
"comments": [],
"author_id": 1,
"creation_date": "2025-03-04T16:59:05.265795",
"moderation_date": "2025-03-04T16:59:05.265829",
"active_status": true
}
]
File diff suppressed because one or more lines are too long
+34
View File
@@ -0,0 +1,34 @@
version: '3.8'
services:
mongodb:
image: mongodb/mongodb-community-server
container_name: tort-mongodb
restart: always
network_mode: bridge
environment:
MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER}
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASS}
ports:
- "127.0.0.1:${MONGO_PORT}:27017"
healthcheck:
test: ["CMD", "mongosh", "--username", "${MONGO_USER}", "--password", "${MONGO_PASS}", "--eval", "db.runCommand({ ping: 1 })"]
interval: 10s
timeout: 5s
retries: 2
backend:
build:
context: ./app
image: tort-backend:latest
depends_on:
mongodb:
condition: service_healthy
container_name: tort-backend
network_mode: "host"
environment:
MONGO_HOST: ${MONGO_HOST}
MONGO_PORT: ${MONGO_PORT}
MONGO_USER: ${MONGO_USER}
MONGO_PASS: ${MONGO_PASS}
-2
View File
@@ -1,2 +0,0 @@
fastapi==0.115.7
pymongo==4.10.1