Merge branch 'development' into diego

This commit is contained in:
Diego Calvo
2021-03-11 08:37:33 +01:00
11 changed files with 187 additions and 113 deletions

View File

@@ -196,6 +196,14 @@ Endpoint url: `/api/v1/user/update/`
Permissions: only accessible for your own user instance 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] ### my_user [GET]
Endpoint url: `/api/v1/my_user/` Endpoint url: `/api/v1/my_user/`
@@ -208,6 +216,12 @@ 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` For each row it creates a Company instance, and a user instance linked to the company, with role `COOP_MANAGER`
### activate_user
Endpoint: `/activate/<uidb64>/<token>/`
This endpoint is reached from the URL sent to the user after their registration
### User Management ### User Management
Creation: Creation:
@@ -309,6 +323,7 @@ Location ednpoints:
- `/api/v1/provinces/` - `/api/v1/provinces/`
- `/api/v1/cities/` - `/api/v1/cities/`
Tables filled with data from `datasets/gadm36_ESP.gpkg` with `loadgisdata` command.
## Shop Integrations ## Shop Integrations

View File

@@ -41,6 +41,8 @@ DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage'
EMAIL_BACKEND = "anymail.backends.amazon_ses.EmailBackend" EMAIL_BACKEND = "anymail.backends.amazon_ses.EmailBackend"
DEFAULT_FROM_EMAIL = "no-reply@latienda.com" DEFAULT_FROM_EMAIL = "no-reply@latienda.com"
# DEFAULT_FROM_EMAIL = "samuel.molina@enreda.coop"
SERVER_EMAIL = "mail-server@latienda.com" SERVER_EMAIL = "mail-server@latienda.com"
ANYMAIL = { ANYMAIL = {

View File

@@ -7,11 +7,13 @@ import csv
from django.test import TestCase from django.test import TestCase
from django.core import mail from django.core import mail
from django.utils.http import urlsafe_base64_encode
from django.utils.encoding import force_bytes
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from rest_framework import status 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 from companies.models import Company
@@ -56,6 +58,10 @@ class CustomUserViewSetTest(APITestCase):
# assert instance is inactive # assert instance is inactive
info = json.loads(response.content) info = json.loads(response.content)
self.assertTrue(info['is_active']) self.assertTrue(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): def test_anon_user_cannot_modify_existing_instance(self):
"""Not logged-in user cannot modify existing instance """Not logged-in user cannot modify existing instance
@@ -182,6 +188,8 @@ class CustomUserViewSetTest(APITestCase):
# Assert instance exists on db # Assert instance exists on db
self.assertTrue(self.model.objects.get(email=response.data['email'])) 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): def test_admin_user_can_modify_existing_instance(self):
"""Admin user can modify existing instance """Admin user can modify existing instance
@@ -533,3 +541,42 @@ class MyUserViewTest(APITestCase):
# check response # check response
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
class ActivateUserTest(APITestCase):
def setUp(self):
self.endpoint = 'activate/<uidb64>/<token>/'
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, 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())

View File

@@ -79,6 +79,8 @@ class CustomUserViewSet(viewsets.ModelViewSet):
instance = self.model(**serializer.validated_data) instance = self.model(**serializer.validated_data)
instance.set_password(password) instance.set_password(password)
instance.save() instance.save()
# send verification email
utils.send_verification_email(request, instance)
return Response(self.read_serializer_class( return Response(self.read_serializer_class(
instance, many=False, context={'request': request}).data, instance, many=False, context={'request': request}).data,
@@ -168,7 +170,6 @@ def my_user(request):
return Response({'error': {str(type(e))}}, status=500) return Response({'error': {str(type(e))}}, status=500)
@api_view(['POST',]) @api_view(['POST',])
@permission_classes([IsAdminUser,]) @permission_classes([IsAdminUser,])
def load_coop_managers(request): def load_coop_managers(request):
@@ -200,10 +201,10 @@ def activate_user(request, uidb64, token):
except (TypeError, ValueError, OverflowError, User.DoesNotExist): except (TypeError, ValueError, OverflowError, User.DoesNotExist):
user = None 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 # activate user
user.is_active = True user.is_active = True
user.save() user.save()
return HttpResponse(f"Tu cuenta de usuario {request.user.email} ha sido activada") return Response(f"Tu cuenta de usuario {user.email} ha sido activada")
else: 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)

View File

@@ -73,7 +73,10 @@ class City(models.Model):
updated = models.DateTimeField('date last update', auto_now=True) updated = models.DateTimeField('date last update', auto_now=True)
def __str__(self): 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: class Meta:
verbose_name = "Municipio" verbose_name = "Municipio"

View File

@@ -1,15 +1,22 @@
from django.contrib import admin from django.contrib import admin
from django_admin_listfilter_dropdown.filters import RelatedDropdownFilter, ChoiceDropdownFilter
from . import models from . import models
# Register your models here. # Register your models here.
class HistoryAdmin(admin.ModelAdmin): class HistoryAdmin(admin.ModelAdmin):
list_display = ('company_name', 'rss_url', 'sync_date', 'result', 'quantity',) list_display = ('company_name', 'rss_url', 'sync_date', 'result', 'quantity',)
list_filter = ('company__company_name',) list_filter = (
('company', RelatedDropdownFilter),
)
def company_name(self, instance): def company_name(self, instance):
return instance.company.company_name if instance.company and instance.company.company_name:
return instance.company.company_name
else:
return 'NULL'
admin.site.register(models.HistorySync, HistoryAdmin) admin.site.register(models.HistorySync, HistoryAdmin)

View File

@@ -14,7 +14,6 @@ from rest_framework import status
from companies.factories import CompanyFactory from companies.factories import CompanyFactory
from products.factories import ProductFactory, ActiveProductFactory from products.factories import ProductFactory, ActiveProductFactory
from products.models import Product from products.models import Product
from products.utils import find_related_products_v3
from core.factories import CustomUserFactory from core.factories import CustomUserFactory
from core.utils import get_tokens_for_user from core.utils import get_tokens_for_user
@@ -196,19 +195,23 @@ class ProductViewSetTest(APITestCase):
self.assertEquals(len(expected_instance), len(payload)) self.assertEquals(len(expected_instance), len(payload))
def test_anon_can_get_related_products(self): def test_anon_can_get_related_products(self):
tag = 'cosa'
company = CompanyFactory()
# Create instances # Create instances
instance = self.factory() instance = self.factory()
# make our user the creator # make our user the creator
instance.creator = self.user instance.creator = self.user
instance.save() instance.save()
url = f"{self.endpoint}{instance.id}/related/" 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) response = self.client.get(url)
self.assertEquals(response.status_code, 200) self.assertEquals(response.status_code, 200)
payload= response.json() payload= response.json()
self.assertTrue(len(payload) <= 6) self.assertTrue(len(payload) <= 10)
# authenticated user # authenticated user
def test_auth_user_can_paginate_instances(self): def test_auth_user_can_paginate_instances(self):
@@ -1159,44 +1162,6 @@ class AdminProductViewSetTest(APITestCase):
self.assertEquals(response.status_code, 204) self.assertEquals(response.status_code, 204)
class FindRelatedProductsTest(APITestCase):
def setUp(self):
"""Tests setup
"""
self.factory = ActiveProductFactory
self.model = Product
# clear table
self.model.objects.all().delete()
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),
]
unexpected_instances = [
self.factory(tags="notcool"), # shouldn't catch it
self.factory(tags="azules"),
]
# searh for it
results = find_related_products_v3(tag)
# assert result
self.assertTrue(len(results) == len(expected_instances))
class PurchaseEmailTest(APITestCase): class PurchaseEmailTest(APITestCase):
def setUp(self): def setUp(self):
@@ -1233,9 +1198,6 @@ class PurchaseEmailTest(APITestCase):
def test_auth_user_can_use(self): def test_auth_user_can_use(self):
company = CompanyFactory() company = CompanyFactory()
self.user.role = 'COOP_MANAGER'
self.user.company = company
self.user.save()
product = ProductFactory(company=company) product = ProductFactory(company=company)
data = { data = {
@@ -1253,3 +1215,21 @@ class PurchaseEmailTest(APITestCase):
self.assertEquals(response.status_code, 200) self.assertEquals(response.status_code, 200)
self.assertEquals(2, len(mail.outbox)) 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'])

View File

@@ -85,35 +85,45 @@ def extract_search_filters(result_set):
return filter_dict return filter_dict
def find_related_products_v7(description, tags, attributes, category): def get_related_products(product):
products_qs = Product.objects.filter( """Make different db searches until you get 10 instances to return
description=description,
tags__in=tags,
attributes__in=attributes,
category=category
)[:6]
return products_qs
def find_related_products_v3(keyword):
""" """
Ranked product search total_results = []
SearchVectors for the fields # search by category
SearchQuery for the value category_qs = Product.objects.filter(category=product.category)[:10]
SearchRank for relevancy scoring and ranking # add to results
""" for item in category_qs:
vector = SearchVector('name') + SearchVector('description') + SearchVector('tags__label') + SearchVector('attributes__label') + SearchVector('category__name') total_results.append(item)
query = SearchQuery(keyword)
products_qs = Product.objects.annotate( # check size
rank=SearchRank(vector, query) if len(total_results) < 10:
).filter(rank__gt=0.05) # removed order_by because its lost in casting # 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 Ranked product search

View File

@@ -5,6 +5,7 @@ import json
from django.db.models import Q from django.db.models import Q
from django.core import serializers from django.core import serializers
from django.core.validators import validate_email
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.core.mail import EmailMessage from django.core.mail import EmailMessage
@@ -28,7 +29,7 @@ from products.serializers import ProductSerializer, TagFilterSerializer, SearchR
from companies.models import Company from companies.models import Company
from stats.models import StatsLog from stats.models import StatsLog
from back_latienda.permissions import IsCreator, IsSiteAdmin, ReadOnly from back_latienda.permissions import IsCreator, IsSiteAdmin, ReadOnly
from .utils import extract_search_filters, find_related_products_v6, product_loader, find_related_products_v7 from .utils import extract_search_filters, ranked_product_search, product_loader, get_related_products
from utils.tag_serializers import TaggitSerializer from utils.tag_serializers import TaggitSerializer
from utils.tag_filters import ProductTagFilter, ProductOrderFilter from utils.tag_filters import ProductTagFilter, ProductOrderFilter
@@ -58,7 +59,7 @@ class ProductViewSet(viewsets.ModelViewSet):
""" """
# TODO: find the most similar products # TODO: find the most similar products
product = self.get_object() product = self.get_object()
qs = find_related_products_v7(product.description, product.tags.all(), product.attributes.all(), product.category) qs = get_related_products(product)
serializer = self.serializer_class(qs, many=True) serializer = self.serializer_class(qs, many=True)
return Response(data=serializer.data) return Response(data=serializer.data)
@@ -194,7 +195,7 @@ def product_search(request):
# split query string into single words # split query string into single words
chunks = q.split(' ') chunks = q.split(' ')
for chunk in chunks: 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 # update price values
if product_set: if product_set:
if prices['min'] is None or min_price['price__min'] < prices['min']: if prices['min'] is None or min_price['price__min'] < prices['min']:
@@ -259,7 +260,6 @@ def purchase_email(request):
# check data # check data
if request.user.is_anonymous and 'email' not in 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) return Response({"error": "Anonymous users must include an email parameter value"}, status=status.HTTP_406_NOT_ACCEPTABLE)
try: try:
for param in ('telephone', 'company', 'product', 'comment'): for param in ('telephone', 'company', 'product', 'comment'):
assert(param in data.keys()) assert(param in data.keys())
@@ -267,44 +267,50 @@ def purchase_email(request):
return Response({"error": "Required parameters for anonymous user: telephone, company, product, comment"}, status=status.HTTP_406_NOT_ACCEPTABLE) return Response({"error": "Required parameters for anonymous user: telephone, company, product, comment"}, status=status.HTTP_406_NOT_ACCEPTABLE)
if request.user.is_anonymous: if request.user.is_anonymous:
email = data.get('email') user_email = data.get('email')
else: else:
email = request.user.email user_email = request.user.email
telephone = data.get('telephone') 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 company # get company
company = Company.objects.filter(id=data['company']).first() company = Company.objects.filter(id=data['company']).first()
if not company: if not company:
return Response({"error": "Invalid value for company"}, status=status.HTTP_406_NOT_ACCEPTABLE) return Response({"error": "Invalid value for company"}, status=status.HTTP_406_NOT_ACCEPTABLE)
# get company manager
manager = User.objects.filter(company=company).first()
if not manager or manager.role != 'COOP_MANAGER':
return Response({"error": "Company has no managing user"}, status=status.HTTP_406_NOT_ACCEPTABLE)
# get product # get product
product = Product.objects.filter(id=data['product'], company=company).first() product = Product.objects.filter(id=data['product'], company=company).first()
if not product: if not product:
return Response({"error": "Invalid value for product"}, status=status.HTTP_406_NOT_ACCEPTABLE) return Response({"error": "Invalid value for product"}, status=status.HTTP_406_NOT_ACCEPTABLE)
# check company.email
# send email to company manager if company.email is None:
manager_message = render_to_string('purchase_notification.html', { return Response({"error": "Related compay has no contact email address"}, status=status.HTTP_406_NOT_ACCEPTABLE)
'company': company, try:
'user': request.user, # send email to company
'product': product, company_message = render_to_string('purchase_notification.html', {
'telephone': data['telephone'], 'company': company,
}) 'user': request.user,
subject = "Contacto de usuario sobre venta" 'product': product,
email = EmailMessage(subject, manager_message, to=[manager.email]) 'telephone': data['telephone'],
email.send() })
logging.info(f"Email sent to {manager.email} as manager of {company}") subject = "[latienda.coop] Solicitud de compra"
# send confirmation email to user email = EmailMessage(subject, company_message, to=[company.email])
user_message = render_to_string('purchase_contact_confirmation.html', { email.send()
'company': company, logging.info(f"Email sent to {company}")
'product': product, # send confirmation email to user
}) user_message = render_to_string('purchase_contact_confirmation.html', {
subject = 'Confirmación de contacto con vendedor' 'company': company,
email = EmailMessage(subject, user_message, to=[email]) 'product': product,
email.send() 'company_message': company_message,
logging.info(f"Purchase Contact confirmation email sent to {email}") })
subject = 'Confirmación de petición de compra'
email = EmailMessage(subject, user_message, to=[user_email])
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 # create statslog instance to register interaction
stats_data = { stats_data = {

View File

@@ -1,6 +1,10 @@
Hola usuario. Hola usuario.
Hemos envíado correctamente el email al usuario que gestiona {{company}} sobre el producto {{product}}.
Deberías revibir una respuesta directa en los próximos días. Hemos envíado correctamente el siguiente email a la empresa {{company}} sobre el producto {{product}}:
{{company_message}}
Deberías recibir una respuesta directa en los próximos días.
LaTiendaCOOP LaTiendaCOOP

View File

@@ -1,9 +1,8 @@
Hola usuario. Hola {{company}}.
Te contactamos por tu puesto como gestor de {{company}}.
El usuario {{user.email}} ha mostrado interés en la compra de {{product}}. El usuario {{user.email}} ha mostrado interés en la compra del producto {{product}}.
Ponte en contacto con el usuario tan pronto como te sea posible, y finalizar la venta. Ponte en contacto con el usuario tan pronto como te sea posible para finalizar la venta.
Teléfono de contacto: {{telephone}} Teléfono de contacto: {{telephone}}