tgshops-integrations 2.4__py3-none-any.whl → 3.0__py3-none-any.whl
Sign up to get free protection for your applications and to get access to all the features.
- tgshops_integrations/middlewares/gateway.py +116 -90
- tgshops_integrations/models/products.py +2 -3
- tgshops_integrations/nocodb_connector/categories_management.py +149 -0
- tgshops_integrations/nocodb_connector/client.py +189 -181
- tgshops_integrations/nocodb_connector/model_mapping.py +93 -84
- tgshops_integrations/nocodb_connector/products_management.py +151 -0
- {tgshops_integrations-2.4.dist-info → tgshops_integrations-3.0.dist-info}/METADATA +1 -1
- tgshops_integrations-3.0.dist-info/RECORD +18 -0
- tgshops_integrations-2.4.dist-info/RECORD +0 -16
- {tgshops_integrations-2.4.dist-info → tgshops_integrations-3.0.dist-info}/WHEEL +0 -0
- {tgshops_integrations-2.4.dist-info → tgshops_integrations-3.0.dist-info}/top_level.txt +0 -0
@@ -1,36 +1,68 @@
|
|
1
|
-
from typing import List,Optional
|
2
|
-
import importlib.util
|
1
|
+
from typing import List, Optional
|
3
2
|
from pathlib import Path
|
3
|
+
import importlib.util
|
4
4
|
|
5
5
|
from aiocache import cached
|
6
|
-
from
|
6
|
+
from models.products import ProductModel
|
7
|
+
|
8
|
+
# TODO: For test purposes, remove in production
|
9
|
+
import sys
|
10
|
+
sys.path.append('/home/latoff/Desktop/MarketBots')
|
11
|
+
|
7
12
|
from tgshops_integrations.nocodb_connector.client import NocodbClient
|
13
|
+
from tgshops_integrations.nocodb_connector.model_mapping import (
|
14
|
+
dump_product_data,
|
15
|
+
dump_product_data_with_check,
|
16
|
+
get_pagination_info,
|
17
|
+
ID_FIELD,
|
18
|
+
parse_product_data,
|
19
|
+
PRODUCT_CATEGORY_ID_LOOKUP_FIELD,
|
20
|
+
PRODUCT_NAME_FIELD,
|
21
|
+
PRODUCT_PRICE_FIELD,
|
22
|
+
PRODUCT_STOCK_FIELD,
|
23
|
+
PRODUCT_IMAGES_LOOKUP_FIELD,
|
24
|
+
PRODUCT_EXTERNAL_ID,
|
25
|
+
)
|
26
|
+
from services.nocodb_connector.categories import CategoryManager
|
27
|
+
from services.nocodb_connector.products import ProductManager
|
28
|
+
from loguru import logger
|
8
29
|
|
9
|
-
from tgshops_integrations.nocodb_connector.model_mapping import dump_product_data,dump_product_data_with_check, get_pagination_info, ID_FIELD, \
|
10
|
-
parse_product_data, PRODUCT_CATEGORY_ID_LOOKUP_FIELD, PRODUCT_NAME_FIELD, PRODUCT_PRICE_FIELD, \
|
11
|
-
PRODUCT_STOCK_FIELD, PRODUCT_IMAGES_LOOKUP_FIELD
|
12
30
|
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
31
|
+
def custom_key_builder(func, *args, **kwargs):
|
32
|
+
"""
|
33
|
+
Key builder function for caching.
|
34
|
+
Excludes 'self' by processing args from args[1:].
|
35
|
+
"""
|
36
|
+
args_key_part = "-".join(str(arg) for arg in args[1:])
|
37
|
+
kwargs_key_part = "-".join(f"{key}-{value}" for key, value in sorted(kwargs.items()))
|
38
|
+
return f"{func.__name__}-{args_key_part}-{kwargs_key_part}"
|
17
39
|
|
18
|
-
from loguru import logger
|
19
40
|
|
20
41
|
class Gateway(NocodbClient):
|
21
42
|
|
22
|
-
def __init__(
|
23
|
-
|
24
|
-
|
25
|
-
|
43
|
+
def __init__(
|
44
|
+
self,
|
45
|
+
logging: bool = False,
|
46
|
+
NOCODB_HOST: Optional[str] = None,
|
47
|
+
NOCODB_API_KEY: Optional[str] = None,
|
48
|
+
SOURCE: Optional[str] = None,
|
49
|
+
filter_buttons: Optional[List[str]] = [],
|
50
|
+
config_path: Optional[Path] = None,
|
51
|
+
special_attributes: bool = False,
|
52
|
+
):
|
53
|
+
super().__init__(NOCODB_HOST=NOCODB_HOST, NOCODB_API_KEY=NOCODB_API_KEY, SOURCE=SOURCE)
|
26
54
|
|
27
55
|
self.logging = logging
|
28
|
-
self.required_fields = [
|
56
|
+
self.required_fields = []
|
29
57
|
self.projection = []
|
30
58
|
self.special_attributes = special_attributes
|
31
59
|
self.filter_buttons = filter_buttons
|
32
60
|
|
33
|
-
|
61
|
+
if config_path:
|
62
|
+
self.load_config_from_path(config_path)
|
63
|
+
|
64
|
+
def load_config_from_path(self, config_path: Path):
|
65
|
+
"""Loads configuration from the specified path."""
|
34
66
|
if config_path.exists():
|
35
67
|
spec = importlib.util.spec_from_file_location("config", config_path)
|
36
68
|
self.config = importlib.util.module_from_spec(spec)
|
@@ -38,80 +70,74 @@ class Gateway(NocodbClient):
|
|
38
70
|
else:
|
39
71
|
raise FileNotFoundError(f"Configuration file not found at {config_path}")
|
40
72
|
|
41
|
-
async def load_data(self,SOURCE=None):
|
42
|
-
|
43
|
-
|
44
|
-
self.
|
45
|
-
self.
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
self.product_manager.actual_products=await self.get_all_products()
|
83
|
-
# self.product_manager.actual_products = await self.product_manager.get_products_v2(offset=0,limit=200)
|
73
|
+
async def load_data(self, SOURCE: Optional[str] = None):
|
74
|
+
"""Loads necessary data including tables, categories, and products."""
|
75
|
+
self.SOURCE = SOURCE
|
76
|
+
self.tables_list = await self.get_all_tables()
|
77
|
+
self.products_table = self.tables_list[self.config.NOCODB_PRODUCTS]
|
78
|
+
self.category_manager = CategoryManager(
|
79
|
+
table_id=self.tables_list[self.config.NOCODB_CATEGORIES],
|
80
|
+
NOCODB_HOST=self.NOCODB_HOST,
|
81
|
+
NOCODB_API_KEY=self.NOCODB_API_KEY,
|
82
|
+
logging=True,
|
83
|
+
filter_buttons=self.filter_buttons,
|
84
|
+
)
|
85
|
+
self.product_manager = ProductManager(
|
86
|
+
table_id=self.tables_list[self.config.NOCODB_PRODUCTS],
|
87
|
+
NOCODB_HOST=self.NOCODB_HOST,
|
88
|
+
NOCODB_API_KEY=self.NOCODB_API_KEY,
|
89
|
+
logging=True,
|
90
|
+
)
|
91
|
+
|
92
|
+
@cached(ttl=60, key_builder=custom_key_builder)
|
93
|
+
async def update_attributes(self, products: List[ProductModel]):
|
94
|
+
"""Updates attributes for the product table."""
|
95
|
+
system_attributes = [PRODUCT_EXTERNAL_ID, PRODUCT_IMAGES_LOOKUP_FIELD]
|
96
|
+
attributes = await self.get_table_meta(table_name=self.products_table)
|
97
|
+
self.columns = [item['title'].lower() for item in attributes.get('columns', [])]
|
98
|
+
|
99
|
+
# Ensure system attributes exist
|
100
|
+
for attribute_name in system_attributes:
|
101
|
+
if attribute_name.lower() not in self.columns:
|
102
|
+
await self.create_table_column(table_name=self.products_table, name=attribute_name)
|
103
|
+
logger.info(f"Created attribute: {attribute_name}")
|
104
|
+
|
105
|
+
# Validate and add extra attributes
|
106
|
+
for item in products:
|
107
|
+
attributes = await self.get_table_meta(table_name=self.products_table)
|
108
|
+
self.columns = [col['title'].lower() for col in attributes.get('columns', [])]
|
109
|
+
|
110
|
+
for attribute in item.extra_attributes:
|
111
|
+
if attribute.name.rstrip().lower() not in self.columns:
|
112
|
+
await self.create_table_column(table_name=self.products_table, name=attribute.name.lower())
|
113
|
+
logger.info(f"Created attribute: {attribute.name.lower()}")
|
84
114
|
|
85
|
-
|
86
|
-
|
115
|
+
async def update_products(self, external_products: List[ProductModel]):
|
116
|
+
"""Updates product data by comparing with existing records."""
|
117
|
+
await self.update_attributes(products=external_products)
|
118
|
+
self.actual_products = await self.product_manager.get_all_products()
|
119
|
+
self.ids_mapping = {product.external_id: product.id for product in self.actual_products}
|
120
|
+
self.products_meta = {product.external_id: product for product in self.actual_products}
|
87
121
|
|
88
122
|
for product in external_products:
|
89
|
-
if product.external_id in self.ids_mapping
|
90
|
-
product.id=self.ids_mapping[product.external_id]
|
91
|
-
|
92
|
-
|
123
|
+
if product.external_id in self.ids_mapping:
|
124
|
+
product.id = self.ids_mapping[product.external_id]
|
125
|
+
current_hash = self.product_manager.hash_product(product, special_attributes=self.special_attributes)
|
126
|
+
existing_hash = self.product_manager.hash_product(
|
127
|
+
self.products_meta[product.external_id],
|
128
|
+
special_attributes=self.special_attributes,
|
129
|
+
)
|
130
|
+
if current_hash != existing_hash:
|
131
|
+
await self.product_manager.update_product(product=product)
|
93
132
|
else:
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
def find_product_id_by_name(self,name: str):
|
108
|
-
for product in self.product_manager.actual_products.products:
|
109
|
-
if product.name == name:
|
110
|
-
return product.id
|
111
|
-
return None # Return None if no product is found with the given name
|
112
|
-
|
113
|
-
async def delete_all_products(self):
|
114
|
-
items = await self.product_manager.get_products_v2(offset=0,limit=200)
|
115
|
-
products_table = self.tables_list[self.config.NOCODB_PRODUCTS]
|
116
|
-
for num,item in enumerate(items):
|
117
|
-
await self.delete_table_record(products_table, item.id)
|
133
|
+
checked_data = dump_product_data_with_check(
|
134
|
+
data=product,
|
135
|
+
data_check=self.category_manager.categories,
|
136
|
+
)
|
137
|
+
await self.product_manager.create_product(product=product, checked_data=checked_data)
|
138
|
+
|
139
|
+
async def delete_all_products(self):
|
140
|
+
"""Deletes all products in the table."""
|
141
|
+
items = await self.product_manager.get_products_v2(offset=0, limit=200)
|
142
|
+
for item in items:
|
143
|
+
await self.product_manager.delete_table_record(self.products_table, item.id)
|
@@ -4,7 +4,6 @@ from datetime import datetime
|
|
4
4
|
|
5
5
|
from pydantic import BaseModel, Field, schema, validator
|
6
6
|
|
7
|
-
|
8
7
|
class ExtraAttribute(BaseModel):
|
9
8
|
name: str
|
10
9
|
description: Optional[str]
|
@@ -29,12 +28,12 @@ class ExternalProductModel(BaseModel):
|
|
29
28
|
class ProductModel(BaseModel):
|
30
29
|
id: Optional[str]
|
31
30
|
external_id: Optional[str]
|
32
|
-
category: Optional[List[
|
31
|
+
category: Optional[List[int]]
|
33
32
|
category_name: Optional[List[str]]
|
34
33
|
name: str
|
35
34
|
description: Optional[str]
|
36
35
|
price: Optional[float]
|
37
|
-
final_price: Optional[
|
36
|
+
final_price: Optional[int]
|
38
37
|
currency: Optional[str]
|
39
38
|
stock_qty: int
|
40
39
|
orders_qty: int = Field(0, hidden_field=True)
|
@@ -0,0 +1,149 @@
|
|
1
|
+
from typing import List
|
2
|
+
from loguru import logger
|
3
|
+
from aiocache import cached
|
4
|
+
from models.categories import CategoryModel, CategoryResponseModel, CategoryListResponseModel
|
5
|
+
from models.products import ProductModel
|
6
|
+
from tgshops_integrations.nocodb_connector.client import custom_key_builder, NocodbClient
|
7
|
+
from tgshops_integrations.nocodb_connector.model_mapping import (
|
8
|
+
CATEGORY_IMAGE_FIELD,
|
9
|
+
CATEGORY_NAME_FIELD,
|
10
|
+
CATEGORY_PARENT_FIELD,
|
11
|
+
CATEGORY_PARENT_ID_FIELD,
|
12
|
+
PRODUCT_NAME_FIELD,
|
13
|
+
CATEGORY_ID_OF_CATEGORY_FIELD,
|
14
|
+
dump_category_data,
|
15
|
+
get_pagination_info,
|
16
|
+
parse_category_data,
|
17
|
+
)
|
18
|
+
|
19
|
+
class CategoryManager(NocodbClient):
|
20
|
+
def __init__(
|
21
|
+
self,
|
22
|
+
table_id=None,
|
23
|
+
logging=False,
|
24
|
+
config_type=None,
|
25
|
+
NOCODB_HOST=None,
|
26
|
+
NOCODB_API_KEY=None,
|
27
|
+
SOURCE=None,
|
28
|
+
filter_buttons=None,
|
29
|
+
):
|
30
|
+
super().__init__(NOCODB_HOST=NOCODB_HOST, NOCODB_API_KEY=NOCODB_API_KEY, SOURCE=SOURCE)
|
31
|
+
self.NOCODB_HOST = NOCODB_HOST
|
32
|
+
self.NOCODB_API_KEY = NOCODB_API_KEY
|
33
|
+
self.SOURCE = SOURCE
|
34
|
+
self.CONFIG_TYPE = config_type
|
35
|
+
self.categories_table = table_id
|
36
|
+
self.external_categories = {}
|
37
|
+
self.logging = logging
|
38
|
+
self.filter_categories = []
|
39
|
+
self.filter_buttons = filter_buttons or []
|
40
|
+
self.required_fields = [CATEGORY_NAME_FIELD]
|
41
|
+
self.projection = ["Id", CATEGORY_NAME_FIELD, CATEGORY_PARENT_ID_FIELD, CATEGORY_ID_OF_CATEGORY_FIELD]
|
42
|
+
|
43
|
+
@cached(ttl=30, key_builder=custom_key_builder)
|
44
|
+
async def get_categories(self, table_id: str) -> List[CategoryModel]:
|
45
|
+
records = await self.get_table_records(table_id, self.required_fields, self.projection)
|
46
|
+
return [parse_category_data(record) for record in records]
|
47
|
+
|
48
|
+
@cached(ttl=30, key_builder=custom_key_builder)
|
49
|
+
async def get_categories_v2(self, table_id: str, offset: int = None, limit: int = None) -> CategoryListResponseModel:
|
50
|
+
response = await self.get_table_records_v2(
|
51
|
+
table_name=self.categories_table,
|
52
|
+
required_fields=self.required_fields,
|
53
|
+
projection=self.projection,
|
54
|
+
offset=offset,
|
55
|
+
limit=limit,
|
56
|
+
)
|
57
|
+
page_info = get_pagination_info(page_info=response['pageInfo'])
|
58
|
+
categories = [parse_category_data(record) for record in response['list']]
|
59
|
+
return CategoryListResponseModel(categories=categories, page_info=page_info)
|
60
|
+
|
61
|
+
@cached(ttl=30, key_builder=custom_key_builder)
|
62
|
+
async def get_category(self, table_id: str, category_id: str) -> CategoryModel:
|
63
|
+
record = await self.get_table_record(self.categories_table, category_id, self.required_fields, self.projection)
|
64
|
+
return parse_category_data(record)
|
65
|
+
|
66
|
+
async def create_category(self, table_id: str, category: CategoryModel) -> CategoryModel:
|
67
|
+
category_json = dump_category_data(category)
|
68
|
+
record = await self.create_table_record(self.categories_table, category_json)
|
69
|
+
return parse_category_data(record)
|
70
|
+
|
71
|
+
@cached(ttl=30, key_builder=custom_key_builder)
|
72
|
+
async def get_categories_in_category(self, table_id: str, category_id: str) -> List[CategoryModel]:
|
73
|
+
extra_where = (
|
74
|
+
f"({CATEGORY_PARENT_ID_FIELD},eq,{category_id})"
|
75
|
+
if category_id
|
76
|
+
else f"({CATEGORY_PARENT_FIELD},eq,0)"
|
77
|
+
)
|
78
|
+
records = await self.get_table_records(
|
79
|
+
table_name=self.categories_table,
|
80
|
+
required_fields=self.required_fields,
|
81
|
+
projection=self.projection,
|
82
|
+
extra_where=extra_where,
|
83
|
+
)
|
84
|
+
return [parse_category_data(record) for record in records]
|
85
|
+
|
86
|
+
async def update_categories(self, external_products: List[ProductModel]) -> None:
|
87
|
+
self.categories = await self.get_product_categories(table_id=self.categories_table, table_name=PRODUCT_NAME_FIELD)
|
88
|
+
categories_list = list(self.categories.keys())
|
89
|
+
properties_to_create = []
|
90
|
+
|
91
|
+
for product in external_products:
|
92
|
+
for num, product_property in enumerate(product.product_properties):
|
93
|
+
if product_property not in categories_list:
|
94
|
+
parent_id = (
|
95
|
+
self.categories[self.filter_buttons[num]]
|
96
|
+
if self.filter_buttons
|
97
|
+
else self.categories[product.category_I_name[0]]
|
98
|
+
)
|
99
|
+
properties_to_create.append([product_property, parent_id])
|
100
|
+
categories_list.append(product_property)
|
101
|
+
|
102
|
+
if properties_to_create:
|
103
|
+
properties_to_create.sort(key=lambda x: x[0])
|
104
|
+
new_id = max(self.categories.values(), default=0) + 1
|
105
|
+
|
106
|
+
for product_property, parent_id in properties_to_create:
|
107
|
+
new_property = await self.create_product_category(
|
108
|
+
table_id=self.categories_table,
|
109
|
+
property_name=product_property,
|
110
|
+
category_id=new_id,
|
111
|
+
table_name=PRODUCT_NAME_FIELD,
|
112
|
+
)
|
113
|
+
if self.logging:
|
114
|
+
logger.info(f"New Category: {new_property}")
|
115
|
+
new_id += 1
|
116
|
+
|
117
|
+
async def link_categories(self, parent_id: int, child_id: int):
|
118
|
+
metadata = await self.get_table_meta(self.categories_table)
|
119
|
+
linked_column = next((col for col in metadata['columns'] if col["title"] == "Set parent category" and col["uidt"] == "Links"), None)
|
120
|
+
|
121
|
+
if linked_column:
|
122
|
+
await self.link_table_record(
|
123
|
+
linked_column["base_id"],
|
124
|
+
linked_column["fk_model_id"],
|
125
|
+
child_id,
|
126
|
+
linked_column["id"],
|
127
|
+
parent_id,
|
128
|
+
)
|
129
|
+
|
130
|
+
async def unlink_categories(self, parent_id: int, child_id: int):
|
131
|
+
metadata = await self.get_table_meta(self.categories_table)
|
132
|
+
linked_column = next((col for col in metadata['columns'] if col["uidt"] == "Links"), None)
|
133
|
+
|
134
|
+
if linked_column:
|
135
|
+
await self.unlink_table_record(
|
136
|
+
linked_column["base_id"],
|
137
|
+
linked_column["fk_model_id"],
|
138
|
+
parent_id,
|
139
|
+
linked_column["id"],
|
140
|
+
child_id,
|
141
|
+
)
|
142
|
+
|
143
|
+
async def map_categories(self, external_products: List[ProductModel]) -> List[ProductModel]:
|
144
|
+
for num, product in enumerate(external_products):
|
145
|
+
if not product.category and product.product_properties:
|
146
|
+
external_products[num].category = [
|
147
|
+
str(self.categories[property_name]) for property_name in product.product_properties
|
148
|
+
]
|
149
|
+
return external_products
|