Skip to content

Commit 48e5e4c

Browse files
#605 ebay store categories
1 parent 95716fc commit 48e5e4c

25 files changed

Lines changed: 1367 additions & 9 deletions

OneSila/OneSila/settings/base.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,10 @@
429429
"https://api.ebay.com/oauth/api_scope/sell.stores.readonly",
430430
"https://api.ebay.com/oauth/scope/sell.edelivery",
431431
"https://api.ebay.com/oauth/api_scope/commerce.vero",
432+
"https://api.ebay.com/oauth/api_scope/sell.inventory.mapping",
433+
"https://api.ebay.com/oauth/api_scope/commerce.message",
434+
"https://api.ebay.com/oauth/api_scope/commerce.feedback",
435+
"https://api.ebay.com/oauth/api_scope/commerce.shipping",
432436
]
433437

434438

OneSila/media/migrations/0007_auto_20250930_0953.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,6 @@ def populate_image_titles(apps, schema_editor):
2828
media.title = title
2929
media.save(update_fields=['title'])
3030
updated_count += 1
31-
32-
print(f"Updated {updated_count} image media objects with titles")
3331

3432

3533
def reverse_populate_image_titles(apps, schema_editor):
@@ -42,9 +40,6 @@ def reverse_populate_image_titles(apps, schema_editor):
4240

4341
# Clear titles for all IMAGE type media
4442
Media.objects.filter(type=Media.IMAGE).update(title=None)
45-
46-
print("Cleared titles for all IMAGE type media objects")
47-
4843

4944

5045
class Migration(migrations.Migration):

OneSila/sales_channels/integrations/ebay/factories/imports/products_imports.py

Lines changed: 144 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
EbayMediaThroughProduct,
4040
EbayPrice,
4141
EbayProductContent,
42-
EbayEanCode, EbayProductProperty, EbayProductCategory,
42+
EbayEanCode, EbayProductProperty, EbayProductCategory, EbayStoreCategory, EbayProductStoreCategory,
4343
)
4444
from sales_channels.models import SalesChannelIntegrationPricelist, SalesChannelViewAssign
4545
from sales_prices.models import SalesPrice
@@ -1416,6 +1416,10 @@ def _extract_listing_details(*, payload: Mapping[str, Any] | None) -> tuple[str
14161416
view=resolved_view,
14171417
offer_payloads=offer_payloads,
14181418
)
1419+
self._sync_product_store_categories(
1420+
product=local_product,
1421+
offer_payloads=offer_payloads,
1422+
)
14191423

14201424
def _ensure_product_offer(
14211425
self,
@@ -1551,6 +1555,145 @@ def _sync_product_category(
15511555
exc=exc,
15521556
)
15531557

1558+
def _extract_offer_store_category_names(
1559+
self,
1560+
*offer_payloads: Mapping[str, Any] | None,
1561+
) -> list[str] | None:
1562+
"""Return normalized store category paths when explicitly declared by the offer payload."""
1563+
1564+
for payload in offer_payloads:
1565+
if not isinstance(payload, Mapping):
1566+
continue
1567+
1568+
for key in ("store_category_names", "storeCategoryNames"):
1569+
if key not in payload:
1570+
continue
1571+
1572+
raw_values = payload.get(key)
1573+
if isinstance(raw_values, str):
1574+
source_values = [raw_values]
1575+
elif isinstance(raw_values, list):
1576+
source_values = raw_values
1577+
else:
1578+
return []
1579+
1580+
normalized_values: list[str] = []
1581+
for raw_value in source_values:
1582+
if raw_value is None:
1583+
continue
1584+
normalized = str(raw_value).strip()
1585+
if normalized:
1586+
normalized_values.append(normalized)
1587+
1588+
return normalized_values
1589+
1590+
return None
1591+
1592+
def _resolve_store_category_leaf_by_full_path(
1593+
self,
1594+
*,
1595+
full_path: str,
1596+
) -> EbayStoreCategory | None:
1597+
"""Resolve a store-category full path and return the leaf category when it exists."""
1598+
1599+
segments = [segment.strip() for segment in str(full_path).split("/") if segment and segment.strip()]
1600+
if not segments:
1601+
return None
1602+
1603+
parent_id: int | None = None
1604+
current: EbayStoreCategory | None = None
1605+
last_index = len(segments) - 1
1606+
1607+
for index, segment in enumerate(segments):
1608+
queryset = EbayStoreCategory.objects.filter(
1609+
sales_channel=self.sales_channel,
1610+
parent_id=parent_id,
1611+
name__iexact=segment,
1612+
)
1613+
1614+
# The requested path must end on a leaf category.
1615+
if index == last_index:
1616+
queryset = queryset.filter(is_leaf=True)
1617+
1618+
current = queryset.order_by("id").first()
1619+
if current is None:
1620+
return None
1621+
1622+
parent_id = current.id
1623+
1624+
return current
1625+
1626+
def _sync_product_store_categories(
1627+
self,
1628+
*,
1629+
product: Any | None,
1630+
offer_payloads: tuple[Mapping[str, Any] | None, ...],
1631+
) -> None:
1632+
"""Create/update product store-category mapping from offer storeCategoryNames."""
1633+
1634+
if product is None:
1635+
return
1636+
1637+
store_category_names = self._extract_offer_store_category_names(*offer_payloads)
1638+
if store_category_names is None:
1639+
return
1640+
1641+
resolved_categories: list[EbayStoreCategory] = []
1642+
seen_ids: set[int] = set()
1643+
for full_path in store_category_names:
1644+
category = self._resolve_store_category_leaf_by_full_path(full_path=full_path)
1645+
if category is None or category.id in seen_ids:
1646+
continue
1647+
resolved_categories.append(category)
1648+
seen_ids.add(category.id)
1649+
if len(resolved_categories) >= 2:
1650+
break
1651+
1652+
if not resolved_categories:
1653+
return
1654+
1655+
primary_category = resolved_categories[0]
1656+
secondary_category = resolved_categories[1] if len(resolved_categories) > 1 else None
1657+
mapping = (
1658+
EbayProductStoreCategory.objects.filter(
1659+
product=product,
1660+
sales_channel=self.sales_channel,
1661+
)
1662+
.order_by("id")
1663+
.first()
1664+
)
1665+
1666+
try:
1667+
if mapping is None:
1668+
mapping = EbayProductStoreCategory.objects.create(
1669+
multi_tenant_company=self.multi_tenant_company,
1670+
product=product,
1671+
sales_channel=self.sales_channel,
1672+
primary_store_category=primary_category,
1673+
secondary_store_category=secondary_category,
1674+
)
1675+
else:
1676+
update_fields: list[str] = []
1677+
if mapping.primary_store_category_id != primary_category.id:
1678+
mapping.primary_store_category = primary_category
1679+
update_fields.append("primary_store_category")
1680+
desired_secondary_id = secondary_category.id if secondary_category else None
1681+
if mapping.secondary_store_category_id != desired_secondary_id:
1682+
mapping.secondary_store_category = secondary_category
1683+
update_fields.append("secondary_store_category")
1684+
if mapping.sales_channel_id != self.sales_channel.id:
1685+
mapping.sales_channel = self.sales_channel
1686+
update_fields.append("sales_channel")
1687+
if update_fields:
1688+
mapping.save(update_fields=update_fields)
1689+
except ValidationError:
1690+
return
1691+
1692+
EbayProductStoreCategory.objects.filter(
1693+
product=product,
1694+
sales_channel=self.sales_channel,
1695+
).exclude(id=mapping.id).delete()
1696+
15541697
def update_remote_product(
15551698
self,
15561699
import_instance: Any,

OneSila/sales_channels/integrations/ebay/factories/mixins.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ def get_api(self) -> API:
4444
"refresh_token": self.sales_channel.refresh_token,
4545
"refresh_token_expiry": self.sales_channel.refresh_token_expiration.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z",
4646
"email_or_username": "OneSila",
47+
"scopes": settings.EBAY_APPLICATION_SCOPES,
4748
"password": "???" # For some reason username and password are validating even if we provide the
4849
# refresh_token and we can add anything here
4950
}
@@ -126,6 +127,12 @@ def _get_account_api_base_url(self) -> str:
126127

127128
return "https://api.ebay.com/sell/account/v1"
128129

130+
def _get_stores_api_base_url(self) -> str:
131+
if self.sales_channel.environment == EbaySalesChannel.SANDBOX:
132+
return "https://api.sandbox.ebay.com/sell/stores/v1"
133+
134+
return "https://api.ebay.com/sell/stores/v1"
135+
129136
def _get_account_headers(self) -> dict[str, str]:
130137
api = getattr(self, "api", None)
131138
if api is None:
@@ -150,6 +157,23 @@ def _request_account_policy(self, endpoint: str, view_remote_id: str) -> dict:
150157
except ValueError:
151158
return {}
152159

160+
def _request_store(self, *, endpoint: str) -> dict:
161+
"""Call Store API with user token via requests, bypassing broken wrapper auth mode."""
162+
url = f"{self._get_stores_api_base_url()}/{endpoint}"
163+
headers = self._get_account_headers()
164+
response = requests.get(url, headers=headers)
165+
response.raise_for_status()
166+
try:
167+
return response.json()
168+
except ValueError:
169+
return {}
170+
171+
def get_store(self) -> dict:
172+
return self._request_store(endpoint="store")
173+
174+
def get_store_categories(self) -> dict:
175+
return self._request_store(endpoint="store/categories")
176+
153177
def get_fulfillment_policies(self) -> dict:
154178
return self._request_account_policy("fulfillment_policy", self.view.remote_id)
155179

OneSila/sales_channels/integrations/ebay/factories/products/mixins.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
render_sales_channel_content_template,
3030
)
3131

32-
from sales_channels.integrations.ebay.models import EbayCategory, EbayProductCategory
32+
from sales_channels.integrations.ebay.models import EbayCategory, EbayProductCategory, EbayProductStoreCategory
3333
from sales_channels.integrations.ebay.models.products import (
3434
EbayMediaThroughProduct,
3535
EbayProductOffer,
@@ -1851,6 +1851,33 @@ def _build_offer_metadata(self) -> Dict[str, Any]:
18511851

18521852
return metadata
18531853

1854+
def _get_store_category_names(self, *, product) -> List[str]:
1855+
row = (
1856+
EbayProductStoreCategory.objects.filter(
1857+
product=product,
1858+
sales_channel=self.sales_channel,
1859+
)
1860+
.select_related("primary_store_category", "secondary_store_category")
1861+
.order_by("-id")
1862+
.first()
1863+
)
1864+
1865+
if row is None:
1866+
return []
1867+
1868+
names: List[str] = []
1869+
if row.primary_store_category:
1870+
primary_path = row.primary_store_category.full_path
1871+
if primary_path and primary_path not in names:
1872+
names.append(primary_path)
1873+
1874+
if row.secondary_store_category:
1875+
secondary_path = row.secondary_store_category.full_path
1876+
if secondary_path and secondary_path not in names:
1877+
names.append(secondary_path)
1878+
1879+
return names[:2]
1880+
18541881
def _apply_offer_section(self, *, payload: Dict[str, Any], key: str, value: Any) -> None:
18551882
if isinstance(value, Mapping):
18561883
if value:
@@ -1882,6 +1909,11 @@ def build_offer_payload(self) -> Dict[str, Any]:
18821909
key="secondaryCategoryId",
18831910
value=secondary_category_id,
18841911
)
1912+
self._apply_offer_section(
1913+
payload=payload,
1914+
key="storeCategoryNames",
1915+
value=self._get_store_category_names(product=product) or None,
1916+
)
18851917
self._apply_offer_section(
18861918
payload=payload,
18871919
key="listingPolicies",

OneSila/sales_channels/integrations/ebay/factories/sales_channels/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from .languages import EbayRemoteLanguagePullFactory
44
from .views import EbaySalesChannelViewPullFactory
55
from .categories import EbayCategorySuggestionFactory
6+
from .store_categories import EbayStoreCategoryPullFactory
67
from .full_schema import EbayProductTypeRuleFactory
78
from .product_type_mapping import EbayProductTypeRemoteMappingFactory
89

@@ -14,5 +15,6 @@
1415
'EbaySalesChannelViewPullFactory',
1516
'EbayProductTypeRuleFactory',
1617
'EbayCategorySuggestionFactory',
18+
'EbayStoreCategoryPullFactory',
1719
'EbayProductTypeRemoteMappingFactory',
1820
]

0 commit comments

Comments
 (0)