Compare commits
9 Commits
4d0a85a43d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 682847111b | |||
| 0263094386 | |||
| 1bf01ebd09 | |||
| fff2850d5d | |||
| f796715eb3 | |||
| b44e70faf7 | |||
| 4e73900969 | |||
| 0eb3c49c4e | |||
| 4967275d0b |
1
.dockerignore
Normal file
1
.dockerignore
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.venv
|
||||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3.13
|
||||||
31
Dockerfile
Normal file
31
Dockerfile
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder
|
||||||
|
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
|
||||||
|
|
||||||
|
# Disable Python downloads, because we want to use the system interpreter
|
||||||
|
# across both images. If using a managed Python version, it needs to be
|
||||||
|
# copied from the build image into the final image; see `standalone.Dockerfile`
|
||||||
|
# for an example.
|
||||||
|
ENV UV_PYTHON_DOWNLOADS=0
|
||||||
|
|
||||||
|
# Install git
|
||||||
|
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
|
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||||
|
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||||
|
uv sync --locked --no-install-project --no-dev
|
||||||
|
COPY . /app
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
|
uv sync --locked --no-dev
|
||||||
|
|
||||||
|
# Then, use a final image without uv
|
||||||
|
FROM python:3.13-slim-bookworm
|
||||||
|
# It is important to use the image that matches the builder, as the path to the
|
||||||
|
# Python executable must be the same, e.g., using `python:3.11-slim-bookworm`
|
||||||
|
# will fail.
|
||||||
|
|
||||||
|
# Copy the application from the builder
|
||||||
|
COPY --from=builder --chown=app:app /app /app
|
||||||
|
ENV PATH="/app/.venv/bin:$PATH"
|
||||||
|
WORKDIR /app
|
||||||
@@ -10,21 +10,23 @@ from .config import (
|
|||||||
PG_PASSWORD,
|
PG_PASSWORD,
|
||||||
)
|
)
|
||||||
|
|
||||||
database_url = (
|
|
||||||
f"postgresql+asyncpg://"
|
|
||||||
f"{PG_LOGIN}:{PG_PASSWORD}@"
|
|
||||||
f"/{PG_DATABASE}?host=/run/postgresql/"
|
|
||||||
)
|
|
||||||
|
|
||||||
# database_url = (
|
# database_url = (
|
||||||
# f"postgresql+asyncpg://"
|
# f"postgresql+asyncpg://"
|
||||||
# f"{PG_LOGIN}:{PG_PASSWORD}@"
|
# f"{PG_LOGIN}:{PG_PASSWORD}@"
|
||||||
# f"{PG_HOST}:{PG_PORT}/{PG_DATABASE}"
|
# f"/{PG_DATABASE}?host=/run/postgresql/"
|
||||||
# )
|
# )
|
||||||
|
|
||||||
|
database_url = (
|
||||||
|
f"postgresql+asyncpg://"
|
||||||
|
f"{PG_LOGIN}:{PG_PASSWORD}@"
|
||||||
|
f"{PG_HOST}:{PG_PORT}/{PG_DATABASE}"
|
||||||
|
)
|
||||||
|
|
||||||
engine = create_async_engine(
|
engine = create_async_engine(
|
||||||
database_url,
|
database_url,
|
||||||
pool_timeout=2000
|
pool_timeout=2000,
|
||||||
|
pool_size=10,
|
||||||
|
pool_pre_ping=True,
|
||||||
)
|
)
|
||||||
session_factory = async_sessionmaker(
|
session_factory = async_sessionmaker(
|
||||||
engine,
|
engine,
|
||||||
|
|||||||
2
build-docker.sh
Executable file
2
build-docker.sh
Executable file
@@ -0,0 +1,2 @@
|
|||||||
|
docker build -t git.denco.store/fakz9/sipro-stocks:latest .
|
||||||
|
docker push git.denco.store/fakz9/sipro-stocks:latest
|
||||||
@@ -20,6 +20,7 @@ class MarketplaceProduct(BaseSiproModel):
|
|||||||
mp_price_bought: Mapped[int] = mapped_column()
|
mp_price_bought: Mapped[int] = mapped_column()
|
||||||
price_recommended: Mapped[int] = mapped_column()
|
price_recommended: Mapped[int] = mapped_column()
|
||||||
is_archived: Mapped[bool] = mapped_column()
|
is_archived: Mapped[bool] = mapped_column()
|
||||||
|
is_deleted: Mapped[bool] = mapped_column()
|
||||||
|
|
||||||
product_id: Mapped[int] = mapped_column(ForeignKey("products.id"))
|
product_id: Mapped[int] = mapped_column(ForeignKey("products.id"))
|
||||||
product: Mapped["Product"] = relationship()
|
product: Mapped["Product"] = relationship()
|
||||||
|
|||||||
94
docker-compose.yml
Normal file
94
docker-compose.yml
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
services:
|
||||||
|
taskiq_worker:
|
||||||
|
image: git.denco.store/fakz9/sipro-stocks:latest
|
||||||
|
container_name: stocks_worker
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: .env
|
||||||
|
command: [ "sh", "./start_taskiq.sh" ]
|
||||||
|
volumes_from:
|
||||||
|
- tmp
|
||||||
|
volumes:
|
||||||
|
- pg-socket:/run/postgresql
|
||||||
|
networks:
|
||||||
|
- appnet
|
||||||
|
depends_on:
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
rabbitmq:
|
||||||
|
condition: service_healthy
|
||||||
|
taskiq_scheduler:
|
||||||
|
image: git.denco.store/fakz9/sipro-stocks:latest
|
||||||
|
container_name: stocks_scheduler
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: .env
|
||||||
|
command: [ "sh", "./start_scheduler.sh" ]
|
||||||
|
volumes_from:
|
||||||
|
- tmp
|
||||||
|
volumes:
|
||||||
|
- pg-socket:/run/postgresql
|
||||||
|
networks:
|
||||||
|
- appnet
|
||||||
|
depends_on:
|
||||||
|
- fastapi
|
||||||
|
fastapi:
|
||||||
|
image: git.denco.store/fakz9/sipro-stocks:latest
|
||||||
|
container_name: stocks_fastapi
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: .env
|
||||||
|
command: [ "sh", "./start_fastapi.sh" ]
|
||||||
|
volumes_from:
|
||||||
|
- tmp
|
||||||
|
volumes:
|
||||||
|
- pg-socket:/run/postgresql
|
||||||
|
networks:
|
||||||
|
- appnet
|
||||||
|
depends_on:
|
||||||
|
- taskiq_worker
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
tmp:
|
||||||
|
image: busybox:latest
|
||||||
|
command: [ "chmod", "-R","777", "/tmp/docker" ]
|
||||||
|
volumes:
|
||||||
|
- /tmp/docker/
|
||||||
|
rabbitmq:
|
||||||
|
image: rabbitmq:latest
|
||||||
|
container_name: stocks_rabbitmq
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
RABBITMQ_DEFAULT_USER: guest
|
||||||
|
RABBITMQ_DEFAULT_PASS: guest
|
||||||
|
RABBITMQ_DEFAULT_VHOST: stocks_vhost
|
||||||
|
networks:
|
||||||
|
- appnet
|
||||||
|
healthcheck:
|
||||||
|
test: [ "CMD", "rabbitmqctl", "status" ]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
redis:
|
||||||
|
image: redis:latest
|
||||||
|
container_name: stocks_redis
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes_from:
|
||||||
|
- tmp
|
||||||
|
environment:
|
||||||
|
REDIS_PASSWORD: ${REDIS_PASSWORD}
|
||||||
|
command: [ "redis-server", "--unixsocket","/tmp/docker/redis.sock", "--unixsocketperm", "777", "--requirepass", "${REDIS_PASSWORD}" ]
|
||||||
|
networks:
|
||||||
|
- appnet
|
||||||
|
healthcheck:
|
||||||
|
test: [ "CMD" ,"redis-cli", "ping" ]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 2s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
networks:
|
||||||
|
appnet:
|
||||||
|
volumes:
|
||||||
|
pg-socket:
|
||||||
|
driver: local
|
||||||
|
driver_opts:
|
||||||
|
type: none
|
||||||
|
device: /run/postgresql
|
||||||
|
o: bind
|
||||||
3
main.py
3
main.py
@@ -125,9 +125,10 @@ def get_status(task_id):
|
|||||||
|
|
||||||
@app.get('/marketplace/{marketplace_id}/stocks')
|
@app.get('/marketplace/{marketplace_id}/stocks')
|
||||||
async def get_marketplace_stocks(
|
async def get_marketplace_stocks(
|
||||||
marketplace_id: int,
|
|
||||||
session: SessionDependency,
|
session: SessionDependency,
|
||||||
|
marketplace_id: int,
|
||||||
only_available: bool = False
|
only_available: bool = False
|
||||||
):
|
):
|
||||||
updater = StocksUpdater(session)
|
updater = StocksUpdater(session)
|
||||||
return await updater.get_all_stocks_for_marketplace(int(marketplace_id), only_available)
|
return await updater.get_all_stocks_for_marketplace(int(marketplace_id), only_available)
|
||||||
|
|
||||||
|
|||||||
25
pyproject.toml
Normal file
25
pyproject.toml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
[project]
|
||||||
|
name = "sipro-stocks"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Add your description here"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
dependencies = [
|
||||||
|
"aiohttp[speedups]>=3.12.15",
|
||||||
|
"alembic>=1.16.4",
|
||||||
|
"asyncpg>=0.30.0",
|
||||||
|
"celery[librabbitmq,redis]>=5.5.3",
|
||||||
|
"fastapi>=0.116.1",
|
||||||
|
"gevent>=25.5.1",
|
||||||
|
"gunicorn>=23.0.0",
|
||||||
|
"pydantic>=2.11.7",
|
||||||
|
"pyjwt>=2.10.1",
|
||||||
|
"python-dotenv>=1.1.1",
|
||||||
|
"redis[hiredis]>=5.2.1",
|
||||||
|
"sqlalchemy[asyncio]>=2.0.43",
|
||||||
|
"taskiq-aio-pika==0.4.2",
|
||||||
|
"taskiq-fastapi==0.3.5",
|
||||||
|
"taskiq-pipelines>=0.1.4",
|
||||||
|
"taskiq==0.11.17",
|
||||||
|
"uvicorn[standard]>=0.35.0",
|
||||||
|
]
|
||||||
@@ -256,7 +256,7 @@ async def get_stocks_data(
|
|||||||
func.coalesce(is_master_subquery.c.is_master, False).label('is_master'),
|
func.coalesce(is_master_subquery.c.is_master, False).label('is_master'),
|
||||||
func.coalesce(slaves_stock_subquery.c.slaves_stock, 0).label('slaves_stock'),
|
func.coalesce(slaves_stock_subquery.c.slaves_stock, 0).label('slaves_stock'),
|
||||||
MarketplaceProduct.price_recommended.label('price_recommended'),
|
MarketplaceProduct.price_recommended.label('price_recommended'),
|
||||||
MarketplaceProduct.is_archived.label('is_archived'),
|
MarketplaceProduct.is_deleted.label('is_deleted'),
|
||||||
func.coalesce(fbo_stock_subquery.c.quantity, 0).label('fbo_stock')
|
func.coalesce(fbo_stock_subquery.c.quantity, 0).label('fbo_stock')
|
||||||
)
|
)
|
||||||
.select_from(
|
.select_from(
|
||||||
@@ -318,25 +318,24 @@ async def get_stocks_data(
|
|||||||
is_master,
|
is_master,
|
||||||
slaves_stock,
|
slaves_stock,
|
||||||
price_recommended,
|
price_recommended,
|
||||||
is_archived,
|
is_deleted,
|
||||||
fbo_stock
|
fbo_stock
|
||||||
) in marketplace_products:
|
) in marketplace_products:
|
||||||
if is_archived or (sell_from_price > price_recommended) or is_paused or reset:
|
base_dict: StockData = {
|
||||||
response.append({
|
'article': denco_article,
|
||||||
'article': denco_article,
|
'marketplace_product': marketplace_product,
|
||||||
'full_stock': 0,
|
'product_id': marketplace_product.product_id,
|
||||||
'marketplace_product': marketplace_product,
|
'full_stock': 0
|
||||||
'product_id': marketplace_product.product_id
|
}
|
||||||
})
|
zero_stock = any([
|
||||||
continue
|
is_deleted,
|
||||||
if fbo_stock > 0 and prefer_fbo_over_fbs:
|
sell_from_price > price_recommended,
|
||||||
response.append({
|
is_paused,
|
||||||
'article': denco_article,
|
fbo_stock > 0 and prefer_fbo_over_fbs,
|
||||||
'full_stock': 0,
|
45 > company.balance
|
||||||
'marketplace_product': marketplace_product,
|
])
|
||||||
'product_id': marketplace_product.product_id
|
if zero_stock:
|
||||||
|
response.append(base_dict)
|
||||||
})
|
|
||||||
continue
|
continue
|
||||||
is_mix = mix_stock is not None
|
is_mix = mix_stock is not None
|
||||||
|
|
||||||
@@ -359,15 +358,7 @@ async def get_stocks_data(
|
|||||||
full_stock = warehouse_stock
|
full_stock = warehouse_stock
|
||||||
if (not sell_mixes) and is_mix:
|
if (not sell_mixes) and is_mix:
|
||||||
full_stock = warehouse_stock
|
full_stock = warehouse_stock
|
||||||
if 45 > company.balance:
|
|
||||||
full_stock = 0
|
|
||||||
full_stock = max([0, full_stock])
|
full_stock = max([0, full_stock])
|
||||||
|
base_dict['full_stock'] = full_stock
|
||||||
response.append({
|
response.append(base_dict)
|
||||||
'article': denco_article,
|
|
||||||
'full_stock': full_stock,
|
|
||||||
'marketplace_product': marketplace_product,
|
|
||||||
'product_id': marketplace_product.product_id
|
|
||||||
|
|
||||||
})
|
|
||||||
return response
|
return response
|
||||||
|
|||||||
1
start_fastapi.sh
Executable file
1
start_fastapi.sh
Executable file
@@ -0,0 +1 @@
|
|||||||
|
gunicorn -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000 main:app
|
||||||
@@ -1,57 +1,11 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
ulimit -n 97816
|
ulimit -n 97816
|
||||||
|
|
||||||
# Load Redis password from .env file
|
|
||||||
export REDIS_PASSWORD=$(grep -m 1 'REDIS_PASSWORD' .env | cut -d '=' -f2)
|
|
||||||
|
|
||||||
# Color definitions for log messages
|
|
||||||
GREEN='\033[0;32m'
|
GREEN='\033[0;32m'
|
||||||
YELLOW='\033[0;33m'
|
|
||||||
RED='\033[0;31m'
|
|
||||||
NC='\033[0m' # No Color
|
|
||||||
|
|
||||||
# Function to print log messages with colors
|
|
||||||
log_info() {
|
log_info() {
|
||||||
echo -e "${GREEN}[INFO] $1${NC}"
|
echo -e "${GREEN}[INFO] $1${NC}"
|
||||||
}
|
}
|
||||||
|
|
||||||
log_warning() {
|
|
||||||
echo -e "${YELLOW}[WARNING] $1${NC}"
|
|
||||||
}
|
|
||||||
|
|
||||||
log_error() {
|
|
||||||
echo -e "${RED}[ERROR] $1${NC}"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Start clearing Redis locks
|
|
||||||
log_info "Clearing Redis locks..."
|
|
||||||
|
|
||||||
# Check if Redis password was set correctly
|
|
||||||
if [ -z "$REDIS_PASSWORD" ]; then
|
|
||||||
log_error "REDIS_PASSWORD not set. Please check your .env file."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Deleting keys and getting the total deleted count
|
|
||||||
TOTAL_DELETED_KEYS=$(redis-cli -a "$REDIS_PASSWORD" --scan --pattern '*_lock' 2>/dev/null | \
|
|
||||||
xargs -I {} redis-cli -a "$REDIS_PASSWORD" del {} 2>/dev/null | wc -l)
|
|
||||||
|
|
||||||
# Log the result
|
|
||||||
if [ "$TOTAL_DELETED_KEYS" -gt 0 ]; then
|
|
||||||
log_info "Total deleted keys: $TOTAL_DELETED_KEYS"
|
|
||||||
else
|
|
||||||
log_warning "No keys matched the pattern '*_lock'."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Clear RabbitMQ queue
|
|
||||||
log_info "Purging RabbitMQ queue 'taskiq' in vhost 'stocks_vhost'..."
|
|
||||||
rabbitmqctl purge_queue -p stocks_vhost taskiq
|
|
||||||
if [ $? -eq 0 ]; then
|
|
||||||
log_info "RabbitMQ queue purged successfully."
|
|
||||||
else
|
|
||||||
log_error "Failed to purge RabbitMQ queue. Please check your RabbitMQ setup."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Start the Taskiq worker
|
# Start the Taskiq worker
|
||||||
log_info "Starting Taskiq worker..."
|
log_info "Starting Taskiq worker..."
|
||||||
taskiq worker background:taskiq_broker background.tasks --max-async-task 1000 --max-threadpool-threads 8 --max-prefetch 10000 --workers 1
|
taskiq worker background:taskiq_broker background.tasks --max-async-task 1000 --max-threadpool-threads 8 --max-prefetch 10000 --workers 1
|
||||||
|
|||||||
30
test.py
30
test.py
@@ -1,30 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
from idlelib.pyparse import trans
|
|
||||||
from pydoc import browse
|
|
||||||
|
|
||||||
import background.tasks
|
|
||||||
from background import taskiq_broker
|
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
|
||||||
# # generate random json dict with strings and integers
|
|
||||||
# def generate_json():
|
|
||||||
# import random
|
|
||||||
# import string
|
|
||||||
# import json
|
|
||||||
# data = {}
|
|
||||||
# for i in range(0, 10):
|
|
||||||
# key = ''.join(random.choices(string.ascii_lowercase, k=5))
|
|
||||||
# value = random.randint(0, 100)
|
|
||||||
# data[key] = value
|
|
||||||
# return json.dumps(data)
|
|
||||||
# # generate json dict
|
|
||||||
# data = generate_json()
|
|
||||||
await taskiq_broker.startup()
|
|
||||||
await background.tasks.update_marketplace.kiq(41)
|
|
||||||
await taskiq_broker.shutdown()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
loop.run_until_complete(main())
|
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
import redis.asyncio
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from typing import List, Union
|
from typing import List, Union
|
||||||
|
|
||||||
@@ -8,6 +10,7 @@ from sqlalchemy import select, or_
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import joinedload, selectinload
|
from sqlalchemy.orm import joinedload, selectinload
|
||||||
|
|
||||||
|
from backend.config import REDIS_URL
|
||||||
from backend.session import session_factory
|
from backend.session import session_factory
|
||||||
from database import Marketplace, MarketplaceProduct, Warehouse, Company
|
from database import Marketplace, MarketplaceProduct, Warehouse, Company
|
||||||
from database.sipro.enums.general import BaseMarketplace
|
from database.sipro.enums.general import BaseMarketplace
|
||||||
@@ -74,9 +77,16 @@ class StocksUpdater:
|
|||||||
f"{marketplace.name} successfully fully updated in {round(time.time() - start, 2)} seconds.")
|
f"{marketplace.name} successfully fully updated in {round(time.time() - start, 2)} seconds.")
|
||||||
|
|
||||||
async def get_all_stocks_for_marketplace(self, marketplace_id: int, only_available: bool) -> List[StockData]:
|
async def get_all_stocks_for_marketplace(self, marketplace_id: int, only_available: bool) -> List[StockData]:
|
||||||
|
cache_key = f"marketplace_stocks_{marketplace_id}_{only_available}"
|
||||||
|
client = redis.asyncio.from_url(REDIS_URL)
|
||||||
|
if cached := await client.get(cache_key):
|
||||||
|
return json.loads(cached)
|
||||||
|
|
||||||
marketplace = await self.get_marketplace(marketplace_id)
|
marketplace = await self.get_marketplace(marketplace_id)
|
||||||
updater = UpdaterFactory.get_updater(self.session, marketplace)
|
updater = UpdaterFactory.get_updater(self.session, marketplace)
|
||||||
return await updater.get_all_stocks(only_available)
|
response = await updater.get_all_stocks(only_available)
|
||||||
|
await client.set(cache_key, json.dumps(response), ex=600)
|
||||||
|
return response
|
||||||
|
|
||||||
async def full_update_all_marketplaces(self, marketplace_ids: Union[List[int], None] = None):
|
async def full_update_all_marketplaces(self, marketplace_ids: Union[List[int], None] = None):
|
||||||
marketplaces = await self.get_marketplaces(marketplace_ids)
|
marketplaces = await self.get_marketplaces(marketplace_ids)
|
||||||
|
|||||||
Reference in New Issue
Block a user