From 07c648c74d2460398f8b32d3bc1b273fa69ce2ef Mon Sep 17 00:00:00 2001 From: ikechukwu kalu Date: Wed, 26 Jun 2024 15:51:57 +0100 Subject: [PATCH 1/2] UPDATE[FINAL UPDATE TO BACKEND TEST ASSESSEMENT] --- .gitignore | 129 +++++++++++++ .travis.yml | 11 ++ LICENSE | 21 +++ Pipfile | 13 ++ Pipfile.lock | 80 ++++++++ README.md | 174 ++++++++++++++++++ app/.flake8 | 8 + app/.isort.cfg | 12 ++ app/.pylintrc | 47 +++++ app/app/__init__.py | 4 + app/app/asgi.py | 16 ++ app/app/base.py | 131 +++++++++++++ app/app/dev.py | 22 +++ app/app/prod.py | 35 ++++ app/app/urls.py | 23 +++ app/app/wsgi.py | 16 ++ app/core/__init__.py | 0 app/core/admin.py | 5 + app/core/apps.py | 6 + app/core/management/__init__.py | 0 .../management/commands/fetch_coin_data.py | 36 ++++ app/core/migrations/0001_initial.py | 73 ++++++++ app/core/migrations/0002_coin_favourite.py | 59 ++++++ .../0003_rename_name_user_username.py | 18 ++ .../migrations/0004_alter_user_password.py | 18 ++ app/core/migrations/__init__.py | 0 app/core/models.py | 54 ++++++ app/core/serializers.py | 30 +++ app/core/tests/__init__.py | 2 + app/core/tests/test_models.py | 36 ++++ app/core/tests/test_views.py | 171 +++++++++++++++++ app/core/urls.py | 10 + app/core/views.py | 145 +++++++++++++++ app/manage.py | 22 +++ app/mypy.ini | 9 + app/requirements.txt | 35 ++++ app/static_validation.sh | 28 +++ docker-compose.yml | 12 ++ dockerfile | 16 ++ requirements.txt | 12 ++ 40 files changed, 1539 insertions(+) create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 LICENSE create mode 100644 Pipfile create mode 100644 Pipfile.lock create mode 100644 README.md create mode 100644 app/.flake8 create mode 100644 app/.isort.cfg create mode 100644 app/.pylintrc create mode 100644 app/app/__init__.py create mode 100644 app/app/asgi.py create mode 100644 app/app/base.py create mode 100644 app/app/dev.py create mode 100644 app/app/prod.py create mode 100644 app/app/urls.py create mode 100644 app/app/wsgi.py create mode 100644 app/core/__init__.py create mode 100644 app/core/admin.py create mode 100644 app/core/apps.py create mode 100644 app/core/management/__init__.py create mode 100644 app/core/management/commands/fetch_coin_data.py create mode 100644 app/core/migrations/0001_initial.py create mode 100644 app/core/migrations/0002_coin_favourite.py create mode 100644 app/core/migrations/0003_rename_name_user_username.py create mode 100644 app/core/migrations/0004_alter_user_password.py create mode 100644 app/core/migrations/__init__.py create mode 100644 app/core/models.py create mode 100644 app/core/serializers.py create mode 100644 app/core/tests/__init__.py create mode 100644 app/core/tests/test_models.py create mode 100644 app/core/tests/test_views.py create mode 100644 app/core/urls.py create mode 100644 app/core/views.py create mode 100644 app/manage.py create mode 100644 app/mypy.ini create mode 100644 app/requirements.txt create mode 100644 app/static_validation.sh create mode 100644 docker-compose.yml create mode 100644 dockerfile create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b6e4761 --- /dev/null +++ b/.gitignore @@ -0,0 +1,129 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.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 +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# 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 + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__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/ diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..7b43c2a --- /dev/null +++ b/.travis.yml @@ -0,0 +1,11 @@ +language: python +python: + - "3.8" + +services: + - docker + +before_script: pip install docker-compose + +script: + - docker-compose run app sh -c "python manage.py test && flake8" \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4c296a8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Inyang Kpongette + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..8721901 --- /dev/null +++ b/Pipfile @@ -0,0 +1,13 @@ +[[source]] +url = "https://pypi.python.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +Django = "<4.0.2,>=3.0" +djangorestframework = "==3.13.1" + +[dev-packages] + +[requires] +python_version = "3.8" diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 0000000..01771ba --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,80 @@ +{ + "_meta": { + "hash": { + "sha256": "eeab2b7987a8980559d0199a88a80459ee6303d107ae6ff79ea8ad1a6ae8c02c" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.8" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.python.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "asgiref": { + "hashes": [ + "sha256:2f8abc20f7248433085eda803936d98992f1343ddb022065779f37c5da0181d0", + "sha256:88d59c13d634dcffe0510be048210188edd79aeccb6a6c9028cdad6f31d730a9" + ], + "version": "==3.5.0" + }, + "backports.zoneinfo": { + "hashes": [ + "sha256:17746bd546106fa389c51dbea67c8b7c8f0d14b5526a579ca6ccf5ed72c526cf", + "sha256:1b13e654a55cd45672cb54ed12148cd33628f672548f373963b0bff67b217328", + "sha256:1c5742112073a563c81f786e77514969acb58649bcdf6cdf0b4ed31a348d4546", + "sha256:4a0f800587060bf8880f954dbef70de6c11bbe59c673c3d818921f042f9954a6", + "sha256:5c144945a7752ca544b4b78c8c41544cdfaf9786f25fe5ffb10e838e19a27570", + "sha256:7b0a64cda4145548fed9efc10322770f929b944ce5cee6c0dfe0c87bf4c0c8c9", + "sha256:8439c030a11780786a2002261569bdf362264f605dfa4d65090b64b05c9f79a7", + "sha256:8961c0f32cd0336fb8e8ead11a1f8cd99ec07145ec2931122faaac1c8f7fd987", + "sha256:89a48c0d158a3cc3f654da4c2de1ceba85263fafb861b98b59040a5086259722", + "sha256:a76b38c52400b762e48131494ba26be363491ac4f9a04c1b7e92483d169f6582", + "sha256:da6013fd84a690242c310d77ddb8441a559e9cb3d3d59ebac9aca1a57b2e18bc", + "sha256:e55b384612d93be96506932a786bbcde5a2db7a9e6a4bb4bffe8b733f5b9036b", + "sha256:e81b76cace8eda1fca50e345242ba977f9be6ae3945af8d46326d776b4cf78d1", + "sha256:e8236383a20872c0cdf5a62b554b27538db7fa1bbec52429d8d106effbaeca08", + "sha256:f04e857b59d9d1ccc39ce2da1021d196e47234873820cbeaad210724b1ee28ac", + "sha256:fadbfe37f74051d024037f223b8e001611eac868b5c5b06144ef4d8b799862f2" + ], + "markers": "python_version < '3.9'", + "version": "==0.2.1" + }, + "django": { + "hashes": [ + "sha256:2485eea3cc4c3bae13080dee866ebf90ba9f98d1afe8fda89bfb0eb2e218ef86", + "sha256:7cd8e8a3ed2bc0dfda05ce1e53a9c81b30eefd7aa350e538a18884475e4d4ce2" + ], + "index": "pypi", + "version": "==4.0.1" + }, + "djangorestframework": { + "hashes": [ + "sha256:0c33407ce23acc68eca2a6e46424b008c9c02eceb8cf18581921d0092bc1f2ee", + "sha256:24c4bf58ed7e85d1fe4ba250ab2da926d263cd57d64b03e8dcef0ac683f8b1aa" + ], + "index": "pypi", + "version": "==3.13.1" + }, + "pytz": { + "hashes": [ + "sha256:3672058bc3453457b622aab7a1c3bfd5ab0bdae451512f6cf25f64ed37f5b87c", + "sha256:acad2d8b20a1af07d4e4c9d2e9285c5ed9104354062f275f3fcd88dcef4f1326" + ], + "version": "==2021.3" + }, + "sqlparse": { + "hashes": [ + "sha256:0c00730c74263a94e5a9919ade150dfc3b19c574389985446148402998287dae", + "sha256:48719e356bb8b42991bdbb1e8b83223757b93789c00910a616a071910ca4a64d" + ], + "version": "==0.4.2" + } + }, + "develop": {} +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..21d027d --- /dev/null +++ b/README.md @@ -0,0 +1,174 @@ +# Welcome! + +Hi! this your determinant test for the position of Django backend developer at **LibertyAssured**. If you have any issues, you can read this docs or also contact Lolu for further clarification. + +## Overview + +For this exercise you will be cover some basic concepts of web development and production ready deployment and you will hence be tested in the following basic concepts. + +- Django and Django query-sets +- PostgreSQL Setup and connection to Django +- Cloud deployment +- PEP guidelines, conformity and quality of code +- General understanding of the python programming language. + +## Test Rundown + +You will be required to fork this repository into your personal account and then carry out few operations of extending functionality of the application and then make a pull request with your branch name to the **Liberty** main branch as you progress. + +## Test Guide + +After completing stage the process in in the rundown, please create branch for your self, please make sure to name the the branch with the following convention **\/update**, and also all commits to your branch should carry a message in the following format **\[Activity details]**. + +- A sample branch name would be **paul/update**, and., +- A sample commit message would be **FIX[ADDED CORS CONTROL]** + +## Task Description + +You are required to extend a skeleton application to such that it can recreate or conform to the responses which you would be seeing below. +**----------------** + +- Request -> register (Create User account) + +```yaml +{ + "email": "teiker@libertymail.com", + "username": "way2teiker", + "password": "Solarizedgowns", +} +``` + +- Response -> + +```yaml +{ + "message": "Created user successfully", + "username": "way2teiker", + "status-code": 200, +} +``` + +**---------------------** + +- Request -> get_all_coins (Get and display all coins) +- Response -> + +```yaml +[ + { "name": "BTC", "USD-PRICE": "42,3529", "volume": "19,331,340" }, + { "name": "SAND", "USD-PRICE": "100", "volume": "19,331,340,302" }, + { "name": "ETH", "USD-PRICE": "4,356", "volume": "199,331,340" }, +] +``` + +**---------------------** + +- Request -> add_favourite (Add favourite coins to username) + +```yaml +{ "username": "way2teiker", "favourite": "USDT" } +``` + +- Response -> + +```yaml +{ + "message": "Added USDT to Favourite successfully", + "username": "way2teiker", + "coin-name": "USDT", + "status-code": 200, +} +``` + +**---------------------** + +- Request -> view_favourites (Add favourite coins to username) + +```yaml +{ "username": "way2teiker" } +``` + +- Response -> + +```yaml +{"message":"Welcome back way2teiker thanks for using our platform", +"subscribed_favourites": [ + { + "name": "BTC", + "USD-PRICE": "42,3529", + "volume": "19,331,340" + }, + { + "name": "SAND", + "USD-PRICE": "100", + "volume": "19,331,340,302" + }, + { + "name": "ETH", + "USD-PRICE": "4,356", + "volume": "199,331,340" + } + ] +``` + +\*\* **---------------------** \*\* + +## Resources for task + +Please register on https://docs.coinapi.io/?python#exchange-rates for free and get an api-key for your use. +Once this is done you can use their API docs for the propagation of your task. + +**Finally** +You have been provided with a virtual machine IP address hosted on Digital Ocean please host your project appropriately using NGINX, GUNICORN and POSTGRESQL (as database). A password for the droplet will be provided. + +- Please add your postman link to the above created endpoints for review. +- Also note that you can ignore the Docker and CI/CD instantiations on the application. + +### Good luck, as we look forward to working with you at Liberty Assured in building amazing projects and relationships. + +## Installation and Setup + +- **Note:** Run the command with git bash on Windows OS + +1. Clone the repository. + ```bash + git clone https://github.com/dprograma/Backend-Test + ``` +2. Create a virtual environment + ```bash + python3 -m venv venv + ``` +3. Activate the virtual environment + ```bash + source venv/bin/activate # for Mac/Linux + venv\Scripts\activate # for Windows CMD + source venv/Scripts/activate # git bash on Windows + ``` +4. Install the required packages: + ```bash + pip install -r requirements.txt + ``` +5. Change directory to django app directory + ```bash + cd app + ``` +6. Make bash file for static validation executable + ``` + chmod +x static_validation.sh + ``` +7. Set Django Settings Module for Development + ```bash + export DJANGO_SETTINGS_MODULE=app.dev + ``` +8. Run static validation (black, isort, migration, pylint, mypy, test) + ```bash + ./static_validation.sh + ``` +9. Populate coin data into database + ```bash + python manage.py fetch_coin_data + ``` +10. Run the development server: + ```bash + python manage.py runserver + ``` diff --git a/app/.flake8 b/app/.flake8 new file mode 100644 index 0000000..4ddacaf --- /dev/null +++ b/app/.flake8 @@ -0,0 +1,8 @@ +[flake8] +exclude = + migrations + __pycache__, + manage.py, + settings.py, + __init__.py, + apps.py \ No newline at end of file diff --git a/app/.isort.cfg b/app/.isort.cfg new file mode 100644 index 0000000..7ebf3f9 --- /dev/null +++ b/app/.isort.cfg @@ -0,0 +1,12 @@ +[settings] +line_length = 100 +multi_line_output = 3 +include_trailing_comma = True +force_grid_wrap = 0 +use_parentheses = True +ensure_newline_before_comments = True +skip_glob = **/migrations/*.py + +[django] +known_django = django +known_third_party = rest_framework diff --git a/app/.pylintrc b/app/.pylintrc new file mode 100644 index 0000000..2852abe --- /dev/null +++ b/app/.pylintrc @@ -0,0 +1,47 @@ +[MASTER] + +# Specify additional Python builtins +extension-pkg-whitelist = _tkinter + +[MESSAGES CONTROL] + +# Enable or disable specific message categories +disable = + missing-docstring, + invalid-name, + unused-import, + logging-fstring-interpolation, + too-few-public-methods, + too-many-ancestors, + no-member, + unused-argument, + duplicate-code, + no-value-for-parameter, + line-too-long + + +[BASIC] + +# Specify the maximum line length +max-line-length = 120 + +[VARIABLES] + +# Define a list of additional names considered predefined +additional-builtins = _ + +[FORMAT] + + +[TYPECHECK] + +# Enable type checking using Mypy +# init-hook = 'import sys; sys.path.append("."); import mypy; mypy.patch_builtin_type_checkers()' +generated-members = signals +ignored-modules = core/migrations + +[DESIGN] + +# Specify the maximum number of instance attributes allowed +max-attributes = 12 + diff --git a/app/app/__init__.py b/app/app/__init__.py new file mode 100644 index 0000000..336a150 --- /dev/null +++ b/app/app/__init__.py @@ -0,0 +1,4 @@ +"""PEP 517. + +EMPTY INIT JUST TO SHOW THIS AS A PYTHON MODULE +""" diff --git a/app/app/asgi.py b/app/app/asgi.py new file mode 100644 index 0000000..a21acfd --- /dev/null +++ b/app/app/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for app 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.0/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.prod') + +application = get_asgi_application() diff --git a/app/app/base.py b/app/app/base.py new file mode 100644 index 0000000..ba4fc8c --- /dev/null +++ b/app/app/base.py @@ -0,0 +1,131 @@ +""" +Django settings for app project. + +Generated by 'django-admin startproject' using Django 4.0.1. + +For more information on this file, see +https://docs.djangoproject.com/en/4.0/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/4.0/ref/settings/ +""" +import os +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/4.0/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'NOSECRETKEY' + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'core', + 'rest_framework', + 'corsheaders', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'corsheaders.middleware.CorsMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'app.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 = 'app.wsgi.application' + + +# Password validation +# https://docs.djangoproject.com/en/4.0/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.0/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.0/howto/static-files/ + +STATIC_URL = 'static/' + +# Default primary key field type +# https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +AUTH_USER_MODEL = 'core.User' + +REST_FRAMEWORK = { + # Use Django's standard `django.contrib.auth` permissions, + # or allow read-only access for unauthenticated users. + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly' + ] +} + +CORS_ALLOW_ALL_ORIGINS = True + +CORS_ALLOW_METHODS = ( + "DELETE", + "GET", + "OPTIONS", + "PATCH", + "POST", + "PUT", +) \ No newline at end of file diff --git a/app/app/dev.py b/app/app/dev.py new file mode 100644 index 0000000..d64fc35 --- /dev/null +++ b/app/app/dev.py @@ -0,0 +1,22 @@ +from .base import * + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'django-insecure-h-h69cr05lmc*w4vtkf+5qltg8#(v9j9oxs-*-^#vjd' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = ["*"] + + +# Database +# https://docs.djangoproject.com/en/4.0/ref/settings/#databases + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } +} + + diff --git a/app/app/prod.py b/app/app/prod.py new file mode 100644 index 0000000..27535f7 --- /dev/null +++ b/app/app/prod.py @@ -0,0 +1,35 @@ +from .base import * + +SECRET_KEY = '92yc9A4oOY%~?6\n]&xZc6+mW)"8)KMMZ8&KbSC\|gIPX!<[RQ9Co-4W_A#PsiG' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = False + +ALLOWED_HOSTS = ["*"] + + +# Database +# https://docs.djangoproject.com/en/4.0/ref/settings/#databases + +# DATABASES = { +# 'default': { +# 'ENGINE': 'django.db.backends.postgresql', +# 'NAME': 'libertydb', +# 'USER': 'liberty', +# 'PASSWORD': 'liberty', +# 'HOST': 'localhost', +# 'PORT': '5432', +# } +# } + +DATABASES = { + 'default': { + 'ENGINE': os.environ.get('DB_ENGINE', 'django.db.backends.postgresql'), + 'NAME': os.environ.get('DB_NAME'), + 'USER': os.environ.get('DB_USER'), + 'PASSWORD': os.environ.get('DB_PASSWORD'), + 'HOST': os.environ.get('DB_HOST', 'localhost'), + 'PORT': os.environ.get('DB_PORT', '5432'), + } +} + diff --git a/app/app/urls.py b/app/app/urls.py new file mode 100644 index 0000000..7a8810a --- /dev/null +++ b/app/app/urls.py @@ -0,0 +1,23 @@ +"""app URL Configuration. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/4.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, include + +urlpatterns = [ + path('admin/', admin.site.urls), + path('api-auth/', include('rest_framework.urls')), + path('api/', include('core.urls')), +] diff --git a/app/app/wsgi.py b/app/app/wsgi.py new file mode 100644 index 0000000..3c76aaa --- /dev/null +++ b/app/app/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for app 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.0/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.prod') + +application = get_wsgi_application() diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/admin.py b/app/core/admin.py new file mode 100644 index 0000000..479e882 --- /dev/null +++ b/app/core/admin.py @@ -0,0 +1,5 @@ +"""Manage admin page for main app.""" + +# from django.contrib import admin + +# Register your models here. diff --git a/app/core/apps.py b/app/core/apps.py new file mode 100644 index 0000000..c0ce093 --- /dev/null +++ b/app/core/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "core" diff --git a/app/core/management/__init__.py b/app/core/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/management/commands/fetch_coin_data.py b/app/core/management/commands/fetch_coin_data.py new file mode 100644 index 0000000..e46437e --- /dev/null +++ b/app/core/management/commands/fetch_coin_data.py @@ -0,0 +1,36 @@ +# Create a management command to fetch coin data +# api/management/commands/fetch_coin_data.py +import requests +from django.core.management.base import BaseCommand + +from core.models import Coin + + +class Command(BaseCommand): + help = "Fetch coin data from CoinAPI" + + def handle(self, *args, **kwargs): + api_key = "5EDE76A4-29E0-4E5B-8505-FEEFED9B29B3" + url = "https://rest.coinapi.io/v1/assets" + headers = {"X-CoinAPI-Key": api_key} + + try: + response = requests.get(url, headers=headers) + response.raise_for_status() # Raise an error for bad status codes + data = response.json() + print(data) + # Access the rates key in the response + for coin_data in data[:100]: + Coin.objects.update_or_create( + name=coin_data["asset_id"], + defaults={ + "usd_price": coin_data.get("price_usd", 0), + "volume": coin_data.get("volume_1mth_usd", 0), + }, + ) + self.stdout.write(self.style.SUCCESS("Successfully fetched coin data")) + + except requests.exceptions.RequestException as e: + self.stderr.write(self.style.ERROR(f"Error fetching data from CoinAPI: {e}")) + except KeyError as e: + self.stderr.write(self.style.ERROR(f"Key error: {e}")) diff --git a/app/core/migrations/0001_initial.py b/app/core/migrations/0001_initial.py new file mode 100644 index 0000000..7ab5dbd --- /dev/null +++ b/app/core/migrations/0001_initial.py @@ -0,0 +1,73 @@ +# Generated by Django 4.0.1 on 2022-02-19 10:18 + +from django.db import migrations, models + + +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", + ), + ), + ("email", models.EmailField(max_length=255, unique=True)), + ("name", models.CharField(max_length=255)), + ("is_active", models.BooleanField(default=True)), + ("is_staff", models.BooleanField(default=False)), + ( + "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={ + "abstract": False, + }, + ), + ] diff --git a/app/core/migrations/0002_coin_favourite.py b/app/core/migrations/0002_coin_favourite.py new file mode 100644 index 0000000..bc4814e --- /dev/null +++ b/app/core/migrations/0002_coin_favourite.py @@ -0,0 +1,59 @@ +# Generated by Django 5.0.6 on 2024-06-25 00:32 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="Coin", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=10)), + ("usd_price", models.DecimalField(decimal_places=4, max_digits=20)), + ("volume", models.DecimalField(decimal_places=4, max_digits=30)), + ], + ), + migrations.CreateModel( + name="Favourite", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "coin", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="core.coin" + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/app/core/migrations/0003_rename_name_user_username.py b/app/core/migrations/0003_rename_name_user_username.py new file mode 100644 index 0000000..674e6de --- /dev/null +++ b/app/core/migrations/0003_rename_name_user_username.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.6 on 2024-06-25 13:21 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0002_coin_favourite"), + ] + + operations = [ + migrations.RenameField( + model_name="user", + old_name="name", + new_name="username", + ), + ] diff --git a/app/core/migrations/0004_alter_user_password.py b/app/core/migrations/0004_alter_user_password.py new file mode 100644 index 0000000..4cada4d --- /dev/null +++ b/app/core/migrations/0004_alter_user_password.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.6 on 2024-06-25 13:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0003_rename_name_user_username"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="password", + field=models.CharField(max_length=255), + ), + ] diff --git a/app/core/migrations/__init__.py b/app/core/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/models.py b/app/core/models.py new file mode 100644 index 0000000..b9a656b --- /dev/null +++ b/app/core/models.py @@ -0,0 +1,54 @@ +"""Create and manage app models and methods.""" + +from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin +from django.db import models + +# Create your models here. + + +class UserManager(BaseUserManager): + """USER MANAGER CLASS GOING TO MANAGE OUR USER CLASS.""" + + def create_user(self, email, password=None, **extra_fields): + """Create_user method creates and saves new user objects.""" + if not email: + raise ValueError("User must have valid email address") + + user = self.model(email=self.normalize_email(email), **extra_fields) + user.set_password(password) + user.save(using=self._db) + + return user + + def create_superuser(self, email, password): + """Create and saves a new super user.""" + user = self.create_user(email, password) + user.is_staff = True + user.is_superuser = True + + return user + + +class User(AbstractBaseUser, PermissionsMixin): + """Custom user model that supports using email instead of username.""" + + email = models.EmailField(max_length=255, unique=True) + username = models.CharField(max_length=255) + password = models.CharField(max_length=255) + is_active = models.BooleanField(default=True) + is_staff = models.BooleanField(default=False) + + objects = UserManager() + + USERNAME_FIELD = "email" + + +class Coin(models.Model): + name = models.CharField(max_length=10) + usd_price = models.DecimalField(max_digits=20, decimal_places=4) + volume = models.DecimalField(max_digits=30, decimal_places=4) + + +class Favourite(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE) + coin = models.ForeignKey(Coin, on_delete=models.CASCADE) diff --git a/app/core/serializers.py b/app/core/serializers.py new file mode 100644 index 0000000..9cbfe4b --- /dev/null +++ b/app/core/serializers.py @@ -0,0 +1,30 @@ +# api/serializers.py +from rest_framework import serializers + +from .models import Coin, Favourite, User + + +class UserSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ["email", "username", "password"] + + def create(self, validated_data): + user = User.objects.create_user( + username=validated_data["username"], + email=validated_data["email"], + password=validated_data["password"], + ) + return user + + +class CoinSerializer(serializers.ModelSerializer): + class Meta: + model = Coin + exclude = ("id",) + + +class FavouriteSerializer(serializers.ModelSerializer): + class Meta: + model = Favourite + exclude = ("id",) diff --git a/app/core/tests/__init__.py b/app/core/tests/__init__.py new file mode 100644 index 0000000..75cd3ae --- /dev/null +++ b/app/core/tests/__init__.py @@ -0,0 +1,2 @@ +"""TESTS MODULE FOR APP TESTING. +""" diff --git a/app/core/tests/test_models.py b/app/core/tests/test_models.py new file mode 100644 index 0000000..12aca9c --- /dev/null +++ b/app/core/tests/test_models.py @@ -0,0 +1,36 @@ +"""Tests for main app are found here.""" + +from django.contrib.auth import get_user_model +from django.test import TestCase + + +class ModelTests(TestCase): + """Test model creations and management.""" + + def test_create_user_with_email_successful(self): + """Test creating a new user with an email is successful.""" + email = "test@test.com" + password = "testpassword123" + user = get_user_model().objects.create_user(email=email, password=password) + + self.assertEqual(user.email, email) + self.assertTrue(user.check_password(password)) + + def test_new_user_email_normalized(self): + """Test the email for a new uer is normalized.""" + email = "test@TEST.com" + user = get_user_model().objects.create_user(email, "testpassword123") + + self.assertEqual(user.email, email.lower()) + + def test_new_user_invalid_email(self): + """Test creating user with no email raises an error.""" + with self.assertRaises(ValueError): + get_user_model().objects.create_user(None, "test123") + + def test_create_new_superuser(self): + """Test can create superuser.""" + user = get_user_model().objects.create_superuser("test@TEST.com", "test123") + + self.assertTrue(user.is_superuser) + self.assertTrue(user.is_staff) diff --git a/app/core/tests/test_views.py b/app/core/tests/test_views.py new file mode 100644 index 0000000..7ee79a3 --- /dev/null +++ b/app/core/tests/test_views.py @@ -0,0 +1,171 @@ +from django.test import TestCase +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +from core.models import Coin, Favourite, User +from core.serializers import CoinSerializer + + +class RegisterViewTestCase(TestCase): + def setUp(self): + self.client = APIClient() + self.url = reverse("register") + + def test_register_user_success(self): + data = { + "username": "testuser", + "password": "password123", + "email": "testuser@example.com", + } + response = self.client.post(self.url, data, format="json") + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data["message"], "Created user successfully") + self.assertEqual(response.data["username"], "testuser") + self.assertEqual(response.data["status-code"], 200) + self.assertTrue(User.objects.filter(username="testuser").exists()) + + def test_register_user_missing_data(self): + data = {"username": "testuser"} + response = self.client.post(self.url, data, format="json") + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data["status"], "error") + self.assertEqual(response.data["response_code"], "99") + self.assertIn("response", response.data) + + def test_register_user_invalid_data(self): + data = {"username": "testuser", "password": "short", "email": "not-an-email"} + response = self.client.post(self.url, data, format="json") + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data["status"], "error") + self.assertEqual(response.data["response_code"], "99") + self.assertIn("response", response.data) + + +class GetAllCoinsViewTestCase(TestCase): + def setUp(self): + self.client = APIClient() + self.url = reverse("get_all_coins") # Use the appropriate URL name or path + + # Create sample coins + Coin.objects.create(name="Bitcoin", usd_price="74847", volume="934579") + Coin.objects.create(name="Ethereum", usd_price="3372", volume="73282362") + + def test_get_all_coins_success(self): + response = self.client.get(self.url, format="json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + coins = Coin.objects.all() + serializer = CoinSerializer(coins, many=True) + self.assertEqual(response.data, serializer.data) + + def test_get_all_coins_no_content(self): + Coin.objects.all().delete() # Ensure no coins are present + response = self.client.get(self.url, format="json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, []) + + +# class AddFavouriteViewTestCase(TestCase): +# def setUp(self): +# self.client = APIClient() +# self.url = reverse("add_favourite") # Use the appropriate URL name or path + +# # Create a sample user +# self.user = User.objects.create_user(username="testuser", email='email@example.com', password="testpass") + +# # Create sample coins +# self.coin1 = Coin.objects.create(name="Bitcoin", usd_price="74847", volume="934579") +# self.coin2 = Coin.objects.create(name="Ethereum", usd_price="74856", volume="958583") + +# def test_add_favourite_success(self): +# data = {"username": "testuser", "favourite": "Bitcoin"} +# response = self.client.post(self.url, data, format="json") + +# self.assertEqual(response.status_code, status.HTTP_200_OK) +# self.assertTrue( +# Favourite.objects.filter(user=self.user, coin=self.coin1).exists() +# ) +# self.assertEqual(response.data["username"], "testuser") +# self.assertEqual(response.data["coin-name"], "Bitcoin") + +# def test_add_favourite_invalid_user(self): +# data = {"username": "invaliduser", "favourite": "Bitcoin"} +# response = self.client.post(self.url, data, format="json") + +# self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) +# self.assertEqual(response.data["response_code"], "99") + +# def test_add_favourite_invalid_coin(self): +# data = {"username": "testuser", "favourite": "InvalidCoin"} +# response = self.client.post(self.url, data, format="json") + +# self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) +# self.assertEqual(response.data["response_code"], "99") + +class AddFavouriteViewTest(TestCase): + + def setUp(self): + self.client = APIClient() + self.url = reverse('add_favourite') + self.user = User.objects.create_user(username='testuser', email='test@example.com', password='testpassword') + self.coin = Coin.objects.create(name='Bitcoin', usd_price=30000, volume=700000) + + def test_add_favourite_success(self): + data = { + "username": self.user.username, + "favourite": self.coin.name + } + response = self.client.post(self.url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['message'], f'Added {self.coin.name} to Favourite successfully') + self.assertEqual(response.data['username'], self.user.username) + self.assertEqual(response.data['coin-name'], self.coin.name) + + def test_add_favourite_invalid_user(self): + data = { + "username": "nonexistentuser", + "favourite": self.coin.name + } + response = self.client.post(self.url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data['status'], 'error') + + def test_add_favourite_invalid_coin(self): + data = { + "username": self.user.username, + "favourite": "nonexistentcoin" + } + response = self.client.post(self.url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data['status'], 'error') + + +class ViewFavouriteViewTestCase(TestCase): + def setUp(self): + self.client = APIClient() + self.url = reverse("view_favourites") # Use the appropriate URL name or path + + # Create a sample user + self.user = User.objects.create_user(username="testuser", email='email@exampl.com', password="testpass") + + # Create sample coins + self.coin1 = Coin.objects.create(name="Bitcoin", usd_price="74847", volume="934579") + self.coin2 = Coin.objects.create(name="Ethereum", usd_price="758", volume="54905") + + # Add favourites for the user + Favourite.objects.create(user=self.user, coin=self.coin1) + Favourite.objects.create(user=self.user, coin=self.coin2) + + + def test_view_favourite_invalid_user(self): + data = {"username": "invaliduser"} + response = self.client.get(self.url, data, format="json") + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data["response_code"], "99") + diff --git a/app/core/urls.py b/app/core/urls.py new file mode 100644 index 0000000..64cbe4b --- /dev/null +++ b/app/core/urls.py @@ -0,0 +1,10 @@ +from django.urls import path + +from .views import AddFavouriteView, GetAllCoinsView, RegisterView, ViewFavouriteView + +urlpatterns = [ + path("register/", RegisterView.as_view(), name="register"), + path("get_all_coins/", GetAllCoinsView.as_view(), name="get_all_coins"), + path("add_favourite/", AddFavouriteView.as_view(), name="add_favourite"), + path("view_favourites/", ViewFavouriteView.as_view(), name="view_favourites"), +] diff --git a/app/core/views.py b/app/core/views.py new file mode 100644 index 0000000..e1115e3 --- /dev/null +++ b/app/core/views.py @@ -0,0 +1,145 @@ +from django.core.exceptions import ObjectDoesNotExist +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_exempt +from rest_framework import generics, status +from rest_framework.exceptions import ParseError, PermissionDenied, ValidationError +from rest_framework.permissions import AllowAny +from rest_framework.response import Response + +from .models import Coin, Favourite, User +from .serializers import CoinSerializer, UserSerializer + + +@method_decorator(csrf_exempt, name="dispatch") +class RegisterView(generics.CreateAPIView): + """Class to create new user""" + + queryset = User.objects.all() + serializer_class = UserSerializer + permission_classes = [AllowAny] + + def post(self, request, *args, **kwargs) -> Response: + """Create new user""" + try: + serializer = self.get_serializer(data=request.data) + if serializer.is_valid(): + serializer.save() + return Response( + { + "message": "Created user successfully", + "username": serializer.data["username"], + "status-code": 200, + }, + status=status.HTTP_201_CREATED, + ) + return Response( + { + "status": "error", + "response_code": "99", + "response": "User could not be created.", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + except (ValidationError, KeyError, ParseError, PermissionDenied) as e: + return Response( + {"status": "error", "response_code": "99", "response": str(e)}, + status=status.HTTP_400_BAD_REQUEST, + ) + + +@method_decorator(csrf_exempt, name="dispatch") +class GetAllCoinsView(generics.ListAPIView): + """Class to retrieve all the coins""" + + queryset = Coin.objects.all() + serializer_class = CoinSerializer + + def get(self, request, *args, **kwargs): + """Retrieve all coins form the db""" + try: + coins = self.get_queryset() + serializer = self.get_serializer(coins, many=True) + return Response(serializer.data) + except (ParseError, PermissionDenied, ValidationError) as e: + return Response( + {"status": "error", "response_code": "99", "response": str(e)}, + status=status.HTTP_400_BAD_REQUEST, + ) + + +@method_decorator(csrf_exempt, name="dispatch") +class AddFavouriteView(generics.CreateAPIView): + """Class to add a favourite coin to a user""" + + queryset = Favourite.objects.all() + permission_classes = [AllowAny] + + def post(self, request, *args, **kwargs): + """Add a favourite coin to user account""" + try: + user = User.objects.get(username=request.data["username"]) + coin = Coin.objects.get(name=request.data["favourite"]) + favourite = Favourite(user=user, coin=coin) + favourite.save() + return Response( + { + "message": f"Added {coin.name} to Favourite successfully", + "username": user.username, + "coin-name": coin.name, + "status-code": 200, + }, + status=status.HTTP_200_OK, + ) + except User.DoesNotExist: + return Response( + {"status": "error", "response_code": "99", "response": "User does not exist"}, + status=status.HTTP_400_BAD_REQUEST, + ) + except Coin.DoesNotExist: + return Response( + {"status": "error", "response_code": "99", "response": "Coin does not exist"}, + status=status.HTTP_400_BAD_REQUEST, + ) + except (ValidationError, KeyError, ParseError, PermissionDenied) as e: + return Response( + {"status": "error", "response_code": "99", "response": str(e)}, + status=status.HTTP_400_BAD_REQUEST, + ) + + +@method_decorator(csrf_exempt, name="dispatch") +class ViewFavouriteView(generics.ListAPIView): + """Class to view a user's favourite coin/coins""" + + queryset = Favourite.objects.all() + serializer_class = CoinSerializer + + def get(self, request, *args, **kwargs): + """View user's favourite coins""" + try: + user = User.objects.get(username=request.data["username"]) + favourites = Favourite.objects.filter(user=user) + subscribed_favourites = [f.coin for f in favourites] + serializer = self.get_serializer(subscribed_favourites, many=True) + return Response( + { + "message": f"Welcome back {user.username} thanks for using our platform", + "subscribed_favourites": serializer.data, + } + ) + except ( + ObjectDoesNotExist, + KeyError, + ParseError, + PermissionDenied, + ValidationError, + ) as e: + return Response( + {"status": "error", "response_code": "99", "response": str(e)}, + status=status.HTTP_400_BAD_REQUEST, + ) + except Favourite.DoesNotExist as e: + return Response( + {"status": "error", "response_code": "99", "response": str(e)}, + status=status.HTTP_400_BAD_REQUEST, + ) diff --git a/app/manage.py b/app/manage.py new file mode 100644 index 0000000..9aca62d --- /dev/null +++ b/app/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', 'app.prod') + 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/app/mypy.ini b/app/mypy.ini new file mode 100644 index 0000000..5eb27c6 --- /dev/null +++ b/app/mypy.ini @@ -0,0 +1,9 @@ +[mypy] +plugins = + mypy_django_plugin.main + +[mypy.plugins.django-stubs] +django_settings_module = "app.dev" + +[mypy-*.migrations.*] +ignore_errors = True diff --git a/app/requirements.txt b/app/requirements.txt new file mode 100644 index 0000000..7490b21 --- /dev/null +++ b/app/requirements.txt @@ -0,0 +1,35 @@ +asgiref==3.8.1 +astroid==3.2.2 +black==24.4.2 +certifi==2024.6.2 +charset-normalizer==3.3.2 +click==8.1.7 +coinapi.rest.v1==1.3 +colorama==0.4.6 +dill==0.3.8 +Django==5.0.6 +django-cors-headers==4.4.0 +django-stubs==5.0.2 +django-stubs-ext==5.0.2 +djangorestframework==3.15.2 +djangorestframework-types==0.8.0 +idna==3.7 +isort==5.13.2 +mccabe==0.7.0 +mypy==1.10.1 +mypy-extensions==1.0.0 +packaging==24.1 +pathspec==0.12.1 +platformdirs==4.2.2 +psycopg2-binary==2.9.9 +pylint==3.2.3 +pylint-django==2.5.5 +pylint-plugin-utils==0.8.2 +requests==2.32.3 +sqlparse==0.5.0 +tomlkit==0.12.5 +types-PyYAML==6.0.12.20240311 +types-requests==2.32.0.20240622 +typing_extensions==4.12.2 +tzdata==2024.1 +urllib3==2.2.2 diff --git a/app/static_validation.sh b/app/static_validation.sh new file mode 100644 index 0000000..a8e95ff --- /dev/null +++ b/app/static_validation.sh @@ -0,0 +1,28 @@ +#!/bin/bash + +# Run black +echo "Running black..." +black core --line-length=90 + +# Run isort +echo "Running isort..." +isort core + +# Run django migrations check to ensure that there are no migrations left to create +echo "Running makemigrations..." +python manage.py makemigrations + +echo "Running migrate..." +python manage.py migrate + +# run python static validation +echo "Running pylint" +pylint core + +# Run mypy +echo "Running mypy..." +mypy core + +# Run Test +echo "Running test..." +python manage.py test \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..46e8ed0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +version: "3" + +services: + app: + build: + context: . + ports: + - "8000:8000" + volumes: + - ./app:/app + command: > + sh -c "python manage.py runserver 0.0.0.0:8000" \ No newline at end of file diff --git a/dockerfile b/dockerfile new file mode 100644 index 0000000..ef0d16f --- /dev/null +++ b/dockerfile @@ -0,0 +1,16 @@ +FROM python:3.8.12-alpine +LABEL Inyang Kpongette + +ENV PYTHONUNBUFFERED 1 + +COPY ./requirements.txt /requirements.txt +RUN apk add --no-cache --virtual .build-deps gcc musl-dev \ + && pip install --no-cache-dir -r /requirements.txt \ + && apk del .build-deps +RUN pip install flake8 flake8-docstrings restructuredtext-lint +RUN mkdir /app +WORKDIR /app +COPY ./app /app + +RUN adduser -D user +USER user \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..358d74c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,12 @@ +asgiref==3.8.1 +certifi==2024.6.2 +charset-normalizer==3.3.2 +coinapi.rest.v1==1.3 +Django==5.0.6 +djangorestframework==3.15.2 +idna==3.7 +psycopg2-binary==2.9.9 +requests==2.32.3 +sqlparse==0.5.0 +tzdata==2024.1 +urllib3==2.2.2 From 0d0c25f92ff21cce825e004b4ffed0b896607c08 Mon Sep 17 00:00:00 2001 From: ikechukwu kalu Date: Wed, 26 Jun 2024 23:21:48 +0100 Subject: [PATCH 2/2] FIX[COMPLETING FIX ON MERGE CONFLICT] --- README.md | 147 ++++++++++++++++++++++++++++--------------- app/app/asgi.py | 2 +- app/app/prod.py | 19 +----- app/app/settings.py | 126 ------------------------------------- app/app/urls.py | 4 +- app/app/wsgi.py | 2 +- app/core/models.py | 22 +++++-- app/manage.py | 2 +- app/requirements.txt | 35 ----------- requirements.txt | 39 ++++++++++-- 10 files changed, 157 insertions(+), 241 deletions(-) delete mode 100644 app/app/settings.py delete mode 100644 app/requirements.txt diff --git a/README.md b/README.md index 2a5be2f..21d027d 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,15 @@ # Welcome! -Hi! this your determinant test for the position of Django backend developer at **LibertyAssured**. If you have any issues, you can read this docs or also contact Lolu for further clarification. +Hi! this your determinant test for the position of Django backend developer at **LibertyAssured**. If you have any issues, you can read this docs or also contact Lolu for further clarification. +## Overview -## Overview - -For this exercise you will be cover some basic concepts of web development and production ready deployment and you will hence be tested in the following basic concepts. +For this exercise you will be cover some basic concepts of web development and production ready deployment and you will hence be tested in the following basic concepts. - Django and Django query-sets - PostgreSQL Setup and connection to Django - Cloud deployment -- PEP guidelines, conformity and quality of code +- PEP guidelines, conformity and quality of code - General understanding of the python programming language. ## Test Rundown @@ -21,76 +20,76 @@ You will be required to fork this repository into your personal account and then After completing stage the process in in the rundown, please create branch for your self, please make sure to name the the branch with the following convention **\/update**, and also all commits to your branch should carry a message in the following format **\[Activity details]**. -- A sample branch name would be **paul/update**, and., +- A sample branch name would be **paul/update**, and., - A sample commit message would be **FIX[ADDED CORS CONTROL]** ## Task Description You are required to extend a skeleton application to such that it can recreate or conform to the responses which you would be seeing below. **----------------** + - Request -> register (Create User account) + ```yaml { -"email":"teiker@libertymail.com", -"username":"way2teiker", -"password":"Solarizedgowns", + "email": "teiker@libertymail.com", + "username": "way2teiker", + "password": "Solarizedgowns", } ``` -- Response -> + +- Response -> + ```yaml { - "message": "Created user successfully", - "username": "way2teiker", - "status-code": 200 + "message": "Created user successfully", + "username": "way2teiker", + "status-code": 200, } +``` -``` **---------------------** + - Request -> get_all_coins (Get and display all coins) -- Response -> +- Response -> + ```yaml -[{ - "name": "BTC", - "USD-PRICE": "42,3529", - "volume": "19,331,340" -}, -{ - "name": "SAND", - "USD-PRICE": "100", - "volume": "19,331,340,302" -}, -{ - "name": "ETH", - "USD-PRICE": "4,356", - "volume": "199,331,340" -} +[ + { "name": "BTC", "USD-PRICE": "42,3529", "volume": "19,331,340" }, + { "name": "SAND", "USD-PRICE": "100", "volume": "19,331,340,302" }, + { "name": "ETH", "USD-PRICE": "4,356", "volume": "199,331,340" }, ] -``` +``` + **---------------------** + - Request -> add_favourite (Add favourite coins to username) + ```yaml -{ -"username":"way2teiker", -"favourite":"USDT", -} +{ "username": "way2teiker", "favourite": "USDT" } ``` -- Response -> + +- Response -> + ```yaml { - "message": "Added USDT to Favourite successfully", - "username": "way2teiker", - "coin-name": "USDT", - "status-code": 200 + "message": "Added USDT to Favourite successfully", + "username": "way2teiker", + "coin-name": "USDT", + "status-code": 200, } -``` +``` + **---------------------** + - Request -> view_favourites (Add favourite coins to username) + ```yaml -{ -"username":"way2teiker" -} +{ "username": "way2teiker" } ``` -- Response -> + +- Response -> + ```yaml {"message":"Welcome back way2teiker thanks for using our platform", "subscribed_favourites": [ @@ -109,9 +108,10 @@ You are required to extend a skeleton application to such that it can recreate o "USD-PRICE": "4,356", "volume": "199,331,340" } - ] -``` -** **---------------------** ** + ] +``` + +\*\* **---------------------** \*\* ## Resources for task @@ -119,9 +119,56 @@ Please register on https://docs.coinapi.io/?python#exchange-rates for free and g Once this is done you can use their API docs for the propagation of your task. **Finally** -You have been provided with a virtual machine IP address hosted on Digital Ocean please host your project appropriately using NGINX, GUNICORN and POSTGRESQL (as database). A password for the droplet will be provided. +You have been provided with a virtual machine IP address hosted on Digital Ocean please host your project appropriately using NGINX, GUNICORN and POSTGRESQL (as database). A password for the droplet will be provided. - Please add your postman link to the above created endpoints for review. - Also note that you can ignore the Docker and CI/CD instantiations on the application. -### Good luck, as we look forward to working with you at Liberty Assured in building amazing projects and relationships. \ No newline at end of file +### Good luck, as we look forward to working with you at Liberty Assured in building amazing projects and relationships. + +## Installation and Setup + +- **Note:** Run the command with git bash on Windows OS + +1. Clone the repository. + ```bash + git clone https://github.com/dprograma/Backend-Test + ``` +2. Create a virtual environment + ```bash + python3 -m venv venv + ``` +3. Activate the virtual environment + ```bash + source venv/bin/activate # for Mac/Linux + venv\Scripts\activate # for Windows CMD + source venv/Scripts/activate # git bash on Windows + ``` +4. Install the required packages: + ```bash + pip install -r requirements.txt + ``` +5. Change directory to django app directory + ```bash + cd app + ``` +6. Make bash file for static validation executable + ``` + chmod +x static_validation.sh + ``` +7. Set Django Settings Module for Development + ```bash + export DJANGO_SETTINGS_MODULE=app.dev + ``` +8. Run static validation (black, isort, migration, pylint, mypy, test) + ```bash + ./static_validation.sh + ``` +9. Populate coin data into database + ```bash + python manage.py fetch_coin_data + ``` +10. Run the development server: + ```bash + python manage.py runserver + ``` diff --git a/app/app/asgi.py b/app/app/asgi.py index 3163a3a..a21acfd 100644 --- a/app/app/asgi.py +++ b/app/app/asgi.py @@ -11,6 +11,6 @@ from django.core.asgi import get_asgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings') +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.prod') application = get_asgi_application() diff --git a/app/app/prod.py b/app/app/prod.py index 27535f7..0540a38 100644 --- a/app/app/prod.py +++ b/app/app/prod.py @@ -1,26 +1,11 @@ from .base import * -SECRET_KEY = '92yc9A4oOY%~?6\n]&xZc6+mW)"8)KMMZ8&KbSC\|gIPX!<[RQ9Co-4W_A#PsiG' +SECRET_KEY = os.environ.get('SECRET_KEY') # SECURITY WARNING: don't run with debug turned on in production! DEBUG = False -ALLOWED_HOSTS = ["*"] - - -# Database -# https://docs.djangoproject.com/en/4.0/ref/settings/#databases - -# DATABASES = { -# 'default': { -# 'ENGINE': 'django.db.backends.postgresql', -# 'NAME': 'libertydb', -# 'USER': 'liberty', -# 'PASSWORD': 'liberty', -# 'HOST': 'localhost', -# 'PORT': '5432', -# } -# } +ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS').split(',') DATABASES = { 'default': { diff --git a/app/app/settings.py b/app/app/settings.py deleted file mode 100644 index 5356348..0000000 --- a/app/app/settings.py +++ /dev/null @@ -1,126 +0,0 @@ -""" -Django settings for app project. - -Generated by 'django-admin startproject' using Django 4.0.1. - -For more information on this file, see -https://docs.djangoproject.com/en/4.0/topics/settings/ - -For the full list of settings and their values, see -https://docs.djangoproject.com/en/4.0/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/4.0/howto/deployment/checklist/ - -# SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'django-insecure-h-h69cr05lmc*w4vtkf+5qltg8#(v9j9oxs-*-^#vjd' - -# SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True - -ALLOWED_HOSTS = [] - - -# Application definition - -INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'core' -] - -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 = 'app.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 = 'app.wsgi.application' - - -# Database -# https://docs.djangoproject.com/en/4.0/ref/settings/#databases - -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': BASE_DIR / 'db.sqlite3', - } -} - - -# Password validation -# https://docs.djangoproject.com/en/4.0/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.0/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.0/howto/static-files/ - -STATIC_URL = 'static/' - -# Default primary key field type -# https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-field - -DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' - -AUTH_USER_MODEL = 'core.User' \ No newline at end of file diff --git a/app/app/urls.py b/app/app/urls.py index 7c49775..7a8810a 100644 --- a/app/app/urls.py +++ b/app/app/urls.py @@ -14,8 +14,10 @@ 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 urlpatterns = [ path('admin/', admin.site.urls), + path('api-auth/', include('rest_framework.urls')), + path('api/', include('core.urls')), ] diff --git a/app/app/wsgi.py b/app/app/wsgi.py index 0efb709..3c76aaa 100644 --- a/app/app/wsgi.py +++ b/app/app/wsgi.py @@ -11,6 +11,6 @@ from django.core.wsgi import get_wsgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings') +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.prod') application = get_wsgi_application() diff --git a/app/core/models.py b/app/core/models.py index a417f39..b9a656b 100644 --- a/app/core/models.py +++ b/app/core/models.py @@ -1,8 +1,8 @@ """Create and manage app models and methods.""" +from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin from django.db import models -from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, \ - PermissionsMixin + # Create your models here. @@ -12,7 +12,7 @@ class UserManager(BaseUserManager): def create_user(self, email, password=None, **extra_fields): """Create_user method creates and saves new user objects.""" if not email: - raise ValueError('User must have valid email address') + raise ValueError("User must have valid email address") user = self.model(email=self.normalize_email(email), **extra_fields) user.set_password(password) @@ -33,10 +33,22 @@ class User(AbstractBaseUser, PermissionsMixin): """Custom user model that supports using email instead of username.""" email = models.EmailField(max_length=255, unique=True) - name = models.CharField(max_length=255) + username = models.CharField(max_length=255) + password = models.CharField(max_length=255) is_active = models.BooleanField(default=True) is_staff = models.BooleanField(default=False) objects = UserManager() - USERNAME_FIELD = 'email' + USERNAME_FIELD = "email" + + +class Coin(models.Model): + name = models.CharField(max_length=10) + usd_price = models.DecimalField(max_digits=20, decimal_places=4) + volume = models.DecimalField(max_digits=30, decimal_places=4) + + +class Favourite(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE) + coin = models.ForeignKey(Coin, on_delete=models.CASCADE) diff --git a/app/manage.py b/app/manage.py index 4931389..9aca62d 100644 --- a/app/manage.py +++ b/app/manage.py @@ -6,7 +6,7 @@ def main(): """Run administrative tasks.""" - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings') + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.prod') try: from django.core.management import execute_from_command_line except ImportError as exc: diff --git a/app/requirements.txt b/app/requirements.txt deleted file mode 100644 index 7490b21..0000000 --- a/app/requirements.txt +++ /dev/null @@ -1,35 +0,0 @@ -asgiref==3.8.1 -astroid==3.2.2 -black==24.4.2 -certifi==2024.6.2 -charset-normalizer==3.3.2 -click==8.1.7 -coinapi.rest.v1==1.3 -colorama==0.4.6 -dill==0.3.8 -Django==5.0.6 -django-cors-headers==4.4.0 -django-stubs==5.0.2 -django-stubs-ext==5.0.2 -djangorestframework==3.15.2 -djangorestframework-types==0.8.0 -idna==3.7 -isort==5.13.2 -mccabe==0.7.0 -mypy==1.10.1 -mypy-extensions==1.0.0 -packaging==24.1 -pathspec==0.12.1 -platformdirs==4.2.2 -psycopg2-binary==2.9.9 -pylint==3.2.3 -pylint-django==2.5.5 -pylint-plugin-utils==0.8.2 -requests==2.32.3 -sqlparse==0.5.0 -tomlkit==0.12.5 -types-PyYAML==6.0.12.20240311 -types-requests==2.32.0.20240622 -typing_extensions==4.12.2 -tzdata==2024.1 -urllib3==2.2.2 diff --git a/requirements.txt b/requirements.txt index 843358c..7490b21 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,35 @@ -Django>=3.0,<4.0.2 -djangorestframework==3.13.1 - -# flake8>=3.6.0,<3.7.0 \ No newline at end of file +asgiref==3.8.1 +astroid==3.2.2 +black==24.4.2 +certifi==2024.6.2 +charset-normalizer==3.3.2 +click==8.1.7 +coinapi.rest.v1==1.3 +colorama==0.4.6 +dill==0.3.8 +Django==5.0.6 +django-cors-headers==4.4.0 +django-stubs==5.0.2 +django-stubs-ext==5.0.2 +djangorestframework==3.15.2 +djangorestframework-types==0.8.0 +idna==3.7 +isort==5.13.2 +mccabe==0.7.0 +mypy==1.10.1 +mypy-extensions==1.0.0 +packaging==24.1 +pathspec==0.12.1 +platformdirs==4.2.2 +psycopg2-binary==2.9.9 +pylint==3.2.3 +pylint-django==2.5.5 +pylint-plugin-utils==0.8.2 +requests==2.32.3 +sqlparse==0.5.0 +tomlkit==0.12.5 +types-PyYAML==6.0.12.20240311 +types-requests==2.32.0.20240622 +typing_extensions==4.12.2 +tzdata==2024.1 +urllib3==2.2.2