diff --git a/api/migrations/0005_category_product_subcategory.py b/api/migrations/0005_category_product_subcategory.py new file mode 100644 index 0000000000000000000000000000000000000000..c4bbd259ff6f7feccfd701f11cd06d86f45ef937 --- /dev/null +++ b/api/migrations/0005_category_product_subcategory.py @@ -0,0 +1,61 @@ +# Generated by Django 3.0.3 on 2020-03-16 11:31 + +from decimal import Decimal +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0004_auto_20200309_1953'), + ] + + operations = [ + migrations.CreateModel( + name='Category', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50, verbose_name='name')), + ('image', models.ImageField(blank=True, null=True, upload_to='uploads/categories/', verbose_name='image')), + ], + options={ + 'verbose_name': 'category', + 'verbose_name_plural': 'categories', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='Subcategory', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50, verbose_name='name')), + ('image', models.ImageField(blank=True, null=True, upload_to='uploads/subcategories/', verbose_name='image')), + ('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='subcategories', to='api.Category', verbose_name='category')), + ], + options={ + 'verbose_name': 'subcategory', + 'verbose_name_plural': 'subcategories', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='Product', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=200, verbose_name='name')), + ('description', models.TextField(verbose_name='description')), + ('price', models.DecimalField(decimal_places=2, max_digits=12, validators=[django.core.validators.MinValueValidator(Decimal('0.01'))], verbose_name='price')), + ('stock', models.PositiveIntegerField(verbose_name='stock')), + ('image', models.ImageField(blank=True, null=True, upload_to='uploads/products/', verbose_name='image')), + ('subcategory', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='products', to='api.Subcategory', verbose_name='subcategory')), + ], + options={ + 'verbose_name': 'product', + 'verbose_name_plural': 'products', + 'ordering': ['subcategory', 'name'], + }, + ), + ] diff --git a/api/models.py b/api/models.py index 1eb8b6d418dd47fca9dc03a3a89cfcdc2db1e04e..b5b99ee27f7117698ba5736626ec9ae386f5de23 100644 --- a/api/models.py +++ b/api/models.py @@ -1,3 +1,4 @@ +import decimal import uuid from django.contrib.auth import models as auth_models @@ -44,6 +45,85 @@ class User(auth_models.AbstractUser): return self.username +class Category(db_models.Model): + id = db_models.UUIDField(default=uuid.uuid4, primary_key=True, verbose_name=_('ID')) + name = db_models.CharField(max_length=50, verbose_name=_('name')) + image = db_models.ImageField( + blank=True, + null=True, + upload_to='uploads/categories/', + verbose_name=_('image') + ) + + class Meta: + ordering = ['name'] + verbose_name = _('category') + verbose_name_plural = _('categories') + + def __str__(self): + return self.name + + +class Subcategory(db_models.Model): + id = db_models.UUIDField(default=uuid.uuid4, primary_key=True, verbose_name=_('ID')) + name = db_models.CharField(max_length=50, verbose_name=_('name')) + category = db_models.ForeignKey( + Category, + on_delete=db_models.CASCADE, + related_name='subcategories', + verbose_name=_('category') + ) + image = db_models.ImageField( + blank=True, + null=True, + upload_to='uploads/subcategories/', + verbose_name=_('image') + ) + + class Meta: + ordering = ['name'] + verbose_name = _('subcategory') + verbose_name_plural = _('subcategories') + + def __str__(self): + return self.name + + +class Product(db_models.Model): + id = db_models.UUIDField(default=uuid.uuid4, primary_key=True, verbose_name=_('ID')) + name = db_models.CharField(max_length=200, verbose_name=_('name')) + subcategory = db_models.ForeignKey( + Subcategory, + blank=True, + null=True, + on_delete=db_models.SET_NULL, + related_name='products', + verbose_name=_('subcategory') + ) + description = db_models.TextField(verbose_name=_('description')) + price = db_models.DecimalField( + decimal_places=2, + max_digits=12, + validators=[validators.MinValueValidator(decimal.Decimal('0.01'))], + verbose_name=_('price') + ) + stock = db_models.PositiveIntegerField(verbose_name=_('stock')) + image = db_models.ImageField( + blank=True, + null=True, + upload_to='uploads/products/', + verbose_name=_('image') + ) + + class Meta: + ordering = ['subcategory', 'name'] + verbose_name = _('product') + verbose_name_plural = _('products') + + def __str__(self): + return self.name + + class Program(db_models.Model): id = db_models.UUIDField(default=uuid.uuid4, primary_key=True, verbose_name=_('ID')) name = db_models.CharField(max_length=200, verbose_name=_('name')) diff --git a/api/serializers.py b/api/serializers.py index 84d2b7bd04fbf6695639742db09871d383271af7..de6722b290d923eb2a3db0fda4000afe953a27c8 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -65,6 +65,44 @@ class UserSerializer(serializers.ModelSerializer): return super().validate(attrs) +class ProductSerializer(serializers.ModelSerializer): + category = serializers.ReadOnlyField(source='subcategory.category.pk') + category_name = serializers.ReadOnlyField(source='subcategory.category.name') + subcategory_name = serializers.ReadOnlyField(source='subcategory.name') + + class Meta: + fields = [ + 'id', + 'name', + 'category', + 'category_name', + 'subcategory', + 'subcategory_name', + 'description', + 'price', + 'stock', + 'image', + ] + model = models.Product + read_only_fields = ['id'] + + +class SubcategorySerializer(serializers.ModelSerializer): + category_name = serializers.ReadOnlyField(source='category.name') + + class Meta: + fields = ['id', 'name', 'category', 'category_name', 'image'] + model = models.Subcategory + read_only_fields = ['id'] + + +class CategorySerializer(serializers.ModelSerializer): + class Meta: + fields = ['id', 'name', 'image'] + model = models.Category + read_only_fields = ['id'] + + class ProgramSerializer(serializers.ModelSerializer): class Meta: fields = [ diff --git a/api/tests.py b/api/tests.py index d28f288b74495f029a6dbb7d20665fbbe5ec62ca..91aa600fc297086a2e6dfc0b7fbce800dfc03f28 100644 --- a/api/tests.py +++ b/api/tests.py @@ -25,6 +25,21 @@ USER_DATA = { 'sub_district': 'Dummy Sub-District', } +CATEGORY_DATA = { + 'name': 'Dummy Category', +} + +SUBCATEGORY_DATA = { + 'name': 'Dummy Subcategory', +} + +PRODUCT_DATA = { + 'name': 'Dummy Product', + 'description': 'Dummy description.', + 'price': '1000.00', + 'stock': 1, +} + PROGRAM_DATA = { 'name': 'Dummy Program', 'description': 'Dummy description.', @@ -253,6 +268,106 @@ class UserTest(rest_framework_test.APITestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) +class ProductTest(rest_framework_test.APITestCase): + def setUp(self): + self.superuser = models.User.objects.create_superuser(**SUPERUSER_DATA) + self.superuser_http_authorization = get_http_authorization( + SUPERUSER_DATA['username'], + SUPERUSER_DATA['password'] + ) + self.category = models.Category.objects.create(**CATEGORY_DATA) + self.subcategory = models.Subcategory.objects.create( + **dict(CATEGORY_DATA, category=self.category) + ) + + def test_category_model_string_representation(self): + category = models.Category.objects.create(**CATEGORY_DATA) + self.assertEqual(str(category), CATEGORY_DATA['name']) + + def test_subcategory_model_string_representation(self): + category = models.Category.objects.create(**CATEGORY_DATA) + subcategory = models.Subcategory.objects.create(**dict(SUBCATEGORY_DATA, category=category)) + self.assertEqual(str(subcategory), SUBCATEGORY_DATA['name']) + + def test_product_model_string_representation(self): + category = models.Category.objects.create(**CATEGORY_DATA) + subcategory = models.Subcategory.objects.create(**dict(SUBCATEGORY_DATA, category=category)) + product = models.Product.objects.create(**dict(PRODUCT_DATA, subcategory=subcategory)) + self.assertEqual(str(product), PRODUCT_DATA['name']) + + def test_product_list_success(self): + http_authorization = self.superuser_http_authorization + url = urls.reverse('product-list') + self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_product_detail_success(self): + product = models.Product.objects.create(**dict(PRODUCT_DATA, subcategory=self.subcategory)) + product_id = str(product.id) + http_authorization = self.superuser_http_authorization + url = urls.reverse('product-detail', args=[product_id]) + self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_create_product_success(self): + http_authorization = self.superuser_http_authorization + data = PRODUCT_DATA + data['subcategory'] = self.subcategory.id + url = urls.reverse('product-list') + self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member + response = self.client.post(url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(models.Product.objects.count(), 1) + product_id = response.json()['id'] + self.assertEqual(models.Product.objects.get(id=product_id).name, data['name']) + + def test_create_product_fail(self): + http_authorization = self.superuser_http_authorization + url = urls.reverse('product-list') + self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member + response = self.client.post(url) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(models.Product.objects.count(), 0) + + def test_update_product_success(self): + http_authorization = self.superuser_http_authorization + data = PRODUCT_DATA + data['subcategory'] = self.subcategory.id + url = urls.reverse('product-list') + self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member + response = self.client.post(url, data, format='json') + product_id = response.json()['id'] + data = { + 'name': 'Dummy', + } + url = urls.reverse('product-detail', args=[product_id]) + response = self.client.patch(url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(models.Product.objects.get(id=product_id).name, data['name']) + data = PRODUCT_DATA + data['subcategory'] = self.subcategory.id + response = self.client.put(url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(models.Product.objects.get(id=product_id).name, data['name']) + + def test_update_product_fail(self): + http_authorization = self.superuser_http_authorization + data = PRODUCT_DATA + data['subcategory'] = self.subcategory.id + url = urls.reverse('product-list') + self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member + response = self.client.post(url, data, format='json') + product_id = response.json()['id'] + data = { + 'name': '', + } + url = urls.reverse('product-detail', args=[product_id]) + response = self.client.patch(url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + class ProgramTest(rest_framework_test.APITestCase): def setUp(self): self.superuser = models.User.objects.create_superuser(**SUPERUSER_DATA) diff --git a/api/urls.py b/api/urls.py index c7db5eca8d23b3eedadd54a99199199d282ba097..7357c4dbf06f13eacb304450bf1edec856174ac5 100644 --- a/api/urls.py +++ b/api/urls.py @@ -17,6 +17,16 @@ urlpatterns = [ urls.path('auth/logout-all/', knox_views.LogoutAllView.as_view(), name='auth-logout-all'), urls.path('users/', api_views.UserList.as_view(), name='user-list'), urls.path('users/<str:pk>/', api_views.UserDetail.as_view(), name='user-detail'), + urls.path('categories/', api_views.CategoryList.as_view(), name='category-list'), + urls.path('categories/<str:pk>/', api_views.CategoryDetail.as_view(), name='category-detail'), + urls.path('subcategories/', api_views.SubcategoryList.as_view(), name='subcategory-list'), + urls.path( + 'subcategories/<str:pk>/', + api_views.SubcategoryDetail.as_view(), + name='subcategory-detail' + ), + urls.path('products/', api_views.ProductList.as_view(), name='product-list'), + urls.path('products/<str:pk>/', api_views.ProductDetail.as_view(), name='product-detail'), urls.path('programs/', api_views.ProgramList.as_view(), name='program-list'), urls.path('programs/<str:pk>/', api_views.ProgramDetail.as_view(), name='program-detail'), urls.path('config/', api_views.ConfigDetail.as_view(), name='config-detail'), diff --git a/api/views.py b/api/views.py index 2cdd99690dc68816f3bf1db19aa86a68aa3e2092..85437846e0c23555b22bf1dc3e99f17d8d850928 100644 --- a/api/views.py +++ b/api/views.py @@ -121,6 +121,87 @@ class UserDetail(generics.RetrieveUpdateDestroyAPIView): return super().get_object() +class CategoryList(generics.ListCreateAPIView): + filter_backends = [ + filters.OrderingFilter, + filters.SearchFilter, + rest_framework.DjangoFilterBackend, + ] + filterset_fields = ['name'] + ordering_fields = ['name'] + pagination_class = paginations.SmallResultsSetPagination + permission_classes = [ + rest_framework_permissions.IsAuthenticated, + api_permissions.IsAdminUserOrReadOnly + ] + queryset = models.Category.objects.all() + search_fields = ['name'] + serializer_class = api_serializers.CategorySerializer + + +class CategoryDetail(generics.RetrieveUpdateDestroyAPIView): + permission_classes = [ + rest_framework_permissions.IsAuthenticated, + api_permissions.IsAdminUserOrReadOnly + ] + queryset = models.Category.objects.all() + serializer_class = api_serializers.CategorySerializer + + +class SubcategoryList(generics.ListCreateAPIView): + filter_backends = [ + filters.OrderingFilter, + filters.SearchFilter, + rest_framework.DjangoFilterBackend, + ] + filterset_fields = ['name', 'category'] + ordering_fields = ['name'] + pagination_class = paginations.SmallResultsSetPagination + permission_classes = [ + rest_framework_permissions.IsAuthenticated, + api_permissions.IsAdminUserOrReadOnly + ] + queryset = models.Subcategory.objects.all() + search_fields = ['name'] + serializer_class = api_serializers.SubcategorySerializer + + +class SubcategoryDetail(generics.RetrieveUpdateDestroyAPIView): + permission_classes = [ + rest_framework_permissions.IsAuthenticated, + api_permissions.IsAdminUserOrReadOnly + ] + queryset = models.Subcategory.objects.all() + serializer_class = api_serializers.SubcategorySerializer + + +class ProductList(generics.ListCreateAPIView): + filter_backends = [ + filters.OrderingFilter, + filters.SearchFilter, + rest_framework.DjangoFilterBackend, + ] + filterset_fields = ['name', 'subcategory', 'subcategory__category'] + ordering_fields = ['name', 'price', 'stock'] + pagination_class = paginations.SmallResultsSetPagination + permission_classes = [ + rest_framework_permissions.IsAuthenticated, + api_permissions.IsAdminUserOrReadOnly + ] + queryset = models.Product.objects.all() + search_fields = ['name'] + serializer_class = api_serializers.ProductSerializer + + +class ProductDetail(generics.RetrieveUpdateDestroyAPIView): + permission_classes = [ + rest_framework_permissions.IsAuthenticated, + api_permissions.IsAdminUserOrReadOnly + ] + queryset = models.Product.objects.all() + serializer_class = api_serializers.ProductSerializer + + class ProgramList(generics.ListCreateAPIView): filter_backends = [ filters.OrderingFilter, diff --git a/locale/id/LC_MESSAGES/django.mo b/locale/id/LC_MESSAGES/django.mo index 33c40fff60af4b9bc07ced661c80e5cf57721eeb..42cb481a339fe44c7014cf8ddecc5110eee0fff7 100644 Binary files a/locale/id/LC_MESSAGES/django.mo and b/locale/id/LC_MESSAGES/django.mo differ diff --git a/locale/id/LC_MESSAGES/django.po b/locale/id/LC_MESSAGES/django.po index ad93c510449c408a824f935c13b86f55df91e8df..4345211b67ba9d1b45b8ed9b3e5d4c7a15a6cb54 100644 --- a/locale/id/LC_MESSAGES/django.po +++ b/locale/id/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2020-03-09 20:50+0700\n" +"POT-Creation-Date: 2020-03-16 18:32+0700\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language-Team: LANGUAGE <LL@li.org>\n" @@ -18,105 +18,142 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: api/models.py:12 api/models.py:48 api/models.py:86 +#: api/models.py:13 api/models.py:49 api/models.py:68 api/models.py:93 +#: api/models.py:128 api/models.py:166 msgid "ID" msgstr "ID" -#: api/models.py:15 +#: api/models.py:16 msgid "full name" msgstr "nama lengkap" -#: api/models.py:16 +#: api/models.py:17 msgid "phone number" msgstr "nomor telepon" -#: api/models.py:17 +#: api/models.py:18 msgid "address" msgstr "alamat" -#: api/models.py:21 +#: api/models.py:22 msgid "neighborhood" msgstr "RT" -#: api/models.py:26 +#: api/models.py:27 msgid "hamlet" msgstr "RW" -#: api/models.py:28 +#: api/models.py:29 msgid "urban village" msgstr "kelurahan" -#: api/models.py:29 +#: api/models.py:30 msgid "sub-district" msgstr "kecamatan" -#: api/models.py:34 +#: api/models.py:35 msgid "profile picture" msgstr "foto profil" -#: api/models.py:36 +#: api/models.py:37 msgid "OTP" msgstr "OTP" -#: api/models.py:40 +#: api/models.py:41 msgid "user" msgstr "pengguna" -#: api/models.py:41 +#: api/models.py:42 msgid "users" msgstr "pengguna" -#: api/models.py:49 +#: api/models.py:50 api/models.py:69 api/models.py:94 api/models.py:129 #, fuzzy #| msgid "full name" msgid "name" msgstr "nama lengkap" -#: api/models.py:50 +#: api/models.py:55 api/models.py:80 api/models.py:115 +msgid "image" +msgstr "gambar" + +#: api/models.py:60 api/models.py:74 +msgid "category" +msgstr "kategori" + +#: api/models.py:61 +msgid "categories" +msgstr "kategori" + +#: api/models.py:85 api/models.py:101 +msgid "subcategory" +msgstr "subkategori" + +#: api/models.py:86 +msgid "subcategories" +msgstr "subkategori" + +#: api/models.py:103 api/models.py:130 msgid "description" msgstr "deskripsi" -#: api/models.py:55 +#: api/models.py:108 +msgid "price" +msgstr "harga" + +#: api/models.py:110 +msgid "stock" +msgstr "stok" + +#: api/models.py:120 +msgid "product" +msgstr "produk" + +#: api/models.py:121 +msgid "products" +msgstr "produk" + +#: api/models.py:135 msgid "start date and time" msgstr "tanggal dan waktu mulai" -#: api/models.py:60 +#: api/models.py:140 msgid "end date and time" msgstr "tanggal dan waktu berakhir" -#: api/models.py:66 +#: api/models.py:146 msgid "location" msgstr "lokasi" -#: api/models.py:68 +#: api/models.py:148 msgid "speaker" msgstr "pembicara" -#: api/models.py:73 +#: api/models.py:153 msgid "poster image" msgstr "gambar poster" -#: api/models.py:78 +#: api/models.py:158 msgid "program" msgstr "program" -#: api/models.py:79 +#: api/models.py:159 msgid "programs" msgstr "program" -#: api/models.py:87 +#: api/models.py:167 msgid "send SMS" msgstr "kirim SMS" -#: api/models.py:90 +#: api/models.py:170 msgid "config" msgstr "konfigurasi" -#: api/models.py:91 +#: api/models.py:171 msgid "configs" msgstr "konfigurasi" -#: api/serializers.py:90 +#: api/serializers.py:126 msgid "End date time should be greater than start date time." msgstr "Waktu tanggal berakhir harus lebih besar dari waktu tanggal mulai."