From f128ec8f3b154fbb2590ac2af8cb7902b53fac66 Mon Sep 17 00:00:00 2001 From: elitonzky Date: Fri, 18 Aug 2023 18:57:46 -0300 Subject: [PATCH] feat: viewsets and models for wpp-cloud catalog and product --- marketplace/clients/base.py | 27 ++ marketplace/clients/exceptions.py | 7 + marketplace/clients/facebook/client.py | 127 +++++++++ .../types/channels/whatsapp_cloud/urls.py | 37 +++ .../types/channels/whatsapp_cloud/views.py | 245 +++++++++++++++++- marketplace/core/types/urls.py | 8 +- marketplace/settings.py | 1 + marketplace/wpp_products/__init__.py | 0 marketplace/wpp_products/apps.py | 6 + .../wpp_products/migrations/0001_initial.py | 238 +++++++++++++++++ .../wpp_products/migrations/__init__.py | 0 marketplace/wpp_products/models.py | 70 +++++ marketplace/wpp_products/parsers.py | 94 +++++++ marketplace/wpp_products/serializers.py | 24 ++ marketplace/wpp_products/tasks.py | 58 +++++ marketplace/wpp_products/views.py | 0 16 files changed, 937 insertions(+), 5 deletions(-) create mode 100644 marketplace/clients/base.py create mode 100644 marketplace/clients/exceptions.py create mode 100644 marketplace/clients/facebook/client.py create mode 100644 marketplace/core/types/channels/whatsapp_cloud/urls.py create mode 100644 marketplace/wpp_products/__init__.py create mode 100644 marketplace/wpp_products/apps.py create mode 100644 marketplace/wpp_products/migrations/0001_initial.py create mode 100644 marketplace/wpp_products/migrations/__init__.py create mode 100644 marketplace/wpp_products/models.py create mode 100644 marketplace/wpp_products/parsers.py create mode 100644 marketplace/wpp_products/serializers.py create mode 100644 marketplace/wpp_products/tasks.py create mode 100644 marketplace/wpp_products/views.py diff --git a/marketplace/clients/base.py b/marketplace/clients/base.py new file mode 100644 index 00000000..647385ff --- /dev/null +++ b/marketplace/clients/base.py @@ -0,0 +1,27 @@ +import requests + +from marketplace.clients.exceptions import CustomAPIException + + +class RequestClient: + def make_request( + self, url: str, method: str, headers=None, data=None, params=None, files=None + ): + response = requests.request( + method=method, + url=url, + headers=headers, + json=data, + timeout=60, + params=params, + files=files, + ) + if response.status_code >= 500: + raise CustomAPIException(status_code=response.status_code) + elif response.status_code >= 400: + raise CustomAPIException( + detail=response.json() if response.text else response.text, + status_code=response.status_code, + ) + + return response diff --git a/marketplace/clients/exceptions.py b/marketplace/clients/exceptions.py new file mode 100644 index 00000000..56b22c3c --- /dev/null +++ b/marketplace/clients/exceptions.py @@ -0,0 +1,7 @@ +from rest_framework.exceptions import APIException + + +class CustomAPIException(APIException): + def __init__(self, detail=None, code=None, status_code=None): + super().__init__(detail, code) + self.status_code = status_code or self.status_code diff --git a/marketplace/clients/facebook/client.py b/marketplace/clients/facebook/client.py new file mode 100644 index 00000000..1a952d8e --- /dev/null +++ b/marketplace/clients/facebook/client.py @@ -0,0 +1,127 @@ +import time + +from django.conf import settings + +from marketplace.clients.base import RequestClient + +WHATSAPP_VERSION = settings.WHATSAPP_VERSION +ACCESS_TOKEN = settings.WHATSAPP_SYSTEM_USER_ACCESS_TOKEN + + +class FacebookAuthorization: + BASE_URL = f"https://graph.facebook.com/{WHATSAPP_VERSION}/" + + def __init__(self): + self.access_token = ACCESS_TOKEN + + def _get_headers(self): + headers = {"Authorization": f"Bearer {self.access_token}"} + return headers + + @property + def get_url(self): + return self.BASE_URL + + +class FacebookClient(FacebookAuthorization, RequestClient): + def create_catalog(self, business_id, name, category=None): + url = self.get_url + f"{business_id}/owned_product_catalogs" + data = {"name": name} + if category: + data["vertical"] = category + + headers = self._get_headers() + response = self.make_request(url, method="POST", headers=headers, data=data) + + return response.json() + + def destroy_catalog(self, catalog_id): + url = self.get_url + f"{catalog_id}" + + headers = self._get_headers() + response = self.make_request(url, method="DELETE", headers=headers) + + return response.json().get("success") + + def create_product_feed(self, product_catalog_id, name): + url = self.get_url + f"{product_catalog_id}/product_feeds" + + data = {"name": name} + headers = self._get_headers() + response = self.make_request(url, method="POST", headers=headers, data=data) + + return response.json() + + def upload_product_feed(self, feed_id, file): + url = self.get_url + f"{feed_id}/uploads" + + headers = self._get_headers() + files = { + "file": ( + file.name, + file, + file.content_type, + ) + } + response = self.make_request(url, method="POST", headers=headers, files=files) + return response.json() + + def get_upload_status(self, feed_id, max_attempts=10, wait_time=30): + """ + Checks the upload status using long polling. + + Args: + upload_id (str): The ID of the upload. + max_attempts (int): Maximum number of polling attempts. Default is 10. + wait_time (int): Wait time in seconds between polling attempts. Default is 30 seconds. + + Returns: + bool or str: True if 'end_time' is found, otherwise a formatted error message. + """ + url = self.get_url + f"{feed_id}/uploads" + headers = self._get_headers() + + attempts = 0 + while attempts < max_attempts: + response = self.make_request(url, method="GET", headers=headers) + data = response.json() + + if data.get("data") and data["data"][0].get("end_time"): + return True + + time.sleep(wait_time) + attempts += 1 + + total_wait_time = wait_time * max_attempts + return ( + f"Unable to retrieve the upload completion status for feed {feed_id}. " + f"Waited for a total of {total_wait_time} seconds." + ) + + def list_products_by_feed(self, feed_id): + url = self.get_url + f"{feed_id}/products" + + headers = self._get_headers() + response = self.make_request(url, method="GET", headers=headers) + + return response.json() + + def list_all_products_by_feed(self, feed_id): + url = self.get_url + f"{feed_id}/products" + headers = self._get_headers() + all_products = [] + + while url: + response = self.make_request(url, method="GET", headers=headers).json() + all_products.extend(response.get("data", [])) + url = response.get("paging", {}).get("next") + + return all_products + + def destroy_feed(self, feed_id): + url = self.get_url + f"{feed_id}" + + headers = self._get_headers() + response = self.make_request(url, method="DELETE", headers=headers) + + return response.json().get("success") diff --git a/marketplace/core/types/channels/whatsapp_cloud/urls.py b/marketplace/core/types/channels/whatsapp_cloud/urls.py new file mode 100644 index 00000000..9508951e --- /dev/null +++ b/marketplace/core/types/channels/whatsapp_cloud/urls.py @@ -0,0 +1,37 @@ +from django.urls import path + +from .views import CatalogViewSet, ProductFeedViewSet + + +urlpatterns = [ + path( + "/catalogs/", + CatalogViewSet.as_view({"post": "create", "get": "list"}), + name="catalog-list-create", + ), + path( + "/catalogs//", + CatalogViewSet.as_view({"get": "retrieve", "delete": "destroy"}), + name="catalog-detail-destroy", + ), + path( + "/catalogs//products/", + CatalogViewSet.as_view({"get": "list_products"}), + name="catalog-products-list", + ), + path( + "/catalogs//product_feeds/", + ProductFeedViewSet.as_view({"post": "create", "get": "list"}), + name="product-feed-list-create", + ), + path( + "/catalogs//product_feeds//", + ProductFeedViewSet.as_view({"get": "retrieve", "delete": "destroy"}), + name="product-feed-detail-destroy", + ), + path( + "/catalogs//product_feeds//products/", + ProductFeedViewSet.as_view({"get": "list_products"}), + name="product-feed-products-list", + ), +] diff --git a/marketplace/core/types/channels/whatsapp_cloud/views.py b/marketplace/core/types/channels/whatsapp_cloud/views.py index ab1017e4..d3589506 100644 --- a/marketplace/core/types/channels/whatsapp_cloud/views.py +++ b/marketplace/core/types/channels/whatsapp_cloud/views.py @@ -1,24 +1,30 @@ import string import requests + + from typing import TYPE_CHECKING from rest_framework.response import Response from rest_framework.exceptions import ValidationError -from rest_framework import status +from rest_framework import ( + status, + viewsets, +) from rest_framework.decorators import action from rest_framework.exceptions import APIException from django.conf import settings from django.utils.crypto import get_random_string - -if TYPE_CHECKING: - from rest_framework.request import Request # pragma: no cover +from django.shortcuts import get_object_or_404 from marketplace.core.types import views from marketplace.applications.models import App from marketplace.celery import app as celery_app from marketplace.connect.client import ConnectProjectClient from marketplace.flows.client import FlowsClient +from marketplace.clients.facebook.client import FacebookClient +from marketplace.wpp_products.models import Catalog, ProductFeed, Product +from marketplace.wpp_products.parsers import ProductFeedParser from ..whatsapp_base import mixins from ..whatsapp_base.serializers import WhatsAppSerializer @@ -29,6 +35,16 @@ from .serializers import WhatsAppCloudConfigureSerializer +from marketplace.wpp_products.serializers import ( + CatalogSerializer, + ProductFeedSerializer, + ProductSerializer, +) + + +if TYPE_CHECKING: + from rest_framework.request import Request # pragma: no cover + class WhatsAppCloudViewSet( views.BaseAppTypeViewSet, @@ -340,3 +356,224 @@ def report_sent_messages(self, request: "Request", **kwargs): ) return Response(status=response.status_code) + + +class CatalogViewSet(viewsets.ViewSet): + serializer_class = CatalogSerializer + + def create(self, request, app_uuid, *args, **kwargs): + app = get_object_or_404(App, uuid=app_uuid, code="wpp-cloud") + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + + name = serializer.validated_data["name"] + business_id = app.config.get("wa_business_id") + category = request.data.get("category") + + if business_id is None: + raise ValidationError( + "The app does not have a business id on [config.wa_business_id]" + ) + + client = FacebookClient() + response = client.create_catalog(business_id, name, category) + + if response: + facebook_catalog_id = response.get("id") + try: + catalog = Catalog.objects.create( + app=app, + facebook_catalog_id=facebook_catalog_id, + name=name, + created_by=self.request.user, + ) + if "catalogs" not in app.config: + app.config["catalogs"] = [] + + app.config["catalogs"].append( + {"facebook_catalog_id": facebook_catalog_id} + ) + app.save() + + flows_client = FlowsClient() + detail_channel = flows_client.detail_channel(app.flow_object_uuid) + + flows_config = detail_channel["config"] + flows_config["catalogs"] = app.config["catalogs"] + + response = flows_client.update_config( + data=flows_config, flow_object_uuid=app.flow_object_uuid + ) + + serializer = CatalogSerializer(catalog) + return Response(serializer.data, status=status.HTTP_201_CREATED) + except Exception as e: + return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) + + return Response(serializer.data, status=status.HTTP_400_BAD_REQUEST) + + def retrieve(self, request, app_uuid, catalog_uuid, *args, **kwargs): + catalog = get_object_or_404(Catalog, uuid=catalog_uuid, app__uuid=app_uuid) + serializer = self.serializer_class(catalog) + return Response(serializer.data) + + def list(self, request, app_uuid, *args, **kwargs): + catalogs = Catalog.objects.filter(app__uuid=app_uuid) + serializer = self.serializer_class(catalogs, many=True) + return Response(serializer.data) + + def destroy(self, request, app_uuid, catalog_uuid, *args, **kwargs): + catalog = get_object_or_404(Catalog, uuid=catalog_uuid, app__uuid=app_uuid) + + client = FacebookClient() + + is_deleted = client.destroy_catalog(catalog.facebook_catalog_id) + + if is_deleted: + if "catalogs" in catalog.app.config: + catalogs_to_remove = [] + + for idx, catalog_entry in enumerate(catalog.app.config["catalogs"]): + if ( + catalog_entry.get("facebook_catalog_id") + == catalog.facebook_catalog_id + ): + catalogs_to_remove.append(idx) + + # Remove backwards to avoid indexing issues + for idx in reversed(catalogs_to_remove): + del catalog.app.config["catalogs"][idx] + catalog.app.save() + + # Update the Flows config after modifying the app config + flows_client = FlowsClient() + detail_channel = flows_client.detail_channel( + catalog.app.flow_object_uuid + ) + flows_config = detail_channel["config"] + flows_config["catalogs"] = catalog.app.config["catalogs"] + + flows_client.update_config( + data=flows_config, flow_object_uuid=catalog.app.flow_object_uuid + ) + + catalog.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + else: + return Response( + {"detail": "Failed to delete catalog on Facebook"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + @action(detail=True, methods=["GET"]) + def list_products(self, request, app_uuid, catalog_uuid, *args, **kwargs): + catalog = get_object_or_404(Catalog, uuid=catalog_uuid, app__uuid=app_uuid) + products = Product.objects.filter(catalog=catalog) + serializer = ProductSerializer(products, many=True) + return Response(serializer.data) + + +class ProductFeedViewSet(viewsets.ViewSet): + serializer_class = ProductFeedSerializer + + def create(self, request, app_uuid, catalog_uuid, *args, **kwargs): + catalog = get_object_or_404(Catalog, uuid=catalog_uuid, app__uuid=app_uuid) + + file_uploaded = request.FILES.get("file") + name = request.data.get("name") + + if file_uploaded is None: + return Response( + {"error": "No file was uploaded"}, status=status.HTTP_400_BAD_REQUEST + ) + + client = FacebookClient() + response = client.create_product_feed( + product_catalog_id=catalog.facebook_catalog_id, name=name + ) + + if "id" not in response: + return Response( + {"error": "Unexpected response from Facebook API", "details": response}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + product_feed = ProductFeed.objects.create( + facebook_feed_id=response["id"], + name=name, + catalog=catalog, + created_by=self.request.user, + ) + response = client.upload_product_feed( + feed_id=product_feed.facebook_feed_id, file=file_uploaded + ) + serializer = ProductFeedSerializer(product_feed) + data = serializer.data.copy() + if "id" not in response: + return Response( + {"error": "The file couldn't be sent. Please try again."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + data["facebook_session_upload_id"] = response["id"] + file_uploaded.seek(0) + + parser = ProductFeedParser(file_uploaded) + file_products = parser.parse_as_dict() + + if file_products is {}: + return Response( + {"error": "Error on parse uploaded file to dict"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + kwargs = dict( + product_feed_uuid=product_feed.uuid, + file_products=file_products, + user_email=self.request.user.email, + ) + if file_products: + celery_app.send_task(name="create_products_by_feed", kwargs=kwargs) + + return Response(data, status=status.HTTP_201_CREATED) + + def retrieve(self, request, app_uuid, catalog_uuid, feed_uuid, *args, **kwargs): + product_feed = get_object_or_404( + ProductFeed, uuid=feed_uuid, catalog__uuid=catalog_uuid + ) + serializer = self.serializer_class(product_feed) + return Response(serializer.data) + + def list(self, request, app_uuid, catalog_uuid, *args, **kwargs): + product_feeds = ProductFeed.objects.filter(catalog__uuid=catalog_uuid) + serializer = self.serializer_class(product_feeds, many=True) + return Response(serializer.data) + + def destroy(self, request, app_uuid, catalog_uuid, feed_uuid, *args, **kwargs): + product_feed = get_object_or_404( + ProductFeed, uuid=feed_uuid, catalog__uuid=catalog_uuid + ) + + client = FacebookClient() + + is_deleted = client.destroy_feed(product_feed.facebook_feed_id) + + if is_deleted: + product_feed.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + else: + return Response( + {"detail": "Failed to delete feed on Facebook"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + @action(detail=True, methods=["GET"]) + def list_products( + self, request, app_uuid, catalog_uuid, feed_uuid, *args, **kwargs + ): + product_feed = get_object_or_404( + ProductFeed, uuid=feed_uuid, catalog__uuid=catalog_uuid + ) + products = Product.objects.filter(feed=product_feed, catalog__uuid=catalog_uuid) + serializer = ProductSerializer(products, many=True) + return Response(serializer.data) diff --git a/marketplace/core/types/urls.py b/marketplace/core/types/urls.py index 9e46df60..267b618c 100644 --- a/marketplace/core/types/urls.py +++ b/marketplace/core/types/urls.py @@ -15,5 +15,11 @@ urlpatterns.append(path(f"apptypes/{apptype.code}/", include(router.urls))) urlpatterns.append( - path("apptypes/generic/", include("marketplace.core.types.channels.generic.urls")) + path("apptypes/generic/", include("marketplace.core.types.channels.generic.urls")), +) +urlpatterns.append( + path( + "apptypes/wpp-cloud/", + include("marketplace.core.types.channels.whatsapp_cloud.urls"), + ), ) diff --git a/marketplace/settings.py b/marketplace/settings.py index ff6129d1..bd987a52 100644 --- a/marketplace/settings.py +++ b/marketplace/settings.py @@ -64,6 +64,7 @@ "marketplace.interactions", "marketplace.grpc", "marketplace.wpp_templates", + "marketplace.wpp_products", # installed apps "rest_framework", "storages", diff --git a/marketplace/wpp_products/__init__.py b/marketplace/wpp_products/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/marketplace/wpp_products/apps.py b/marketplace/wpp_products/apps.py new file mode 100644 index 00000000..d3f6a254 --- /dev/null +++ b/marketplace/wpp_products/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class WppProductConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "marketplace.wpp_products" diff --git a/marketplace/wpp_products/migrations/0001_initial.py b/marketplace/wpp_products/migrations/0001_initial.py new file mode 100644 index 00000000..5950395c --- /dev/null +++ b/marketplace/wpp_products/migrations/0001_initial.py @@ -0,0 +1,238 @@ +# Generated by Django 3.2.4 on 2023-08-31 18:43 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("applications", "0016_app_configured"), + ] + + operations = [ + migrations.CreateModel( + name="Catalog", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "uuid", + models.UUIDField(default=uuid.uuid4, editable=False, unique=True), + ), + ( + "created_on", + models.DateTimeField(auto_now_add=True, verbose_name="Created on"), + ), + ( + "modified_on", + models.DateTimeField(auto_now=True, verbose_name="Modified on"), + ), + ("facebook_catalog_id", models.CharField(max_length=30, unique=True)), + ("name", models.CharField(max_length=100)), + ( + "app", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="catalogs", + to="applications.app", + ), + ), + ( + "created_by", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="created_catalogs", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "modified_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="modified_catalogs", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="ProductFeed", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "uuid", + models.UUIDField(default=uuid.uuid4, editable=False, unique=True), + ), + ( + "created_on", + models.DateTimeField(auto_now_add=True, verbose_name="Created on"), + ), + ( + "modified_on", + models.DateTimeField(auto_now=True, verbose_name="Modified on"), + ), + ("facebook_feed_id", models.CharField(max_length=30, unique=True)), + ("name", models.CharField(max_length=100)), + ( + "status", + models.CharField( + choices=[ + ("pending", "Pending"), + ("error", "Error"), + ("success", "Success"), + ], + default="pending", + max_length=10, + ), + ), + ( + "catalog", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="feeds", + to="wpp_products.catalog", + ), + ), + ( + "created_by", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="created_productfeeds", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "modified_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="modified_productfeeds", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="Product", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "uuid", + models.UUIDField(default=uuid.uuid4, editable=False, unique=True), + ), + ( + "created_on", + models.DateTimeField(auto_now_add=True, verbose_name="Created on"), + ), + ( + "modified_on", + models.DateTimeField(auto_now=True, verbose_name="Modified on"), + ), + ("facebook_product_id", models.CharField(max_length=30, unique=True)), + ("product_retailer_id", models.CharField(max_length=50)), + ("title", models.CharField(max_length=200)), + ("description", models.TextField(max_length=9999)), + ( + "availability", + models.CharField( + choices=[ + ("in stock", "in stock"), + ("out of stock", "out of stock"), + ], + max_length=12, + ), + ), + ( + "condition", + models.CharField( + choices=[ + ("new", "new"), + ("refurbished", "refurbished"), + ("used", "used"), + ], + max_length=11, + ), + ), + ("price", models.CharField(max_length=50)), + ("link", models.URLField()), + ("image_link", models.URLField()), + ("brand", models.CharField(max_length=100)), + ( + "catalog", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="products", + to="wpp_products.catalog", + ), + ), + ( + "created_by", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="created_products", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "feed", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="products", + to="wpp_products.productfeed", + ), + ), + ( + "modified_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="modified_products", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/marketplace/wpp_products/migrations/__init__.py b/marketplace/wpp_products/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/marketplace/wpp_products/models.py b/marketplace/wpp_products/models.py new file mode 100644 index 00000000..e6dccef5 --- /dev/null +++ b/marketplace/wpp_products/models.py @@ -0,0 +1,70 @@ +from django.db import models +from marketplace.core.models import BaseModel +from marketplace.applications.models import App +from django.core.exceptions import ValidationError + + +class Catalog(BaseModel): + facebook_catalog_id = models.CharField(max_length=30, unique=True) + name = models.CharField(max_length=100) + app = models.ForeignKey(App, on_delete=models.CASCADE, related_name="catalogs") + + def __str__(self): + return self.name + + def clean(self) -> None: + super().clean() + if self.app.code != "wpp-cloud": + raise ValidationError("The App must be a 'WhatsApp Cloud' AppType.") + + def save(self, *args, **kwargs): + self.clean() + super().save(*args, **kwargs) + + +class ProductFeed(BaseModel): + facebook_feed_id = models.CharField(max_length=30, unique=True) + name = models.CharField(max_length=100) + catalog = models.ForeignKey(Catalog, on_delete=models.CASCADE, related_name="feeds") + status = models.CharField( + max_length=10, + choices=[("pending", "Pending"), ("error", "Error"), ("success", "Success")], + default="pending", + ) + + def __str__(self): + return self.name + + +class Product(BaseModel): + AVAILABILITY_CHOICES = [("in stock", "in stock"), ("out of stock", "out of stock")] + CONDITION_CHOICES = [ + ("new", "new"), + ("refurbished", "refurbished"), + ("used", "used"), + ] + facebook_product_id = models.CharField(max_length=30, unique=True) + product_retailer_id = models.CharField(max_length=50) + # facebook required fields + title = models.CharField(max_length=200) + description = models.TextField(max_length=9999) + availability = models.CharField(max_length=12, choices=AVAILABILITY_CHOICES) + condition = models.CharField(max_length=11, choices=CONDITION_CHOICES) + price = models.CharField(max_length=50) # Example: "9.99 USD" + link = models.URLField() + image_link = models.URLField() + brand = models.CharField(max_length=100) + + catalog = models.ForeignKey( + Catalog, on_delete=models.CASCADE, related_name="products" + ) + feed = models.ForeignKey( + ProductFeed, + on_delete=models.CASCADE, + related_name="products", + null=True, + blank=True, + ) + + def __str__(self): + return self.title diff --git a/marketplace/wpp_products/parsers.py b/marketplace/wpp_products/parsers.py new file mode 100644 index 00000000..2d4137de --- /dev/null +++ b/marketplace/wpp_products/parsers.py @@ -0,0 +1,94 @@ +import csv +import json +from xml.etree import ElementTree + + +class ProductFeedParser: + def __init__(self, uploaded_file): + self.uploaded_file = uploaded_file + self.content = self.try_decode(uploaded_file.read()) + + def try_decode(self, byte_content, encodings=None): + if encodings is None: + encodings = ["utf-8", "ISO-8859-1", "windows-1252", "utf-16", "utf-32"] + + for encoding in encodings: + try: + return byte_content.decode(encoding) + except UnicodeDecodeError: + continue + + return byte_content.decode("utf-8", errors="replace") + + def detect_format(self): + if self.content.startswith("