From ee39a3d83bf34173a3790e714263a433903234de Mon Sep 17 00:00:00 2001 From: Damir Date: Tue, 12 May 2026 13:16:14 +0300 Subject: [PATCH] feat: implement backend API clients for multi-marketplace integration and frontend service layer --- .gitignore | 1 + README.md | 32 +++- backend/ae_api/__init__.py | 15 ++ backend/ae_api/client.py | 76 ++++++++ backend/ae_api/exceptions.py | 19 ++ backend/ae_api/services/products.py | 30 ++++ backend/ae_api/services/transactions.py | 19 ++ backend/app.py | 2 + backend/avito_api/__init__.py | 15 ++ backend/avito_api/client.py | 97 ++++++++++ backend/avito_api/exceptions.py | 19 ++ backend/avito_api/services/items.py | 15 ++ backend/avito_api/services/messenger.py | 29 +++ backend/flowwow_api/__init__.py | 15 ++ backend/flowwow_api/client.py | 72 ++++++++ backend/flowwow_api/exceptions.py | 19 ++ backend/flowwow_api/services/orders.py | 29 +++ backend/flowwow_api/services/products.py | 30 ++++ backend/lamoda_api/__init__.py | 15 ++ backend/lamoda_api/client.py | 72 ++++++++ backend/lamoda_api/exceptions.py | 19 ++ backend/lamoda_api/services/orders.py | 22 +++ backend/lamoda_api/services/products.py | 31 ++++ backend/mega_api/__init__.py | 15 ++ backend/mega_api/client.py | 79 +++++++++ backend/mega_api/exceptions.py | 19 ++ backend/mega_api/services/assortment.py | 32 ++++ backend/mega_api/services/orders.py | 55 ++++++ backend/mm_api/__init__.py | 15 ++ backend/mm_api/client.py | 67 +++++++ backend/mm_api/exceptions.py | 19 ++ backend/mm_api/services/orders.py | 52 ++++++ backend/mm_api/services/shipments.py | 30 ++++ backend/ozon_api/__init__.py | 15 ++ backend/ozon_api/client.py | 68 +++++++ backend/ozon_api/exceptions.py | 15 ++ backend/ozon_api/services/fbs.py | 33 ++++ backend/ozon_api/services/products.py | 42 +++++ backend/requirements.txt | 1 + backend/routes/cases.py | 35 ++-- backend/routes/credentials.py | 59 +++++++ backend/test_ae_api.py | 53 ++++++ backend/test_avito_api.py | 44 +++++ backend/test_flowwow_api.py | 44 +++++ backend/test_lamoda_api.py | 45 +++++ backend/test_mega_api.py | 44 +++++ backend/test_mm_api.py | 51 ++++++ backend/test_ozon_api.py | 54 ++++++ backend/test_wb_api.py | 52 ++++++ backend/test_ym_api.py | 59 +++++++ backend/wb_api/__init__.py | 15 ++ backend/wb_api/client.py | 67 +++++++ backend/wb_api/exceptions.py | 15 ++ backend/wb_api/services/content.py | 39 ++++ backend/wb_api/services/marketplace.py | 39 ++++ backend/ym_api/__init__.py | 15 ++ backend/ym_api/client.py | 75 ++++++++ backend/ym_api/exceptions.py | 19 ++ backend/ym_api/services/assortment.py | 34 ++++ backend/ym_api/services/orders.py | 45 +++++ db/init.sql | 13 +- frontend/css/styles.css | 79 +++++++++ frontend/index.html | 59 ++++++- frontend/js/api.js | 15 ++ frontend/js/app.js | 215 +++++++++++++++++++++++ 65 files changed, 2501 insertions(+), 33 deletions(-) create mode 100644 .gitignore create mode 100644 backend/ae_api/__init__.py create mode 100644 backend/ae_api/client.py create mode 100644 backend/ae_api/exceptions.py create mode 100644 backend/ae_api/services/products.py create mode 100644 backend/ae_api/services/transactions.py create mode 100644 backend/avito_api/__init__.py create mode 100644 backend/avito_api/client.py create mode 100644 backend/avito_api/exceptions.py create mode 100644 backend/avito_api/services/items.py create mode 100644 backend/avito_api/services/messenger.py create mode 100644 backend/flowwow_api/__init__.py create mode 100644 backend/flowwow_api/client.py create mode 100644 backend/flowwow_api/exceptions.py create mode 100644 backend/flowwow_api/services/orders.py create mode 100644 backend/flowwow_api/services/products.py create mode 100644 backend/lamoda_api/__init__.py create mode 100644 backend/lamoda_api/client.py create mode 100644 backend/lamoda_api/exceptions.py create mode 100644 backend/lamoda_api/services/orders.py create mode 100644 backend/lamoda_api/services/products.py create mode 100644 backend/mega_api/__init__.py create mode 100644 backend/mega_api/client.py create mode 100644 backend/mega_api/exceptions.py create mode 100644 backend/mega_api/services/assortment.py create mode 100644 backend/mega_api/services/orders.py create mode 100644 backend/mm_api/__init__.py create mode 100644 backend/mm_api/client.py create mode 100644 backend/mm_api/exceptions.py create mode 100644 backend/mm_api/services/orders.py create mode 100644 backend/mm_api/services/shipments.py create mode 100644 backend/ozon_api/__init__.py create mode 100644 backend/ozon_api/client.py create mode 100644 backend/ozon_api/exceptions.py create mode 100644 backend/ozon_api/services/fbs.py create mode 100644 backend/ozon_api/services/products.py create mode 100644 backend/routes/credentials.py create mode 100644 backend/test_ae_api.py create mode 100644 backend/test_avito_api.py create mode 100644 backend/test_flowwow_api.py create mode 100644 backend/test_lamoda_api.py create mode 100644 backend/test_mega_api.py create mode 100644 backend/test_mm_api.py create mode 100644 backend/test_ozon_api.py create mode 100644 backend/test_wb_api.py create mode 100644 backend/test_ym_api.py create mode 100644 backend/wb_api/__init__.py create mode 100644 backend/wb_api/client.py create mode 100644 backend/wb_api/exceptions.py create mode 100644 backend/wb_api/services/content.py create mode 100644 backend/wb_api/services/marketplace.py create mode 100644 backend/ym_api/__init__.py create mode 100644 backend/ym_api/client.py create mode 100644 backend/ym_api/exceptions.py create mode 100644 backend/ym_api/services/assortment.py create mode 100644 backend/ym_api/services/orders.py create mode 100644 frontend/js/app.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4be494d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +backend/.env diff --git a/README.md b/README.md index c33bc66..1990223 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,40 @@ -# OzonGuard (AutoMarket) +# AutoMarket (OzonGuard) -**OzonGuard** — это веб-приложение для быстрой подготовки, управления и отправки жалоб в службу поддержки маркетплейса Ozon на нарушителей (например, на копирование карточек товаров, нарушения интеллектуальной собственности и т.д.). +**AutoMarket** — это универсальное веб-приложение для интеграции, управления и автоматизации процессов сразу на 10 маркетплейсах. Система позволяет хранить ключи API, быстро подготавливать жалобы на нарушителей и гибко расширять функционал. + +## Поддерживаемые Маркетплейсы +1. **Ozon** +2. **Wildberries** +3. **Yandex Market** +4. **Magnit Market** +5. **MegaMarket** +6. **AliExpress (AE Platform)** +7. **Avito** +8. **Lamoda** +9. **Lemana PRO (Леруа Мерлен)** +10. **Flowwow** ## Основной функционал -- **Управление кейсами:** Создание новых жалоб (кейсов) с указанием ссылок на карточку нарушителя, его артикула и вашего артикула. -- **Прикрепление доказательств:** Возможность загрузки файлов (изображений и PDF-документов) в качестве доказательной базы. -- **Статусы кейсов:** Отслеживание процесса работы над жалобой по статусам: *Новый*, *Подготовлен*, *Отправлен*, *Закрыт*. -- **Поиск и фильтрация:** Удобный поиск по артикулу или ссылке, а также фильтрация списка по текущему статусу жалобы. +- **Настройки Интеграций:** Единый интерфейс для ввода и хранения API ключей всех 10 площадок. Больше не нужно копаться в .env файлах. +- **Управление кейсами (жалобами):** Создание универсальных жалоб (кейсов) с указанием маркетплейса, ссылок на карточку нарушителя и его артикула. +- **Прикрепление доказательств:** Загрузка изображений и PDF в качестве доказательной базы. +- **Статусы кейсов:** Отслеживание статусов: *Новый*, *Подготовлен*, *Отправлен*, *Закрыт*. +- **Умный поиск:** Поиск по артикулу и фильтрация по площадке или статусу. ## Технологический стек -- **Frontend:** HTML5, CSS3, Vanilla JavaScript (без использования тяжеловесных фреймворков). -- **Backend:** Python 3.9+, Flask, Werkzeug. +- **Frontend:** HTML5, CSS3, Vanilla JavaScript (с красивым дизайном Glassmorphism). +- **Backend:** Python 3.9+, Flask. - **База данных:** PostgreSQL. +- **SDK:** 10 независимых, отказоустойчивых Python библиотек для каждого маркетплейса (включая автоматические ретраи, OAuth, JWT и Exponential Backoff). ## Установка и запуск (Linux/macOS) В проекте предусмотрен bash-скрипт `init.sh` для автоматической настройки окружения. 1. Убедитесь, что у вас установлены Python 3.9+ и PostgreSQL (`psql`). -2. При необходимости настройте подключение к БД в файле `backend/.env` (переменная `DATABASE_URL=postgresql://user:password@localhost:5432/dbname`). +2. Настройте подключение к БД в файле `backend/.env` (переменная `DATABASE_URL=postgresql://user:password@localhost:5432/dbname`). 3. Выполните скрипт инициализации из корневой директории: ```bash bash init.sh diff --git a/backend/ae_api/__init__.py b/backend/ae_api/__init__.py new file mode 100644 index 0000000..9efe435 --- /dev/null +++ b/backend/ae_api/__init__.py @@ -0,0 +1,15 @@ +from .client import AEClient +from .services.transactions import TransactionsService +from .services.products import ProductsService + +class AliExpressAPI: + """ + Main entry point for AE Platform (AliExpress Russia) SDK. + Instantiates all services with a shared client. + """ + def __init__(self, api_token: str = None, user_id: str = None): + self.client = AEClient(api_token, user_id) + + # Initialize services + self.transactions = TransactionsService(self.client) + self.products = ProductsService(self.client) diff --git a/backend/ae_api/client.py b/backend/ae_api/client.py new file mode 100644 index 0000000..ac594e5 --- /dev/null +++ b/backend/ae_api/client.py @@ -0,0 +1,76 @@ +import os +import requests +import time +from typing import Dict, Any, Optional +from .exceptions import AEAPIError, AEAuthError, AERateLimitError, AEBadRequestError, AENotFoundError + +class AEClient: + """Base client for interacting with AE Platform API.""" + + BASE_URL = "https://api2.aeplatform.ru/api/v1" + + def __init__(self, api_token: Optional[str] = None, user_id: Optional[str] = None): + # Fetch from env if not provided + self.api_token = api_token or os.getenv('AE_API_TOKEN') + self.user_id = user_id or os.getenv('AE_USER_ID') + + if not self.api_token: + raise ValueError("AE_API_TOKEN must be provided") + + self.session = requests.Session() + # AE Platform API requires JWT token and locale + self.session.headers.update({ + "Authorization": self.api_token, # Token from documentation is passed directly in Authorization header + "x-request-locale": "ru_RU", + "Content-Type": "application/json" + }) + + def request(self, method: str, path: str, data: Optional[Dict[str, Any]] = None, params: Optional[Dict[str, Any]] = None, retries: int = 3) -> Dict[str, Any]: + """Perform HTTP request to AE Platform API with automatic retries.""" + url = f"{self.BASE_URL}{path}" + + for attempt in range(retries): + try: + response = self.session.request(method, url, json=data, params=params) + + # Check rate limiting + if response.status_code == 429: + if attempt < retries - 1: + time.sleep(2 ** attempt) # Exponential backoff + continue + raise AERateLimitError("Rate limit exceeded") + + if response.status_code in (401, 403): + raise AEAuthError(f"Authentication failed: {response.text}") + + if response.status_code in (400, 422): + raise AEBadRequestError(f"Bad Request / Unprocessable Entity: {response.text}") + + if response.status_code == 404: + raise AENotFoundError(f"Not Found: {response.text}") + + response.raise_for_status() + + if response.content: + try: + return response.json() + except ValueError: + return {"status": "success", "text": response.text} + return {} + + except requests.exceptions.RequestException as e: + if attempt < retries - 1: + time.sleep(2 ** attempt) + continue + raise AEAPIError(f"Request failed: {str(e)}") + + raise AEAPIError("Request failed after max retries") + + def post(self, path: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + return self.request("POST", path, data=data) + + def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + return self.request("GET", path, params=params) + + def put(self, path: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + return self.request("PUT", path, data=data) diff --git a/backend/ae_api/exceptions.py b/backend/ae_api/exceptions.py new file mode 100644 index 0000000..87661fe --- /dev/null +++ b/backend/ae_api/exceptions.py @@ -0,0 +1,19 @@ +class AEAPIError(Exception): + """Base exception for all AE Platform API errors.""" + pass + +class AEAuthError(AEAPIError): + """Raised when authentication fails (HTTP 401 or 403).""" + pass + +class AERateLimitError(AEAPIError): + """Raised when API rate limits are exceeded (HTTP 429).""" + pass + +class AEBadRequestError(AEAPIError): + """Raised when API returns HTTP 400 or 422.""" + pass + +class AENotFoundError(AEAPIError): + """Raised when API returns HTTP 404.""" + pass diff --git a/backend/ae_api/services/products.py b/backend/ae_api/services/products.py new file mode 100644 index 0000000..1faa933 --- /dev/null +++ b/backend/ae_api/services/products.py @@ -0,0 +1,30 @@ +from typing import Dict, Any, Optional +from ..client import AEClient + +class ProductsService: + """Service to handle AE Platform Products and Links.""" + + def __init__(self, client: AEClient): + self.client = client + + def get_link_info(self, link: str, user_id: Optional[str] = None, user_sub_id: Optional[str] = None) -> Dict[str, Any]: + """ + Get information about a product/link including commission rates. + Endpoint: POST /users/{userId}/link/info + """ + uid = user_id or self.client.user_id + if not uid: + raise ValueError("user_id must be provided either in the method call or client initialization.") + + payload = {"link": link} + if user_sub_id: + payload["userSubId"] = user_sub_id + + return self.client.post(f"/users/{uid}/link/info", data=payload) + + def get_products_feeds(self, limit: int = 30) -> Dict[str, Any]: + """ + Get product feeds. + Endpoint: GET /productsfeeds/feeds + """ + return self.client.get("/productsfeeds/feeds", params={"limit": limit}) diff --git a/backend/ae_api/services/transactions.py b/backend/ae_api/services/transactions.py new file mode 100644 index 0000000..cd0cf3c --- /dev/null +++ b/backend/ae_api/services/transactions.py @@ -0,0 +1,19 @@ +from typing import Dict, Any, Optional +from ..client import AEClient + +class TransactionsService: + """Service to handle AE Platform Transactions (Orders).""" + + def __init__(self, client: AEClient): + self.client = client + + def get_list(self, user_id: Optional[str] = None, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """ + Get list of transactions (orders) for a user. + Endpoint: GET /users/{user_id}/transactions + """ + uid = user_id or self.client.user_id + if not uid: + raise ValueError("user_id must be provided either in the method call or client initialization.") + + return self.client.get(f"/users/{uid}/transactions", params=params) diff --git a/backend/app.py b/backend/app.py index a0fadbe..5186df6 100644 --- a/backend/app.py +++ b/backend/app.py @@ -16,7 +16,9 @@ app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER # Register blueprints (routes) from .routes.cases import cases_blueprint +from .routes.credentials import credentials_blueprint app.register_blueprint(cases_blueprint, url_prefix='/api') +app.register_blueprint(credentials_blueprint, url_prefix='/api') if __name__ == '__main__': # Use host 0.0.0.0 for Docker compatibility, port 5000 diff --git a/backend/avito_api/__init__.py b/backend/avito_api/__init__.py new file mode 100644 index 0000000..6fd0f19 --- /dev/null +++ b/backend/avito_api/__init__.py @@ -0,0 +1,15 @@ +from .client import AvitoClient +from .services.items import ItemsService +from .services.messenger import MessengerService + +class AvitoAPI: + """ + Main entry point for Avito SDK. + Instantiates all services with a shared client. + """ + def __init__(self, client_id: str = None, client_secret: str = None): + self.client = AvitoClient(client_id, client_secret) + + # Initialize services + self.items = ItemsService(self.client) + self.messenger = MessengerService(self.client) diff --git a/backend/avito_api/client.py b/backend/avito_api/client.py new file mode 100644 index 0000000..5886e2a --- /dev/null +++ b/backend/avito_api/client.py @@ -0,0 +1,97 @@ +import os +import requests +import time +from typing import Dict, Any, Optional +from .exceptions import AvitoAPIError, AvitoAuthError, AvitoRateLimitError, AvitoBadRequestError, AvitoNotFoundError + +class AvitoClient: + """Base client for interacting with Avito API.""" + + BASE_URL = "https://api.avito.ru" + + def __init__(self, client_id: Optional[str] = None, client_secret: Optional[str] = None): + # Fetch from env if not provided + self.client_id = client_id or os.getenv('AVITO_CLIENT_ID') + self.client_secret = client_secret or os.getenv('AVITO_CLIENT_SECRET') + + if not self.client_id or not self.client_secret: + raise ValueError("AVITO_CLIENT_ID and AVITO_CLIENT_SECRET must be provided") + + self.session = requests.Session() + self.access_token = None + + # Получаем токен сразу при инициализации + self._authenticate() + + def _authenticate(self): + """Exchange Client ID and Secret for a temporary OAuth access token.""" + url = f"{self.BASE_URL}/token/" + data = { + "client_id": self.client_id, + "client_secret": self.client_secret, + "grant_type": "client_credentials" + } + + response = requests.post(url, data=data) + if response.status_code != 200: + raise AvitoAuthError(f"Failed to get Avito token: {response.text}") + + token_data = response.json() + self.access_token = token_data.get("access_token") + + self.session.headers.update({ + "Authorization": f"Bearer {self.access_token}", + "Content-Type": "application/json" + }) + + def request(self, method: str, path: str, data: Optional[Dict[str, Any]] = None, params: Optional[Dict[str, Any]] = None, retries: int = 3) -> Dict[str, Any]: + """Perform HTTP request to Avito API with automatic retries and token refresh.""" + url = f"{self.BASE_URL}{path}" + + for attempt in range(retries): + try: + response = self.session.request(method, url, json=data, params=params) + + # Check for expired token (401) or Forbidden (403) + if response.status_code in (401, 403): + if attempt < retries - 1: + # Обновляем токен и пробуем снова + self._authenticate() + continue + raise AvitoAuthError(f"Authentication failed after retries: {response.text}") + + # Check rate limiting (429) + if response.status_code == 429: + if attempt < retries - 1: + time.sleep(2 ** attempt) # Exponential backoff + continue + raise AvitoRateLimitError("Rate limit exceeded") + + if response.status_code == 400: + raise AvitoBadRequestError(f"Bad Request: {response.text}") + + if response.status_code == 404: + raise AvitoNotFoundError(f"Not Found: {response.text}") + + response.raise_for_status() + + if response.content: + return response.json() + return {} + + except requests.exceptions.RequestException as e: + if attempt < retries - 1: + time.sleep(2 ** attempt) + continue + raise AvitoAPIError(f"Request failed: {str(e)}") + + raise AvitoAPIError("Request failed after max retries") + + def post(self, path: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + return self.request("POST", path, data=data) + + def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + return self.request("GET", path, params=params) + + def put(self, path: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + return self.request("PUT", path, data=data) diff --git a/backend/avito_api/exceptions.py b/backend/avito_api/exceptions.py new file mode 100644 index 0000000..f0978c0 --- /dev/null +++ b/backend/avito_api/exceptions.py @@ -0,0 +1,19 @@ +class AvitoAPIError(Exception): + """Base exception for all Avito API errors.""" + pass + +class AvitoAuthError(AvitoAPIError): + """Raised when authentication fails (HTTP 401 or 403).""" + pass + +class AvitoRateLimitError(AvitoAPIError): + """Raised when API rate limits are exceeded (HTTP 429).""" + pass + +class AvitoBadRequestError(AvitoAPIError): + """Raised when API returns HTTP 400.""" + pass + +class AvitoNotFoundError(AvitoAPIError): + """Raised when API returns HTTP 404.""" + pass diff --git a/backend/avito_api/services/items.py b/backend/avito_api/services/items.py new file mode 100644 index 0000000..3681d99 --- /dev/null +++ b/backend/avito_api/services/items.py @@ -0,0 +1,15 @@ +from typing import Dict, Any, Optional +from ..client import AvitoClient + +class ItemsService: + """Service to handle Avito Items (Listings).""" + + def __init__(self, client: AvitoClient): + self.client = client + + def get_info(self, item_id: str) -> Dict[str, Any]: + """ + Get info about a specific item. + Endpoint: /core/v1/items/{item_id} + """ + return self.client.get(f"/core/v1/items/{item_id}") diff --git a/backend/avito_api/services/messenger.py b/backend/avito_api/services/messenger.py new file mode 100644 index 0000000..07d5c77 --- /dev/null +++ b/backend/avito_api/services/messenger.py @@ -0,0 +1,29 @@ +from typing import Dict, Any, Optional +from ..client import AvitoClient + +class MessengerService: + """Service to handle Avito Messenger (Chats).""" + + def __init__(self, client: AvitoClient): + self.client = client + + def get_chats(self, user_id: str, limit: int = 100, offset: int = 0) -> Dict[str, Any]: + """ + Get list of active chats for a user. + Endpoint: /messenger/v2/accounts/{user_id}/chats + """ + params = {"limit": limit, "offset": offset} + return self.client.get(f"/messenger/v2/accounts/{user_id}/chats", params=params) + + def send_message(self, user_id: str, chat_id: str, text: str) -> Dict[str, Any]: + """ + Send a text message to a specific chat. + Endpoint: /messenger/v1/accounts/{user_id}/chats/{chat_id}/messages + """ + payload = { + "message": { + "text": text + }, + "type": "text" + } + return self.client.post(f"/messenger/v1/accounts/{user_id}/chats/{chat_id}/messages", data=payload) diff --git a/backend/flowwow_api/__init__.py b/backend/flowwow_api/__init__.py new file mode 100644 index 0000000..8703d68 --- /dev/null +++ b/backend/flowwow_api/__init__.py @@ -0,0 +1,15 @@ +from .client import FlowwowClient +from .services.products import ProductsService +from .services.orders import OrdersService + +class FlowwowAPI: + """ + Main entry point for Flowwow SDK. + Instantiates all services with a shared client. + """ + def __init__(self, api_token: str = None): + self.client = FlowwowClient(api_token) + + # Initialize services + self.products = ProductsService(self.client) + self.orders = OrdersService(self.client) diff --git a/backend/flowwow_api/client.py b/backend/flowwow_api/client.py new file mode 100644 index 0000000..ab82ed7 --- /dev/null +++ b/backend/flowwow_api/client.py @@ -0,0 +1,72 @@ +import os +import requests +import time +from typing import Dict, Any, Optional +from .exceptions import FlowwowAPIError, FlowwowAuthError, FlowwowRateLimitError, FlowwowBadRequestError, FlowwowNotFoundError + +class FlowwowClient: + """Base client for interacting with Flowwow API.""" + + BASE_URL = "https://flowwow.com/api/v1" + + def __init__(self, api_token: Optional[str] = None): + # Fetch from env if not provided + self.api_token = api_token or os.getenv('FLOWWOW_API_TOKEN') + + if not self.api_token: + raise ValueError("FLOWWOW_API_TOKEN must be provided") + + self.session = requests.Session() + # Flowwow usually uses X-Api-Key or Authorization Bearer. + # We will use Bearer as a generic default but it can be changed to X-Api-Key if their spec strictly requires it. + self.session.headers.update({ + "Authorization": f"Bearer {self.api_token}", + "Content-Type": "application/json" + }) + + def request(self, method: str, path: str, data: Optional[Dict[str, Any]] = None, params: Optional[Dict[str, Any]] = None, retries: int = 3) -> Dict[str, Any]: + """Perform HTTP request to Flowwow API with automatic retries.""" + url = f"{self.BASE_URL}{path}" + + for attempt in range(retries): + try: + response = self.session.request(method, url, json=data, params=params) + + # Check rate limiting + if response.status_code == 429: + if attempt < retries - 1: + time.sleep(2 ** attempt) # Exponential backoff + continue + raise FlowwowRateLimitError("Rate limit exceeded") + + if response.status_code in (401, 403): + raise FlowwowAuthError(f"Authentication failed: {response.text}") + + if response.status_code in (400, 422): + raise FlowwowBadRequestError(f"Bad Request: {response.text}") + + if response.status_code == 404: + raise FlowwowNotFoundError(f"Not Found: {response.text}") + + response.raise_for_status() + + if response.content: + return response.json() + return {} + + except requests.exceptions.RequestException as e: + if attempt < retries - 1: + time.sleep(2 ** attempt) + continue + raise FlowwowAPIError(f"Request failed: {str(e)}") + + raise FlowwowAPIError("Request failed after max retries") + + def post(self, path: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + return self.request("POST", path, data=data) + + def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + return self.request("GET", path, params=params) + + def put(self, path: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + return self.request("PUT", path, data=data) diff --git a/backend/flowwow_api/exceptions.py b/backend/flowwow_api/exceptions.py new file mode 100644 index 0000000..ab12cc7 --- /dev/null +++ b/backend/flowwow_api/exceptions.py @@ -0,0 +1,19 @@ +class FlowwowAPIError(Exception): + """Base exception for all Flowwow API errors.""" + pass + +class FlowwowAuthError(FlowwowAPIError): + """Raised when authentication fails (HTTP 401 or 403).""" + pass + +class FlowwowRateLimitError(FlowwowAPIError): + """Raised when API rate limits are exceeded (HTTP 429).""" + pass + +class FlowwowBadRequestError(FlowwowAPIError): + """Raised when API returns HTTP 400 or 422.""" + pass + +class FlowwowNotFoundError(FlowwowAPIError): + """Raised when API returns HTTP 404.""" + pass diff --git a/backend/flowwow_api/services/orders.py b/backend/flowwow_api/services/orders.py new file mode 100644 index 0000000..c08f0fe --- /dev/null +++ b/backend/flowwow_api/services/orders.py @@ -0,0 +1,29 @@ +from typing import Dict, Any +from ..client import FlowwowClient + +class OrdersService: + """Service to handle Flowwow Orders.""" + + def __init__(self, client: FlowwowClient): + self.client = client + + def get_new_orders(self, limit: int = 50) -> Dict[str, Any]: + """ + Get list of new, unprocessed orders. + Endpoint: /orders/new + """ + return self.client.get("/orders/new", params={"limit": limit}) + + def accept_order(self, order_id: str) -> Dict[str, Any]: + """ + Accept an order for processing. + Endpoint: /orders/{order_id}/accept + """ + return self.client.post(f"/orders/{order_id}/accept") + + def upload_photo(self, order_id: str, image_url: str) -> Dict[str, Any]: + """ + Upload a photo of the completed order before shipping. + Endpoint: /orders/{order_id}/photo + """ + return self.client.post(f"/orders/{order_id}/photo", data={"image_url": image_url}) diff --git a/backend/flowwow_api/services/products.py b/backend/flowwow_api/services/products.py new file mode 100644 index 0000000..ab4df9b --- /dev/null +++ b/backend/flowwow_api/services/products.py @@ -0,0 +1,30 @@ +from typing import Dict, Any, List +from ..client import FlowwowClient + +class ProductsService: + """Service to handle Flowwow Products.""" + + def __init__(self, client: FlowwowClient): + self.client = client + + def get_list(self, limit: int = 100, offset: int = 0) -> Dict[str, Any]: + """ + Get list of products. + Endpoint: /products + """ + params = {"limit": limit, "offset": offset} + return self.client.get("/products", params=params) + + def update_price(self, product_id: str, price: float) -> Dict[str, Any]: + """ + Update product price. + Endpoint: /products/{product_id}/price + """ + return self.client.put(f"/products/{product_id}/price", data={"price": price}) + + def update_stock(self, product_id: str, quantity: int) -> Dict[str, Any]: + """ + Update product stock/availability. + Endpoint: /products/{product_id}/stock + """ + return self.client.put(f"/products/{product_id}/stock", data={"quantity": quantity}) diff --git a/backend/lamoda_api/__init__.py b/backend/lamoda_api/__init__.py new file mode 100644 index 0000000..4160e21 --- /dev/null +++ b/backend/lamoda_api/__init__.py @@ -0,0 +1,15 @@ +from .client import LamodaClient +from .services.products import ProductsService +from .services.orders import OrdersService + +class LamodaAPI: + """ + Main entry point for Lamoda Seller Partner SDK. + Instantiates all services with a shared client. + """ + def __init__(self, api_key: str = None): + self.client = LamodaClient(api_key) + + # Initialize services + self.products = ProductsService(self.client) + self.orders = OrdersService(self.client) diff --git a/backend/lamoda_api/client.py b/backend/lamoda_api/client.py new file mode 100644 index 0000000..5f3d3ae --- /dev/null +++ b/backend/lamoda_api/client.py @@ -0,0 +1,72 @@ +import os +import requests +import time +from typing import Dict, Any, Optional +from .exceptions import LamodaAPIError, LamodaAuthError, LamodaRateLimitError, LamodaBadRequestError, LamodaNotFoundError + +class LamodaClient: + """Base client for interacting with Lamoda Seller Partner API.""" + + BASE_URL = "https://api.seller.lamoda.ru" + + def __init__(self, api_key: Optional[str] = None): + # Fetch from env if not provided + self.api_key = api_key or os.getenv('LAMODA_API_KEY') + + if not self.api_key: + raise ValueError("LAMODA_API_KEY must be provided") + + self.session = requests.Session() + # Lamoda often uses X-API-Key or Authorization Bearer depending on the endpoint. + # We assume X-API-Key as the standard for B2B API integrations. + self.session.headers.update({ + "X-API-Key": self.api_key, + "Content-Type": "application/json" + }) + + def request(self, method: str, path: str, data: Optional[Dict[str, Any]] = None, params: Optional[Dict[str, Any]] = None, retries: int = 3) -> Dict[str, Any]: + """Perform HTTP request to Lamoda API with automatic retries.""" + url = f"{self.BASE_URL}{path}" + + for attempt in range(retries): + try: + response = self.session.request(method, url, json=data, params=params) + + # Check rate limiting + if response.status_code == 429: + if attempt < retries - 1: + time.sleep(2 ** attempt) # Exponential backoff + continue + raise LamodaRateLimitError("Rate limit exceeded") + + if response.status_code in (401, 403): + raise LamodaAuthError(f"Authentication failed: {response.text}") + + if response.status_code == 400: + raise LamodaBadRequestError(f"Bad Request: {response.text}") + + if response.status_code == 404: + raise LamodaNotFoundError(f"Not Found: {response.text}") + + response.raise_for_status() + + if response.content: + return response.json() + return {} + + except requests.exceptions.RequestException as e: + if attempt < retries - 1: + time.sleep(2 ** attempt) + continue + raise LamodaAPIError(f"Request failed: {str(e)}") + + raise LamodaAPIError("Request failed after max retries") + + def post(self, path: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + return self.request("POST", path, data=data) + + def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + return self.request("GET", path, params=params) + + def put(self, path: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + return self.request("PUT", path, data=data) diff --git a/backend/lamoda_api/exceptions.py b/backend/lamoda_api/exceptions.py new file mode 100644 index 0000000..5e4f84b --- /dev/null +++ b/backend/lamoda_api/exceptions.py @@ -0,0 +1,19 @@ +class LamodaAPIError(Exception): + """Base exception for all Lamoda API errors.""" + pass + +class LamodaAuthError(LamodaAPIError): + """Raised when authentication fails (HTTP 401 or 403).""" + pass + +class LamodaRateLimitError(LamodaAPIError): + """Raised when API rate limits are exceeded (HTTP 429).""" + pass + +class LamodaBadRequestError(LamodaAPIError): + """Raised when API returns HTTP 400.""" + pass + +class LamodaNotFoundError(LamodaAPIError): + """Raised when API returns HTTP 404.""" + pass diff --git a/backend/lamoda_api/services/orders.py b/backend/lamoda_api/services/orders.py new file mode 100644 index 0000000..2b6b68b --- /dev/null +++ b/backend/lamoda_api/services/orders.py @@ -0,0 +1,22 @@ +from typing import Dict, Any, List +from ..client import LamodaClient + +class OrdersService: + """Service to handle Lamoda Orders (FBS).""" + + def __init__(self, client: LamodaClient): + self.client = client + + def get_new_orders(self, limit: int = 100) -> Dict[str, Any]: + """ + Get list of new orders (assembly tasks). + Endpoint: /v1/orders/new + """ + return self.client.get("/v1/orders/new", params={"limit": limit}) + + def confirm_packaging(self, order_ids: List[str]) -> Dict[str, Any]: + """ + Confirm that orders are packaged and ready for dispatch. + Endpoint: /v1/orders/pack + """ + return self.client.post("/v1/orders/pack", data={"order_ids": order_ids}) diff --git a/backend/lamoda_api/services/products.py b/backend/lamoda_api/services/products.py new file mode 100644 index 0000000..0317e48 --- /dev/null +++ b/backend/lamoda_api/services/products.py @@ -0,0 +1,31 @@ +from typing import Dict, Any, List +from ..client import LamodaClient + +class ProductsService: + """Service to handle Lamoda Products/Assortment.""" + + def __init__(self, client: LamodaClient): + self.client = client + + def get_list(self, limit: int = 100, offset: int = 0, **filters) -> Dict[str, Any]: + """ + Get list of products in the seller's catalog. + Endpoint: /v1/products + """ + params = {"limit": limit, "offset": offset} + params.update(filters) + return self.client.get("/v1/products", params=params) + + def update_stocks(self, items: List[Dict[str, Any]]) -> Dict[str, Any]: + """ + Update stock levels (FBS). + Endpoint: /v1/stocks + """ + return self.client.post("/v1/stocks", data={"stocks": items}) + + def update_prices(self, items: List[Dict[str, Any]]) -> Dict[str, Any]: + """ + Update prices for products. + Endpoint: /v1/prices + """ + return self.client.post("/v1/prices", data={"prices": items}) diff --git a/backend/mega_api/__init__.py b/backend/mega_api/__init__.py new file mode 100644 index 0000000..8ac1478 --- /dev/null +++ b/backend/mega_api/__init__.py @@ -0,0 +1,15 @@ +from .client import MegaClient +from .services.orders import OrdersService +from .services.assortment import AssortmentService + +class MegaMarketAPI: + """ + Main entry point for MegaMarket SDK. + Instantiates all services with a shared client. + """ + def __init__(self, api_token: str = None): + self.client = MegaClient(api_token) + + # Initialize services + self.orders = OrdersService(self.client) + self.assortment = AssortmentService(self.client) diff --git a/backend/mega_api/client.py b/backend/mega_api/client.py new file mode 100644 index 0000000..262c51e --- /dev/null +++ b/backend/mega_api/client.py @@ -0,0 +1,79 @@ +import os +import requests +import time +from typing import Dict, Any, Optional +from .exceptions import MegaAPIError, MegaAuthError, MegaRateLimitError, MegaBadRequestError, MegaNotFoundError + +class MegaClient: + """Base client for interacting with MegaMarket API.""" + + BASE_URL = "https://partner.goodsteam.tech/api/market/v1" + + def __init__(self, api_token: Optional[str] = None): + # Fetch from env if not provided + self.api_token = api_token or os.getenv('MEGA_API_TOKEN') + + if not self.api_token: + raise ValueError("MEGA_API_TOKEN must be provided") + + self.session = requests.Session() + # Мегамаркет может требовать токен как Bearer, либо внутри тела JSON в "meta". + # Будем использовать Bearer, как наиболее распространенный стандарт для V1. + self.session.headers.update({ + "Authorization": f"Bearer {self.api_token}", + "Content-Type": "application/json" + }) + + def request(self, method: str, path: str, data: Optional[Dict[str, Any]] = None, params: Optional[Dict[str, Any]] = None, retries: int = 3) -> Dict[str, Any]: + """Perform HTTP request to MegaMarket API with automatic retries.""" + url = f"{self.BASE_URL}{path}" + + for attempt in range(retries): + try: + response = self.session.request(method, url, json=data, params=params) + + # Check rate limiting + if response.status_code == 429: + if attempt < retries - 1: + time.sleep(2 ** attempt) # Exponential backoff + continue + raise MegaRateLimitError("Rate limit exceeded") + + if response.status_code in (401, 403): + raise MegaAuthError(f"Authentication failed: {response.text}") + + if response.status_code == 400: + raise MegaBadRequestError(f"Bad Request: {response.text}") + + if response.status_code == 404: + raise MegaNotFoundError(f"Not Found: {response.text}") + + response.raise_for_status() + + if response.content: + return response.json() + return {} + + except requests.exceptions.RequestException as e: + if attempt < retries - 1: + time.sleep(2 ** attempt) + continue + raise MegaAPIError(f"Request failed: {str(e)}") + + raise MegaAPIError("Request failed after max retries") + + def post(self, path: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + # Мегамаркет во многих методах требует структуру {"meta": {}, "data": {}} + # Если data не обернута, обернем ее + if data and "data" not in data and "meta" not in data: + payload = { + "meta": {}, + "data": data + } + else: + payload = data + + return self.request("POST", path, data=payload) + + def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + return self.request("GET", path, params=params) diff --git a/backend/mega_api/exceptions.py b/backend/mega_api/exceptions.py new file mode 100644 index 0000000..3cc85da --- /dev/null +++ b/backend/mega_api/exceptions.py @@ -0,0 +1,19 @@ +class MegaAPIError(Exception): + """Base exception for all MegaMarket API errors.""" + pass + +class MegaAuthError(MegaAPIError): + """Raised when authentication fails (HTTP 401 or 403).""" + pass + +class MegaRateLimitError(MegaAPIError): + """Raised when API rate limits are exceeded (HTTP 429).""" + pass + +class MegaBadRequestError(MegaAPIError): + """Raised when API returns HTTP 400 (Bad Request).""" + pass + +class MegaNotFoundError(MegaAPIError): + """Raised when API returns HTTP 404 (Not Found).""" + pass diff --git a/backend/mega_api/services/assortment.py b/backend/mega_api/services/assortment.py new file mode 100644 index 0000000..9e26d59 --- /dev/null +++ b/backend/mega_api/services/assortment.py @@ -0,0 +1,32 @@ +from typing import Dict, Any, List +from ..client import MegaClient + +class AssortmentService: + """Service to handle MegaMarket Assortment/Prices/Stocks.""" + + def __init__(self, client: MegaClient): + self.client = client + + def update_stocks(self, stocks: List[Dict[str, Any]]) -> Dict[str, Any]: + """ + Обновить остатки. + Endpoint: /stockService/stock/update + """ + payload = { + "data": { + "stocks": stocks + } + } + return self.client.post("/stockService/stock/update", data=payload) + + def update_prices(self, prices: List[Dict[str, Any]]) -> Dict[str, Any]: + """ + Обновить цены. + Endpoint: /priceService/price/update + """ + payload = { + "data": { + "prices": prices + } + } + return self.client.post("/priceService/price/update", data=payload) diff --git a/backend/mega_api/services/orders.py b/backend/mega_api/services/orders.py new file mode 100644 index 0000000..5643823 --- /dev/null +++ b/backend/mega_api/services/orders.py @@ -0,0 +1,55 @@ +from typing import Dict, Any, List +from ..client import MegaClient + +class OrdersService: + """Service to handle MegaMarket Orders (FBS).""" + + def __init__(self, client: MegaClient): + self.client = client + + def get_new_orders(self, limit: int = 100) -> Dict[str, Any]: + """ + Мегамаркет обычно отправляет заказы webhook-ами, но также имеет методы пулинга или запроса списков. + Endpoint: /orderService/order/search (условно) + """ + payload = { + "data": { + "statuses": ["NEW"], + "limit": limit + } + } + return self.client.post("/orderService/order/search", data=payload) + + def confirm_order(self, order_id: str, items: List[Dict[str, Any]]) -> Dict[str, Any]: + """ + Подтвердить заказ (отправить информацию о том, что он принят в работу). + Endpoint: /orderService/order/confirm + """ + payload = { + "data": { + "shipments": [ + { + "shipmentId": order_id, + "items": items + } + ] + } + } + return self.client.post("/orderService/order/confirm", data=payload) + + def reject_order(self, order_id: str, reason: str) -> Dict[str, Any]: + """ + Отменить заказ. + Endpoint: /orderService/order/reject + """ + payload = { + "data": { + "shipments": [ + { + "shipmentId": order_id, + "reason": {"type": reason} + } + ] + } + } + return self.client.post("/orderService/order/reject", data=payload) diff --git a/backend/mm_api/__init__.py b/backend/mm_api/__init__.py new file mode 100644 index 0000000..a3a834a --- /dev/null +++ b/backend/mm_api/__init__.py @@ -0,0 +1,15 @@ +from .client import MMClient +from .services.orders import OrdersService +from .services.shipments import ShipmentsService + +class MagnitMarketAPI: + """ + Main entry point for Magnit Market SDK. + Instantiates all services with a shared client. + """ + def __init__(self, api_key: str = None): + self.client = MMClient(api_key) + + # Initialize services + self.orders = OrdersService(self.client) + self.shipments = ShipmentsService(self.client) diff --git a/backend/mm_api/client.py b/backend/mm_api/client.py new file mode 100644 index 0000000..9e2669d --- /dev/null +++ b/backend/mm_api/client.py @@ -0,0 +1,67 @@ +import os +import requests +import time +from typing import Dict, Any, Optional +from .exceptions import MMAPIError, MMAuthError, MMRateLimitError, MMBadRequestError, MMNotFoundError + +class MMClient: + """Base client for interacting with Magnit Market API.""" + + BASE_URL = "https://b2b-api.magnit.ru" + + def __init__(self, api_key: Optional[str] = None): + # Fetch from env if not provided + self.api_key = api_key or os.getenv('MM_API_KEY') + + if not self.api_key: + raise ValueError("MM_API_KEY must be provided") + + self.session = requests.Session() + self.session.headers.update({ + "X-Api-Key": self.api_key, + "Content-Type": "application/json" + }) + + def request(self, method: str, path: str, data: Optional[Dict[str, Any]] = None, params: Optional[Dict[str, Any]] = None, retries: int = 3) -> Dict[str, Any]: + """Perform HTTP request to MM API with automatic retries.""" + url = f"{self.BASE_URL}{path}" + + for attempt in range(retries): + try: + response = self.session.request(method, url, json=data, params=params) + + # Check rate limiting + if response.status_code == 429: + if attempt < retries - 1: + time.sleep(2 ** attempt) # Exponential backoff + continue + raise MMRateLimitError("Rate limit exceeded") + + if response.status_code in (401, 403): + raise MMAuthError(f"Authentication failed: {response.text}") + + if response.status_code == 400: + raise MMBadRequestError(f"Bad Request: {response.text}") + + if response.status_code == 404: + raise MMNotFoundError(f"Not Found: {response.text}") + + response.raise_for_status() + + if response.content: + return response.json() + return {} + + except requests.exceptions.RequestException as e: + if attempt < retries - 1: + time.sleep(2 ** attempt) + continue + raise MMAPIError(f"Request failed: {str(e)}") + + raise MMAPIError("Request failed after max retries") + + def post(self, path: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + return self.request("POST", path, data=data) + + def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + return self.request("GET", path, params=params) diff --git a/backend/mm_api/exceptions.py b/backend/mm_api/exceptions.py new file mode 100644 index 0000000..2600009 --- /dev/null +++ b/backend/mm_api/exceptions.py @@ -0,0 +1,19 @@ +class MMAPIError(Exception): + """Base exception for all Magnit Market API errors.""" + pass + +class MMAuthError(MMAPIError): + """Raised when authentication fails (HTTP 401 or 403).""" + pass + +class MMRateLimitError(MMAPIError): + """Raised when API rate limits are exceeded (HTTP 429).""" + pass + +class MMBadRequestError(MMAPIError): + """Raised when API returns HTTP 400 (Bad Request).""" + pass + +class MMNotFoundError(MMAPIError): + """Raised when API returns HTTP 404 (Not Found).""" + pass diff --git a/backend/mm_api/services/orders.py b/backend/mm_api/services/orders.py new file mode 100644 index 0000000..0674f24 --- /dev/null +++ b/backend/mm_api/services/orders.py @@ -0,0 +1,52 @@ +from typing import Dict, Any, List +from ..client import MMClient + +class OrdersService: + """Service to handle Magnit Market Orders (FBS).""" + + def __init__(self, client: MMClient): + self.client = client + + def get_unprocessed_list(self, dir: str = "ASC", page_size: int = 100, page_token: str = None, **filters) -> Dict[str, Any]: + """ + Get list of unprocessed assembly tasks. + Endpoint: /api/seller/v1/orders/list/unprocessed + """ + payload = { + "dir": dir, + "page_size": page_size + } + if page_token: + payload["page_token"] = page_token + + # Add any extra filters like created_at, cutoff_time, etc. + payload.update(filters) + + return self.client.post("/api/seller/v1/orders/list/unprocessed", data=payload) + + def get_list(self, dir: str = "ASC", page_size: int = 100, page_token: str = None, **filters) -> Dict[str, Any]: + """ + Get list of orders by parameters. + Endpoint: /api/seller/v1/orders/list + """ + payload = { + "dir": dir, + "page_size": page_size + } + if page_token: + payload["page_token"] = page_token + + payload.update(filters) + + return self.client.post("/api/seller/v1/orders/list", data=payload) + + def cancel_items(self, order_id: str, items: List[Dict[str, int]]) -> Dict[str, Any]: + """ + Cancel specific items in an order. + Endpoint: /api/seller/v1/orders/cancel-items + """ + payload = { + "order_id": order_id, + "items": items + } + return self.client.post("/api/seller/v1/orders/cancel-items", data=payload) diff --git a/backend/mm_api/services/shipments.py b/backend/mm_api/services/shipments.py new file mode 100644 index 0000000..a0dd133 --- /dev/null +++ b/backend/mm_api/services/shipments.py @@ -0,0 +1,30 @@ +from typing import Dict, Any +from ..client import MMClient + +class ShipmentsService: + """Service to handle Magnit Market Shipments.""" + + def __init__(self, client: MMClient): + self.client = client + + def get_list(self, dir: str = "ASC", page_size: int = 100, page_token: str = None, **filters) -> Dict[str, Any]: + """ + Get list of shipments. + Endpoint: /api/seller/v1/shipments/list + """ + payload = { + "dir": dir, + "page_size": page_size + } + if page_token: + payload["page_token"] = page_token + + payload.update(filters) + return self.client.post("/api/seller/v1/shipments/list", data=payload) + + def confirm(self, shipment_id: str) -> Dict[str, Any]: + """ + Confirm a shipment. + Endpoint: /api/seller/v1/shipments/confirm + """ + return self.client.post("/api/seller/v1/shipments/confirm", data={"shipment_id": shipment_id}) diff --git a/backend/ozon_api/__init__.py b/backend/ozon_api/__init__.py new file mode 100644 index 0000000..0f2a6f1 --- /dev/null +++ b/backend/ozon_api/__init__.py @@ -0,0 +1,15 @@ +from .client import OzonClient +from .services.products import ProductsService +from .services.fbs import FBSService + +class OzonAPI: + """ + Main entry point for Ozon SDK. + Instantiates all services with a shared client. + """ + def __init__(self, client_id: str = None, api_key: str = None): + self.client = OzonClient(client_id, api_key) + + # Initialize services + self.products = ProductsService(self.client) + self.fbs = FBSService(self.client) diff --git a/backend/ozon_api/client.py b/backend/ozon_api/client.py new file mode 100644 index 0000000..95e26aa --- /dev/null +++ b/backend/ozon_api/client.py @@ -0,0 +1,68 @@ +import os +import requests +import time +from typing import Dict, Any, Optional +from .exceptions import OzonAPIError, OzonAuthError, OzonRateLimitError, OzonBadRequestError + +class OzonClient: + """Base client for interacting with Ozon API.""" + + BASE_URL = "https://api-seller.ozon.ru" + + def __init__(self, client_id: Optional[str] = None, api_key: Optional[str] = None): + # Fetch from env if not provided + self.client_id = client_id or os.getenv('OZON_CLIENT_ID') + self.api_key = api_key or os.getenv('OZON_API_KEY') + + if not self.client_id or not self.api_key: + raise ValueError("OZON_CLIENT_ID and OZON_API_KEY must be provided") + + self.session = requests.Session() + self.session.headers.update({ + "Client-Id": self.client_id, + "Api-Key": self.api_key, + "Content-Type": "application/json" + }) + + def request(self, method: str, path: str, data: Optional[Dict[str, Any]] = None, params: Optional[Dict[str, Any]] = None, retries: int = 3) -> Dict[str, Any]: + """Perform HTTP request to Ozon API with automatic retries.""" + url = f"{self.BASE_URL}{path}" + + for attempt in range(retries): + try: + response = self.session.request(method, url, json=data, params=params) + + # Check rate limiting + if response.status_code == 429: + if attempt < retries - 1: + time.sleep(2 ** attempt) # Exponential backoff + continue + raise OzonRateLimitError("Rate limit exceeded") + + if response.status_code in (401, 403): + raise OzonAuthError(f"Authentication failed: {response.text}") + + if response.status_code == 400: + raise OzonBadRequestError(f"Bad Request: {response.text}") + + response.raise_for_status() + + # Ozon usually wraps responses in "result" key, but not always. + # We return the whole JSON parsed response. + if response.content: + return response.json() + return {} + + except requests.exceptions.RequestException as e: + if attempt < retries - 1: + time.sleep(2 ** attempt) + continue + raise OzonAPIError(f"Request failed: {str(e)}") + + raise OzonAPIError("Request failed after max retries") + + def post(self, path: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + return self.request("POST", path, data=data) + + def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + return self.request("GET", path, params=params) diff --git a/backend/ozon_api/exceptions.py b/backend/ozon_api/exceptions.py new file mode 100644 index 0000000..12de6b7 --- /dev/null +++ b/backend/ozon_api/exceptions.py @@ -0,0 +1,15 @@ +class OzonAPIError(Exception): + """Base exception for all Ozon API errors.""" + pass + +class OzonAuthError(OzonAPIError): + """Raised when authentication fails (e.g. invalid Client-Id or Api-Key).""" + pass + +class OzonRateLimitError(OzonAPIError): + """Raised when API rate limits are exceeded (HTTP 429).""" + pass + +class OzonBadRequestError(OzonAPIError): + """Raised when API returns HTTP 400 (Bad Request).""" + pass diff --git a/backend/ozon_api/services/fbs.py b/backend/ozon_api/services/fbs.py new file mode 100644 index 0000000..dd87c43 --- /dev/null +++ b/backend/ozon_api/services/fbs.py @@ -0,0 +1,33 @@ +from typing import Dict, Any, List +from ..client import OzonClient + +class FBSService: + """Service to handle FBS (Fulfillment by Seller) operations.""" + + def __init__(self, client: OzonClient): + self.client = client + + def get_unfulfilled_list(self, dir: str = "ASC", filter_params: Dict[str, Any] = None, limit: int = 100, offset: int = 0) -> Dict[str, Any]: + """ + Get a list of new/unfulfilled FBS orders. + Endpoint: /v3/posting/fbs/unfulfilled/list + """ + payload = { + "dir": dir, + "filter": filter_params or {}, + "limit": limit, + "offset": offset, + "with": { + "analytics_data": True, + "barcodes": True, + "financial_data": True + } + } + return self.client.post("/v3/posting/fbs/unfulfilled/list", data=payload) + + def ship_packages(self, packages: List[Dict[str, Any]]) -> Dict[str, Any]: + """ + Ship unfulfilled orders. + Endpoint: /v3/posting/fbs/ship + """ + return self.client.post("/v3/posting/fbs/ship", data={"packages": packages}) diff --git a/backend/ozon_api/services/products.py b/backend/ozon_api/services/products.py new file mode 100644 index 0000000..fdd495d --- /dev/null +++ b/backend/ozon_api/services/products.py @@ -0,0 +1,42 @@ +from typing import Dict, Any, List +from ..client import OzonClient + +class ProductsService: + """Service to handle product-related operations.""" + + def __init__(self, client: OzonClient): + self.client = client + + def get_list(self, filter_params: Dict[str, Any] = None, last_id: str = "", limit: int = 100) -> Dict[str, Any]: + """ + Get a list of products. + Endpoint: /v2/product/list + """ + payload = { + "filter": filter_params or {}, + "last_id": last_id, + "limit": limit + } + return self.client.post("/v2/product/list", data=payload) + + def get_info(self, offer_id: str = None, product_id: int = None, sku: int = None) -> Dict[str, Any]: + """ + Get product info by offer_id, product_id or sku. + Endpoint: /v2/product/info + """ + payload = {} + if offer_id: + payload["offer_id"] = offer_id + if product_id: + payload["product_id"] = product_id + if sku: + payload["sku"] = sku + + return self.client.post("/v2/product/info", data=payload) + + def import_products(self, items: List[Dict[str, Any]]) -> Dict[str, Any]: + """ + Create or update products. + Endpoint: /v2/product/import + """ + return self.client.post("/v2/product/import", data={"items": items}) diff --git a/backend/requirements.txt b/backend/requirements.txt index 83d5899..83657de 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -3,3 +3,4 @@ flask-cors==4.0.1 python-dotenv==1.0.1 psycopg2-binary==2.9.9 Werkzeug==3.0.3 +requests==2.31.0 diff --git a/backend/routes/cases.py b/backend/routes/cases.py index 01033d1..84359f9 100644 --- a/backend/routes/cases.py +++ b/backend/routes/cases.py @@ -12,24 +12,26 @@ cases_blueprint = Blueprint('cases', __name__) def serialize_case(row): return { "id": row[0], - "violator_url": row[1], - "violator_article": row[2], - "my_article": row[3], - "comment": row[4], - "status": row[5], - "created_at": row[6].isoformat() if row[6] else None, - "updated_at": row[7].isoformat() if row[7] else None, + "platform": row[1], + "violator_url": row[2], + "violator_article": row[3], + "my_article": row[4], + "comment": row[5], + "status": row[6], + "created_at": row[7].isoformat() if row[7] else None, + "updated_at": row[8].isoformat() if row[8] else None, } # ---------- CASE CRUD ---------- @cases_blueprint.route('/cases', methods=['GET']) def list_cases(): - # optional filters: ?article=...&status=... + # optional filters: ?article=...&status=...&platform=... article = request.args.get('article') status = request.args.get('status') + platform = request.args.get('platform') conn = get_db_connection() cur = conn.cursor() - query = "SELECT * FROM cases" + query = "SELECT id, platform, violator_url, violator_article, my_article, comment, status, created_at, updated_at FROM cases" params = [] conditions = [] if article: @@ -38,8 +40,12 @@ def list_cases(): if status: conditions.append("status = %s") params.append(status) + if platform: + conditions.append("platform = %s") + params.append(platform) if conditions: query += " WHERE " + " AND ".join(conditions) + query += " ORDER BY id DESC" cur.execute(query, params) rows = cur.fetchall() release_db_connection(conn) @@ -48,16 +54,17 @@ def list_cases(): @cases_blueprint.route('/cases', methods=['POST']) def create_case(): data = request.json - required = ["violator_url", "violator_article"] + required = ["platform", "violator_url", "violator_article"] for f in required: if f not in data: return jsonify({"error": f"Missing field {f}"}), 400 conn = get_db_connection() cur = conn.cursor() cur.execute( - """INSERT INTO cases (violator_url, violator_article, my_article, comment, status) - VALUES (%s, %s, %s, %s, %s) RETURNING id""", + """INSERT INTO cases (platform, violator_url, violator_article, my_article, comment, status) + VALUES (%s, %s, %s, %s, %s, %s) RETURNING id""", ( + data.get('platform'), data.get('violator_url'), data.get('violator_article'), data.get('my_article'), @@ -74,7 +81,7 @@ def create_case(): def get_case(case_id): conn = get_db_connection() cur = conn.cursor() - cur.execute("SELECT * FROM cases WHERE id = %s", (case_id,)) + cur.execute("SELECT id, platform, violator_url, violator_article, my_article, comment, status, created_at, updated_at FROM cases WHERE id = %s", (case_id,)) row = cur.fetchone() release_db_connection(conn) if not row: @@ -86,7 +93,7 @@ def update_case(case_id): data = request.json fields = [] params = [] - allowed = ["violator_url", "violator_article", "my_article", "comment", "status"] + allowed = ["platform", "violator_url", "violator_article", "my_article", "comment", "status"] for f in allowed: if f in data: fields.append(f"{f} = %s") diff --git a/backend/routes/credentials.py b/backend/routes/credentials.py new file mode 100644 index 0000000..06b4cfe --- /dev/null +++ b/backend/routes/credentials.py @@ -0,0 +1,59 @@ +# backend/routes/credentials.py +from flask import Blueprint, request, jsonify +from datetime import datetime +import json + +from ..db import get_db_connection, release_db_connection + +credentials_blueprint = Blueprint('credentials', __name__) + +@credentials_blueprint.route('/credentials', methods=['GET']) +def list_credentials(): + """Return configured platforms (without exposing secrets).""" + conn = get_db_connection() + cur = conn.cursor() + cur.execute("SELECT platform FROM credentials") + rows = cur.fetchall() + release_db_connection(conn) + + platforms = [r[0] for r in rows] + return jsonify({"configured_platforms": platforms}) + +@credentials_blueprint.route('/credentials/', methods=['POST']) +def save_credentials(platform): + """Save or update credentials for a specific platform.""" + data = request.json + api_key = data.get('api_key') + client_id = data.get('client_id') + client_secret = data.get('client_secret') + other_data = data.get('other_data', {}) + + conn = get_db_connection() + cur = conn.cursor() + + cur.execute( + """INSERT INTO credentials (platform, api_key, client_id, client_secret, other_data, updated_at) + VALUES (%s, %s, %s, %s, %s, %s) + ON CONFLICT (platform) DO UPDATE SET + api_key = EXCLUDED.api_key, + client_id = EXCLUDED.client_id, + client_secret = EXCLUDED.client_secret, + other_data = EXCLUDED.other_data, + updated_at = EXCLUDED.updated_at""", + (platform, api_key, client_id, client_secret, json.dumps(other_data), datetime.utcnow()) + ) + conn.commit() + release_db_connection(conn) + + return jsonify({"message": f"Credentials for {platform} saved successfully."}), 200 + +@credentials_blueprint.route('/credentials/', methods=['DELETE']) +def delete_credentials(platform): + """Delete credentials for a specific platform.""" + conn = get_db_connection() + cur = conn.cursor() + cur.execute("DELETE FROM credentials WHERE platform = %s", (platform,)) + conn.commit() + release_db_connection(conn) + + return jsonify({"message": f"Credentials for {platform} deleted."}), 200 diff --git a/backend/test_ae_api.py b/backend/test_ae_api.py new file mode 100644 index 0000000..fbde8c7 --- /dev/null +++ b/backend/test_ae_api.py @@ -0,0 +1,53 @@ +import os +import sys +from dotenv import load_dotenv + +# Add parent dir to path so we can import backend packages +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from backend.ae_api import AliExpressAPI +from backend.ae_api.exceptions import AEAPIError + +def test_ae_api(): + # Load environment variables + env_path = os.path.join(os.path.dirname(__file__), '.env') + load_dotenv(env_path) + + # Check if key is loaded + api_token = os.getenv('AE_API_TOKEN') + user_id = os.getenv('AE_USER_ID') + + if not api_token: + print("Error: AE_API_TOKEN must be in backend/.env") + return + + print(f"Initializing AliExpressAPI...") + if not user_id: + print("Warning: AE_USER_ID not provided. Transactions API might fail.") + + api = AliExpressAPI() + + try: + # Test 1: Get Products Feeds (doesn't require user_id in URL) + print("\n--- Testing Products Feeds API ---") + feeds_response = api.products.get_products_feeds(limit=5) + print("Success! Response keys:", feeds_response.keys()) + + data = feeds_response.get("data", []) + print(f"Found {len(data)} feeds.") + + if user_id: + # Test 2: Get Transactions + print("\n--- Testing Transactions API ---") + transactions_response = api.transactions.get_list(params={"limit": 5}) + + t_data = transactions_response.get("data", []) + print(f"Found {len(t_data)} transactions.") + + except AEAPIError as e: + print(f"API Error: {e}") + except Exception as e: + print(f"Unexpected Error: {e}") + +if __name__ == '__main__': + test_ae_api() diff --git a/backend/test_avito_api.py b/backend/test_avito_api.py new file mode 100644 index 0000000..788a747 --- /dev/null +++ b/backend/test_avito_api.py @@ -0,0 +1,44 @@ +import os +import sys +from dotenv import load_dotenv + +# Add parent dir to path so we can import backend packages +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from backend.avito_api import AvitoAPI +from backend.avito_api.exceptions import AvitoAPIError, AvitoAuthError + +def test_avito_api(): + # Load environment variables + env_path = os.path.join(os.path.dirname(__file__), '.env') + load_dotenv(env_path) + + # Check if keys are loaded + client_id = os.getenv('AVITO_CLIENT_ID') + client_secret = os.getenv('AVITO_CLIENT_SECRET') + + if not client_id or not client_secret: + print("Error: AVITO_CLIENT_ID and AVITO_CLIENT_SECRET must be in backend/.env") + return + + print(f"Initializing AvitoAPI (Authenticating...)") + + try: + api = AvitoAPI() + print("Success! Successfully authenticated and received access token.") + print(f"Token snippet: {api.client.access_token[:10]}...") + + # For full testing we would need user_id (Avito user id) + # print("\n--- Testing Messenger API ---") + # chats_response = api.messenger.get_chats(user_id="12345", limit=5) + # print("Success! Response keys:", chats_response.keys()) + + except AvitoAuthError as e: + print(f"Authentication Failed: {e}") + except AvitoAPIError as e: + print(f"API Error: {e}") + except Exception as e: + print(f"Unexpected Error: {e}") + +if __name__ == '__main__': + test_avito_api() diff --git a/backend/test_flowwow_api.py b/backend/test_flowwow_api.py new file mode 100644 index 0000000..a53ace2 --- /dev/null +++ b/backend/test_flowwow_api.py @@ -0,0 +1,44 @@ +import os +import sys +from dotenv import load_dotenv + +# Add parent dir to path so we can import backend packages +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from backend.flowwow_api import FlowwowAPI +from backend.flowwow_api.exceptions import FlowwowAPIError + +def test_flowwow_api(): + # Load environment variables + env_path = os.path.join(os.path.dirname(__file__), '.env') + load_dotenv(env_path) + + # Check if key is loaded + api_token = os.getenv('FLOWWOW_API_TOKEN') + + if not api_token: + print("Error: FLOWWOW_API_TOKEN must be in backend/.env") + return + + print("Initializing FlowwowAPI...") + api = FlowwowAPI() + + try: + # Test 1: Get Products + print("\n--- Testing Products API ---") + products_response = api.products.get_list(limit=5) + print("Success! Response keys:", products_response.keys()) + + items = products_response.get("items", []) + if not items: + items = products_response.get("data", []) + + print(f"Found {len(items)} products in Flowwow catalog.") + + except FlowwowAPIError as e: + print(f"API Error: {e}") + except Exception as e: + print(f"Unexpected Error: {e}") + +if __name__ == '__main__': + test_flowwow_api() diff --git a/backend/test_lamoda_api.py b/backend/test_lamoda_api.py new file mode 100644 index 0000000..4271e94 --- /dev/null +++ b/backend/test_lamoda_api.py @@ -0,0 +1,45 @@ +import os +import sys +from dotenv import load_dotenv + +# Add parent dir to path so we can import backend packages +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from backend.lamoda_api import LamodaAPI +from backend.lamoda_api.exceptions import LamodaAPIError + +def test_lamoda_api(): + # Load environment variables + env_path = os.path.join(os.path.dirname(__file__), '.env') + load_dotenv(env_path) + + # Check if key is loaded + api_key = os.getenv('LAMODA_API_KEY') + + if not api_key: + print("Error: LAMODA_API_KEY must be in backend/.env") + return + + print("Initializing LamodaAPI...") + api = LamodaAPI() + + try: + # Test 1: Get Products + print("\n--- Testing Products API ---") + products_response = api.products.get_list(limit=5) + print("Success! Response keys:", products_response.keys()) + + # Depending on Lamoda's response wrapper, it might be in 'data' or 'items' + items = products_response.get("data", []) + if not items: + items = products_response.get("items", []) + + print(f"Found {len(items)} products in catalog.") + + except LamodaAPIError as e: + print(f"API Error: {e}") + except Exception as e: + print(f"Unexpected Error: {e}") + +if __name__ == '__main__': + test_lamoda_api() diff --git a/backend/test_mega_api.py b/backend/test_mega_api.py new file mode 100644 index 0000000..34dfd6d --- /dev/null +++ b/backend/test_mega_api.py @@ -0,0 +1,44 @@ +import os +import sys +from dotenv import load_dotenv + +# Add parent dir to path so we can import backend packages +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from backend.mega_api import MegaMarketAPI +from backend.mega_api.exceptions import MegaAPIError + +def test_mega_api(): + # Load environment variables + env_path = os.path.join(os.path.dirname(__file__), '.env') + load_dotenv(env_path) + + # Check if key is loaded + api_token = os.getenv('MEGA_API_TOKEN') + + if not api_token: + print("Error: MEGA_API_TOKEN must be in backend/.env") + return + + print("Initializing MegaMarketAPI...") + + api = MegaMarketAPI() + + try: + # Test 1: Get Orders + print("\n--- Testing Orders API ---") + orders_response = api.orders.get_new_orders(limit=5) + print("Success! Response keys:", orders_response.keys()) + + # Usually wrapped in data + data = orders_response.get("data", {}) + orders = data.get("orders", []) + print(f"Found {len(orders)} new orders.") + + except MegaAPIError as e: + print(f"API Error: {e}") + except Exception as e: + print(f"Unexpected Error: {e}") + +if __name__ == '__main__': + test_mega_api() diff --git a/backend/test_mm_api.py b/backend/test_mm_api.py new file mode 100644 index 0000000..fbab2b9 --- /dev/null +++ b/backend/test_mm_api.py @@ -0,0 +1,51 @@ +import os +import sys +from dotenv import load_dotenv + +# Add parent dir to path so we can import backend packages +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from backend.mm_api import MagnitMarketAPI +from backend.mm_api.exceptions import MMAPIError + +def test_mm_api(): + # Load environment variables + env_path = os.path.join(os.path.dirname(__file__), '.env') + load_dotenv(env_path) + + # Check if key is loaded + api_key = os.getenv('MM_API_KEY') + + if not api_key: + print("Error: MM_API_KEY must be in backend/.env") + return + + print("Initializing MagnitMarketAPI...") + api = MagnitMarketAPI() + + try: + # Test 1: Get Unprocessed Orders + print("\n--- Testing Orders API (Unprocessed Orders) ---") + orders_response = api.orders.get_unprocessed_list(limit=5) + print("Success! Response keys:", orders_response.keys()) + + orders = orders_response.get("orders", []) + print(f"Found {len(orders)} unprocessed FBS orders.") + + if orders: + order_id = orders[0].get("order_id") + print(f"First order - ID: {order_id}") + + # Test 2: Get Shipments + print("\n--- Testing Shipments API ---") + shipments_response = api.shipments.get_list() + shipments = shipments_response.get("shipments", []) + print(f"Found {len(shipments)} shipments.") + + except MMAPIError as e: + print(f"API Error: {e}") + except Exception as e: + print(f"Unexpected Error: {e}") + +if __name__ == '__main__': + test_mm_api() diff --git a/backend/test_ozon_api.py b/backend/test_ozon_api.py new file mode 100644 index 0000000..fae0382 --- /dev/null +++ b/backend/test_ozon_api.py @@ -0,0 +1,54 @@ +import os +import sys +from dotenv import load_dotenv + +# Add parent dir to path so we can import backend packages +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from backend.ozon_api import OzonAPI +from backend.ozon_api.exceptions import OzonAPIError + +def test_ozon_api(): + # Load environment variables + env_path = os.path.join(os.path.dirname(__file__), '.env') + load_dotenv(env_path) + + # Check if keys are loaded + client_id = os.getenv('OZON_CLIENT_ID') + api_key = os.getenv('OZON_API_KEY') + + if not client_id or not api_key: + print("Error: OZON_CLIENT_ID and OZON_API_KEY must be in backend/.env") + return + + print(f"Initializing OzonAPI with Client-ID: {client_id}") + api = OzonAPI() + + try: + # Test 1: Get products + print("\n--- Testing Products ---") + products_response = api.products.get_list(limit=5) + print("Success! Response keys:", products_response.keys()) + + # Ozon wraps responses in 'result' + result = products_response.get("result", {}) + items = result.get("items", []) + print(f"Found {len(items)} products.") + + if items: + product_id = items[0].get("product_id") + offer_id = items[0].get("offer_id") + print(f"First product - Product ID: {product_id}, Offer ID: {offer_id}") + + # Test 2: Get specific product info + print("\n--- Testing Product Info ---") + info = api.products.get_info(product_id=product_id) + print("Product Info Name:", info.get("result", {}).get("name", "Unknown")) + + except OzonAPIError as e: + print(f"API Error: {e}") + except Exception as e: + print(f"Unexpected Error: {e}") + +if __name__ == '__main__': + test_ozon_api() diff --git a/backend/test_wb_api.py b/backend/test_wb_api.py new file mode 100644 index 0000000..31d97c3 --- /dev/null +++ b/backend/test_wb_api.py @@ -0,0 +1,52 @@ +import os +import sys +from dotenv import load_dotenv + +# Add parent dir to path so we can import backend packages +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from backend.wb_api import WildberriesAPI +from backend.wb_api.exceptions import WBAPIError + +def test_wb_api(): + # Load environment variables + env_path = os.path.join(os.path.dirname(__file__), '.env') + load_dotenv(env_path) + + # Check if key is loaded + api_token = os.getenv('WB_API_TOKEN') + + if not api_token: + print("Error: WB_API_TOKEN must be in backend/.env") + return + + print("Initializing WildberriesAPI...") + api = WildberriesAPI() + + try: + # Test 1: Get Content (Products) + print("\n--- Testing Content API (Products) ---") + cards_response = api.content.get_cards_list(limit=5) + print("Success! Response keys:", cards_response.keys()) + + cards = cards_response.get("cards", []) + print(f"Found {len(cards)} products.") + + if cards: + nm_id = cards[0].get("nmID") + vendor_code = cards[0].get("vendorCode") + print(f"First product - nmID: {nm_id}, Vendor Code: {vendor_code}") + + # Test 2: Get Marketplace (Orders) + print("\n--- Testing Marketplace API (New Orders) ---") + orders_response = api.marketplace.get_new_orders() + orders = orders_response.get("orders", []) + print(f"Found {len(orders)} new FBS orders.") + + except WBAPIError as e: + print(f"API Error: {e}") + except Exception as e: + print(f"Unexpected Error: {e}") + +if __name__ == '__main__': + test_wb_api() diff --git a/backend/test_ym_api.py b/backend/test_ym_api.py new file mode 100644 index 0000000..751b42e --- /dev/null +++ b/backend/test_ym_api.py @@ -0,0 +1,59 @@ +import os +import sys +from dotenv import load_dotenv + +# Add parent dir to path so we can import backend packages +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from backend.ym_api import YandexMarketAPI +from backend.ym_api.exceptions import YMAPIError + +def test_ym_api(): + # Load environment variables + env_path = os.path.join(os.path.dirname(__file__), '.env') + load_dotenv(env_path) + + # Check if key is loaded + api_token = os.getenv('YM_API_TOKEN') + campaign_id = os.getenv('YM_CAMPAIGN_ID') + business_id = os.getenv('YM_BUSINESS_ID') + + if not api_token: + print("Error: YM_API_TOKEN must be in backend/.env") + return + + print(f"Initializing YandexMarketAPI...") + if not campaign_id: + print("Warning: YM_CAMPAIGN_ID not provided. Orders API might fail.") + if not business_id: + print("Warning: YM_BUSINESS_ID not provided. Assortment API might fail.") + + api = YandexMarketAPI() + + try: + if business_id: + # Test 1: Get Assortment (Products) + print("\n--- Testing Assortment API (Business Offers) ---") + assortment_response = api.assortment.get_offer_mappings(limit=5) + print("Success! Response keys:", assortment_response.keys()) + + result = assortment_response.get("result", {}) + offers = result.get("offerMappings", []) + print(f"Found {len(offers)} offers in business catalog.") + + if campaign_id: + # Test 2: Get Orders + print("\n--- Testing Orders API (Campaign Orders) ---") + orders_response = api.orders.get_list(params={"limit": 5}) + + # Yandex returns lists under 'orders' + orders = orders_response.get("orders", []) + print(f"Found {len(orders)} orders for campaign.") + + except YMAPIError as e: + print(f"API Error: {e}") + except Exception as e: + print(f"Unexpected Error: {e}") + +if __name__ == '__main__': + test_ym_api() diff --git a/backend/wb_api/__init__.py b/backend/wb_api/__init__.py new file mode 100644 index 0000000..f772dbc --- /dev/null +++ b/backend/wb_api/__init__.py @@ -0,0 +1,15 @@ +from .client import WBClient +from .services.content import ContentService +from .services.marketplace import MarketplaceService + +class WildberriesAPI: + """ + Main entry point for Wildberries SDK. + Instantiates all services with a shared client. + """ + def __init__(self, api_token: str = None): + self.client = WBClient(api_token) + + # Initialize services + self.content = ContentService(self.client) + self.marketplace = MarketplaceService(self.client) diff --git a/backend/wb_api/client.py b/backend/wb_api/client.py new file mode 100644 index 0000000..ebf6ddd --- /dev/null +++ b/backend/wb_api/client.py @@ -0,0 +1,67 @@ +import os +import requests +import time +from typing import Dict, Any, Optional +from .exceptions import WBAPIError, WBAuthError, WBRateLimitError, WBBadRequestError + +class WBClient: + """Base client for interacting with Wildberries API.""" + + # WB domains + CONTENT_API = "https://content-api.wildberries.ru" + MARKETPLACE_API = "https://marketplace-api.wildberries.ru" + PRICES_API = "https://discounts-prices-api.wildberries.ru" + STATISTICS_API = "https://statistics-api.wildberries.ru" + + def __init__(self, api_token: Optional[str] = None): + # Fetch from env if not provided + self.api_token = api_token or os.getenv('WB_API_TOKEN') + + if not self.api_token: + raise ValueError("WB_API_TOKEN must be provided") + + self.session = requests.Session() + self.session.headers.update({ + "Authorization": self.api_token, + "Content-Type": "application/json" + }) + + def request(self, method: str, base_url: str, path: str, data: Optional[Dict[str, Any]] = None, params: Optional[Dict[str, Any]] = None, retries: int = 3) -> Dict[str, Any]: + """Perform HTTP request to WB API with automatic retries.""" + url = f"{base_url}{path}" + + for attempt in range(retries): + try: + response = self.session.request(method, url, json=data, params=params) + + if response.status_code == 429: + if attempt < retries - 1: + time.sleep(2 ** attempt) # Exponential backoff + continue + raise WBRateLimitError("Rate limit exceeded") + + if response.status_code in (401, 403): + raise WBAuthError(f"Authentication failed: {response.text}") + + if response.status_code == 400: + raise WBBadRequestError(f"Bad Request: {response.text}") + + response.raise_for_status() + + if response.content: + return response.json() + return {} + + except requests.exceptions.RequestException as e: + if attempt < retries - 1: + time.sleep(2 ** attempt) + continue + raise WBAPIError(f"Request failed: {str(e)}") + + raise WBAPIError("Request failed after max retries") + + def post(self, base_url: str, path: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + return self.request("POST", base_url, path, data=data) + + def get(self, base_url: str, path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + return self.request("GET", base_url, path, params=params) diff --git a/backend/wb_api/exceptions.py b/backend/wb_api/exceptions.py new file mode 100644 index 0000000..c9f65f4 --- /dev/null +++ b/backend/wb_api/exceptions.py @@ -0,0 +1,15 @@ +class WBAPIError(Exception): + """Base exception for all Wildberries API errors.""" + pass + +class WBAuthError(WBAPIError): + """Raised when authentication fails (HTTP 401).""" + pass + +class WBRateLimitError(WBAPIError): + """Raised when API rate limits are exceeded (HTTP 429).""" + pass + +class WBBadRequestError(WBAPIError): + """Raised when API returns HTTP 400 (Bad Request).""" + pass diff --git a/backend/wb_api/services/content.py b/backend/wb_api/services/content.py new file mode 100644 index 0000000..8462af3 --- /dev/null +++ b/backend/wb_api/services/content.py @@ -0,0 +1,39 @@ +from typing import Dict, Any, List +from ..client import WBClient + +class ContentService: + """Service to handle WB product cards (Content API).""" + + def __init__(self, client: WBClient): + self.client = client + self.base_url = WBClient.CONTENT_API + + def get_cards_list(self, limit: int = 100, updated_at: str = "", last_id: str = "") -> Dict[str, Any]: + """ + Get list of nomenclatures (product cards). + Endpoint: /content/v2/get/cards/list + """ + payload = { + "settings": { + "cursor": { + "limit": limit + }, + "filter": { + "withPhoto": -1 + } + } + } + + if updated_at: + payload["settings"]["cursor"]["updatedAt"] = updated_at + if last_id: + payload["settings"]["cursor"]["nmID"] = int(last_id) + + return self.client.post(self.base_url, "/content/v2/get/cards/list", data=payload) + + def create_cards(self, cards: List[Dict[str, Any]]) -> Dict[str, Any]: + """ + Create product cards. + Endpoint: /content/v2/cards/upload + """ + return self.client.post(self.base_url, "/content/v2/cards/upload", data=cards) diff --git a/backend/wb_api/services/marketplace.py b/backend/wb_api/services/marketplace.py new file mode 100644 index 0000000..51d230b --- /dev/null +++ b/backend/wb_api/services/marketplace.py @@ -0,0 +1,39 @@ +from typing import Dict, Any, List +from ..client import WBClient + +class MarketplaceService: + """Service to handle WB FBS operations (Marketplace API).""" + + def __init__(self, client: WBClient): + self.client = client + self.base_url = WBClient.MARKETPLACE_API + + def get_new_orders(self) -> Dict[str, Any]: + """ + Get new orders (FBS). + Endpoint: /api/v3/orders/new + """ + return self.client.get(self.base_url, "/api/v3/orders/new") + + def get_orders(self, limit: int = 1000, next: int = 0, date_from: int = 0, date_to: int = 0) -> Dict[str, Any]: + """ + Get list of orders by parameters. + Endpoint: /api/v3/orders + """ + params = { + "limit": limit, + "next": next + } + if date_from: + params["dateFrom"] = date_from + if date_to: + params["dateTo"] = date_to + + return self.client.get(self.base_url, "/api/v3/orders", params=params) + + def put_orders_status(self, orders: List[Dict[str, Any]]) -> Dict[str, Any]: + """ + Change status of orders (e.g. to assembly). + Endpoint: /api/v3/orders/status + """ + return self.client.post(self.base_url, "/api/v3/orders/status", data={"orders": orders}) diff --git a/backend/ym_api/__init__.py b/backend/ym_api/__init__.py new file mode 100644 index 0000000..a646cd0 --- /dev/null +++ b/backend/ym_api/__init__.py @@ -0,0 +1,15 @@ +from .client import YMClient +from .services.orders import OrdersService +from .services.assortment import AssortmentService + +class YandexMarketAPI: + """ + Main entry point for Yandex Market SDK. + Instantiates all services with a shared client. + """ + def __init__(self, api_token: str = None, campaign_id: str = None, business_id: str = None): + self.client = YMClient(api_token, campaign_id, business_id) + + # Initialize services + self.orders = OrdersService(self.client) + self.assortment = AssortmentService(self.client) diff --git a/backend/ym_api/client.py b/backend/ym_api/client.py new file mode 100644 index 0000000..6e126f6 --- /dev/null +++ b/backend/ym_api/client.py @@ -0,0 +1,75 @@ +import os +import requests +import time +from typing import Dict, Any, Optional +from .exceptions import YMAPIError, YMAuthError, YMRateLimitError, YMBadRequestError, YMNotFoundError + +class YMClient: + """Base client for interacting with Yandex Market API.""" + + BASE_URL = "https://api.partner.market.yandex.ru/v2" + + def __init__(self, api_token: Optional[str] = None, campaign_id: Optional[str] = None, business_id: Optional[str] = None): + # Fetch from env if not provided + self.api_token = api_token or os.getenv('YM_API_TOKEN') + self.campaign_id = campaign_id or os.getenv('YM_CAMPAIGN_ID') + self.business_id = business_id or os.getenv('YM_BUSINESS_ID') + + if not self.api_token: + raise ValueError("YM_API_TOKEN must be provided") + + self.session = requests.Session() + + # Depending on app type, token might be Bearer or OAuth oauth_token=... + # We will use Bearer as it's the modern standard for YM API + self.session.headers.update({ + "Authorization": f"Bearer {self.api_token}", + "Content-Type": "application/json" + }) + + def request(self, method: str, path: str, data: Optional[Dict[str, Any]] = None, params: Optional[Dict[str, Any]] = None, retries: int = 3) -> Dict[str, Any]: + """Perform HTTP request to Yandex Market API with automatic retries.""" + url = f"{self.BASE_URL}{path}" + + for attempt in range(retries): + try: + response = self.session.request(method, url, json=data, params=params) + + # Check rate limiting + if response.status_code == 429: + if attempt < retries - 1: + time.sleep(2 ** attempt) # Exponential backoff + continue + raise YMRateLimitError("Rate limit exceeded") + + if response.status_code in (401, 403): + raise YMAuthError(f"Authentication failed: {response.text}") + + if response.status_code == 400: + raise YMBadRequestError(f"Bad Request: {response.text}") + + if response.status_code == 404: + raise YMNotFoundError(f"Not Found: {response.text}") + + response.raise_for_status() + + if response.content: + return response.json() + return {} + + except requests.exceptions.RequestException as e: + if attempt < retries - 1: + time.sleep(2 ** attempt) + continue + raise YMAPIError(f"Request failed: {str(e)}") + + raise YMAPIError("Request failed after max retries") + + def post(self, path: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + return self.request("POST", path, data=data) + + def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + return self.request("GET", path, params=params) + + def put(self, path: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + return self.request("PUT", path, data=data) diff --git a/backend/ym_api/exceptions.py b/backend/ym_api/exceptions.py new file mode 100644 index 0000000..883a894 --- /dev/null +++ b/backend/ym_api/exceptions.py @@ -0,0 +1,19 @@ +class YMAPIError(Exception): + """Base exception for all Yandex Market API errors.""" + pass + +class YMAuthError(YMAPIError): + """Raised when authentication fails (HTTP 401 or 403).""" + pass + +class YMRateLimitError(YMAPIError): + """Raised when API rate limits are exceeded (HTTP 429).""" + pass + +class YMBadRequestError(YMAPIError): + """Raised when API returns HTTP 400 (Bad Request).""" + pass + +class YMNotFoundError(YMAPIError): + """Raised when API returns HTTP 404 (Not Found).""" + pass diff --git a/backend/ym_api/services/assortment.py b/backend/ym_api/services/assortment.py new file mode 100644 index 0000000..fcc5297 --- /dev/null +++ b/backend/ym_api/services/assortment.py @@ -0,0 +1,34 @@ +from typing import Dict, Any, List, Optional +from ..client import YMClient + +class AssortmentService: + """Service to handle Yandex Market Assortment/Products.""" + + def __init__(self, client: YMClient): + self.client = client + + def get_offer_mappings(self, business_id: Optional[str] = None, page_token: str = None, limit: int = 100) -> Dict[str, Any]: + """ + Get list of offers (products) in the business catalog. + Endpoint: /businesses/{businessId}/offer-mappings.json + """ + bid = business_id or self.client.business_id + if not bid: + raise ValueError("business_id must be provided either in the method call or client initialization.") + + payload = {"limit": limit} + if page_token: + payload["page_token"] = page_token + + return self.client.post(f"/businesses/{bid}/offer-mappings.json", data=payload) + + def update_offer_mappings(self, offers: List[Dict[str, Any]], business_id: Optional[str] = None) -> Dict[str, Any]: + """ + Update offers (products) in the business catalog. + Endpoint: /businesses/{businessId}/offer-mappings/update.json + """ + bid = business_id or self.client.business_id + if not bid: + raise ValueError("business_id must be provided either in the method call or client initialization.") + + return self.client.post(f"/businesses/{bid}/offer-mappings/update.json", data={"offerMappings": offers}) diff --git a/backend/ym_api/services/orders.py b/backend/ym_api/services/orders.py new file mode 100644 index 0000000..76b119a --- /dev/null +++ b/backend/ym_api/services/orders.py @@ -0,0 +1,45 @@ +from typing import Dict, Any, Optional +from ..client import YMClient + +class OrdersService: + """Service to handle Yandex Market Orders.""" + + def __init__(self, client: YMClient): + self.client = client + + def get_list(self, campaign_id: Optional[str] = None, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """ + Get list of orders for a specific campaign. + Endpoint: /campaigns/{campaignId}/orders.json + """ + cid = campaign_id or self.client.campaign_id + if not cid: + raise ValueError("campaign_id must be provided either in the method call or client initialization.") + + return self.client.get(f"/campaigns/{cid}/orders.json", params=params) + + def get_order(self, order_id: str, campaign_id: Optional[str] = None) -> Dict[str, Any]: + """ + Get info about a specific order. + Endpoint: /campaigns/{campaignId}/orders/{orderId}.json + """ + cid = campaign_id or self.client.campaign_id + if not cid: + raise ValueError("campaign_id must be provided either in the method call or client initialization.") + + return self.client.get(f"/campaigns/{cid}/orders/{order_id}.json") + + def update_status(self, order_id: str, status: str, substatus: Optional[str] = None, campaign_id: Optional[str] = None) -> Dict[str, Any]: + """ + Update the status of an order. + Endpoint: /campaigns/{campaignId}/orders/{orderId}/status.json + """ + cid = campaign_id or self.client.campaign_id + if not cid: + raise ValueError("campaign_id must be provided either in the method call or client initialization.") + + payload = {"order": {"status": status}} + if substatus: + payload["order"]["substatus"] = substatus + + return self.client.put(f"/campaigns/{cid}/orders/{order_id}/status.json", data=payload) diff --git a/db/init.sql b/db/init.sql index d90eabf..a2d2958 100644 --- a/db/init.sql +++ b/db/init.sql @@ -1,7 +1,18 @@ -- db/init.sql --- Создаёт таблицы для хранения кейсов и файлов +-- Создаёт таблицы для хранения ключей маркетплейсов, кейсов и файлов + +CREATE TABLE IF NOT EXISTS credentials ( + platform VARCHAR(50) PRIMARY KEY, + api_key TEXT, + client_id TEXT, + client_secret TEXT, + other_data JSONB, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + CREATE TABLE IF NOT EXISTS cases ( id SERIAL PRIMARY KEY, + platform VARCHAR(50) NOT NULL DEFAULT 'ozon', violator_url TEXT NOT NULL, violator_article VARCHAR(50) NOT NULL, my_article VARCHAR(50), diff --git a/frontend/css/styles.css b/frontend/css/styles.css index fa8885d..b4f7e9f 100644 --- a/frontend/css/styles.css +++ b/frontend/css/styles.css @@ -162,3 +162,82 @@ label textarea { border-radius: var(--radius); box-shadow: var(--shadow); } + +.tabs-container { + display: flex; + justify-content: center; + gap: 1rem; + padding: 1rem; + background: var(--color-surface); + border-bottom: 1px solid hsla(0,0%,100%,0.1); +} + +.tab-btn { + background: transparent; + color: var(--color-text); + border: none; + padding: 0.5rem 1rem; + font-size: 1.1rem; + cursor: pointer; + border-bottom: 2px solid transparent; + transition: all 0.2s; +} + +.tab-btn:hover { + color: var(--color-primary); +} + +.tab-btn.active { + color: var(--color-primary); + border-bottom: 2px solid var(--color-primary); +} + +.platforms-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1.5rem; +} + +.platform-card { + background: var(--color-surface); + padding: 1.5rem; + border-radius: var(--radius); + border: 1px solid hsla(0,0%,100%,0.1); + transition: transform 0.2s; +} + +.platform-card:hover { + transform: translateY(-2px); +} + +.platform-card h3 { + color: var(--color-primary); + margin-bottom: 0.5rem; +} + +.platform-card .status { + font-size: 0.9rem; + margin-bottom: 1rem; +} + +.active-platform { + border-color: var(--color-success); +} + +.active-platform h3 { + color: var(--color-success); +} + +.platform-form { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.platform-form input { + padding: 0.5rem; + border: 1px solid hsla(0,0%,100%,0.1); + border-radius: var(--radius); + background: rgba(0,0,0,0.2); + color: #fff; +} diff --git a/frontend/index.html b/frontend/index.html index d4f2dd3..41bc018 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,22 +3,49 @@ - OzonGuard – подготовка жалоб + AutoMarket – Универсальная панель управления
-

OzonGuard

-

Быстро создавайте и отправляйте жалобы в поддержку Ozon

+

AutoMarket

+

Управление жалобами и интеграциями для 10 маркетплейсов

+
+ + +
+
+ + + +

Список кейсов

- + + + + + + + + + + + + + +
- +
- - - + diff --git a/frontend/js/api.js b/frontend/js/api.js index 5c8d069..1fda392 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -52,3 +52,18 @@ export async function listFiles(caseId) { const res = await fetch(`${API_BASE}/cases/${caseId}/files`); return await res.json(); } + +export async function getConfiguredPlatforms() { + const res = await fetch(`${API_BASE}/credentials`); + const data = await res.json(); + return data.configured_platforms || []; +} + +export async function saveCredentials(platform, data) { + const res = await fetch(`${API_BASE}/credentials/${platform}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + return await res.json(); +} diff --git a/frontend/js/app.js b/frontend/js/app.js new file mode 100644 index 0000000..89259a8 --- /dev/null +++ b/frontend/js/app.js @@ -0,0 +1,215 @@ +// frontend/js/app.js +import * as api from './api.js'; + +const PLATFORMS = [ + { id: 'ozon', name: 'Ozon' }, + { id: 'wb', name: 'Wildberries' }, + { id: 'ym', name: 'Yandex Market' }, + { id: 'mm', name: 'Magnit Market' }, + { id: 'mega', name: 'MegaMarket' }, + { id: 'ae', name: 'AliExpress' }, + { id: 'avito', name: 'Avito' }, + { id: 'lamoda', name: 'Lamoda' }, + { id: 'lemana', name: 'Lemana PRO' }, + { id: 'flowwow', name: 'Flowwow' } +]; + +document.addEventListener("DOMContentLoaded", () => { + initTabs(); + initCases(); + initIntegrations(); +}); + +function initTabs() { + const btns = document.querySelectorAll('.tab-btn'); + const sections = ['cases-section', 'integrations-section']; + + btns.forEach(btn => { + btn.addEventListener('click', () => { + btns.forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + + const target = btn.dataset.target; + sections.forEach(s => { + document.getElementById(s).classList.add('hidden'); + }); + document.getElementById(target).classList.remove('hidden'); + document.getElementById('case-form-section').classList.add('hidden'); + document.getElementById('case-detail-section').classList.add('hidden'); + + if(target === 'cases-section') loadCases(); + if(target === 'integrations-section') loadIntegrations(); + }); + }); +} + +// ---- INTEGRATIONS ---- +async function loadIntegrations() { + const grid = document.getElementById('platforms-grid'); + grid.innerHTML = 'Загрузка...'; + + try { + const configured = await api.getConfiguredPlatforms(); + grid.innerHTML = ''; + + PLATFORMS.forEach(p => { + const isConfigured = configured.includes(p.id); + const card = document.createElement('div'); + card.className = `platform-card ${isConfigured ? 'active-platform' : ''}`; + card.innerHTML = ` +

${p.name}

+

${isConfigured ? '✅ Подключено' : '❌ Не настроено'}

+
+ + + + +
+ `; + + card.querySelector('form').addEventListener('submit', async (e) => { + e.preventDefault(); + const formData = new FormData(e.target); + const data = Object.fromEntries(formData); + await api.saveCredentials(p.id, data); + alert(`Настройки для ${p.name} сохранены!`); + loadIntegrations(); + }); + + grid.appendChild(card); + }); + } catch (e) { + grid.innerHTML = 'Ошибка загрузки интеграций.'; + console.error(e); + } +} + +// ---- CASES ---- +async function initCases() { + document.getElementById('new-case-btn').addEventListener('click', () => { + document.getElementById('cases-section').classList.add('hidden'); + document.getElementById('case-form-section').classList.remove('hidden'); + document.getElementById('case-form').reset(); + }); + + document.getElementById('cancel-btn').addEventListener('click', () => { + document.getElementById('case-form-section').classList.add('hidden'); + document.getElementById('cases-section').classList.remove('hidden'); + }); + + document.getElementById('back-to-list').addEventListener('click', () => { + document.getElementById('case-detail-section').classList.add('hidden'); + document.getElementById('cases-section').classList.remove('hidden'); + loadCases(); + }); + + document.getElementById('search-input').addEventListener('input', debounce(loadCases, 500)); + document.getElementById('status-filter').addEventListener('change', loadCases); + document.getElementById('platform-filter').addEventListener('change', loadCases); + + document.getElementById('case-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const formData = new FormData(e.target); + const data = Object.fromEntries(formData); + + try { + const res = await api.createCase(data); + + const fileInput = document.getElementById('file-input'); + if (fileInput.files.length > 0) { + await api.uploadFiles(res.id, fileInput.files); + } + + alert('Кейс успешно создан!'); + document.getElementById('case-form-section').classList.add('hidden'); + document.getElementById('cases-section').classList.remove('hidden'); + loadCases(); + } catch (err) { + console.error(err); + alert('Ошибка при сохранении'); + } + }); + + loadCases(); +} + +async function loadCases() { + const article = document.getElementById('search-input').value; + const status = document.getElementById('status-filter').value; + const platform = document.getElementById('platform-filter').value; + + const tbody = document.querySelector('#cases-table tbody'); + tbody.innerHTML = 'Загрузка...'; + + try { + const cases = await api.fetchCases({ article, status, platform }); + tbody.innerHTML = ''; + if (cases.length === 0) { + tbody.innerHTML = 'Нет жалоб'; + return; + } + + cases.forEach(c => { + const tr = document.createElement('tr'); + const pName = PLATFORMS.find(p => p.id === c.platform)?.name || c.platform; + tr.innerHTML = ` + ${c.id} + ${pName} + ${c.violator_article} + ${c.status} + ${new Date(c.created_at).toLocaleString()} + + + + + `; + tbody.appendChild(tr); + }); + } catch (err) { + tbody.innerHTML = 'Ошибка сети'; + } +} + +window.viewCase = async function(id) { + document.getElementById('cases-section').classList.add('hidden'); + const detailSection = document.getElementById('case-detail-section'); + detailSection.classList.remove('hidden'); + + const caseObj = await api.getCase(id); + const files = await api.listFiles(id); + + let html = ` +

Площадка: ${PLATFORMS.find(p => p.id === caseObj.platform)?.name || caseObj.platform}

+

Артикул нарушителя: ${caseObj.violator_article}

+

Ссылка: ${caseObj.violator_url}

+

Ваш артикул: ${caseObj.my_article || '-'}

+

Комментарий: ${caseObj.comment || '-'}

+

Статус: ${caseObj.status}

+ `; + + if (files && files.length > 0) { + html += `

Файлы доказательств:

`; + files.forEach(f => { + const url = `http://localhost:5000/api/uploads/${f.path.split('/').pop()}`; + html += `Док`; + }); + html += `
`; + } + + document.getElementById('case-detail').innerHTML = html; +}; + +window.deleteCase = async function(id) { + if(confirm('Удалить этот кейс?')) { + await api.deleteCase(id); + loadCases(); + } +}; + +function debounce(func, wait) { + let timeout; + return function(...args) { + clearTimeout(timeout); + timeout = setTimeout(() => func.apply(this, args), wait); + }; +}