diff --git a/.gitignore b/.gitignore index aa7ea9b..8c73834 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ # environment variables .env + # virtual env venv/ diff --git a/README.md b/README.md index c7546df..b45a011 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,11 @@ This README aims to document functionality of backend as well as required steps - [First Steps](#first-steps) - [Load location data](#load-location-data) - [Load taxonomy data](#load-taxonomy-data) -- [Endpoints](#endpoints) +- [Company Endpoints](#company-endpoints) +- [Product Endpoints](#product-endpoints) +- [Core Endpoints](#core-endpoints) +- [History Endpoints](#history-endpoints) +- [Stats Endpoints](#stats-endpoints) - [Shop Integrations](#shop-integrations) - [WooCommerce](#woocommerce) - [Product Search](#product-search) @@ -57,25 +61,172 @@ This data serves as initial Tags To load initial set of tags: `python manage.py addtaxonomy` -## Endpoints +## Company Endpoints + +### CompanyViewSet + +Queryset: validated Company instances only + +Permissions: + +- anon user: safe methods +- auth user: full access where user is company creator + +### MyCompanyViewSet + +Queryset: Company instances where user is creator + +Permissions: + +- anon user: no access +- auth user: full access + +### AdminCompanyViewSet + +Queryset: all Company instances, validated or not + +Permissions: only accesible to authenticated users with role `SITE_ADMIN` -## Pagination +### random_company_sample -By default a `LimitOffsetPagination` pagination is enabled +Method view that returns a randome sample of companies -Examples: `http://127.0.0.1:8000/api/v1/products/?limit=10&offset=0` +By default it returns 6 instances, but can be customized through parameter `size` -The response data has the following keys: -``` -dict_keys(['count', 'next', 'previous', 'results']) -``` + +## Product Endpoints + +### ProductViewSet + +Endpoint url: `/api/v1/products/` + +Queryset: active Product instances only + +Permissions: + +- anon user: safe methods +- auth user: full access where user is product creator + +### MyProductsViewSet + +Endpoint url: `/api/v1/my_products/` + +Queryset: Product instances where user is creator + +Permissions: + +- anon user: no access +- auth user: full access + + +### AdminProductsViewSet + +Endpoint url: `/api/v1/admin_products/` + +Queryset: all Product instances, acgtive or not + +Permissions: only accesible to authenticated users with role `SITE_ADMIN` + +### load_coop_products [POST] + +Endpoint url: `/api/v1/load_products/` + +Method view that reads a CSV file. + +### product_search [GET] + +Endpoint url: `/api/v1/search_products/` + +Allows searching of Products to all users + +Parameters: + +- q: used for search [MANDATORY] +- limit: max number of returned instances [OPTIONAL] +- offset: where to start counting results [OPTIONAL] +- shipping_cost: true/false +- discount: true/false +- category: string +- tags: string +- order: string (newest/oldest) +- price_min: int +- price_max: int + + +### purchase_email [POST] + +Endpoint url: `/api/v1/purchase_email/` + +Sends email to company manager about the product that the user wants to purchase, and sends confirmation email to user. + +Parameters: + +- email: mandatory for anonymous users +- telephone +- company +- product +- comment + +## Core Endpoints + +### CustomUserViewSet + +Endpoint url: `/api/v1/users/` + +Queryset: all CustomUser instances + +Permissions: + +- anon user: only POST to register new user +- auth user: no access +- admin user: full access + +### ChangeUserPasswordView + +Ednpoint url: `/api/v1/user/change_password//` + +Permissions: only accessible for your own user instance + + +### UpdateUserView + +Endpoint url: `/api/v1/user/update/` + +Permissions: only accessible for your own user instance + + +### create_company_user [POST] + +Edndpoint: `/api/v1/create_company_user/` + +Simultaneously create a company and its related user + +NOT WORKING!!! + +### my_user [GET] + +Endpoint url: `/api/v1/my_user/` + +Returns instance of authenticated user + +### load_coop_managers [POST] + +Ednpoint url: `/api/v1/load_coops/` + +For each row it creates a Company instance, and a user instance linked to the company, with role `COOP_MANAGER` + +### activate_user + +Endpoint: `/activate///` + +This endpoint is reached from the URL sent to the user after their registration ### User Management Creation: -- endpoint: /api/v1/users/ +- endpoint: `/api/v1/users/` - method: GET - payload: ```json @@ -149,32 +300,21 @@ To create user: ``` -### Companies - -Endpoint url: `/api/v1/companies/` - -To get company linked to authenticated user: `/api/v1/my_company/` - -### Products - -Endpoint url: `/api/v1/products/` - -To get products linked to authenticated user: `/api/v1/my_products/` - -### History +## History Endpoints Endpoint url: `/api/v1/history/`: Historical records about product importation -### Stats +## Stats Endpoints + Endpoint url: `/api/v1/stats/` logs about user interaction with products links -### Locations +## Location Endpoints Location ednpoints: @@ -183,6 +323,7 @@ Location ednpoints: - `/api/v1/provinces/` - `/api/v1/cities/` +Tables filled with data from `datasets/gadm36_ESP.gpkg` with `loadgisdata` command. ## Shop Integrations diff --git a/back_latienda/permissions.py b/back_latienda/permissions.py index 992f43e..4d9cb9f 100644 --- a/back_latienda/permissions.py +++ b/back_latienda/permissions.py @@ -35,6 +35,23 @@ class IsStaff(permissions.BasePermission): return request.user.is_staff +class IsSiteAdmin(permissions.BasePermission): + """ + Grant permission if request.user.role == 'SITE_ADMIN' + """ + + admin_role = 'SITE_ADMIN' + + def has_object_permission(self, request, view, obj): + if request.user.is_authenticated: + return request.user.role == self.admin_role + return False + + def has_permission(self, request, view): + if request.user.is_authenticated: + return request.user.role == self.admin_role + return False + class ReadOnly(permissions.BasePermission): def has_permission(self, request, view): @@ -68,3 +85,4 @@ class YourOwnUserPermissions(permissions.BasePermission): return True else: return False + diff --git a/back_latienda/routers.py b/back_latienda/routers.py index f0c9c26..83385ca 100644 --- a/back_latienda/routers.py +++ b/back_latienda/routers.py @@ -1,8 +1,8 @@ from rest_framework import routers from core.views import CustomUserViewSet -from companies.views import CompanyViewSet -from products.views import ProductViewSet +from companies.views import CompanyViewSet, AdminCompanyViewSet +from products.views import ProductViewSet, MyProductsViewSet, AdminProductsViewSet from history.views import HistorySyncViewSet from stats.views import StatsLogViewSet @@ -13,7 +13,10 @@ router = routers.DefaultRouter() router.register('users', CustomUserViewSet, basename='users') router.register('companies', CompanyViewSet, basename='company') +router.register('admin_companies', AdminCompanyViewSet, basename='admin-companies') router.register('products', ProductViewSet, basename='product') +router.register('my_products', MyProductsViewSet, basename='my-products') +router.register('admin_products', AdminProductsViewSet, basename='admin-products') router.register('history', HistorySyncViewSet, basename='history') router.register('stats', StatsLogViewSet, basename='stats') diff --git a/back_latienda/settings/base.py b/back_latienda/settings/base.py index d7249b5..4996fc6 100644 --- a/back_latienda/settings/base.py +++ b/back_latienda/settings/base.py @@ -34,10 +34,9 @@ SECRET_KEY = 'td*#7t-(1e9^(g0cod*hs**dp(%zvg@=$cug_-dtzcj#i2mrz@' # Application definition INSTALLED_APPS = [ + 'suit', 'dal', 'dal_select2', - 'suit', - 'django.contrib.admin', 'django.contrib.auth', @@ -57,6 +56,7 @@ INSTALLED_APPS = [ 'anymail', 'storages', 'mapwidgets', + 'django_admin_listfilter_dropdown', # local apps 'core', @@ -170,3 +170,6 @@ MAP_WIDGETS = { ), "GOOGLE_MAP_API_KEY": os.getenv('GOOGLE_MAP_API_KEY') } + +# ACTIVATION_REDIRECT URL +ACTIVATION_REDIRECT = os.getenv('ACTIVATION_REDIRECT') diff --git a/back_latienda/settings/production.py b/back_latienda/settings/production.py index 46fb796..eafc231 100644 --- a/back_latienda/settings/production.py +++ b/back_latienda/settings/production.py @@ -40,7 +40,9 @@ AWS_DEFAULT_ACL = None DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage' EMAIL_BACKEND = "anymail.backends.amazon_ses.EmailBackend" -DEFAULT_FROM_EMAIL = "no-reply@latienda.com" +# DEFAULT_FROM_EMAIL = "no-reply@latienda.com" +DEFAULT_FROM_EMAIL = "info@latienda.coop" + SERVER_EMAIL = "mail-server@latienda.com" ANYMAIL = { diff --git a/back_latienda/urls.py b/back_latienda/urls.py index b60dd0c..b18a320 100644 --- a/back_latienda/urls.py +++ b/back_latienda/urls.py @@ -26,6 +26,7 @@ from companies import views as company_views from stats import views as stat_views from .routers import router +admin.site.site_header = 'LaTiendaCOOP Administration' urlpatterns = [ path('admin/', admin.site.urls), @@ -34,14 +35,16 @@ urlpatterns = [ path('api/v1/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), path('api/v1/token/verify/', TokenVerifyView.as_view(), name='token_verify'), path('api/v1/user/change_password//', core_views.ChangeUserPasswordView.as_view(), name="change-password"), + path('api/v1/admin_stats/', core_views.admin_stats, name='admin-stats'), path('api/v1/load_coops/', core_views.load_coop_managers, name='coop-loader'), path('api/v1/load_products/', product_views.load_coop_products, name='product-loader'), path('api/v1/search_products/', product_views.product_search, name='product-search'), path('api/v1/create_company_user/', core_views.create_company_user, name='create-company-user'), path('api/v1/my_user/', core_views.my_user, name='my-user'), - path('api/v1/my_company/', company_views.my_company , name='my-company'), + path('api/v1/my_company/', company_views.my_company, name='my-company'), path('api/v1/companies/sample/', company_views.random_company_sample , name='company-sample'), - path('api/v1/my_products/', product_views.my_products, name='my-products'), + path('api/v1/purchase_email/', product_views.purchase_email, name='purchase-email'), + path('api/v1/products/all_categories/', product_views.all_categories, name='all-categories'), path('api/v1/stats/me/', stat_views.track_user, name='user-tracker'), path('api/v1/autocomplete/category-tag/', product_views.CategoryTagAutocomplete.as_view(), name='category-autocomplete'), path('api/v1/', include(router.urls)), diff --git a/companies/admin.py b/companies/admin.py index 5dd4451..2e21305 100644 --- a/companies/admin.py +++ b/companies/admin.py @@ -1,6 +1,7 @@ from django.contrib import admin from django.contrib.gis.db.models import PointField +from django_admin_listfilter_dropdown.filters import DropdownFilter from mapwidgets.widgets import GooglePointFieldWidget from . import models @@ -8,9 +9,9 @@ from . import models # Register your models here. class CompanyAdmin(admin.ModelAdmin): - list_display = ('short_name', 'city', 'email', 'shop', 'platform', 'sync', 'is_validated', 'is_active') - list_filter = ('platform', 'sync', 'is_validated', 'is_active', 'city') - search_fields = ('short_name', 'company_name', 'email', 'url') + list_display = ('short_name', 'city', 'email', 'shop', 'platform', 'sync', 'is_validated', 'is_active', 'link') + list_filter = ('platform', 'sync', 'is_validated', 'is_active', ('city', DropdownFilter)) + search_fields = ('short_name', 'company_name', 'email', 'web_link', 'city') formfield_overrides = { PointField: {"widget": GooglePointFieldWidget} diff --git a/companies/models.py b/companies/models.py index 84baff0..15022fc 100644 --- a/companies/models.py +++ b/companies/models.py @@ -24,7 +24,7 @@ class Company(models.Model): cif = models.CharField('CIF', max_length=15, null=True, blank=True) company_name = models.CharField('Razón Social Cooperativa', max_length=1000, null=True, blank=True) - short_name = models.CharField('Apócope', max_length=100, null=True, blank=True) # autogenerar si blank + short_name = models.CharField('Nombre', max_length=100, null=True, blank=True) # autogenerar si blank web_link = models.URLField('Enlace a la web', null=True, blank=True) shop = models.BooleanField('Tienda Online', null=True, default=False) shop_link = models.URLField('Enlace a la tienda', null=True, blank=True) @@ -52,8 +52,21 @@ class Company(models.Model): 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, blank=True, related_name='creator') - # history = models.ForeignKey(HistorySync, null=True, on_delete=models.DO_NOTHING, related_name='company') + history = models.ForeignKey(HistorySync, null=True, blank=True, on_delete=models.DO_NOTHING, related_name='comp_hist') class Meta: verbose_name = "Compañía" verbose_name_plural = "Compañías" + + def __str__(self): + if self.short_name: + return self.short_name + elif self.company_name: + return self.company_name + else: + return f"Compañía #{self.id}" + + def link(self): + return self.shop_link is not None + + link.boolean = True diff --git a/companies/tests.py b/companies/tests.py index 07189da..1ad084f 100644 --- a/companies/tests.py +++ b/companies/tests.py @@ -7,7 +7,7 @@ from django.test import TestCase from rest_framework.test import APITestCase from rest_framework import status -from companies.factories import ValidatedCompanyFactory +from companies.factories import ValidatedCompanyFactory, CompanyFactory from companies.models import Company from core.factories import CustomUserFactory @@ -146,7 +146,7 @@ class CompanyViewSetTest(APITestCase): 'logo': None, 'city': None, 'address': 'qwer qewr 5', - 'geo': None, + 'geo': {'longitude': 1.0, 'latitude': 1.0}, 'phone': '1234', 'mobile': '4321', 'other_phone': '41423', @@ -293,6 +293,7 @@ class CompanyViewSetTest(APITestCase): # check order self.assertTrue(response.data[0]['id'] > response.data[1]['id']) + # TODO: test email_manager action class MyCompanyViewTest(APITestCase): """CompanyViewset tests @@ -313,7 +314,9 @@ class MyCompanyViewTest(APITestCase): def test_auth_user_gets_data(self): # create instance - user_instances = [self.factory(creator=self.user) for i in range(5)] + company = CompanyFactory() + self.user.company = company + self.user.save() # Authenticate token = get_tokens_for_user(self.user) @@ -321,32 +324,10 @@ class MyCompanyViewTest(APITestCase): # Query endpoint response = self.client.get(self.endpoint) - payload = response.json() - # Assert forbidden code self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEquals(len(user_instances), len(payload)) - - def test_auth_user_can_paginate_instances(self): - """authenticated user can paginate instances - """ - - # Authenticate - token = get_tokens_for_user(self.user) - self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token['access']}") - - # create instances - instances = [self.factory(creator=self.user) for n in range(12)] - - # Request list - url = f"{self.endpoint}?limit=5&offset=10" - response = self.client.get(url) - - # Assert access is allowed - self.assertEqual(response.status_code, status.HTTP_200_OK) - # assert only 2 instances in response payload = response.json() - self.assertEquals(2, len(payload)) + self.assertEquals(payload['company']['id'], company.id) def test_anon_user_cannot_access(self): # send in request @@ -404,3 +385,168 @@ class RandomCompanySampleTest(APITestCase): self.assertEquals(size, len(payload)) # test IDs not correlative (eventually it could be, because it's random) self.assertTrue(payload[0]['id'] != (payload[1]['id'] + 1)) + + +class AdminCompanyViewSetTest(APITestCase): + + def setUp(self): + """Tests setup + """ + self.endpoint = '/api/v1/admin_companies/' + self.factory = CompanyFactory + self.model = Company + # create user + self.email = f"user@mail.com" + self.password = ''.join(random.choices(string.ascii_uppercase, k = 10)) + self.user = CustomUserFactory(email=self.email, is_active=True) + self.user.set_password(self.password) + self.user.save() + + def test_anon_user_cannot_access(self): + instance = self.factory() + url = f"{self.endpoint}{instance.id}/" + # GET + response = self.client.get(self.endpoint) + # check response + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + # POST + response = self.client.post(self.endpoint, data={}) + # check response + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + # PUT + response = self.client.get(url, data={}) + # check response + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + # delete + response = self.client.get(url) + # check response + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_auth_user_cannot_access(self): + # Authenticate + token = get_tokens_for_user(self.user) + self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token['access']}") + + instance = self.factory() + url = f"{self.endpoint}{instance.id}/" + # GET + response = self.client.get(self.endpoint) + # check response + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + # POST + response = self.client.post(self.endpoint, data={}) + # check response + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + # PUT + response = self.client.get(url, data={}) + # check response + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + # delete + response = self.client.get(url) + # check response + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_admin_user_can_list(self): + # make user site amdin + self.user.role = 'SITE_ADMIN' + self.user.save() + + # Authenticate + token = get_tokens_for_user(self.user) + self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token['access']}") + + # create instances + instance = [self.factory() for i in range(random.randint(1,5))] + # query endpoint + response = self.client.get(self.endpoint) + + # assertions + self.assertEquals(response.status_code, 200) + payload = response.json() + self.assertEquals(len(instance), len(payload)) + + def test_admin_user_can_get_details(self): + # make user site amdin + self.user.role = 'SITE_ADMIN' + self.user.save() + + # Authenticate + token = get_tokens_for_user(self.user) + self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token['access']}") + + # create instances + instance = self.factory() + url = f"{self.endpoint}{instance.id}/" + # query endpoint + response = self.client.get(url) + + # assertions + self.assertEquals(response.status_code, 200) + payload = response.json() + self.assertEquals(instance.id, payload['id']) + + def test_admin_can_create_instance(self): + # make user site amdin + self.user.role = 'SITE_ADMIN' + self.user.save() + + # Authenticate + token = get_tokens_for_user(self.user) + self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token['access']}") + + # create instances + data = { + 'short_name': 'test_compnay short _name', + } + # query endpoint + response = self.client.post(self.endpoint, data=data) + + # assertions + self.assertEquals(response.status_code, 201) + payload = response.json() + self.assertEquals(data['short_name'], payload['short_name']) + + def test_admin_can_update_instance(self): + # make user site amdin + self.user.role = 'SITE_ADMIN' + self.user.save() + + # Authenticate + token = get_tokens_for_user(self.user) + self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token['access']}") + + # create instance + instance = self.factory() + url = f"{self.endpoint}{instance.id}/" + + # data + data = { + 'short_name': 'test_compnay short _name', + } + # query endpoint + response = self.client.put(url, data=data) + + # assertions + self.assertEquals(response.status_code, 200) + payload = response.json() + self.assertEquals(data['short_name'], payload['short_name']) + + def test_admin_can_delete_instance(self): + # make user site amdin + self.user.role = 'SITE_ADMIN' + self.user.save() + + # Authenticate + token = get_tokens_for_user(self.user) + self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token['access']}") + + # create instance + instance = self.factory() + url = f"{self.endpoint}{instance.id}/" + + # query endpoint + response = self.client.delete(url) + + # assertions + self.assertEquals(response.status_code, 204) + diff --git a/companies/views.py b/companies/views.py index 436bd58..f6ebe7e 100644 --- a/companies/views.py +++ b/companies/views.py @@ -18,8 +18,8 @@ from stats.models import StatsLog from companies.models import Company from companies.serializers import CompanySerializer from utils.tag_filters import CompanyTagFilter -from back_latienda.permissions import IsCreator from rest_framework import filters +from back_latienda.permissions import IsCreator, IsSiteAdmin from utils import woocommerce @@ -69,11 +69,13 @@ class CompanyViewSet(viewsets.ModelViewSet): # send email to company subject = "Contacto de usuario" email = EmailMessage(subject, company_message, to=[instance.creator.email]) + email.content_subtype = "html" email.send() logging.info(f"Email sent to {instance.creator.email} as manager of {instance.name}") # send confirmation email to user subject = 'Confirmación de contacto' email = EmailMessage(subject, message, to=[request.user.email]) + email.content_subtype = "html" email.send() logging.info(f"Contact confirmation email sent to {request.user.email}") stats_data = { @@ -102,10 +104,12 @@ class CompanyViewSet(viewsets.ModelViewSet): }) # send email to company email = EmailMessage(subject, company_message, to=[instance.creator.email]) + email.content_subtype = "html" email.send() logging.info(f"Email sent to {instance.creator.email} as manager of {instance.name}") # send confirmation email to user email = EmailMessage(subject, user_message, to=[data['email']]) + email.content_subtype = "html" email.send() logging.info(f"Contact confirmation email sent to anonymous user {data['email']}") # statslog data to register interaction @@ -159,23 +163,37 @@ class CompanyViewSet(viewsets.ModelViewSet): return Response(message) -@api_view(['GET',]) -@permission_classes([IsAuthenticated,]) +@api_view(['GET']) +@permission_classes([IsAuthenticated]) def my_company(request): - limit = request.GET.get('limit') - offset = request.GET.get('offset') - qs = Company.objects.filter(creator=request.user) - company_serializer = CompanySerializer(qs, many=True) - data = company_serializer.data - # RESULTS PAGINATION - if limit is not None and offset is not None: - limit = int(limit) - offset = int(offset) - data = data[offset:(limit+offset)] - elif limit is not None: - limit = int(limit) - data = data[:limit] - return Response(data=data) + if request.user.company: + serializer = CompanySerializer(request.user.company) + return Response({'company': serializer.data}) + else: + return Response(status=status.HTTP_406_NOT_ACCEPTABLE) + +''' +class MyCompanyViewSet(viewsets.ModelViewSet): + model = Company + serializer_class = CompanySerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + return self.model.objects.filter(company=self.request.user.company) + + def perform_create(self, serializer): + serializer.save(creator=self.request.user) +''' + +class AdminCompanyViewSet(viewsets.ModelViewSet): + """ Allows user with role 'SITE_ADMIN' to access all company instances + """ + queryset = Company.objects.all() + serializer_class = CompanySerializer + permission_classes = [IsSiteAdmin] + + def perform_create(self, serializer): + serializer.save(creator=self.request.user) @api_view(['GET',]) diff --git a/core/admin.py b/core/admin.py index 81f9dab..3e21527 100644 --- a/core/admin.py +++ b/core/admin.py @@ -1,8 +1,10 @@ from django.contrib import admin + from . import models # Register your models here. + class UserAdmin(admin.ModelAdmin): list_display = ('email', 'full_name', 'role', 'company', 'email_verified', 'is_active', 'is_staff', 'created', 'last_visit') list_filter = ('is_active', 'is_staff', 'email_verified') diff --git a/core/apps.py b/core/apps.py index 26f78a8..8925cc3 100644 --- a/core/apps.py +++ b/core/apps.py @@ -1,5 +1,47 @@ from django.apps import AppConfig +from suit.apps import DjangoSuitConfig +from suit.menu import ParentItem, ChildItem + + +class SuitConfig(DjangoSuitConfig): + layout = 'horizontal' + menu = ( + ParentItem('Usuarios', children=[ + ChildItem('Usuarios', model='core.CustomUser'), + ], icon='fa fa-leaf'), + + ParentItem('Cooperativas', children=[ + ChildItem('Cooperativas', model='companies.Company'), + ], icon='fa fa-leaf'), + + ParentItem('Productos', children=[ + ChildItem('Productos', model='products.Product'), + ], icon='fa fa-leaf'), + + ParentItem('Categorías', children=[ + ChildItem('Categorías', model='products.categoryTag'), + ChildItem('Tags', model='products.TreeTag'), + ChildItem('Atributos', model='products.AttributeTag'), + ], icon='fa fa-leaf'), + + ParentItem('Importación', children=[ + ChildItem('Historial', model='history.History'), + ChildItem('Logs', model='stats.StatsLog'), + ], icon='fa fa-leaf'), + + ParentItem('Otros', children=[ + ChildItem('Grupos',model='auth.group'), + ChildItem('Países', model='geo.Country'), + ChildItem('Regiones', model='geo.Region'), + ChildItem('Provincias', model='geo.Province'), + ChildItem('Municipios', model='geo.City'), + ], icon='fa fa-leaf'), + ) + + def ready(self): + super(SuitConfig, self).ready() + class CoreConfig(AppConfig): name = 'core' diff --git a/core/models.py b/core/models.py index 3c49a74..89d995f 100644 --- a/core/models.py +++ b/core/models.py @@ -24,6 +24,8 @@ class UserManager(BaseUserManager): def create_user(self, email, password=None, **extra_fields): extra_fields.setdefault('is_superuser', False) + extra_fields.setdefault('is_staff', False) + extra_fields.setdefault('is_active', False) return self._create_user(email, password, **extra_fields) def create_superuser(self, email, password, **extra_fields): diff --git a/core/serializers.py b/core/serializers.py index e71c71f..0121405 100644 --- a/core/serializers.py +++ b/core/serializers.py @@ -23,7 +23,7 @@ class CustomUserWriteSerializer(serializers.ModelSerializer): class Meta: model = models.CustomUser - fields = ('email', 'full_name', 'role', 'password', 'provider') + fields = ('email', 'full_name', 'role', 'password', 'provider', 'notify') class CreatorSerializer(serializers.ModelSerializer): @@ -85,7 +85,7 @@ class UpdateUserSerializer(serializers.ModelSerializer): class Meta: model = models.CustomUser - fields = ('full_name', 'email') + fields = ('full_name', 'email', 'notify') def validate_email(self, value): user = self.context['request'].user diff --git a/core/tests.py b/core/tests.py index bc48c2c..6144a64 100644 --- a/core/tests.py +++ b/core/tests.py @@ -7,11 +7,14 @@ import csv from django.test import TestCase from django.core import mail +from django.utils.http import urlsafe_base64_encode +from django.utils.encoding import force_bytes +from django.conf import settings from rest_framework.test import APITestCase from rest_framework import status -from core.utils import get_tokens_for_user +from core.utils import get_tokens_for_user, account_activation_token from companies.models import Company @@ -39,7 +42,7 @@ class CustomUserViewSetTest(APITestCase): self.user = self.factory(email=self.reg_email, password=self.password, is_active=True) # anon user - def test_anon_user_can_create_active_instance(self): + def test_anon_user_can_create_inactive_instance(self): """Not logged-in user can create new instance of User but it's inactive """ data = { @@ -55,7 +58,11 @@ class CustomUserViewSetTest(APITestCase): self.assertEqual(response.status_code, status.HTTP_201_CREATED) # assert instance is inactive info = json.loads(response.content) - self.assertTrue(info['is_active']) + self.assertFalse(info['is_active']) + # Assert instance exists on db + self.assertTrue(self.model.objects.get(email=info['email'])) + # assert verification email + self.assertTrue(len(mail.outbox) == 1) def test_anon_user_cannot_modify_existing_instance(self): """Not logged-in user cannot modify existing instance @@ -155,6 +162,30 @@ class CustomUserViewSetTest(APITestCase): # Assert access is forbidden self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + def test_auth_user_can_modify_own_instance(self): + """Regular user can modify own instance + """ + # Create instance + data = { + "email": "new_email@mail.com", + "full_name": "New Full Name", + 'notify': True, + } + + # Authenticate + token = get_tokens_for_user(self.user) + self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token['access']}") + + # Query endpoint + url = f'{self.endpoint}{self.user.pk}/' + response = self.client.put(url, data=data, format='json') + # Assert forbidden code + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # Assert instance has been modified + for key in data: + self.assertEqual(data[key], response.data[key]) + # admin user def test_admin_user_can_create_instance(self): """Admin user can create new instance @@ -182,6 +213,8 @@ class CustomUserViewSetTest(APITestCase): # Assert instance exists on db self.assertTrue(self.model.objects.get(email=response.data['email'])) + # assert verification email + self.assertTrue(len(mail.outbox) == 1) def test_admin_user_can_modify_existing_instance(self): """Admin user can modify existing instance @@ -324,99 +357,6 @@ class ChangeUserPasswordViewTest(APITestCase): self.assertEqual(stored_password_hash, base64.b64encode(new_password_hash).decode()) -class UpdateUserViewTest(APITestCase): - - def setUp(self): - """Tests setup - """ - self.endpoint = '/api/v1/users/' - self.factory = factories.CustomUserFactory - self.model = models.CustomUser - # create regular user - self.reg_email = f"user@mail.com" - self.password = ''.join(random.choices(string.ascii_uppercase, k = 10)) - self.user = self.factory(email=self.reg_email, is_active=True) - self.user.set_password(self.password) - self.user.save() - # create admin user - self.admin_email = f"admin_user@mail.com" - self.admin_user = self.factory(email=self.admin_email, is_staff=True, is_active=True) - self.admin_user.set_password(self.password) - self.admin_user.save() - - def test_auth_user_can_modify_own_instance(self): - """Regular user can modify own instance - """ - # Create instance - data = { - "email": "new_email@mail.com", - "full_name": "New Full Name", - 'provider': 'PROVIDER', - 'notify': True, - } - - # Authenticate - token = get_tokens_for_user(self.user) - self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token['access']}") - - # Query endpoint - url = f'{self.endpoint}{self.user.pk}/' - response = self.client.put(url, data=data, format='json') - # Assert forbidden code - self.assertEqual(response.status_code, status.HTTP_200_OK) - - def test_auth_user_cannot_modify_random_instance(self): - """Regular user cannot modify randnom instance - """ - # Create instance - instance = self.factory() - - # Authenticate - token = get_tokens_for_user(self.user) - self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token['access']}") - - # Query endpoint - url = f'{self.endpoint}{instance.pk}/' - response = self.client.put(url, data={}, format='json') - # Assert forbidden code - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_anon_user_cannot_modify_random_instance(self): - """anon user cannot modify instance - """ - # Create instance - instance = self.factory() - - # Query endpoint - url = f'{self.endpoint}{instance.pk}/' - response = self.client.put(url, data={}, format='json') - # Assert forbidden code - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - - def test_admin_user_can_modify_random_instance(self): - """Regular user cannot modify randnom instance - """ - # Create instance - instance = self.factory() - - # Authenticate - token = get_tokens_for_user(self.admin_user) - self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token['access']}") - - data = { - "email": "new_email@mail.com", - "full_name": "New Full Name", - 'provider': 'PROVIDER', - 'notify': True, - } - - # Query endpoint - url = f'{self.endpoint}{instance.pk}/' - response = self.client.put(url, data=data, format='json') - # Assert forbidden code - self.assertEqual(response.status_code, status.HTTP_200_OK) - - class LoadCoopManagerTestCase(APITestCase): def setUp(self): @@ -533,3 +473,252 @@ class MyUserViewTest(APITestCase): # check response self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + +class ActivateUserTest(APITestCase): + + def setUp(self): + self.endpoint = 'activate///' + self.factory = factories.CustomUserFactory + self.model = models.CustomUser + # create user + self.email = f"user@mail.com" + self.password = ''.join(random.choices(string.ascii_uppercase, k = 10)) + self.user = self.factory(email=self.email, is_active=False) + self.user.set_password(self.password) + self.user.save() + + def test_correct_activation(self): + # create values + uid = urlsafe_base64_encode(force_bytes(self.user.pk)) + token = account_activation_token.make_token(self.user) + + url = f'/activate/{uid}/{token}/' + + response = self.client.get(url) + + # assertions + self.assertEquals(response.status_code, 302) + self.assertEquals(response.url, settings.ACTIVATION_REDIRECT) + + def test_correct_activation_no_redirect(self): + # set ACTIVATION_REDIRECT to '' + settings.ACTIVATION_REDIRECT = '' + # create values + uid = urlsafe_base64_encode(force_bytes(self.user.pk)) + token = account_activation_token.make_token(self.user) + + url = f'/activate/{uid}/{token}/' + + response = self.client.get(url) + + # assertions + self.assertEquals(response.status_code, 200) + self.assertTrue(self.user.email in str(response.content)) + + def test_bad_activation(self): + # create values + uid = urlsafe_base64_encode(force_bytes(self.user.pk))[:-1] + token = account_activation_token.make_token(self.user)[:-1] + + url = f'/activate/{uid}/{token}/' + + response = self.client.get(url) + + # assertions + self.assertEquals(response.status_code, 406) + self.assertTrue('error' in response.json()) + + +class CreateCompanyUserTest(APITestCase): + + def setUp(self): + self.endpoint = '/api/v1/create_company_user/' + self.factory = factories.CustomUserFactory + self.model = models.CustomUser + # create user + self.email = "user@mail.com" + self.password = ''.join(random.choices(string.ascii_uppercase, k = 10)) + self.user = self.factory(email=self.email, is_active=True) + self.user.set_password(self.password) + self.user.save() + + def test_auth_user_can_create(self): + # instances data + data = { + 'user': { + 'email': 'test@email.com', + 'full_name': 'TEST NAME', + 'password': 'VENTILADORES1234499.89', + }, + 'company': { + 'cif': 'qwerewq', + 'company_name': 'qwerewq', + 'short_name': 'qwerewq', + 'web_link': 'http://qwerewq.com', + 'shop': True, + 'shop_link': 'http://qwerewq.com', + 'platform': 'PRESTASHOP', + 'email': 'test@email.com', + 'logo': None, + 'city': None, + 'address': 'qwer qewr 5', + 'geo': {'longitude': 1.0, 'latitude': 1.0}, + 'phone': '1234', + 'mobile': '4321', + 'other_phone': '41423', + 'description': 'dfgfdgdfg', + 'shop_rss_feed': 'http://qwerewq.com', + 'sale_terms': 'tewrnmfew f ewfrfew ewewew f', + 'shipping_cost': '12.25', + 'sync': False + } + } + + # Authenticate + token = get_tokens_for_user(self.user) + self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token['access']}") + + response = self.client.post(self.endpoint, data=data, format='json') + + self.assertEquals(response.status_code, 201) + self.assertEquals(len(mail.outbox), 1) + # user exists and it's inactice + self.assertTrue(self.model.objects.get(email='test@email.com')) + self.assertFalse(self.model.objects.get(email='test@email.com').is_active) + self.assertTrue(Company.objects.get(cif='qwerewq')) + # assert verification email + self.assertTrue(len(mail.outbox) == 1) + + def test_anon_user_can_create(self): + data = { + 'user': { + 'email': 'test@email.com', + 'full_name': 'TEST NAME', + 'password': 'VENTILADORES1234499.89', + }, + 'company': { + 'cif': 'qwerewq', + 'company_name': 'qwerewq', + 'short_name': 'qwerewq', + 'web_link': 'http://qwerewq.com', + 'shop': True, + 'shop_link': 'http://qwerewq.com', + 'platform': 'PRESTASHOP', + 'email': 'test@email.com', + 'logo': None, + 'city': None, + 'address': 'qwer qewr 5', + 'geo': {'longitude': 1.0, 'latitude': 1.0}, + 'phone': '1234', + 'mobile': '4321', + 'other_phone': '41423', + 'description': 'dfgfdgdfg', + 'shop_rss_feed': 'http://qwerewq.com', + 'sale_terms': 'tewrnmfew f ewfrfew ewewew f', + 'shipping_cost': '12.25', + 'sync': False + } + } + + response = self.client.post(self.endpoint, data=data, format='json') + + self.assertEquals(response.status_code, 201) + self.assertEquals(len(mail.outbox), 1) + # user exists and it's inactice + self.assertTrue(self.model.objects.get(email='test@email.com')) + self.assertFalse(self.model.objects.get(email='test@email.com').is_active) + self.assertTrue(Company.objects.get(cif='qwerewq')) + # assert verification email + self.assertTrue(len(mail.outbox) == 1) + + def test_creation_error(self): + + response = self.client.post(self.endpoint, data={}, format='json') + + self.assertEquals(response.status_code, 406) + self.assertEquals(len(mail.outbox), 0) + + +class AdminStatsTest(APITestCase): + + def setUp(self): + self.endpoint = '/api/v1/admin_stats/' + self.factory = factories.CustomUserFactory + self.model = models.CustomUser + # create user + self.email = "user@mail.com" + self.password = ''.join(random.choices(string.ascii_uppercase, k = 10)) + self.user = self.factory(email=self.email, is_active=True) + self.user.set_password(self.password) + self.user.save() + + # anonymous user + def test_anon_user_cannot_crud(self): + """Not logged-in user cannot access endpoint at all + """ + + # Query endpoint + response = self.client.get(self.endpoint) + # Assert access is forbidden + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + # Query endpoint + response = self.client.post(self.endpoint, data={}) + # Assert access is forbidden + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + # Query endpoint + response = self.client.put(self.endpoint, data={}) + # Assert access is forbidden + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + # Query endpoint + response = self.client.delete(self.endpoint) + # Assert access is forbidden + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + # authenticated user + def test_auth_user_cannot_crud(self): + """Authenticated user cannot access endpoint at all + """ + # Authenticate user + token = get_tokens_for_user(self.user) + self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token['access']}") + + # Query endpoint + response = self.client.get(self.endpoint) + # Assert access is forbidden + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + # Query endpoint + response = self.client.post(self.endpoint, data={}) + # Assert access is forbidden + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + # Query endpoint + response = self.client.put(self.endpoint, data={}) + # Assert access is forbidden + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + # Query endpoint + response = self.client.delete(self.endpoint) + # Assert access is forbidden + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + # admin user + def test_admin_can_get_data(self): + # make user admin + self.user.is_staff = True + self.user.save() + + # Authenticate + token = get_tokens_for_user(self.user) + self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token['access']}") + + response = self.client.get(self.endpoint) + self.assertEquals(response.status_code, 200) + payload = response.json() + expected_entries = ['company_count', 'product_count', 'companies_per_region', 'products_per_region', 'companies_timeline', 'products_timeline', 'users_timeline', 'contact_timeline', 'shopping_timeline'] + for name in expected_entries: + self.assertTrue(name in payload) + diff --git a/core/utils.py b/core/utils.py index 4fd5e41..ad0f96e 100644 --- a/core/utils.py +++ b/core/utils.py @@ -81,6 +81,7 @@ def send_verification_email(request, user): email = EmailMessage( subject, message, to=[user.email] ) + email.content_subtype = "html" email.send() logging.info(f"Verification email sent to {user.email}") except Exception as e: diff --git a/core/views.py b/core/views.py index f3c90ef..6b23ebe 100644 --- a/core/views.py +++ b/core/views.py @@ -10,7 +10,9 @@ from django.contrib.auth import get_user_model from django.utils.http import urlsafe_base64_decode from django.utils.encoding import force_text from django.db import IntegrityError -from django.contrib.gis.geos import Point +from django.contrib.gis.geos import Point, GEOSGeometry +from django.shortcuts import redirect +from django.conf import settings from rest_framework import status from rest_framework import viewsets @@ -20,7 +22,10 @@ from rest_framework.generics import UpdateAPIView from rest_framework.decorators import api_view, permission_classes from companies.models import Company -from geo.models import City +from companies.serializers import CompanySerializer +from products.models import Product +from geo.models import City, Region +from stats.models import StatsLog from . import models from . import serializers as core_serializers @@ -43,9 +48,9 @@ logging.basicConfig( class CustomUserViewSet(viewsets.ModelViewSet): - model = models.CustomUser + model = User model_name = 'custom_user' - queryset = models.CustomUser.objects.all() + queryset = User.objects.all() permission_classes = [CustomUserPermissions,] read_serializer_class = core_serializers.CustomUserReadSerializer write_serializer_class = core_serializers.CustomUserWriteSerializer @@ -76,9 +81,11 @@ class CustomUserViewSet(viewsets.ModelViewSet): if serializer.is_valid(): # save model instance data password = serializer.validated_data.pop('password') - instance = self.model(**serializer.validated_data) + instance = self.model.objects.create_user(**serializer.validated_data) instance.set_password(password) instance.save() + # send verification email + utils.send_verification_email(request, instance) return Response(self.read_serializer_class( instance, many=False, context={'request': request}).data, @@ -98,63 +105,47 @@ class ChangeUserPasswordView(UpdateAPIView): serializer_class = core_serializers.ChangePasswordSerializer -class UpdateUserView(UpdateAPIView): - - model = models.CustomUser - queryset = model.objects.all() - permission_classes = (YourOwnUserPermissions,) - serializer_class = core_serializers.UpdateUserSerializer - - @api_view(['POST',]) -@permission_classes([CustomUserPermissions,]) +@permission_classes([AllowAny]) def create_company_user(request): """ - Create non-validated company and manager user associated + Create non-validated company and associated managing user """ - user_data = { - 'full_name': request.data['user']['full_name'], - 'email': request.data['user']['email'], - 'password': request.data['user']['password'] - } - company_data = { - 'cif': request.data['company']['cif'], - 'company_name': request.data['company']['company_name'], - 'short_name': request.data['company']['short_name'], - 'web_link': request.data['company']['web_link'], - 'shop': request.data['company']['shop'], - 'city': request.data['company']['city'], - 'geo': request.data['company']['geo'], - 'address': request.data['company']['address'] - } - try: - user = models.CustomUser.objects.create(email=user_data['email'], full_name=user_data['full_name']) - except IntegrityError as e: - return Response({"errors": {"details": str(e)}}, status=status.HTTP_409_CONFLICT) + if 'user' not in request.data: + return Response({"error": "Missing parameter: user"}, status=406) + if 'company' not in request.data: + return Response({"error": "Missing parameter: company"}, status=406) - try: - city = company_data.pop('city') - #city = City.objects.get(name=city) + # create company + company_data = request.data['company'] + company_serializer = CompanySerializer( + data=company_data, + ) + if company_serializer.is_valid(): + # save model instance data + new_company = Company.objects.create(**company_serializer.validated_data) + else: + return Response({"error": company_serializer.errors}, status=406) - geo = company_data.pop('geo') - geo = Point(geo['latitude'],geo['longitude']) + # create user + user_data = request.data['user'] + user_data['role'] = 'COOP_MANAGER' + user_data['company'] = new_company.id + user_serializer = core_serializers.CustomUserWriteSerializer( + data=user_data, + ) + if user_serializer.is_valid(): + # save model instance data + password = user_serializer.validated_data.pop('password') + new_user = User.objects.create_user(**user_serializer.validated_data) + new_user.set_password(password) + new_user.save() + # send verification email + utils.send_verification_email(request, new_user) + else: + return Response({"error": user_serializer.errors}, status=406) - company = Company.objects.create(**company_data, city=city, geo=geo) - except Exception as e: - user.delete() - return Response({"errors": {"details": str(e)}}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - - user.set_password(user_data['password']) - user.company = company - user.role = 'COOP_MANAGER' - user.save() - - company.creator = user - company.save() - - serializer = core_serializers.CustomUserSerializer(user) - - return Response(data=serializer.data,status=status.HTTP_201_CREATED) + return Response(status=status.HTTP_201_CREATED) @api_view(['GET',]) @@ -168,7 +159,6 @@ def my_user(request): return Response({'error': {str(type(e))}}, status=500) - @api_view(['POST',]) @permission_classes([IsAdminUser,]) def load_coop_managers(request): @@ -200,10 +190,66 @@ def activate_user(request, uidb64, token): except (TypeError, ValueError, OverflowError, User.DoesNotExist): user = None - if user is not None and account_activation_token.check_token(user, token): + if user is not None and utils.account_activation_token.check_token(user, token): # activate user user.is_active = True user.save() - return HttpResponse(f"Tu cuenta de usuario {request.user.email} ha sido activada") + if settings.ACTIVATION_REDIRECT: + return redirect(settings.ACTIVATION_REDIRECT) + return Response(f"Tu cuenta de usuario {user.email} ha sido activada") else: - return HttpResponse(f"Tu token de verificacion no coincide con ningún usuario registrado") + return Response({"error": f"Tu token de verificacion no coincide con ningún usuario registrado"}, status=status.HTTP_406_NOT_ACCEPTABLE) + + +@api_view(['GET',]) +@permission_classes([IsAdminUser,]) +def admin_stats(request): + company_count = Company.objects.count() + product_count = Product.objects.count() + companies_per_region = {} + products_per_region = {} + + for region in Region.objects.all(): + count = Company.objects.filter(geo__within=region.geo).count() + companies_per_region[region.name] = count + count = Product.objects.filter(company__geo__within=region.geo).count() + products_per_region[region.name] = count + + today = datetime.date.today() + # companies timeline: count companies at increments of 4 weeks + companies_timeline = {} + # products timeline: count products at increments of 4 weeks + products_timeline = {} + # users timeline: count users at increments of 4 weeks + users_timeline = {} + # contact timeline: count statlogs from contact at increments of 4 weeks + contact_timeline = {} + # shopping timeline: count statlogs from shopping at increments of 4 weeks + shopping_timeline = {} + for i in range(1, 13): + before = today - datetime.timedelta(weeks=( (i-1) * 4 )) + after = today - datetime.timedelta(weeks=(i*4)) + # companies + companies_timeline[i] = Company.objects.filter(created__range= [after, before]).count() + # products + products_timeline[i] = Product.objects.filter(created__range= [after, before]).count() + # users + users_timeline[i] = User.objects.filter(created__range= [after, before]).count() + # contact + contact_timeline[i] = StatsLog.objects.filter(contact=True, created__range= [after, before]).count() + # shopping + shopping_timeline[i] = StatsLog.objects.filter(shop=True, created__range= [after, before]).count() + + data = { + 'company_count': company_count, + 'product_count': product_count, + 'companies_per_region': companies_per_region, + 'products_per_region': products_per_region, + 'companies_timeline': companies_timeline, + 'products_timeline': products_timeline, + 'users_timeline': users_timeline, + 'contact_timeline': contact_timeline, + 'shopping_timeline': shopping_timeline, + } + + return Response(data=data) diff --git a/example.env b/example.env index 66a53de..87e699b 100644 --- a/example.env +++ b/example.env @@ -14,4 +14,6 @@ AWS_SECRET_ACCESS_KEY_SES = '' WC_KEY = '' WC_SECRET = '' # GOOGLE MAPS -GOOGLE_MAP_API_KEY = '' \ No newline at end of file +GOOGLE_MAP_API_KEY = '' +# USER ACTIVATION REDIRECTION +ACTIVATION_REDIRECT = '' \ No newline at end of file diff --git a/geo/models.py b/geo/models.py index 9d1dec9..f2cbd9a 100644 --- a/geo/models.py +++ b/geo/models.py @@ -73,7 +73,10 @@ class City(models.Model): updated = models.DateTimeField('date last update', auto_now=True) def __str__(self): - return f'{self.name} [{self.province}]' + if self.province: + return f'{self.name} [{self.province}]' + else: + return f'{self.name}' class Meta: verbose_name = "Municipio" diff --git a/history/admin.py b/history/admin.py index a60a2d3..adeeefb 100644 --- a/history/admin.py +++ b/history/admin.py @@ -1,7 +1,22 @@ from django.contrib import admin +from django_admin_listfilter_dropdown.filters import RelatedDropdownFilter, ChoiceDropdownFilter + from . import models # Register your models here. -admin.site.register(models.HistorySync) +class HistoryAdmin(admin.ModelAdmin): + list_display = ('company_name', 'rss_url', 'sync_date', 'result', 'quantity',) + list_filter = ( + ('company', RelatedDropdownFilter), + ) + + def company_name(self, instance): + if instance.company and instance.company.company_name: + return instance.company.company_name + else: + return 'NULL' + + +admin.site.register(models.HistorySync, HistoryAdmin) diff --git a/products/admin.py b/products/admin.py index 94cc68e..8d42fcb 100644 --- a/products/admin.py +++ b/products/admin.py @@ -1,23 +1,29 @@ from django.contrib import admin +from django_admin_listfilter_dropdown.filters import DropdownFilter, RelatedDropdownFilter, ChoiceDropdownFilter + from . import models from . import forms # Register your models here. + +def model_admin_callable(co): + return co.company_name + class ProductAdmin(admin.ModelAdmin): form = forms.ProductTagForm - list_display = ('name', 'category', 'sourcing_date', 'company') - list_filter = ('company', 'tags', 'category', 'attributes') + list_display = ('name', 'category', 'sourcing_date', 'company', 'active' ) + list_filter = ( + ('company', RelatedDropdownFilter), + ('tags', RelatedDropdownFilter), + ('category', RelatedDropdownFilter), + ('attributes', RelatedDropdownFilter) + ) + search_fields = ('name', 'sku', 'description') -''' -class ProductInline(admin.TabularInline): - model = models.Product - form = forms.ProductTagForm -''' - admin.site.register(models.Product, ProductAdmin) admin.site.register(models.TreeTag) admin.site.register(models.CategoryTag) diff --git a/products/models.py b/products/models.py index 50cde7f..7ee89fd 100644 --- a/products/models.py +++ b/products/models.py @@ -64,13 +64,13 @@ class Product(models.Model): category = SingleTagField(to=CategoryTag, null=True, blank=True, on_delete=models.SET_NULL) # main tag category attributes = TagField(to=AttributeTag, blank=True, related_name='product_attributes') identifiers = models.TextField('Identificador único de producto', null=True, blank=True) - active = models.BooleanField('Mostrar producto', default=False) + active = models.BooleanField('Activo', default=False) # 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.SET_NULL, null=True, related_name='product') - history = models.ForeignKey('history.HistorySync', on_delete=models.SET_NULL, null=True, related_name='product') + creator = models.ForeignKey('core.CustomUser', on_delete=models.SET_NULL, null=True, blank=True, related_name='product') + history = models.ForeignKey('history.HistorySync', on_delete=models.SET_NULL, null=True, blank=True, related_name='product') def __str__(self): return f"{self.name} / {self.sku}" diff --git a/products/tests.py b/products/tests.py index e654ea3..d0f979b 100644 --- a/products/tests.py +++ b/products/tests.py @@ -6,14 +6,14 @@ from urllib.parse import quote from django.utils import timezone from django.test import TestCase +from django.core import mail from rest_framework.test import APITestCase from rest_framework import status from companies.factories import CompanyFactory from products.factories import ProductFactory, ActiveProductFactory -from products.models import Product -from products.utils import find_related_products_v3 +from products.models import Product, CategoryTag from core.factories import CustomUserFactory from core.utils import get_tokens_for_user @@ -194,6 +194,25 @@ class ProductViewSetTest(APITestCase): # Assert number of instnaces in response self.assertEquals(len(expected_instance), len(payload)) + def test_anon_can_get_related_products(self): + tag = 'cosa' + company = CompanyFactory() + # Create instances + instance = self.factory() + # make our user the creator + instance.creator = self.user + instance.save() + + instances = [self.factory(tags=tag, company=company) for i in range(10)] + + url = f"{self.endpoint}{instances[0].id}/related/" + + response = self.client.get(url) + + self.assertEquals(response.status_code, 200) + payload= response.json() + self.assertTrue(len(payload) <= 10) + # authenticated user def test_auth_user_can_paginate_instances(self): """authenticated user can paginate instances @@ -374,7 +393,6 @@ class ProductViewSetTest(APITestCase): url = self.endpoint + f'{instance.pk}/' response = self.client.put(url, data=data, format='json') - # Assert endpoint returns OK code self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) @@ -934,9 +952,13 @@ class MyProductsViewTest(APITestCase): def test_auth_user_gets_data(self): # create instance + company = CompanyFactory() + self.user.company = company + self.user.save() + user_instances = [ - self.factory(creator=self.user), - self.factory(creator=self.user), + self.factory(company=company), + self.factory(company=company), ] # Authenticate @@ -946,11 +968,9 @@ class MyProductsViewTest(APITestCase): # Query endpoint response = self.client.get(self.endpoint) payload = response.json() - # Assert forbidden code self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEquals(payload['count'], len(payload['results'])) - self.assertEquals(len(user_instances), payload['count']) + self.assertEquals(len(user_instances), len(payload)) def test_auth_user_can_paginate_instances(self): """authenticated user can paginate instances @@ -960,7 +980,11 @@ class MyProductsViewTest(APITestCase): self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token['access']}") # create instances - instances = [self.factory(creator=self.user) for n in range(12)] + company = CompanyFactory() + self.user.company = company + self.user.save() + + instances = [self.factory(company=company) for n in range(12)] # Request list url = f"{self.endpoint}?limit=5&offset=10" @@ -970,8 +994,8 @@ class MyProductsViewTest(APITestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) # assert only 2 instances in response payload = response.json() - self.assertEquals(payload['count'], len(payload['results'])) - self.assertEquals(2, payload['count']) + self.assertEquals(payload['count'], self.model.objects.count()) + self.assertEquals(2, len(payload['results'])) def test_anon_user_cannot_access(self): # send in request @@ -980,41 +1004,291 @@ class MyProductsViewTest(APITestCase): # check response self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + def test_auth_user_without_company(self): + # Authenticate + token = get_tokens_for_user(self.user) + self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token['access']}") -class FindRelatedProductsTest(APITestCase): + # Query endpoint + response = self.client.get(self.endpoint) + payload = response.json() + # Assert forbidden code + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEquals([], payload) + + +class AdminProductViewSetTest(APITestCase): def setUp(self): """Tests setup """ - self.factory = ActiveProductFactory + self.endpoint = '/api/v1/admin_products/' + self.factory = ProductFactory self.model = Product - # clear table - self.model.objects.all().delete() + # create user + self.email = f"user@mail.com" + self.password = ''.join(random.choices(string.ascii_uppercase, k = 10)) + self.user = CustomUserFactory(email=self.email, is_active=True) + self.user.set_password(self.password) + # self.user.role = 'SITE_ADMIN' + self.user.save() - def test_v3_find_by_tags(self): - # create tagged product - tag = 'cool' - expected_instances = [ - self.factory(tags=tag), - self.factory(tags=f'{tag} hat'), - self.factory(tags=f'temperatures/{tag}'), - self.factory(tags=f'temperatures/{tag}, body/hot'), - self.factory(tags=f'temperatures/{tag}, hats/{tag}'), - # multiple hits - self.factory(tags=tag, attributes=tag), - self.factory(tags=tag, attributes=tag, category=tag), - self.factory(tags=tag, attributes=tag, category=tag, name=tag), - self.factory(tags=tag, attributes=tag, category=tag, name=tag, description=tag), + def test_anon_user_cannot_access(self): + instance = self.factory() + url = f"{self.endpoint}{instance.id}/" + # GET + response = self.client.get(self.endpoint) + # check response + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + # POST + response = self.client.post(self.endpoint, data={}) + # check response + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + # PUT + response = self.client.get(url, data={}) + # check response + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + # delete + response = self.client.get(url) + # check response + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_auth_user_cannot_access(self): + # Authenticate + token = get_tokens_for_user(self.user) + self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token['access']}") + + instance = self.factory() + url = f"{self.endpoint}{instance.id}/" + # GET + response = self.client.get(self.endpoint) + # check response + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + # POST + response = self.client.post(self.endpoint, data={}) + # check response + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + # PUT + response = self.client.get(url, data={}) + # check response + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + # delete + response = self.client.get(url) + # check response + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_admin_user_can_list(self): + # make user site amdin + self.user.role = 'SITE_ADMIN' + self.user.save() + + # Authenticate + token = get_tokens_for_user(self.user) + self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token['access']}") + + # create instances + instance = [self.factory() for i in range(random.randint(1,5))] + # query endpoint + response = self.client.get(self.endpoint) + + # assertions + self.assertEquals(response.status_code, 200) + payload = response.json() + self.assertEquals(len(instance), len(payload)) + + def test_admin_user_can_get_details(self): + # make user site amdin + self.user.role = 'SITE_ADMIN' + self.user.save() + + # Authenticate + token = get_tokens_for_user(self.user) + self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token['access']}") + + # create instances + instance = self.factory() + url = f"{self.endpoint}{instance.id}/" + # query endpoint + response = self.client.get(url) + + # assertions + self.assertEquals(response.status_code, 200) + payload = response.json() + self.assertEquals(instance.id, payload['id']) + + def test_admin_can_create_instance(self): + # make user site amdin + self.user.role = 'SITE_ADMIN' + self.user.save() + + # Authenticate + token = get_tokens_for_user(self.user) + self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token['access']}") + + # create instances + data = { + 'name': 'test_product_name', + } + # query endpoint + response = self.client.post(self.endpoint, data=data) + + # assertions + self.assertEquals(response.status_code, 201) + payload = response.json() + self.assertEquals(data['name'], payload['name']) + + def test_admin_can_update_instance(self): + # make user site amdin + self.user.role = 'SITE_ADMIN' + self.user.save() + + # Authenticate + token = get_tokens_for_user(self.user) + self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token['access']}") + + # create instance + instance = self.factory() + url = f"{self.endpoint}{instance.id}/" + + # data + data = { + 'name': 'test_product_name', + } + # query endpoint + response = self.client.put(url, data=data) + + # assertions + self.assertEquals(response.status_code, 200) + payload = response.json() + self.assertEquals(data['name'], payload['name']) + + def test_admin_can_delete_instance(self): + # make user site amdin + self.user.role = 'SITE_ADMIN' + self.user.save() + + # Authenticate + token = get_tokens_for_user(self.user) + self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token['access']}") + + # create instance + instance = self.factory() + url = f"{self.endpoint}{instance.id}/" + + # query endpoint + response = self.client.delete(url) + + # assertions + self.assertEquals(response.status_code, 204) + + +class PurchaseEmailTest(APITestCase): + + def setUp(self): + """Tests setup + """ + self.endpoint = '/api/v1/purchase_email/' + self.factory = ProductFactory + self.model = Product + # create user + self.email = f"user@mail.com" + self.password = ''.join(random.choices(string.ascii_uppercase, k = 10)) + self.user = CustomUserFactory(email=self.email, is_active=True) + self.user.set_password(self.password) + self.user.save() + + def test_anon_user_can_use(self): + company = CompanyFactory() + self.user.role = 'COOP_MANAGER' + self.user.company = company + self.user.save() + product = ProductFactory(company=company) + + data = { + 'email': self.email, + 'telephone': '123123123', + 'company': company.id, + 'product': product.id, + 'comment': '', + } + response = self.client.post(self.endpoint, data=data, format='json') + # assertions + self.assertEquals(response.status_code, 200) + self.assertEquals(2, len(mail.outbox)) + + def test_auth_user_can_use(self): + # required instances + company = CompanyFactory() + product = ProductFactory(company=company) + # make user the manager + self.user.company = company + self.user.role = 'COOP_MANAGER' + self.user.save() + + data = { + 'telephone': '123123123', + 'company': company.id, + 'product': product.id, + 'comment': '', + } + # Authenticate + token = get_tokens_for_user(self.user) + self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token['access']}") + + response = self.client.post(self.endpoint, data=data, format='json') + # assertions + self.assertEquals(response.status_code, 200) + self.assertEquals(2, len(mail.outbox)) + + def test_anon_user_bad_email(self): + company = CompanyFactory() + product = ProductFactory(company=company) + + data = { + 'email': '324r@qwer', + 'telephone': '123123123', + 'company': company.id, + 'product': product.id, + 'comment': '', + } + + response = self.client.post(self.endpoint, data=data, format='json') + # assertions + self.assertEquals(response.status_code, 406) + payload = response.json() + self.assertTrue( 'email' in payload['error']) + + +class AllCategoriesTest(APITestCase): + + def setUp(self): + """Tests setup + """ + self.endpoint = '/api/v1/products/all_categories/' + # self.factory = ProductFactory + self.model = CategoryTag + # create user + self.email = f"user@mail.com" + self.password = ''.join(random.choices(string.ascii_uppercase, k = 10)) + self.user = CustomUserFactory(email=self.email, is_active=True) + self.user.set_password(self.password) + self.user.save() + + def test_get_all_categories(self): + # create instances + instances = [ + self.model.objects.create(name='A'), + self.model.objects.create(name='B'), + self.model.objects.create(name='C'), + self.model.objects.create(name='D'), + self.model.objects.create(name='E'), ] - unexpected_instances = [ - self.factory(tags="notcool"), # shouldn't catch it - self.factory(tags="azules"), - ] + response = self.client.get(self.endpoint) - # searh for it - results = find_related_products_v3(tag) + # assertions + self.assertEquals(response.status_code, 200) - # assert result - self.assertTrue(len(results) == len(expected_instances)) + payload = response.json() + self.assertEquals(len(instances), len(payload)) diff --git a/products/utils.py b/products/utils.py index abb78bd..c0f4c7e 100644 --- a/products/utils.py +++ b/products/utils.py @@ -6,6 +6,7 @@ from django.db.models import Q from django.contrib.postgres.search import SearchQuery, SearchRank, SearchVector, TrigramSimilarity from django.db.models import Max, Min from django.conf import settings +from django.utils import timezone import requests @@ -85,25 +86,45 @@ def extract_search_filters(result_set): return filter_dict -def find_related_products_v3(keyword): +def get_related_products(product): + """Make different db searches until you get 10 instances to return """ - Ranked product search + total_results = [] - SearchVectors for the fields - SearchQuery for the value - SearchRank for relevancy scoring and ranking - """ - vector = SearchVector('name') + SearchVector('description') + SearchVector('tags__label') + SearchVector('attributes__label') + SearchVector('category__name') - query = SearchQuery(keyword) + # search by category + category_qs = Product.objects.filter(category=product.category)[:10] + # add to results + for item in category_qs: + total_results.append(item) - products_qs = Product.objects.annotate( - rank=SearchRank(vector, query) - ).filter(rank__gt=0.05) # removed order_by because its lost in casting + # check size + if len(total_results) < 10: + # search by tags + tags_qs = Product.objects.filter(tags__in=product.tags.all())[:10] + # add to results + for item in tags_qs: + total_results.append(item) - return set(products_qs) + # check size + if len(total_results) < 10: + # search by coop + coop_qs = Product.objects.filter(company=product.company)[:10] + # add to results + for item in coop_qs: + total_results.append(item) + + # check size + if len(total_results) < 10: + # search by latest + latest_qs = Product.objects.order_by('-created')[:10] + # add to results + for item in coop_qs: + total_results.append(item) + + return total_results[:10] -def find_related_products_v6(keyword, shipping_cost=None, discount=None, category=None, tags=None, price_min=None,price_max=None): +def ranked_product_search(keyword, shipping_cost=None, discount=None, category=None, tags=None, price_min=None,price_max=None): """ Ranked product search @@ -153,9 +174,9 @@ def find_related_products_v6(keyword, shipping_cost=None, discount=None, categor # filter by price if price_min is not None: - products_qs = products_qs.filter(price__gt=price_min) + products_qs = products_qs.filter(price__gte=price_min) if price_max is not None: - products_qs = products_qs.filter(price__lt=price_max) + products_qs = products_qs.filter(price__lte=price_max) # get min_price and max_price min_price = products_qs.aggregate(Min('price')) @@ -183,7 +204,7 @@ def product_loader(csv_reader, user, company=None): return None # create historysync instance - history = HistorySync.objects.create(company=company, sync_date=datetime.datetime.now()) + history = HistorySync.objects.create(company=company, sync_date=timezone.now()) for row in csv_reader: # trim strings for key in row: diff --git a/products/views.py b/products/views.py index 989c7ae..f80edbe 100644 --- a/products/views.py +++ b/products/views.py @@ -1,32 +1,39 @@ import logging import csv import datetime +import json from django.db.models import Q from django.core import serializers +from django.core.validators import validate_email +from django.contrib.auth import get_user_model +from django.template.loader import render_to_string +from django.core.mail import EmailMessage # Create your views here. from rest_framework import status from rest_framework import viewsets from rest_framework.response import Response -from rest_framework.permissions import IsAuthenticatedOrReadOnly, IsAdminUser, IsAuthenticated +from rest_framework.permissions import IsAuthenticatedOrReadOnly, IsAdminUser, IsAuthenticated, AllowAny from rest_framework.decorators import api_view, permission_classes, action from rest_framework.filters import OrderingFilter from django_filters.rest_framework import DjangoFilterBackend import requests -from products.models import Product, CategoryTag -from products.serializers import ProductSerializer, TagFilterSerializer, SearchResultSerializer -from companies.models import Company from history.models import HistorySync from dal import autocomplete -from back_latienda.permissions import IsCreator -from .utils import extract_search_filters, find_related_products_v3, find_related_products_v6, product_loader +from products.models import Product, CategoryTag +from products.serializers import ProductSerializer, TagFilterSerializer, SearchResultSerializer +from companies.models import Company +from stats.models import StatsLog +from back_latienda.permissions import IsCreator, IsSiteAdmin, ReadOnly +from .utils import extract_search_filters, ranked_product_search, product_loader, get_related_products from utils.tag_serializers import TaggitSerializer from utils.tag_filters import ProductTagFilter, ProductOrderFilter +User = get_user_model() logging.basicConfig( filename='logs/product-load.log', @@ -37,42 +44,47 @@ logging.basicConfig( class ProductViewSet(viewsets.ModelViewSet): - queryset = Product.objects.filter(active=True).order_by('-created') - serializer_class = ProductSerializer - permission_classes = [IsAuthenticatedOrReadOnly, IsCreator] - filter_backends = [DjangoFilterBackend, OrderingFilter] - filterset_class = ProductTagFilter + queryset = Product.objects.filter(active=True).order_by('-created') + serializer_class = ProductSerializer + permission_classes = [IsAuthenticatedOrReadOnly, IsCreator] + filter_backends = [DjangoFilterBackend, OrderingFilter] + filterset_class = ProductTagFilter - def perform_create(self, serializer): - serializer.save(creator=self.request.user, company=self.request.user.company) + def perform_create(self, serializer): + serializer.save(creator=self.request.user, company=self.request.user.company) - @action(detail=True, methods=['GET',]) - def related(request): - # TODO: find the most similar products - return Response(data=[]) + @action(detail=True, methods=['GET',]) + def related(self, request, pk=None): + """Find instances similar to the one referenced + """ + # TODO: find the most similar products + product = self.get_object() + qs = get_related_products(product) + serializer = self.serializer_class(qs, many=True) + return Response(data=serializer.data) -@api_view(['GET',]) -@permission_classes([IsAuthenticated,]) -def my_products(request): - limit = request.GET.get('limit') - offset = request.GET.get('offset') - qs = Product.objects.filter(creator=request.user) - product_serializer = ProductSerializer(qs, many=True) - data = product_serializer.data - # RESULTS PAGINATION - if limit is not None and offset is not None: - limit = int(limit) - offset = int(offset) - data = data[offset:(limit+offset)] - elif limit is not None: - limit = int(limit) - data = data[:limit] - # prepare response payload - payload = {} - payload['results'] = data - payload['count'] = len(payload['results']) - return Response(data=payload) +class MyProductsViewSet(viewsets.ModelViewSet): + """ Allows user to get all products where user is product creator + """ + model = Product + serializer_class = ProductSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + return self.model.objects.filter(company=self.request.user.company).order_by('-created') + + + +class AdminProductsViewSet(viewsets.ModelViewSet): + """ Allows user with role 'SITE_ADMIN' to access all product instances + """ + queryset = Product.objects.all() + serializer_class = ProductSerializer + permission_classes = [IsSiteAdmin] + + def perform_create(self, serializer): + serializer.save(creator=self.request.user) @api_view(['POST',]) @@ -169,7 +181,7 @@ def product_search(request): if q == '': # filter entire queryset - products_qs = Product.objects.all() + products_qs = Product.objects.filter(active=True) if tags: products_qs = Product.objects.filter(tags=tags) if category: @@ -181,7 +193,7 @@ def product_search(request): # split query string into single words chunks = q.split(' ') for chunk in chunks: - product_set, min_price, max_price = find_related_products_v6(chunk, shipping_cost, discount, category, tags, price_min, price_max) + product_set, min_price, max_price = ranked_product_search(chunk, shipping_cost, discount, category, tags, price_min, price_max) # update price values if product_set: if prices['min'] is None or min_price['price__min'] < prices['min']: @@ -235,3 +247,98 @@ class CategoryTagAutocomplete(autocomplete.Select2QuerySetView): qs = qs.filter(name__icontains=self.q) return qs # [x.label for x in qs] + + +@api_view(['POST']) +@permission_classes([AllowAny,]) +def purchase_email(request): + """Notify coop manager and user about item purchase + """ + data = json.loads(request.body) + # check data + if request.user.is_anonymous and 'email' not in data: + return Response({"error": "Anonymous users must include an email parameter value"}, status=status.HTTP_406_NOT_ACCEPTABLE) + try: + for param in ('telephone', 'company', 'product', 'comment'): + assert(param in data.keys()) + except: + return Response({"error": "Required parameters for anonymous user: telephone, company, product, comment"}, status=status.HTTP_406_NOT_ACCEPTABLE) + + if request.user.is_anonymous: + user_email = data.get('email') + else: + user_email = request.user.email + telephone = data.get('telephone') + # validate email + try: + validate_email(user_email) + except: + return Response({"error": "Value for email is not valid"}, status=status.HTTP_406_NOT_ACCEPTABLE) + # get comment + comment = data.get('comment') + # get company + company = Company.objects.filter(id=data['company']).first() + if not company: + return Response({"error": "Invalid value for company"}, status=status.HTTP_406_NOT_ACCEPTABLE) + # get managing user + managing_user = User.objects.filter(company=company, role='COOP_MANAGER').first() + if not managing_user: + return Response({"error": "No managing user found for company"}, status=status.HTTP_406_NOT_ACCEPTABLE) + # get product + product = Product.objects.filter(id=data['product'], company=company).first() + if not product: + return Response({"error": "Invalid value for product"}, status=status.HTTP_406_NOT_ACCEPTABLE) + # check company.email + if company.email is None: + return Response({"error": "Related compay has no contact email address"}, status=status.HTTP_406_NOT_ACCEPTABLE) + try: + # send email to company + company_message = render_to_string('purchase_notification_v2.html', { + 'company': company, + 'user': request.user, + 'product': product, + 'telephone': data['telephone'], + 'comment': comment, + }) + subject = "[latienda.coop] Solicitud de compra" + email = EmailMessage(subject, company_message, to=[company.email]) + email.content_subtype = "html" + email.send() + logging.info(f"Email sent to {company}") + # send confirmation email to user + user_message = render_to_string('purchase_contact_confirmation_v2.html', { + 'company': company, + 'product': product, + 'company_message': company_message, + 'manager': managing_user, + }) + subject = 'Confirmación de petición de compra' + email = EmailMessage(subject, user_message, to=[user_email]) + email.content_subtype = "html" + email.send() + logging.info(f"Purchase Contact confirmation email sent to {user_email}") + except Exception as e: + return Response({'error': f"Could not send emails [{str(type(e))}]: {str(e)}"}, status=500) + + # create statslog instance to register interaction + stats_data = { + 'action_object': product, + 'user': request.user if request.user.is_authenticated else None, + 'anonymous': request.user.is_anonymous, + 'contact': True, + 'shop': company.shop, + } + StatsLog.objects.create(**stats_data) + + # response + return Response() + + +@api_view(['GET']) +@permission_classes([AllowAny,]) +def all_categories(request): + all_categories = [] + for instance in CategoryTag.objects.all(): + all_categories.append(instance.label) + return Response(data=all_categories) + diff --git a/requirements.txt b/requirements.txt index ad2f270..df15689 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,6 +17,7 @@ woocommerce==2.1.1 django-autocomplete-light==3.8.2 # manually install `pip install --default-timeout=100 future` to avoid wcapi to timeout django-map-widgets==0.3.0 +django-admin-list-filter-dropdown==1.0.3 # required for production django-anymail[amazon_ses]==8.2 boto3==1.17.11 diff --git a/static/.gitignore b/static/.gitignore new file mode 100644 index 0000000..c96a04f --- /dev/null +++ b/static/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/stats/tests.py b/stats/tests.py index 3bfe3ce..ea3510c 100644 --- a/stats/tests.py +++ b/stats/tests.py @@ -116,7 +116,7 @@ class TrackUserViewTest(APITestCase): 'model': 'company', 'id': company.id, }, - 'geo': (12.2, -0.545) + 'geo': {'latitude': 12.2, 'longitude': -0.545} } # Query endpoint diff --git a/stats/views.py b/stats/views.py index 778d62e..a11193e 100644 --- a/stats/views.py +++ b/stats/views.py @@ -52,13 +52,18 @@ def track_user(request): try: data = json.loads(request.body) + if data.get('geo'): + coordinates = (data['geo'].get('latitude'), data['geo'].get('longitude')) + else: + coordinates = None + # gather instance data instance_data = { 'action_object': data.get('action_object'), 'user': None if request.user.is_anonymous else request.user, 'anonymous': request.user.is_anonymous, 'ip_address': data.get('ip'), - 'geo': Point(data.get('geo')), + 'geo': Point(coordinates), } if data['action_object'].get('model') == 'product': @@ -76,4 +81,4 @@ def track_user(request): return Response(status=status.HTTP_201_CREATED) except Exception as e: logging.error(f"Stats could not be created: {str(e)}") - return Response(f"Process could not be registered: {str(type(e))}", status=status.HTTP_406_NOT_ACCEPTABLE) + return Response(f"Process could not be registered [{str(type(e))}]: {str(e)}", status=status.HTTP_406_NOT_ACCEPTABLE) diff --git a/templates/company_contact.html b/templates/company_contact.html index 9d4d4a5..b17c9f6 100644 --- a/templates/company_contact.html +++ b/templates/company_contact.html @@ -1,10 +1,174 @@ -Hola {{company.creator.full_name}}. -Estamos contactando contigo como usuario gerente de la compañina {{company.company_name}}. + + + + + + + + + -Datos de usuario: + + Interés en copra de producto + + + +
+
+ + + +

+ Confirmación de + solicitud enviada +

+
+
+

+ Hola {{company.creator.full_name}}. + Estamos contactando contigo como usuario gerente de la compañía {{company.company_name}}. + + Datos de usuario: + + - {{user.full_name}} + - {{user.email}} + + Contenido del mensaje: + {{data}} +

+
+
+ 2021 La Tienda.Coop +
+
+ + diff --git a/templates/confirm_company_contact.html b/templates/confirm_company_contact.html index 70dcb0e..d766daf 100644 --- a/templates/confirm_company_contact.html +++ b/templates/confirm_company_contact.html @@ -1,2 +1,166 @@ -Hola {{user.full_name}}. -Hemos enviado un email a {{company.company_name}} sobre tu petición. + + + + + + + + + + + + Interés en copra de producto + + + +
+
+ + + +

+ Confirmación de + solicitud enviada +

+
+
+

+ Hola {{user.full_name}}. + Hemos enviado un email a {{company.company_name}} sobre tu petición. +

+
+
+ 2021 La Tienda.Coop +
+
+ + diff --git a/templates/email_verification.html b/templates/email_verification.html index 37a09c8..7238ce7 100644 --- a/templates/email_verification.html +++ b/templates/email_verification.html @@ -1,7 +1,168 @@ -{% autoescape off %} -Hola {{ user.full_name }}, -Por favor, verifica tu registro en LaTiendaCOOP haciendo click en el siguiente enlace: + + + + + + + + + -http://{{ domain }}{% url 'activate_user' uidb64=uid token=token %} + + Interés en copra de producto + + + +
+
+ + + +

+ Confirmación de + solicitud enviada +

+
+
+

+ Hola {{ user.full_name }}, + Por favor, verifica tu registro en LaTiendaCOOP haciendo click en el siguiente enlace: + + http://{{ domain }}{% url 'activate_user' uidb64=uid token=token %} +

+
+
+ 2021 La Tienda.Coop +
+
+ + \ No newline at end of file diff --git a/templates/purchase_contact_confirmation.html b/templates/purchase_contact_confirmation.html new file mode 100755 index 0000000..32652ff --- /dev/null +++ b/templates/purchase_contact_confirmation.html @@ -0,0 +1,197 @@ + + + + + + + + + + + + Interés en copra de producto + + + +
+
+ + + +

+ Confirmación de + solicitud enviada +

+
+
+ +
+ + + + Enlace al producto + Nombre del producto: {{product.name}} + {{product.sku}} + {{product.price}} +

¡Hola! Tu solicitud de compra de este producto ha sido comunicado a la cooperativa. Pronto tendrás noticias suyas. Para cualquier duda o asistencia, ponte en contacto con {{company.short_name}}.

+
+
+

Contacto cooperativa

+
+
Nombre:
+
{{manager.full_name}}
+
Email:
+
{{company.email}}
+ +
+ + + +
+
+
+ 2021 La Tienda.Coop +
+
+ + diff --git a/templates/purchase_contact_confirmation_v2.html b/templates/purchase_contact_confirmation_v2.html new file mode 100644 index 0000000..fff1cf7 --- /dev/null +++ b/templates/purchase_contact_confirmation_v2.html @@ -0,0 +1,1043 @@ + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ +
+ + + + +
+ + + + + + +
+ + + +
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + +
+
+ Confirmación de solicitud + enviada +
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+
+ Enlace al + producto +
+ Nombre del + producto +
+ {{product.name}} +
+ {{product.sku}} +
+ {{product.price}}€ +
+
+
+

+ ¡Hola! Tu solicitud de + compra de este producto ha + sido comunicado a la + cooperativa. Pronto tendrás + noticias suyas. Para + cualquier duda o asistencia, + ponte en contacto con + {{company.short_name}}. +

+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + + + + + + + + +
+
+ Datos cooperativa +
+
+
+ Nombre: + + {{company.company_name}} +
+
+
+ Email: + + {{company.email}} +
+
+ + logo cooperativa + +
+
+ +
+
+ +
+ + + + + + +
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + + +
+

+ +
+
+ 2021 La Tienda.coop +
+
+
+ +
+
+ +
+ + + diff --git a/templates/purchase_notification_v2.html b/templates/purchase_notification_v2.html new file mode 100755 index 0000000..668ce6e --- /dev/null +++ b/templates/purchase_notification_v2.html @@ -0,0 +1,1033 @@ + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ +
+ + + + +
+ + + + + + +
+ + + +
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + +
+
+ Interés en compra de + producto +
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + + +
+ + + + + + +
+ + + +
+
+
+ Enlace al + producto +
+ Nombre del + producto +
+ {{product.name}} +
+ {{product.sku}} +
+ {{product.price}}€ +
+
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + + + + + + + + + + + +
+
+ Datos cliente +
+
+
+ Nombre: + + {{user.full_name}} +
+
+
+ Email: + + {{user.email}} +
+
+
+ Teléfono: + + {{telephone}} +
+
+
+ Comentarios: {{comment}} +
+
+
+ +
+
+ +
+ + + + + + +
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + + +
+

+ +
+
+ 2021 La Tienda.coop +
+
+
+ +
+
+ +
+ + + diff --git a/utils/woocommerce.py b/utils/woocommerce.py index 8cc76f7..11a947e 100644 --- a/utils/woocommerce.py +++ b/utils/woocommerce.py @@ -170,7 +170,9 @@ def migrate_shop_products(url, key, secret, user=None, version="wc/v3"): history.quantity = counter history.save() + logging.info(f"Products created: {len(new_products)}") print(f"Products created: {len(new_products)}") + return new_products