tgshops-integrations 2.4__py3-none-any.whl → 3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|