diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..c240757e --- /dev/null +++ b/.gitignore @@ -0,0 +1,370 @@ + +# Created by https://www.gitignore.io/api/python,django,pycharm,pycharm+all,pycharm+iml +# Edit at https://www.gitignore.io/?templates=python,django,pycharm,pycharm+all,pycharm+iml + +### Django ### +*.log +*.pot +*.pyc +__pycache__/ +local_settings.py +db.sqlite3 +db.sqlite3-journal +media + +# If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/ +# in your Git repository. Update and uncomment the following line accordingly. +# /staticfiles/ + +### Django.Python Stack ### +# Byte-compiled / optimized / DLL files +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# Mr Developer +.mr.developer.cfg +.project +.pydevproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +### PyCharm ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.vscode/ + +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### PyCharm Patch ### +# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 + +# *.iml +# modules.xml +# .idea/misc.xml +# *.ipr + +# Sonarlint plugin +.idea/**/sonarlint/ + +# SonarQube Plugin +.idea/**/sonarIssues.xml + +# Markdown Navigator plugin +.idea/**/markdown-navigator.xml +.idea/**/markdown-navigator/ + +### PyCharm+all ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff + +# Generated files + +# Sensitive or high-churn files + +# Gradle + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake + +# Mongo Explorer plugin + +# File-based project format + +# IntelliJ + +# mpeltonen/sbt-idea plugin + +# JIRA plugin + +# Cursive Clojure plugin + +# Crashlytics plugin (for Android Studio and IntelliJ) + +# Editor-based Rest Client + +# Android studio 3.1+ serialized cache file + +### PyCharm+all Patch ### +# Ignores the whole .idea folder and all .iml files +# See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360 + +.idea/ + +# Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023 + +*.iml +modules.xml +.idea/misc.xml +*.ipr + +# Sonarlint plugin +.idea/sonarlint + +### PyCharm+iml ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff + +# Generated files + +# Sensitive or high-churn files + +# Gradle + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake + +# Mongo Explorer plugin + +# File-based project format + +# IntelliJ + +# mpeltonen/sbt-idea plugin + +# JIRA plugin + +# Cursive Clojure plugin + +# Crashlytics plugin (for Android Studio and IntelliJ) + +# Editor-based Rest Client + +# Android studio 3.1+ serialized cache file + +### PyCharm+iml Patch ### +# Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023 + + +### Python ### +# Byte-compiled / optimized / DLL files + +# C extensions + +# Distribution / packaging + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. + +# Installer logs + +# Unit test / coverage reports + +# Translations + +# Scrapy stuff: + +# Sphinx documentation + +# PyBuilder + +# pyenv + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. + +# celery beat schedule file + +# SageMath parsed files + +# Spyder project settings + +# Rope project settings + +# Mr Developer + +# mkdocs documentation + +# mypy + +# Pyre type checker + +# End of https://www.gitignore.io/api/python,django,pycharm,pycharm+all,pycharm+iml +postgres/ +venv/ +env/ +*/**/venv +*/**/env +*/**/screenlog.* + diff --git a/README.md b/README.md index 9e89cd4f..301d6a0c 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,92 @@ -# In name of Allah - -## Introduction -We want a simple app to schedule tasks for users. It should be possible to use django admin as interface for this application. - -There are two kind of users: -- normal users: -- admin users - -normal users can only see, filter & add to their own tasks. These tasks will have a title, description, owner and time to send field. When user creates new task, it should be scheduled to send an email to its owner at the specified time (use celery for this purpose). - -admin users have the permission to manage users, add to them and delete them. Also they can manage all tasks of users, add task for them and edit their tasks. When created or edited, scheduled tasks should be added or edited. - -**note** that each user must have below fields: -- email +# In the name of GOD +##Description +This projects is a simple task scheduling project in which there are two types +of users namely, 'normal' and 'admin'. Users have these fields: - username - password -- first name -- last name -- permissions (admin & normal) - -You should extend AbstractUser for implementing user model. +- first_name +- last_name +- email +- and a set of permissions -In addition to these (all should be implemented in django admin) write an API for authentication (login & signup) and an API for getting list of tasks (according to permission of user). +Normal users can add and view their own tasks which have: +- title +- description +- owner +- time_to_send -### Note -Use django rest framework and JWT for authentication. +while admins not only can edit and view and add to normal users' tasks, but +they also can manage the normal users themselves. -Also do not forget to write unit test for authentication API. +There are sign up and login APIs through which users can sign up and access +their account. Superusers can also use the sign up API to add new users or +admins. -## Expectations +I've decided that admins can't add other admins and change permissions +groups, but it's easily modifiable. The permissions which are set for users +upon creation are also easily modifiable through set_permission method +in User model. -So What does matter to us? -- a clean structure of codebase & components -- clean code practices -- well written unit tests -- finally, ability to learn +When creating tasks, the time_to_send field is used to schedule an email +on that time to the user. ## Tasks +you can see the list of tasks that I specified for myself in the pictures below. +![picture](static/tasks1.png) +![picture](static/tasks2.png) + +## Django Admin +For the django admin interface to be used by both normal and admin users +I needed to create 2 different instances of AdminSite since normal users +can't access the admin interface because they're not staff members. +So there are 2 URLs, one for admins(/admin) and one for normal users +(/user) which are both customized to meet the specifications pointed out +in the document. To be able to add admins and permissions, you have to +be a superuser. You can create a superuser with this command: +``` +python manage.py createsuperuser --email your_email --username your_username +``` +Then you're asked to enter a password and your superuser account will be +created successfully. + +## Authentication +Authentication is implemented by django simple_jwt module and both of +login(token obtain) and refresh token are tested using django TestCase. +To run the tests you can use the following command in the root of the project. +``` +python manage.py test +``` + +## Run +To run the project you have to open your terminal in the root of the +project next to manage.py. -1. Fork this repository -2. Break and specify your tasks in project management tool (append the image of your tasks to readme file of your project) -3. Learn & Develop -4. Push your code to your repository -5. Explain the roadmap of your development in readme of repository (also append the image of your specified tasks on part 2 to file) -6. Send us a pull request, we will review and get back to you -7. Enjoy +Then install the requirements: +``` +pip install -r requirements.txt +``` +After that migrate the database migrations: +``` +python manage.py migrate +``` +Then run the server on your localhost with the command below: +``` +python manage.py runserver +``` +and you can access the APIs or the interfaces with their URLs. +APIs are documented in the postman collection, you just need to +add authorization token in the header where needed. Interfaces are +also easy to follow and self-explanatory. +For email scheduling you need to install and run redis.After that +you have to enter values for celery and email backend configs in +settings.py of the project and also run the celery worker with the +command below: +``` +celery -A Scheduler worker -l info +``` -**Finally** don't be afraid to ask anything from us. +## Conclusion +I hardly tried to follow the specifications of the challenge document +and tried to create a clean and easily modifiable project. Don't +to contact me and ask for a presentation on how I managed to developed +the project and ask any questions that might have occurred to you. \ No newline at end of file diff --git a/Scheduler/__init__.py b/Scheduler/__init__.py new file mode 100644 index 00000000..f7052b09 --- /dev/null +++ b/Scheduler/__init__.py @@ -0,0 +1,5 @@ +from __future__ import absolute_import + +# This will make sure the app is always imported when +# Django starts so that shared_task will use this app. +from .celery import app as celery_app diff --git a/Scheduler/asgi.py b/Scheduler/asgi.py new file mode 100644 index 00000000..f6a7ff69 --- /dev/null +++ b/Scheduler/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for Scheduler project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/3.1/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'Scheduler.settings') + +application = get_asgi_application() diff --git a/Scheduler/celery.py b/Scheduler/celery.py new file mode 100644 index 00000000..5c1a0260 --- /dev/null +++ b/Scheduler/celery.py @@ -0,0 +1,18 @@ +from __future__ import absolute_import +import os +from celery import Celery +from django.conf import settings + +# set the default Django settings module for the 'celery' program. +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'Scheduler.settings') +app = Celery('Scheduler') + +# Using a string here means the worker will not have to +# pickle the object when using Windows. +app.config_from_object('django.conf:settings') +app.autodiscover_tasks(lambda: settings.INSTALLED_APPS) + + +@app.task(bind=True) +def debug_task(self): + print('Request: {0!r}'.format(self.request)) \ No newline at end of file diff --git a/Scheduler/settings.py b/Scheduler/settings.py new file mode 100644 index 00000000..30af694d --- /dev/null +++ b/Scheduler/settings.py @@ -0,0 +1,140 @@ +""" +Django settings for Scheduler project. + +Generated by 'django-admin startproject' using Django 3.1.6. + +For more information on this file, see +https://docs.djangoproject.com/en/3.1/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/3.1/ref/settings/ +""" + +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'fbh*0obn+o395@(g4)=^lh)ev+04mo6y3nlvp(kntr)k+p-qw#' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = ['localhost', ] + + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'rest_framework', + 'rest_framework_simplejwt', + 'users', + 'tasks', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'Scheduler.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'Scheduler.wsgi.application' +AUTH_USER_MODEL = 'users.Member' + +# Database +# https://docs.djangoproject.com/en/3.1/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } +} + + +# Password validation +# https://docs.djangoproject.com/en/3.1/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + +# CELERY CONFIG +BROKER_URL = 'redis://localhost:6379' +CELERY_RESULT_BACKEND = 'redis://localhost:6379' +CELERY_ACCEPT_CONTENT = ['application/json'] +CELERY_TASK_SERIALIZER = 'json' +CELERY_RESULT_SERIALIZER = 'json' +CELERY_TIMEZONE = 'UTC' + +# EMAIL BACKEND CONFIG +EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +EMAIL_HOST = 'smtp.gmail.com' +EMAIL_USE_TLS = True +EMAIL_PORT = 587 +EMAIL_HOST_USER = 'yahyavimohammad@gmail.com' +EMAIL_HOST_PASSWORD = '' + + +# Internationalization +# https://docs.djangoproject.com/en/3.1/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/3.1/howto/static-files/ + +STATIC_URL = '/static/' diff --git a/Scheduler/urls.py b/Scheduler/urls.py new file mode 100644 index 00000000..7716621e --- /dev/null +++ b/Scheduler/urls.py @@ -0,0 +1,26 @@ +"""Scheduler URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/3.1/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, include + +from tasks.admin import user_site + +urlpatterns = [ + path('admin/', admin.site.urls), + path('user/', user_site.urls), + path('api/v1.0/auth/', include('authentication.urls')), + path('api/v1.0/task/', include('tasks.urls')), +] diff --git a/Scheduler/wsgi.py b/Scheduler/wsgi.py new file mode 100644 index 00000000..6359a75d --- /dev/null +++ b/Scheduler/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for Scheduler project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/3.1/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'Scheduler.settings') + +application = get_wsgi_application() diff --git a/Scheduler_APIs.json b/Scheduler_APIs.json new file mode 100644 index 00000000..0ee8143a --- /dev/null +++ b/Scheduler_APIs.json @@ -0,0 +1,142 @@ +{ + "info": { + "_postman_id": "b74d1e88-46ee-4e49-9555-57c5fb9f9283", + "name": "New Collection", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "item": [ + { + "name": "Registeration", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"username\" : \"test_username\",\r\n \"password\" : \"test_password\",\r\n \"first_name\" : \"test_first_name\",\r\n \"last_name\" : \"test_last_name\",\r\n \"email\" : \"test@email.com\",\r\n \"is_staff\" : false\r\n}" + }, + "url": { + "raw": "localhost:8000/api/v1.0/auth/register/", + "host": [ + "localhost" + ], + "port": "8000", + "path": [ + "api", + "v1.0", + "auth", + "register", + "" + ] + }, + "description": "Sign up to the app(for anonymous users. You can't set is_staff to true in this case).\nCreate users(is_staff=false) and admins(is_staff=true)(For superusers only)." + }, + "response": [] + }, + { + "name": "Login", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"username\" : \"test_username\",\r\n \"password\" : \"test_password\"\r\n}" + }, + "url": { + "raw": "localhost:8000/api/v1.0/auth/login/", + "host": [ + "localhost" + ], + "port": "8000", + "path": [ + "api", + "v1.0", + "auth", + "login", + "" + ] + }, + "description": "Login with your credentials(open to everyone)." + }, + "response": [] + }, + { + "name": "Refresh Token", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"refresh\": \"refresh\"\r\n}" + }, + "url": { + "raw": "localhost:8000/api/v1.0/auth/token/refresh/", + "host": [ + "localhost" + ], + "port": "8000", + "path": [ + "api", + "v1.0", + "auth", + "token", + "refresh", + "" + ] + }, + "description": "Request for a new access token if your old one is expired." + }, + "response": [] + }, + { + "name": "Get a List of Tasks", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "Auth", + "value": "Bearer Token", + "type": "text" + } + ], + "url": { + "raw": "localhost:8000/api/v1.0/task/", + "host": [ + "localhost" + ], + "port": "8000", + "path": [ + "api", + "v1.0", + "task", + "" + ] + }, + "description": "Get a list of all the tasks(if you are an admin) or just your own tasks(if you are a normal user)." + }, + "response": [] + } + ] +} \ No newline at end of file diff --git a/authentication/__init__.py b/authentication/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/authentication/admin.py b/authentication/admin.py new file mode 100644 index 00000000..ea5d68b7 --- /dev/null +++ b/authentication/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/authentication/apps.py b/authentication/apps.py new file mode 100644 index 00000000..b63aae72 --- /dev/null +++ b/authentication/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class AuthenticationConfig(AppConfig): + name = 'authentication' diff --git a/authentication/migrations/__init__.py b/authentication/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/authentication/models.py b/authentication/models.py new file mode 100644 index 00000000..fd18c6ea --- /dev/null +++ b/authentication/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/authentication/serializers.py b/authentication/serializers.py new file mode 100644 index 00000000..7f609afa --- /dev/null +++ b/authentication/serializers.py @@ -0,0 +1,24 @@ +from django.contrib.auth import get_user_model + +from rest_framework import serializers + +Member = get_user_model() + + +class RegisterSerializer(serializers.ModelSerializer): + class Meta: + model = Member + fields = ('id', 'username', 'password', 'first_name', 'last_name', 'email', 'is_staff') + extra_kwargs = { + 'password': {'write_only': True}, + } + + def create(self, validated_data): + member = Member.objects.create_user(username=validated_data['username'], + password=validated_data['password'], + first_name=validated_data['first_name'], + last_name=validated_data['last_name'], + email=validated_data['email'], + is_staff=validated_data['is_staff']) + member.set_permissions() + return member diff --git a/authentication/tests/__init__.py b/authentication/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/authentication/tests/test_authentication.py b/authentication/tests/test_authentication.py new file mode 100644 index 00000000..60433ef1 --- /dev/null +++ b/authentication/tests/test_authentication.py @@ -0,0 +1,53 @@ +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from users.models import Member + + +class AuthenticationTest(TestCase): + def setUp(self): + self.email = 'authTest@example.com' + self.username = 'authTest_username' + self.password = 'authTest_password' + self.first_name = 'authTest_first_name' + self.last_name = 'authTest_last_name' + self.member = Member.objects.create_user(email=self.email, + username=self.username, + password=self.password, + first_name=self.first_name, + last_name=self.last_name) + self.data = { + 'username': self.username, + 'password': self.password + } + + def test_login(self): + client = APIClient() + response = client.post('/api/v1.0/auth/login/', self.data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_login_wrong_username(self): + self.data['username'] = 'wrong' + client = APIClient() + response = client.post('/api/v1.0/auth/login/', self.data, format='json') + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_login_wrong_password(self): + self.data['password'] = 'wrong' + client = APIClient() + response = client.post('/api/v1.0/auth/login/', self.data, format='json') + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_refresh_token(self): + client = APIClient() + response1 = client.post('/api/v1.0/auth/login/', self.data, format='json') + refresh = {"refresh": str(response1.data['refresh'])} + response = client.post('/api/v1.0/auth/token/refresh/', refresh, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_refresh_token_wrong(self): + client = APIClient() + refresh = {"refresh": "wrong"} + response = client.post('/api/v1.0/auth/token/refresh/', refresh, format='json') + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) diff --git a/authentication/urls.py b/authentication/urls.py new file mode 100644 index 00000000..681fe2c7 --- /dev/null +++ b/authentication/urls.py @@ -0,0 +1,10 @@ +from django.urls import path +from rest_framework_simplejwt import views as jwt_views +from . import views + + +urlpatterns = [ + path('register/', views.RegisterView.as_view()), + path('login/', jwt_views.TokenObtainPairView.as_view()), + path('token/refresh/', jwt_views.TokenRefreshView.as_view()), +] \ No newline at end of file diff --git a/authentication/views.py b/authentication/views.py new file mode 100644 index 00000000..1ba51074 --- /dev/null +++ b/authentication/views.py @@ -0,0 +1,31 @@ +from rest_framework import generics, status +from rest_framework.permissions import AllowAny +from rest_framework.response import Response + +from . import serializers as auth_serializers + + +class RegisterView(generics.CreateAPIView): + """ + Register a new member with the user data + """ + permission_classes = (AllowAny,) + serializer_class = auth_serializers.RegisterSerializer + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + create_staff = False + if 'is_staff' in serializer.validated_data: + create_staff = serializer.validated_data['is_staff'] + else: + serializer.validated_data['is_staff'] = False + is_superuser = request.user.is_superuser + + if create_staff and not is_superuser: + return Response('Only superusers can create new staff members', + status=status.HTTP_401_UNAUTHORIZED) + + self.perform_create(serializer) + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) diff --git a/manage.py b/manage.py new file mode 100644 index 00000000..bd5b7031 --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'Scheduler.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..c354b172 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,23 @@ +amqp==5.0.5 +asgiref==3.3.1 +billiard==3.6.3.0 +celery==5.0.5 +click==7.1.2 +click-didyoumean==0.0.3 +click-plugins==1.1.1 +click-repl==0.1.6 +Django==3.1.6 +djangorestframework==3.12.2 +djangorestframework-simplejwt==4.6.0 +importlib-metadata==3.4.0 +kombu==5.0.2 +prompt-toolkit==3.0.16 +PyJWT==2.0.1 +pytz==2021.1 +redis==3.5.3 +six==1.15.0 +sqlparse==0.4.1 +typing-extensions==3.7.4.3 +vine==5.0.0 +wcwidth==0.2.5 +zipp==3.4.0 diff --git a/static/tasks1.png b/static/tasks1.png new file mode 100644 index 00000000..f87e7fd3 Binary files /dev/null and b/static/tasks1.png differ diff --git a/static/tasks2.png b/static/tasks2.png new file mode 100644 index 00000000..81b1ee94 Binary files /dev/null and b/static/tasks2.png differ diff --git a/tasks/__init__.py b/tasks/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tasks/admin.py b/tasks/admin.py new file mode 100644 index 00000000..a2a2ed94 --- /dev/null +++ b/tasks/admin.py @@ -0,0 +1,99 @@ +from django.contrib import admin +from django.contrib.admin.sites import AdminSite +from django.contrib.auth.forms import AuthenticationForm +from django.core.exceptions import ValidationError +from django.urls import reverse +from django.utils.html import format_html +from django.utils.http import urlencode +from django.utils.translation import gettext_lazy as _ + +from users.models import Member +from .models import Task +from .tasks import send_scheduled_mail + + +@admin.register(Task) +class TaskAdmin(admin.ModelAdmin): + list_display = ('title', 'description', 'view_owner_link', 'time_to_send') + ordering = ('time_to_send', 'title') + search_fields = ('title', 'description', 'owner') + list_filter = ('owner', 'time_to_send') + list_per_page = 20 + + def view_owner_link(self, obj): + url = ( + reverse('admin:users_member_change', args=[obj.owner_id]) + + '?' + + urlencode({'tasks__id': f'{obj.id}'}) + ) + return format_html('{}', url, obj.owner) + + view_owner_link.short_description = 'Owner' + + def save_model(self, request, obj, form, change): + time_to_send = obj.time_to_send + email = obj.owner.email + send_scheduled_mail.apply_async(args=[email], eta=time_to_send) + super().save_model(request, obj, form, change) + + def render_change_form(self, request, context, *args, **kwargs): + context['adminform'].form.fields['owner'].queryset = Member.objects.filter(is_staff=False) + return super(TaskAdmin, self).render_change_form(request, context, *args, **kwargs) + + +# django admin interface authentication for non-staff members +# for the new instance of AdminSite +class UserAuthenticationForm(AuthenticationForm): + error_messages = { + **AuthenticationForm.error_messages, + 'invalid_login': _( + "Please enter the correct %(username)s and password for a user " + "account. Note that both fields may be case-sensitive." + ), + } + required_css_class = 'required' + + def confirm_login_allowed(self, user): + super().confirm_login_allowed(user) + if user.is_staff: + raise ValidationError( + self.error_messages['invalid_login'], + code='invalid_login', + params={'username': self.username_field.verbose_name} + ) + + +# New instance of AdminSite for non-staff users +class UserSite(AdminSite): + login_form = UserAuthenticationForm + + def has_permission(self, request): + return request.user.is_active and not request.user.is_staff + + +user_site = UserSite(name='user_interface') + + +class TaskUser(admin.ModelAdmin): + list_display = ('title', 'description', 'owner', 'time_to_send') + ordering = ('time_to_send', 'title') + search_fields = ('title', 'description', 'owner') + list_filter = ('time_to_send',) + list_per_page = 20 + + def get_queryset(self, request): + qs = super(TaskUser, self).get_queryset(request) + return qs.filter(owner=request.user) + + def save_model(self, request, obj, form, change): + obj.owner = request.user + time_to_send = obj.time_to_send + email = obj.owner.email + send_scheduled_mail.apply_async(args=[email], eta=time_to_send) + super().save_model(request, obj, form, change) + + def get_readonly_fields(self, request, obj=None): + return ['owner', ] + + +user_site.register(Task, TaskUser) diff --git a/tasks/apps.py b/tasks/apps.py new file mode 100644 index 00000000..00e3968c --- /dev/null +++ b/tasks/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class TasksConfig(AppConfig): + name = 'tasks' diff --git a/tasks/migrations/0001_initial.py b/tasks/migrations/0001_initial.py new file mode 100644 index 00000000..21326927 --- /dev/null +++ b/tasks/migrations/0001_initial.py @@ -0,0 +1,27 @@ +# Generated by Django 3.1.6 on 2021-02-16 17:47 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Task', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=150)), + ('description', models.CharField(blank=True, max_length=300)), + ('time_to_send', models.DateTimeField()), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='Tasks', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/tasks/migrations/__init__.py b/tasks/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tasks/models.py b/tasks/models.py new file mode 100644 index 00000000..3b5339c7 --- /dev/null +++ b/tasks/models.py @@ -0,0 +1,14 @@ +from django.db import models + + +from django.db import models + + +class Task(models.Model): + title = models.CharField(max_length=150, blank=False) + description = models.CharField(max_length=300, blank=True) + owner = models.ForeignKey('users.Member', on_delete=models.CASCADE, related_name='Tasks') + time_to_send = models.DateTimeField(blank=False) + + def __str__(self): + return self.title diff --git a/tasks/serializers.py b/tasks/serializers.py new file mode 100644 index 00000000..293ccccb --- /dev/null +++ b/tasks/serializers.py @@ -0,0 +1,16 @@ +from rest_framework import serializers + +from .models import Task +from users.serializers import MemberSerializer + + +class TaskSerializer(serializers.ModelSerializer): + owner = MemberSerializer(read_only=True) + + class Meta: + model = Task + fields = '__all__' + extra_kwargs = { + 'owner': {'read_only': True}, + } + diff --git a/tasks/tasks.py b/tasks/tasks.py new file mode 100644 index 00000000..cfbef410 --- /dev/null +++ b/tasks/tasks.py @@ -0,0 +1,17 @@ +from celery import shared_task +from celery.utils.log import get_task_logger + +from django.core.mail import send_mail + +from Scheduler.settings import EMAIL_HOST_USER + +logger = get_task_logger(__name__) + + +@shared_task(name="send_scheduled_mail") +def send_scheduled_mail(email): + + logger.info("Sent scheduled email") + message = "This message was scheduled to be sent to you automatically by " \ + "the django scheduler" + return send_mail('reminder', message, EMAIL_HOST_USER, [email], fail_silently=False) diff --git a/tasks/tests.py b/tasks/tests.py new file mode 100644 index 00000000..de8bdc00 --- /dev/null +++ b/tasks/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/tasks/urls.py b/tasks/urls.py new file mode 100644 index 00000000..eb48d1ca --- /dev/null +++ b/tasks/urls.py @@ -0,0 +1,7 @@ +from django.urls import path +from .views import TaskListView + + +urlpatterns = [ + path('', TaskListView.as_view()), +] diff --git a/tasks/views.py b/tasks/views.py new file mode 100644 index 00000000..d5af97bd --- /dev/null +++ b/tasks/views.py @@ -0,0 +1,17 @@ +from rest_framework import generics +from rest_framework.permissions import IsAuthenticated + +from .models import Task +from .serializers import TaskSerializer + + +class TaskListView(generics.ListAPIView): + permission_classes = (IsAuthenticated,) + serializer_class = TaskSerializer + queryset = Task.objects.all() + + def get_queryset(self): + user = self.request.user + qs = Task.objects.all() + if not user.is_staff: + qs = Task.objects.filter(owner=user) diff --git a/users/__init__.py b/users/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/users/admin.py b/users/admin.py new file mode 100644 index 00000000..ab8bb3c5 --- /dev/null +++ b/users/admin.py @@ -0,0 +1,57 @@ +from django.contrib.auth.admin import UserAdmin, UserCreationForm +from django.contrib import admin + +from users.models import Member + + +class CustomUserCreationForm(UserCreationForm): + class Meta: + model = Member + fields = ("username", "email", "first_name", "last_name", "is_staff", ) + + +@admin.register(Member) +class MemberAdmin(UserAdmin): + list_display = ('first_name', 'last_name', 'email', 'is_staff') + ordering = ('last_name', 'first_name') + search_fields = ('first_name', 'last_name', 'email') + add_fieldsets = ( + (None, { + 'classes': ('wide',), + 'fields': ('username', 'password1', 'password2', 'email', 'first_name', 'last_name', 'is_staff'), + }), + ) + add_form = CustomUserCreationForm + list_per_page = 20 + + # Staffs which are not superusers can't view and edit other staffs + def get_queryset(self, request): + qs = super(MemberAdmin, self).get_queryset(request) + if request.user.is_superuser: + return qs + return qs.filter(is_staff=False) + + def get_form(self, request, obj=None, **kwargs): + defaults = {} + if obj is None: + defaults['form'] = self.add_form + defaults.update(kwargs) + form = super().get_form(request, obj, **defaults) + is_superuser = request.user.is_superuser + disabled_fields = set() + + # Prevent non-super_users to add another super_user or admin + # and to change user permissions and groups + if not is_superuser: + disabled_fields |= { + 'is_staff', + 'is_superuser', + 'groups', + 'user_permissions', + } + + for f in disabled_fields: + if f in form.base_fields: + form.base_fields[f].disabled = True + + return form diff --git a/users/apps.py b/users/apps.py new file mode 100644 index 00000000..24442200 --- /dev/null +++ b/users/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class UsersConfig(AppConfig): + name = 'users' diff --git a/users/migrations/0001_initial.py b/users/migrations/0001_initial.py new file mode 100644 index 00000000..c3ce14ba --- /dev/null +++ b/users/migrations/0001_initial.py @@ -0,0 +1,44 @@ +# Generated by Django 3.1.6 on 2021-02-14 19:32 + +import django.contrib.auth.models +import django.contrib.auth.validators +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='Member', + 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')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('first_name', models.CharField(max_length=150)), + ('last_name', models.CharField(max_length=150)), + ('email', models.EmailField(max_length=254, unique=True)), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + 'abstract': False, + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + ] diff --git a/users/migrations/__init__.py b/users/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/users/models.py b/users/models.py new file mode 100644 index 00000000..6ac79f0b --- /dev/null +++ b/users/models.py @@ -0,0 +1,24 @@ +from django.contrib.auth.models import AbstractUser, Permission +from django.db import models + + +class Member(AbstractUser): + first_name = models.CharField(max_length=150, blank=False) + last_name = models.CharField(max_length=150, blank=False) + email = models.EmailField(blank=False, unique=True) + + def __str__(self): + return f"{self.last_name}, {self.first_name}" + + def set_permissions(self): + if self.is_staff: + permission_handles = ['Can add user', 'Can change user', 'Can delete user', 'Can view user' + 'Can add task', 'Can change task', 'Can view task'] + for p in permission_handles: + permission = Permission.objects.get(name=p) + self.user_permissions.add(permission) + else: + permission_handles = ['Can add task', 'Can view task'] + for p in permission_handles: + permission = Permission.objects.get(name=p) + self.user_permissions.add(permission) diff --git a/users/serializers.py b/users/serializers.py new file mode 100644 index 00000000..b136d03b --- /dev/null +++ b/users/serializers.py @@ -0,0 +1,9 @@ +from rest_framework import serializers + +from .models import Member + + +class MemberSerializer(serializers.ModelSerializer): + class Meta: + model = Member + fields = ('first_name', 'last_name', 'email') diff --git a/users/tests.py b/users/tests.py new file mode 100644 index 00000000..de8bdc00 --- /dev/null +++ b/users/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/users/views.py b/users/views.py new file mode 100644 index 00000000..c60c7904 --- /dev/null +++ b/users/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here.