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/.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/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/settings.py b/app/app/base.py similarity index 83% rename from app/app/settings.py rename to app/app/base.py index 5356348..ba4fc8c 100644 --- a/app/app/settings.py +++ b/app/app/base.py @@ -9,7 +9,7 @@ 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'. @@ -20,13 +20,7 @@ # 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 = [] - +SECRET_KEY = 'NOSECRETKEY' # Application definition @@ -37,12 +31,15 @@ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', - 'core' + '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', @@ -71,17 +68,6 @@ 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 @@ -123,4 +109,23 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' -AUTH_USER_MODEL = 'core.User' \ No newline at end of file +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..0540a38 --- /dev/null +++ b/app/app/prod.py @@ -0,0 +1,20 @@ +from .base import * + +SECRET_KEY = os.environ.get('SECRET_KEY') + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = False + +ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS').split(',') + +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 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/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/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/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/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/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 old mode 100755 new mode 100644 index 4931389..9aca62d --- 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/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/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/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