diff --git a/chat/__init__.py b/chat/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chat/admin.py b/chat/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/chat/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/chat/apps.py b/chat/apps.py new file mode 100644 index 0000000..8ebb9f0 --- /dev/null +++ b/chat/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ChatConfig(AppConfig): + name = 'chat' diff --git a/chat/models.py b/chat/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/chat/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/chat/tests.py b/chat/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/chat/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/chat/views.py b/chat/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/chat/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/core/classes/mail.py b/core/classes/mail.py new file mode 100644 index 0000000..92d3383 --- /dev/null +++ b/core/classes/mail.py @@ -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) \ No newline at end of file diff --git a/core/forms/__init__.py b/core/forms/__init__.py index 5be3ddc..b45e604 100644 --- a/core/forms/__init__.py +++ b/core/forms/__init__.py @@ -1 +1,2 @@ -from core.forms.auth import * \ No newline at end of file +from core.forms.auth import * +from core.forms.profiles import * \ No newline at end of file diff --git a/core/forms/auth.py b/core/forms/auth.py index 2825dbe..8005427 100644 --- a/core/forms/auth.py +++ b/core/forms/auth.py @@ -13,4 +13,8 @@ class OTPSelectorForm(Form): self.fields['provider'] = ChoiceField(choices=otp_choices) class OTPVerificationForm(Form): - token = CharField() \ No newline at end of file + token = CharField() + +class PWResetForm(Form): + password1 = CharField(widget=PasswordInput) + password2 = CharField(widget=PasswordInput) \ No newline at end of file diff --git a/core/forms/profiles.py b/core/forms/profiles.py index e477b8a..7ec2f2f 100644 --- a/core/forms/profiles.py +++ b/core/forms/profiles.py @@ -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") \ No newline at end of file + model = get_user_model() + fields = ('first_name', 'last_name', "display_name", "email", 'mobile', "role", "image", "remove_image") \ No newline at end of file diff --git a/core/helpers/files.py b/core/helpers/files.py index 16d9137..832a1b4 100644 --- a/core/helpers/files.py +++ b/core/helpers/files.py @@ -1,5 +1,5 @@ import uuid import os.path -def generate_storage_filename(): - return uuid.uuid4() \ No newline at end of file +def generate_storage_filename(*args, **kwargs): + return "uploads/" + str(uuid.uuid4()) \ No newline at end of file diff --git a/core/helpers/mail.py b/core/helpers/mail.py new file mode 100644 index 0000000..fa3ef7f --- /dev/null +++ b/core/helpers/mail.py @@ -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) \ No newline at end of file diff --git a/core/models/__init__.py b/core/models/__init__.py index a4026f3..b73eae0 100644 --- a/core/models/__init__.py +++ b/core/models/__init__.py @@ -1,3 +1,3 @@ from core.models.files import * from core.models.profiles import * -from core.models.otp import * \ No newline at end of file +from core.models.auth import * \ No newline at end of file diff --git a/core/models/otp.py b/core/models/auth.py similarity index 64% rename from core/models/otp.py rename to core/models/auth.py index fa26efd..f763f1d 100644 --- a/core/models/otp.py +++ b/core/models/auth.py @@ -10,4 +10,9 @@ class OTPUser(Model): 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) \ No newline at end of file diff --git a/core/models/profiles.py b/core/models/profiles.py index 1709a04..f1340b3 100644 --- a/core/models/profiles.py +++ b/core/models/profiles.py @@ -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) \ No newline at end of file + 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 \ No newline at end of file diff --git a/core/modules/mail.py b/core/modules/mail.py new file mode 100644 index 0000000..fcfaa88 --- /dev/null +++ b/core/modules/mail.py @@ -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 \ No newline at end of file diff --git a/core/modules/navigation.py b/core/modules/navigation.py index 1ca8d37..15bf1b6 100644 --- a/core/modules/navigation.py +++ b/core/modules/navigation.py @@ -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!") diff --git a/core/modules/urls.py b/core/modules/urls.py index 69654b5..059b4fe 100644 --- a/core/modules/urls.py +++ b/core/modules/urls.py @@ -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 diff --git a/core/navigations.py b/core/navigation.py similarity index 98% rename from core/navigations.py rename to core/navigation.py index 19d8a99..81e606b 100644 --- a/core/navigations.py +++ b/core/navigation.py @@ -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") diff --git a/core/views/auth.py b/core/views/auth.py index 89290ff..cc54810 100644 --- a/core/views/auth.py +++ b/core/views/auth.py @@ -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 @@ -125,4 +128,28 @@ class OTPValidatorView(FormView): 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") \ No newline at end of file diff --git a/core/views/profiles.py b/core/views/profiles.py index b704246..ef7616b 100644 --- a/core/views/profiles.py +++ b/core/views/profiles.py @@ -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 diff --git a/templates/backend/auth/pwrequest.html b/templates/backend/auth/pwrequest.html new file mode 100644 index 0000000..cc02348 --- /dev/null +++ b/templates/backend/auth/pwrequest.html @@ -0,0 +1,28 @@ +{% extends "backend/auth/base.html" %} +{% load bootstrap4 %} +{% block content %} +