diff --git a/README.md b/README.md index c7155f1..822206f 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,14 @@ This README aims to document functionality of backend as well as required steps - [Load location data](#load-location-data) - [Load taxonomy data](#load-taxonomy-data) - [Endpoints](#endpoints) +- [Shop Integrations](#shop-integrations) + - [WooCommerce](#woocommerce) - [Product Search](#product-search) - [Massive Data Load Endpoints](#massive-data-load-endpoints) - [COOP and Managing User Data Load](#coop-and-managing-user-data-load) - [Product Data Load](#product-data-load) - [GeoIP Setup](#geoip-setup) +- [Tags](#tags) - [Development Utils](#development-utils) - [Fake product data generation](#fake-product-data-generation) @@ -169,6 +172,30 @@ Location ednpoints: - `/api/v1/cities/` +## Shop Integrations + +We provide integrations with online shop platforms + +It requires the json field `Company.credentials` to have the appropiate format and values + +Endoint: `/api/v1/companies/{PK}/import_products/` + +The software to handle different platform imports can be found in `utils` + +### WooCommerce + +Credential format: + +```json +{ + "key": "qwerweqr", + "secret": "asdfsa", +} +``` + +Method: `utils.woocommerce.migrate_shop_products` + + ## Product Search Endpoint: `/api/v1/product_search/` @@ -231,6 +258,22 @@ Optional: `sudo apt install libmaxminddb0 libmaxminddb-dev mmdb-bin` +## Tags + +Both `Company` and `Product` models make use of tags. + +### Load shopping taxonomy + +To create the initial set of tags, we can use the `addtaxonomy` management command. +Reads the data from `datasets/shop-taxonomy.es-ES.txt` which is from google shopping + + +### Top-level tags + +In order to extract the top level tags for use as categories, we can use the `extractparenttas` management command. +It saves the results to `datasets/top_tags.txt` + + ## Development Utils ### Fake product data generation diff --git a/companies/models.py b/companies/models.py index 8b8a470..3d0efdd 100644 --- a/companies/models.py +++ b/companies/models.py @@ -1,12 +1,15 @@ # from django.db import models from django.contrib.gis.db import models - +from django.contrib.postgres.fields import JSONField +from django.contrib.auth import get_user_model from tagulous.models import TagField -# from core.models import TreeTag - +from history.models import HistorySync # Create your models here. +# User = get_user_model() + + class Company(models.Model): WOO_COMMERCE = 'WOO_COMMERCE' @@ -42,14 +45,13 @@ class Company(models.Model): sync = models.BooleanField('Sincronizar tienda', default=False, null=True, blank=True) is_validated = models.BooleanField('Validado', default=False, null=True, blank=True) is_active = models.BooleanField('Activado', default=False, null=True, blank=True) + credentials = JSONField(null=True) # internal created = models.DateTimeField('date of creation', auto_now_add=True) updated = models.DateTimeField('date last update', auto_now=True) creator = models.ForeignKey('core.CustomUser', on_delete=models.DO_NOTHING, null=True, related_name='creator') - - def __str__(self): - return self.company_name + # history = models.ForeignKey(HistorySync, null=True, on_delete=models.DO_NOTHING, related_name='company') class Meta: verbose_name = "Compañía" diff --git a/companies/views.py b/companies/views.py index a2114ae..18634a5 100644 --- a/companies/views.py +++ b/companies/views.py @@ -19,6 +19,8 @@ from companies.serializers import CompanySerializer from utils.tag_filters import CompanyTagFilter from back_latienda.permissions import IsCreator +from utils import woocommerce + class CompanyViewSet(viewsets.ModelViewSet): queryset = Company.objects.all() @@ -35,8 +37,7 @@ class CompanyViewSet(viewsets.ModelViewSet): Send email to company.creator """ try: - queryset = self.get_custom_queryset(request) - instance = queryset.filter(pk=kwargs['pk']).first() + instance = self.queryset.filter(pk=kwargs['pk']).first() if instance: # IP stuff client_ip, is_routable = get_client_ip(request) @@ -121,6 +122,35 @@ class CompanyViewSet(viewsets.ModelViewSet): except Exception as e: return Response({"errors":{"details": str(e),}}, status=500) + @action(detail=True, methods=['GET', ]) + def import_products(self, request, **kwargs): + instance = self.queryset.filter(pk=kwargs['pk']).first() + # check if it's a shop + if instance.shop is not True: + return Response({'error': 'This company is not a shop'}) + # check required credentials + credentials = instance.credentials + if credentials is None or credentials == {}: + return Response({'error': 'This company has no registered credentials'}) + # check what platform + platform = instance.platform + if platform is None: + message = {'error': 'This company is not registered with any platforms'} + elif platform == 'WOO_COMMERCE': + # recheck credentials + if 'key' in credentials.keys() and 'secret' in credentials.keys(): + # execute import + products = woocommerce.migrate_shop_products( + instance.web_link, + credentials['key'], + credentials['secret']) + message = {'details': f'{len(products)} products added for {instance.company_name}'} + else: + message = {"error": 'Credentials have wrong format'} + else: + message = {'error': f'Platform {plaform} not registered'} + return Response(message) + @api_view(['GET',]) @permission_classes([IsAuthenticated,]) diff --git a/core/management/commands/extractparenttags.py b/core/management/commands/extractparenttags.py new file mode 100644 index 0000000..7e14657 --- /dev/null +++ b/core/management/commands/extractparenttags.py @@ -0,0 +1,31 @@ +import logging + +from django.core.management.base import BaseCommand +from django.conf import settings + +from core.models import TreeTag + + +class Command(BaseCommand): + + help = 'Extract top level tags' + + def handle(self, *args, **kwargs): + # get all instances + tags = TreeTag.objects.all() + top_tags = [] + + print("Extracting top-level tags from TreeTag instances") + # extract tags with no ancestor + for tag in tags: + if not tag.get_ancestors(): + top_tags.append(tag.name) + + print("Saving top-level tags to file") + # save results to dataset/top_tags.txt + path = f"{settings.BASE_DIR}/../datasets/top_tags.txt" + with open(path, 'wt') as f: + f.writelines([tag + '\n' for tag in top_tags]) + + # print out results + logging.info(f"Extracted {len(top_tags)} to {path}") diff --git a/datasets/shop-taxonomy.es-ES.txt b/datasets/shop-taxonomy.es-ES.txt index 6633f29..0332dcf 100644 --- a/datasets/shop-taxonomy.es-ES.txt +++ b/datasets/shop-taxonomy.es-ES.txt @@ -1,4 +1,3 @@ -# Google_Product_Taxonomy_Version: 2015-02-19 Alimentación, bebida y tabaco Alimentación, bebida y tabaco/Alimentos Alimentación, bebida y tabaco/Alimentos/Aliños y especias @@ -3786,7 +3785,7 @@ Equipamiento deportivo/Equipamiento para atletismo/Gimnasia/Trampolines Equipamiento deportivo/Equipamiento para atletismo/Hockey sobre hierba y lacrosse Equipamiento deportivo/Equipamiento para atletismo/Hockey sobre hierba y lacrosse/Conjuntos de equipamiento para lacrosse Equipamiento deportivo/Equipamiento para atletismo/Hockey sobre hierba y lacrosse/Equipamiento de protección para hockey sobre hierba y lacrosse -Equipamiento deportivo/Equipamiento para atletismo/Hockey sobre hierba y lacrosse/Equipamiento de protección para hockey sobre hierba y lacrosse/Almohadillas de hombros para lacrosse y hockey sobre hierba +Equipamiento deportivo/Equipamiento para atletismo/Hockey sobre hierba y lacrosse/Equipamiento de protección para hockey sobre hierba y lacrosse/Almohadillas de hombros para lacrosse y hockey sobre hierba Equipamiento deportivo/Equipamiento para atletismo/Hockey sobre hierba y lacrosse/Equipamiento de protección para hockey sobre hierba y lacrosse/Cascos para hockey sobre hierba y lacrosse Equipamiento deportivo/Equipamiento para atletismo/Hockey sobre hierba y lacrosse/Equipamiento de protección para hockey sobre hierba y lacrosse/Guantes para hockey sobre hierba y lacrosse Equipamiento deportivo/Equipamiento para atletismo/Hockey sobre hierba y lacrosse/Equipamiento de protección para hockey sobre hierba y lacrosse/Máscaras y gafas protectoras para hockey sobre hierba y lacrosse diff --git a/datasets/top_tags.txt b/datasets/top_tags.txt new file mode 100644 index 0000000..d09f51d --- /dev/null +++ b/datasets/top_tags.txt @@ -0,0 +1,21 @@ +Alimentación, bebida y tabaco +Arte y ocio +Bebés y niños pequeños +Bricolaje +Cámaras y ópticas +Casa y jardín +Economía e industria +Electrónica +Elementos religiosos y ceremoniales +Equipamiento deportivo +Juegos y juguetes +Maletas y bolsos de viaje +Material de oficina +Mobiliario +Multimedia +Productos para adultos +Productos para mascotas y animales +Ropa y accesorios +Salud y belleza +Software +Vehículos y recambios diff --git a/products/models.py b/products/models.py index 14cc0a9..cd739dd 100644 --- a/products/models.py +++ b/products/models.py @@ -35,7 +35,7 @@ class Product(models.Model): discount = models.DecimalField('Descuento', max_digits=5, decimal_places=2, null=True, blank=True) stock = models.PositiveIntegerField('Stock', null=True) tags = TagField(to=TreeTag) - category = SingleTagField(null=True) # main tag category + category = SingleTagField(blank=True, null=True) # main tag category attributes = TagField(to=TreeTag, related_name='product_attributes') identifiers = models.TextField('Identificador único de producto', null=True, blank=True) diff --git a/products/serializers.py b/products/serializers.py index 0f09d36..0cd0dee 100644 --- a/products/serializers.py +++ b/products/serializers.py @@ -7,6 +7,18 @@ from utils.tag_serializers import TagListSerializerField, TaggitSerializer, Sing class ProductSerializer(TaggitSerializer, serializers.ModelSerializer): + tags = TagListSerializerField(required=False) + category = SingleTagSerializerField(required=False) # main tag category + attributes = TagListSerializerField(required=False) + # image = serializers.SerializerMethodField() + + class Meta: + model = Product + exclude = ['created', 'updated', 'creator'] + + +class ProductSearchSerializer(TaggitSerializer, serializers.ModelSerializer): + tags = TagListSerializerField(required=False) category = SingleTagSerializerField(required=False) # main tag category attributes = TagListSerializerField(required=False) diff --git a/products/views.py b/products/views.py index f9f356b..61a332e 100644 --- a/products/views.py +++ b/products/views.py @@ -18,7 +18,7 @@ from rest_framework.decorators import api_view, permission_classes, action import requests from products.models import Product -from products.serializers import ProductSerializer, TagFilterSerializer +from products.serializers import ProductSerializer, TagFilterSerializer, ProductSearchSerializer from companies.models import Company from history.models import HistorySync @@ -48,7 +48,7 @@ class ProductViewSet(viewsets.ModelViewSet): @action(detail=True, methods=['GET',]) def related(request): - # find the most similar products + # TODO: find the most similar products return Response(data=[]) @@ -175,7 +175,7 @@ def product_search(request): # extract filters from result_set filters = extract_search_filters(result_set) # serialize and respond - product_serializer = ProductSerializer(result_set, many=True) + product_serializer = ProductSearchSerializer(result_set, many=True, context={'request': request}) return Response(data={"filters": filters, "products": product_serializer.data}) except Exception as e: return Response({"errors": {"details": str(type(e))}}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/requirements.txt b/requirements.txt index 05d3342..cb2021b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,4 +12,5 @@ django-tagulous==1.1.0 Pillow==8.1.0 drf-extra-fields==3.0.4 django-ipware==3.0.2 -geoip2==4.1.0 \ No newline at end of file +geoip2==4.1.0 +woocommerce==2.1.1 diff --git a/utils/woocommerce.py b/utils/woocommerce.py new file mode 100644 index 0000000..c341176 --- /dev/null +++ b/utils/woocommerce.py @@ -0,0 +1,115 @@ +""" +This file holds the functions necesary to: + +- Connect to seller's shop API +- Load information on seller [??] +- Load product information + +""" +import logging + +from woocommerce import API + +from companies.models import Company +from products.models import Product +from products.serializers import ProductSerializer + + +def get_wcapi_instance(url, key, secret, version="wc/v3"): + wcapi = API( + url=url, + consumer_key=key, + consumer_secret=secret, + wp_api=True, + version=version + ) + return wcapi + + +def migrate_shop_products(url, key, secret, version="wc/v3"): + # get wcapi + wcapi = get_wcapi_instance(url, key, secret, version) + + consumer_key = 'ck_565539bb25b472b1ff7a209eb157ca11c0a26397' + consumer_secret = 'cs_9c1690ba5da0dd70f51d61c395628fa14d1a104c' + + # get company fom url + company = Company.objects.filter(web_link=url).first() + + if not company: + # logging.error(f"Could not find Company with URL: {url}") + # print(f"Could not find Company with URL: {url}") + # return None + # TODO: ELIMINATE THIS AFTER DEBUGGING + company = Company.objects.create(web_link=url) + logging.error(f"Created Company for testing: {url}") + + # list products + response = wcapi.get('/products/') + if response.status_code == 200: + products = response.json() + elif response.status_code == 401: + logging.error(f"{response.status_code} [{response.url}]: {response.json()}") + return None + else: + logging.error(f"Could not load products from {url}: [{response.status_code}]") + print(f"Could not load products fom {url}: [{response.status_code}]") + return None + + product_fields = [f.name for f in Product._meta.get_fields()] + counter = 0 + products_created = [] + for product in products: + instance_data = {'company':company.id} + # parse the product info + for key in product: + if key in product_fields: + instance_data[key] = product[key] + # remove unwanted fields + instance_data.pop('id') + # extract m2m field data + tags = instance_data.pop('tags') + attributes = instance_data.pop('attributes') + + # create instance with serializer + ''' + serializer = ProductSerializer(data=instance_data) + if serializer.is_valid(): + new = serializer.save() + if tags: + new.tags.set(tags) + if attributes: + new.attributes.set(attributes) + new.save() + else: + logging.error(f"{serializer.errors}") + continue + ''' + # alternative method + serializer = ProductSerializer(data=instance_data) + if serializer.is_valid(): + try: + new = Product.objects.create(**serializer.validated_data) + if tags: + new.tags.set(tags) + if attributes: + new.attributes.set(attributes) + new.save() + except Exception as e: + logging.error(f"Could not create product instance: {str(e)}") + else: + logging.error(f"{serializer.errors}") + continue + + products_created.append(new) + counter += 1 + logging.info(f"Product {instance_data.get('name')} created") + print(f"Product {instance_data.get('name')} created") + + print(f"Products created: {counter}") + return products_created + + + + +