Making a CRUD application with Python is actually one of the basic thing a Python Developer should be able to do. Although some may argue it is extremely basic – it is not.
Requirements
The basic requirements for this one, were the following:
- Display “There are no recipes”, if there are no recipes written:
- Have a nice form to create a recipe:
- Delete the recipe with a blurred option:
- Display the recipes nicely, one below another:
- Have a database (this is not Excel and VBA where you can save things in a spreadsheet 🙂 )
- Show “details” nicely, with ingredients splitted by comma and shown to a list:
Code
Ok, the code is actually available in GitHub (scroll a bit more), but here are some of the highlights:
Forms
The forms are part of the “Django” magic – they take the class which they model and render it into HTML page
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
from django import forms from app.forms.common import DisabledForm from app.models import Recipe class RecipeForm(forms.ModelForm): class Meta: model = Recipe labels = { 'time': 'Time (Minutes)', 'image_url': 'Image URL', } fields = '__all__' class DeleteRecipeForm(RecipeForm, DisabledForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) DisabledForm.__init__(self) |
The DisabledForm() class is the one that makes the form look “unclickable”. See the “Delete Recipe” picture above to get an idea:
1 2 3 4 5 |
class DisabledForm(): def __init__(self): for (_, field) in self.fields.items(): field.widget.attrs['disabled'] = True field.widget.attrs['readonly'] = True |
Views
The views are the part of the code, that take care about which html file should be presented with what content, upon a given request. In the project, these are into 2 files. Index.py is actually rather straight-forward, presenting either a simple html file with “There are no recipes” text on it or listing the recipes above each other. Which version you will see, depends on the value of the recipes_present key of the context:
1 2 3 4 5 6 7 8 9 10 11 |
from django.shortcuts import render from app.models import Recipe def index(request): recipes_present = Recipe.objects.exists() recipes = Recipe.objects.all() context = { 'recipes_present': recipes_present, 'recipes': recipes, } return render(request, 'index.html', context) |
For the other recipes.py views the story is not that trivial. There we see a difference between GET and POST requests, for each of the CRUD actions. A pk variable is taken, in oder to locate exactly the correct recipe from the database with recipe = Recipe.objects.get(pk=pk):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 |
from django.shortcuts import render, redirect from app.forms.recipes import RecipeForm, DeleteRecipeForm from app.models import Recipe def recipe_create(request): if request.method == 'GET': context = { 'form': RecipeForm(), } return render(request, 'create.html', context) else: form = RecipeForm(request.POST) if form.is_valid(): form.save(commit=True) return redirect('index') context = { 'form': form, } return render(request, 'create.html', context) def recipe_edit(request, pk): recipe = Recipe.objects.get(pk=pk) if request.method == 'GET': context = { 'recipe': recipe, 'form': RecipeForm(instance=recipe) } return render(request, 'edit.html', context) else: form = RecipeForm(request.POST, instance=recipe) if form.is_valid(): form.save() return redirect('index') context = { 'recipe': recipe, 'form': form, } return render(request, 'edit.html', context) def recipe_details(request, pk): recipe = Recipe.objects.get(pk=pk) ingredients = recipe.ingredients.split(', ') context = { 'recipe': recipe, 'ingredients': ingredients, } return render(request, 'details.html', context) def recipe_delete(request, pk): recipe = Recipe.objects.get(pk=pk) if request.method == 'GET': context = { 'recipe': recipe, 'form': DeleteRecipeForm(instance=recipe) } return render(request, 'delete.html', context) else: recipe.delete() return redirect('index') |
Model
Our model is only one, as the app is quite small. Anyway, this one is used for the forms creation:
1 2 3 4 5 6 7 8 9 10 11 |
from django.db import models class Recipe(models.Model): title = models.CharField(max_length=30) image_url = models.URLField() description = models.TextField() ingredients = models.CharField(max_length=250) time = models.IntegerField() def __str__(self): return f'{self.title} - {self.description}.' |
Urls
There are two type of urls – /app/views/urls.py and /recipes/urls.py. The old Roman saying – “Divide and rule” is actually quite an important one here, putting the recipe app logic into the recipe app and leaving almost nothing to the main app:
1 2 3 4 |
urlpatterns = [ path('admin/', admin.site.urls), path('', include('app.urls')) ] |
1 2 3 4 5 6 7 |
urlpatterns = ( path('', index, name='index'), path('create/', recipe_create, name='recipe create'), path('edit/<int:pk>', recipe_edit, name='recipe edit'), path('delete/<int:pk>', recipe_delete, name='recipe delete'), path('details/<int:pk>', recipe_details, name='recipe details'), ) |
Templates
Partial template
As you have probably noticed that the upper part of the app is repeating everywhere, it is a good idea to take this part of the app into a partial template. It looks like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
<html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <link rel="stylesheet" href="/static/css/style.css"> <link rel="icon" type="image/png" href="/static/images/chef.png"/> <title>Recipes</title> </head> <body> <nav> <ul> <img src="/static/images/chef.png" alt="chef"> <li class="title"><a href="{% url 'index' %}">Recipes</a></li> <li><a href="{% url 'recipe create' %}">Add Recipe</a></li> </ul> </nav> {% block site_content %} {% endblock %} </body> </html> |
Index.html
I have decided to put the Index, as you may easily see the If-Else-EndIf logic in this one, deciding whether to render the “No recipe” part of the page or the pictures with the text.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
{% extends 'partials/site.html' %} {% block site_content %} <div class="container"> <h1>My Recipes</h1> {% if recipes_present %} <div class="items-container"> {% for recipe in recipes %} <div class="item"> <h2>{{ recipe.title }}</h2> <img class="detail-img" src={{ recipe.image_url }} alt="recipe-image"> <div class="recipe-info"> {{ recipe.description }} </div> <div class="buttons-container"> <a class="button detail" href="{% url 'recipe details' recipe.id %}">Details</a> <a class="button edit" href={% url 'recipe edit' recipe.id %}>Edit</a> <a class="button delete" href={% url 'recipe delete' recipe.id %}>Delete</a> </div> </div> {% endfor %} </div> {% else %} <h2>There are no recipes.</h2> {% endif %} </div> {% endblock %} |
Delete.html
The delete is actually chosen, because it provides just a small taste of how little code is needed, if one uses the partial site and the forms correctly. 15 lines are enough:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
{% extends 'partials/site.html' %} {% block site_content %} <div class="container"> <h1>Delete Recipe</h1> <div class="items-container"> <div class="item"> <form method="POST"> {{ form }} {% csrf_token %} <input type="submit" class="button delete" value="Delete"> </form> </div> </div> </div> {% endblock %} |
How to run it & GitHub:
I guess you probably will not be able to replicate the project with the code above. Don’t worry, there is a zip archive in git, that you can actually extract and run: https://github.com/Vitosh/Python_personal/tree/master/PythonProjects/recipes_su
Still, just having the GitHub will not be enough. There is a database behind it and if you run it without setting the database, you would get: conn = _connect(dsn, connection_factory=connection_factory, **kwasync) django.db.utils.OperationalError: FATAL: database “recipes” does not exist
- First make sure that you have a database named “recipes”;
- Then make sure that you run migrate from manage.py;
- The settings.py is another story;