diff --git a/config/api/v1/urls.py b/config/api/v1/urls.py index e99663a..3dea484 100644 --- a/config/api/v1/urls.py +++ b/config/api/v1/urls.py @@ -10,4 +10,6 @@ urlpatterns = [ ), path("redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"), path("shop/", include("shop.api.v1.routers")), + path("files/", include("files.api.v1.urls")), + path("auth/", include("users.api.v1.urls")), ] diff --git a/config/settings/base.py b/config/settings/base.py index 231f688..020f03a 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -23,9 +23,11 @@ THIRD_PARTY_APPS = [ "django_filters", "watchman", "drf_spectacular", + "rest_framework_simplejwt", ] PROJECT_APPS = [ + "files", "frontend", "shop", "tpv", @@ -179,3 +181,43 @@ SPECTACULAR_SETTINGS = { CELERY_BROKER_URL = env.str("CELERY_BROKER_URL", default="redis://172.17.0.1:6379/0") CELERY_TIME_ZONE = TIME_ZONE CELERY_ALWAYS_EAGER = DEBUG + +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": datetime.timedelta(minutes=5), + "REFRESH_TOKEN_LIFETIME": datetime.timedelta(days=1), + "ROTATE_REFRESH_TOKENS": False, + "BLACKLIST_AFTER_ROTATION": False, + "UPDATE_LAST_LOGIN": False, + + "ALGORITHM": "HS256", + "SIGNING_KEY": SECRET_KEY, + "VERIFYING_KEY": "", + "AUDIENCE": None, + "ISSUER": None, + "JSON_ENCODER": None, + "JWK_URL": None, + "LEEWAY": 0, + + "AUTH_HEADER_TYPES": ("Bearer", "JWT", ), + "AUTH_HEADER_NAME": "HTTP_AUTHORIZATION", + "USER_ID_FIELD": "id", + "USER_ID_CLAIM": "user_id", + "USER_AUTHENTICATION_RULE": "rest_framework_simplejwt.authentication.default_user_authentication_rule", + + "AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.AccessToken",), + "TOKEN_TYPE_CLAIM": "token_type", + "TOKEN_USER_CLASS": "rest_framework_simplejwt.models.TokenUser", + + "JTI_CLAIM": "jti", + + "SLIDING_TOKEN_REFRESH_EXP_CLAIM": "refresh_exp", + "SLIDING_TOKEN_LIFETIME": datetime.timedelta(minutes=5), + "SLIDING_TOKEN_REFRESH_LIFETIME": datetime.timedelta(days=1), + + "TOKEN_OBTAIN_SERIALIZER": "rest_framework_simplejwt.serializers.TokenObtainPairSerializer", + "TOKEN_REFRESH_SERIALIZER": "rest_framework_simplejwt.serializers.TokenRefreshSerializer", + "TOKEN_VERIFY_SERIALIZER": "rest_framework_simplejwt.serializers.TokenVerifySerializer", + "TOKEN_BLACKLIST_SERIALIZER": "rest_framework_simplejwt.serializers.TokenBlacklistSerializer", + "SLIDING_TOKEN_OBTAIN_SERIALIZER": "rest_framework_simplejwt.serializers.TokenObtainSlidingSerializer", + "SLIDING_TOKEN_REFRESH_SERIALIZER": "rest_framework_simplejwt.serializers.TokenRefreshSlidingSerializer", +} diff --git a/config/urls.py b/config/urls.py index 5334ecb..062bfbe 100644 --- a/config/urls.py +++ b/config/urls.py @@ -1,3 +1,6 @@ +import debug_toolbar +from django.conf import settings +from django.conf.urls.static import static from django.contrib import admin from django.urls import path, include @@ -9,3 +12,7 @@ urlpatterns = [ path("tpv/", include("tpv.urls", namespace="tpv")), path("auth/", include("users.urls", namespace="users")), ] + +if settings.DEBUG: + urlpatterns.append(path('__debug__/', include(debug_toolbar.urls))) + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/files/__init__.py b/files/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/files/admin.py b/files/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/files/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/files/api/__init__.py b/files/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/files/api/v1/__init__.py b/files/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/files/api/v1/serializers.py b/files/api/v1/serializers.py new file mode 100644 index 0000000..5551ae1 --- /dev/null +++ b/files/api/v1/serializers.py @@ -0,0 +1,10 @@ +from rest_framework import serializers + +from files.models import FileUpload + + +class FileUploadSerializer(serializers.ModelSerializer): + class Meta: + model = FileUpload + fields = ('file', 'id') + read_only_fields = ('id', ) diff --git a/files/api/v1/urls.py b/files/api/v1/urls.py new file mode 100644 index 0000000..e5784fe --- /dev/null +++ b/files/api/v1/urls.py @@ -0,0 +1,8 @@ +from rest_framework.routers import DefaultRouter + +from files.api.v1.views import FileUploadViewSet + +router = DefaultRouter(trailing_slash=True) +router.register('', FileUploadViewSet) + +urlpatterns = router.urls diff --git a/files/api/v1/views.py b/files/api/v1/views.py new file mode 100644 index 0000000..9816ba3 --- /dev/null +++ b/files/api/v1/views.py @@ -0,0 +1,8 @@ +from rest_framework.viewsets import ModelViewSet +from files.api.v1.serializers import FileUploadSerializer +from files.models import FileUpload + + +class FileUploadViewSet(ModelViewSet): + serializer_class = FileUploadSerializer + queryset = FileUpload.objects.all() diff --git a/files/apps.py b/files/apps.py new file mode 100644 index 0000000..a5b5712 --- /dev/null +++ b/files/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class FilesConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "files" diff --git a/files/migrations/0001_initial.py b/files/migrations/0001_initial.py new file mode 100644 index 0000000..cac7f3d --- /dev/null +++ b/files/migrations/0001_initial.py @@ -0,0 +1,28 @@ +# Generated by Django 5.0.3 on 2024-05-04 22:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="FileUpload", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("file", models.FileField(upload_to="uploads")), + ], + ), + ] diff --git a/files/migrations/__init__.py b/files/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/files/models.py b/files/models.py new file mode 100644 index 0000000..aba40cc --- /dev/null +++ b/files/models.py @@ -0,0 +1,5 @@ +from django.db import models + + +class FileUpload(models.Model): + file = models.FileField(upload_to="uploads", blank=False, null=False) diff --git a/files/tests.py b/files/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/files/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/files/views.py b/files/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/files/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/requirements.txt b/requirements.txt index e00f58e..2ba11cf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ celery==5.2.7 django==5.0.3 django-cors-headers==3.13.0 django-cryptography==1.1 +django-debug-toolbar==3.7.0 django-extensions==3.2.0 django-filter==23.1 django-environ==0.10.0 diff --git a/shop/admin.py b/shop/admin.py index 888aedb..1365de8 100644 --- a/shop/admin.py +++ b/shop/admin.py @@ -11,7 +11,6 @@ class ProductAdmin(ModelAdmin): "id", "name", "stock", - "unit", ) @@ -24,6 +23,7 @@ class ProductPriceAdmin(ModelAdmin): "product", "price", "date", + "tax", ) diff --git a/shop/api/v1/serializers.py b/shop/api/v1/serializers.py index 65d5b87..1ddcd16 100644 --- a/shop/api/v1/serializers.py +++ b/shop/api/v1/serializers.py @@ -1,25 +1,49 @@ from rest_framework import serializers + +from files.api.v1.serializers import FileUploadSerializer from shop.models import Product, ProductPrice, Customer, Tax, OrderLine, Order class ProductSerializer(serializers.ModelSerializer): + images = FileUploadSerializer(many=True, read_only=True) + price = serializers.SerializerMethodField() + + def get_price(self, product): + last_price = product.prices.last() + + return ProductPriceSerializer(last_price).data + class Meta: model = Product fields = ( "name", "description", "stock", - "unit", + "is_digital_asset", + "images", + "price", + ) + + +class TaxSerializer(serializers.ModelSerializer): + class Meta: + model = Tax + fields = ( + "code", + "value", ) class ProductPriceSerializer(serializers.ModelSerializer): + tax = TaxSerializer() + class Meta: model = ProductPrice fields = ( "price", "date", - "product", + "tax", + "price_with_tax", ) @@ -39,15 +63,6 @@ class CustomerSerializer(serializers.ModelSerializer): ) -class TaxSerializer(serializers.ModelSerializer): - class Meta: - model = Tax - fields = ( - "code", - "value", - ) - - class OrderSerializer(serializers.ModelSerializer): class Meta: model = Order diff --git a/shop/migrations/0002_remove_product_unit_order_status_product_images_and_more.py b/shop/migrations/0002_remove_product_unit_order_status_product_images_and_more.py new file mode 100644 index 0000000..89932b4 --- /dev/null +++ b/shop/migrations/0002_remove_product_unit_order_status_product_images_and_more.py @@ -0,0 +1,40 @@ +# Generated by Django 5.0.3 on 2024-05-04 22:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("files", "0001_initial"), + ("shop", "0001_initial"), + ] + + operations = [ + migrations.RemoveField( + model_name="product", + name="unit", + ), + migrations.AddField( + model_name="order", + name="status", + field=models.CharField(default="PEN", max_length=3, verbose_name="Estado"), + ), + migrations.AddField( + model_name="product", + name="images", + field=models.ManyToManyField( + blank=True, to="files.fileupload", verbose_name="Imágenes" + ), + ), + migrations.AddField( + model_name="product", + name="is_digital_asset", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="product", + name="url", + field=models.URLField(blank=True, verbose_name="URL de descarga"), + ), + ] diff --git a/shop/models.py b/shop/models.py index 8fb05b8..7ccb71c 100644 --- a/shop/models.py +++ b/shop/models.py @@ -26,7 +26,10 @@ class Product(models.Model): verbose_name=_("Stock"), ) is_digital_asset = models.BooleanField(default=False) - url = models.URLField(blank=True, verbose_name=_('URL de descarga')) + url = models.URLField(blank=True, verbose_name=_("URL de descarga")) + images = models.ManyToManyField( + "files.FileUpload", blank=True, verbose_name=_("Imágenes") + ) def __str__(self): return self.name @@ -58,7 +61,7 @@ class ProductPrice(models.Model): @property def price_with_tax(self): - tax_value = self.price * (self.tax.value / 100) + tax_value = self.price * Decimal(self.tax.value / 100) return round(self.price + tax_value, 2) class Meta: diff --git a/shop/tests/test_shop_models.py b/shop/tests/test_shop_models.py index 3aaf5e2..2ec83f5 100644 --- a/shop/tests/test_shop_models.py +++ b/shop/tests/test_shop_models.py @@ -16,17 +16,14 @@ class ShopModelsTest(TestCase): self.potatoes = Product.objects.create( name="Patatas", stock=Decimal("100.00"), - unit=Product.UnitChoices.WEIGHT_KG, ) self.gasoline = Product.objects.create( name="Gasolina", stock=Decimal("800.00"), - unit=Product.UnitChoices.VOLUME_LITER, ) self.usb_c = Product.objects.create( name="Cable USB-C", stock=Decimal("5.00"), - unit=Product.UnitChoices.UNIT, ) ProductPrice.objects.create( diff --git a/users/api/__init__.py b/users/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/users/api/v1/__init__.py b/users/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/users/api/v1/serializers.py b/users/api/v1/serializers.py new file mode 100644 index 0000000..844cce9 --- /dev/null +++ b/users/api/v1/serializers.py @@ -0,0 +1,73 @@ +from typing import List + +from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.auth.password_validation import get_password_validators, validate_password +from rest_framework import serializers +from rest_framework.exceptions import ValidationError +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer + +User = get_user_model() + + +AUTH_PASSWORD_VALIDATORS = getattr(settings, 'AUTH_PASSWORD_VALIDATORS') + + +class UserSerializer(serializers.ModelSerializer): + permissions = serializers.SerializerMethodField() + + def get_permissions(self, obj: User) -> List[str]: + group_permissions = obj.get_group_permissions() + user_permissions = obj.get_user_permissions() + + return sorted(user_permissions.union(group_permissions)) + + class Meta: + model = User + fields = ( + 'username', + 'email', + 'first_name', + 'last_name', + 'is_staff', + 'is_superuser', + 'permissions', + ) + read_only_fields = ('username', 'code', 'is_staff', 'is_superuser', 'permissions', ) + + +class UpdatePasswordSerializer(serializers.ModelSerializer): + old_password = serializers.CharField(write_only=True) + new_password = serializers.CharField(write_only=True) + new_password2 = serializers.CharField(write_only=True) + + def validate_old_password(self, password): + user = self.context.get('request').user + + if not user.check_password(password): + raise ValidationError('Wrong old password') + + return password + + def validate_new_password(self, password): + old_password = self.context.get('request').data.get('old_password') + + if password == old_password: + raise ValidationError('Password can\'t be the same as the old one') + + password2 = self.context.get('request').data.get('new_password2') + if password != password2: + raise ValidationError('Password mismatch') + + validate_password(password, password_validators=get_password_validators(AUTH_PASSWORD_VALIDATORS)) + + return password + + def save(self, *args, **kwargs): + new_password = self.validated_data.get('new_password') + self.instance.set_password(new_password) + self.instance.save() + + class Meta: + model = User + fields = ('old_password', 'new_password', 'new_password2', ) diff --git a/users/api/v1/urls.py b/users/api/v1/urls.py new file mode 100644 index 0000000..c92b2fd --- /dev/null +++ b/users/api/v1/urls.py @@ -0,0 +1,16 @@ +from django.urls import path +from users.api.v1.views import retrieve_update_me, change_password + +from rest_framework_simplejwt.views import ( + token_refresh, + token_obtain_pair, + token_verify +) + +urlpatterns = [ + path('login/', token_obtain_pair, name='login'), + path('refresh/', token_refresh, name='refresh_jwt'), + path('verify/', token_verify, name='verify_jwt'), + path('me/', retrieve_update_me, name='user_info'), + path('change-password/', change_password, name='change_password'), +] diff --git a/users/api/v1/views.py b/users/api/v1/views.py new file mode 100644 index 0000000..f928085 --- /dev/null +++ b/users/api/v1/views.py @@ -0,0 +1,33 @@ +from django.contrib.auth.models import User +from rest_framework.generics import RetrieveUpdateAPIView, UpdateAPIView +from rest_framework.permissions import IsAuthenticated + +from users.api.v1.serializers import UserSerializer, UpdatePasswordSerializer + + +class RetrieveUpdateMe(RetrieveUpdateAPIView): + """ + get: Return user info + + put: Update user info + """ + serializer_class = UserSerializer + + def get_object(self): + return self.request.user + + +class ChangePassword(UpdateAPIView): + """ + put: Update user's password + """ + serializer_class = UpdatePasswordSerializer + permission_classes = (IsAuthenticated, ) + queryset = User.objects.all() + + def get_object(self): + return self.request.user + + +retrieve_update_me = RetrieveUpdateMe.as_view() +change_password = ChangePassword.as_view() diff --git a/users/tests/__init__.py b/users/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/users/tests/test_change_password.py b/users/tests/test_change_password.py new file mode 100644 index 0000000..f29b4c7 --- /dev/null +++ b/users/tests/test_change_password.py @@ -0,0 +1,85 @@ +from rest_framework import status +from rest_framework.test import APITestCase +from django.contrib.auth import get_user_model + +User = get_user_model() + + +class TestChangePassword(APITestCase): + def setUp(self) -> None: + self.password = 'theonering' + self.user = User.objects.create( + username='sauron', + email='sauron@mordor.middleearth', + ) + + self.user.set_password(self.password) + self.user.save() + + def test_login_then_change_password(self): + response = self.client.post('/api/v1/auth/login/', { + 'username': self.user.username, + 'password': self.password, + }) + + assert response.status_code == status.HTTP_200_OK + jwt_token = response.data.get('access') + self.client.credentials(HTTP_AUTHORIZATION=f'JWT {jwt_token}') + response = self.client.put('/api/v1/auth/change-password/', { + 'new_password': 'barad-dur', + 'new_password2': 'barad-dur', + 'old_password': self.password, + }) + + assert response.status_code == status.HTTP_200_OK + + def test_login_then_change_password_with_wrong_password(self): + response = self.client.post('/api/v1/auth/login/', { + 'username': self.user.username, + 'password': self.password, + }) + + assert response.status_code == status.HTTP_200_OK + jwt_token = response.data.get('access') + self.client.credentials(HTTP_AUTHORIZATION=f'JWT {jwt_token}') + response = self.client.put('/api/v1/auth/change-password/', { + 'new_password': 'barad-dur', + 'new_password2': 'barad-dur', + 'old_password': 'incorrectoldpassword', + }) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_login_then_change_password_mismatch_password(self): + response = self.client.post('/api/v1/auth/login/', { + 'username': self.user.username, + 'password': self.password, + }) + + assert response.status_code == status.HTTP_200_OK + jwt_token = response.data.get('access') + self.client.credentials(HTTP_AUTHORIZATION=f'JWT {jwt_token}') + response = self.client.put('/api/v1/auth/change-password/', { + 'new_password': 'barad-dur', + 'new_password2': 'mountdoom', + 'old_password': self.password, + }) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_login_then_change_password_but_its_the_same(self): + response = self.client.post('/api/v1/auth/login/', { + 'username': self.user.username, + 'password': self.password, + }) + + assert response.status_code == status.HTTP_200_OK + jwt_token = response.data.get('access') + self.client.credentials(HTTP_AUTHORIZATION=f'JWT {jwt_token}') + response = self.client.put('/api/v1/auth/change-password/', { + 'new_password': self.password, + 'new_password2': self.password, + 'old_password': self.password, + }) + + assert response.status_code == status.HTTP_400_BAD_REQUEST diff --git a/users/tests/test_login.py b/users/tests/test_login.py new file mode 100644 index 0000000..21a6341 --- /dev/null +++ b/users/tests/test_login.py @@ -0,0 +1,79 @@ +from rest_framework import status +from rest_framework.test import APITestCase +from django.contrib.auth import get_user_model + +User = get_user_model() + + +class TestLogin(APITestCase): + def setUp(self) -> None: + self.password = 'theonering' + self.user = User.objects.create( + username='sauron', + email='sauron@mordor.middleearth', + ) + + self.user.set_password(self.password) + self.user.save() + + def test_login(self): + response = self.client.post('/api/v1/auth/login/', { + 'username': self.user.username, + 'password': self.password, + }) + + assert response.status_code == status.HTTP_200_OK + assert response.data.get('access') is not None + assert response.data.get('refresh') is not None + + def test_login_then_verify(self): + response = self.client.post('/api/v1/auth/login/', { + 'username': self.user.username, + 'password': self.password, + }) + + assert response.status_code == status.HTTP_200_OK + jwt_token = response.data.get('access') + response = self.client.post('/api/v1/auth/verify/', { + 'token': jwt_token, + }) + + assert response.status_code == status.HTTP_200_OK + + def test_login_then_refresh(self): + response = self.client.post('/api/v1/auth/login/', { + 'username': self.user.username, + 'password': self.password, + }) + + assert response.status_code == status.HTTP_200_OK + jwt_token = response.data.get('refresh') + response = self.client.post('/api/v1/auth/refresh/', { + 'refresh': jwt_token, + }) + + assert response.status_code == status.HTTP_200_OK + assert response.data.get('access') is not None + assert response.data.get('access') != jwt_token + + def test_login_then_get_user_info(self): + response = self.client.post('/api/v1/auth/login/', { + 'username': self.user.username, + 'password': self.password, + }) + + assert response.status_code == status.HTTP_200_OK + jwt_token = response.data.get('access') + self.client.credentials(HTTP_AUTHORIZATION=f'JWT {jwt_token}') + response = self.client.get('/api/v1/auth/me/') + + assert response.status_code == status.HTTP_200_OK + assert response.data.get('email') == self.user.email + + def test_login_failed(self): + response = self.client.post('/api/v1/auth/login/', { + 'username': self.user.username, + 'password': 'wrongpassword', + }) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED diff --git a/users/tests/test_users_model.py b/users/tests/test_users_model.py new file mode 100644 index 0000000..fe7ee38 --- /dev/null +++ b/users/tests/test_users_model.py @@ -0,0 +1,37 @@ +import pytest + +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group +from django.test import TestCase + + +User = get_user_model() + + +class TestUsers(TestCase): + + def test_create_user(self): + User.objects.create_user({ + 'username': 'user 1', + 'password': 'password1' + }) + User.objects.all().count() == 1 + + def test_new_user_invalid_username(self): + """Test creating user with no username raises error""" + with pytest.raises(ValueError): + User.objects.create_user(None, 'test123') + + def test_create_new_superuser(self): + """Test creating a new superuser""" + # Creation with standard method + user = User.objects.create_superuser( + 'testsuperuser@adminemail.com', + 'testadmin123' + ) + assert user.is_superuser + assert user.is_staff + + def test_create_group(self): + Group.objects.create(name='Group1') + assert Group.objects.count() == 1