tgshops-integrations 2.4__tar.gz → 3.1__tar.gz
Sign up to get free protection for your applications and to get access to all the features.
- {tgshops_integrations-2.4 → tgshops_integrations-3.1}/PKG-INFO +1 -1
- {tgshops_integrations-2.4 → tgshops_integrations-3.1}/setup.py +1 -1
- tgshops_integrations-3.1/tgshops_integrations/middlewares/gateway.py +143 -0
- {tgshops_integrations-2.4 → tgshops_integrations-3.1}/tgshops_integrations/models/products.py +2 -3
- tgshops_integrations-3.1/tgshops_integrations/nocodb_connector/categories_management.py +149 -0
- tgshops_integrations-3.1/tgshops_integrations/nocodb_connector/client.py +288 -0
- tgshops_integrations-3.1/tgshops_integrations/nocodb_connector/model_mapping.py +198 -0
- tgshops_integrations-3.1/tgshops_integrations/nocodb_connector/products_management.py +151 -0
- {tgshops_integrations-2.4 → tgshops_integrations-3.1}/tgshops_integrations.egg-info/PKG-INFO +1 -1
- {tgshops_integrations-2.4 → tgshops_integrations-3.1}/tgshops_integrations.egg-info/SOURCES.txt +2 -2
- tgshops_integrations-2.4/tgshops_integrations/middlewares/gateway.py +0 -117
- tgshops_integrations-2.4/tgshops_integrations/nocodb_connector/categories.py +0 -168
- tgshops_integrations-2.4/tgshops_integrations/nocodb_connector/client.py +0 -280
- tgshops_integrations-2.4/tgshops_integrations/nocodb_connector/model_mapping.py +0 -189
- tgshops_integrations-2.4/tgshops_integrations/nocodb_connector/products.py +0 -181
- {tgshops_integrations-2.4 → tgshops_integrations-3.1}/README.md +0 -0
- {tgshops_integrations-2.4 → tgshops_integrations-3.1}/setup.cfg +0 -0
- {tgshops_integrations-2.4 → tgshops_integrations-3.1}/tgshops_integrations/__init__.py +0 -0
- {tgshops_integrations-2.4 → tgshops_integrations-3.1}/tgshops_integrations/middlewares/__init__.py +0 -0
- {tgshops_integrations-2.4 → tgshops_integrations-3.1}/tgshops_integrations/models/__init__.py +0 -0
- {tgshops_integrations-2.4 → tgshops_integrations-3.1}/tgshops_integrations/models/categories.py +0 -0
- {tgshops_integrations-2.4 → tgshops_integrations-3.1}/tgshops_integrations/nocodb_connector/__init__.py +0 -0
- {tgshops_integrations-2.4 → tgshops_integrations-3.1}/tgshops_integrations/nocodb_connector/tables.py +0 -0
- {tgshops_integrations-2.4 → tgshops_integrations-3.1}/tgshops_integrations.egg-info/dependency_links.txt +0 -0
- {tgshops_integrations-2.4 → tgshops_integrations-3.1}/tgshops_integrations.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: tgshops_integrations
|
3
|
-
Version:
|
3
|
+
Version: 3.1
|
4
4
|
Summary: Library is intended to provide the integration of the external service or CRM system with the TelegramShops/It allows to configure the relationship between NocoDB list of the products used further to display in the shop/As a resultss the products can be synchronized and updated uppon the request.
|
5
5
|
Home-page: https://git.the-devs.com/virtual-shops/shop-system/shop-backend-integrations/integration-library/integration-library
|
6
6
|
Author: Dimi Latoff
|
@@ -0,0 +1,143 @@
|
|
1
|
+
from typing import List, Optional
|
2
|
+
from pathlib import Path
|
3
|
+
import importlib.util
|
4
|
+
|
5
|
+
from aiocache import cached
|
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
|
+
|
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 tgshops_integrations.nocodb_connector.categories import CategoryManager
|
27
|
+
from tgshops_integrations.nocodb_connector.products import ProductManager
|
28
|
+
from loguru import logger
|
29
|
+
|
30
|
+
|
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}"
|
39
|
+
|
40
|
+
|
41
|
+
class Gateway(NocodbClient):
|
42
|
+
|
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)
|
54
|
+
|
55
|
+
self.logging = logging
|
56
|
+
self.required_fields = []
|
57
|
+
self.projection = []
|
58
|
+
self.special_attributes = special_attributes
|
59
|
+
self.filter_buttons = filter_buttons
|
60
|
+
|
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."""
|
66
|
+
if config_path.exists():
|
67
|
+
spec = importlib.util.spec_from_file_location("config", config_path)
|
68
|
+
self.config = importlib.util.module_from_spec(spec)
|
69
|
+
spec.loader.exec_module(self.config)
|
70
|
+
else:
|
71
|
+
raise FileNotFoundError(f"Configuration file not found at {config_path}")
|
72
|
+
|
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()}")
|
114
|
+
|
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}
|
121
|
+
|
122
|
+
for product in external_products:
|
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)
|
132
|
+
else:
|
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)
|
{tgshops_integrations-2.4 → tgshops_integrations-3.1}/tgshops_integrations/models/products.py
RENAMED
@@ -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
|
@@ -0,0 +1,288 @@
|
|
1
|
+
from typing import List, Optional, Dict, Union
|
2
|
+
import httpx
|
3
|
+
import requests
|
4
|
+
import io
|
5
|
+
from loguru import logger
|
6
|
+
from tgshops_integrations.nocodb_connector.model_mapping import ID_FIELD
|
7
|
+
|
8
|
+
|
9
|
+
def custom_key_builder(func, *args, **kwargs) -> str:
|
10
|
+
"""
|
11
|
+
Custom key builder for caching.
|
12
|
+
Excludes 'self' by starting args processing from args[1:].
|
13
|
+
"""
|
14
|
+
args_key_part = "-".join(str(arg) for arg in args[1:])
|
15
|
+
kwargs_key_part = "-".join(f"{key}-{value}" for key, value in sorted(kwargs.items()))
|
16
|
+
return f"{func.__name__}-{args_key_part}-{kwargs_key_part}"
|
17
|
+
|
18
|
+
|
19
|
+
class NocodbClient:
|
20
|
+
def __init__(self, NOCODB_HOST: Optional[str] = None, NOCODB_API_KEY: Optional[str] = None, SOURCE: Optional[str] = None):
|
21
|
+
self.NOCODB_HOST = NOCODB_HOST
|
22
|
+
self.NOCODB_API_KEY = NOCODB_API_KEY
|
23
|
+
self.SOURCE = SOURCE
|
24
|
+
self.httpx_client = httpx.AsyncClient(timeout=60.0)
|
25
|
+
self.httpx_client.headers = {"xc-token": self.NOCODB_API_KEY}
|
26
|
+
|
27
|
+
def construct_get_params(
|
28
|
+
self,
|
29
|
+
required_fields: Optional[List[str]] = None,
|
30
|
+
projection: Optional[List[str]] = None,
|
31
|
+
extra_where: Optional[str] = None,
|
32
|
+
offset: Optional[int] = None,
|
33
|
+
limit: Optional[int] = None
|
34
|
+
) -> Dict[str, Union[str, int]]:
|
35
|
+
"""
|
36
|
+
Constructs GET parameters for API requests.
|
37
|
+
"""
|
38
|
+
params = {}
|
39
|
+
if projection:
|
40
|
+
params["fields"] = ','.join(projection)
|
41
|
+
if required_fields:
|
42
|
+
params["where"] = "~and".join(f"({field},isnot,null)" for field in required_fields)
|
43
|
+
if extra_where:
|
44
|
+
params["where"] = f"{params.get('where', '')}~and{extra_where}" if params.get("where") else extra_where
|
45
|
+
if offset is not None:
|
46
|
+
params["offset"] = offset
|
47
|
+
if limit is not None:
|
48
|
+
params["limit"] = limit
|
49
|
+
return params
|
50
|
+
|
51
|
+
async def get_table_records(
|
52
|
+
self,
|
53
|
+
table_name: str,
|
54
|
+
required_fields: Optional[List[str]] = None,
|
55
|
+
projection: Optional[List[str]] = None,
|
56
|
+
extra_where: Optional[str] = None,
|
57
|
+
limit: Optional[int] = None
|
58
|
+
) -> List[dict]:
|
59
|
+
"""
|
60
|
+
Fetches records from a specified table.
|
61
|
+
"""
|
62
|
+
url = f"{self.NOCODB_HOST}/tables/{table_name}/records"
|
63
|
+
params = self.construct_get_params(required_fields, projection, extra_where, limit=limit)
|
64
|
+
response = await self.httpx_client.get(url, params=params)
|
65
|
+
if response.status_code == 200:
|
66
|
+
return response.json().get("list", [])
|
67
|
+
logger.error(f"Error fetching records: {response.text}")
|
68
|
+
raise Exception(response.text)
|
69
|
+
|
70
|
+
async def get_table_records_v2(self,
|
71
|
+
table_name: str,
|
72
|
+
required_fields: list = None,
|
73
|
+
projection: list = None,
|
74
|
+
extra_where: str = None,
|
75
|
+
offset: int = None,
|
76
|
+
limit: int = 25) -> dict:
|
77
|
+
url = f"{self.NOCODB_HOST}/tables/{table_name}/records"
|
78
|
+
extra_params = self.construct_get_params(required_fields, projection, extra_where, offset=offset, limit=limit)
|
79
|
+
response = await self.httpx_client.get(url, params=extra_params)
|
80
|
+
if response.status_code == 200:
|
81
|
+
return response.json()
|
82
|
+
raise Exception(response.text)
|
83
|
+
|
84
|
+
async def get_table_record(self,
|
85
|
+
table_name: str,
|
86
|
+
record_id: str,
|
87
|
+
required_fields: list = None,
|
88
|
+
projection: list = None) -> dict:
|
89
|
+
url = f"{self.NOCODB_HOST}/tables/{table_name}/records/{record_id}"
|
90
|
+
extra_params = self.construct_get_params(required_fields, projection)
|
91
|
+
response = await self.httpx_client.get(url, params=extra_params)
|
92
|
+
if response.status_code == 200:
|
93
|
+
return response.json()
|
94
|
+
raise Exception(response.text)
|
95
|
+
|
96
|
+
async def create_table_record(self, table_name: str, record: dict) -> dict:
|
97
|
+
"""
|
98
|
+
Creates a new record in a table.
|
99
|
+
"""
|
100
|
+
url = f"{self.NOCODB_HOST}/tables/{table_name}/records"
|
101
|
+
response = await self.httpx_client.post(url, json=record)
|
102
|
+
if response.status_code == 200:
|
103
|
+
record["id"] = response.json().get("id") or response.json().get("Id")
|
104
|
+
return record
|
105
|
+
logger.error(f"Error creating record: {response.text}")
|
106
|
+
raise Exception(response.text)
|
107
|
+
|
108
|
+
async def delete_table_record(self, table_name: str, record_id: str) -> dict:
|
109
|
+
"""
|
110
|
+
Deletes a record from a specified table.
|
111
|
+
"""
|
112
|
+
url = f"{self.NOCODB_HOST}/tables/{table_name}/records"
|
113
|
+
response = requests.delete(url, json={"Id": record_id}, headers=self.httpx_client.headers)
|
114
|
+
if response.status_code == 200:
|
115
|
+
logger.info(f"Deleted record {record_id}")
|
116
|
+
return response.json()
|
117
|
+
logger.error(f"Error deleting record: {response.text}")
|
118
|
+
raise Exception(response.text)
|
119
|
+
|
120
|
+
async def get_product_categories(self, table_id: str, table_name: str) -> Dict[str, str]:
|
121
|
+
"""
|
122
|
+
Fetches product categories from a specified table.
|
123
|
+
"""
|
124
|
+
url = f"{self.NOCODB_HOST}/tables/{table_id}/records"
|
125
|
+
params = self.construct_get_params(limit=75)
|
126
|
+
response = await self.httpx_client.get(url, params=params)
|
127
|
+
if response.status_code == 200:
|
128
|
+
return {category[table_name]: category["Id"] for category in response.json().get("list", [])}
|
129
|
+
logger.error(f"Error fetching categories: {response.text}")
|
130
|
+
raise Exception(response.text)
|
131
|
+
|
132
|
+
async def create_product_category(
|
133
|
+
self, table_id: str, category_name: str, table_name: str, category_id: int = 0
|
134
|
+
) -> dict:
|
135
|
+
"""
|
136
|
+
Creates a new product category in a specified table.
|
137
|
+
"""
|
138
|
+
url = f"{self.NOCODB_HOST}/tables/{table_id}/records"
|
139
|
+
record = {table_name: category_name, "Id": category_id}
|
140
|
+
response = await self.httpx_client.post(url, json=record)
|
141
|
+
if response.status_code == 200:
|
142
|
+
return record
|
143
|
+
logger.error(f"Error creating product category: {response.text}")
|
144
|
+
raise Exception(response.text)
|
145
|
+
|
146
|
+
async def get_table_meta(self, table_name: str) -> dict:
|
147
|
+
"""
|
148
|
+
Fetches metadata of a table.
|
149
|
+
"""
|
150
|
+
url = f"{self.NOCODB_HOST.replace('/api/v2', '/api/v1')}/db/meta/tables/{table_name}"
|
151
|
+
response = await self.httpx_client.get(url)
|
152
|
+
if response.status_code == 200:
|
153
|
+
return response.json()
|
154
|
+
logger.error(f"Error fetching table metadata: {response.text}")
|
155
|
+
raise Exception(response.text)
|
156
|
+
|
157
|
+
async def get_all_tables(self, source: Optional[str] = None) -> Dict[str, str]:
|
158
|
+
"""
|
159
|
+
Fetches all tables from the specified project source.
|
160
|
+
"""
|
161
|
+
source = source or self.SOURCE
|
162
|
+
url = f"{self.NOCODB_HOST.replace('/api/v2', '/api/v1')}/db/meta/projects/{source}/tables?includeM2M=false"
|
163
|
+
response = await self.httpx_client.get(url)
|
164
|
+
if response.status_code == 200:
|
165
|
+
tables_info = response.json().get('list', [])
|
166
|
+
self.tables_list = {table["title"]: table["id"] for table in tables_info}
|
167
|
+
return self.tables_list
|
168
|
+
logger.error(f"Failed to fetch tables: {response.text}")
|
169
|
+
raise Exception(response.text)
|
170
|
+
|
171
|
+
async def get_sources(self) -> list:
|
172
|
+
"""
|
173
|
+
Fetches all project sources.
|
174
|
+
"""
|
175
|
+
url = f"{self.NOCODB_HOST.replace('/api/v2', '/api/v1')}/db/meta/projects/"
|
176
|
+
response = await self.httpx_client.get(url)
|
177
|
+
if response.status_code == 200:
|
178
|
+
return response.json().get('list', [])
|
179
|
+
logger.error(f"Failed to fetch sources: {response.text}")
|
180
|
+
raise Exception(response.text)
|
181
|
+
|
182
|
+
async def get_table_meta(self, table_name: str) -> dict:
|
183
|
+
"""
|
184
|
+
Fetches metadata of a specified table.
|
185
|
+
"""
|
186
|
+
url = f"{self.NOCODB_HOST.replace('/api/v2', '/api/v1')}/db/meta/tables/{table_name}"
|
187
|
+
response = await self.httpx_client.get(url)
|
188
|
+
if response.status_code == 200:
|
189
|
+
return response.json()
|
190
|
+
logger.error(f"Failed to fetch table metadata: {response.text}")
|
191
|
+
raise Exception(response.text)
|
192
|
+
|
193
|
+
async def create_table_column(self, table_name: str, name: str) -> dict:
|
194
|
+
"""
|
195
|
+
Creates a new column in the specified table.
|
196
|
+
"""
|
197
|
+
url = f"{self.NOCODB_HOST.replace('/api/v2', '/api/v1')}/db/meta/tables/{table_name}/columns"
|
198
|
+
payload = {
|
199
|
+
"column_name": name,
|
200
|
+
"dt": "character varying",
|
201
|
+
"dtx": "specificType",
|
202
|
+
"ct": "varchar(45)",
|
203
|
+
"clen": 45,
|
204
|
+
"dtxp": "45",
|
205
|
+
"dtxs": "",
|
206
|
+
"altered": 1,
|
207
|
+
"uidt": "SingleLineText",
|
208
|
+
"uip": "",
|
209
|
+
"uicn": "",
|
210
|
+
"title": name
|
211
|
+
}
|
212
|
+
response = await self.httpx_client.post(url, json=payload)
|
213
|
+
if response.status_code == 200:
|
214
|
+
return response.json()
|
215
|
+
logger.error(f"Failed to create table column: {response.text}")
|
216
|
+
raise Exception(response.text)
|
217
|
+
|
218
|
+
async def link_table_record(
|
219
|
+
self,
|
220
|
+
base_id: str,
|
221
|
+
fk_model_id: str,
|
222
|
+
record_id: str,
|
223
|
+
source_column_id: str,
|
224
|
+
linked_record_id: str
|
225
|
+
) -> dict:
|
226
|
+
"""
|
227
|
+
Links a record to another record in a many-to-many relationship.
|
228
|
+
"""
|
229
|
+
url = f"{self.NOCODB_HOST.replace('/api/v2', '/api/v1')}/db/data/noco/{base_id}/{fk_model_id}/{record_id}/mm/{source_column_id}/{linked_record_id}"
|
230
|
+
response = await self.httpx_client.post(url, headers=self.httpx_client.headers)
|
231
|
+
if response.status_code == 200:
|
232
|
+
return response.json()
|
233
|
+
logger.error(f"Failed to link table record: {response.text}")
|
234
|
+
raise Exception(response.text)
|
235
|
+
|
236
|
+
async def unlink_table_record(
|
237
|
+
self,
|
238
|
+
base_id: str,
|
239
|
+
fk_model_id: str,
|
240
|
+
record_id: str,
|
241
|
+
source_column_id: str,
|
242
|
+
linked_record_id: str
|
243
|
+
) -> dict:
|
244
|
+
"""
|
245
|
+
Unlinks a record from another record in a many-to-many relationship.
|
246
|
+
"""
|
247
|
+
url = f"{self.NOCODB_HOST.replace('/api/v2', '/api/v1')}/db/data/noco/{base_id}/{fk_model_id}/{record_id}/mm/{source_column_id}/{linked_record_id}"
|
248
|
+
response = await self.httpx_client.delete(url)
|
249
|
+
if response.status_code == 200:
|
250
|
+
return response.json()
|
251
|
+
logger.error(f"Failed to unlink table record: {response.text}")
|
252
|
+
raise Exception(response.text)
|
253
|
+
|
254
|
+
async def save_image_to_nocodb(
|
255
|
+
self,
|
256
|
+
image_url: str,
|
257
|
+
image_name: str,
|
258
|
+
source_column_id: str,
|
259
|
+
product_table_name: str,
|
260
|
+
images_column_id: str
|
261
|
+
) -> Optional[str]:
|
262
|
+
"""
|
263
|
+
Saves an image to NocoDB's storage.
|
264
|
+
"""
|
265
|
+
try:
|
266
|
+
response = requests.get(image_url)
|
267
|
+
response.raise_for_status()
|
268
|
+
except requests.RequestException as e:
|
269
|
+
logger.error(f"Error loading image from URL {image_url}: {e}")
|
270
|
+
return None
|
271
|
+
|
272
|
+
file = io.BytesIO(response.content)
|
273
|
+
file_size = file.getbuffer().nbytes
|
274
|
+
|
275
|
+
if not file_size:
|
276
|
+
logger.error(f"Image file from {image_url} is empty.")
|
277
|
+
return None
|
278
|
+
|
279
|
+
files = {'file': (image_name, file, 'image/jpeg')}
|
280
|
+
url = f"{self.NOCODB_HOST.replace('/api/v2', '/api/v1')}/db/storage/upload?path=noco/{source_column_id}/{product_table_name}/{images_column_id}"
|
281
|
+
try:
|
282
|
+
response = await self.httpx_client.post(url, files=files, timeout=httpx.Timeout(200.0))
|
283
|
+
if response.status_code == 200:
|
284
|
+
return response.json()[0].get('url', None)
|
285
|
+
logger.error(f"Error saving image {image_name}: {response.text}")
|
286
|
+
except Exception as e:
|
287
|
+
logger.error(f"Unexpected error saving image {image_name}: {e}")
|
288
|
+
return None
|