Consuming API with Django and Chart.js [Part 3]

This is going to be the third and final part of this series. In the previous part of this series; our aim was just to get our application to work. Now that we have achieved that; in this part, we will be making our code more presentable.

Objectives:

I) Carry out separation of concerns.
II) Add styling to our page to make the User Interface clean.

Objective I

All of our business logic was written in one single function in the views.py file. We will be correcting that here by separating our code into the service.py and views.py files.

We have to ensure that our service.py file is not aware of any HTTP communication, so we completely isolate the following logic to the views.py file; the handling of input extraction from the HTTP request, input validation and, handling of HTTP parameters. Every other logic will go into the service.py file. Explanation of the code will be included as comments in the various files where we will be making changes.

services.py

First, we create a services.py file in our price application directory.
service.py

Then we will add the code containing our functions below

import requests
from datetime import date, timedelta , datetime
from .forms import PriceSearchForm

#function to get the current and today-10days dates respectively
class getDateService():
    def getCurrentDateView(self):
        datetime_today = date.today() #get current date
        date_today = str(datetime_today) #convert datetime class to string
        date_10daysago = str(datetime_today - timedelta(days=10)) #get date of today -10 days

        #assign 'date from' and 'date to' for chart template heading 
        date_from = date_10daysago 
        date_to = date_today

        return date_from,date_to

#function to make the api get call and retrieve the default 10days api data.
class getDefaultData():
    def makeDefaultApiView(self, date_from, date_to):
        PriceFormSearch = initialData(date_from, date_to) #call the initial data function and append the values to the search form
        search_form_default= PriceFormSearch

        return search_form_default

class getUserInputDateRange():
    def userFormInputView(self, date_from, date_to, date_today):
        if date_to > date_today:   #if the date to from input is greater than today's date; there wont be data for the extra days, so we change the 'date_to' input back to todays's date
            date_to = date_today
        PriceFormSearch = initialData(date_from, date_to)
        search_form_current= PriceFormSearch  #when the page reloads, set the default form date input values to the dates picked by the user

        return  search_form_current
class outOfRange():
    def ooR(self, date_from, date_to, range_error):
        from_date= datetime.strptime(date_from, '%Y-%m-%d').date()
        to_date= datetime.strptime(date_to, '%Y-%m-%d').date()

        if from_date < (to_date - timedelta(days=90)):   #check if the date range is not greater than 3 months
            range_error= 'No more than 3 months data can be displayed'

        PriceFormSearch = initialData(date_from, date_to)
        search_form_values= PriceFormSearch

        return date_from, date_to, range_error, search_form_values

def initialData(date_from, date_to):  #initial data function to render our date inputs out with chosen value
    initial_data={'date_from':date_from,   
                    'date_to':date_to,
                }
    PriceForm = PriceSearchForm(initial=initial_data) #append the date_from and date_to values to the search form

    return PriceForm

views.py

You can go ahead and do a complete overhaul of your views file and replace the content with the code below.

import requests
from django.shortcuts import render
from .forms import PriceSearchForm
from .services import getDateService,getDefaultData,getUserInputDateRange,outOfRange #import business logic from services.py layer


# Create your views here.
def chart(request):
    bitcoin_price= None
    wrong_input = None
    range_error = None

    # assign the functions imported from services.py to variables to allow for easier use
    initiateDateGet = getDateService()
    initiateDefaultDataGet = getDefaultData()
    initiateUserDateGet = getUserInputDateRange()
    initiateRangeErrorGet = outOfRange()

    date_from, date_to = initiateDateGet.getCurrentDateView() #get the dates for present day and present day - 10 days 

    search_form= initiateDefaultDataGet.makeDefaultApiView(date_from, date_to) #use the 10days period obtained from the function above to set the default form values

    bitcoin_price = getBitcoinData(date_from, date_to)#use the 10days period obtained from the function above to get dafualt 10days data

    from_date, to_date = getUserDateView(request) #if request method is 'post', validate the form and get date range supplied by user and use it for the api call

    if from_date is not None and to_date is not None:  #check if data was supplied by the user

        date_today=date_to #assign todays date to date_today variable

        date_from, date_to, date_out_of_range, search_form = initiateRangeErrorGet.ooR(from_date, to_date, range_error)  #check if the supplied date range is not greater than 3 months

        if date_out_of_range is not None:
            range_error = date_out_of_range  #if date range is more than 3 months, render this error in the html page
            bitcoin_price = None
        else:
            bitcoin_price, date_from, date_to, wrong_input = getUserInputData(from_date, to_date, date_today, wrong_input) #if there is data supplied my the user via the form, proceed to make the api call and retrieve the required data
            search_form = initiateUserDateGet.userFormInputView(from_date, to_date, date_today ) #make the date range submitted in the form supplied by the user via the form the default input of the form

    context = {
        'search_form': search_form,
        'price': bitcoin_price,
        'wrong_input':wrong_input,
        'date_from':date_from,
        'date_to':date_to,
        'range_error': range_error
        }

    return render(request, "chart.html", context)

#function to confirm if valid date ranges have been supplied by the user.
def getUserDateView(request):
    date_from = None
    date_to = None
    search_form= PriceSearchForm(request.POST or None) #get post request from the front end
    if request.method == 'POST': 
        if search_form.is_valid():  #Confirm if valid data was received from the form
            date_from = request.POST.get('date_from') #extract input 1 from submitted data
            date_to = request.POST.get('date_to') #extract input 2 from submitted data

        else:
            raise Http400("Sorry, this did not work. Invalid input")

    return date_from,date_to


def getUserInputData(date_from, date_to, date_today, wrong_input):
    from_date= None
    to_date= None
    requested_btc_price_range= None

    if date_to > date_from:     #confirm that input2 is greater than input 1
        if date_to > date_today:    #if the date to from input is greater than today's date; there wont be data for the extra days, so we change the 'date_to' input back to todays's date
            date_to = date_today 
        api= 'https://api.coindesk.com/v1/bpi/historical/close.json?start=' + date_from + '&end=' + date_to + '&index=[USD]' #use the 10days period obtained above to get dafualt 10days value
        try:
            response = requests.get(api, timeout=10) #get api response data from coindesk based on date range supplied by user with a timeout of 10seconds
            response.raise_for_status()        #raise error if HTTP request returned an unsuccessful status code.
            prices = response.json() #convert response to json format
            requested_btc_price_range=prices.get("bpi") #filter prices based on "bpi" values only
            from_date= date_from
            to_date= date_to
        except requests.exceptions.ConnectionError as errc:  #raise error if connection fails
            raise ConnectionError(errc)
        except requests.exceptions.Timeout as errt:     #raise error if the request gets timed out after 10 seconds without receiving a single byte
            raise TimeoutError(errt)
        except requests.exceptions.HTTPError as err:     #raise a general error if the above named errors are not triggered 
            raise SystemExit(err)
    else:
        wrong_input = 'Wrong date input selection: date from cant be greater than date to, please try again' #print out an error message if the user chooses a date that is greater than input1's date 

    return requested_btc_price_range, from_date, to_date , wrong_input,

def getBitcoinData(date_from, date_to):

    api= 'https://api.coindesk.com/v1/bpi/historical/close.json?start=' + date_from + '&end=' + date_to + '&index=[USD]' 
    try:
        response = requests.get(api, timeout=10) #get api response data from coindesk based on date range supplied by user
        response.raise_for_status()              #raise error if HTTP request returned an unsuccessful status code.
        prices = response.json() #convert response to json format
        default_btc_price_range=prices.get("bpi") #filter prices based on "bpi" values only
    except requests.exceptions.ConnectionError as errc:  #raise error if connection fails
        raise ConnectionError(errc)
    except requests.exceptions.Timeout as errt:     #raise error if the request gets timed out after 10 seconds without receiving a single byte
        raise TimeoutError(errt)
    except requests.exceptions.HTTPError as err:    #raise a general error if the above named errors are not triggered 
        raise SystemExit(err)

    return default_btc_price_range

Objective II

We will be using crispy forms to make our forms look really crispy😉. All pun intended.
Run the command below

pip install django-crispy-forms

Then after installation, we'll need to adjust our settings file. Add this under your installed app section

#3rd Party
    'crispy_forms'

and this directly below the installed app settings.

CRISPY_TEMPLATE_PACK = 'bootstrap4'

It should look exactly like this
settings

chart.html

We are going to add this error alert code to our chart.html file to cap possible data that can be obtained to 3months so our chart does not become unreadable. We'll also be changing our form code, replacing our previous span elements with hidden input, and adding an h4 tag header to give our chart a dynamic title.

<!-- raise error  when selected date range is more than 3 months -->
 {% if range_error %}
     <div class="alert alert-warning" role="alert">
        {{range_error}}
     </div>
 {% endif %}

 <div class="row">
    <!-- form to filter dates on chart -->
    <form id="myForm" action="" method='POST'>
      {% csrf_token %}
      <div class="d-flex flex-nowrap cover">
        {{search_form| crispy}} <!-- render form with crispy forms -->
      <div class="">
      <button type="submit" class="order-2 btn btn-primary mt-3"> Render</button>
      </div>

      </div>
    </form>     
  </div>

 <h4 class="text-center">Bitcoin price change from {{date_from}} to {{date_to}}</h4>
     {% for date,price in price.items %}
        <input type="hidden" class="date-item" value="{{date}}">
        <input type="hidden" class="price-item" value="{{price}}">
     {% endfor %}

Please ensure you add all the code above within the {block element} template tags.

Next, add the bootstrap CDN and link to your CSS stylesheet within the {block style} template tags.

<!-- Bootstrap CSS -->
    <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-BmbxuPwQa2lc/FVzBcNJ7UAyJxM6wuqIj61tLrc4wSX0szH/Ev+nYRRuWlolflfl" crossorigin="anonymous">
    <!-- Local css -->
    <link rel="stylesheet" href="{% static 'css/style.css' %}">

chart.js

Since we are appending the data received from the api to our input element now, we can no longer use innerHtml. We need to go to our chart.js file and make little changes to lines 7 and 12. Change innerhtml to value.

date[i] = dates[i].value
price[j] = prices[j].value

style.css

Create a style.css file in your CSS folder and add the following lines of code to it.

.form-control {
    width: unset;
    margin: 0 !important ;
    display: unset !important;
}
.form-group, .form-group div{
    margin-right:0 !important ;
    width: 33%;
    display: unset !important;
}
.cover{
    text-align: center;
    justify-content: space-evenly;
}
form{
    margin-bottom: 2rem;
}
input{
    cursor: pointer;
}
.btn{
    margin-top: 0 !important;
}
.chart-container{
    width: 100% !important;
}

@media (max-width: 760px) {
    .form-control{
        width: 100%;
    }
}
@media (max-width: 991px) {
        .btn{
            margin-top: 25px !important;
    }
}

Congratulations. We have carried out separation of concern, so our code looks more readable and we have also added little styling to make it look much more presentable. Here is the new look of our application.
New UI

Thank you for coming this far. You can access the Github repo here.

If you have any questions, feel free to drop them as a comment or send me a message on Linkedin or Twitter and I'll ensure I respond as quickly as I can. Ciao 👋
Work completed

32