Compare commits

...

13 commits

Author SHA1 Message Date
Benjamin 4ada3dbc5d Set some template headers 2023-03-19 19:12:57 +01:00
Benjamin c5b3e83f6f Refactor forms 2023-03-19 19:12:57 +01:00
Benjamin 1d1a0653e5 Add license header to source files 2023-03-19 19:12:57 +01:00
Benjamin 2f4ed102d7 Add url patterns 2023-03-19 19:12:57 +01:00
Benjamin abbba03deb Add views 2023-03-19 19:12:57 +01:00
Benjamin de214a9e2a Add HTML templates 2023-03-19 19:12:57 +01:00
Benjamin 0141332d2e Add forms 2023-03-19 19:12:57 +01:00
Benjamin da7ae6151e Register models to admin 2023-03-19 19:12:57 +01:00
Benjamin da694d8d63 Add models 2023-03-19 19:12:57 +01:00
Benjamin 62b4865a26 Add links to recipes add to nav list and index 2023-03-19 19:12:56 +01:00
Benjamin f8d5ca0e83 Add recipes/ url pattern 2023-03-19 19:12:56 +01:00
Benjamin f6f1590f02 Start App Recipes 2023-03-19 19:12:56 +01:00
Benjamin df50a8dc42 Set template titles login and new-user 2023-03-19 19:12:46 +01:00
20 changed files with 568 additions and 0 deletions

0
recipes/__init__.py Normal file
View file

25
recipes/admin.py Normal file
View file

@ -0,0 +1,25 @@
"""
Barn Web App - A collection of web-apps for my family's personal use,
including a recipe database.
Copyright © 2023 Benjamin Stadlbauer
This program is free software: you can redistribute it and/or modify it
under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or (at
your option) any later version.
This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
General Public License for more details.
This program comes with a copy of the GNU Affero General Public License
file at the root of this project.
"""
from django.contrib import admin
from .models import Recipe, Version, Ingredient
admin.site.register(Recipe)
admin.site.register(Version)
admin.site.register(Ingredient)

25
recipes/apps.py Normal file
View file

@ -0,0 +1,25 @@
"""
Barn Web App - A collection of web-apps for my family's personal use,
including a recipe database.
Copyright © 2023 Benjamin Stadlbauer
This program is free software: you can redistribute it and/or modify it
under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or (at
your option) any later version.
This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
General Public License for more details.
This program comes with a copy of the GNU Affero General Public License
file at the root of this project.
"""
from django.apps import AppConfig
class RecipesConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'recipes'

56
recipes/forms.py Normal file
View file

@ -0,0 +1,56 @@
"""
Barn Web App - A collection of web-apps for my family's personal use,
including a recipe database.
Copyright © 2023 Benjamin Stadlbauer
This program is free software: you can redistribute it and/or modify it
under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or (at
your option) any later version.
This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
General Public License for more details.
This program comes with a copy of the GNU Affero General Public License
file at the root of this project.
"""
from django.forms import ModelForm, ValidationError, modelformset_factory, BooleanField
from .models import Recipe, Version, Ingredient
class RecipeForm(ModelForm):
class Meta:
model = Recipe
fields = ['title', 'slug']
class VersionForm(ModelForm):
recipe_id: int
def __init__(self, *args, **kwargs):
placeholder = None
if 'author_placeholder' in kwargs:
placeholder = kwargs.pop('author_placeholder')
super().__init__(*args, **kwargs)
if placeholder:
self.fields['author'].widget.attrs.update({'placeholder': placeholder})
class Meta:
model = Version
fields = ['label', 'slug', 'body', 'author']
def clean_slug(self):
slug = self.cleaned_data['slug']
if 'slug' in self.changed_data:
recipe = Recipe.objects.get(id=self.recipe_id)
if recipe.versions.filter(slug=slug).count() > 0: # type: ignore
raise ValidationError('A recipe version with this slug already exists.')
return slug
class IngredientForm(ModelForm):
class Meta:
model = Ingredient
fields = ['text']
IngredientFormSet = modelformset_factory(Ingredient, fields=('text',), extra=1)

View file

53
recipes/models.py Normal file
View file

@ -0,0 +1,53 @@
"""
Barn Web App - A collection of web-apps for my family's personal use,
including a recipe database.
Copyright © 2023 Benjamin Stadlbauer
This program is free software: you can redistribute it and/or modify it
under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or (at
your option) any later version.
This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
General Public License for more details.
This program comes with a copy of the GNU Affero General Public License
file at the root of this project.
"""
from django.db import models
from django.contrib.auth.models import User
from django.urls import reverse
class Recipe(models.Model):
title = models.CharField(max_length=100, null=False, blank=False)
slug = models.SlugField(unique=True)
def __str__(self) -> str:
return self.title
def get_absolute_url(self):
return reverse('recipe', kwargs={'slug': self.slug})
class Version(models.Model):
label = models.CharField(max_length=20, default='Original')
slug = models.SlugField(max_length=20, default='original')
body = models.TextField(null=True, blank=True)
user = models.ForeignKey(User, on_delete=models.PROTECT, null=False, blank=False)
author = models.CharField(max_length=30, blank=True)
recipe = models.ForeignKey(Recipe, on_delete=models.PROTECT, null=False, blank=False, related_name='versions')
def __str__(self) -> str:
return self.recipe.title + ' - ' + self.label
def get_absolute_url(self):
return reverse('version', kwargs={'slug_recipe': self.recipe.slug, 'slug_version': self.slug})
class Ingredient(models.Model):
text = models.CharField(max_length=50, null=False, blank=False)
version = models.ForeignKey(Version, on_delete=models.CASCADE, null=False, blank=False, related_name='ingredients')
def __str__(self) -> str:
return self.text + ' for ' + str(self.version)

View file

@ -0,0 +1,28 @@
{% extends "base_main.html" %}
{% comment %}
Barn Web App - A collection of web-apps for my family's personal use,
including a recipe database.
Copyright © 2023 Benjamin Stadlbauer
This program is free software: you can redistribute it and/or modify it
under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or (at
your option) any later version.
This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
General Public License for more details.
This program comes with a copy of the GNU Affero General Public License
file at the root of this project.
{% endcomment %}
{% block title %}{{ recipe.title }}{% endblock %}
{% block main %}
<form action="" method="post">
{%csrf_token %}
{{ form.as_div }}
<input type="submit" value="Submit">
</form>
{% endblock %}

View file

@ -0,0 +1,59 @@
{% extends "base_main.html" %}
{% comment %}
Barn Web App - A collection of web-apps for my family's personal use,
including a recipe database.
Copyright © 2023 Benjamin Stadlbauer
This program is free software: you can redistribute it and/or modify it
under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or (at
your option) any later version.
This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
General Public License for more details.
This program comes with a copy of the GNU Affero General Public License
file at the root of this project.
{% endcomment %}
{% block title %}{{ title }}{% endblock %}
{% block main %}
<form action="" method="post" id="version-form">
{%csrf_token %}
{% if recipe_form %}
{{ recipe_form.as_div }}
{% endif %}
{{ version_form.as_div }}
{{ ingredients_formset.management_form }}
{% for ingredient_form in ingredients_formset %}
{{ ingredient_form.as_div }}
{% endfor %}
<button id="add-ingredient" type="button">Add Ingredient</button>
<input type="submit" value="Submit">
</form>
<script>
let firstIngredientDiv = document.querySelector("input[id^=id_ingredient][id$=text]").parentElement;
let addIngredientButton = document.querySelector("#add-ingredient");
let form = document.querySelector("#version-form");
let totalIngredientFormsInput = document.querySelector("#id_ingredient-TOTAL_FORMS");
addIngredientButton.addEventListener('click', addIngredient);
function addIngredient(e) {
let nextIngredientIndex = document.querySelectorAll("input[id^=id_ingredient][id$=text]").length
e.preventDefault();
let newIngredientDiv = firstIngredientDiv.cloneNode(true);
let formRegex = new RegExp('ingredient-(\\d){1}-', 'g');
newIngredientDiv.innerHTML = newIngredientDiv.innerHTML.replace(formRegex, `ingredient-${nextIngredientIndex}-`);
form.insertBefore(newIngredientDiv, addIngredientButton);
totalIngredientFormsInput.setAttribute('value', `${nextIngredientIndex + 1}`);
}
</script>
{% endblock %}

View file

@ -0,0 +1,30 @@
{% extends "base_main.html" %}
{% comment %}
Barn Web App - A collection of web-apps for my family's personal use,
including a recipe database.
Copyright © 2023 Benjamin Stadlbauer
This program is free software: you can redistribute it and/or modify it
under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or (at
your option) any later version.
This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
General Public License for more details.
This program comes with a copy of the GNU Affero General Public License
file at the root of this project.
{% endcomment %}
{% block title %}{{ recipe.title }}{% endblock %}
{% block main %}
<h1>{{ recipe.title }}</h1>
<p><a href="{% url 'edit-recipe' recipe.slug %}">Edit recipe name</a></p>
<p><a href="{% url 'add-version' recipe.slug %}">Add version</a></p>
<ul>
{% for v in versions %}
<li><a href="{{ v.get_absolute_url }}">{{ v.label }}</a></li>
{% endfor %}
</ul>
{% endblock %}

View file

@ -0,0 +1,29 @@
{% extends "base_main.html" %}
{% comment %}
Barn Web App - A collection of web-apps for my family's personal use,
including a recipe database.
Copyright © 2023 Benjamin Stadlbauer
This program is free software: you can redistribute it and/or modify it
under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or (at
your option) any later version.
This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
General Public License for more details.
This program comes with a copy of the GNU Affero General Public License
file at the root of this project.
{% endcomment %}
{% block title %}Recipes{% endblock %}
{% block main %}
<h1>Recipes</h1>
<p><a href="{% url 'add-recipe' %}">Add recipe</a></p>
<ul>
{% for recipe in recipes %}
<li><a href="{{ recipe.get_absolute_url }}">{{ recipe.title }}</a></li>
{% endfor %}
</ul>
{% endblock %}

View file

@ -0,0 +1,35 @@
{% extends "base_main.html" %}
{% comment %}
Barn Web App - A collection of web-apps for my family's personal use,
including a recipe database.
Copyright © 2023 Benjamin Stadlbauer
This program is free software: you can redistribute it and/or modify it
under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or (at
your option) any later version.
This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
General Public License for more details.
This program comes with a copy of the GNU Affero General Public License
file at the root of this project.
{% endcomment %}
{% block title %}{{ version.recipe.title }}{% if has_multiple_versions %} ({{ version.label }}){% endif %}{% endblock %}
{% block main %}
<h1>{{ version.recipe.title }}{% if has_multiple_versions %} ({{ version.label }}){% endif %}</h1>
{% if has_multiple_versions %}
<p><a href="{{ recipe.get_absolute_url }}">Show all versions</a></p>
{% endif %}
<p><a href="{% url 'edit-recipe' recipe.slug %}">Edit recipe name</a></p>
<p><a href="{% url 'add-version' recipe.slug %}">Add version</a></p>
<p><a href="{% url 'edit-version' recipe.slug version.slug %}">Edit Version</a></p>
<ul>
{% for i in ingredients %}
<li>{{ i.text }}</li>
{% endfor %}
</ul>
<p>{{ version.body }}</p>
{% endblock %}

22
recipes/tests.py Normal file
View file

@ -0,0 +1,22 @@
"""
Barn Web App - A collection of web-apps for my family's personal use,
including a recipe database.
Copyright © 2023 Benjamin Stadlbauer
This program is free software: you can redistribute it and/or modify it
under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or (at
your option) any later version.
This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
General Public License for more details.
This program comes with a copy of the GNU Affero General Public License
file at the root of this project.
"""
from django.test import TestCase
# Create your tests here.

31
recipes/urls.py Normal file
View file

@ -0,0 +1,31 @@
"""
Barn Web App - A collection of web-apps for my family's personal use,
including a recipe database.
Copyright © 2023 Benjamin Stadlbauer
This program is free software: you can redistribute it and/or modify it
under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or (at
your option) any later version.
This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
General Public License for more details.
This program comes with a copy of the GNU Affero General Public License
file at the root of this project.
"""
from django.urls import path
from . import views
urlpatterns = [
path('', views.recipes, name='recipes'),
path('add-recipe/', views.add_recipe, name='add-recipe'),
path('<slug:slug>/add-version/', views.add_version, name='add-version'),
path('<slug:slug>/edit-recipe/', views.edit_recipe, name='edit-recipe'),
path('<slug:slug_recipe>/<slug:slug_version>/edit-version/', views.edit_version, name='edit-version'),
path('<slug:slug_recipe>/<slug:slug_version>/', views.version, name='version'),
path('<slug:slug>/', views.recipe, name='recipe'),
]

167
recipes/views.py Normal file
View file

@ -0,0 +1,167 @@
"""
Barn Web App - A collection of web-apps for my family's personal use,
including a recipe database.
Copyright © 2023 Benjamin Stadlbauer
This program is free software: you can redistribute it and/or modify it
under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or (at
your option) any later version.
This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
General Public License for more details.
This program comes with a copy of the GNU Affero General Public License
file at the root of this project.
"""
from django.shortcuts import render, get_object_or_404, redirect
from .models import Recipe, Version, Ingredient
from .forms import RecipeForm, VersionForm, IngredientFormSet
from django.contrib.auth.decorators import login_required
from django.urls import reverse
from django.http.response import HttpResponseRedirect
from django.conf import settings
RECIPE_FORM_PREFIX = 'recipe'
VERSION_FORM_PREFIX = 'version'
INGREDIENTS_FORMSET_PREFIX = 'ingredient'
def get_name_of_user(user):
return user.first_name if user.first_name else user.username
@login_required
def recipes(request):
recipes = Recipe.objects.all()
return render(request, 'recipes.html', context={'recipes': recipes})
def version(request, slug_recipe, slug_version):
recipe = get_object_or_404(Recipe, slug=slug_recipe)
has_multiple_versions = recipe.versions.all().count() > 1 # type: ignore
version = get_object_or_404(Version, recipe=recipe, slug=slug_version)
ingredients = version.ingredients.all() # type: ignore
return render(request, 'version.html', context={'recipe': recipe, 'has_multiple_versions': has_multiple_versions, 'version': version, 'ingredients': ingredients})
@login_required
def recipe(request, slug):
recipe = get_object_or_404(Recipe, slug=slug)
versions = recipe.versions.all() # type: ignore
if (1 == versions.count()):
return HttpResponseRedirect(reverse('version', kwargs={'slug_recipe': slug, 'slug_version': versions.get().slug}))
else:
return render(request, 'recipe.html', context={'recipe': recipe, 'versions': versions})
def create_version(request, recipe: Recipe, version_form: VersionForm) -> Version:
version = version_form.save(commit=False)
version.user = request.user
version.recipe = recipe
version.save()
return version
def save_ingredients(version: Version, ingredients_formset: IngredientFormSet): # type: ignore
for ingredient in ingredients_formset.save(commit=False):
ingredient.version = version
ingredient.save()
@login_required
def add_recipe(request):
if request.method == 'POST':
recipe_form = RecipeForm(request.POST, prefix=RECIPE_FORM_PREFIX)
version_form = VersionForm(request.POST, prefix=VERSION_FORM_PREFIX, author_placeholder=get_name_of_user(request.user))
ingredients_formset = IngredientFormSet(request.POST, queryset=Ingredient.objects.none(), prefix=INGREDIENTS_FORMSET_PREFIX)
if recipe_form.is_valid() and version_form.is_valid() and ingredients_formset.is_valid():
recipe = recipe_form.save(commit=True)
version = create_version(request, recipe, version_form)
save_ingredients(version, ingredients_formset)
return HttpResponseRedirect(reverse('recipe', kwargs={'slug': recipe.slug}))
else:
recipe_form = RecipeForm(prefix=RECIPE_FORM_PREFIX)
version_form = VersionForm(prefix=VERSION_FORM_PREFIX, author_placeholder=get_name_of_user(request.user))
ingredients_formset = IngredientFormSet(queryset=Ingredient.objects.none(), prefix=INGREDIENTS_FORMSET_PREFIX)
return render(request, 'recipe-form.html', {'recipe_form': recipe_form, 'version_form': version_form, 'ingredients_formset': ingredients_formset, 'title': 'Add recipe'})
@login_required
def edit_recipe(request, slug):
recipe = get_object_or_404(Recipe, slug=slug)
# It is assumed every recipe has at least one version
if not request.user.is_superuser:
users = set()
for version in recipe.versions.all(): # type: ignore
users.add(version.user)
if len(users) > 1 or (len(users) == 1 and next(iter(users)) != request.user):
return redirect(f"/accounts/login/?next={request.path}")
if request.method == 'POST':
form = RecipeForm(request.POST, instance=recipe, prefix=RECIPE_FORM_PREFIX)
if form.is_valid():
if form.has_changed():
form.save()
return HttpResponseRedirect(reverse('recipe', kwargs={'slug': recipe.slug}))
else:
form = RecipeForm(instance=recipe, prefix=RECIPE_FORM_PREFIX)
return render(request, 'edit-recipe.html', {'form': form})
@login_required
def add_version(request, slug):
recipe = get_object_or_404(Recipe, slug=slug)
if (recipe.versions.all().count() > 0): # type: ignore
version_initial = {'label': '', 'slug': ''}
else:
version_initial = {}
if request.method == 'POST':
version_form = VersionForm(request.POST, prefix=VERSION_FORM_PREFIX, initial=version_initial, author_placeholder=get_name_of_user(request.user))
ingredients_formset = IngredientFormSet(request.POST, queryset=Ingredient.objects.none(), prefix=INGREDIENTS_FORMSET_PREFIX)
version_form.recipe_id = recipe.id # type: ignore
if version_form.is_valid() and ingredients_formset.is_valid():
version = create_version(request, recipe, version_form)
save_ingredients(version, ingredients_formset)
return HttpResponseRedirect(reverse('version', kwargs={'slug_recipe': version.recipe.slug, 'slug_version': version.slug}))
else:
version_form = VersionForm(prefix=VERSION_FORM_PREFIX, initial=version_initial, author_placeholder=get_name_of_user(request.user))
ingredients_formset = IngredientFormSet(queryset=Ingredient.objects.none(), prefix=INGREDIENTS_FORMSET_PREFIX)
return render(request, 'recipe-form.html', {'version_form': version_form, 'ingredients_formset': ingredients_formset, 'title': f'Add version to {recipe.title}'})
@login_required
def edit_version(request, slug_recipe, slug_version):
recipe = get_object_or_404(Recipe, slug=slug_recipe)
version = get_object_or_404(Version, recipe=recipe, slug=slug_version)
if version.user != request.user and not request.user.is_superuser:
return redirect(f"/accounts/login/?next={request.path}")
if request.method == 'POST':
version_form = VersionForm(request.POST, prefix=VERSION_FORM_PREFIX, instance=version, author_placeholder=get_name_of_user(request.user))
ingredients_formset = IngredientFormSet(request.POST, queryset=version.ingredients.all(), prefix=INGREDIENTS_FORMSET_PREFIX) # type: ignore
version_form.recipe_id = recipe.id # type: ignore
if version_form.is_valid() and ingredients_formset.is_valid():
if version_form.has_changed():
version = version_form.save()
if ingredients_formset.has_changed():
for ingredient in ingredients_formset.save(commit=False):
ingredient.version = version
ingredient.save()
return HttpResponseRedirect(reverse('version', kwargs={'slug_recipe': version.recipe.slug, 'slug_version': version.slug}))
else:
version_form = VersionForm(instance=version, prefix=VERSION_FORM_PREFIX, author_placeholder=get_name_of_user(request.user))
ingredients_formset = IngredientFormSet(queryset=version.ingredients.all(), prefix=INGREDIENTS_FORMSET_PREFIX) # type: ignore
return render(request, 'recipe-form.html', {'version_form': version_form, 'ingredients_formset': ingredients_formset, 'title': f'Edit {recipe.title} ({version.label})'})

View file

@ -50,6 +50,7 @@ ALLOWED_HOSTS = []
# Application definition
INSTALLED_APPS = [
'recipes.apps.RecipesConfig',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',

View file

@ -38,6 +38,7 @@ from . import views
urlpatterns = [
path('', views.index, name='index'),
path('recipes/', include('recipes.urls')),
path('admin/', admin.site.urls),
path('accounts/new-user/', views.new_user, name='new-user'),
path('accounts/profile/', views.profile, name='profile'),

View file

@ -36,6 +36,7 @@
{% endif %}
</ul>
<ul>
<li><a href="{% url 'recipes' %}">Recipes</a></li>
<li><a href="https://benjamin.stadlbauer.wien/git/Barn/django-project">Source code on Forgejo</a></li>
</ul>
</nav>

View file

@ -20,4 +20,7 @@
{% block title %}Barn{% endblock %}
{% block main %}
<h1>Barn</h1>
<ul>
<li><h2><a href="{% url 'recipes' %}">Recipes</a></h2></li>
</ul>
{% endblock %}

View file

@ -17,6 +17,7 @@
This program comes with a copy of the GNU Affero General Public License
file at the root of this project.
{% endcomment %}
{% block title %}Login{% endblock %}
{% block main %}

View file

@ -17,6 +17,7 @@
This program comes with a copy of the GNU Affero General Public License
file at the root of this project.
{% endcomment %}
{% block title %}New User{% endblock %}
{% block main %}
{% if form.errors %}