tgshops-integrations 2.3__tar.gz → 3.0__tar.gz

Sign up to get free protection for your applications and to get access to all the features.
Files changed (25) hide show
  1. {tgshops_integrations-2.3 → tgshops_integrations-3.0}/PKG-INFO +1 -1
  2. {tgshops_integrations-2.3 → tgshops_integrations-3.0}/setup.py +1 -1
  3. tgshops_integrations-3.0/tgshops_integrations/middlewares/gateway.py +143 -0
  4. {tgshops_integrations-2.3 → tgshops_integrations-3.0}/tgshops_integrations/models/products.py +2 -3
  5. tgshops_integrations-3.0/tgshops_integrations/nocodb_connector/categories_management.py +149 -0
  6. tgshops_integrations-3.0/tgshops_integrations/nocodb_connector/client.py +288 -0
  7. tgshops_integrations-3.0/tgshops_integrations/nocodb_connector/model_mapping.py +198 -0
  8. tgshops_integrations-3.0/tgshops_integrations/nocodb_connector/products_management.py +151 -0
  9. {tgshops_integrations-2.3 → tgshops_integrations-3.0}/tgshops_integrations.egg-info/PKG-INFO +1 -1
  10. {tgshops_integrations-2.3 → tgshops_integrations-3.0}/tgshops_integrations.egg-info/SOURCES.txt +2 -2
  11. tgshops_integrations-2.3/tgshops_integrations/middlewares/gateway.py +0 -117
  12. tgshops_integrations-2.3/tgshops_integrations/nocodb_connector/categories.py +0 -168
  13. tgshops_integrations-2.3/tgshops_integrations/nocodb_connector/client.py +0 -280
  14. tgshops_integrations-2.3/tgshops_integrations/nocodb_connector/model_mapping.py +0 -189
  15. tgshops_integrations-2.3/tgshops_integrations/nocodb_connector/products.py +0 -181
  16. {tgshops_integrations-2.3 → tgshops_integrations-3.0}/README.md +0 -0
  17. {tgshops_integrations-2.3 → tgshops_integrations-3.0}/setup.cfg +0 -0
  18. {tgshops_integrations-2.3 → tgshops_integrations-3.0}/tgshops_integrations/__init__.py +0 -0
  19. {tgshops_integrations-2.3 → tgshops_integrations-3.0}/tgshops_integrations/middlewares/__init__.py +0 -0
  20. {tgshops_integrations-2.3 → tgshops_integrations-3.0}/tgshops_integrations/models/__init__.py +0 -0
  21. {tgshops_integrations-2.3 → tgshops_integrations-3.0}/tgshops_integrations/models/categories.py +0 -0
  22. {tgshops_integrations-2.3 → tgshops_integrations-3.0}/tgshops_integrations/nocodb_connector/__init__.py +0 -0
  23. {tgshops_integrations-2.3 → tgshops_integrations-3.0}/tgshops_integrations/nocodb_connector/tables.py +0 -0
  24. {tgshops_integrations-2.3 → tgshops_integrations-3.0}/tgshops_integrations.egg-info/dependency_links.txt +0 -0
  25. {tgshops_integrations-2.3 → tgshops_integrations-3.0}/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: 2.3
3
+ Version: 3.0
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
@@ -2,7 +2,7 @@ from setuptools import setup,find_packages
2
2
 
3
3
  setup(
4
4
  name='tgshops_integrations',
5
- version='2.3',
5
+ version='3.0',
6
6
  packages=find_packages(),
7
7
  install_requires=[
8
8
  # List your library's dependencies here
@@ -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 services.nocodb_connector.categories import CategoryManager
27
+ from services.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)
@@ -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[str]]
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[float]
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