Eshop Platform

Eshop Platform

This Eshop Platform originally designed to be used for selling argicultural products.It uses techs like elasticsearch,DRF,paypal,etc. But it can be used for selling any other type of product.

First let’s create a custom user manager and a custom user model for our app.In our models.py add:

from django.db import models
from django.contrib.auth.models import BaseUserManager,AbstractBaseUser
from django.contrib.auth.models import User
from django.core.mail import send_mail

class MyUserManager(BaseUserManager):
    def create_user(self,username,email,password=None):
        if not username:
            raise ValueError('Users must have an username')
        user = self.model(
           username=username,
           email=self.normalize_email(email),
        )

        user.set_password(password)
        user.save(using=self._db)
        return user

    def create_superuser(self,username,email,password):
        user = self.create_user(
           username,
           email=email,
           password=password,
        )
        user.is_admin = True
        user.save(using=self._db)
        return user

class MyUser(AbstractBaseUser):
    first_name = models.CharField(
      max_length = 250,
      verbose_name="First Name"
    )
    last_name = models.CharField(
      max_length = 250,
      verbose_name ="Last Name"

    )
    username = models.CharField(
      max_length = 250,
      unique = True
    )
    email = models.EmailField(
    verbose_name = 'email address',
    max_length = 255,
    unique = True
    )
    is_active = models.BooleanField(default=True)
    is_admin = models.BooleanField(default=False)


    USERNAME_FIELD = 'username'
    objects = MyUserManager()


    REQUIRED_FIELDS = ['email']

    def get_first(self):
        return self.first_name
  
    def get_usename(self):      """
        Returns email instead of the fullname for the user.
        """
        return str(self.username)

   
    def get_email(self):
        return self.email

    @property
    def is_staff(self):
        'Is the user a member of staff?'
        return self.is_admin

    def email_user(self, *args, **kwargs):
        send_mail(
        '{}'.format(args[0]),
        '{}'.format(args[1]),
        "Eshop",
        [args[2]],
       )

It’s very useful to create our user model manager and model to understand how create_user and create_superuser works in order to make more complex apps in the future,plus gives us more control over our user model.Always remember to add our custom model(if we use one) in our settings.py:

AUTH_USER_MODEL = 'appname.MyUser'

It categorize products via category

Below are the Category and Product models:

class Category(models.Model):
    name = models.CharField(max_length=250)

    def __str__(self): 
      return self.name

class Product(models.Model):
    name = models.CharField(max_length=250)
    photo = models.ImageField(upload_to='products_photos')
    price = models.IntegerField()
    stock = models.PositiveIntegerField()
    description = models.CharField(max_length=250)
    category = models.ForeignKey(Category, on_delete=models.CASCADE,related_name='category')
   
   objects = models.Manager()
   c_objects = ProductManager()

   def __str__(self):
      return self.name

   def get_photo(self):
      return self.photo

   def get_price(self):
      return self.price

   def get_stock(self):
      return self.stock

   def get_description(self):
      return self.description

   def get_category(self):
      return self.category 

A ForeignKey relationship between Category and Product is a must,in order for categories to have a lot of products.As you can see above i use a custom model manager as a example.Its wise to use Managers to keep your code clean,efficient and maintaible.Here is an example below:

class ProductManager(models.Manager):
      def category(self,cat_name):

        return super().get_queryset().filter(category__name = cat_name)
Here an example of how the category page will look like

The html above has the products appear three by three.You can switch all of that and adapt it to your needs with basic knowledge of HTML and CSS. Here is the HTML code below(It doesn’t include the Bootstrap 4 Navigation bar):

{% extends 'base.html' %}
<head>
<meta name="viewport" content="width=device-width", initial-scale = 1 >
</head>
{% block content %}
{% load static %}
{% for i in products %}
<div style="width:100%;margin-top:3%">
<div style="float:left;width:33%;text-align:center">
<a href="{% url 'products' i.category i.name%}">{{i.name}}<br><img height="200" src="{% static i.get_photo.url %}"><br>
<p>{{i.get_price}}£</p>
</div>
{% endfor %}
</div>
{% endblock %}

As you can see above, it parses the category and the name of the product in the url for it to look like this : http://websitename.com/category/product .

Product page

Sample Product page

The most important in this HTMl is that we must set a min and max value user would be able to order.


{% load rest_framework %}
<form  method="post">
    {% csrf_token %}
    <div class="form-group">
   <label style="font-size:30px" for="quantity">Quantity:</label>
   <input id='quantity' type="number" class="form-control" 
    name="quantity" min='0' max='{{product.stock}}'>
    </div>

  <button style="margin-top:2%;width:80%" class="btn btn-success" type="submit">Add to Basket</button>
  <p style="margin-top:5%">{{product.description}}</p>
</form>

Obviously the min value would be 0 and the max would be product’s stock.

The url.py shloud look like this:

from django.contrib import admin
from django.urls import path
from money import views

urlpatterns = [
    path('',views.home,name='home'),
    path('<category_name>/',views.CategoryView.as_view(),name='categories'),
    path('<i_category>/<i_name>/',views.ProductView.as_view(),name='products'),
]

Now we add a product to basket we have to recognize if a user is registered or it is just a guest.We create a model for registered users to save their basket and we will save the guest’s basket in the sessions for limited time(It’s not safe to save important information in the session but these are not.)So our Item,Basket models and Product views would look like this:

class Basket(models.Model):
    user = models.ForeignKey(settings.AUTH_USER_MODEL,blank=True,on_delete=models.CASCADE)
    final_price = models.DecimalField(max_digits=5,decimal_places=2,blank=True)

    def get_final_price(self):
        return self.final_price

class Item(models.Model):
    basket = models.ForeignKey(Basket,blank=True,null=True,on_delete=models.CASCADE)
    guest_basket = models.ForeignKey(Guest_Basket,blank=True,null=True,on_delete=models.CASCADE)
    quantity = models.PositiveIntegerField()
    price = models.DecimalField(max_digits=5,decimal_places=2,blank=True)
    product = models.ForeignKey(Product,blank=True,on_delete=models.CASCADE)

    def name(self):
        return self.product.name

Notice that in the Item model we have a basket and a guest basket field in order to have only one model for both cases.One of the two will always be blank.For unregistered users we save their basket first in session.If they want to make an order later we save the basket in the database.

First of all we have to create a serializer Item class:

from rest_framework import serializers
from .models import Item
import decimal

class ItemSerializer(serializers.ModelSerializer):


    class Meta:
        model = Item
        fields = ('quantity',)

    def create(self,validate_data):
        basket = self.context.get('basket')
        product = self.context.get('product')
        quantity = validate_data['quantity']
        item_price = decimal.Decimal(product.price * (quantity/1000))

        try:
            item = Item.objects.get(product=product,basket=basket)
        except Item.DoesNotExist:
            item = Item(basket=basket,quantity=quantity,price=item_price,product=product)
        else:
            item.price += item_price
            item.quantity += quantity
        item.save()
        return item
from rest_framework.views import APIView
from rest_framework.renderers import TemplateHTMLRenderer
from rest_framework.response import Response
import decimal


class ProductView(APIView):
    renderer_classes = [TemplateHTMLRenderer]
    template_name = 'product.html'
    permission_classes = (AllowAny,)

    def get(self,request,**kargs):
        product = Product.objects.get(name=kargs.get('i_name'))
        serializer = ItemSerializer
        return Response({'product':product,'cats':cats(),'serializer':serializer})

    def post(self,request,**kargs):
        product = Product.objects.get(name=kargs.get('i_name'))
        user = request.user
        if user.id:
            try:
                basket = Basket.objects.get(user=user)
            except Basket.DoesNotExist:
                basket = Basket.objects.create(user=user)
            serializer = ItemSerializer(data=request.data,context={'basket':basket,'product':product})
            if serializer.is_valid():
                serializer.save()
                total = 0
                items = Item.objects.filter(basket=basket)
                for item in items:
                    total += item.price
                basket.final_price = total
                basket.save()
                return redirect('cart')
            else:
                return Response({'serializer':serializer})

        else:
            q = int(request.POST.get('quantity'))
            price = product.get_price()
            f_price = price * (q / 1000)
            list1 = request.session.get('product')
            if not  list1:
                 list1 = {product.name:[f_price,q]}
            else:
                if product.name in list1:
                    list1[product.name][0] += f_price
                    list1[product.name][1] += q
                else:
                    list1.update({product.name:[f_price,q]})
            request.session['product'] = list1
            return redirect('session_cart')

Now add these lines in the urls.py:

    path('cart',views.User_Basket.as_view(),name='cart'),
    path('basket',views.session_cart,name='session_cart'),

Now lets make some views and HTMLs for our cart(user basket) and basket(session basket).Update the views.py.

class User_Basket(APIView):
    renderer_classes = [TemplateHTMLRenderer]
    template_name = 'cart.html'
    authentication_classes =[SessionAuthentication,BasicAuthentication]
    permission_classes = (IsAuthenticated,)

    #if we want to use the oauth2 authentication backend uncoment
    #authentication_classes = (OAuth2Authentication,)
    #permission_classes = [IsAuthenticated, TokenHasReadWriteScope]

    def get(self,request):
        user = request.user
        try:
            basket = Basket.objects.get(user=user)
        except Basket.DoesNotExist:
            categories = cats()
            return Response({'cats':categories})
        items = Item.objects.filter(basket=basket)
        if not items:
            basket.final_price = 0
            basket.save()
        #data = serializers.serialize('json', items)
        return YOuResponse({'basket':basket,'items':items,'cats':cats()})

def session_cart(request):
    products = []
    ids_price = request.session.get('product')
    if ids_price:
        for id,price_quantity in ids_price.items():
            products.append([Product.objects.get(name=id),price_quantity])
    total_price = 0
    for i,prices in products:
        total_price += prices[0]
    request.session['total_price']= total_price
    return render(request,'session_cart.html',{'products':products,'total_price':total_price,'cats':cats()})

You have must noticed by now the cats() function that the render parses to the html.When we have responses that there are not update frequently it is wise to save them in cache memory in order to reduce the workload of the API such as the categories.So we add to the views the following code:

from django.core.cache import cache
def cats():
    if 'categories' in cache:
        categories = cache.get('categories')
        if not  categories:
            cats = Category.objects.all()
            categories = cats
    else:
        cats = Category.objects.all()
        cache.set('categories',cats)
        categories = cache.get('categories')
    return categories

Now is a good time to install our ElasticSearch server is our Eshop.First of all we need to install the elasticsearch server.Install elasticsearch from here , after that open the folder and run in with bin/elasticsearch on your terminal.I personally used the django-elasticsearch-dsl module.

pip install django-elasticsearch-dsl

Lets make a documents.py file in our app folder:

from django_elasticsearch_dsl import DocType,Index,fields
from .models import Product,Category

category = Index('category')

category.settings(
    number_of_shards=1,
    number_of_replicas=0
)


@category.doc_type
class CategoryDocument(DocType):

    class  Meta:
        model = Category
        fields = [
          'name'
          ]
product = Index('product')
product.settings(
    number_of_shards=1,
    number_of_replicas=0
)

@product.doc_type
class ProductDocument(DocType):
    category = fields.ObjectField(properties={
       'name': fields.TextField(),
    })
    class Meta:
        model = Product
        fields = [
        'id','name','price','description'
        ]

It’s similar to the forms.py.Notice the unique way to add the category field because is a ForgeingKey After we build our documents.py run the following command:

./manage.py search_index --rebuild

To build our elasticsearch server.Keep in mind that in order to add,delete or make changes to our Product or Categories we have to open our elasticsearch server,plus run the above command after the changes to update the server.

Now that we have build our elasticsearch server is time to use it.Let’s put a simple search field in our home page.

 <form  type="get" action="{% url 'result' %}">
  <div class="input-group mb-3">
 <input id="myInput" type="text" class="form-control" placeholder="Search on Eshop" name="search_box" aria-label="Recipient's username" aria-describedby="basic-addon2">
 <div class="input-group-append">
   <button type="submit" class="btn btn-outline-success"type="button">Search</button>
 </div>
</div>
</form>

We must add in urls.py:

     path('results',views.result,name='result'),

And finally in our views.py:

from .documents import ProductDocument,CategoryDocument

def result(request):
    if request.method == "GET":
        word = request.GET.get('search_box',None)
        products = ProductDocument.search().filter("match",category__name=word)
        return render(request,'results.html',{'products':products,'cats':cats()})

As you can see we search products via category name,we can change this and put any other field(name,description,price) we added in the ProductDocument class in documents.py.

Now let’s add some products to our basket and see how it looks like.

<div style="width:70%;margin-left:15%;font-size:25px;margin-top:4%">
<table class="table table-bordered ">
  <thead>
    <tr>
      <th>Product</th>
      <th>Price per kg</th>
      <th>Total price</th>
      <th>Quantity</th>
      <th>Actions</th>
    </tr>
  </thead>
  <tbody>
    {% csrf_token %}
    {% for item in items %}
    <tr>
      <td>
        <p style="float:left">{{item.product.name}}<p>
        <img style="margin-left:2%" height="50" width="100"  src="{% static item.product.photo.url %}">

      </td>
      <td>{{item.product.price}}</td>
      <td>{{item.price}}</td>
      <td>{{item.quantity}}
      <td>
        <div style="width:100%:text-align:center">
          <a  href="{% url 'delete_item' item.product.id %}"><button style="float:left" type="button" class="btn btn-danger">Remove</button></a>
        <form method="get" action="/{{item.product.category.name}}/{{item.product.name}}">
          <button style="margin-left:3%" type="submit" class="btn btn-primary">Watch Item</button>
        </form>
       </div>
    </tr>
    {% endfor %}

    </tbody>
  </table>
</div>
<div style="margin-right:20%;width:30%;float:right">
<div style="text-align:center;box-sizing:border-box;border-bottom:5px inset;">
<p>Total<p>
</div>
<div style="margin-top:3%;text-align:center;box-sizing:border-box;border-bottom:5px inset;">
<p>{{basket.final_price}}<p>
</div>
<a href="{% url 'order_handler' %}"><button style="width:70%;margin-left:15%;margin-top:3%" type="submit" class="btn btn-success">Proceed to Checkout</button></a>
</div>

I used this table as an example

Notice that if we add the same product twice ,it just add the quantity.Now we have to add the delete action in our views.py.We need 2 views for that,one for the registered users and on for the simple guests(as it mentioned above the users baskets is stored in the database and the guests basket is in the session

Add these lines in our urls.py:

path('delete/<item_product_id>',views.delete_item,name='delete_item'),
    path('delete_session/<product_id>',views.delete_session_item,name='delete_session_item'),

And in our views.py:

 #for registered users
def delete_item(request,item_product_id):
    product = Product.objects.get(pk=item_product_id)
    user = request.user
    basket = Basket.objects.get(user=user)
    item = Item.objects.get(basket=basket,product=product).delete()
    return redirect('cart')

def delete_session_item(request,product_id):
    list_products = request.session['product']
    if list_products:
        if product_id in list(list_products) :
            del list_products[product_id]
    request.session['product'] = list_products
    return redirect('session_cart')

Now let’s handle our checkout.Again we have two scenarios for users.The first one for registered users we need to save the some informations to make more easy for user to buy again.You must be notice the proceed to ckeckout button in our basket.Add this line to our urls.py:

    path('order_details',views.Order_Handler.as_view(),name="order_handler"),

And in our views.py:

class Order_Handler(APIView):
    renderer_classes = [TemplateHTMLRenderer]
    template_name = 'order_details.html'
    authentication_classes =[SessionAuthentication,BasicAuthentication]
    permission_classes = (IsAuthenticated,)
    #if we want to use the oauth2 authentication backend uncoment
    #authentication_classes = (OAuth2Authentication,)
    #permission_classes = [IsAuthenticated, TokenHasReadWriteScope]

    def get(self,request):
        user = request.user
        request.session['executed']= True
        ran_id = str(uuid.uuid4())
        try:
            ad = Address.objects.get(user=user)
        except Address.DoesNotExist:
            return redirect('user_ad')
        else:
            ad = Address.objects.get(user=user)
            basket = Basket.objects.get(user=user)
            order= Order(user=user,address=ad,basket=basket)
            order.save()
            return Response({'order':order,'ran_id':ran_id})

Let’s break this down.First of all we save a variable in session for future use(will explain this later) ,we create a random uuid to parse in our html and from there to our success payment url in order to make it accessible only from redirection.The view tests if there is an address saved related to the user,if not it redirects you to create one,if yes we continue to complete our order.Assuming there isn’t an address yet.Create an address serializer.

class AddressSerializer(serializers.ModelSerializer):
    first_name = serializers.CharField(required=True)
    last_name = serializers.CharField(required=True)
    phone_number = serializers.CharField(required=True)
    address = serializers.CharField(required=True)
    post_code = serializers.CharField(required=True)
    town_city = serializers.CharField(required=True)



    class Meta:
        model = Address
        exclude = ('user', )
       
    def create(self,validate_data):
        request = self.context.get("request")
        user = request.user
        ad = Address(user=user,**validate_data)      
        ad.save()
        return ad

    def update(self,instance,validate_data):
        instance.first_name = validate_data['first_name']
        instance.last_name = validate_data['last_name']
        instance.phone_number = validate_data['phone_number']
        instance.address = validate_data['address']
        instance.post_code = validate_data['post_code']
        instance.town_city = validate_data['town_city']
        instance.save()
        return instance

Now in our views.py:

from .serialiazers import AddressSerializer

class User_Ad(APIView):
    renderer_classes = [TemplateHTMLRenderer]
    template_name = 'user_ad.html'
    authentication_classes =[SessionAuthentication,BasicAuthentication]
    permission_classes = (IsAuthenticated,)
    #if we want to use the oauth2 authentication backend uncomment
    #authentication_classes = (OAuth2Authentication,)
    #permission_classes = [IsAuthenticated, TokenHasReadWriteScope]


    def get(self,request):
        serializer = AddressSerializer()
        return Response({'serializer':serializer,'cats':cats()})

    def post(self,request):
          serializer = AddressSerializer(data=request.data,context={'request':request})
          if serializer.is_valid():
              serializer.save()
              return redirect('order_handler')
          categories = Category.objects.all()
          return Response({'serializer':serializer,'cats':cats()})

Back to our Order handler view,we make an order model:

class Order(models.Model):
    user = models.ForeignKey(settings.AUTH_USER_MODEL,blank=True,on_delete=models.CASCADE)
    address = models.ForeignKey(Address,on_delete=models.CASCADE)
    basket = models.ForeignKey(Basket,on_delete=models.CASCADE)
    is_completed = models.BooleanField(default=False)

    def items(self):
        items_info = []
        items = Item.objects.filter(basket=self.basket)
        for item in items:
            items_info.append([item,item.quantity])
        return items_info
    def items_details(self):
        details = []
        items = Item.objects.filter(basket=self.basket)
        for item in items:
            line = 'Product:{},Quantity:{}'.format(item.product.name,item.quantity)
            details.append(line)
        return details
    def final_price(self):
        return self.basket.final_price

    def name(self):
        return self.address.first_name
    def last_name(self):
        return self.address.last_name

    def phone(self):
        return str(self.address.phone_number)
    def country(self):
        return str(self.address.country.name)
    def ad(self):
        return str(self.address.address)
    def post_code(self):
        return str(self.address.post_code)
    def town(self):
        return str(self.address.town_city)
    def extra(self):
        return str(self.address.extra_info)

We have save our order in our View but we have a boolean field is_completed as false because we have to complete our payment.

We almost done.It’s time to handle the payments.Here is a basic a html of our order summary before the payment.

It’s a very basic and ugly,but let’s look the more important things inside the code below:

<h1>Products</h1>

{% for item,quantity in order.items %}

<img style="margin-left:2%" height="50" width="100"  src="{% static item.product.get_photo.url %}">
{{item.product.name}}
{{quantity}}
{{item.product.price}}
{% endfor %}<br>
{{order.basket.final_price}}
<br>
<div style="margin-top:4%">
<h2>Shipping details<h2>
  <p>{{order.address.first_name}}</p>
  <p>{{order.address.last_name}}</p>
  <p>{{order.address.phone_number}}<p>
  <p>{{order.address.country.name}}</p>
  <p>{{order.address.address}}</p>
  <p>{{order.address.post_code}}</p>
  <p>{{order.address.town_city}}</p>
  <p>{{order.address.extra_info}}<p>
<a href="{% url 'change_address' %}"><button type="button" class="btn">Change Address</button></a>
<form action="https://www.sandbox.paypal.com/webscr" method="post">
<!--if you want to use it in production use the below form action--!>
<!--<form action="https://www.paypal.com/cgi-bin/webscr" method="post">-->
  {% csrf_token %}

    <input type="hidden" name="cmd" value="_cart">
    <input type="hidden" name="upload" value="1">
    <input type="hidden" name="business" value="[email protected]">
    <input type="hidden" name="return" value="http://127.0.0.1:8000/order_completed/{{order.id}}/{{ran_id}}">
    <input type="hidden" name="cancel_return" value="http://127.0.0.1/error/">
    <input type="hidden" name="currency_code" value="USD">
    {% for item,quantity in order.items %}

    <input type="hidden" name="item_name_{{item.product.id}}" value="{{item.product.name}}">
    <input type="hidden" name="amount_{{item.product.id}}" value="{{item.price}}">
    {% endfor %}

    <input type="image" src="https://www.paypal.com/en_US/i/btn/x-click-but01.gif" name="submit" alt="Make payments with PayPal - it's fast, free and secure!">
    </form>
</div>

1)We have a change address button for registered users to change the delivery address if they want to.If you noticed above in the Address Serializer we have an update method as well.

Last but not least we have to handle the payments.To do that we make a post request to the url:https://www.paypal.com/cgi-bin/webscr (if we want to use the sandbox paypal for testing look at the comments in the above html code).Now we make a for loop inside the form for our products to appear more detailed like this in paypal:

If the payment is successful paypal will redirect you to the return url and if it is not to the cancel return.In our success url we parse the order.id and and the variable (ran_id) we created in our order handler view.We need the order.id to mark at as completed (we set the boolean field as True) and the ran_id to make the url almost impossible to access it without redirection.If the order is completed we must reduce the stock of the product that have been ordered.We set as executed variable as True in our sessions(we can save in more than one place).The success view will check if the variable is still there,if yes we reduce the stock then we delete the variable from session.If not we don’t do any action.We do that because if the user hit refresh we don’t want our products to reduce again.

Add these lines to our urls.py:

       path('order_completed/<int:id>/<ran_id>',views.Payment_success.as_view(),name='payment_success'),

       path('error',views.error,name='payment_error'),

And in our views.py:

class Payment_success(APIView):
    renderer_classes = [TemplateHTMLRenderer]
    template_name = 'order_success.html'


    def get(self,request,**kwargs):
            id = kwargs.get('id')
            order = Order.objects.get(pk=id)
            order.is_completed = True
            order.save()
            if request.session.get('executed'):
                for item,quantity in order.items():
                    product = Product.objects.get(pk=item.product.id)
                    product.stock -= quantity
                    product.save()
                del request.session['executed']
            basket = order.basket
            del request.session['key_for_redirection']
            return Response({'order':order,'cats':cats()})

def error(request):
    return render(request,'error.html')

For unregistered user we follow a little different path . Check the full code here  .For authorization(how to combine oauth2 and sessions) and sign up users I will make another topic.Feel free to recommend any improvements and comments errors.

­

Leave a Reply

Your email address will not be published. Required fields are marked *