Integrate basic mail functionality (might need a queue...)
This commit is contained in:
parent
53780751d1
commit
bd0519146c
28 changed files with 273 additions and 42 deletions
0
chat/__init__.py
Normal file
0
chat/__init__.py
Normal file
3
chat/admin.py
Normal file
3
chat/admin.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
5
chat/apps.py
Normal file
5
chat/apps.py
Normal file
|
@ -0,0 +1,5 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ChatConfig(AppConfig):
|
||||
name = 'chat'
|
3
chat/models.py
Normal file
3
chat/models.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from django.db import models
|
||||
|
||||
# Create your models here.
|
3
chat/tests.py
Normal file
3
chat/tests.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
3
chat/views.py
Normal file
3
chat/views.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
45
core/classes/mail.py
Normal file
45
core/classes/mail.py
Normal file
|
@ -0,0 +1,45 @@
|
|||
import smtplib
|
||||
import email
|
||||
import email.utils
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from dbsettings.functions import getValue
|
||||
|
||||
class BaseMailProvider:
|
||||
@property
|
||||
def get_name(self):
|
||||
return "Base Mail Provider"
|
||||
|
||||
@property
|
||||
def get_logo(self):
|
||||
return ""
|
||||
|
||||
def send_message(self, message):
|
||||
raise NotImplementedError(f"{type(self)} does not implement send_message()!")
|
||||
|
||||
def send_mail(self, subject, content, recipients, cc=[], bcc=[], headers={}, sender=getValue("core.mail.sender", "expephalon@localhost")):
|
||||
message = email.message_from_string(content)
|
||||
headers["From"] = sender
|
||||
headers["To"] = ",".join(recipients)
|
||||
headers["Cc"] = ",".join(cc)
|
||||
headers["Bcc"] = ",".join(bcc)
|
||||
headers["Subject"] = subject
|
||||
headers["Message-ID"] = email.utils.make_msgid("expephalon", urlparse(getValue("core.base_url", "http://localhost/").split(":")[1]).netloc)
|
||||
headers["Date"] = email.utils.formatdate()
|
||||
for header, value in headers.items():
|
||||
message.add_header(header, value)
|
||||
self.send_message(message)
|
||||
|
||||
class SMTPMailProvider(BaseMailProvider):
|
||||
def __init__(self, host=getValue("core.smtp.host", "localhost"), port=int(getValue("core.smtp.port", 0)), username=getValue("core.smtp.username", "") or None, password=getValue("core.smtp.password", "")):
|
||||
self.smtp = smtplib.SMTP(host, port)
|
||||
if username:
|
||||
self.smtp.login(username, password)
|
||||
|
||||
@property
|
||||
def get_name(self):
|
||||
return "SMTP Mail"
|
||||
|
||||
def send_message(self, message):
|
||||
self.smtp.send_message(message)
|
|
@ -1 +1,2 @@
|
|||
from core.forms.auth import *
|
||||
from core.forms.profiles import *
|
|
@ -14,3 +14,7 @@ class OTPSelectorForm(Form):
|
|||
|
||||
class OTPVerificationForm(Form):
|
||||
token = CharField()
|
||||
|
||||
class PWResetForm(Form):
|
||||
password1 = CharField(widget=PasswordInput)
|
||||
password2 = CharField(widget=PasswordInput)
|
|
@ -1,15 +1,19 @@
|
|||
from django.forms import ModelForm, CharField, EmailField
|
||||
from django.forms import ModelForm, CharField, BooleanField, ImageField
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from phonenumber_field.formfields import PhoneNumberField
|
||||
|
||||
from core.models import AdminProfile
|
||||
|
||||
class AdminEditForm(ModelForm):
|
||||
#fields from User model that you want to edit
|
||||
first_name = CharField(required=True, label=_('First Name'))
|
||||
last_name = CharField(required=True, label=_('Last Name'))
|
||||
email = EmailField(required=True, labels=_("Email Address"))
|
||||
display_name = CharField(required=False, label=_('Internal Display Name'))
|
||||
mobile = PhoneNumberField(required=False, label=_('Mobile Number'))
|
||||
role = CharField(required=False, label=_("Role"))
|
||||
image = ImageField(required=False, label=_("Image"))
|
||||
remove_image = BooleanField(required=False, label=_("Remove image from profile?"))
|
||||
|
||||
class Meta:
|
||||
model = AdminProfile
|
||||
fields = ('first_name', 'last_name', "email", 'mobile', "role", "image")
|
||||
model = get_user_model()
|
||||
fields = ('first_name', 'last_name', "display_name", "email", 'mobile', "role", "image", "remove_image")
|
|
@ -1,5 +1,5 @@
|
|||
import uuid
|
||||
import os.path
|
||||
|
||||
def generate_storage_filename():
|
||||
return uuid.uuid4()
|
||||
def generate_storage_filename(*args, **kwargs):
|
||||
return "uploads/" + str(uuid.uuid4())
|
12
core/helpers/mail.py
Normal file
12
core/helpers/mail.py
Normal file
|
@ -0,0 +1,12 @@
|
|||
from core.modules.mail import providers
|
||||
|
||||
from dbsettings.functions import getValue
|
||||
|
||||
def get_provider_by_name(name, fallback=True):
|
||||
return providers.get(name, None) or providers["smtp"]
|
||||
|
||||
def get_default_provider(fallback=True):
|
||||
return get_provider_by_name(getValue("core.email.provider", "smtp"), fallback)
|
||||
|
||||
def send_mail(provider=None, *args):
|
||||
return get_provider_by_name(provider)().mail(*args)
|
|
@ -1,3 +1,3 @@
|
|||
from core.models.files import *
|
||||
from core.models.profiles import *
|
||||
from core.models.otp import *
|
||||
from core.models.auth import *
|
|
@ -11,3 +11,8 @@ class LoginSession(Model):
|
|||
uuid = UUIDField(default=uuid4, primary_key=True)
|
||||
user = ForeignKey(get_user_model(), CASCADE)
|
||||
creation = DateTimeField(auto_now_add=True)
|
||||
|
||||
class PWResetToken(Model):
|
||||
token = UUIDField(default=uuid4, primary_key=True)
|
||||
user = ForeignKey(get_user_model(), CASCADE)
|
||||
creation = DateTimeField(auto_now_add=True)
|
|
@ -4,10 +4,17 @@ from phonenumber_field.modelfields import PhoneNumberField
|
|||
from django.db.models import OneToOneField, CASCADE, CharField, ImageField
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from core.helpers.files import generate_storage_filename
|
||||
|
||||
class Profile(PolymorphicModel):
|
||||
user = OneToOneField(get_user_model(), CASCADE)
|
||||
mobile = PhoneNumberField(blank=True)
|
||||
|
||||
class AdminProfile(Profile):
|
||||
role = CharField(max_length=64)
|
||||
image = ImageField(null=True)
|
||||
role = CharField(max_length=255)
|
||||
image = ImageField(null=True, blank=True, upload_to=generate_storage_filename)
|
||||
display_name = CharField("Internal Display Name", max_length=255, null=True, blank=True)
|
||||
|
||||
@property
|
||||
def get_internal_name(self):
|
||||
return self.display_name if self.display_name else self.user.get_full_name
|
15
core/modules/mail.py
Normal file
15
core/modules/mail.py
Normal file
|
@ -0,0 +1,15 @@
|
|||
from core.classes.mail import SMTPMailProvider
|
||||
|
||||
import importlib
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
providers = { "smtp": SMTPMailProvider }
|
||||
|
||||
for module in settings.EXPEPHALON_MODULES:
|
||||
try:
|
||||
mom = importlib.import_module(f"{module}.mail")
|
||||
for name, provider in mom.MAILPROVIDERS.items():
|
||||
providers[name] = provider
|
||||
except (AttributeError, ModuleNotFoundError):
|
||||
continue
|
|
@ -11,7 +11,7 @@ navigations = {
|
|||
|
||||
for module in ["core"] + settings.EXPEPHALON_MODULES:
|
||||
try:
|
||||
mon = importlib.import_module(f"{module}.navigations")
|
||||
mon = importlib.import_module(f"{module}.navigation")
|
||||
for name, nav in mon.NAVIGATIONS:
|
||||
if name in navigations.keys:
|
||||
raise ValueError(f"Error in {module}: Navigation of name {name} already exists!")
|
||||
|
|
|
@ -9,6 +9,7 @@ from core.views import (
|
|||
OTPSelectorView,
|
||||
LogoutView,
|
||||
OTPValidatorView,
|
||||
PWResetView,
|
||||
BackendNotImplementedView,
|
||||
AdminListView,
|
||||
AdminDeleteView,
|
||||
|
@ -28,6 +29,7 @@ URLPATTERNS.append(path('login/', LoginView.as_view(), name="login"))
|
|||
URLPATTERNS.append(path('login/otp/select/', OTPSelectorView.as_view(), name="otpselector"))
|
||||
URLPATTERNS.append(path('login/otp/validate/', OTPValidatorView.as_view(), name="otpvalidator"))
|
||||
URLPATTERNS.append(path('logout/', LogoutView.as_view(), name="logout"))
|
||||
URLPATTERNS.append(path('login/reset/', PWResetView.as_view(), name="pwreset"))
|
||||
|
||||
# Base Backend URLs
|
||||
|
||||
|
|
|
@ -88,7 +88,7 @@ navigations["backend_main"].add_section(reports_section)
|
|||
|
||||
administration_section = NavSection("Administration", "")
|
||||
|
||||
user_administration_item = NavItem("Administrator Users", "fa-users-cog", "backendni")
|
||||
user_administration_item = NavItem("Administrator Users", "fa-users-cog", "admins")
|
||||
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")
|
|
@ -4,11 +4,14 @@ 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 django.utils import timezone
|
||||
|
||||
from core.forms import LoginForm, OTPSelectorForm, OTPVerificationForm
|
||||
from core.models.otp import LoginSession
|
||||
from core.forms import LoginForm, OTPSelectorForm, OTPVerificationForm, PWResetForm
|
||||
from core.models.auth import LoginSession, PWResetToken
|
||||
from core.helpers.otp import get_user_otps, get_otp_choices, get_otp_by_name
|
||||
|
||||
from dbsettings.functions import getValue
|
||||
|
||||
class LoginView(FormView):
|
||||
template_name = f"{settings.EXPEPHALON_BACKEND}/auth/login.html"
|
||||
form_class = LoginForm
|
||||
|
@ -126,3 +129,27 @@ class LogoutView(View):
|
|||
def get(self, request, *args, **kwargs):
|
||||
logout(request)
|
||||
return redirect("login")
|
||||
|
||||
class PWResetView(FormView):
|
||||
template_name = f"{settings.EXPEPHALON_BACKEND}/auth/pwreset.html"
|
||||
form_class = PWResetForm
|
||||
|
||||
def validate_session(self):
|
||||
try:
|
||||
token = PWResetToken.objects.get(token=self.kwargs["pk"])
|
||||
max_age = int(getValue("core.auth.pwreset.max_age", "86400"))
|
||||
assert token.creation > timezone.now() - timezone.timedelta(seconds=max_age)
|
||||
return token.user
|
||||
except:
|
||||
messages.error(self.request, "Incorrect or expired password reset link.")
|
||||
raise PermissionDenied()
|
||||
|
||||
def form_valid(self, form):
|
||||
user = self.validate_session()
|
||||
if not form.cleaned_data["password1"] == form.cleaned_data["password2"]:
|
||||
messages.error(self.request, "Entered passwords do not match - please try again.")
|
||||
return self.form_invalid(form)
|
||||
user.set_password(form.cleaned_data["password1"])
|
||||
user.save()
|
||||
messages.success(self.request, "Your password has been changed. You can now login with your new password.")
|
||||
return redirect("login")
|
|
@ -4,6 +4,7 @@ from django.urls import reverse_lazy
|
|||
from django.contrib.auth import get_user_model
|
||||
|
||||
from core.models import AdminProfile
|
||||
from core.forms import AdminEditForm
|
||||
|
||||
class AdminListView(ListView):
|
||||
template_name = f"{settings.EXPEPHALON_BACKEND}/profiles/index.html"
|
||||
|
@ -16,32 +17,65 @@ class AdminListView(ListView):
|
|||
|
||||
class AdminEditView(FormView):
|
||||
template_name = f"{settings.EXPEPHALON_BACKEND}/profiles/update.html"
|
||||
model = get_user_model()
|
||||
success_url = reverse_lazy("dbsettings")
|
||||
fields = ["key", "value"]
|
||||
form_class = AdminEditForm
|
||||
success_url = reverse_lazy("admins")
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["title"] = "Edit Setting"
|
||||
context["title"] = "Edit Administrator"
|
||||
return context
|
||||
|
||||
def get_initial(self):
|
||||
initial = super().get_initial()
|
||||
admin = get_user_model().objects.get(id=self.kwargs["pk"])
|
||||
assert type(admin.profile) == AdminProfile
|
||||
initial["first_name"] = admin.first_name
|
||||
initial["last_name"] = admin.last_name
|
||||
initial["email"] = admin.username
|
||||
initial["mobile"] = admin.profile.mobile
|
||||
initial["role"] = admin.profile.role
|
||||
initial["display_name"] = admin.profile.display_name
|
||||
return initial
|
||||
|
||||
def form_valid(self, form):
|
||||
admin = get_user_model().objects.get(id=self.kwargs["pk"])
|
||||
admin.first_name = form.cleaned_data["first_name"]
|
||||
admin.last_name = form.cleaned_data["last_name"]
|
||||
admin.username = form.cleaned_data["email"]
|
||||
admin.email = form.cleaned_data["email"]
|
||||
admin.profile.mobile = form.cleaned_data["mobile"]
|
||||
admin.profile.role = form.cleaned_data["role"]
|
||||
admin.profile.display_name = form.cleaned_data["display_name"]
|
||||
|
||||
if form.cleaned_data["image"] or form.cleaned_data["remove_image"]:
|
||||
admin.profile.image = form.cleaned_data["image"]
|
||||
|
||||
admin.profile.save()
|
||||
admin.save()
|
||||
return super().form_valid(form)
|
||||
|
||||
class AdminDeleteView(DeleteView):
|
||||
template_name = f"{settings.EXPEPHALON_BACKEND}/profiles/delete.html"
|
||||
model = get_user_model()
|
||||
success_url = reverse_lazy("dbsettings")
|
||||
success_url = reverse_lazy("admins")
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["title"] = "Delete Administrator"
|
||||
return context
|
||||
|
||||
def get_object(self, queryset=None):
|
||||
admin = super().get_object(queryset=queryset)
|
||||
assert type(admin.profile) == AdminProfile
|
||||
return admin
|
||||
|
||||
class AdminCreateView(FormView):
|
||||
template_name = f"{settings.EXPEPHALON_BACKEND}/profiles/create.html"
|
||||
model = get_user_model()
|
||||
success_url = reverse_lazy("dbsettings")
|
||||
success_url = reverse_lazy("admins")
|
||||
fields = ["key", "value"]
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["title"] = "Create Setting"
|
||||
context["title"] = "Create Administrator"
|
||||
return context
|
||||
|
|
28
templates/backend/auth/pwrequest.html
Normal file
28
templates/backend/auth/pwrequest.html
Normal file
|
@ -0,0 +1,28 @@
|
|||
{% extends "backend/auth/base.html" %}
|
||||
{% load bootstrap4 %}
|
||||
{% block content %}
|
||||
<div class="mx-auto app-login-box col-sm-12 col-md-10 col-lg-9">
|
||||
<div class="app-logo"></div>
|
||||
<h4 class="mb-0">
|
||||
<span class="d-block">Welcome back,</span>
|
||||
<span>Let us help you regain access to your account.</span></h4>
|
||||
<!--<h6 class="mt-3">No account? <a href="javascript:void(0);" class="text-primary">Sign up now</a></h6>-->
|
||||
{% bootstrap_messages %}
|
||||
<div class="divider row"></div>
|
||||
<div>
|
||||
<form method="POST" class="">
|
||||
{% csrf_token %}
|
||||
<div class="form-row">
|
||||
<div class="col-md-6">
|
||||
<div class="position-relative form-group"><label for="exampleEmail" class="">Email</label><input name="email" id="exampleEmail" placeholder="Email here..." type="email" class="form-control"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="divider row"></div>
|
||||
<div class="d-flex align-items-center">
|
||||
<button class="btn btn-primary btn-lg">Start Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
33
templates/backend/auth/pwreset.html
Normal file
33
templates/backend/auth/pwreset.html
Normal file
|
@ -0,0 +1,33 @@
|
|||
{% extends "backend/auth/base.html" %}
|
||||
{% load bootstrap4 %}
|
||||
{% block content %}
|
||||
<div class="mx-auto app-login-box col-sm-12 col-md-10 col-lg-9">
|
||||
<div class="app-logo"></div>
|
||||
<h4 class="mb-0">
|
||||
<span class="d-block">Welcome back,</span>
|
||||
<span>Please set your password.</span></h4>
|
||||
<!--<h6 class="mt-3">No account? <a href="javascript:void(0);" class="text-primary">Sign up now</a></h6>-->
|
||||
{% bootstrap_messages %}
|
||||
<div class="divider row"></div>
|
||||
<div>
|
||||
<form method="POST" class="">
|
||||
{% csrf_token %}
|
||||
<div class="form-row">
|
||||
<div class="col-md-6">
|
||||
<div class="position-relative form-group"><label for="examplePassword" class="">Password</label><input name="password1" id="examplePassword" placeholder="Password here..." type="password"
|
||||
class="form-control"></div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="position-relative form-group"><label for="examplePassword" class="">Confirm Password</label><input name="password2" id="examplePassword" placeholder="Password here..." type="password"
|
||||
class="form-control"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="divider row"></div>
|
||||
<div class="d-flex align-items-center">
|
||||
<button class="btn btn-primary btn-lg">Set Password</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -70,21 +70,21 @@
|
|||
</div>
|
||||
<ul class="header-menu nav">
|
||||
<li class="nav-iftem">
|
||||
<a href="javascript:void(0);" class="nav-link">
|
||||
<a href="{% url "dashboard" %}" class="nav-link">
|
||||
<i class="nav-link-icon fa fa-database"> </i>
|
||||
Statistics
|
||||
Administration
|
||||
</a>
|
||||
</li>
|
||||
<li class="btn-group nav-item">
|
||||
<a href="javascript:void(0);" class="nav-link">
|
||||
<i class="nav-link-icon fa fa-edit"></i>
|
||||
Projects
|
||||
<a href="{% url "backendni" %}" class="nav-link">
|
||||
<i class="nav-link-icon fa fa-comment-dots"></i>
|
||||
Chat
|
||||
</a>
|
||||
</li>
|
||||
<li class="dropdown nav-item">
|
||||
<a href="javascript:void(0);" class="nav-link">
|
||||
<a href="{% url "admins_edit" request.user.id %}" class="nav-link">
|
||||
<i class="nav-link-icon fa fa-cog"></i>
|
||||
Settings
|
||||
User Settings
|
||||
</a>
|
||||
</li>
|
||||
</ul> </div>
|
||||
|
@ -99,10 +99,7 @@
|
|||
<i class="fa fa-angle-down ml-2 opacity-8"></i>
|
||||
</a>
|
||||
<div tabindex="-1" role="menu" aria-hidden="true" class="dropdown-menu dropdown-menu-right">
|
||||
<button type="button" tabindex="0" class="dropdown-item">User Account</button>
|
||||
<button type="button" tabindex="0" class="dropdown-item">Settings</button>
|
||||
<h6 tabindex="-1" class="dropdown-header">Header</h6>
|
||||
<button type="button" tabindex="0" class="dropdown-item">Actions</button>
|
||||
<a href="{% url "admins_edit" request.user.id %}" type="button" tabindex="0" class="dropdown-item">User Account</a>
|
||||
<div tabindex="-1" class="dropdown-divider"></div>
|
||||
<a href="{% url "logout" %}" type="button" tabindex="0" class="dropdown-item">Logout</a>
|
||||
</div>
|
||||
|
@ -110,7 +107,7 @@
|
|||
</div>
|
||||
<div class="widget-content-left ml-3 header-user-info">
|
||||
<div class="widget-heading">
|
||||
{{ request.user.get_full_name }}
|
||||
{{ request.user.profile.get_internal_name }}
|
||||
</div>
|
||||
<div class="widget-subheading">
|
||||
{{ request.user.profile.role }}
|
||||
|
|
|
@ -33,7 +33,7 @@
|
|||
<div class="card-body">
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane fade show active" id="tabs-eg-77">
|
||||
<form method="POST">
|
||||
<form method="POST" enctype="multipart/form-data" >
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form form %}
|
||||
{% buttons %}
|
||||
|
|
|
@ -27,7 +27,7 @@
|
|||
<div class="card-header-tab card-header-tab-animation card-header">
|
||||
<div class="card-header-title">
|
||||
<i class="header-icon lnr-apartment icon-gradient bg-love-kiss"> </i>
|
||||
Deleting {{ object.user.username }}
|
||||
Deleting {{ object.get_full_name }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
|
@ -35,7 +35,7 @@
|
|||
<div class="tab-pane fade show active" id="tabs-eg-77">
|
||||
<form method="POST">
|
||||
{% csrf_token %}
|
||||
Are you sure you wish to delete {{ object.user.username }}? This will irrevocably delete their account and any associated information. You can also disable the user without deleting their data!
|
||||
Are you sure you wish to delete {{ object.get_full_name }}? This will irrevocably delete their account and any associated information. You can also disable the user without deleting their data!
|
||||
{% buttons %}
|
||||
<button type="submit" class="btn-shadow mr-3 btn btn-success">
|
||||
<i class="fa fa-check"></i> Save
|
||||
|
|
|
@ -52,7 +52,7 @@
|
|||
<td>{{ admin.role }}</td>
|
||||
<td>{{ admin.user.username }}</td>
|
||||
<td>{{ admin.mobile }}</td>
|
||||
<td><a href=""><i class="fas fa-edit" title="Edit Setting"></i></a> <a href=""><i style="color: darkred;" class="fas fa-trash-alt" title="Delete Setting"></i></a></td>
|
||||
<td><a href="{% url "admins_edit" admin.user.id %}"><i class="fas fa-edit" title="Edit Administrator"></i></a> <a href="{% url "admins_delete" admin.user.id %}"><i style="color: darkred;" class="fas fa-trash-alt" title="Delete Administrator"></i></a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
|
|
@ -33,7 +33,7 @@
|
|||
<div class="card-body">
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane fade show active" id="tabs-eg-77">
|
||||
<form method="POST">
|
||||
<form method="POST" enctype="multipart/form-data" >
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form form %}
|
||||
{% buttons %}
|
||||
|
|
Loading…
Reference in a new issue