Skip to content

Commit

Permalink
feat(backend): add foundation
Browse files Browse the repository at this point in the history
  • Loading branch information
emptybutton committed Jul 3, 2024
1 parent 808a4a5 commit 39e13c0
Show file tree
Hide file tree
Showing 25 changed files with 566 additions and 1 deletion.
2 changes: 1 addition & 1 deletion backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,4 @@ RUN poetry install --no-root
COPY . .

ENTRYPOINT ["poetry", "run"]
CMD ["uvicorn", "src.presentation.app:app"]
CMD ["python", "src"]
30 changes: 30 additions & 0 deletions backend/src/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import asyncio
from contextlib import asynccontextmanager

from faststream import FastStream, ContextRepo

from periphery import mongo
from periphery.brokers import kafka_broker
from presentation.event_routes import router


@asynccontextmanager
async def faststream_lifespan(context: ContextRepo) -> None:
yield
await mongo.client.close()


async def start_faststream() -> None:
faststream = FastStream(kafka_broker, lifespan=faststream_lifespan)
kafka_broker.include_router(router)

await faststream.run()


async def main() -> None:
await start_faststream()


if __name__ == "__main__":
asyncio.run(main())

5 changes: 5 additions & 0 deletions backend/src/infrastructure/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from infrastructure import (
mq_gateway as mq_gateway,
repos as repos,
yandex_lavka_gateway as yandex_lavka_gateway,
)
16 changes: 16 additions & 0 deletions backend/src/infrastructure/mq_gateway.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from pydantic import BaseModel

from periphery.brokers import redis_broker


class _MultiplicationOccurred(BaseModel):
a: int
b: int
result: int


async def push_multiplication_occurred(a: int, b: int, result: int) -> None:
event = _MultiplicationOccurred(a=a, b=b, result=result)

async with redis_broker:
await redis_broker.publish(event, "multiplication_occurred")
5 changes: 5 additions & 0 deletions backend/src/infrastructure/repos/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from infrastructure.repos import (
users as users,
products as products,
categories as categories,
)
12 changes: 12 additions & 0 deletions backend/src/infrastructure/repos/categories.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from model.domain.entities import Category
from periphery.mongo import db


async def add(category: Category) -> None:
await db.categories.insert_one({
"id": category.id,
"user_id": category.user_id,
"name": category.name,
"product_ids": category.product_ids,
"subcategory_ids": category.subcategory_ids,
})
25 changes: 25 additions & 0 deletions backend/src/infrastructure/repos/products.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from typing import Iterable

from model.domain.entities import Product
from periphery.mongo import db


async def add(product: Product) -> None:
await db.products.insert_one(_document_of(product))


async def extend_by(products: Iterable[Product]) -> None:
await db.products.insert_many(map(_document_of, products))


def _document_of(product: Product) -> dict:
return {
"id": product.id,
"name": product.name,
"price_rubles": product.price.rubles,
"page_url": product.page.url,
"quantity": {
"total": product.quantity.total,
"unit_code": product.quantity.unit.value,
},
}
32 changes: 32 additions & 0 deletions backend/src/infrastructure/repos/users.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from typing import Optional

from model.domain.entities import User
from model.domain.vos import Address, City
from periphery.mongo import db


async def add(user: User) -> None:
await db.users.insert_one({
"id": user.id,
"telegram_chat_id": user.telegram_chat_id,
"city": user.address.city.name,
})


async def get_by_telegram_chat_id(telegram_chat_id: int) -> Optional[User]:
user_record = await db.users.find_one(
{"telegram_chat_id": telegram_chat_id},
{"id": 1, "city": 1, "_id": 0},
)

if user_record is None:
return None

id_ = user_record.get("id")
city_name = user_record.get("city")

if None in [id_, city_name]:
return None

address = Address(city=City(name=city_name))
return User(id=id_, telegram_chat_id=telegram_chat_id, address=address)
78 changes: 78 additions & 0 deletions backend/src/infrastructure/yandex_lavka_gateway.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
from typing import Iterable

import aiohttp

from model.domain.entities import User, Product
from model.domain.vos import Page, Price, QuantityUnit, Quantity


async def searth_products_for(
user: User,
*,
query: str,
latitude: float,
longitude: float,
products_limit: int = 32,
subcategories_limit: int = 0,
) -> Iterable[Product]:
url = "https://lavka.yandex.ru/api/v1/providers/search/v2/lavka"
headers = {
"accept": "application/json",
"accept-language": "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7",
"content-type": "application/json",
"sec-ch-ua": "\"Not/A)Brand\";v=\"8\", \"Chromium\";v=\"126\", \"Google Chrome\";v=\"126\"",
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": "\"Windows\"",
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-origin",
"x-csrf-token": "3248921c6150c0e59d4f10b94d2b4ac3d9222fb3:1719991575",
"x-lavka-web-city": "213",
"x-lavka-web-locale": "ru-RU"
}
body = {
"additionalData": {
"city": user.city.name,
},
"text": query,
"position": {"location": [latitude, longitude]},
"productsLimit": products_limit,
"subcategories_limit": subcategories_limit,
"depotType": "regular"
}

async with aiohttp.ClientSession() as session:
async with session.post(url, json=body, headers=headers) as response:
if response.status != 200:
return None

raw_product_by_index = await response.json().get("products")

if raw_product_by_index is None:
return None

raw_products = raw_product_by_index.values()

for raw_product in raw_products:
rubels = raw_product.get("numberDiscountPrice")
if rubels is None:
rubels = raw_product["numberPrice"]

if "г" in raw_product["amount"] or "кг" in raw_product["amount"]:
quantity_unit = QuantityUnit.kilogram
elif "мл" in raw_product["amount"] or "л" in raw_product["amount"]:
quantity_unit = QuantityUnit.liter
else:
quantity_unit = None

total = int(raw_product["amount"].split("&")[0])

url = f"https://lavka.yandex.ru/213/good/{raw_product["deepLink"]}"
name = raw_product["title"]

yield Product(
name=name,
page=Page(url=url),
price=Price(rubels=rubels),
quantity=Quantity(total=total, unit=quantity_unit)
)
4 changes: 4 additions & 0 deletions backend/src/model/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from model import (
domain as domain,
cases as cases,
)
4 changes: 4 additions & 0 deletions backend/src/model/cases/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from cases import (
add_category_by_query as add_category_by_query,
registration as registration,
)
49 changes: 49 additions & 0 deletions backend/src/model/cases/add_category_by_query.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from dataclasses import dataclass

from infrastructure import yandex_lavka_gateway
from infrastructure.repos import users, products, categories
from model.domain.entities import Category, Product, User


@dataclass(frozen=True, kw_only=True)
class OutputDTO:
user: User
category: Category
products: tuple[Product, ...]


class NoUserError(Exception): ...


async def perform(
query: str,
latitude: float,
longitude: float,
telegram_chat_id: int,
) -> OutputDTO:
user = await users.get_by_telegram_chat_id(telegram_chat_id)

if user is None:
raise NoUserError

found_products = tuple(await yandex_lavka_gateway.searth_products_for(
user,
query=query,
latitude=latitude,
longitude=longitude,
))

await products.extend_by(found_products)

product_ids = [product.id for product in found_products]

category = Category(
user_id=user.id,
name=query,
product_ids=product_ids,
subcategory_ids=list(),
)

await categories.add(category)

return OutputDTO(user=user, category=category, products=products)
17 changes: 17 additions & 0 deletions backend/src/model/cases/register_user.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from infrastructure.repos import users
from model.domain.entities import User
from model.domain.vos import Address, City


async def perform(telegram_chat_id: int, city_name: str) -> User:
user = await users.get_by_telegram_chat_id(telegram_chat_id)

if user is not None:
return user

address = Address(city=City(name=city_name))
user = User(telegram_chat_id=telegram_chat_id, address=address)

await users.add(user)

return user
5 changes: 5 additions & 0 deletions backend/src/model/domain/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from model.domain import (
entities as entities,
errors as errors,
vos as vos,
)
34 changes: 34 additions & 0 deletions backend/src/model/domain/entities.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from dataclasses import dataclass, field
from uuid import UUID, uuid4

from model.domain.errors import FreeProductError
from model.domain.vos import Price, Page, Quantity, Address


@dataclass(kw_only=True)
class User:
id: UUID = field(default_factory=uuid4)
telegram_chat_id: int
address: Address


@dataclass(kw_only=True)
class Category:
id: UUID = field(default_factory=uuid4)
user_id: UUID
name: str
product_ids: list[UUID]
subcategory_ids: list[UUID]


@dataclass(kw_only=True)
class Product:
id: UUID = field(default_factory=uuid4)
name: str
price: Price
quantity: Quantity
page: Page

def __post_init__(self) -> None:
if self.price.rubles == 0:
raise FreeProductError
10 changes: 10 additions & 0 deletions backend/src/model/domain/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
class ValidationError(Exception): ...


class FreeProductError(ValidationError): ...


class NegativePriceError(ValidationError): ...


class NegativeQuantityError(ValidationError): ...
44 changes: 44 additions & 0 deletions backend/src/model/domain/vos.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from dataclasses import dataclass, field
from enum import Enum, auto
from typing import Optional

from model.domain.errors import NegativePriceError, NegativeQuantityError


@dataclass(frozen=True, kw_only=True)
class Price:
rubles: int

def __post_init__(self) -> None:
if self.rubles < 0:
raise NegativePriceError


class QuantityUnit(Enum):
kilogram = auto()
liter = auto()


@dataclass(frozen=True, kw_only=True)
class Quantity:
total: int
unit: Optional[QuantityUnit] = field(default=None)

def __post_init__(self) -> None:
if self.rubles < 0:
raise NegativeQuantityError


@dataclass(frozen=True, kw_only=True)
class Page:
url: str


@dataclass(frozen=True, kw_only=True)
class City:
name: str


@dataclass(frozen=True, kw_only=True)
class Address:
city: City
5 changes: 5 additions & 0 deletions backend/src/periphery/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from periphery import (
mongo as mongo,
envs as envs,
brokers as brokers,
)
Loading

0 comments on commit 39e13c0

Please sign in to comment.