diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1cfbf63 --- /dev/null +++ b/.gitignore @@ -0,0 +1,281 @@ +# Created by https://www.toptal.com/developers/gitignore/api/python,visualstudiocode,django +# Edit at https://www.toptal.com/developers/gitignore?templates=python,visualstudiocode,django + +### 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/ +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 +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo + +# Django stuff: + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .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 + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +### 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 + +# Django stuff: + +# Flask stuff: + +# Scrapy stuff: + +# Sphinx documentation + +# PyBuilder + +# Jupyter Notebook + +# IPython + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .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. + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm + +# Celery stuff + +# SageMath parsed files + +# Environments + +# Spyder project settings + +# Rope project settings + +# mkdocs documentation + +# mypy + +# Pyre type checker + +# pytype static type analyzer + +# Cython debug symbols + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. + +### VisualStudioCode ### +.vscode/* +.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +# Support for Project snippet scope +.vscode/*.code-snippets + +# Ignore code-workspaces +*.code-workspace + +# End of https://www.toptal.com/developers/gitignore/api/python,visualstudiocode,django diff --git a/README.md b/README.md index 1fb5bf9..33f726f 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,23 @@ -# In name of Allah +# Documentation + +You can check full documentation with postman from [here](https://documenter.getpostman.com/view/17658108/2s83zgu4om) ## Introduction -We want to develop a REST based django application to read signed in user's KuCoin open positions for them. For this to happen, you must be able to read user's positions (if requested from user) every 30 seconds. - -The application you develop must have these features: -- Users should be able to **sign up** (provide name, username & kucoin details). -- Users should be able to **sign in**. -- Users should be able to **request position tracking**. -- Users should be able to **see list** of current open positions. -- Application should be able to track user's positions every 30 seconds. -- Application should be able to handle multi users. -- Application must be able to resume its job after restart. - -You can use [this api](https://docs.kucoin.com/#list-accounts) from KuCoin to handle this. You should create KuCoin account if you do not have already & get api key & secret to test your implementation. Note we do not need your api key, that is only for your own usage. - -### Note -- We do **NOT want any kind of UI** from you -- KuCoin api key & secrets should not be stored in raw format - - -## Expectations: -We want a clean, readable and maintainable code with meaningful comments and docstrings. Also you need to provide postman API doc for web app. - -## Task -1. Fork this repository -2. Develop the challenge with Django 3 or higher -3. Push your code to your repository -4. Send us a pull request, we will review and get back to you -5. Enjoy + +This project is a REST based django application to read signed in user's KuCoin open positions for them. For this to happen, application able to read user's positions (if requested from user) every 30 seconds. + +Application have these features: + +- Users able to **sign up** (provide name, username & kucoin details). +- Users able to **sign in** with jwt authentication. +- Users able to **request position tracking** with send request based on **track** field ("true" or "false"). +- Users able to **see list** of current open (active) positions. +- Application able to track user's positions every 30 seconds with celery periodic task. +- Application able to handle multi users with celery task & redis. +- Application able to resume its job after restart with redis backend and database. + +Application use [this api](https://docs.kucoin.com/#list-accounts) from KuCoin to handle KuCoin positions. + +# Note + +**Attention!** All settings such as SECRET_KEY, DEBUG, ENCRYPT_KEY and etc have been exhibited because this is a test project, please keep those in production secret. 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..4089163 --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,23 @@ +from django.contrib import admin +from .models import User +from django.contrib.auth.admin import UserAdmin as DefaultUserAdmin + + +@admin.register(User) +class UserAdmin(DefaultUserAdmin): + ''' + Change field of default user admin page + ''' + + fieldsets = ( + (None, {'fields': ('username', 'password')}), + ('Personal info', + { + 'fields': + ('name', 'kucoin_key', 'kucoin_secret', 'kucoin_passphrase')}), + ('Permissions', {'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions'), }), + ('Important dates', {'fields': ('last_login', 'date_joined')}),) + + list_filter = ('is_staff', 'is_superuser', 'is_active', 'groups') + list_display = ('username', 'name', 'is_staff', 'is_active') + list_editable = ('is_staff', 'is_active') diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..3e3c765 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'accounts' diff --git a/accounts/encryption.py b/accounts/encryption.py new file mode 100644 index 0000000..1e5e08c --- /dev/null +++ b/accounts/encryption.py @@ -0,0 +1,32 @@ +from cryptography.fernet import Fernet +import base64 +from django.conf import settings + + +def encrypt(txt) -> str: + ''' + Encrypt data + ''' + + try: + txt = str(txt) + cipher_suite = Fernet(settings.ENCRYPT_KEY) + encrypted_text = cipher_suite.encrypt(txt.encode('ascii')) + encrypted_text = base64.urlsafe_b64encode(encrypted_text).decode("ascii") + return encrypted_text + except Exception: + return None + + +def decrypt(txt) -> str: + ''' + Decrypt data + ''' + + try: + txt = base64.urlsafe_b64decode(txt) + cipher_suite = Fernet(settings.ENCRYPT_KEY) + decoded_text = cipher_suite.decrypt(txt).decode("ascii") + return decoded_text + except Exception: + return None diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..9e2f495 --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,44 @@ +# Generated by Django 4.1.2 on 2022-10-06 14:59 + +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='User', + fields=[ + ('id', models.BigAutoField(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')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), + ('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')), + ('name', models.CharField(max_length=150, verbose_name='name')), + ('kucoin_key', models.CharField(max_length=200)), + ('kucoin_secret', models.CharField(max_length=200)), + ('kucoin_passphrase', models.CharField(max_length=200)), + ('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, + }, + ), + ] 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..8e5fab8 --- /dev/null +++ b/accounts/models.py @@ -0,0 +1,72 @@ +from django.db import models +from django.contrib.auth.models import AbstractUser +from django.utils.translation import gettext_lazy as _ +from django.contrib.auth.base_user import BaseUserManager +from django.utils import timezone +from django.core.signing import Signer +from .encryption import encrypt, decrypt + +signer = Signer() + + +class UserManager(BaseUserManager): + ''' + UserManager to create user and encrypts kucoin details and save to the database. + ''' + + def encryption(self, key, secret, passphrase) -> tuple: + ''' + Encrypt key, secret & passphrase + ''' + + return encrypt(key), encrypt(secret), encrypt(passphrase) + + def create_user(self, name, username, kucoin_key, kucoin_secret, kucoin_passphrase, password=None): + + now = timezone.now() + + kucoin_key, kucoin_secret, kucoin_passphrase = self.encryption(kucoin_key, kucoin_secret, kucoin_passphrase) + + user = self.model( + name=name, username=username, kucoin_key=kucoin_key, kucoin_secret=kucoin_secret, + kucoin_passphrase=kucoin_passphrase, is_staff=False, is_active=True, is_superuser=False, date_joined=now, + last_login=now,) + user.set_password(password) + user.save(using=self._db) + + return user + + def create_superuser(self, name, username, password=None): + user = self.model(name=name, username=username) + user.is_staff = True + user.is_active = True + user.is_superuser = True + user.set_password(password) + user.save(using=self._db) + + return user + + +class User(AbstractUser): + ''' + User model with name, username & kucoin details + ''' + + name = models.CharField(_("name"), max_length=150) + kucoin_key = models.CharField(max_length=200) + kucoin_secret = models.CharField(max_length=200) + kucoin_passphrase = models.CharField(max_length=200) + + objects = UserManager() + + REQUIRED_FIELDS = ['name'] + + def __str__(self) -> str: + return self.username + + def decryption(self, key, secret, passphrase) -> tuple: + ''' + Decrypt kucoin details of the user + ''' + + return decrypt(key), decrypt(secret), decrypt(passphrase) diff --git a/accounts/serializers.py b/accounts/serializers.py new file mode 100644 index 0000000..6118f30 --- /dev/null +++ b/accounts/serializers.py @@ -0,0 +1,35 @@ +from rest_framework import serializers +from .models import User + + +class RegisterSerializer(serializers.ModelSerializer): + ''' + User serializers for register + ''' + + confirm_password = serializers.CharField(write_only=True, required=True) + + class Meta: + model = User + fields = ("username", "name", "password", "confirm_password", + "kucoin_key", "kucoin_secret", "kucoin_passphrase") + extra_kwargs = { + 'kucoin_key': {'write_only': True, 'required': True}, + 'kucoin_secret': {'write_only': True, 'required': True}, + 'kucoin_passphrase': {'write_only': True, 'required': True}, + } + + def validate(self, attrs) -> dict: + ''' + Checking password & other fields + ''' + + if attrs['password'] != attrs['confirm_password']: + raise serializers.ValidationError({"password": "Password fields didn't match."}) + + return super().validate(attrs) + + def create(self, validated_data) -> User: + validated_data.pop('confirm_password') + + return User.objects.create_user(**validated_data) 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/urls.py b/accounts/urls.py new file mode 100644 index 0000000..abcc6b3 --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,16 @@ +from django.urls import path +from .views import Register +from rest_framework_simplejwt.views import ( + TokenRefreshView, + TokenObtainPairView, +) + +app_name = "accounts" + +urlpatterns = [ + path('register/', Register.as_view(), name="register"), # register user + + # JWTAuthentication + path('login/', TokenObtainPairView.as_view(), name='token_obtain_pair'), # login user with jwt + path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), # refresh token in jwt +] diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..59c3c57 --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,20 @@ +from rest_framework.generics import CreateAPIView +from .serializers import RegisterSerializer +from rest_framework.permissions import AllowAny +from rest_framework.response import Response + + +class Register(CreateAPIView): + ''' + Signup users with given fields. + ''' + + serializer_class = RegisterSerializer + permission_classes = (AllowAny,) + + def create(self, request) -> Response: + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + serializer.save() + + return Response({"message": "Account created successfully."}, status=201) diff --git a/crypto_reader/__init__.py b/crypto_reader/__init__.py new file mode 100644 index 0000000..15d7c50 --- /dev/null +++ b/crypto_reader/__init__.py @@ -0,0 +1,5 @@ +# 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 + +__all__ = ('celery_app',) diff --git a/crypto_reader/asgi.py b/crypto_reader/asgi.py new file mode 100644 index 0000000..f13fb13 --- /dev/null +++ b/crypto_reader/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for crypto_reader 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/4.1/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'crypto_reader.settings') + +application = get_asgi_application() diff --git a/crypto_reader/celery.py b/crypto_reader/celery.py new file mode 100644 index 0000000..24f8470 --- /dev/null +++ b/crypto_reader/celery.py @@ -0,0 +1,22 @@ +import os + +from celery import Celery + +# Set the default Django settings module for the 'celery' program. +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'crypto_reader.settings') + +app = Celery('crypto_reader') + +# Using a string here means the worker doesn't have to serialize +# the configuration object to child processes. +# - namespace='CELERY' means all celery-related configuration keys +# should have a `CELERY_` prefix. +app.config_from_object('django.conf:settings', namespace='CELERY') + +# Load task modules from all registered Django apps. +app.autodiscover_tasks() + + +@app.task(bind=True, ignore_result=True) +def debug_task(self): + print(f'Request: {self.request!r}') diff --git a/crypto_reader/settings.py b/crypto_reader/settings.py new file mode 100644 index 0000000..13eb397 --- /dev/null +++ b/crypto_reader/settings.py @@ -0,0 +1,156 @@ +""" +Django settings for crypto_reader project. + +Generated by 'django-admin startproject' using Django 4.1.2. + +For more information on this file, see +https://docs.djangoproject.com/en/4.1/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/4.1/ref/settings/ +""" + +import os +from pathlib import Path +from datetime import timedelta + +# 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/4.1/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'django-insecure-r1j8m$^@^n9iyf9rfouhh0cv6pf^6$p=d-6ng1039elb6^a_p=' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +# SECURITY WARNING: keep the encrypt key used in production secret! +ENCRYPT_KEY = b'7lXZs9UHMEQzmjRABWyTcEV6aUuy8BvxWj5Ee7yGOgI=' + +ALLOWED_HOSTS = [] + +AUTH_USER_MODEL = "accounts.User" + +# 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', + 'rest_framework_simplejwt.token_blacklist', + 'django_celery_beat', + 'accounts', + 'kucoin', +] + +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework_simplejwt.authentication.JWTAuthentication', + ], +} + +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(hours=1), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=7), + 'ROTATE_REFRESH_TOKENS': True, + 'BLACKLIST_AFTER_ROTATION': True, +} + +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 = 'crypto_reader.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 = 'crypto_reader.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/4.1/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } +} + + +# Password validation +# https://docs.djangoproject.com/en/4.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', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/4.1/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/4.1/howto/static-files/ + +STATIC_URL = 'static/' + +STATIC_ROOT = os.path.join(BASE_DIR, 'static/') + +# Default primary key field type +# https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +# celery settings +# SECURITY WARNING: don't use raw password in production! +CELERY_BROKER_URL = 'redis://default:blR9KZ39lE6cBf8JraZsVtbpU1Bruh3U@redis-18003.c275.us-east-1-4.ec2.cloud.redislabs.com:18003' +CELERY_RESULT_BACKEND = 'redis://default:blR9KZ39lE6cBf8JraZsVtbpU1Bruh3U@redis-18003.c275.us-east-1-4.ec2.cloud.redislabs.com:18003' diff --git a/crypto_reader/urls.py b/crypto_reader/urls.py new file mode 100644 index 0000000..d5bb9d4 --- /dev/null +++ b/crypto_reader/urls.py @@ -0,0 +1,23 @@ +"""crypto_reader URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/4.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 + +urlpatterns = [ + path('admin/', admin.site.urls), # admin page + path('accounts/', include('accounts.urls')), # accounts app + path('kucoin/', include('kucoin.urls')), # kucoin app +] diff --git a/crypto_reader/wsgi.py b/crypto_reader/wsgi.py new file mode 100644 index 0000000..eab201b --- /dev/null +++ b/crypto_reader/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for crypto_reader 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/4.1/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'crypto_reader.settings') + +application = get_wsgi_application() diff --git a/kucoin/__init__.py b/kucoin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kucoin/admin.py b/kucoin/admin.py new file mode 100644 index 0000000..55e8b07 --- /dev/null +++ b/kucoin/admin.py @@ -0,0 +1,13 @@ +from django.contrib import admin +from .models import Order + + +@admin.register(Order) +class OrderAdmin(admin.ModelAdmin): + ''' + Register and change Order admin page + ''' + + list_filter = ('isActive',) + list_display = ('user', 'clientOid', 'isActive') + list_editable = ('isActive',) diff --git a/kucoin/apps.py b/kucoin/apps.py new file mode 100644 index 0000000..97a5812 --- /dev/null +++ b/kucoin/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class KucoinConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'kucoin' diff --git a/kucoin/migrations/0001_initial.py b/kucoin/migrations/0001_initial.py new file mode 100644 index 0000000..262efb0 --- /dev/null +++ b/kucoin/migrations/0001_initial.py @@ -0,0 +1,32 @@ +# Generated by Django 4.0.8 on 2022-10-07 11:46 + +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='Order', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('clientOid', models.CharField(max_length=200)), + ('side', models.CharField(max_length=200)), + ('symbol', models.CharField(max_length=200)), + ('type', models.CharField(max_length=200)), + ('remark', models.CharField(max_length=200)), + ('stp', models.CharField(max_length=200)), + ('tradeType', models.CharField(max_length=200)), + ('isActive', models.BooleanField()), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/kucoin/migrations/__init__.py b/kucoin/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kucoin/models.py b/kucoin/models.py new file mode 100644 index 0000000..ce22d4e --- /dev/null +++ b/kucoin/models.py @@ -0,0 +1,18 @@ +from django.db import models +from accounts.models import User + + +class Order(models.Model): + ''' + save kucoin orders as objects of Order model + ''' + + user = models.ForeignKey(User, on_delete=models.CASCADE) + clientOid = models.CharField(max_length=200) + side = models.CharField(max_length=200) + symbol = models.CharField(max_length=200) + type = models.CharField(max_length=200) + remark = models.CharField(max_length=200) + stp = models.CharField(max_length=200) + tradeType = models.CharField(max_length=200) + isActive = models.BooleanField() diff --git a/kucoin/serializers.py b/kucoin/serializers.py new file mode 100644 index 0000000..03463cb --- /dev/null +++ b/kucoin/serializers.py @@ -0,0 +1,20 @@ +from rest_framework import serializers +from kucoin.models import Order + + +class OrderSerializers(serializers.ModelSerializer): + ''' + Serializer for order model with all fields + ''' + + class Meta: + model = Order + fields = '__all__' + + +class TrackSerializers(serializers.Serializer): + ''' + track boolean field + ''' + + track = serializers.BooleanField() diff --git a/kucoin/tasks.py b/kucoin/tasks.py new file mode 100644 index 0000000..fd37a49 --- /dev/null +++ b/kucoin/tasks.py @@ -0,0 +1,13 @@ +from .utils import update_orders +from accounts.models import User +from crypto_reader.celery import app + + +@app.task(name='kucoin.tasks.tracking_position_per_user') +def tracking(user_pk): + ''' + Tracking position of each user every 30 seconds + ''' + + user = User.objects.get(pk=user_pk) + update_orders(user) diff --git a/kucoin/tests.py b/kucoin/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/kucoin/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/kucoin/urls.py b/kucoin/urls.py new file mode 100644 index 0000000..8d4b2ca --- /dev/null +++ b/kucoin/urls.py @@ -0,0 +1,11 @@ +from django.urls import path +from .views import OpenPositions, TrackPositions + +app_name = "kucoin" + +urlpatterns = [ + path('open_positions/', OpenPositions.as_view(), name='open_positions'), # see list of current open positions + + # enable or disable tracking position & see list of tracking positions + path('tracking_positions/', TrackPositions.as_view(), name='tracking_positions'), +] diff --git a/kucoin/utils.py b/kucoin/utils.py new file mode 100644 index 0000000..94ced94 --- /dev/null +++ b/kucoin/utils.py @@ -0,0 +1,88 @@ +import time +import base64 +import hashlib +import hmac +import time +import json +import requests +from accounts.encryption import decrypt +from django_celery_beat.models import PeriodicTask, IntervalSchedule +from .models import Order + + +def kucoin_api(key, secret, passphrase, endpoint) -> tuple: + ''' + Get data from kucoin with specefic header + ''' + + url = 'https://api.kucoin.com' + endpoint + + # decrypt data + key = decrypt(key) + secret = decrypt(secret) + passphrase = decrypt(passphrase) + + # create signature + now = int(time.time() * 1000) + str_to_sign = str(now) + 'GET' + endpoint + signature = base64.b64encode( + hmac.new(secret.encode('utf-8'), str_to_sign.encode('utf-8'), hashlib.sha256).digest()) + passphrase = base64.b64encode( + hmac.new(secret.encode('utf-8'), + passphrase.encode('utf-8'), + hashlib.sha256).digest()) + + # create headers for requests + headers = { + "KC-API-SIGN": signature, + "KC-API-TIMESTAMP": str(now), + "KC-API-KEY": key, + "KC-API-PASSPHRASE": passphrase, + "KC-API-KEY-VERSION": "2" + } + + response = requests.request('get', url, headers=headers) + return response.status_code, response.json() + + +def create_or_delete_celery_task(user, track) -> dict: + ''' + Create or delete celery task based on track field + ''' + + schedule, created = IntervalSchedule.objects.get_or_create(every=30, period=IntervalSchedule.SECONDS,) + + if track: + PeriodicTask.objects.get_or_create(interval=schedule, name=f"User({user.pk})", + task='kucoin.tasks.tracking_position_per_user', + args=json.dumps([f"{user.pk}"]),) + + return {'message': 'Tracking Enabled'} + + PeriodicTask.objects.filter(name=f"User({user.pk})").delete() + + return {'message': 'Tracking Disabled'} + + +def update_orders(user): + ''' + Update order objects for user. Request to kucoin api and get and save in database. + ''' + + # request to kucoin + status, response = kucoin_api(user.kucoin_key, user.kucoin_secret, + user.kucoin_passphrase, '/api/v1/orders') + + if response.get('code') == '200000': + items = response['data']['items'] + + # Save list as objects of Order model + for item in items: + Order.objects.update_or_create( + user=user, clientOid=item['clientOid'], + side=item['side'], + symbol=item['symbol'], + type=item['type'], + remark=item['remark'], + stp=item['stp'], + tradeType=item['tradeType'], isActive=item['isActive']) diff --git a/kucoin/views.py b/kucoin/views.py new file mode 100644 index 0000000..c48c054 --- /dev/null +++ b/kucoin/views.py @@ -0,0 +1,48 @@ +from rest_framework.generics import GenericAPIView +from kucoin.models import Order +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated +from .serializers import OrderSerializers, TrackSerializers +from .utils import create_or_delete_celery_task, update_orders + + +class OpenPositions(GenericAPIView): + ''' + Get and view open (active) positions. Orders save as objects in database with Order model and querying to get those. + ''' + + permission_classes = (IsAuthenticated,) + serializer_class = OrderSerializers + + def get(self, request) -> Response: + user = request.user + update_orders(user) + orders = Order.objects.filter(user=user, isActive=True) + data = OrderSerializers(orders, many=True).data or 'Empty' + + return Response(data, status=200) + + +class TrackPositions(GenericAPIView): + ''' + Enable or disable tracking positions for each user & see list of tracking positions. enable/disable tracking using create/delete celery task. Get list of tracking positions using querying Order model. + ''' + + permission_classes = (IsAuthenticated,) + serializer_class = TrackSerializers + + def post(self, request): + user = request.user + serializer = TrackSerializers(data=request.data) + serializer.is_valid(raise_exception=True) + track = serializer.data['track'] + data = create_or_delete_celery_task(user, track) + + return Response(data, status=200) + + def get(self, request) -> Response: + user = request.user + orders = Order.objects.filter(user=user) + data = OrderSerializers(orders, many=True).data or 'Empty' + + return Response(data, status=200) diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..9d66408 --- /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', 'crypto_reader.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 0000000..788485d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,50 @@ +amqp==5.1.1 +asgiref==3.5.2 +astroid==2.12.10 +async-timeout==4.0.2 +autopep8==1.7.0 +billiard==3.6.4.0 +celery==5.2.7 +certifi==2022.9.24 +cffi==1.15.1 +charset-normalizer==2.1.1 +click==8.1.3 +click-didyoumean==0.3.0 +click-plugins==1.1.1 +click-repl==0.2.0 +cryptography==38.0.1 +Deprecated==1.2.13 +dill==0.3.5.1 +Django==4.0.8 +django-celery-beat==2.3.0 +django-timezone-field==5.0 +djangorestframework==3.14.0 +djangorestframework-simplejwt==5.2.1 +idna==3.4 +isort==5.10.1 +kombu==5.2.4 +lazy-object-proxy==1.7.1 +mccabe==0.7.0 +packaging==21.3 +platformdirs==2.5.2 +prompt-toolkit==3.0.31 +pycodestyle==2.9.1 +pycparser==2.21 +PyJWT==2.5.0 +pylint==2.15.3 +pyparsing==3.0.9 +python-crontab==2.6.0 +python-dateutil==2.8.2 +pytz==2022.4 +redis==4.3.4 +requests==2.28.1 +six==1.16.0 +sqlparse==0.4.3 +toml==0.10.2 +tomli==2.0.1 +tomlkit==0.11.5 +tzdata==2022.4 +urllib3==1.26.12 +vine==5.0.0 +wcwidth==0.2.5 +wrapt==1.14.1 diff --git a/runner.sh b/runner.sh new file mode 100755 index 0000000..06dab73 --- /dev/null +++ b/runner.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +echo "Create and activated venv..." +pip install virtualenv +python3 -m virtualenv venv +if [ "$OSTYPE" == "linux-gnu" ]; then +source venv/bin/activate +elif [[ "$OSTYPE" == "cygwin" || "$OSTYPE" == "msys" ]]; then +.\venv\Scripts\activate.bat +fi +pip install --upgrade pip +pip install -r requirements.txt + +echo "Makemigrations and migrate..." +python3 manage.py makemigrations --noinput +python3 manage.py migrate --noinput + +echo "Collectstatic..." +python3 manage.py collectstatic --noinput + +echo "Start celery worker in background..." +celery -A crypto_reader worker --beat --scheduler django --loglevel=info & + +echo "Runserver" +python3 manage.py runserver