From 77081282558846a5fcc64929d3f94e0d5e5db90d Mon Sep 17 00:00:00 2001 From: Klaus-Uwe Mitterer Date: Wed, 15 Apr 2020 22:19:03 +0200 Subject: [PATCH] Finally got all the OTP stuff working Finalized dbsettings views Easter egg for missing backend pages --- core/classes/otp.py | 3 + core/forms/__init__.py | 1 + core/forms/auth.py | 16 +++ core/helpers/otp.py | 25 ++++ core/models/__init__.py | 3 +- core/models/otp.py | 13 ++ core/modules/otp.py | 6 +- core/modules/urls.py | 22 +++- core/navigations.py | 50 ++++---- core/urls.py | 11 +- core/{views.py => views/__init__.py} | 16 +-- core/views/auth.py | 113 ++++++++++++++++++ core/views/dbsettings.py | 30 +++++ expephalon/settings.py | 1 + requirements.txt | 1 + smsotp/otp.py | 2 +- static/backend/fontawesome | 1 - templates/backend/auth_base.html | 46 +++++++ templates/backend/base.html | 6 +- templates/backend/dbsettings/create.html | 57 +++++++++ templates/backend/dbsettings/delete.html | 57 +++++++++ .../index.html} | 8 +- templates/backend/dbsettings/update.html | 57 +++++++++ templates/backend/login.html | 58 +++++---- templates/backend/notimplemented.html | 40 +++++++ templates/backend/otp_selector.html | 27 +++++ templates/backend/otp_verifier.html | 27 +++++ 27 files changed, 610 insertions(+), 87 deletions(-) create mode 100644 core/forms/__init__.py create mode 100644 core/forms/auth.py create mode 100644 core/helpers/otp.py create mode 100644 core/models/otp.py rename core/{views.py => views/__init__.py} (51%) create mode 100644 core/views/auth.py create mode 100644 core/views/dbsettings.py delete mode 160000 static/backend/fontawesome create mode 100644 templates/backend/auth_base.html create mode 100644 templates/backend/dbsettings/create.html create mode 100644 templates/backend/dbsettings/delete.html rename templates/backend/{dbsettings.html => dbsettings/index.html} (78%) create mode 100644 templates/backend/dbsettings/update.html create mode 100644 templates/backend/notimplemented.html create mode 100644 templates/backend/otp_selector.html create mode 100644 templates/backend/otp_verifier.html diff --git a/core/classes/otp.py b/core/classes/otp.py index 6e04028..bd7140a 100644 --- a/core/classes/otp.py +++ b/core/classes/otp.py @@ -9,6 +9,9 @@ class BaseOTPProvider: def get_logo(self): return "" + def __str__(self): + return self.get_name + @property def is_active(self): '''Returns True if the provider is properly configured and ready to use.''' diff --git a/core/forms/__init__.py b/core/forms/__init__.py new file mode 100644 index 0000000..5be3ddc --- /dev/null +++ b/core/forms/__init__.py @@ -0,0 +1 @@ +from core.forms.auth import * \ No newline at end of file diff --git a/core/forms/auth.py b/core/forms/auth.py new file mode 100644 index 0000000..2825dbe --- /dev/null +++ b/core/forms/auth.py @@ -0,0 +1,16 @@ +from django.forms import Form, EmailField, CharField, PasswordInput, ChoiceField + +from core.helpers.otp import get_otp_choices + +class LoginForm(Form): + email = EmailField() + password = CharField(widget=PasswordInput) + +class OTPSelectorForm(Form): + def __init__(self, *args, **kwargs): + otp_choices = kwargs.pop('otp_choices', []) + super(OTPSelectorForm, self).__init__(*args, **kwargs) + self.fields['provider'] = ChoiceField(choices=otp_choices) + +class OTPVerificationForm(Form): + token = CharField() \ No newline at end of file diff --git a/core/helpers/otp.py b/core/helpers/otp.py new file mode 100644 index 0000000..d1be561 --- /dev/null +++ b/core/helpers/otp.py @@ -0,0 +1,25 @@ +from core.models import OTPUser +from core.modules.otp import providers + +def get_user_otps(user): + try: + all_otps = OTPUser.objects.filter(user=user) + except: + return {} + + user_providers = [otp.provider for otp in all_otps] + active_providers = {} + + for name, provider in providers.items(): + if name in user_providers: + active_providers[name] = provider + + return active_providers + +def get_otp_by_name(name): + for pname, provider in providers.items(): + if pname == name: + return provider + +def get_otp_choices(user): + return [(name, provider) for name, provider in get_user_otps(user).items()] \ No newline at end of file diff --git a/core/models/__init__.py b/core/models/__init__.py index 9aa500d..a4026f3 100644 --- a/core/models/__init__.py +++ b/core/models/__init__.py @@ -1,2 +1,3 @@ from core.models.files import * -from core.models.profiles import * \ No newline at end of file +from core.models.profiles import * +from core.models.otp import * \ No newline at end of file diff --git a/core/models/otp.py b/core/models/otp.py new file mode 100644 index 0000000..fa26efd --- /dev/null +++ b/core/models/otp.py @@ -0,0 +1,13 @@ +from django.db.models import Model, ForeignKey, CharField, DateTimeField, UUIDField, CASCADE +from django.contrib.auth import get_user_model + +from uuid import uuid4 + +class OTPUser(Model): + user = ForeignKey(get_user_model(), CASCADE) + provider = CharField(max_length=255) + +class LoginSession(Model): + uuid = UUIDField(default=uuid4, primary_key=True) + user = ForeignKey(get_user_model(), CASCADE) + creation = DateTimeField(auto_now_add=True) \ No newline at end of file diff --git a/core/modules/otp.py b/core/modules/otp.py index e482cfc..e5f4ca0 100644 --- a/core/modules/otp.py +++ b/core/modules/otp.py @@ -2,12 +2,12 @@ import importlib from django.conf import settings -providers = [] +providers = {} for module in settings.EXPEPHALON_MODULES: try: moo = importlib.import_module(f"{module}.otp") - for provider in moo.OTPPROVIDERS: - providers.append(provider) + for name, provider in moo.OTPPROVIDERS.items(): + providers[name] = provider except (AttributeError, ModuleNotFoundError): continue \ No newline at end of file diff --git a/core/modules/urls.py b/core/modules/urls.py index 78dc60f..00a6a84 100644 --- a/core/modules/urls.py +++ b/core/modules/urls.py @@ -3,7 +3,16 @@ import importlib from django.conf import settings from django.urls import path -URLPATTERNS = [] +from core.views import DashboardView, LoginView, OTPSelectorView, LogoutView, OTPValidatorView, BackendNotImplementedView + +URLPATTERNS = [ + path('login/', LoginView.as_view(), name="login"), + path('login/otp/select/', OTPSelectorView.as_view(), name="otpselector"), + path('login/otp/validate/', OTPValidatorView.as_view(), name="otpvalidator"), + path('logout/', LogoutView.as_view(), name="logout"), + path('admin/', DashboardView.as_view(), name="dashboard"), + path('admin/oops/', BackendNotImplementedView.as_view(), name="backendni") +] for module in settings.EXPEPHALON_MODULES: try: @@ -13,6 +22,11 @@ for module in settings.EXPEPHALON_MODULES: except (AttributeError, ModuleNotFoundError): pass -if "dbsettings" in settings.INSTALLED_APPS: - from core.views import DBSettingsListView - URLPATTERNS.append(path("admin/dbsettings/", DBSettingsListView.as_view(), name="dbsettings")) \ No newline at end of file +try: + from core.views import DBSettingsListView, DBSettingsEditView, DBSettingsDeleteView, DBSettingsCreateView + URLPATTERNS.append(path("admin/dbsettings/", DBSettingsListView.as_view(), name="dbsettings")) + URLPATTERNS.append(path("admin/dbsettings//delete/", DBSettingsDeleteView.as_view(), name="dbsettings_delete")) + URLPATTERNS.append(path("admin/dbsettings//edit/", DBSettingsEditView.as_view(), name="dbsettings_edit")) + URLPATTERNS.append(path("admin/dbsettings/create/", DBSettingsCreateView.as_view(), name="dbsettings_create")) +except: + pass \ No newline at end of file diff --git a/core/navigations.py b/core/navigations.py index d464e80..19d8a99 100644 --- a/core/navigations.py +++ b/core/navigations.py @@ -6,7 +6,7 @@ from django.conf import settings dashboard_section = NavSection("Dashboard", "") -dashboard_item = NavItem("Dashboard", "fa-rocket", "backend") +dashboard_item = NavItem("Dashboard", "fa-rocket", "dashboard") dashboard_section.add_item(dashboard_item) @@ -16,10 +16,10 @@ navigations["backend_main"].add_section(dashboard_section) clients_section = NavSection("Clients", "") -client_list_item = NavItem("List Clients", "fa-user-tag", "backend") -client_add_item = NavItem("Add Client", "fa-user-edit", "backend") -client_groups_item = NavItem("Client Groups", "fa-users", "backend") -client_leads_item = NavItem("Leads", "fa-blender-phone", "backend") +client_list_item = NavItem("List Clients", "fa-user-tag", "backendni") +client_add_item = NavItem("Add Client", "fa-user-edit", "backendni") +client_groups_item = NavItem("Client Groups", "fa-users", "backendni") +client_leads_item = NavItem("Leads", "fa-blender-phone", "backendni") clients_section.add_item(client_list_item) clients_section.add_item(client_add_item) @@ -32,8 +32,8 @@ navigations["backend_main"].add_section(clients_section) quotes_section = NavSection("Quotes", "") -quote_list_item = NavItem("List Quotes", "fa-file-invoice-dollar", "backend") -quote_create_item = NavItem("Create Quote", "fa-plus-square", "backend") +quote_list_item = NavItem("List Quotes", "fa-file-invoice-dollar", "backendni") +quote_create_item = NavItem("Create Quote", "fa-plus-square", "backendni") quotes_section.add_item(quote_list_item) quotes_section.add_item(quote_create_item) @@ -44,11 +44,11 @@ navigations["backend_main"].add_section(quotes_section) billing_section = NavSection("Billing", "") -invoice_list_item = NavItem("List Invoices", "fa-file-invoice-dollar", "backend") -invoice_create_item = NavItem("Create Invoice", "fa-plus-square", "backend") -billable_list_item = NavItem("List Billable Items", "fa-hand-holding-usd", "backend") -billable_create_item = NavItem("Create Billable Item", "fa-plus-square", "backend") -list_transaction_item = NavItem("Transaction List", "fa-funnel-dollar", "backend") +invoice_list_item = NavItem("List Invoices", "fa-file-invoice-dollar", "backendni") +invoice_create_item = NavItem("Create Invoice", "fa-plus-square", "backendni") +billable_list_item = NavItem("List Billable Items", "fa-hand-holding-usd", "backendni") +billable_create_item = NavItem("Create Billable Item", "fa-plus-square", "backendni") +list_transaction_item = NavItem("Transaction List", "fa-funnel-dollar", "backendni") billing_section.add_item(invoice_list_item) billing_section.add_item(invoice_create_item) @@ -62,9 +62,9 @@ navigations["backend_main"].add_section(billing_section) support_section = NavSection("Support", "") -ticket_view_item = NavItem("View Tickets", "fa-life-ring", "backend") -ticket_add_item = NavItem("Add Ticket", "fa-plus-square", "backend") -conversation_add_item = NavItem("Add Conversation", "fa-comments", "backend") +ticket_view_item = NavItem("View Tickets", "fa-life-ring", "backendni") +ticket_add_item = NavItem("Add Ticket", "fa-plus-square", "backendni") +conversation_add_item = NavItem("Add Conversation", "fa-comments", "backendni") support_section.add_item(ticket_view_item) support_section.add_item(ticket_add_item) @@ -76,8 +76,8 @@ navigations["backend_main"].add_section(support_section) reports_section = NavSection("Reports", "") -report_period_item = NavItem("Income by period", "fa-chart-bar", "backend") -report_forecast_item = NavItem("Income Forecast", "fa-chart-area", "backend") +report_period_item = NavItem("Income by period", "fa-chart-bar", "backendni") +report_forecast_item = NavItem("Income Forecast", "fa-chart-area", "backendni") reports_section.add_item(report_period_item) reports_section.add_item(report_forecast_item) @@ -88,14 +88,14 @@ navigations["backend_main"].add_section(reports_section) administration_section = NavSection("Administration", "") -user_administration_item = NavItem("Administrator Users", "fa-users-cog", "backend") -brand_administration_item = NavItem("Brands", "fa-code-branch", "backend") -sms_administration_item = NavItem("SMS Gateway", "fa-sms", "backend") -otp_administration_item = NavItem("Two-Factor Authentication", "fa-id-badge", "backend") -backup_administration_item = NavItem("Backups", "fa-shield-alt", "backend") -product_administration_item = NavItem("Products", "fa-cube", "backend") -pgroup_administration_item = NavItem("Product Groups", "fa-cubes", "backend") -payment_administration_item = NavItem("Payment Gateways", "fa-credit-card", "backend") +user_administration_item = NavItem("Administrator Users", "fa-users-cog", "backendni") +brand_administration_item = NavItem("Brands", "fa-code-branch", "backendni") +sms_administration_item = NavItem("SMS Gateway", "fa-sms", "backendni") +otp_administration_item = NavItem("Two-Factor Authentication", "fa-id-badge", "backendni") +backup_administration_item = NavItem("Backups", "fa-shield-alt", "backendni") +product_administration_item = NavItem("Products", "fa-cube", "backendni") +pgroup_administration_item = NavItem("Product Groups", "fa-cubes", "backendni") +payment_administration_item = NavItem("Payment Gateways", "fa-credit-card", "backendni") dbsettings_item = NavItem("Database Settings", "fa-database", "dbsettings") administration_section.add_item(user_administration_item) diff --git a/core/urls.py b/core/urls.py index b7c5ec5..1519a0d 100644 --- a/core/urls.py +++ b/core/urls.py @@ -1,10 +1 @@ -from django.urls import path, include - -from core.views import DashboardView -from core.modules.urls import URLPATTERNS - -import importlib - -urlpatterns = [ - path('admin/', DashboardView.as_view(), name="backend"), -] + URLPATTERNS +from core.modules.urls import URLPATTERNS as urlpatterns diff --git a/core/views.py b/core/views/__init__.py similarity index 51% rename from core/views.py rename to core/views/__init__.py index 1eedd75..01d15a1 100644 --- a/core/views.py +++ b/core/views/__init__.py @@ -1,7 +1,10 @@ from django.shortcuts import render -from django.views.generic import TemplateView, ListView +from django.views.generic import TemplateView from django.conf import settings +from core.views.dbsettings import * +from core.views.auth import * + # Create your views here. class IndexView(TemplateView): @@ -10,12 +13,5 @@ class IndexView(TemplateView): class DashboardView(TemplateView): template_name = f"{settings.EXPEPHALON_BACKEND}/index.html" -try: - from dbsettings.models import Setting - - class DBSettingsListView(ListView): - template_name = f"{settings.EXPEPHALON_BACKEND}/dbsettings.html" - model = Setting - -except ModuleNotFoundError: - pass +class BackendNotImplementedView(TemplateView): + template_name = f"{settings.EXPEPHALON_BACKEND}/notimplemented.html" \ No newline at end of file diff --git a/core/views/auth.py b/core/views/auth.py new file mode 100644 index 0000000..f97d59e --- /dev/null +++ b/core/views/auth.py @@ -0,0 +1,113 @@ +from django.conf import settings +from django.views.generic import FormView, View +from django.contrib.auth import authenticate, login, logout +from django.shortcuts import redirect +from django.core.exceptions import PermissionDenied +from django.contrib import messages + +from core.forms import LoginForm, OTPSelectorForm, OTPVerificationForm +from core.models.otp import LoginSession +from core.helpers.otp import get_user_otps, get_otp_choices, get_otp_by_name + +class LoginView(FormView): + template_name = f"{settings.EXPEPHALON_BACKEND}/login.html" + form_class = LoginForm + + def get(self, request, *args, **kwargs): + if request.user.is_authenticated: + return redirect(request.GET.get("next", "dashboard")) + return super().get(request, *args, **kwargs) + + def form_valid(self, form): + user = authenticate(username=form.cleaned_data['email'],password=form.cleaned_data['password']) + if user: + if not get_user_otps(user): + login(self.request, user) + return redirect("dashboard") + session = LoginSession.objects.create(user=user) + self.request.session["otpsession"] = str(session.uuid) + self.request.session["next"] = self.request.GET.get("next", "dashboard") + return redirect("otpselector") + return super().form_invalid(form) + +class OTPSelectorView(FormView): + template_name = f"{settings.EXPEPHALON_BACKEND}/otp_selector.html" + form_class = OTPSelectorForm + + def clean_session(self): + for key in ("otpsession", "otpprovider", "next"): + try: + del self.request.session[key] + except: + pass + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + try: + assert self.request.session["otpsession"] + except: + raise PermissionDenied() + user = LoginSession.objects.get(uuid=self.request.session["otpsession"]).user + kwargs["otp_choices"] = get_otp_choices(user) + return kwargs + + def form_valid(self, form): + self.request.session["otpprovider"] = form.cleaned_data["provider"] + return redirect("otpvalidator") + + def form_invalid(self, form): + self.clean_session() + return redirect("login") + +class OTPValidatorView(FormView): + template_name = f"{settings.EXPEPHALON_BACKEND}/otp_verifier.html" + form_class = OTPVerificationForm + + def clean_session(self): + for key in ("otpsession", "otpprovider", "next"): + try: + del self.request.session[key] + except: + pass + + def validate_session(self, request): + try: + assert request.session["otpsession"] + assert request.session["otpprovider"] + user = LoginSession.objects.get(uuid=request.session["otpsession"]).user + assert request.session["otpprovider"] in get_user_otps(user).keys() + provider = get_otp_by_name(request.session["otpprovider"])() + return user, provider + except: + self.clean_session() + raise PermissionDenied() + + def get(self, request, *args, **kwargs): + user, provider = self.validate_session(request) + response = provider.start_authentication(user) + messages.info(request, response) + return super().get(request, *args, **kwargs) + + def post(self, request, *args, **kwargs): + self.validate_session(request) + return super().post(request, *args, **kwargs) + + def form_invalid(self, form): + self.clean_session() + return redirect("login") + + def form_valid(self, form): + user, provider = self.validate_session(self.request) + if provider.validate_token(user, form.cleaned_data["token"]): + login(self.request, user) + ret = redirect(self.request.session.get("next", "dashboard")) + self.clean_session() + return ret + self.clean_session() + messages.error(self.request, "Incorrect token entered. Please try again. If the issue persists, contact support to regain access to your account.") + return redirect("login") + +class LogoutView(View): + def get(self, request, *args, **kwargs): + logout(request) + return redirect("login") \ No newline at end of file diff --git a/core/views/dbsettings.py b/core/views/dbsettings.py new file mode 100644 index 0000000..ad0ca0e --- /dev/null +++ b/core/views/dbsettings.py @@ -0,0 +1,30 @@ +from django.conf import settings +from django.views.generic import ListView, UpdateView, DeleteView, CreateView +from django.urls import reverse_lazy + +try: + from dbsettings.models import Setting + + class DBSettingsListView(ListView): + template_name = f"{settings.EXPEPHALON_BACKEND}/dbsettings.html" + model = Setting + + class DBSettingsEditView(UpdateView): + template_name = f"{settings.EXPEPHALON_BACKEND}/dbsettings_update.html" + model = Setting + success_url = reverse_lazy("dbsettings") + fields = ["key", "value"] + + class DBSettingsDeleteView(DeleteView): + template_name = f"{settings.EXPEPHALON_BACKEND}/dbsettings_delete.html" + model = Setting + success_url = reverse_lazy("dbsettings") + + class DBSettingsCreateView(CreateView): + template_name = f"{settings.EXPEPHALON_BACKEND}/dbsettings_create.html" + model = Setting + success_url = reverse_lazy("dbsettings") + fields = ["key", "value"] + +except ModuleNotFoundError: + pass diff --git a/expephalon/settings.py b/expephalon/settings.py index 2d1fa9b..288297d 100644 --- a/expephalon/settings.py +++ b/expephalon/settings.py @@ -16,6 +16,7 @@ INSTALLED_APPS = [ 'django.contrib.staticfiles', 'polymorphic', 'phonenumber_field', + 'bootstrap4', 'core', 'dbsettings', ] + EXPEPHALON_MODULES diff --git a/requirements.txt b/requirements.txt index 445bc44..60576dc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ boto3 Pillow django-polymorphic django-phonenumber-field[phonenumbers] +django-bootstrap4 diff --git a/smsotp/otp.py b/smsotp/otp.py index 6bfdf0e..0be0192 100644 --- a/smsotp/otp.py +++ b/smsotp/otp.py @@ -37,4 +37,4 @@ class SMSOTP(BaseOTPProvider): except OTPToken.DoesNotExist: return False -OTPPROVIDERS = [SMSOTP] \ No newline at end of file +OTPPROVIDERS = {"smsotp": SMSOTP} \ No newline at end of file diff --git a/static/backend/fontawesome b/static/backend/fontawesome deleted file mode 160000 index 4e64024..0000000 --- a/static/backend/fontawesome +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 4e6402443679e0a9d12c7401ac8783ef4646657f diff --git a/templates/backend/auth_base.html b/templates/backend/auth_base.html new file mode 100644 index 0000000..84f42d3 --- /dev/null +++ b/templates/backend/auth_base.html @@ -0,0 +1,46 @@ +{% load static %} + + + + + + + + + Login - ArchitectUI HTML Bootstrap 4 Dashboard Template + + + + + + + + + +
+
+
+
+
+
+
+
+
+
+

Perfect Balance

+

ArchitectUI is like a dream. Some think it's too good to be true! Extensive collection of unified React Boostrap Components and Elements.

+
+
+
+
+
+
+ {% block content %}{% endblock %} +
+
+
+
+
+ + diff --git a/templates/backend/base.html b/templates/backend/base.html index ec22eeb..da30209 100644 --- a/templates/backend/base.html +++ b/templates/backend/base.html @@ -102,16 +102,16 @@ - + Logout @@ -45,8 +45,8 @@ {% for setting in object_list %} {{ setting.key }} - Click to display... - + Click to display... + {% endfor %} diff --git a/templates/backend/dbsettings/update.html b/templates/backend/dbsettings/update.html new file mode 100644 index 0000000..5030baf --- /dev/null +++ b/templates/backend/dbsettings/update.html @@ -0,0 +1,57 @@ +{% extends "backend/base.html" %} +{% load bootstrap4 %} +{% block content %} +
+
+
+
+ + +
+
Database Settings - Edit Setting +
Edit key and value of a setting +
+
+
+
+ + +
+
+
+
+
+
+
+
+ + Editing {{ form.key.value }} +
+
+
+
+
+
+ {% csrf_token %} + {% bootstrap_form form %} + {% buttons %} + + + Cancel + + {% endbuttons %} +
+
+
+
+
+
+
+ + + +{% endblock %} \ No newline at end of file diff --git a/templates/backend/login.html b/templates/backend/login.html index 0a1eff0..995e013 100644 --- a/templates/backend/login.html +++ b/templates/backend/login.html @@ -1,26 +1,34 @@ - - - - - - - - Sign in - - - -
-

Sign in

- {% csrf_token %} -
- - - Sign in -

Forgot Password?

- - +{% extends "backend/auth_base.html" %} +{% load bootstrap4 %} +{% block content %} +
+{% endblock %} \ No newline at end of file diff --git a/templates/backend/notimplemented.html b/templates/backend/notimplemented.html new file mode 100644 index 0000000..8f2d0f1 --- /dev/null +++ b/templates/backend/notimplemented.html @@ -0,0 +1,40 @@ +{% extends "backend/base.html" %} +{% block content %} +
+
+
+
+ + +
+
Oops! +
This is not implemented yet... +
+
+
+
+
+
+
+
+
+
+
+
+ + But here's something for you: +
+
+
+
+
+
+ +
+
+
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/backend/otp_selector.html b/templates/backend/otp_selector.html new file mode 100644 index 0000000..88b20cd --- /dev/null +++ b/templates/backend/otp_selector.html @@ -0,0 +1,27 @@ +{% extends "backend/auth_base.html" %} +{% load bootstrap4 %} +{% block content %} + +{% endblock %} \ No newline at end of file diff --git a/templates/backend/otp_verifier.html b/templates/backend/otp_verifier.html new file mode 100644 index 0000000..4191afc --- /dev/null +++ b/templates/backend/otp_verifier.html @@ -0,0 +1,27 @@ +{% extends "backend/auth_base.html" %} +{% load bootstrap4 %} +{% block content %} + +{% endblock %} \ No newline at end of file