diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..9b3fc5a --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + name = 'accounts' diff --git a/accounts/forms.py b/accounts/forms.py new file mode 100644 index 0000000..cabaed6 --- /dev/null +++ b/accounts/forms.py @@ -0,0 +1,43 @@ +from django import forms +from .models import CustomUser +from django.contrib.auth import get_user_model + +User = get_user_model() + + +class SignUpForm(forms.ModelForm): + password1 = forms.CharField(label='Password', widget=forms.PasswordInput) + password2 = forms.CharField(label='Password confirmation', widget=forms.PasswordInput) + + class Meta: + model = User + fields = ['name','email','phone'] + + def clean_password2(self): + password1 = self.cleaned_data.get("password1") + password2 = self.cleaned_data.get("password2") + if password1 and password2 and password1 != password2: + raise forms.ValidationError("Passwords don't match") + if len(password1) < 8: + raise forms.ValidationError('It must be 8 character or more') + return password2 + + def save(self, commit=True): + user = super().save(commit=False) + user.set_password(self.cleaned_data["password1"]) + if commit: + user.save() + return user + + +class CreateStaffForm(forms.ModelForm): + + class Meta: + model = User + fields = ['name','email','phone','is_active'] + +class UpdateStaffForm(forms.ModelForm): + + class Meta: + model = User + fields = ['name','email','phone','is_active'] \ No newline at end of file diff --git a/accounts/managers.py b/accounts/managers.py new file mode 100644 index 0000000..1b7e934 --- /dev/null +++ b/accounts/managers.py @@ -0,0 +1,30 @@ +from django.db import models +from django.contrib.auth.models import BaseUserManager + + +class CustomUserManager(BaseUserManager): + def create_user(self, email, name, password=None): + + if not email: + raise ValueError('Staff must have an email address') + + user = self.model( + email=self.normalize_email(email), + name=name, + ) + + user.set_password(password) + user.save(using=self._db) + return user + + def create_superuser(self, email, name, phone, password=None): + + user = self.create_user( + email, + password=password, + name=name, + ) + user.is_active = True + user.is_superuser = True + user.save(using=self._db) + return user \ No newline at end of file diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..f142c81 --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,44 @@ +# Generated by Django 3.0.7 on 2020-10-13 07:44 + +import accounts.models +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('name', models.CharField(max_length=100, verbose_name='Full Name')), + ('email', models.EmailField(max_length=100, unique=True, verbose_name='Email Address')), + ('phone', models.CharField(max_length=50, unique=True, verbose_name='Phone Number')), + ('is_superuser', models.BooleanField(default=False)), + ('is_admin', models.BooleanField(default=False)), + ('is_manager', models.BooleanField(default=True)), + ('is_staff', models.BooleanField(default=False)), + ('is_active', models.BooleanField(default=False)), + ('created_by', models.CharField(blank=True, max_length=100, null=True)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Profile', + fields=[ + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL)), + ('profile_pic', models.ImageField(default='profile_pics/user.svg', upload_to=accounts.models.profile_pic_filename, verbose_name='Profile Picture')), + ], + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/accounts/models.py b/accounts/models.py new file mode 100644 index 0000000..4309908 --- /dev/null +++ b/accounts/models.py @@ -0,0 +1,61 @@ +from uuid import uuid4 +from django.db import models +from django.contrib.auth.models import AbstractBaseUser + + +from .managers import CustomUserManager + + +class CustomUser(AbstractBaseUser): + name = models.CharField(verbose_name='Full Name', max_length=100) + email = models.EmailField(verbose_name='Email Address', unique=True, max_length=100) + phone = models.CharField(max_length=50, unique=True, verbose_name='Phone Number') + + is_superuser = models.BooleanField(default=False) + is_admin = models.BooleanField(default=False) + + is_manager = models.BooleanField(default=True) + is_staff = models.BooleanField(default=False) + + is_active = models.BooleanField(default=False) + created_by = models.CharField(max_length=100, blank=True, null=True) + + objects = CustomUserManager() + + USERNAME_FIELD = 'email' + REQUIRED_FIELDS = ['name', 'phone'] + + + def has_perms(self, perm, obj=None): + + if self.is_superuser or self.is_admin or self.is_manager: + return True + + def has_module_perms(self, app_label): + + if self.is_superuser or self.is_admin or self.is_manager: + return True + + def __str__(self): + return self.email + + +# Profile Picture +def profile_pic_filename(instance, filename): + ext = filename.split('.')[1] + new_filename = f'{uuid4()}.{ext}' + return f'profile_pics/{new_filename}' + + +class Profile(models.Model): + user = models.OneToOneField(CustomUser, primary_key=True, on_delete=models.CASCADE) + profile_pic = models.ImageField(verbose_name='Profile Picture', default='profile_pics/user.svg', upload_to=profile_pic_filename) + + def get_absolute_url(self): + return reverse('accounts:profile', kwargs={'pk': self.user_id}) + + def get_profile_update_url(self): + return reverse('accounts:profile-update', kwargs={'pk': self.user_id}) + + def __str__(self): + return f'{self.user.name} Profile' \ No newline at end of file diff --git a/accounts/templates/accounts/account_activation_email.html b/accounts/templates/accounts/account_activation_email.html new file mode 100644 index 0000000..1cb6b75 --- /dev/null +++ b/accounts/templates/accounts/account_activation_email.html @@ -0,0 +1,20 @@ + + + + + + + Registration Confirmation - Fagrimacs + + + + +

Welcome to Fagrimacs

+

+ Please click link below to confirm your email and complete registration. +

+

+ {{ confirm_url }} +

+ + diff --git a/accounts/templates/accounts/create_staff.html b/accounts/templates/accounts/create_staff.html new file mode 100644 index 0000000..77c1de4 --- /dev/null +++ b/accounts/templates/accounts/create_staff.html @@ -0,0 +1,72 @@ +{% extends 'dashboard/base.html' %} +{% block content %} + + + {% include 'messages.html' %} + +
+
+
+
+
Add New Staff
+
+
+
{% csrf_token %} + {{ form.as_p }} + Phone number will be used as default password + +
+ +
+
+
+
+
+
+
10 Most Recent Staff
+
+
+ {% if staff|length > 0 %} + + + + + + {% for person in staff %} + + + + + + + {% endfor %} + +
#NamePhoneEmail
{{ person.num }}. + {{ person.name }} + {{ person.phone_number }}{{ person.email }}
+ {% else %} +
No staff have been added yet
+ {% endif %} +
+
+
+
+ + + + + +{% endblock %} \ No newline at end of file diff --git a/accounts/templates/accounts/customuser_confirm_delete.html b/accounts/templates/accounts/customuser_confirm_delete.html new file mode 100644 index 0000000..d912bd8 --- /dev/null +++ b/accounts/templates/accounts/customuser_confirm_delete.html @@ -0,0 +1,59 @@ +{% extends 'base.html' %} +{% block content %} + + + {% include 'messages.html' %} +
+
+
+
+
Delete Staff {{ customuser.name }}
+
+
+
Proceed to delete {{ customuser.name }} ?
+
{% csrf_token %} + +
+
+
+
+
+
+
+
{{ customuser.name }} Details
+
+
+ + + + + + + +
Name{{ customuser.name }}
Phone{{ customuser.phone_number }}
Email{{ customuser.email }}
Created{{ customuser.date_joined|date:'d-m-Y H:i' }}
+
+
+
+
+ + + + + +{% endblock %} \ No newline at end of file diff --git a/accounts/templates/accounts/edit_staff.html b/accounts/templates/accounts/edit_staff.html new file mode 100644 index 0000000..9d0e60a --- /dev/null +++ b/accounts/templates/accounts/edit_staff.html @@ -0,0 +1,87 @@ +{% extends 'dashboard/base.html' %} +{% block title %}Update Staff{% endblock %} +{% block content %} + + + {% include 'messages.html' %} +
+
+
+
+
Edit {{ person.name }}
+
+
+
{% csrf_token %} + {{ form.as_p }} + +
+
+
+
+
+
+
+
{{ person.name }} Details
+
+
+ + + + + + + + +
Name{{ person.name }}
Phone{{ person.phone_number }}
Email{{ person.email }}
Created{{ person.date_joined|date:'d-m-Y H:i' }}
Branch{{ person.branch }}
+ {% if request.user.is_superuser %} + + Set Permissions + + {% endif %} +
+
+
+
+
+
+
Update {{ person.get_short_name }}'s Password
+
+
+
{% csrf_token %} + {{ password_form.as_p }} + +
+
+
+
+
+
+
+
Profile Photo
+
+
+
+
+
+
+
+ +{% endblock %} \ No newline at end of file diff --git a/accounts/templates/accounts/home.html b/accounts/templates/accounts/home.html new file mode 100644 index 0000000..6bd4474 --- /dev/null +++ b/accounts/templates/accounts/home.html @@ -0,0 +1,52 @@ +{% extends 'dashboard/base.html' %} +{% block title %}Staff{% endblock title %} + +{% block content %} + + + {% include 'messages.html' %} + +
+
+ {% if staff|length > 0 %} + + + + + + + {% for person in staff %} + + + + + + + + {% endfor %} + +
#NamePhoneEmailBranch
{{ person.num }}. + {{ person.name }} + {{ person.phone_number }}{{ person.email }}{{ person.branch }}
+ {% else %} +
No staff have been added yet
+ {% endif %} +
+
+ + +{% endblock %} \ No newline at end of file diff --git a/accounts/templates/accounts/login.html b/accounts/templates/accounts/login.html new file mode 100644 index 0000000..a0edb8c --- /dev/null +++ b/accounts/templates/accounts/login.html @@ -0,0 +1,22 @@ +{% extends 'base.html' %} +{% load crispy_forms_tags %} + +{% block content %} +
+
+
+
+
+

Login

+
+
+ {% csrf_token %} + {{form|crispy}} + +
+
+
+
+
+ +{% endblock %} \ No newline at end of file diff --git a/accounts/templates/accounts/registration_complete.html b/accounts/templates/accounts/registration_complete.html new file mode 100644 index 0000000..5b4bd61 --- /dev/null +++ b/accounts/templates/accounts/registration_complete.html @@ -0,0 +1,13 @@ +{% extends 'base.html' %} + + +{% block content %} + +
+ +

Registration Complete.

+

You can login now

+ Login + +
+{% endblock %} diff --git a/accounts/templates/accounts/registration_pending.html b/accounts/templates/accounts/registration_pending.html new file mode 100644 index 0000000..5df7bd7 --- /dev/null +++ b/accounts/templates/accounts/registration_pending.html @@ -0,0 +1,23 @@ +{% extends 'base.html' %} + +{% load crispy_forms_tags %} + + +{% block content %} +

+
+ +

+ Thank you for registering your account in Fagrimacs. +

+ + + +
+{% endblock %} diff --git a/accounts/templates/accounts/signup.html b/accounts/templates/accounts/signup.html new file mode 100644 index 0000000..43afbd4 --- /dev/null +++ b/accounts/templates/accounts/signup.html @@ -0,0 +1,17 @@ +{% extends 'base.html' %} +{% load crispy_forms_tags %} + +{% block content %} +
+
+
+
+ {% csrf_token %} + {{form|crispy}} + +
+
+
+
+ +{% endblock %} \ No newline at end of file diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts/tokens.py b/accounts/tokens.py new file mode 100644 index 0000000..4944c2b --- /dev/null +++ b/accounts/tokens.py @@ -0,0 +1,13 @@ +import six +from django.contrib.auth.tokens import PasswordResetTokenGenerator + + +class AccountActivationTokenGenerator(PasswordResetTokenGenerator): + def _make_hash_value(self, user, timestamp): + user_id = six.text_type(user.pk) + ts = six.text_type(timestamp) + is_active = six.text_type(user.is_active) + return f'{user_id}{ts}{is_active}' + + +account_activation_token = AccountActivationTokenGenerator() \ No newline at end of file diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..41cafd6 --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,19 @@ +from django.urls import path + +from .import views + +app_name = 'accounts' + + +urlpatterns = [ + path('signup/', views.signup, name='signup'), + path('login/', views.Login.as_view(), name='login'), + path('logout/', views.Logout.as_view(), name='logout'), + path('confirm-email///', + views.ConfirmRegistrationView.as_view(), name='confirm-email'), + path('create-staff/', views.CreateStaff.as_view(), name='create-staff'), + path('update-staff//', views.UpdateStaff.as_view(), name='update-staff'), + path('update-password//', views.UpdatePassword.as_view(), name='update-password'), + path('delete-staff//', views.DeleteStaff.as_view(), name='delete-staff'), + +] \ No newline at end of file diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..d0c3d6c --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,240 @@ +from django.conf import settings +from django.shortcuts import render, redirect, get_object_or_404, reverse +from django.core.mail import EmailMessage +from django.urls import reverse_lazy +from django.template.loader import get_template +from django.utils.encoding import force_bytes, force_text +from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode +from django.views.generic import TemplateView, FormView, DeleteView +from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm +from django.contrib.auth.views import LoginView +from django.contrib.messages.views import SuccessMessageMixin +from django.contrib.auth import authenticate, login, logout +#from .utils import get_all_perms, selected_perms +from django.contrib import messages +from django.contrib.auth.mixins import PermissionRequiredMixin +from django.contrib.auth.models import Permission +from django.contrib.auth import get_user_model + +from .tokens import account_activation_token +from .forms import SignUpForm, CreateStaffForm, UpdateStaffForm +from .models import Profile + + +User = get_user_model() + +BASE_URL = 'http://127.0.0.1:8000' + + +class Login(LoginView): + template_name = 'accounts/login.html' + + def get_success_url(self): + url = self.get_redirect_url() + if url: + return url + elif self.request.user.is_admin: + return reverse('/') + elif self.request.user.is_superuser: + return f'/admin/' + else: + return reverse('dashboard') + + +def signup(request): + form = SignUpForm(request.POST or None) + if form.is_valid(): + user = form.save() + user_email = form.cleaned_data['email'] + user.save() + + #create profile + profile = Profile(user=user) + profile.save() + + # send confirmation email + token = account_activation_token.make_token(user) + user_id = urlsafe_base64_encode(force_bytes(user.id)) + url = BASE_URL + reverse('accounts:confirm-email', + kwargs={'user_id': user_id, 'token': token}) + message = get_template( + 'accounts/account_activation_email.html').render( + {'confirm_url': url}) + mail = EmailMessage( + 'Account Confirmation', + message, + to=[user_email], + from_email=settings.EMAIL_HOST_USER) + mail.content_subtype = 'html' + mail.send() + + return render(request, 'accounts/registration_pending.html', + {'message': ( + 'A confirmation email has been sent to your email' + '. Please confirm to finish registration.')} + ) + return render(request, 'accounts/signup.html', { + 'form': form, + }) + + + +class ConfirmRegistrationView(TemplateView): + + def get(self, request, user_id, token): + user_id = force_text(urlsafe_base64_decode(user_id)) + + user = User.objects.get(pk=user_id) + + context = { + 'message': 'Registration confirmation error. Please click the reset password to generate a new confirmation email.' + } + + if user and account_activation_token.check_token(user, token): + user.is_active = True + user.save() + context['message'] = 'Registration complete. Please login' + + return render(request, 'accounts/registration_complete.html', context) + + +class CreateStaff(FormView): + template_name = 'accounts/create_staff.html' + form_class = CreateStaffForm + + def get(self, request, *args, **kwargs): + + staffs = User.objects.filter(is_staff=True, created_by=self.request.user).order_by('-pk')[:10] + + context = { + 'form': self.form_class, + 'staffs': staffs + } + + return render(request, self.template_name, context=context) + + def post(self, request, *args, **kwargs): + + form = self.form_class(data=request.POST) + + if form.is_valid(): + + staff_obj = form.save(commit=False) + staff_obj.is_staff = True + staff_obj.is_manager = False + staff_obj.created_by = self.request.user + + # Set default password to phone_number + staff_obj.set_password( + raw_password=form.cleaned_data['phone'] + ) + + staff_obj.save() + + + messages.success(request, 'Success, staff created', extra_tags='alert alert-success') + + return redirect(to='accounts:home') + + staffs = User.objects.filter(is_staff=True, created_by=self.request.user).order_by('-pk')[:10] + + context = { + 'form': form, + 'staffs': staffs + } + + messages.error(request, 'Errors occurred', extra_tags='alert alert-danger') + + return render(request, self.template_name, context=context) + + + +class UpdateStaff(FormView): + template_name = 'accounts/edit_staff.html' + form_class = UpdateStaffForm + password_form = PasswordChangeForm + + def post(self, request, *args, **kwargs): + + person = get_object_or_404(User, pk=self.kwargs['pk']) + + form = self.form_class(instance=person, data=request.POST) + + if form.is_valid(): + + form.save() + + messages.success(request, 'Success, staff details updated', extra_tags='alert alert-success') + + return redirect(to='accounts:update-staff', pk=self.kwargs['pk']) + else: + + context = { + 'form': self.form_class(data=request.POST, instance=person), + 'person': person, + 'password_form': self.password_form + } + + messages.error(request, 'Failed, errors occurred.', extra_tags='alert alert-danger') + + return render(request, self.template_name, context=context) + + def get(self, request, *args, **kwargs): + + person = get_object_or_404(User, pk=self.kwargs['pk']) + + password_form = self.password_form(user=person) + + password_form.fields['old_password'].widget.attrs.pop("autofocus", None) + + context = { + 'form': self.form_class(instance=person), + 'person': person, + 'password_form': password_form + } + + return render(request, self.template_name, context=context) + + + +class DeleteStaff(DeleteView): + + model = User + + def get_success_url(self): + + messages.success(self.request, 'Success, staff deleted', extra_tags='alert alert-info') + + return reverse_lazy('accounts:home') + + +class UpdatePassword(FormView): + form_class = PasswordChangeForm + + def post(self, request, *args, **kwargs): + + staff = get_object_or_404(User, pk=self.kwargs['pk']) + + form = self.form_class(user=staff, data=request.POST) + + if form.is_valid(): + + form.save() + + messages.success(request, 'Success, password updated', extra_tags='alert alert-success') + else: + messages.error(request, 'Failed, password NOT updated', extra_tags='alert alert-danger') + + return redirect(to='accounts:update-staff', pk=self.kwargs['pk']) + + +class Logout(FormView): + form_class = AuthenticationForm + template_name = 'accounts/login.html' + + def get(self, request, *args, **kwargs): + + logout(request) + + return redirect(to='accounts:login') + diff --git a/config/settings.py b/config/settings.py index 24c896b..6365026 100644 --- a/config/settings.py +++ b/config/settings.py @@ -31,12 +31,16 @@ # Application definition INSTALLED_APPS = [ + 'accounts', + 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', + + 'crispy_forms', ] MIDDLEWARE = [ @@ -54,7 +58,7 @@ TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], + 'DIRS': [os.path.join(BASE_DIR, 'templates')], 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ @@ -114,7 +118,24 @@ USE_TZ = True +#Email Configurations For Development +EMAIL_HOST_USER = 'noreply@example.com' +EMAIL_BACKEND = 'django.core.mail.backends.filebased.EmailBackend' +EMAIL_FILE_PATH = os.path.join(BASE_DIR, 'sent_mails') + + +AUTH_USER_MODEL = 'accounts.CustomUser' + +CRISPY_TEMPLATE_PACK = 'bootstrap4' + +LOGOUT_REDIRECT_URL = 'accounts:login' + + # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/3.0/howto/static-files/ STATIC_URL = '/static/' +MEDIA_URL = '/media/' + +STATICFILES_DIRS = [os.path.join(BASE_DIR, 'static'),] +MEDIA_ROOT = os.path.join(BASE_DIR, 'media') diff --git a/config/urls.py b/config/urls.py index 082579f..fcde83e 100644 --- a/config/urls.py +++ b/config/urls.py @@ -1,21 +1,9 @@ -"""config URL Configuration - -The `urlpatterns` list routes URLs to views. For more information please see: - https://docs.djangoproject.com/en/3.0/topics/http/urls/ -Examples: -Function views - 1. Add an import: from my_app import views - 2. Add a URL to urlpatterns: path('', views.home, name='home') -Class-based views - 1. Add an import: from other_app.views import Home - 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') -Including another URLconf - 1. Import the include() function: from django.urls import include, path - 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) -""" from django.contrib import admin -from django.urls import path +from django.urls import path, include +from .import views urlpatterns = [ path('admin/', admin.site.urls), + path('dashboard/', views.Dashboard.as_view(), name='dashboard'), + path('accounts/', include('accounts.urls', namespace='accounts')), ] diff --git a/config/views.py b/config/views.py new file mode 100644 index 0000000..1545f00 --- /dev/null +++ b/config/views.py @@ -0,0 +1,6 @@ +from django.shortcuts import render +from django.views.generic import TemplateView + + +class Dashboard(TemplateView): + template_name = 'index.html' \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 93a6ec2..70e65d2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,4 @@ -Django==3.0.7 \ No newline at end of file +Django==3.0.7 +django-crispy-forms==1.9.2 +six==1.15.0 +Pillow==7.2.0 \ No newline at end of file diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..b04cbe4 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,48 @@ + + + + + + + + + + + Django Saas + + + + {% block content %} + {% endblock %} + + + + + + + + \ No newline at end of file diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..6549be5 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,6 @@ +{% extends 'base.html' %} + +{% block content %} + + +{% endblock %} \ No newline at end of file