From 3677481a45cdb538e731b3e3a273437d01c8879c Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Sun, 9 Feb 2020 17:47:23 +0700 Subject: [PATCH 01/90] Initialize project --- .coveragerc | 3 + .gitignore | 134 ++++++++++++++++++++++++++ .gitlab-ci.yml | 46 +++++++++ Procfile | 1 + README.md | 4 + api/__init__.py | 0 api/apps.py | 5 + api/migrations/__init__.py | 0 api/models.py | 0 api/serializers.py | 0 api/tests.py | 9 ++ api/urls.py | 7 ++ api/views.py | 6 ++ home_industry/__init__.py | 0 home_industry/asgi.py | 6 ++ home_industry/settings/__init__.py | 0 home_industry/settings/ci.py | 94 +++++++++++++++++++ home_industry/settings/local.py | 98 +++++++++++++++++++ home_industry/settings/production.py | 135 +++++++++++++++++++++++++++ home_industry/settings/staging.py | 130 ++++++++++++++++++++++++++ home_industry/storages.py | 10 ++ home_industry/urls.py | 5 + home_industry/wsgi.py | 6 ++ manage.py | 21 +++++ requirements.txt | 19 ++++ 25 files changed, 739 insertions(+) create mode 100644 .coveragerc create mode 100644 .gitignore create mode 100644 .gitlab-ci.yml create mode 100644 Procfile create mode 100644 api/__init__.py create mode 100644 api/apps.py create mode 100644 api/migrations/__init__.py create mode 100644 api/models.py create mode 100644 api/serializers.py create mode 100644 api/tests.py create mode 100644 api/urls.py create mode 100644 api/views.py create mode 100644 home_industry/__init__.py create mode 100644 home_industry/asgi.py create mode 100644 home_industry/settings/__init__.py create mode 100644 home_industry/settings/ci.py create mode 100644 home_industry/settings/local.py create mode 100644 home_industry/settings/production.py create mode 100644 home_industry/settings/staging.py create mode 100644 home_industry/storages.py create mode 100644 home_industry/urls.py create mode 100644 home_industry/wsgi.py create mode 100755 manage.py create mode 100644 requirements.txt diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..3ccf6a5 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,3 @@ +[run] +source = + api diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..370685d --- /dev/null +++ b/.gitignore @@ -0,0 +1,134 @@ +# 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/ + +# Elastic Beanstalk Files +.elasticbeanstalk/* +!.elasticbeanstalk/*.cfg.yml +!.elasticbeanstalk/*.global.yml diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..7f41789 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,46 @@ +stages: + - test + - deploy + +test: + image: python:3.6 + stage: test + variables: + DJANGO_SETTINGS_MODULE: $CI_TEST_DJANGO_SETTINGS_MODULE + SECRET_KEY: $CI_TEST_SECRET_KEY + script: + - pip install -r requirements.txt + - python manage.py migrate + - coverage run manage.py test + - coverage report -m + +staging: + image: ruby:2.6 + stage: deploy + variables: + HEROKU_API_KEY: $STAGING_HEROKU_API_KEY + HEROKU_APP: $STAGING_HEROKU_APP + script: + - gem install dpl + - dpl --provider="heroku" --api-key=$HEROKU_API_KEY --app=$HEROKU_APP --run="python manage.py migrate && python manage.py collectstatic --noinput" + only: + - staging + +production: + image: ubuntu:18.04 + stage: deploy + variables: + SSH_PRIVATE_KEY: $PRODUCTION_SSH_PRIVATE_KEY + VPS_PUBLIC_IP_ADDRESS: $PRODUCTION_VPS_PUBLIC_IP_ADDRESS + VPS_USERNAME: $PRODUCTION_VPS_USERNAME + script: + - which ssh-agent || ( apt update -y && apt install openssh-client git -y ) + - eval $(ssh-agent -s) + - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add - + - mkdir -p ~/.ssh + - chmod 700 ~/.ssh + - ssh-keyscan $VPS_PUBLIC_IP_ADDRESS >> ~/.ssh/known_hosts + - chmod 644 ~/.ssh/known_hosts + - ssh $VPS_USERNAME@$VPS_PUBLIC_IP_ADDRESS "~/build-api" + only: + - master diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..2a54d90 --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: gunicorn home_industry.wsgi diff --git a/README.md b/README.md index e69de29..430efbe 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,4 @@ +# Home Industry API + +[![pipeline status](https://gitlab.cs.ui.ac.id/ppl-fasilkom-ui/2020/ppl-c/diskominfo-depok-tpu-online/post-rpl-backend/badges/master/pipeline.svg)](https://gitlab.cs.ui.ac.id/ppl-fasilkom-ui/2020/ppl-c/diskominfo-depok-tpu-online/post-rpl-backend/commits/master) +[![coverage](https://gitlab.cs.ui.ac.id/ppl-fasilkom-ui/2020/ppl-c/diskominfo-depok-tpu-online/post-rpl-backend/badges/master/coverage.svg)](https://gitlab.cs.ui.ac.id/ppl-fasilkom-ui/2020/ppl-c/diskominfo-depok-tpu-online/post-rpl-backend/commits/master) diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/apps.py b/api/apps.py new file mode 100644 index 0000000..c14fe73 --- /dev/null +++ b/api/apps.py @@ -0,0 +1,5 @@ +from django import apps + + +class ApiConfig(apps.AppConfig): + name = 'api' diff --git a/api/migrations/__init__.py b/api/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/models.py b/api/models.py new file mode 100644 index 0000000..e69de29 diff --git a/api/serializers.py b/api/serializers.py new file mode 100644 index 0000000..e69de29 diff --git a/api/tests.py b/api/tests.py new file mode 100644 index 0000000..6b52774 --- /dev/null +++ b/api/tests.py @@ -0,0 +1,9 @@ +from django import urls +from rest_framework import status, test + + +class ApiRootTest(test.APITestCase): + def test_url_exists(self): + url = urls.reverse('api-root') + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) diff --git a/api/urls.py b/api/urls.py new file mode 100644 index 0000000..7477f85 --- /dev/null +++ b/api/urls.py @@ -0,0 +1,7 @@ +from django import urls + +from api import views + +urlpatterns = [ + urls.path('', views.ApiRoot.as_view(), name='api-root'), +] diff --git a/api/views.py b/api/views.py new file mode 100644 index 0000000..15804e6 --- /dev/null +++ b/api/views.py @@ -0,0 +1,6 @@ +from rest_framework import response, views + + +class ApiRoot(views.APIView): + def get(self, request, format=None): + return response.Response({'message': 'Hello, World!'}) diff --git a/home_industry/__init__.py b/home_industry/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/home_industry/asgi.py b/home_industry/asgi.py new file mode 100644 index 0000000..4290904 --- /dev/null +++ b/home_industry/asgi.py @@ -0,0 +1,6 @@ +import os + +from django.core import asgi + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'home_industry.settings') +application = asgi.get_asgi_application() diff --git a/home_industry/settings/__init__.py b/home_industry/settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/home_industry/settings/ci.py b/home_industry/settings/ci.py new file mode 100644 index 0000000..e259913 --- /dev/null +++ b/home_industry/settings/ci.py @@ -0,0 +1,94 @@ +import os + +BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/ + +SECRET_KEY = os.environ['SECRET_KEY'] + +DEBUG = True + +ALLOWED_HOSTS = ['127.0.0.1', 'localhost'] + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'rest_framework', + 'api.apps.ApiConfig', + 'django_cleanup', +] + +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 = 'home_industry.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 = 'home_industry.wsgi.application' + +# Database +# https://docs.djangoproject.com/en/3.0/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), + }, +} + +# Password validation +# https://docs.djangoproject.com/en/3.0/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [] + +# Internationalization +# https://docs.djangoproject.com/en/3.0/topics/i18n/ + +LANGUAGE_CODE = 'id' + +TIME_ZONE = 'Asia/Jakarta' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/3.0/howto/static-files/ + +MEDIA_ROOT = os.path.join(BASE_DIR, 'media') + +MEDIA_URL = '/media/' + +STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles') + +STATIC_URL = '/static/' diff --git a/home_industry/settings/local.py b/home_industry/settings/local.py new file mode 100644 index 0000000..c14d072 --- /dev/null +++ b/home_industry/settings/local.py @@ -0,0 +1,98 @@ +import os + +BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/ + +SECRET_KEY = os.environ['SECRET_KEY'] + +DEBUG = os.environ.get('DEBUG', True) != "False" + +ALLOWED_HOSTS = ['127.0.0.1', 'localhost'] + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'rest_framework', + 'api.apps.ApiConfig', + 'django_cleanup', +] + +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 = 'home_industry.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 = 'home_industry.wsgi.application' + +# Database +# https://docs.djangoproject.com/en/3.0/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': os.environ['DATABASE_NAME'], + 'USER': os.environ['DATABASE_USER'], + 'PASSWORD': os.environ['DATABASE_PASSWORD'], + 'HOST': os.environ['DATABASE_HOST'], + 'PORT': os.environ['DATABASE_PORT'], + }, +} + +# Password validation +# https://docs.djangoproject.com/en/3.0/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [] + +# Internationalization +# https://docs.djangoproject.com/en/3.0/topics/i18n/ + +LANGUAGE_CODE = 'id' + +TIME_ZONE = 'Asia/Jakarta' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/3.0/howto/static-files/ + +MEDIA_ROOT = os.path.join(BASE_DIR, 'media') + +MEDIA_URL = '/media/' + +STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles') + +STATIC_URL = '/static/' diff --git a/home_industry/settings/production.py b/home_industry/settings/production.py new file mode 100644 index 0000000..f5a9283 --- /dev/null +++ b/home_industry/settings/production.py @@ -0,0 +1,135 @@ +import os + +BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/ + +SECRET_KEY = os.environ['SECRET_KEY'] + +DEBUG = False + +ALLOWED_HOSTS = ['industripilar.com', 'www.industripilar.com'] + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'rest_framework', + 'storages', + 'api.apps.ApiConfig', + 'django_cleanup', +] + +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 = 'home_industry.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 = 'home_industry.wsgi.application' + +# Database +# https://docs.djangoproject.com/en/3.0/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': os.environ['DATABASE_NAME'], + 'USER': os.environ['DATABASE_USER'], + 'PASSWORD': os.environ['DATABASE_PASSWORD'], + 'HOST': os.environ['DATABASE_HOST'], + 'PORT': os.environ['DATABASE_PORT'], + }, +} + +# Password validation +# https://docs.djangoproject.com/en/3.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/3.0/topics/i18n/ + +LANGUAGE_CODE = 'id' + +TIME_ZONE = 'Asia/Jakarta' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + +# Security +# https://docs.djangoproject.com/en/3.0/topics/security/ + +CSRF_COOKIE_SECURE = True + +SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') + +SECURE_SSL_REDIRECT = True + +SESSION_COOKIE_SECURE = True + +# django-storages +# https://github.com/jschneier/django-storages + +DEFAULT_FILE_STORAGE = 'home_industry.storages.MediaStorage' + +STATICFILES_STORAGE = 'home_industry.storages.StaticStorage' + +AWS_ACCESS_KEY_ID = os.environ['AWS_ACCESS_KEY_ID'] + +AWS_SECRET_ACCESS_KEY = os.environ['AWS_SECRET_ACCESS_KEY'] + +AWS_STORAGE_BUCKET_NAME = os.environ['AWS_STORAGE_BUCKET_NAME'] + +AWS_DEFAULT_ACL = None + +AWS_S3_OBJECT_PARAMETERS = { + 'CacheControl': 'max-age=86400', +} + +MEDIA_LOCATION = 'media' + +STATIC_LOCATION = 'static' diff --git a/home_industry/settings/staging.py b/home_industry/settings/staging.py new file mode 100644 index 0000000..f29cdb9 --- /dev/null +++ b/home_industry/settings/staging.py @@ -0,0 +1,130 @@ +import os + +import dj_database_url + +BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/ + +SECRET_KEY = os.environ['SECRET_KEY'] + +DEBUG = os.environ.get('DEBUG', True) != "False" + +ALLOWED_HOSTS = ['.herokuapp.com'] + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'rest_framework', + 'storages', + 'api.apps.ApiConfig', + 'django_cleanup', +] + +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 = 'home_industry.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 = 'home_industry.wsgi.application' + +# Database +# https://docs.djangoproject.com/en/3.0/ref/settings/#databases + +DATABASES = { + 'default': dj_database_url.config(conn_max_age=600, ssl_require=True), +} + +# Password validation +# https://docs.djangoproject.com/en/3.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/3.0/topics/i18n/ + +LANGUAGE_CODE = 'id' + +TIME_ZONE = 'Asia/Jakarta' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + +# Security +# https://docs.djangoproject.com/en/3.0/topics/security/ + +CSRF_COOKIE_SECURE = True + +SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') + +SECURE_SSL_REDIRECT = True + +SESSION_COOKIE_SECURE = True + +# django-storages +# https://github.com/jschneier/django-storages + +DEFAULT_FILE_STORAGE = 'home_industry.storages.MediaStorage' + +STATICFILES_STORAGE = 'home_industry.storages.StaticStorage' + +AWS_ACCESS_KEY_ID = os.environ['AWS_ACCESS_KEY_ID'] + +AWS_SECRET_ACCESS_KEY = os.environ['AWS_SECRET_ACCESS_KEY'] + +AWS_STORAGE_BUCKET_NAME = os.environ['AWS_STORAGE_BUCKET_NAME'] + +AWS_DEFAULT_ACL = None + +AWS_S3_OBJECT_PARAMETERS = { + 'CacheControl': 'max-age=86400', +} + +MEDIA_LOCATION = 'media' + +STATIC_LOCATION = 'static' diff --git a/home_industry/storages.py b/home_industry/storages.py new file mode 100644 index 0000000..435c34b --- /dev/null +++ b/home_industry/storages.py @@ -0,0 +1,10 @@ +from django import conf +from storages.backends import s3boto3 + + +class MediaStorage(s3boto3.S3Boto3Storage): + location = conf.settings.MEDIA_LOCATION + + +class StaticStorage(s3boto3.S3Boto3Storage): + location = conf.settings.STATIC_LOCATION diff --git a/home_industry/urls.py b/home_industry/urls.py new file mode 100644 index 0000000..3b7f271 --- /dev/null +++ b/home_industry/urls.py @@ -0,0 +1,5 @@ +from django import urls + +urlpatterns = [ + urls.path('', urls.include('api.urls')), +] diff --git a/home_industry/wsgi.py b/home_industry/wsgi.py new file mode 100644 index 0000000..961d496 --- /dev/null +++ b/home_industry/wsgi.py @@ -0,0 +1,6 @@ +import os + +from django.core import wsgi + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'home_industry.settings') +application = wsgi.get_wsgi_application() diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..c14fb00 --- /dev/null +++ b/manage.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'home_industry.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7433945 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,19 @@ +asgiref==3.2.3 +boto3==1.11.12 +botocore==1.14.12 +coverage==5.0.3 +dj-database-url==0.5.0 +Django==3.0.3 +django-cleanup==4.0.0 +django-storages==1.9.1 +djangorestframework==3.11.0 +docutils==0.15.2 +gunicorn==20.0.4 +jmespath==0.9.4 +psycopg2-binary==2.8.4 +python-dateutil==2.8.1 +pytz==2019.3 +s3transfer==0.3.3 +six==1.14.0 +sqlparse==0.3.0 +urllib3==1.25.8 -- GitLab From 70684b8a3677f143260b2904c5a1257151badca0 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Sun, 9 Feb 2020 22:12:32 +0700 Subject: [PATCH 02/90] [CHORES] Update production.py --- home_industry/settings/production.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/home_industry/settings/production.py b/home_industry/settings/production.py index f5a9283..2e0edf0 100644 --- a/home_industry/settings/production.py +++ b/home_industry/settings/production.py @@ -9,7 +9,7 @@ SECRET_KEY = os.environ['SECRET_KEY'] DEBUG = False -ALLOWED_HOSTS = ['industripilar.com', 'www.industripilar.com'] +ALLOWED_HOSTS = ['api.industripilar.com'] # Application definition -- GitLab From fb4e77918968feb7405f7185e939dea584994340 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Mon, 10 Feb 2020 13:58:47 +0700 Subject: [PATCH 03/90] [CHORES] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 430efbe..77d6e0a 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ # Home Industry API -[![pipeline status](https://gitlab.cs.ui.ac.id/ppl-fasilkom-ui/2020/ppl-c/diskominfo-depok-tpu-online/post-rpl-backend/badges/master/pipeline.svg)](https://gitlab.cs.ui.ac.id/ppl-fasilkom-ui/2020/ppl-c/diskominfo-depok-tpu-online/post-rpl-backend/commits/master) -[![coverage](https://gitlab.cs.ui.ac.id/ppl-fasilkom-ui/2020/ppl-c/diskominfo-depok-tpu-online/post-rpl-backend/badges/master/coverage.svg)](https://gitlab.cs.ui.ac.id/ppl-fasilkom-ui/2020/ppl-c/diskominfo-depok-tpu-online/post-rpl-backend/commits/master) +[![pipeline status](https://gitlab.cs.ui.ac.id/ppl-fasilkom-ui/2020/ppl-c/diskominfo-depok-tpu-online/post-rpl-backend/badges/master/pipeline.svg)](https://gitlab.cs.ui.ac.id/ppl-fasilkom-ui/2020/ppl-c/diskominfo-depok-tpu-online/post-rpl-backend/-/commits/master) +[![coverage report](https://gitlab.cs.ui.ac.id/ppl-fasilkom-ui/2020/ppl-c/diskominfo-depok-tpu-online/post-rpl-backend/badges/master/coverage.svg)](https://gitlab.cs.ui.ac.id/ppl-fasilkom-ui/2020/ppl-c/diskominfo-depok-tpu-online/post-rpl-backend/-/commits/master) -- GitLab From 3f23af9c417b9cc51b963cd8ced17c362656828b Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Mon, 17 Feb 2020 13:33:51 +0700 Subject: [PATCH 04/90] [CHORES] Add lint stage for CI --- .gitlab-ci.yml | 14 +++++++++++++- .pylintrc | 9 +++++++++ requirements.txt | 9 +++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 .pylintrc diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 7f41789..c464187 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,7 +1,19 @@ stages: + - lint - test - deploy +lint: + image: python:3.6 + stage: lint + variables: + DJANGO_SETTINGS_MODULE: $CI_TEST_DJANGO_SETTINGS_MODULE + SECRET_KEY: $CI_TEST_SECRET_KEY + script: + - pip install -r requirements.txt + - python manage.py migrate + - pylint api + test: image: python:3.6 stage: test @@ -22,7 +34,7 @@ staging: HEROKU_APP: $STAGING_HEROKU_APP script: - gem install dpl - - dpl --provider="heroku" --api-key=$HEROKU_API_KEY --app=$HEROKU_APP --run="python manage.py migrate && python manage.py collectstatic --noinput" + - dpl --provider=heroku --api-key=$HEROKU_API_KEY --app=$HEROKU_APP --run="python manage.py migrate && python manage.py collectstatic --noinput" only: - staging diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..9d11776 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,9 @@ +[MASTER] +disable= + missing-module-docstring, + missing-class-docstring, + missing-function-docstring +ignore= + env +load-plugins= + pylint_django diff --git a/requirements.txt b/requirements.txt index 7433945..013d494 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ asgiref==3.2.3 +astroid==2.3.3 boto3==1.11.12 botocore==1.14.12 coverage==5.0.3 @@ -9,11 +10,19 @@ django-storages==1.9.1 djangorestframework==3.11.0 docutils==0.15.2 gunicorn==20.0.4 +isort==4.3.21 jmespath==0.9.4 +lazy-object-proxy==1.4.3 +mccabe==0.6.1 psycopg2-binary==2.8.4 +pylint==2.4.4 +pylint-django==2.0.13 +pylint-plugin-utils==0.6 python-dateutil==2.8.1 pytz==2019.3 s3transfer==0.3.3 six==1.14.0 sqlparse==0.3.0 +typed-ast==1.4.1 urllib3==1.25.8 +wrapt==1.11.2 -- GitLab From 7e0b149be8117743504f68806736d28c6ba93fad Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Mon, 17 Feb 2020 14:04:26 +0700 Subject: [PATCH 05/90] [CHORES] Fix .gitlab-ci.yml --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index c464187..8af0bde 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -12,7 +12,7 @@ lint: script: - pip install -r requirements.txt - python manage.py migrate - - pylint api + - pylint --exit-zero api test: image: python:3.6 -- GitLab From ccbc8ac68bb9d330fbc6bcf925f0628692076377 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Mon, 17 Feb 2020 15:44:55 +0700 Subject: [PATCH 06/90] [CHORES] Add SonarQube integration --- .gitlab-ci.yml | 10 ++++++++++ README.md | 4 ++-- sonar-project.properties | 3 +++ 3 files changed, 15 insertions(+), 2 deletions(-) create mode 100644 sonar-project.properties diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 8af0bde..f03ce46 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -26,6 +26,16 @@ test: - coverage run manage.py test - coverage report -m +sonar_scanner: + image: sonarsource/sonar-scanner-cli + stage: test + script: + - sonar-scanner + -Dsonar.projectKey=$SONARQUBE_PROJECT_KEY + -Dsonar.host.url=​$SONARQUBE_HOST_URL + -Dsonar.login=$SONARQUBE_TOKEN + -Dsonar.branch.name=$CI_COMMIT_REF_NAME + staging: image: ruby:2.6 stage: deploy diff --git a/README.md b/README.md index 77d6e0a..3946cf3 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ # Home Industry API -[![pipeline status](https://gitlab.cs.ui.ac.id/ppl-fasilkom-ui/2020/ppl-c/diskominfo-depok-tpu-online/post-rpl-backend/badges/master/pipeline.svg)](https://gitlab.cs.ui.ac.id/ppl-fasilkom-ui/2020/ppl-c/diskominfo-depok-tpu-online/post-rpl-backend/-/commits/master) -[![coverage report](https://gitlab.cs.ui.ac.id/ppl-fasilkom-ui/2020/ppl-c/diskominfo-depok-tpu-online/post-rpl-backend/badges/master/coverage.svg)](https://gitlab.cs.ui.ac.id/ppl-fasilkom-ui/2020/ppl-c/diskominfo-depok-tpu-online/post-rpl-backend/-/commits/master) +[![pipeline status](https://gitlab.cs.ui.ac.id/ppl-fasilkom-ui/2020/ppl-c/diskominfo-depok-tpu-online/post-rpl-backend/badges/master/pipeline.svg)](https://gitlab.cs.ui.ac.id/ppl-fasilkom-ui/2020/ppl-c/diskominfo-depok-tpu-online/post-rpl-backend/commits/master) +[![coverage report](https://gitlab.cs.ui.ac.id/ppl-fasilkom-ui/2020/ppl-c/diskominfo-depok-tpu-online/post-rpl-backend/badges/master/coverage.svg)](https://gitlab.cs.ui.ac.id/ppl-fasilkom-ui/2020/ppl-c/diskominfo-depok-tpu-online/post-rpl-backend/commits/master) diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..3b21a53 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,3 @@ +sonar.scm.provider=git +sonar.sourceEncoding=UTF-8 +sonar.sources=. -- GitLab From afb2d3c032d70480cb01d062fa7230ebe1b6db24 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Mon, 17 Feb 2020 15:50:46 +0700 Subject: [PATCH 07/90] [CHORES] Fix .gitlab-ci.yml --- .gitlab-ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index f03ce46..a3a126d 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -35,6 +35,7 @@ sonar_scanner: -Dsonar.host.url=​$SONARQUBE_HOST_URL -Dsonar.login=$SONARQUBE_TOKEN -Dsonar.branch.name=$CI_COMMIT_REF_NAME + -Dproject.settings=sonar-project.properties staging: image: ruby:2.6 -- GitLab From d063142990abc9038e0ce83486d9c20b362915d6 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Mon, 17 Feb 2020 15:54:20 +0700 Subject: [PATCH 08/90] [CHORES] Fix .gitlab-ci.yml --- .gitlab-ci.yml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index a3a126d..b8f211e 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -30,12 +30,7 @@ sonar_scanner: image: sonarsource/sonar-scanner-cli stage: test script: - - sonar-scanner - -Dsonar.projectKey=$SONARQUBE_PROJECT_KEY - -Dsonar.host.url=​$SONARQUBE_HOST_URL - -Dsonar.login=$SONARQUBE_TOKEN - -Dsonar.branch.name=$CI_COMMIT_REF_NAME - -Dproject.settings=sonar-project.properties + - sonar-scanner -Dsonar.projectKey=$SONARQUBE_PROJECT_KEY -Dsonar.host.url=​$SONARQUBE_HOST_URL -Dsonar.login=$SONARQUBE_TOKEN -Dsonar.branch.name=$CI_COMMIT_REF_NAME -Dproject.settings=sonar-project.properties staging: image: ruby:2.6 -- GitLab From 79149c43fe818b0a4ddb3311cbbd56c90192fc5b Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Mon, 17 Feb 2020 16:12:34 +0700 Subject: [PATCH 09/90] [CHORES] Fix .gitlab-ci.yml --- .gitlab-ci.yml | 2 +- sonar-project.properties | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index b8f211e..866b59c 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -30,7 +30,7 @@ sonar_scanner: image: sonarsource/sonar-scanner-cli stage: test script: - - sonar-scanner -Dsonar.projectKey=$SONARQUBE_PROJECT_KEY -Dsonar.host.url=​$SONARQUBE_HOST_URL -Dsonar.login=$SONARQUBE_TOKEN -Dsonar.branch.name=$CI_COMMIT_REF_NAME -Dproject.settings=sonar-project.properties + - sonar-scanner -Dsonar.projectKey=$SONARQUBE_PROJECT_KEY -Dsonar.sources=. -Dsonar.host.url=​$SONARQUBE_HOST_URL -Dsonar.login=$SONARQUBE_TOKEN -Dsonar.branch.name=$CI_COMMIT_REF_NAME staging: image: ruby:2.6 diff --git a/sonar-project.properties b/sonar-project.properties index 3b21a53..7a118de 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,3 +1,2 @@ sonar.scm.provider=git sonar.sourceEncoding=UTF-8 -sonar.sources=. -- GitLab From 4ae9a143cda12811de870dcf2ab63bc0b766c9aa Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Mon, 17 Feb 2020 16:19:18 +0700 Subject: [PATCH 10/90] [CHORES] Fix .gitlab-ci.yml --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 866b59c..fbf6ed1 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -27,7 +27,7 @@ test: - coverage report -m sonar_scanner: - image: sonarsource/sonar-scanner-cli + image: addianto/sonar-scanner-cli:latest stage: test script: - sonar-scanner -Dsonar.projectKey=$SONARQUBE_PROJECT_KEY -Dsonar.sources=. -Dsonar.host.url=​$SONARQUBE_HOST_URL -Dsonar.login=$SONARQUBE_TOKEN -Dsonar.branch.name=$CI_COMMIT_REF_NAME -- GitLab From 41c73cf71a11fda92c4126cfc79d438140fd5075 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Mon, 17 Feb 2020 16:34:06 +0700 Subject: [PATCH 11/90] [CHORES] Fix .gitlab-ci.yml --- .gitlab-ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index fbf6ed1..866dfae 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -27,10 +27,10 @@ test: - coverage report -m sonar_scanner: - image: addianto/sonar-scanner-cli:latest + image: ciricihq/gitlab-sonar-scanner stage: test script: - - sonar-scanner -Dsonar.projectKey=$SONARQUBE_PROJECT_KEY -Dsonar.sources=. -Dsonar.host.url=​$SONARQUBE_HOST_URL -Dsonar.login=$SONARQUBE_TOKEN -Dsonar.branch.name=$CI_COMMIT_REF_NAME + - gitlab-sonar-scanner -Dsonar.projectKey=$SONARQUBE_PROJECT_KEY -Dsonar.sources=. -Dsonar.host.url=​$SONARQUBE_HOST_URL -Dsonar.login=$SONARQUBE_TOKEN -Dsonar.branch.name=$CI_COMMIT_REF_NAME staging: image: ruby:2.6 -- GitLab From 522fa49b73bc64fd0a2e08a47fcd76f375f62431 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Mon, 17 Feb 2020 16:45:26 +0700 Subject: [PATCH 12/90] [CHORES] Fix .gitlab-ci.yml --- .gitlab-ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 866dfae..dbd6277 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -29,6 +29,8 @@ test: sonar_scanner: image: ciricihq/gitlab-sonar-scanner stage: test + variables: + SONAR_URL: $SONARQUBE_HOST_URL script: - gitlab-sonar-scanner -Dsonar.projectKey=$SONARQUBE_PROJECT_KEY -Dsonar.sources=. -Dsonar.host.url=​$SONARQUBE_HOST_URL -Dsonar.login=$SONARQUBE_TOKEN -Dsonar.branch.name=$CI_COMMIT_REF_NAME -- GitLab From 24558dd30b00baf19ddb8da0182a1b58315922c0 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Mon, 17 Feb 2020 17:00:19 +0700 Subject: [PATCH 13/90] [CHORES] Fix .gitlab-ci.yml --- .gitlab-ci.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index dbd6277..d15005b 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,5 +1,4 @@ stages: - - lint - test - deploy @@ -27,12 +26,14 @@ test: - coverage report -m sonar_scanner: - image: ciricihq/gitlab-sonar-scanner + image: + name: sonarsource/sonar-scanner-cli:latest + entrypoint: [""] stage: test variables: - SONAR_URL: $SONARQUBE_HOST_URL + GIT_DEPTH: 0 script: - - gitlab-sonar-scanner -Dsonar.projectKey=$SONARQUBE_PROJECT_KEY -Dsonar.sources=. -Dsonar.host.url=​$SONARQUBE_HOST_URL -Dsonar.login=$SONARQUBE_TOKEN -Dsonar.branch.name=$CI_COMMIT_REF_NAME + - sonar-scanner -Dsonar.projectKey=$SONAR_PROJECT_KEY -Dsonar.sources=. -Dsonar.host.url=​$SONAR_HOST_URL -Dsonar.login=$SONAR_TOKEN -Dsonar.branch.name=$CI_COMMIT_REF_NAME staging: image: ruby:2.6 -- GitLab From f84ef34e0580ba095d8820d48e7b02c34a8ed262 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Mon, 17 Feb 2020 17:02:13 +0700 Subject: [PATCH 14/90] [CHORES] Fix .gitlab-ci.yml --- .gitlab-ci.yml | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index d15005b..92b8d6b 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -2,17 +2,6 @@ stages: - test - deploy -lint: - image: python:3.6 - stage: lint - variables: - DJANGO_SETTINGS_MODULE: $CI_TEST_DJANGO_SETTINGS_MODULE - SECRET_KEY: $CI_TEST_SECRET_KEY - script: - - pip install -r requirements.txt - - python manage.py migrate - - pylint --exit-zero api - test: image: python:3.6 stage: test -- GitLab From 5e3d4c30d24ceb6b084742dbfa1026f06a24387b Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Mon, 17 Feb 2020 17:02:56 +0700 Subject: [PATCH 15/90] [CHORES] Fix .gitlab-ci.yml --- .gitlab-ci.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 92b8d6b..c300f05 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -19,8 +19,6 @@ sonar_scanner: name: sonarsource/sonar-scanner-cli:latest entrypoint: [""] stage: test - variables: - GIT_DEPTH: 0 script: - sonar-scanner -Dsonar.projectKey=$SONAR_PROJECT_KEY -Dsonar.sources=. -Dsonar.host.url=​$SONAR_HOST_URL -Dsonar.login=$SONAR_TOKEN -Dsonar.branch.name=$CI_COMMIT_REF_NAME -- GitLab From d0fc14d4815ccd2d29b8b55bc2e9f2752bedd927 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Mon, 17 Feb 2020 17:08:15 +0700 Subject: [PATCH 16/90] [CHORES] Fix .gitlab-ci.yml --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index c300f05..acc0b7e 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -20,7 +20,7 @@ sonar_scanner: entrypoint: [""] stage: test script: - - sonar-scanner -Dsonar.projectKey=$SONAR_PROJECT_KEY -Dsonar.sources=. -Dsonar.host.url=​$SONAR_HOST_URL -Dsonar.login=$SONAR_TOKEN -Dsonar.branch.name=$CI_COMMIT_REF_NAME + - sonar-scanner -Dsonar.projectKey="$SONAR_PROJECT_KEY" -Dsonar.sources=. -Dsonar.host.url=​"$SONAR_HOST_URL" -Dsonar.login="$SONAR_TOKEN" -Dsonar.branch.name=$CI_COMMIT_REF_NAME staging: image: ruby:2.6 -- GitLab From 473df00dfdbb66abbd720996f5d22399610ec088 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Mon, 17 Feb 2020 17:10:00 +0700 Subject: [PATCH 17/90] [CHORES] Fix .gitlab-ci.yml --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index acc0b7e..c7e3ca6 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -20,7 +20,7 @@ sonar_scanner: entrypoint: [""] stage: test script: - - sonar-scanner -Dsonar.projectKey="$SONAR_PROJECT_KEY" -Dsonar.sources=. -Dsonar.host.url=​"$SONAR_HOST_URL" -Dsonar.login="$SONAR_TOKEN" -Dsonar.branch.name=$CI_COMMIT_REF_NAME + - sonar-scanner -Dsonar.projectKey=$SONAR_PROJECT_KEY -Dsonar.sources=. -Dsonar.host.url=​"https://pmpl.cs.ui.ac.id/sonarqube" -Dsonar.login=$SONAR_TOKEN -Dsonar.branch.name=$CI_COMMIT_REF_NAME staging: image: ruby:2.6 -- GitLab From 48fc2b35d835326446b092aa095f0c98f7757da0 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Mon, 17 Feb 2020 17:11:31 +0700 Subject: [PATCH 18/90] [CHORES] Fix .gitlab-ci.yml --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index c7e3ca6..5a32586 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -20,7 +20,7 @@ sonar_scanner: entrypoint: [""] stage: test script: - - sonar-scanner -Dsonar.projectKey=$SONAR_PROJECT_KEY -Dsonar.sources=. -Dsonar.host.url=​"https://pmpl.cs.ui.ac.id/sonarqube" -Dsonar.login=$SONAR_TOKEN -Dsonar.branch.name=$CI_COMMIT_REF_NAME + - sonar-scanner -Dsonar.projectKey=$SONAR_PROJECT_KEY -Dsonar.sources=. -Dsonar.host.url="https://pmpl.cs.ui.ac.id/sonarqube" -Dsonar.login=$SONAR_TOKEN -Dsonar.branch.name=$CI_COMMIT_REF_NAME staging: image: ruby:2.6 -- GitLab From e29ae4c8e91af088b0998693882ca1627625e472 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Mon, 17 Feb 2020 17:19:34 +0700 Subject: [PATCH 19/90] [CHORES] Fix .gitlab-ci.yml --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5a32586..fa434ce 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -20,7 +20,7 @@ sonar_scanner: entrypoint: [""] stage: test script: - - sonar-scanner -Dsonar.projectKey=$SONAR_PROJECT_KEY -Dsonar.sources=. -Dsonar.host.url="https://pmpl.cs.ui.ac.id/sonarqube" -Dsonar.login=$SONAR_TOKEN -Dsonar.branch.name=$CI_COMMIT_REF_NAME + - sonar-scanner -Dsonar.sources=. -Dsonar.login=$SONAR_TOKEN -Dsonar.branch.name=$CI_COMMIT_REF_NAME staging: image: ruby:2.6 -- GitLab From fd4cf20fe22b42dd87e48979a56b75d34bf20713 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Mon, 17 Feb 2020 17:20:50 +0700 Subject: [PATCH 20/90] [CHORES] Fix .gitlab-ci.yml --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index fa434ce..594f4ca 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -20,7 +20,7 @@ sonar_scanner: entrypoint: [""] stage: test script: - - sonar-scanner -Dsonar.sources=. -Dsonar.login=$SONAR_TOKEN -Dsonar.branch.name=$CI_COMMIT_REF_NAME + - sonar-scanner -Dsonar.projectKey=$SONAR_PROJECT_KEY -Dsonar.sources=. -Dsonar.host.url="$SONAR_HOST_URL" -Dsonar.login=$SONAR_TOKEN -Dsonar.branch.name=$CI_COMMIT_REF_NAME staging: image: ruby:2.6 -- GitLab From 5a1cefd3f6f436e4bbc9f75463ad9337cf157d51 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Mon, 17 Feb 2020 17:29:49 +0700 Subject: [PATCH 21/90] [CHORES] Delete sonar-project.properties --- sonar-project.properties | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 sonar-project.properties diff --git a/sonar-project.properties b/sonar-project.properties deleted file mode 100644 index 7a118de..0000000 --- a/sonar-project.properties +++ /dev/null @@ -1,2 +0,0 @@ -sonar.scm.provider=git -sonar.sourceEncoding=UTF-8 -- GitLab From b0dde66ce99f470ea01b590bb5fbf0341fead7d2 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Mon, 17 Feb 2020 17:33:21 +0700 Subject: [PATCH 22/90] [CHORES] Fix .gitlab-ci.yml --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 594f4ca..0f81a41 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -20,7 +20,7 @@ sonar_scanner: entrypoint: [""] stage: test script: - - sonar-scanner -Dsonar.projectKey=$SONAR_PROJECT_KEY -Dsonar.sources=. -Dsonar.host.url="$SONAR_HOST_URL" -Dsonar.login=$SONAR_TOKEN -Dsonar.branch.name=$CI_COMMIT_REF_NAME + - sonar-scanner -Dsonar.projectKey=$SONAR_PROJECT_KEY -Dsonar.sources=. -Dsonar.branch.name=$CI_COMMIT_REF_NAME staging: image: ruby:2.6 -- GitLab From 016a9dc3485bb519a460e5e9fd4819bfe57a5605 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Mon, 17 Feb 2020 17:35:22 +0700 Subject: [PATCH 23/90] [CHORES] Fix .gitlab-ci.yml --- .gitlab-ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 0f81a41..ac41129 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -20,6 +20,7 @@ sonar_scanner: entrypoint: [""] stage: test script: + - echo "sonar-scanner -Dsonar.projectKey=$SONAR_PROJECT_KEY -Dsonar.sources=. -Dsonar.branch.name=$CI_COMMIT_REF_NAME" - sonar-scanner -Dsonar.projectKey=$SONAR_PROJECT_KEY -Dsonar.sources=. -Dsonar.branch.name=$CI_COMMIT_REF_NAME staging: -- GitLab From e6e10367df36c545120714c695d912cdd612c606 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Mon, 17 Feb 2020 17:38:53 +0700 Subject: [PATCH 24/90] [CHORES] Fix .gitlab-ci.yml --- .gitlab-ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index ac41129..cc8dec2 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -20,8 +20,8 @@ sonar_scanner: entrypoint: [""] stage: test script: - - echo "sonar-scanner -Dsonar.projectKey=$SONAR_PROJECT_KEY -Dsonar.sources=. -Dsonar.branch.name=$CI_COMMIT_REF_NAME" - - sonar-scanner -Dsonar.projectKey=$SONAR_PROJECT_KEY -Dsonar.sources=. -Dsonar.branch.name=$CI_COMMIT_REF_NAME + - echo "sonar-scanner -Dsonar.projectKey=$SONAR_PROJECT_KEY -Dsonar.sources=. -Dsonar.host.url=$SONAR_HOST_URL -Dsonar.login=$SONAR_TOKEN -Dsonar.branch.name=$CI_COMMIT_REF_NAME" + - sonar-scanner -Dsonar.projectKey=$SONAR_PROJECT_KEY -Dsonar.sources=. -Dsonar.host.url=$SONAR_HOST_URL -Dsonar.login=$SONAR_TOKEN -Dsonar.branch.name=$CI_COMMIT_REF_NAME staging: image: ruby:2.6 -- GitLab From f0e9d4a5b3412acf81186888bb64593770c5ea7a Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Mon, 17 Feb 2020 17:43:27 +0700 Subject: [PATCH 25/90] [CHORES] Fix .gitlab-ci.yml --- .gitlab-ci.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index cc8dec2..d718865 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -20,8 +20,7 @@ sonar_scanner: entrypoint: [""] stage: test script: - - echo "sonar-scanner -Dsonar.projectKey=$SONAR_PROJECT_KEY -Dsonar.sources=. -Dsonar.host.url=$SONAR_HOST_URL -Dsonar.login=$SONAR_TOKEN -Dsonar.branch.name=$CI_COMMIT_REF_NAME" - - sonar-scanner -Dsonar.projectKey=$SONAR_PROJECT_KEY -Dsonar.sources=. -Dsonar.host.url=$SONAR_HOST_URL -Dsonar.login=$SONAR_TOKEN -Dsonar.branch.name=$CI_COMMIT_REF_NAME + - sonar-scanner -Dsonar.projectKey=$SONAR_PROJECT_KEY -Dsonar.sources=. -Dsonar.host.url=$SONAR_HOST_URL -Dsonar.login=$SONAR_TOKEN staging: image: ruby:2.6 -- GitLab From 90c3368c548bf99a8b7f5d23189d6972fb846d85 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Mon, 17 Feb 2020 17:46:47 +0700 Subject: [PATCH 26/90] [CHORES] Fix .gitlab-ci.yml --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index d718865..51cc957 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -20,7 +20,7 @@ sonar_scanner: entrypoint: [""] stage: test script: - - sonar-scanner -Dsonar.projectKey=$SONAR_PROJECT_KEY -Dsonar.sources=. -Dsonar.host.url=$SONAR_HOST_URL -Dsonar.login=$SONAR_TOKEN + - sonar-scanner -Dsonar.projectKey=$SONAR_PROJECT_KEY -Dsonar.sources=. -Dsonar.host.url=$SONAR_HOST_URL -Dsonar.login=$SONARQUBE_TOKEN staging: image: ruby:2.6 -- GitLab From 4eca6f21c981b21eff4215d32338534ca6de024d Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Mon, 17 Feb 2020 17:50:45 +0700 Subject: [PATCH 27/90] [CHORES] Fix .gitlab-ci.yml --- .gitlab-ci.yml | 14 +++++++++++++- sonar-project.properties | 2 ++ 2 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 sonar-project.properties diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 51cc957..fd3cea8 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,7 +1,19 @@ stages: + - lint - test - deploy +lint: + image: python:3.6 + stage: lint + variables: + DJANGO_SETTINGS_MODULE: $CI_TEST_DJANGO_SETTINGS_MODULE + SECRET_KEY: $CI_TEST_SECRET_KEY + script: + - pip install -r requirements.txt + - python manage.py migrate + - pylint --exit-zero api + test: image: python:3.6 stage: test @@ -20,7 +32,7 @@ sonar_scanner: entrypoint: [""] stage: test script: - - sonar-scanner -Dsonar.projectKey=$SONAR_PROJECT_KEY -Dsonar.sources=. -Dsonar.host.url=$SONAR_HOST_URL -Dsonar.login=$SONARQUBE_TOKEN + - sonar-scanner -Dsonar.projectKey=$SONARQUBE_PROJECT_KEY -Dsonar.sources=. -Dsonar.host.url=$SONARQUBE_HOST_URL -Dsonar.login=$SONARQUBE_TOKEN -Dsonar.branch.name=$CI_COMMIT_REF_NAME" staging: image: ruby:2.6 diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..7a118de --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,2 @@ +sonar.scm.provider=git +sonar.sourceEncoding=UTF-8 -- GitLab From d943e25cb21531096f1a62f691528237babd4989 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Mon, 17 Feb 2020 17:56:32 +0700 Subject: [PATCH 28/90] [CHORES] Fix .gitlab-ci.yml --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index fd3cea8..c7680fd 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -32,7 +32,7 @@ sonar_scanner: entrypoint: [""] stage: test script: - - sonar-scanner -Dsonar.projectKey=$SONARQUBE_PROJECT_KEY -Dsonar.sources=. -Dsonar.host.url=$SONARQUBE_HOST_URL -Dsonar.login=$SONARQUBE_TOKEN -Dsonar.branch.name=$CI_COMMIT_REF_NAME" + - sonar-scanner -Dsonar.projectKey=$SONARQUBE_PROJECT_KEY -Dsonar.sources=. -Dsonar.host.url=$SONARQUBE_HOST_URL -Dsonar.login=$SONARQUBE_TOKEN -Dsonar.branch.name=$CI_COMMIT_REF_NAME staging: image: ruby:2.6 -- GitLab From 314cc923937d96008d86fb0d603d7789b25ded03 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Mon, 17 Feb 2020 18:16:17 +0700 Subject: [PATCH 29/90] [CHORES] Fix SonarQube integration --- .gitlab-ci.yml | 23 ++++++++++------------- sonar-project.properties | 1 + 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index c7680fd..df5790b 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -10,28 +10,25 @@ lint: DJANGO_SETTINGS_MODULE: $CI_TEST_DJANGO_SETTINGS_MODULE SECRET_KEY: $CI_TEST_SECRET_KEY script: - - pip install -r requirements.txt - - python manage.py migrate + - pip3 install -r requirements.txt + - python3 manage.py migrate - pylint --exit-zero api test: - image: python:3.6 + image: + name: sonarsource/sonar-scanner-cli:latest + entrypoint: [""] stage: test variables: DJANGO_SETTINGS_MODULE: $CI_TEST_DJANGO_SETTINGS_MODULE SECRET_KEY: $CI_TEST_SECRET_KEY script: - - pip install -r requirements.txt - - python manage.py migrate + - apt update -y && apt install python3-dev python3-pip zip -y + - pip3 install -r requirements.txt + - python3 manage.py migrate - coverage run manage.py test - coverage report -m - -sonar_scanner: - image: - name: sonarsource/sonar-scanner-cli:latest - entrypoint: [""] - stage: test - script: + - coverage xml - sonar-scanner -Dsonar.projectKey=$SONARQUBE_PROJECT_KEY -Dsonar.sources=. -Dsonar.host.url=$SONARQUBE_HOST_URL -Dsonar.login=$SONARQUBE_TOKEN -Dsonar.branch.name=$CI_COMMIT_REF_NAME staging: @@ -42,7 +39,7 @@ staging: HEROKU_APP: $STAGING_HEROKU_APP script: - gem install dpl - - dpl --provider=heroku --api-key=$HEROKU_API_KEY --app=$HEROKU_APP --run="python manage.py migrate && python manage.py collectstatic --noinput" + - dpl --provider=heroku --api-key=$HEROKU_API_KEY --app=$HEROKU_APP --run="python3 manage.py migrate && python3 manage.py collectstatic --noinput" only: - staging diff --git a/sonar-project.properties b/sonar-project.properties index 7a118de..15fc65f 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,2 +1,3 @@ +sonar.python.coverage.reportPaths=coverage.xml sonar.scm.provider=git sonar.sourceEncoding=UTF-8 -- GitLab From 2b44f137420076facf0e65fa8ca18435cbba2a00 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Mon, 17 Feb 2020 18:20:52 +0700 Subject: [PATCH 30/90] [CHORES] Fix .gitlab-ci.yml --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index df5790b..21c8c5f 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -23,7 +23,7 @@ test: DJANGO_SETTINGS_MODULE: $CI_TEST_DJANGO_SETTINGS_MODULE SECRET_KEY: $CI_TEST_SECRET_KEY script: - - apt update -y && apt install python3-dev python3-pip zip -y + - mkdir -p /var/lib/apt/lists/partial && apt update -y && apt install python3-dev python3-pip zip -y - pip3 install -r requirements.txt - python3 manage.py migrate - coverage run manage.py test -- GitLab From fd8e62ae3ce2a7a290f0bbe5b6fe336b03dc44f2 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Mon, 17 Feb 2020 19:04:35 +0700 Subject: [PATCH 31/90] [CHORES] Fix CI --- .gitlab-ci.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 21c8c5f..5f9d190 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -15,21 +15,21 @@ lint: - pylint --exit-zero api test: - image: - name: sonarsource/sonar-scanner-cli:latest - entrypoint: [""] + image: adoptopenjdk/openjdk11 stage: test variables: DJANGO_SETTINGS_MODULE: $CI_TEST_DJANGO_SETTINGS_MODULE SECRET_KEY: $CI_TEST_SECRET_KEY script: - - mkdir -p /var/lib/apt/lists/partial && apt update -y && apt install python3-dev python3-pip zip -y + - apt update -y && apt install python3-dev python3-pip zip -y - pip3 install -r requirements.txt - python3 manage.py migrate - coverage run manage.py test - coverage report -m - coverage xml - - sonar-scanner -Dsonar.projectKey=$SONARQUBE_PROJECT_KEY -Dsonar.sources=. -Dsonar.host.url=$SONARQUBE_HOST_URL -Dsonar.login=$SONARQUBE_TOKEN -Dsonar.branch.name=$CI_COMMIT_REF_NAME + - wget https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/sonar-scanner-cli-4.2.0.1873-linux.zip + - unzip sonar-scanner-cli-4.2.0.1873-linux -d /tmp + - /tmp/sonar-scanner-cli-4.2.0.1873-linux/bin/sonar-scannersonar-scanner -Dsonar.projectKey=$SONARQUBE_PROJECT_KEY -Dsonar.sources=. -Dsonar.host.url=$SONARQUBE_HOST_URL -Dsonar.login=$SONARQUBE_TOKEN -Dsonar.branch.name=$CI_COMMIT_REF_NAME staging: image: ruby:2.6 -- GitLab From 3c15081a005e7090764d1bdc85149836e54e84ea Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Mon, 17 Feb 2020 19:31:12 +0700 Subject: [PATCH 32/90] [CHORES] Fix CI --- .gitlab-ci.yml | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5f9d190..8bfbea9 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,6 +1,7 @@ stages: - lint - test + - sonar_scanner_test - deploy lint: @@ -10,26 +11,33 @@ lint: DJANGO_SETTINGS_MODULE: $CI_TEST_DJANGO_SETTINGS_MODULE SECRET_KEY: $CI_TEST_SECRET_KEY script: - - pip3 install -r requirements.txt - - python3 manage.py migrate + - pip install -r requirements.txt + - python manage.py migrate - pylint --exit-zero api test: - image: adoptopenjdk/openjdk11 + image: python:3.6 stage: test + artifacts: + untracked: true variables: DJANGO_SETTINGS_MODULE: $CI_TEST_DJANGO_SETTINGS_MODULE SECRET_KEY: $CI_TEST_SECRET_KEY script: - - apt update -y && apt install python3-dev python3-pip zip -y - - pip3 install -r requirements.txt - - python3 manage.py migrate + - pip install -r requirements.txt + - python manage.py migrate - coverage run manage.py test - coverage report -m - - coverage xml - - wget https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/sonar-scanner-cli-4.2.0.1873-linux.zip - - unzip sonar-scanner-cli-4.2.0.1873-linux -d /tmp - - /tmp/sonar-scanner-cli-4.2.0.1873-linux/bin/sonar-scannersonar-scanner -Dsonar.projectKey=$SONARQUBE_PROJECT_KEY -Dsonar.sources=. -Dsonar.host.url=$SONARQUBE_HOST_URL -Dsonar.login=$SONARQUBE_TOKEN -Dsonar.branch.name=$CI_COMMIT_REF_NAME + +sonar_scanner_test: + image: + name: sonarsource/sonar-scanner-cli:latest + entrypoint: [""] + stage: test + dependencies: + - test + script: + - sonar-scanner -Dsonar.projectKey=$SONARQUBE_PROJECT_KEY -Dsonar.sources=. -Dsonar.host.url=$SONARQUBE_HOST_URL -Dsonar.login=$SONARQUBE_TOKEN -Dsonar.branch.name=$CI_COMMIT_REF_NAME staging: image: ruby:2.6 @@ -39,7 +47,7 @@ staging: HEROKU_APP: $STAGING_HEROKU_APP script: - gem install dpl - - dpl --provider=heroku --api-key=$HEROKU_API_KEY --app=$HEROKU_APP --run="python3 manage.py migrate && python3 manage.py collectstatic --noinput" + - dpl --provider=heroku --api-key=$HEROKU_API_KEY --app=$HEROKU_APP --run="python manage.py migrate && python manage.py collectstatic --noinput" only: - staging -- GitLab From a6795f9c921bbac3d5a85dfec64e951b70d85e96 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Mon, 17 Feb 2020 19:32:19 +0700 Subject: [PATCH 33/90] [CHORES] Fix CI --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 8bfbea9..6aa5150 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -33,7 +33,7 @@ sonar_scanner_test: image: name: sonarsource/sonar-scanner-cli:latest entrypoint: [""] - stage: test + stage: sonar_scanner_test dependencies: - test script: -- GitLab From 851b482156871af5bb789f61c0a99965c2955627 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Mon, 17 Feb 2020 19:48:08 +0700 Subject: [PATCH 34/90] [CHORES] Fix CI --- .gitlab-ci.yml | 13 +------------ sonar-project.properties | 2 +- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 6aa5150..036a71d 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,20 +1,8 @@ stages: - - lint - test - sonar_scanner_test - deploy -lint: - image: python:3.6 - stage: lint - variables: - DJANGO_SETTINGS_MODULE: $CI_TEST_DJANGO_SETTINGS_MODULE - SECRET_KEY: $CI_TEST_SECRET_KEY - script: - - pip install -r requirements.txt - - python manage.py migrate - - pylint --exit-zero api - test: image: python:3.6 stage: test @@ -37,6 +25,7 @@ sonar_scanner_test: dependencies: - test script: + - ls -al - sonar-scanner -Dsonar.projectKey=$SONARQUBE_PROJECT_KEY -Dsonar.sources=. -Dsonar.host.url=$SONARQUBE_HOST_URL -Dsonar.login=$SONARQUBE_TOKEN -Dsonar.branch.name=$CI_COMMIT_REF_NAME staging: diff --git a/sonar-project.properties b/sonar-project.properties index 15fc65f..aaece7c 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,3 +1,3 @@ -sonar.python.coverage.reportPaths=coverage.xml +sonar.python.coverage.reportPath=coverage.xml sonar.scm.provider=git sonar.sourceEncoding=UTF-8 -- GitLab From 3823c4f93f09e5c4cfb111258545c58866881ffa Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Mon, 17 Feb 2020 19:49:06 +0700 Subject: [PATCH 35/90] [CHORES] Fix CI --- .gitlab-ci.yml | 1 + sonar-project.properties | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 036a71d..bdd9b78 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -15,6 +15,7 @@ test: - pip install -r requirements.txt - python manage.py migrate - coverage run manage.py test + - coverage xml - coverage report -m sonar_scanner_test: diff --git a/sonar-project.properties b/sonar-project.properties index aaece7c..15fc65f 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,3 +1,3 @@ -sonar.python.coverage.reportPath=coverage.xml +sonar.python.coverage.reportPaths=coverage.xml sonar.scm.provider=git sonar.sourceEncoding=UTF-8 -- GitLab From ef684348280a07e8d6f8a52509bcf0c2abb8f550 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Mon, 17 Feb 2020 19:56:25 +0700 Subject: [PATCH 36/90] [CHORES] Fix CI --- sonar-project.properties | 1 + 1 file changed, 1 insertion(+) diff --git a/sonar-project.properties b/sonar-project.properties index 15fc65f..47de900 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,3 +1,4 @@ sonar.python.coverage.reportPaths=coverage.xml sonar.scm.provider=git sonar.sourceEncoding=UTF-8 +sonar.sources=api -- GitLab From c22fefe31c098911b322f7237ff460d1bf25c797 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Mon, 17 Feb 2020 20:07:31 +0700 Subject: [PATCH 37/90] [CHORES] Fix CI --- .gitlab-ci.yml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index bdd9b78..b4c92dc 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,12 +1,25 @@ stages: + - lint - test - sonar_scanner_test - deploy +lint: + image: python:3.6 + stage: lint + variables: + DJANGO_SETTINGS_MODULE: $CI_TEST_DJANGO_SETTINGS_MODULE + SECRET_KEY: $CI_TEST_SECRET_KEY + script: + - pip install -r requirements.txt + - python manage.py migrate + - pylint --exit-zero api + test: image: python:3.6 stage: test artifacts: + expire_in: 1 hour untracked: true variables: DJANGO_SETTINGS_MODULE: $CI_TEST_DJANGO_SETTINGS_MODULE @@ -26,7 +39,6 @@ sonar_scanner_test: dependencies: - test script: - - ls -al - sonar-scanner -Dsonar.projectKey=$SONARQUBE_PROJECT_KEY -Dsonar.sources=. -Dsonar.host.url=$SONARQUBE_HOST_URL -Dsonar.login=$SONARQUBE_TOKEN -Dsonar.branch.name=$CI_COMMIT_REF_NAME staging: -- GitLab From 4b795eca34e9c288e6eb90e898ae1d5528f478db Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Mon, 17 Feb 2020 20:48:57 +0700 Subject: [PATCH 38/90] [CHORES] Fix CI --- sonar-project.properties | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sonar-project.properties b/sonar-project.properties index 47de900..48fcd81 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,4 +1,5 @@ +sonar.exclusions=manage.py,home_industry/** +sonar.inclusions=api/** sonar.python.coverage.reportPaths=coverage.xml sonar.scm.provider=git sonar.sourceEncoding=UTF-8 -sonar.sources=api -- GitLab From 752cd39fdd4117cd161b7028af8b0da28e2a1979 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Mon, 17 Feb 2020 20:50:19 +0700 Subject: [PATCH 39/90] [CHORES] Fix CI --- .gitlab-ci.yml | 2 +- sonar-project.properties | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index b4c92dc..4da323c 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -39,7 +39,7 @@ sonar_scanner_test: dependencies: - test script: - - sonar-scanner -Dsonar.projectKey=$SONARQUBE_PROJECT_KEY -Dsonar.sources=. -Dsonar.host.url=$SONARQUBE_HOST_URL -Dsonar.login=$SONARQUBE_TOKEN -Dsonar.branch.name=$CI_COMMIT_REF_NAME + - sonar-scanner -Dsonar.projectKey=$SONARQUBE_PROJECT_KEY -Dsonar.host.url=$SONARQUBE_HOST_URL -Dsonar.login=$SONARQUBE_TOKEN -Dsonar.branch.name=$CI_COMMIT_REF_NAME staging: image: ruby:2.6 diff --git a/sonar-project.properties b/sonar-project.properties index 48fcd81..47de900 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,5 +1,4 @@ -sonar.exclusions=manage.py,home_industry/** -sonar.inclusions=api/** sonar.python.coverage.reportPaths=coverage.xml sonar.scm.provider=git sonar.sourceEncoding=UTF-8 +sonar.sources=api -- GitLab From c583ef803f17bed280e84ad8cc4f112f2ead8a7d Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Mon, 2 Mar 2020 22:09:37 +0700 Subject: [PATCH 40/90] Complete User Authentication Endpoints --- .pylintrc | 7 +- api/migrations/0001_initial.py | 55 ++++++ api/models.py | 42 ++++ api/paginations.py | 19 ++ api/permissions.py | 13 ++ api/serializers.py | 49 +++++ api/tests.py | 275 ++++++++++++++++++++++++++- api/urls.py | 12 +- api/utils.py | 30 +++ api/views.py | 103 +++++++++- home_industry/settings/ci.py | 49 ++++- home_industry/settings/local.py | 51 ++++- home_industry/settings/production.py | 34 +++- home_industry/settings/staging.py | 33 +++- home_industry/urls.py | 7 + requirements.txt | 21 ++ 16 files changed, 780 insertions(+), 20 deletions(-) create mode 100644 api/migrations/0001_initial.py create mode 100644 api/paginations.py create mode 100644 api/permissions.py create mode 100644 api/utils.py diff --git a/.pylintrc b/.pylintrc index 9d11776..c41fc3d 100644 --- a/.pylintrc +++ b/.pylintrc @@ -1,9 +1,12 @@ [MASTER] disable= - missing-module-docstring, missing-class-docstring, - missing-function-docstring + missing-function-docstring, + missing-module-docstring ignore= env load-plugins= pylint_django + +[DESIGN] +max-parents=8 diff --git a/api/migrations/0001_initial.py b/api/migrations/0001_initial.py new file mode 100644 index 0000000..74229fb --- /dev/null +++ b/api/migrations/0001_initial.py @@ -0,0 +1,55 @@ +# Generated by Django 3.0.3 on 2020-03-02 12:15 + +import django.contrib.auth.models +import django.contrib.auth.validators +import django.core.validators +from django.db import migrations, models +import django.utils.timezone +import phonenumber_field.modelfields +import re +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0011_update_proxy_permissions'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='ID')), + ('full_name', models.CharField(max_length=200, verbose_name='full name')), + ('phone_number', phonenumber_field.modelfields.PhoneNumberField(max_length=128, region=None, unique=True, verbose_name='phone number')), + ('address', models.CharField(max_length=200, verbose_name='address')), + ('neighborhood', models.CharField(max_length=3, validators=[django.core.validators.RegexValidator(re.compile('^\\d+(?:\\d+)*\\Z'), code='invalid', message=None), django.core.validators.MinLengthValidator(3)], verbose_name='neighborhood')), + ('hamlet', models.CharField(max_length=3, validators=[django.core.validators.RegexValidator(re.compile('^\\d+(?:\\d+)*\\Z'), code='invalid', message=None), django.core.validators.MinLengthValidator(3)], verbose_name='hamlet')), + ('urban_village', models.CharField(max_length=100, verbose_name='urban village')), + ('sub_district', models.CharField(max_length=100, verbose_name='sub-district')), + ('profile_picture', models.ImageField(blank=True, null=True, upload_to='uploads/profile/', verbose_name='profile picture')), + ('otp', models.CharField(blank=True, max_length=6, null=True, verbose_name='OTP')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + 'ordering': ['username'], + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + ] diff --git a/api/models.py b/api/models.py index e69de29..dd0364c 100644 --- a/api/models.py +++ b/api/models.py @@ -0,0 +1,42 @@ +import uuid + +from django.contrib.auth import models as auth_models +from django.core import validators +from django.db import models as db_models +from phonenumber_field import modelfields + + +class User(auth_models.AbstractUser): + id = db_models.UUIDField(default=uuid.uuid4, primary_key=True, verbose_name='ID') + first_name = None + last_name = None + full_name = db_models.CharField(max_length=200, verbose_name='full name') + phone_number = modelfields.PhoneNumberField(unique=True, verbose_name='phone number') + address = db_models.CharField(max_length=200, verbose_name='address') + neighborhood = db_models.CharField( + max_length=3, + validators=[validators.int_list_validator(sep=''), validators.MinLengthValidator(3)], + verbose_name='neighborhood' + ) + hamlet = db_models.CharField( + max_length=3, + validators=[validators.int_list_validator(sep=''), validators.MinLengthValidator(3)], + verbose_name='hamlet' + ) + urban_village = db_models.CharField(max_length=100, verbose_name='urban village') + sub_district = db_models.CharField(max_length=100, verbose_name='sub-district') + profile_picture = db_models.ImageField( + blank=True, + null=True, + upload_to='uploads/profile/', + verbose_name='profile picture' + ) + otp = db_models.CharField(blank=True, max_length=6, null=True, verbose_name='OTP') + + class Meta: + ordering = ['username'] + verbose_name = 'user' + verbose_name_plural = 'users' + + def __str__(self): + return self.username diff --git a/api/paginations.py b/api/paginations.py new file mode 100644 index 0000000..dad60b6 --- /dev/null +++ b/api/paginations.py @@ -0,0 +1,19 @@ +from rest_framework import pagination + + +class SmallResultsSetPagination(pagination.PageNumberPagination): + max_page_size = 100 + page_size = 10 + page_size_query_param = 'page_size' + + +class StandardResultsSetPagination(pagination.PageNumberPagination): + max_page_size = 1000 + page_size = 100 + page_size_query_param = 'page_size' + + +class LargeResultsSetPagination(pagination.PageNumberPagination): + max_page_size = 10000 + page_size = 1000 + page_size_query_param = 'page_size' diff --git a/api/permissions.py b/api/permissions.py new file mode 100644 index 0000000..84a3d7b --- /dev/null +++ b/api/permissions.py @@ -0,0 +1,13 @@ +from rest_framework import permissions + + +class IsAdminUserOrSelf(permissions.BasePermission): + def has_object_permission(self, request, _, obj): + return bool( + (request.user.is_authenticated) and ((request.user.is_staff) or (obj == request.user)) + ) + + +class IsAnonymousUser(permissions.BasePermission): + def has_permission(self, request, _): # pylint: disable=no-self-use + return bool(request.user.is_anonymous) diff --git a/api/serializers.py b/api/serializers.py index e69de29..eaf9dc0 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -0,0 +1,49 @@ +from phonenumber_field import serializerfields +from rest_framework import serializers + +from api import models + + +class PhoneNumberSerializer(serializers.Serializer): # pylint: disable=abstract-method + phone_number = serializerfields.PhoneNumberField() + + +class OTPSerializer(serializers.Serializer): # pylint: disable=abstract-method + otp = serializers.CharField(max_length=6) + + +class UserSerializer(serializers.ModelSerializer): + class Meta: + extra_kwargs = {'password': {'write_only': True}} + fields = [ + 'id', + 'username', + 'password', + 'full_name', + 'phone_number', + 'address', + 'neighborhood', + 'hamlet', + 'urban_village', + 'sub_district', + 'profile_picture', + ] + model = models.User + read_only_fields = ['id', 'is_verified'] + + def create(self, validated_data): + password = validated_data.pop('password', None) + instance = self.Meta.model(**validated_data) + if password is not None: + instance.set_password(password) + instance.save() + return instance + + def update(self, instance, validated_data): + for attr, value in validated_data.items(): + if attr == 'password': + instance.set_password(value) + else: + setattr(instance, attr, value) + instance.save() + return instance diff --git a/api/tests.py b/api/tests.py index 6b52774..e2ec30f 100644 --- a/api/tests.py +++ b/api/tests.py @@ -1,9 +1,274 @@ -from django import urls -from rest_framework import status, test +from datetime import datetime, timedelta +import jwt +from django import conf, test as django_test, urls +from rest_framework import exceptions, status, test as rest_framework_test -class ApiRootTest(test.APITestCase): - def test_url_exists(self): - url = urls.reverse('api-root') +from api import models, utils, views + +SUPERUSER_DATA = { + 'username': 'admin', + 'email': 'admin@example.com', + 'password': 'admin', +} + +USER_DATA = { + 'username': 'dummyuser', + 'password': 'dummypassword', + 'full_name': 'Dummy User', + 'phone_number': '+6285212345678', + 'address': 'Jl. Dummy No.1', + 'neighborhood': '000', + 'hamlet': '000', + 'urban_village': 'Dummy Urban Village', + 'sub_district': 'Dummy Sub-District', +} + + +def get_http_authorization(username, password): + client = rest_framework_test.APIClient() + data = { + 'username': username, + 'password': password, + } + url = urls.reverse('auth-cred-login') + response = client.post(url, data, format='json') + user = models.User.objects.get(username=username) + user.otp = '123456' + user.save() + auth_token = response.json()['token'] + data = { + 'otp': user.otp, + } + url = urls.reverse('auth-otp-login') + client.credentials(HTTP_AUTHORIZATION='Bearer {}'.format(auth_token)) + response = client.post(url, data, format='json') + return 'Token {}'.format(response.json()['token']) + + +class UtilsTest(django_test.TestCase): + def test_generate_otp(self): + otp = utils.generate_otp() + self.assertEqual(len(otp), 6) + + def test_get_username_from_jwt_success(self): + encoded_jwt = jwt.encode( + {'exp': datetime.now() + timedelta(hours=1), 'username': USER_DATA['username']}, + conf.settings.SECRET_KEY, + algorithm='HS256' + ).decode('utf-8') + http_authorization = 'Bearer {}'.format(encoded_jwt) + username = utils.get_username_from_jwt(http_authorization) + self.assertEqual(username, USER_DATA['username']) + + def test_get_username_from_jwt_fail(self): + http_authorization = None + with self.assertRaises(exceptions.NotAuthenticated): + utils.get_username_from_jwt(http_authorization) + http_authorization = 'Bearers token' + with self.assertRaises(exceptions.ValidationError): + utils.get_username_from_jwt(http_authorization) + http_authorization = 'Bearer token' + with self.assertRaises(exceptions.ValidationError): + utils.get_username_from_jwt(http_authorization) + encoded_jwt = jwt.encode( + {'username': USER_DATA['username']}, + conf.settings.SECRET_KEY, + algorithm='HS256' + ).decode('utf-8') + http_authorization = 'Bearer {}'.format(encoded_jwt) + with self.assertRaises(exceptions.ValidationError): + utils.get_username_from_jwt(http_authorization) + + +class AuthTest(rest_framework_test.APITestCase): + def setUp(self): + data = USER_DATA + url = urls.reverse('auth-register') + self.client.post(url, data, format='json') + + def test_auth_cred_login_serializer(self): + serializer = views.AuthCredLogin().get_serializer() + self.assertIsInstance(serializer, views.AuthCredLogin.serializer_class) + + def test_auth_otp_login_serializer(self): + serializer = views.AuthOTPLogin().get_serializer() + self.assertIsInstance(serializer, views.AuthOTPLogin.serializer_class) + + def test_user_authentication_with_username_password_success(self): + data = { + 'username': USER_DATA['username'], + 'password': USER_DATA['password'], + } + url = urls.reverse('auth-cred-login') + response = self.client.post(url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIsNotNone(response.json().get('token')) + user = models.User.objects.get(username=USER_DATA['username']) + user.otp = '123456' + user.save() + http_authorization = 'Bearer {}'.format(response.json()['token']) + data = { + 'otp': '123456', + } + url = urls.reverse('auth-otp-login') + 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_200_OK) + self.assertIsNotNone(response.json().get('token')) + + def test_user_authentication_with_phone_number_success(self): + data = { + 'phone_number': USER_DATA['phone_number'], + } + url = urls.reverse('auth-cred-login') + response = self.client.post(url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIsNotNone(response.json().get('token')) + user = models.User.objects.get(username=USER_DATA['username']) + user.otp = '123456' + user.save() + http_authorization = 'Bearer {}'.format(response.json()['token']) + data = { + 'otp': '123456', + } + url = urls.reverse('auth-otp-login') + 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_200_OK) + self.assertIsNotNone(response.json().get('token')) + + def test_user_authentication_fail(self): + data = {} + url = urls.reverse('auth-cred-login') + response = self.client.post(url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_user_authentication_with_username_password_fail(self): + data = { + 'username': USER_DATA['username'], + 'password': USER_DATA['password'], + } + url = urls.reverse('auth-cred-login') + response = self.client.post(url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIsNotNone(response.json().get('token')) + user = models.User.objects.get(username=USER_DATA['username']) + user.otp = '123456' + user.save() + http_authorization = 'Bearer {}'.format(response.json()['token']) + data = { + 'otp': '654321', + } + url = urls.reverse('auth-otp-login') + 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_400_BAD_REQUEST) + + def test_resend_otp_success(self): + data = { + 'username': USER_DATA['username'], + 'password': USER_DATA['password'], + } + url = urls.reverse('auth-cred-login') + response = self.client.post(url, data, format='json') + http_authorization = 'Bearer {}'.format(response.json()['token']) + url = urls.reverse('auth-resend-otp') + self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member + response = self.client.post(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_resend_otp_fail(self): + data = {} + url = urls.reverse('auth-resend-otp') + response = self.client.post(url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + +class UserTest(rest_framework_test.APITestCase): + def setUp(self): + models.User.objects.create_superuser(**SUPERUSER_DATA) + + def test_user_model_string_representation(self): + user = models.User.objects.create(**USER_DATA) + self.assertEqual(str(user), USER_DATA['username']) + + def test_user_list_success(self): + http_authorization = get_http_authorization( + SUPERUSER_DATA['username'], + SUPERUSER_DATA['password'] + ) + url = urls.reverse('user-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_user_detail_success(self): + http_authorization = get_http_authorization( + SUPERUSER_DATA['username'], + SUPERUSER_DATA['password'] + ) + url = urls.reverse('user-detail', args=['self']) + 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_user_success(self): + http_authorization = get_http_authorization( + SUPERUSER_DATA['username'], + SUPERUSER_DATA['password'] + ) + data = USER_DATA + url = urls.reverse('user-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.User.objects.count(), 2) + user_id = response.json()['id'] + self.assertEqual(models.User.objects.get(id=user_id).full_name, data['full_name']) + + def test_create_user_fail(self): + http_authorization = get_http_authorization( + SUPERUSER_DATA['username'], + SUPERUSER_DATA['password'] + ) + data = {} + url = urls.reverse('user-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_400_BAD_REQUEST) + self.assertEqual(models.User.objects.count(), 1) + + def test_update_user_success(self): + data = USER_DATA + url = urls.reverse('auth-register') + response = self.client.post(url, data, format='json') + user_id = response.json()['id'] + http_authorization = get_http_authorization(USER_DATA['username'], USER_DATA['password']) + data = { + 'full_name': 'Dummy', + } + url = urls.reverse('user-detail', args=[user_id]) + self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member + response = self.client.patch(url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(models.User.objects.get(id=user_id).full_name, data['full_name']) + data = USER_DATA + response = self.client.put(url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(models.User.objects.get(id=user_id).full_name, data['full_name']) + + def test_update_user_fail(self): + data = USER_DATA + url = urls.reverse('auth-register') + response = self.client.post(url, data, format='json') + user_id = response.json()['id'] + http_authorization = get_http_authorization(USER_DATA['username'], USER_DATA['password']) + data = { + 'full_name': '', + } + url = urls.reverse('user-detail', args=[user_id]) + self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member + response = self.client.patch(url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(models.User.objects.get(id=user_id).full_name, USER_DATA['full_name']) diff --git a/api/urls.py b/api/urls.py index 7477f85..547fe74 100644 --- a/api/urls.py +++ b/api/urls.py @@ -1,7 +1,15 @@ from django import urls +from knox import views as knox_views -from api import views +from api import views as api_views urlpatterns = [ - urls.path('', views.ApiRoot.as_view(), name='api-root'), + urls.path('auth/register/', api_views.AuthRegister.as_view(), name='auth-register'), + urls.path('auth/cred-login/', api_views.AuthCredLogin.as_view(), name='auth-cred-login'), + urls.path('auth/otp-login/', api_views.AuthOTPLogin.as_view(), name='auth-otp-login'), + urls.path('auth/resend-otp/', api_views.AuthResendOTP.as_view(), name='auth-resend-otp'), + urls.path('auth/logout/', knox_views.LogoutView.as_view(), name='auth-logout'), + 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//', api_views.UserDetail.as_view(), name='user-detail'), ] diff --git a/api/utils.py b/api/utils.py new file mode 100644 index 0000000..11dedba --- /dev/null +++ b/api/utils.py @@ -0,0 +1,30 @@ +import math +import random + +import jwt +from jwt import exceptions as jwt_exceptions +from django import conf +from rest_framework import exceptions + + +def generate_otp(): + digits = '0123456789' + otp = '' + for _ in range(6): + otp += digits[math.floor(random.random() * 10)] + return otp + + +def get_username_from_jwt(http_authorization): + if http_authorization is None: + raise exceptions.NotAuthenticated() + bearer, _, encoded_jwt = http_authorization.partition(' ') + if bearer != 'Bearer': + raise exceptions.ValidationError() + try: + decoded_jwt = jwt.decode(encoded_jwt, conf.settings.SECRET_KEY, algorithms=['HS256']) + except (jwt_exceptions.DecodeError, jwt_exceptions.ExpiredSignatureError): + raise exceptions.ValidationError() + if (decoded_jwt.get('exp') is None) or (decoded_jwt.get('username') is None): + raise exceptions.ValidationError() + return decoded_jwt['username'] diff --git a/api/views.py b/api/views.py index 15804e6..a607e7e 100644 --- a/api/views.py +++ b/api/views.py @@ -1,6 +1,101 @@ -from rest_framework import response, views +from datetime import datetime, timedelta +import jwt +from django import conf, shortcuts +from django.contrib import auth +from knox import views as knox_views +from rest_framework import ( + generics, permissions as rest_framework_permissions, response, + serializers as rest_framework_serializers, status, views as rest_framework_views +) +from rest_framework.authtoken import serializers as authtoken_serializers -class ApiRoot(views.APIView): - def get(self, request, format=None): - return response.Response({'message': 'Hello, World!'}) +from api import ( + models, paginations, permissions as api_permissions, serializers as api_serializers, utils +) + + +class AuthRegister(generics.CreateAPIView): + permission_classes = [api_permissions.IsAnonymousUser] + serializer_class = api_serializers.UserSerializer + + +class AuthCredLogin(rest_framework_views.APIView): + permission_classes = [rest_framework_permissions.AllowAny] + serializer_class = authtoken_serializers.AuthTokenSerializer + + def get_serializer(self, *args, **kwargs): + return self.serializer_class(*args, **kwargs) + + def post(self, request, _=None): + auth_token_serializer = authtoken_serializers.AuthTokenSerializer(data=request.data) + phone_number_serializer = api_serializers.PhoneNumberSerializer(data=request.data) + if auth_token_serializer.is_valid(): + user = auth_token_serializer.validated_data['user'] + elif phone_number_serializer.is_valid(): + user = shortcuts.get_object_or_404( + models.User, + phone_number=phone_number_serializer.validated_data['phone_number'] + ) + else: + raise rest_framework_serializers.ValidationError() + user.otp = '123456' # user.otp = utils.generate_otp() + user.save() + # Send OTP + encoded_jwt = jwt.encode( + {'exp': datetime.utcnow() + timedelta(hours=1), 'username': user.username}, + conf.settings.SECRET_KEY, + algorithm='HS256' + ).decode('utf-8') + return response.Response({'token': encoded_jwt}, status=status.HTTP_200_OK) + + +class AuthOTPLogin(knox_views.LoginView): + permission_classes = [rest_framework_permissions.AllowAny] + serializer_class = api_serializers.OTPSerializer + + def get_serializer(self, *args, **kwargs): + return self.serializer_class(*args, **kwargs) + + def post(self, request, format=None): # pylint: disable=redefined-builtin + http_authorization = self.request.META.get('HTTP_AUTHORIZATION') + username = utils.get_username_from_jwt(http_authorization) + serializer = api_serializers.OTPSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + user = shortcuts.get_object_or_404(models.User, username=username) + if user.otp != serializer.validated_data['otp']: + return response.Response(status=status.HTTP_400_BAD_REQUEST) + auth.login(request, user) + user.otp = '' + user.save() + return super().post(request, format) + + +class AuthResendOTP(rest_framework_views.APIView): + permission_classes = [rest_framework_permissions.AllowAny] + + def post(self, request, _=None): + http_authorization = self.request.META.get('HTTP_AUTHORIZATION') + username = utils.get_username_from_jwt(http_authorization) + user = shortcuts.get_object_or_404(models.User, username=username) + user.otp = '123456' # user.otp = utils.generate_otp() + user.save() + return response.Response(status=status.HTTP_200_OK) + + +class UserDetail(generics.RetrieveUpdateDestroyAPIView): + permission_classes = [api_permissions.IsAdminUserOrSelf] + queryset = models.User.objects.all() + serializer_class = api_serializers.UserSerializer + + def get_object(self): + if (self.kwargs.get('pk') == 'self') and (self.request.user): + self.kwargs['pk'] = self.request.user.pk + return super().get_object() + + +class UserList(generics.ListCreateAPIView): + pagination_class = paginations.SmallResultsSetPagination + permission_classes = [rest_framework_permissions.IsAdminUser] + queryset = models.User.objects.all() + serializer_class = api_serializers.UserSerializer diff --git a/home_industry/settings/ci.py b/home_industry/settings/ci.py index e259913..4fbd611 100644 --- a/home_industry/settings/ci.py +++ b/home_industry/settings/ci.py @@ -1,5 +1,7 @@ import os +from rest_framework import settings + BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # Quick-start development settings - unsuitable for production @@ -20,7 +22,9 @@ INSTALLED_APPS = [ 'django.contrib.messages', 'django.contrib.staticfiles', 'rest_framework', + 'knox', 'api.apps.ApiConfig', + 'phonenumber_field', 'django_cleanup', ] @@ -67,12 +71,25 @@ DATABASES = { # Password validation # https://docs.djangoproject.com/en/3.0/ref/settings/#auth-password-validators -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/3.0/topics/i18n/ -LANGUAGE_CODE = 'id' +LANGUAGE_CODE = 'en-us' TIME_ZONE = 'Asia/Jakarta' @@ -92,3 +109,31 @@ MEDIA_URL = '/media/' STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles') STATIC_URL = '/static/' + +# Authentication +# https://docs.djangoproject.com/en/3.0/topics/auth/ + +AUTH_USER_MODEL = 'api.User' + +# Django REST framework +# https://www.django-rest-framework.org + +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'knox.auth.TokenAuthentication', + ], + 'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema', +} + +# django-rest-knox +# https://github.com/James1345/django-rest-knox + +REST_KNOX = { + 'SECURE_HASH_ALGORITHM': 'cryptography.hazmat.primitives.hashes.SHA512', + 'AUTH_TOKEN_CHARACTER_LENGTH': 64, + 'TOKEN_TTL': None, + 'USER_SERIALIZER': 'knox.serializers.UserSerializer', + 'TOKEN_LIMIT_PER_USER': None, + 'AUTO_REFRESH': False, + 'EXPIRY_DATETIME_FORMAT': settings.api_settings.DATETIME_FORMAT, +} diff --git a/home_industry/settings/local.py b/home_industry/settings/local.py index c14d072..a877943 100644 --- a/home_industry/settings/local.py +++ b/home_industry/settings/local.py @@ -1,5 +1,7 @@ import os +from rest_framework import settings + BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # Quick-start development settings - unsuitable for production @@ -9,7 +11,7 @@ SECRET_KEY = os.environ['SECRET_KEY'] DEBUG = os.environ.get('DEBUG', True) != "False" -ALLOWED_HOSTS = ['127.0.0.1', 'localhost'] +ALLOWED_HOSTS = ['127.0.0.1', 'localhost', '.ngrok.io'] # Application definition @@ -20,7 +22,9 @@ INSTALLED_APPS = [ 'django.contrib.messages', 'django.contrib.staticfiles', 'rest_framework', + 'knox', 'api.apps.ApiConfig', + 'phonenumber_field', 'django_cleanup', ] @@ -71,12 +75,25 @@ DATABASES = { # Password validation # https://docs.djangoproject.com/en/3.0/ref/settings/#auth-password-validators -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/3.0/topics/i18n/ -LANGUAGE_CODE = 'id' +LANGUAGE_CODE = 'en-us' TIME_ZONE = 'Asia/Jakarta' @@ -96,3 +113,31 @@ MEDIA_URL = '/media/' STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles') STATIC_URL = '/static/' + +# Authentication +# https://docs.djangoproject.com/en/3.0/topics/auth/ + +AUTH_USER_MODEL = 'api.User' + +# Django REST framework +# https://www.django-rest-framework.org + +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'knox.auth.TokenAuthentication', + ], + 'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema', +} + +# django-rest-knox +# https://github.com/James1345/django-rest-knox + +REST_KNOX = { + 'SECURE_HASH_ALGORITHM': 'cryptography.hazmat.primitives.hashes.SHA512', + 'AUTH_TOKEN_CHARACTER_LENGTH': 64, + 'TOKEN_TTL': None, + 'USER_SERIALIZER': 'knox.serializers.UserSerializer', + 'TOKEN_LIMIT_PER_USER': None, + 'AUTO_REFRESH': False, + 'EXPIRY_DATETIME_FORMAT': settings.api_settings.DATETIME_FORMAT, +} diff --git a/home_industry/settings/production.py b/home_industry/settings/production.py index 2e0edf0..28c77cd 100644 --- a/home_industry/settings/production.py +++ b/home_industry/settings/production.py @@ -1,5 +1,7 @@ import os +from rest_framework import settings + BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # Quick-start development settings - unsuitable for production @@ -20,8 +22,10 @@ INSTALLED_APPS = [ 'django.contrib.messages', 'django.contrib.staticfiles', 'rest_framework', + 'knox', 'storages', 'api.apps.ApiConfig', + 'phonenumber_field', 'django_cleanup', ] @@ -90,7 +94,7 @@ AUTH_PASSWORD_VALIDATORS = [ # Internationalization # https://docs.djangoproject.com/en/3.0/topics/i18n/ -LANGUAGE_CODE = 'id' +LANGUAGE_CODE = 'en-us' TIME_ZONE = 'Asia/Jakarta' @@ -100,6 +104,11 @@ USE_L10N = True USE_TZ = True +# Authentication +# https://docs.djangoproject.com/en/3.0/topics/auth/ + +AUTH_USER_MODEL = 'api.User' + # Security # https://docs.djangoproject.com/en/3.0/topics/security/ @@ -111,6 +120,29 @@ SECURE_SSL_REDIRECT = True SESSION_COOKIE_SECURE = True +# Django REST framework +# https://www.django-rest-framework.org + +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'knox.auth.TokenAuthentication', + ], + 'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema', +} + +# django-rest-knox +# https://github.com/James1345/django-rest-knox + +REST_KNOX = { + 'SECURE_HASH_ALGORITHM': 'cryptography.hazmat.primitives.hashes.SHA512', + 'AUTH_TOKEN_CHARACTER_LENGTH': 64, + 'TOKEN_TTL': None, + 'USER_SERIALIZER': 'knox.serializers.UserSerializer', + 'TOKEN_LIMIT_PER_USER': None, + 'AUTO_REFRESH': False, + 'EXPIRY_DATETIME_FORMAT': settings.api_settings.DATETIME_FORMAT, +} + # django-storages # https://github.com/jschneier/django-storages diff --git a/home_industry/settings/staging.py b/home_industry/settings/staging.py index f29cdb9..86b63a1 100644 --- a/home_industry/settings/staging.py +++ b/home_industry/settings/staging.py @@ -1,6 +1,7 @@ import os import dj_database_url +from rest_framework import settings BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) @@ -22,8 +23,10 @@ INSTALLED_APPS = [ 'django.contrib.messages', 'django.contrib.staticfiles', 'rest_framework', + 'knox', 'storages', 'api.apps.ApiConfig', + 'phonenumber_field', 'django_cleanup', ] @@ -85,7 +88,7 @@ AUTH_PASSWORD_VALIDATORS = [ # Internationalization # https://docs.djangoproject.com/en/3.0/topics/i18n/ -LANGUAGE_CODE = 'id' +LANGUAGE_CODE = 'en-us' TIME_ZONE = 'Asia/Jakarta' @@ -95,6 +98,11 @@ USE_L10N = True USE_TZ = True +# Authentication +# https://docs.djangoproject.com/en/3.0/topics/auth/ + +AUTH_USER_MODEL = 'api.User' + # Security # https://docs.djangoproject.com/en/3.0/topics/security/ @@ -106,6 +114,29 @@ SECURE_SSL_REDIRECT = True SESSION_COOKIE_SECURE = True +# Django REST framework +# https://www.django-rest-framework.org + +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'knox.auth.TokenAuthentication', + ], + 'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema', +} + +# django-rest-knox +# https://github.com/James1345/django-rest-knox + +REST_KNOX = { + 'SECURE_HASH_ALGORITHM': 'cryptography.hazmat.primitives.hashes.SHA512', + 'AUTH_TOKEN_CHARACTER_LENGTH': 64, + 'TOKEN_TTL': None, + 'USER_SERIALIZER': 'knox.serializers.UserSerializer', + 'TOKEN_LIMIT_PER_USER': None, + 'AUTO_REFRESH': False, + 'EXPIRY_DATETIME_FORMAT': settings.api_settings.DATETIME_FORMAT, +} + # django-storages # https://github.com/jschneier/django-storages diff --git a/home_industry/urls.py b/home_industry/urls.py index 3b7f271..5614d24 100644 --- a/home_industry/urls.py +++ b/home_industry/urls.py @@ -1,5 +1,12 @@ from django import urls +from django.conf import settings +from rest_framework import documentation urlpatterns = [ urls.path('', urls.include('api.urls')), ] + +if settings.DEBUG: + urlpatterns += [ + urls.path('docs/', documentation.include_docs_urls(title='Home Industry API')), + ] diff --git a/requirements.txt b/requirements.txt index 013d494..e479305 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,28 +1,49 @@ asgiref==3.2.3 astroid==2.3.3 +Babel==2.8.0 boto3==1.11.12 botocore==1.14.12 +certifi==2019.11.28 +cffi==1.14.0 +chardet==3.0.4 +coreapi==2.3.3 +coreschema==0.0.4 coverage==5.0.3 +cryptography==2.8 dj-database-url==0.5.0 Django==3.0.3 django-cleanup==4.0.0 +django-phonenumber-field==4.0.0 +django-rest-knox==4.1.0 django-storages==1.9.1 djangorestframework==3.11.0 docutils==0.15.2 gunicorn==20.0.4 +idna==2.9 isort==4.3.21 +itypes==1.1.0 +Jinja2==2.11.1 jmespath==0.9.4 lazy-object-proxy==1.4.3 +Markdown==3.2.1 +MarkupSafe==1.1.1 mccabe==0.6.1 +phonenumbers==8.11.4 +Pillow==7.0.0 psycopg2-binary==2.8.4 +pycparser==2.19 +Pygments==2.5.2 +PyJWT==1.7.1 pylint==2.4.4 pylint-django==2.0.13 pylint-plugin-utils==0.6 python-dateutil==2.8.1 pytz==2019.3 +requests==2.23.0 s3transfer==0.3.3 six==1.14.0 sqlparse==0.3.0 typed-ast==1.4.1 +uritemplate==3.0.1 urllib3==1.25.8 wrapt==1.11.2 -- GitLab From e7b57a95f5531334e32f2bb63b50bf7c2d4bb495 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Tue, 3 Mar 2020 01:47:09 +0700 Subject: [PATCH 41/90] Add CORS Configuration and Modify Credentials Authentication Flow --- api/tests.py | 44 ++++++---------------------- api/urls.py | 5 ++++ api/views.py | 42 ++++++++++++++++---------- home_industry/settings/ci.py | 7 +++++ home_industry/settings/local.py | 7 +++++ home_industry/settings/production.py | 7 +++++ home_industry/settings/staging.py | 7 +++++ requirements.txt | 1 + 8 files changed, 69 insertions(+), 51 deletions(-) diff --git a/api/tests.py b/api/tests.py index e2ec30f..c2d50e9 100644 --- a/api/tests.py +++ b/api/tests.py @@ -33,16 +33,6 @@ def get_http_authorization(username, password): } url = urls.reverse('auth-cred-login') response = client.post(url, data, format='json') - user = models.User.objects.get(username=username) - user.otp = '123456' - user.save() - auth_token = response.json()['token'] - data = { - 'otp': user.otp, - } - url = urls.reverse('auth-otp-login') - client.credentials(HTTP_AUTHORIZATION='Bearer {}'.format(auth_token)) - response = client.post(url, data, format='json') return 'Token {}'.format(response.json()['token']) @@ -95,6 +85,10 @@ class AuthTest(rest_framework_test.APITestCase): serializer = views.AuthOTPLogin().get_serializer() self.assertIsInstance(serializer, views.AuthOTPLogin.serializer_class) + def test_auth_phone_number_login_serializer(self): + serializer = views.AuthPhoneNumberLogin().get_serializer() + self.assertIsInstance(serializer, views.AuthPhoneNumberLogin.serializer_class) + def test_user_authentication_with_username_password_success(self): data = { 'username': USER_DATA['username'], @@ -104,24 +98,12 @@ class AuthTest(rest_framework_test.APITestCase): response = self.client.post(url, data, format='json') self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertIsNotNone(response.json().get('token')) - user = models.User.objects.get(username=USER_DATA['username']) - user.otp = '123456' - user.save() - http_authorization = 'Bearer {}'.format(response.json()['token']) - data = { - 'otp': '123456', - } - url = urls.reverse('auth-otp-login') - 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_200_OK) - self.assertIsNotNone(response.json().get('token')) def test_user_authentication_with_phone_number_success(self): data = { 'phone_number': USER_DATA['phone_number'], } - url = urls.reverse('auth-cred-login') + url = urls.reverse('auth-phone-number-login') response = self.client.post(url, data, format='json') self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertIsNotNone(response.json().get('token')) @@ -139,17 +121,10 @@ class AuthTest(rest_framework_test.APITestCase): self.assertIsNotNone(response.json().get('token')) def test_user_authentication_fail(self): - data = {} - url = urls.reverse('auth-cred-login') - response = self.client.post(url, data, format='json') - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - def test_user_authentication_with_username_password_fail(self): data = { - 'username': USER_DATA['username'], - 'password': USER_DATA['password'], + 'phone_number': USER_DATA['phone_number'], } - url = urls.reverse('auth-cred-login') + url = urls.reverse('auth-phone-number-login') response = self.client.post(url, data, format='json') self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertIsNotNone(response.json().get('token')) @@ -167,10 +142,9 @@ class AuthTest(rest_framework_test.APITestCase): def test_resend_otp_success(self): data = { - 'username': USER_DATA['username'], - 'password': USER_DATA['password'], + 'phone_number': USER_DATA['phone_number'], } - url = urls.reverse('auth-cred-login') + url = urls.reverse('auth-phone-number-login') response = self.client.post(url, data, format='json') http_authorization = 'Bearer {}'.format(response.json()['token']) url = urls.reverse('auth-resend-otp') diff --git a/api/urls.py b/api/urls.py index 547fe74..0d12f88 100644 --- a/api/urls.py +++ b/api/urls.py @@ -6,6 +6,11 @@ from api import views as api_views urlpatterns = [ urls.path('auth/register/', api_views.AuthRegister.as_view(), name='auth-register'), urls.path('auth/cred-login/', api_views.AuthCredLogin.as_view(), name='auth-cred-login'), + urls.path( + 'auth/phone-number-login/', + api_views.AuthPhoneNumberLogin.as_view(), + name='auth-phone-number-login' + ), urls.path('auth/otp-login/', api_views.AuthOTPLogin.as_view(), name='auth-otp-login'), urls.path('auth/resend-otp/', api_views.AuthResendOTP.as_view(), name='auth-resend-otp'), urls.path('auth/logout/', knox_views.LogoutView.as_view(), name='auth-logout'), diff --git a/api/views.py b/api/views.py index a607e7e..e89cf7a 100644 --- a/api/views.py +++ b/api/views.py @@ -5,8 +5,8 @@ from django import conf, shortcuts from django.contrib import auth from knox import views as knox_views from rest_framework import ( - generics, permissions as rest_framework_permissions, response, - serializers as rest_framework_serializers, status, views as rest_framework_views + generics, permissions as rest_framework_permissions, response, status, + views as rest_framework_views ) from rest_framework.authtoken import serializers as authtoken_serializers @@ -20,26 +20,36 @@ class AuthRegister(generics.CreateAPIView): serializer_class = api_serializers.UserSerializer -class AuthCredLogin(rest_framework_views.APIView): +class AuthCredLogin(knox_views.LoginView): permission_classes = [rest_framework_permissions.AllowAny] serializer_class = authtoken_serializers.AuthTokenSerializer + def get_serializer(self, *args, **kwargs): + return self.serializer_class(*args, **kwargs) + + def post(self, request, format=None): # pylint: disable=redefined-builtin + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + user = serializer.validated_data['user'] + auth.login(request, user) + return super().post(request, format) + + +class AuthPhoneNumberLogin(rest_framework_views.APIView): + permission_classes = [rest_framework_permissions.AllowAny] + serializer_class = api_serializers.PhoneNumberSerializer + def get_serializer(self, *args, **kwargs): return self.serializer_class(*args, **kwargs) def post(self, request, _=None): - auth_token_serializer = authtoken_serializers.AuthTokenSerializer(data=request.data) - phone_number_serializer = api_serializers.PhoneNumberSerializer(data=request.data) - if auth_token_serializer.is_valid(): - user = auth_token_serializer.validated_data['user'] - elif phone_number_serializer.is_valid(): - user = shortcuts.get_object_or_404( - models.User, - phone_number=phone_number_serializer.validated_data['phone_number'] - ) - else: - raise rest_framework_serializers.ValidationError() - user.otp = '123456' # user.otp = utils.generate_otp() + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + user = shortcuts.get_object_or_404( + models.User, + phone_number=serializer.validated_data['phone_number'] + ) + user.otp = '123456' user.save() # Send OTP encoded_jwt = jwt.encode( @@ -60,7 +70,7 @@ class AuthOTPLogin(knox_views.LoginView): def post(self, request, format=None): # pylint: disable=redefined-builtin http_authorization = self.request.META.get('HTTP_AUTHORIZATION') username = utils.get_username_from_jwt(http_authorization) - serializer = api_serializers.OTPSerializer(data=request.data) + serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) user = shortcuts.get_object_or_404(models.User, username=username) if user.otp != serializer.validated_data['otp']: diff --git a/home_industry/settings/ci.py b/home_industry/settings/ci.py index 4fbd611..77275a4 100644 --- a/home_industry/settings/ci.py +++ b/home_industry/settings/ci.py @@ -21,6 +21,7 @@ INSTALLED_APPS = [ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', + 'corsheaders', 'rest_framework', 'knox', 'api.apps.ApiConfig', @@ -31,6 +32,7 @@ INSTALLED_APPS = [ 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', @@ -125,6 +127,11 @@ REST_FRAMEWORK = { 'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema', } +# django-cors-headers +# https://github.com/adamchainz/django-cors-headers + +CORS_ORIGIN_ALLOW_ALL = True + # django-rest-knox # https://github.com/James1345/django-rest-knox diff --git a/home_industry/settings/local.py b/home_industry/settings/local.py index a877943..6eca4a7 100644 --- a/home_industry/settings/local.py +++ b/home_industry/settings/local.py @@ -21,6 +21,7 @@ INSTALLED_APPS = [ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', + 'corsheaders', 'rest_framework', 'knox', 'api.apps.ApiConfig', @@ -31,6 +32,7 @@ INSTALLED_APPS = [ 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', @@ -129,6 +131,11 @@ REST_FRAMEWORK = { 'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema', } +# django-cors-headers +# https://github.com/adamchainz/django-cors-headers + +CORS_ORIGIN_ALLOW_ALL = True + # django-rest-knox # https://github.com/James1345/django-rest-knox diff --git a/home_industry/settings/production.py b/home_industry/settings/production.py index 28c77cd..7c7cb2c 100644 --- a/home_industry/settings/production.py +++ b/home_industry/settings/production.py @@ -21,6 +21,7 @@ INSTALLED_APPS = [ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', + 'corsheaders', 'rest_framework', 'knox', 'storages', @@ -32,6 +33,7 @@ INSTALLED_APPS = [ 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', @@ -130,6 +132,11 @@ REST_FRAMEWORK = { 'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema', } +# django-cors-headers +# https://github.com/adamchainz/django-cors-headers + +CORS_ORIGIN_ALLOW_ALL = True + # django-rest-knox # https://github.com/James1345/django-rest-knox diff --git a/home_industry/settings/staging.py b/home_industry/settings/staging.py index 86b63a1..b68f407 100644 --- a/home_industry/settings/staging.py +++ b/home_industry/settings/staging.py @@ -22,6 +22,7 @@ INSTALLED_APPS = [ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', + 'corsheaders', 'rest_framework', 'knox', 'storages', @@ -33,6 +34,7 @@ INSTALLED_APPS = [ 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', @@ -124,6 +126,11 @@ REST_FRAMEWORK = { 'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema', } +# django-cors-headers +# https://github.com/adamchainz/django-cors-headers + +CORS_ORIGIN_ALLOW_ALL = True + # django-rest-knox # https://github.com/James1345/django-rest-knox diff --git a/requirements.txt b/requirements.txt index e479305..d0af76e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,6 +13,7 @@ cryptography==2.8 dj-database-url==0.5.0 Django==3.0.3 django-cleanup==4.0.0 +django-cors-headers==3.2.1 django-phonenumber-field==4.0.0 django-rest-knox==4.1.0 django-storages==1.9.1 -- GitLab From ce7263c63f3f2f0a3c307abf65dc3fa618c36a97 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Wed, 4 Mar 2020 02:55:37 +0700 Subject: [PATCH 42/90] Fix User Password Validation and Add Indonesian Language Support --- .gitlab-ci.yml | 3 +- api/models.py | 25 +++---- api/permissions.py | 2 +- api/serializers.py | 15 ++++ api/tests.py | 23 +++--- api/utils.py | 2 +- api/views.py | 5 +- home_industry/settings/ci.py | 6 +- home_industry/settings/local.py | 9 ++- home_industry/settings/production.py | 6 +- home_industry/settings/staging.py | 6 +- home_industry/urls.py | 5 +- locale/id/LC_MESSAGES/django.po | 102 +++++++++++++++++++++++++++ sonar-project.properties | 1 + 14 files changed, 172 insertions(+), 38 deletions(-) create mode 100644 locale/id/LC_MESSAGES/django.po diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 4da323c..d3db582 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -20,7 +20,8 @@ test: stage: test artifacts: expire_in: 1 hour - untracked: true + paths: + - coverage.xml variables: DJANGO_SETTINGS_MODULE: $CI_TEST_DJANGO_SETTINGS_MODULE SECRET_KEY: $CI_TEST_SECRET_KEY diff --git a/api/models.py b/api/models.py index dd0364c..e8ba18b 100644 --- a/api/models.py +++ b/api/models.py @@ -3,40 +3,41 @@ import uuid from django.contrib.auth import models as auth_models from django.core import validators from django.db import models as db_models +from django.utils.translation import gettext_lazy as _ from phonenumber_field import modelfields class User(auth_models.AbstractUser): - id = db_models.UUIDField(default=uuid.uuid4, primary_key=True, verbose_name='ID') + id = db_models.UUIDField(default=uuid.uuid4, primary_key=True, verbose_name=_('ID')) first_name = None last_name = None - full_name = db_models.CharField(max_length=200, verbose_name='full name') - phone_number = modelfields.PhoneNumberField(unique=True, verbose_name='phone number') - address = db_models.CharField(max_length=200, verbose_name='address') + full_name = db_models.CharField(max_length=200, verbose_name=_('full name')) + phone_number = modelfields.PhoneNumberField(unique=True, verbose_name=_('phone number')) + address = db_models.CharField(max_length=200, verbose_name=_('address')) neighborhood = db_models.CharField( max_length=3, validators=[validators.int_list_validator(sep=''), validators.MinLengthValidator(3)], - verbose_name='neighborhood' + verbose_name=_('neighborhood') ) hamlet = db_models.CharField( max_length=3, validators=[validators.int_list_validator(sep=''), validators.MinLengthValidator(3)], - verbose_name='hamlet' + verbose_name=_('hamlet') ) - urban_village = db_models.CharField(max_length=100, verbose_name='urban village') - sub_district = db_models.CharField(max_length=100, verbose_name='sub-district') + urban_village = db_models.CharField(max_length=100, verbose_name=_('urban village')) + sub_district = db_models.CharField(max_length=100, verbose_name=_('sub-district')) profile_picture = db_models.ImageField( blank=True, null=True, upload_to='uploads/profile/', - verbose_name='profile picture' + verbose_name=_('profile picture') ) - otp = db_models.CharField(blank=True, max_length=6, null=True, verbose_name='OTP') + otp = db_models.CharField(blank=True, max_length=6, null=True, verbose_name=_('OTP')) class Meta: ordering = ['username'] - verbose_name = 'user' - verbose_name_plural = 'users' + verbose_name = _('user') + verbose_name_plural = _('users') def __str__(self): return self.username diff --git a/api/permissions.py b/api/permissions.py index 84a3d7b..9e5f90b 100644 --- a/api/permissions.py +++ b/api/permissions.py @@ -9,5 +9,5 @@ class IsAdminUserOrSelf(permissions.BasePermission): class IsAnonymousUser(permissions.BasePermission): - def has_permission(self, request, _): # pylint: disable=no-self-use + def has_permission(self, request, _): return bool(request.user.is_anonymous) diff --git a/api/serializers.py b/api/serializers.py index eaf9dc0..a2abd5b 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -1,3 +1,5 @@ +from django.contrib.auth import password_validation +from django.core import exceptions from phonenumber_field import serializerfields from rest_framework import serializers @@ -47,3 +49,16 @@ class UserSerializer(serializers.ModelSerializer): setattr(instance, attr, value) instance.save() return instance + + def validate(self, attrs): + user = models.User(**attrs) + password = attrs.get('password') + errors = {} + if password is not None: + try: + password_validation.validate_password(password=password, user=user) + except exceptions.ValidationError as err: + errors['password'] = list(err.messages) + if errors: + raise serializers.ValidationError(errors) + return super().validate(attrs) diff --git a/api/tests.py b/api/tests.py index c2d50e9..76a66ad 100644 --- a/api/tests.py +++ b/api/tests.py @@ -41,26 +41,26 @@ class UtilsTest(django_test.TestCase): otp = utils.generate_otp() self.assertEqual(len(otp), 6) - def test_get_username_from_jwt_success(self): + def test_get_username_from_bearer_token_success(self): encoded_jwt = jwt.encode( {'exp': datetime.now() + timedelta(hours=1), 'username': USER_DATA['username']}, conf.settings.SECRET_KEY, algorithm='HS256' ).decode('utf-8') http_authorization = 'Bearer {}'.format(encoded_jwt) - username = utils.get_username_from_jwt(http_authorization) + username = utils.get_username_from_bearer_token(http_authorization) self.assertEqual(username, USER_DATA['username']) - def test_get_username_from_jwt_fail(self): + def test_get_username_from_bearer_token_fail(self): http_authorization = None with self.assertRaises(exceptions.NotAuthenticated): - utils.get_username_from_jwt(http_authorization) + utils.get_username_from_bearer_token(http_authorization) http_authorization = 'Bearers token' with self.assertRaises(exceptions.ValidationError): - utils.get_username_from_jwt(http_authorization) + utils.get_username_from_bearer_token(http_authorization) http_authorization = 'Bearer token' with self.assertRaises(exceptions.ValidationError): - utils.get_username_from_jwt(http_authorization) + utils.get_username_from_bearer_token(http_authorization) encoded_jwt = jwt.encode( {'username': USER_DATA['username']}, conf.settings.SECRET_KEY, @@ -68,7 +68,7 @@ class UtilsTest(django_test.TestCase): ).decode('utf-8') http_authorization = 'Bearer {}'.format(encoded_jwt) with self.assertRaises(exceptions.ValidationError): - utils.get_username_from_jwt(http_authorization) + utils.get_username_from_bearer_token(http_authorization) class AuthTest(rest_framework_test.APITestCase): @@ -153,9 +153,8 @@ class AuthTest(rest_framework_test.APITestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) def test_resend_otp_fail(self): - data = {} url = urls.reverse('auth-resend-otp') - response = self.client.post(url, data, format='json') + response = self.client.post(url) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) @@ -206,10 +205,9 @@ class UserTest(rest_framework_test.APITestCase): SUPERUSER_DATA['username'], SUPERUSER_DATA['password'] ) - data = {} url = urls.reverse('user-list') self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member - response = self.client.post(url, data, format='json') + response = self.client.post(url) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(models.User.objects.count(), 1) @@ -239,10 +237,9 @@ class UserTest(rest_framework_test.APITestCase): user_id = response.json()['id'] http_authorization = get_http_authorization(USER_DATA['username'], USER_DATA['password']) data = { - 'full_name': '', + 'password': 'p', } url = urls.reverse('user-detail', args=[user_id]) self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member response = self.client.patch(url, data, format='json') self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(models.User.objects.get(id=user_id).full_name, USER_DATA['full_name']) diff --git a/api/utils.py b/api/utils.py index 11dedba..2af55a9 100644 --- a/api/utils.py +++ b/api/utils.py @@ -15,7 +15,7 @@ def generate_otp(): return otp -def get_username_from_jwt(http_authorization): +def get_username_from_bearer_token(http_authorization): if http_authorization is None: raise exceptions.NotAuthenticated() bearer, _, encoded_jwt = http_authorization.partition(' ') diff --git a/api/views.py b/api/views.py index e89cf7a..f5e237a 100644 --- a/api/views.py +++ b/api/views.py @@ -69,7 +69,7 @@ class AuthOTPLogin(knox_views.LoginView): def post(self, request, format=None): # pylint: disable=redefined-builtin http_authorization = self.request.META.get('HTTP_AUTHORIZATION') - username = utils.get_username_from_jwt(http_authorization) + username = utils.get_username_from_bearer_token(http_authorization) serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) user = shortcuts.get_object_or_404(models.User, username=username) @@ -86,10 +86,11 @@ class AuthResendOTP(rest_framework_views.APIView): def post(self, request, _=None): http_authorization = self.request.META.get('HTTP_AUTHORIZATION') - username = utils.get_username_from_jwt(http_authorization) + username = utils.get_username_from_bearer_token(http_authorization) user = shortcuts.get_object_or_404(models.User, username=username) user.otp = '123456' # user.otp = utils.generate_otp() user.save() + # Send OTP return response.Response(status=status.HTTP_200_OK) diff --git a/home_industry/settings/ci.py b/home_industry/settings/ci.py index 77275a4..b786c12 100644 --- a/home_industry/settings/ci.py +++ b/home_industry/settings/ci.py @@ -91,7 +91,7 @@ AUTH_PASSWORD_VALIDATORS = [ # Internationalization # https://docs.djangoproject.com/en/3.0/topics/i18n/ -LANGUAGE_CODE = 'en-us' +LANGUAGE_CODE = 'id' TIME_ZONE = 'Asia/Jakarta' @@ -101,6 +101,10 @@ USE_L10N = True USE_TZ = True +LOCALE_PATHS = [ + os.path.join(BASE_DIR, 'locale'), +] + # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/3.0/howto/static-files/ diff --git a/home_industry/settings/local.py b/home_industry/settings/local.py index 6eca4a7..8d1b886 100644 --- a/home_industry/settings/local.py +++ b/home_industry/settings/local.py @@ -1,5 +1,6 @@ import os +from django.utils.translation import gettext_lazy as _ from rest_framework import settings BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) @@ -11,7 +12,7 @@ SECRET_KEY = os.environ['SECRET_KEY'] DEBUG = os.environ.get('DEBUG', True) != "False" -ALLOWED_HOSTS = ['127.0.0.1', 'localhost', '.ngrok.io'] +ALLOWED_HOSTS = ['.ngrok.io', '127.0.0.1', 'localhost'] # Application definition @@ -95,7 +96,7 @@ AUTH_PASSWORD_VALIDATORS = [ # Internationalization # https://docs.djangoproject.com/en/3.0/topics/i18n/ -LANGUAGE_CODE = 'en-us' +LANGUAGE_CODE = 'id' TIME_ZONE = 'Asia/Jakarta' @@ -105,6 +106,10 @@ USE_L10N = True USE_TZ = True +LOCALE_PATHS = [ + os.path.join(BASE_DIR, 'locale'), +] + # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/3.0/howto/static-files/ diff --git a/home_industry/settings/production.py b/home_industry/settings/production.py index 7c7cb2c..b8d7a81 100644 --- a/home_industry/settings/production.py +++ b/home_industry/settings/production.py @@ -96,7 +96,7 @@ AUTH_PASSWORD_VALIDATORS = [ # Internationalization # https://docs.djangoproject.com/en/3.0/topics/i18n/ -LANGUAGE_CODE = 'en-us' +LANGUAGE_CODE = 'id' TIME_ZONE = 'Asia/Jakarta' @@ -106,6 +106,10 @@ USE_L10N = True USE_TZ = True +LOCALE_PATHS = [ + os.path.join(BASE_DIR, 'locale'), +] + # Authentication # https://docs.djangoproject.com/en/3.0/topics/auth/ diff --git a/home_industry/settings/staging.py b/home_industry/settings/staging.py index b68f407..362c99e 100644 --- a/home_industry/settings/staging.py +++ b/home_industry/settings/staging.py @@ -90,7 +90,7 @@ AUTH_PASSWORD_VALIDATORS = [ # Internationalization # https://docs.djangoproject.com/en/3.0/topics/i18n/ -LANGUAGE_CODE = 'en-us' +LANGUAGE_CODE = 'id' TIME_ZONE = 'Asia/Jakarta' @@ -100,6 +100,10 @@ USE_L10N = True USE_TZ = True +LOCALE_PATHS = [ + os.path.join(BASE_DIR, 'locale'), +] + # Authentication # https://docs.djangoproject.com/en/3.0/topics/auth/ diff --git a/home_industry/urls.py b/home_industry/urls.py index 5614d24..ee44d8f 100644 --- a/home_industry/urls.py +++ b/home_industry/urls.py @@ -1,12 +1,11 @@ -from django import urls -from django.conf import settings +from django import conf, urls from rest_framework import documentation urlpatterns = [ urls.path('', urls.include('api.urls')), ] -if settings.DEBUG: +if conf.settings.DEBUG: urlpatterns += [ urls.path('docs/', documentation.include_docs_urls(title='Home Industry API')), ] diff --git a/locale/id/LC_MESSAGES/django.po b/locale/id/LC_MESSAGES/django.po new file mode 100644 index 0000000..a317ef7 --- /dev/null +++ b/locale/id/LC_MESSAGES/django.po @@ -0,0 +1,102 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2020-03-04 02:18+0700\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: api/models.py:11 +msgid "ID" +msgstr "ID" + +#: api/models.py:14 +msgid "full name" +msgstr "nama lengkap" + +#: api/models.py:15 +msgid "phone number" +msgstr "nomor telepon" + +#: api/models.py:16 +msgid "address" +msgstr "alamat" + +#: api/models.py:20 +msgid "neighborhood" +msgstr "RT" + +#: api/models.py:25 +msgid "hamlet" +msgstr "RW" + +#: api/models.py:27 +msgid "urban village" +msgstr "kelurahan" + +#: api/models.py:28 +msgid "sub-district" +msgstr "kecamatan" + +#: api/models.py:33 +msgid "profile picture" +msgstr "foto profil" + +#: api/models.py:35 +msgid "OTP" +msgstr "OTP" + +#: api/models.py:39 +msgid "user" +msgstr "pengguna" + +#: api/models.py:40 +msgid "users" +msgstr "pengguna-pengguna" + +msgid "Not a valid string." +msgstr "Bukan string yang valid." + +msgid "This field may not be blank." +msgstr "Bidang ini tidak boleh kosong." + +#, python-brace-format +msgid "Ensure this field has no more than {max_length} characters." +msgstr "Pastikan bidang ini tidak lebih dari {max_length} karakter." + +#, python-brace-format +msgid "Ensure this field has at least {min_length} characters." +msgstr "Pastikan bidang ini memiliki setidaknya {min_length} karakter." + +#, python-brace-format +msgid "" +"Enter a valid phone number (e.g. {example_number}) or a number with an " +"international call prefix." +msgstr "" +"Masukkan nomor telepon yang valid (misalnya {example_number}) atau nomor " +"dengan awalan panggilan internasional." + +#, python-brace-format +msgid "Enter a valid phone number (e.g. {example_number})." +msgstr "Masukkan nomor telepon yang valid (misalnya {example_number})." + +msgid "Phone number" +msgstr "Nomor telepon" + +msgid "Enter a valid phone number." +msgstr "Masukkan nomor telepon yang valid." + +msgid "The phone number entered is not valid." +msgstr "Nomor telepon yang dimasukkan tidak valid." diff --git a/sonar-project.properties b/sonar-project.properties index 47de900..eab118e 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -2,3 +2,4 @@ sonar.python.coverage.reportPaths=coverage.xml sonar.scm.provider=git sonar.sourceEncoding=UTF-8 sonar.sources=api +sonar.test.inclusions=**/*.tests.py -- GitLab From 63d5741e624058e16dcc4788fea33a5382416c0e Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Wed, 4 Mar 2020 10:08:17 +0700 Subject: [PATCH 43/90] Add Filtering for User --- .gitignore | 1 - api/views.py | 7 ++++++- home_industry/settings/ci.py | 3 ++- home_industry/settings/local.py | 4 ++-- home_industry/settings/production.py | 4 ++-- home_industry/settings/staging.py | 4 ++-- locale/id/LC_MESSAGES/django.mo | Bin 0 -> 1872 bytes requirements.txt | 1 + 8 files changed, 15 insertions(+), 9 deletions(-) create mode 100644 locale/id/LC_MESSAGES/django.mo diff --git a/.gitignore b/.gitignore index 370685d..9ba3213 100644 --- a/.gitignore +++ b/.gitignore @@ -52,7 +52,6 @@ coverage.xml .pytest_cache/ # Translations -*.mo *.pot # Django stuff: diff --git a/api/views.py b/api/views.py index f5e237a..9462640 100644 --- a/api/views.py +++ b/api/views.py @@ -3,9 +3,10 @@ from datetime import datetime, timedelta import jwt from django import conf, shortcuts from django.contrib import auth +from django_filters import rest_framework from knox import views as knox_views from rest_framework import ( - generics, permissions as rest_framework_permissions, response, status, + filters, generics, permissions as rest_framework_permissions, response, status, views as rest_framework_views ) from rest_framework.authtoken import serializers as authtoken_serializers @@ -106,7 +107,11 @@ class UserDetail(generics.RetrieveUpdateDestroyAPIView): class UserList(generics.ListCreateAPIView): + filter_backends = [filters.OrderingFilter, filters.SearchFilter, rest_framework.DjangoFilterBackend] + filterset_fields = ['username', 'full_name', 'phone_number'] + ordering_fields = ['username', 'full_name', 'phone_number'] pagination_class = paginations.SmallResultsSetPagination permission_classes = [rest_framework_permissions.IsAdminUser] queryset = models.User.objects.all() + search_fields = ['username', 'full_name', 'phone_number'] serializer_class = api_serializers.UserSerializer diff --git a/home_industry/settings/ci.py b/home_industry/settings/ci.py index b786c12..25784e2 100644 --- a/home_industry/settings/ci.py +++ b/home_industry/settings/ci.py @@ -22,10 +22,11 @@ INSTALLED_APPS = [ 'django.contrib.messages', 'django.contrib.staticfiles', 'corsheaders', + 'django_filters', + 'phonenumber_field', 'rest_framework', 'knox', 'api.apps.ApiConfig', - 'phonenumber_field', 'django_cleanup', ] diff --git a/home_industry/settings/local.py b/home_industry/settings/local.py index 8d1b886..e5590f0 100644 --- a/home_industry/settings/local.py +++ b/home_industry/settings/local.py @@ -1,6 +1,5 @@ import os -from django.utils.translation import gettext_lazy as _ from rest_framework import settings BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) @@ -23,10 +22,11 @@ INSTALLED_APPS = [ 'django.contrib.messages', 'django.contrib.staticfiles', 'corsheaders', + 'django_filters', + 'phonenumber_field', 'rest_framework', 'knox', 'api.apps.ApiConfig', - 'phonenumber_field', 'django_cleanup', ] diff --git a/home_industry/settings/production.py b/home_industry/settings/production.py index b8d7a81..d6809e3 100644 --- a/home_industry/settings/production.py +++ b/home_industry/settings/production.py @@ -22,11 +22,11 @@ INSTALLED_APPS = [ 'django.contrib.messages', 'django.contrib.staticfiles', 'corsheaders', + 'django_filters', + 'phonenumber_field', 'rest_framework', 'knox', - 'storages', 'api.apps.ApiConfig', - 'phonenumber_field', 'django_cleanup', ] diff --git a/home_industry/settings/staging.py b/home_industry/settings/staging.py index 362c99e..bd0ab9d 100644 --- a/home_industry/settings/staging.py +++ b/home_industry/settings/staging.py @@ -23,11 +23,11 @@ INSTALLED_APPS = [ 'django.contrib.messages', 'django.contrib.staticfiles', 'corsheaders', + 'django_filters', + 'phonenumber_field', 'rest_framework', 'knox', - 'storages', 'api.apps.ApiConfig', - 'phonenumber_field', 'django_cleanup', ] diff --git a/locale/id/LC_MESSAGES/django.mo b/locale/id/LC_MESSAGES/django.mo new file mode 100644 index 0000000000000000000000000000000000000000..d7d1ee3a9c359bd59fb926a6910ea05395e65653 GIT binary patch literal 1872 zcmbu9&u<(x6vtf%6qesD1;j5I4pmAuLkmJwyG`h(J1Hynmo}RsxKy4U?~ECbJ(}^P z8r3KRtl~?KkjU@OQ8Uo_l~X0ZQ;~@LTXD@J|pw_WXm4od#b7@nb%Q$HBM2 zv*3Fm`I&(y!Oy@)!Oy{m!2^)?f4SsW;8U1?y_|moK7siU;2QW7_z?Ir*n-Aiz)j58 zu}FGj(1LeBT0adZuY+Td^j6>t;2eZ0`vRo%55PCTJK(F}_aJ`kR}7@@50LCnAPC|b zNOmW|$G|fn#r4{f=fOuXe+PU9+yo)FZl^GwK_~yThx|~Z_^8qTXX~%UJ<++8lPA&1 zH;$?s<)MN8^kQ1;p}QbG)JQiAOj#9@yF`_IrlgK|B1$eC*HV;@-^!G^p{0pka+^;R zQHZINMd{zi&RCw=`Z!@=E9UpH#Ti`iJ)u>^^TZm-O_haMT$g_A^ILK*vRum>i`Cob zxTXEeJvWt0IL=oF#~9(1HA3^L(3!7U$;M~h4jWi^D7kcnGO^EwlMx#o zkWQoYMbj(N< zC!sA8Ya@0vYb-D9Oli1QQ-|ARWfgjnB9&9esv<74{Q3bzV2} zuH#OT#wH)NuYSmCpt_q>OZChkzlbgJrmKzsaeDS$) zq19j`1g28tHB=FV<1H$l$xLaLDh^**P0hYIUOy?yC&hd4?`dA!pzK1G@JJNuc>Szw z`=YE;a&0VX4S{Ohk>UF!V^LA-nJR^@-QQJ6!ij2W8KJCkjJPTA@OTdUSkc$AuIO@w z_SygP_b<%r>e{L(qD5UD31v}YgQJ0>5bp{*QrX{0S!8ipXIrbwS*(Xv%Y>)4v{b%` zI}@lYjL5r*Q<1arhip7y$5P8?)>-^(p&DbUoRX0+_@bAQ;46o1Tp4lr;T^901x`}~ A1poj5 literal 0 HcmV?d00001 diff --git a/requirements.txt b/requirements.txt index d0af76e..7a38b51 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,6 +14,7 @@ dj-database-url==0.5.0 Django==3.0.3 django-cleanup==4.0.0 django-cors-headers==3.2.1 +django-filter==2.2.0 django-phonenumber-field==4.0.0 django-rest-knox==4.1.0 django-storages==1.9.1 -- GitLab From 9afc63f7e36fdb654e0ff8c4c2cdc49252e9612b Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Wed, 4 Mar 2020 23:25:29 +0700 Subject: [PATCH 44/90] Complete SMS Gateway Integration --- api/migrations/0002_config.py | 25 +++++++++ api/models.py | 10 ++++ api/serializers.py | 9 ++- api/tests.py | 81 ++++++++++++++++++++------- api/urls.py | 1 + api/utils.py | 22 +++++--- api/views.py | 26 +++++++-- home_industry/settings/ci.py | 1 + home_industry/settings/local.py | 1 + home_industry/settings/production.py | 9 +++ home_industry/settings/staging.py | 9 +++ home_industry/utils.py | 23 ++++++++ locale/id/LC_MESSAGES/django.mo | Bin 1872 -> 2203 bytes locale/id/LC_MESSAGES/django.po | 46 ++++++++++----- requirements.txt | 1 + 15 files changed, 215 insertions(+), 49 deletions(-) create mode 100644 api/migrations/0002_config.py create mode 100644 home_industry/utils.py diff --git a/api/migrations/0002_config.py b/api/migrations/0002_config.py new file mode 100644 index 0000000..44cf862 --- /dev/null +++ b/api/migrations/0002_config.py @@ -0,0 +1,25 @@ +# Generated by Django 3.0.3 on 2020-03-04 07:45 + +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Config', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='ID')), + ('send_sms', models.BooleanField(default=False, verbose_name='send SMS')), + ], + options={ + 'verbose_name': 'config', + 'verbose_name_plural': 'configs', + }, + ), + ] diff --git a/api/models.py b/api/models.py index e8ba18b..e26d453 100644 --- a/api/models.py +++ b/api/models.py @@ -5,6 +5,7 @@ from django.core import validators from django.db import models as db_models from django.utils.translation import gettext_lazy as _ from phonenumber_field import modelfields +from solo import models as solo_models class User(auth_models.AbstractUser): @@ -41,3 +42,12 @@ class User(auth_models.AbstractUser): def __str__(self): return self.username + + +class Config(solo_models.SingletonModel): + id = db_models.UUIDField(default=uuid.uuid4, primary_key=True, verbose_name=_('ID')) + send_sms = db_models.BooleanField(default=False, verbose_name=_('send SMS')) + + class Meta: + verbose_name = _('config') + verbose_name_plural = _('configs') diff --git a/api/serializers.py b/api/serializers.py index a2abd5b..8362cbf 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -31,7 +31,7 @@ class UserSerializer(serializers.ModelSerializer): 'profile_picture', ] model = models.User - read_only_fields = ['id', 'is_verified'] + read_only_fields = ['id'] def create(self, validated_data): password = validated_data.pop('password', None) @@ -62,3 +62,10 @@ class UserSerializer(serializers.ModelSerializer): if errors: raise serializers.ValidationError(errors) return super().validate(attrs) + + +class ConfigSerializer(serializers.ModelSerializer): + class Meta: + fields = ['id', 'send_sms'] + model = models.Config + read_only_fields = ['id'] diff --git a/api/tests.py b/api/tests.py index 76a66ad..1a139be 100644 --- a/api/tests.py +++ b/api/tests.py @@ -1,4 +1,5 @@ from datetime import datetime, timedelta +from unittest import mock import jwt from django import conf, test as django_test, urls @@ -99,7 +100,8 @@ class AuthTest(rest_framework_test.APITestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertIsNotNone(response.json().get('token')) - def test_user_authentication_with_phone_number_success(self): + @mock.patch('home_industry.utils.send_sms', return_value=None) + def test_user_authentication_with_phone_number_success(self, mock_send_sms): data = { 'phone_number': USER_DATA['phone_number'], } @@ -107,6 +109,7 @@ class AuthTest(rest_framework_test.APITestCase): response = self.client.post(url, data, format='json') self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertIsNotNone(response.json().get('token')) + self.assertEqual(mock_send_sms.call_count, 1) user = models.User.objects.get(username=USER_DATA['username']) user.otp = '123456' user.save() @@ -120,7 +123,8 @@ class AuthTest(rest_framework_test.APITestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertIsNotNone(response.json().get('token')) - def test_user_authentication_fail(self): + @mock.patch('home_industry.utils.send_sms', return_value=None) + def test_user_authentication_fail(self, mock_send_sms): data = { 'phone_number': USER_DATA['phone_number'], } @@ -128,6 +132,7 @@ class AuthTest(rest_framework_test.APITestCase): response = self.client.post(url, data, format='json') self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertIsNotNone(response.json().get('token')) + self.assertEqual(mock_send_sms.call_count, 1) user = models.User.objects.get(username=USER_DATA['username']) user.otp = '123456' user.save() @@ -140,7 +145,8 @@ class AuthTest(rest_framework_test.APITestCase): response = self.client.post(url, data, format='json') self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - def test_resend_otp_success(self): + @mock.patch('home_industry.utils.send_sms', return_value=None) + def test_resend_otp_success(self, mock_send_sms): data = { 'phone_number': USER_DATA['phone_number'], } @@ -150,7 +156,8 @@ class AuthTest(rest_framework_test.APITestCase): url = urls.reverse('auth-resend-otp') self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member response = self.client.post(url) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(mock_send_sms.call_count, 2) def test_resend_otp_fail(self): url = urls.reverse('auth-resend-otp') @@ -160,37 +167,32 @@ class AuthTest(rest_framework_test.APITestCase): class UserTest(rest_framework_test.APITestCase): def setUp(self): - models.User.objects.create_superuser(**SUPERUSER_DATA) + self.superuser = models.User.objects.create_superuser(**SUPERUSER_DATA) + self.superuser_http_authorization = get_http_authorization( + SUPERUSER_DATA['username'], + SUPERUSER_DATA['password'] + ) def test_user_model_string_representation(self): user = models.User.objects.create(**USER_DATA) self.assertEqual(str(user), USER_DATA['username']) def test_user_list_success(self): - http_authorization = get_http_authorization( - SUPERUSER_DATA['username'], - SUPERUSER_DATA['password'] - ) + http_authorization = self.superuser_http_authorization url = urls.reverse('user-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_user_detail_success(self): - http_authorization = get_http_authorization( - SUPERUSER_DATA['username'], - SUPERUSER_DATA['password'] - ) + http_authorization = self.superuser_http_authorization url = urls.reverse('user-detail', args=['self']) 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_user_success(self): - http_authorization = get_http_authorization( - SUPERUSER_DATA['username'], - SUPERUSER_DATA['password'] - ) + http_authorization = self.superuser_http_authorization data = USER_DATA url = urls.reverse('user-list') self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member @@ -201,10 +203,7 @@ class UserTest(rest_framework_test.APITestCase): self.assertEqual(models.User.objects.get(id=user_id).full_name, data['full_name']) def test_create_user_fail(self): - http_authorization = get_http_authorization( - SUPERUSER_DATA['username'], - SUPERUSER_DATA['password'] - ) + http_authorization = self.superuser_http_authorization url = urls.reverse('user-list') self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member response = self.client.post(url) @@ -243,3 +242,43 @@ class UserTest(rest_framework_test.APITestCase): self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member response = self.client.patch(url, data, format='json') self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + +class ConfigTest(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'] + ) + + def test_config_detail_success(self): + models.Config.objects.create() + http_authorization = self.superuser_http_authorization + url = urls.reverse('config-detail') + 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_update_config_success(self): + models.Config.objects.create() + http_authorization = self.superuser_http_authorization + data = { + 'send_sms': True, + } + url = urls.reverse('config-detail') + self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member + response = self.client.patch(url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(models.Config.objects.get().send_sms, data['send_sms']) + + def test_update_config_fail(self): + models.Config.objects.create() + http_authorization = self.superuser_http_authorization + data = { + 'send_sms': None, + } + url = urls.reverse('config-detail') + self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member + response = self.client.patch(url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/api/urls.py b/api/urls.py index 0d12f88..e02bcf6 100644 --- a/api/urls.py +++ b/api/urls.py @@ -17,4 +17,5 @@ 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//', api_views.UserDetail.as_view(), name='user-detail'), + urls.path('config/', api_views.ConfigDetail.as_view(), name='config-detail'), ] diff --git a/api/utils.py b/api/utils.py index 2af55a9..cc23129 100644 --- a/api/utils.py +++ b/api/utils.py @@ -4,27 +4,33 @@ import random import jwt from jwt import exceptions as jwt_exceptions from django import conf -from rest_framework import exceptions +from rest_framework import exceptions as rest_framework_exceptions + +from home_industry import utils def generate_otp(): digits = '0123456789' - otp = '' - for _ in range(6): - otp += digits[math.floor(random.random() * 10)] + otp = ''.join([digits[math.floor(random.random() * 10)] for _ in range(6)]) return otp def get_username_from_bearer_token(http_authorization): if http_authorization is None: - raise exceptions.NotAuthenticated() + raise rest_framework_exceptions.NotAuthenticated() bearer, _, encoded_jwt = http_authorization.partition(' ') if bearer != 'Bearer': - raise exceptions.ValidationError() + raise rest_framework_exceptions.ValidationError() try: decoded_jwt = jwt.decode(encoded_jwt, conf.settings.SECRET_KEY, algorithms=['HS256']) except (jwt_exceptions.DecodeError, jwt_exceptions.ExpiredSignatureError): - raise exceptions.ValidationError() + raise rest_framework_exceptions.ValidationError() if (decoded_jwt.get('exp') is None) or (decoded_jwt.get('username') is None): - raise exceptions.ValidationError() + raise rest_framework_exceptions.ValidationError() return decoded_jwt['username'] + + +def send_otp(phone_number, otp): + message = 'Kode otentikasi akun Industri Pilar Anda adalah {}. ' \ + 'RAHASIAKAN kode otentikasi Anda.'.format(otp) + utils.send_sms(phone_number, message) diff --git a/api/views.py b/api/views.py index 9462640..1e7325c 100644 --- a/api/views.py +++ b/api/views.py @@ -50,9 +50,9 @@ class AuthPhoneNumberLogin(rest_framework_views.APIView): models.User, phone_number=serializer.validated_data['phone_number'] ) - user.otp = '123456' + user.otp = utils.generate_otp() user.save() - # Send OTP + utils.send_otp(user.phone_number, user.otp) encoded_jwt = jwt.encode( {'exp': datetime.utcnow() + timedelta(hours=1), 'username': user.username}, conf.settings.SECRET_KEY, @@ -89,10 +89,10 @@ class AuthResendOTP(rest_framework_views.APIView): http_authorization = self.request.META.get('HTTP_AUTHORIZATION') username = utils.get_username_from_bearer_token(http_authorization) user = shortcuts.get_object_or_404(models.User, username=username) - user.otp = '123456' # user.otp = utils.generate_otp() + user.otp = utils.generate_otp() user.save() - # Send OTP - return response.Response(status=status.HTTP_200_OK) + utils.send_otp(user.phone_number, user.otp) + return response.Response(status=status.HTTP_204_NO_CONTENT) class UserDetail(generics.RetrieveUpdateDestroyAPIView): @@ -107,7 +107,11 @@ class UserDetail(generics.RetrieveUpdateDestroyAPIView): class UserList(generics.ListCreateAPIView): - filter_backends = [filters.OrderingFilter, filters.SearchFilter, rest_framework.DjangoFilterBackend] + filter_backends = [ + filters.OrderingFilter, + filters.SearchFilter, + rest_framework.DjangoFilterBackend, + ] filterset_fields = ['username', 'full_name', 'phone_number'] ordering_fields = ['username', 'full_name', 'phone_number'] pagination_class = paginations.SmallResultsSetPagination @@ -115,3 +119,13 @@ class UserList(generics.ListCreateAPIView): queryset = models.User.objects.all() search_fields = ['username', 'full_name', 'phone_number'] serializer_class = api_serializers.UserSerializer + + +class ConfigDetail(generics.RetrieveUpdateAPIView): + permission_classes = [rest_framework_permissions.IsAdminUser] + queryset = models.Config.objects.all() + serializer_class = api_serializers.ConfigSerializer + + def get_object(self): + obj = shortcuts.get_object_or_404(models.Config) + return obj diff --git a/home_industry/settings/ci.py b/home_industry/settings/ci.py index 25784e2..7c77e00 100644 --- a/home_industry/settings/ci.py +++ b/home_industry/settings/ci.py @@ -26,6 +26,7 @@ INSTALLED_APPS = [ 'phonenumber_field', 'rest_framework', 'knox', + 'solo', 'api.apps.ApiConfig', 'django_cleanup', ] diff --git a/home_industry/settings/local.py b/home_industry/settings/local.py index e5590f0..54e47c5 100644 --- a/home_industry/settings/local.py +++ b/home_industry/settings/local.py @@ -26,6 +26,7 @@ INSTALLED_APPS = [ 'phonenumber_field', 'rest_framework', 'knox', + 'solo', 'api.apps.ApiConfig', 'django_cleanup', ] diff --git a/home_industry/settings/production.py b/home_industry/settings/production.py index d6809e3..188fec9 100644 --- a/home_industry/settings/production.py +++ b/home_industry/settings/production.py @@ -26,6 +26,7 @@ INSTALLED_APPS = [ 'phonenumber_field', 'rest_framework', 'knox', + 'solo', 'api.apps.ApiConfig', 'django_cleanup', ] @@ -136,6 +137,14 @@ REST_FRAMEWORK = { 'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema', } +# Amazon Web Services + +AWS = { + 'AWS_ACCESS_KEY_ID': os.environ['AWS_ACCESS_KEY_ID'], + 'AWS_SECRET_ACCESS_KEY': os.environ['AWS_SECRET_ACCESS_KEY'], + 'AWS_REGION_NAME': 'ap-southeast-1', +} + # django-cors-headers # https://github.com/adamchainz/django-cors-headers diff --git a/home_industry/settings/staging.py b/home_industry/settings/staging.py index bd0ab9d..16b7e2d 100644 --- a/home_industry/settings/staging.py +++ b/home_industry/settings/staging.py @@ -27,6 +27,7 @@ INSTALLED_APPS = [ 'phonenumber_field', 'rest_framework', 'knox', + 'solo', 'api.apps.ApiConfig', 'django_cleanup', ] @@ -130,6 +131,14 @@ REST_FRAMEWORK = { 'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema', } +# Amazon Web Services + +AWS = { + 'AWS_ACCESS_KEY_ID': os.environ['AWS_ACCESS_KEY_ID'], + 'AWS_SECRET_ACCESS_KEY': os.environ['AWS_SECRET_ACCESS_KEY'], + 'AWS_REGION_NAME': 'ap-southeast-1', +} + # django-cors-headers # https://github.com/adamchainz/django-cors-headers diff --git a/home_industry/utils.py b/home_industry/utils.py new file mode 100644 index 0000000..e566248 --- /dev/null +++ b/home_industry/utils.py @@ -0,0 +1,23 @@ +import boto3 +from django import conf +from django.utils.translation import gettext_lazy as _ +from rest_framework import exceptions as rest_framework_exceptions + +from api import models + + +def send_sms(phone_number, message): + try: + aws_settings = conf.settings.AWS + config = models.Config.objects.get() + except (AttributeError, models.Config.DoesNotExist): + raise rest_framework_exceptions.APIException(_('A bad configuration error occurred.')) + if not config.send_sms: + raise rest_framework_exceptions.APIException(_('Server is currently unable to send SMS.')) + client = boto3.client( + 'sns', + aws_access_key_id=aws_settings['AWS_ACCESS_KEY_ID'], + aws_secret_access_key=aws_settings['AWS_SECRET_ACCESS_KEY'], + region_name=aws_settings['AWS_REGION_NAME'] + ) + client.publish(PhoneNumber=phone_number, Message=message) diff --git a/locale/id/LC_MESSAGES/django.mo b/locale/id/LC_MESSAGES/django.mo index d7d1ee3a9c359bd59fb926a6910ea05395e65653..953a869aeecfe5c2cd08584a6178e27553af2faf 100644 GIT binary patch delta 869 zcmYk(&1(}u7{K9K(99hP>3iLy;Q+S4|?e&yQX8(UD@3f1R)9v z_9PPQL2q6>2ws90K`4TrJP6+X2fPS+5k2^OlL=(lcb=J9X5RVuI{h-={ysbMTv6tT zqr`9GIPt}!qO9#wY5@(N$H%yYAJE_*Jd3w7{WYE~1&a z@gN?0R@Rl_1^>Vr>)M8xGUvJ>3dTwHfF%(n%2dy0TI! zb;_BQss4JZPiHGr&bi;+PnnumxDmv6jBj-#NKDhLn@~3f8%S)aTO7UbExL4xF{!qp s)v5JOL;L0~{f!{3+t_aO6TZq`Ul^LNhT5lw+MQq7(_@2K9i2P#A5Cq8GXMYp delta 551 zcmXZZJ4nM&6vpwBHn#OuZEIDmiaLl7$eFNS!Cx%k ze8jATWn9K5%wY?;Y$R%y!7=1AkD(8ja2D54b#^gsR>lw7}il6Zad!L0Q39K z{1N+^zoCmC*oB{1pvf;RGM{5n4e#MGHc{&tip=0PMyPL9Zbn%+L-yJYsz4nVu!)oS zf?W2^pa%a?Cw4f7bW!`_*o#@z!KOPdVuE=dQ&>cY`eCFQv!oi(Ln@@u2^HEf6yCLe zm0ql`k|b4}cZH!Z(L)Zlr(r|-9coCS#%<%MQave8o$eo0g6qh-\n" "Language-Team: LANGUAGE \n" @@ -18,54 +18,74 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: api/models.py:11 +#: api/models.py:12 api/models.py:48 msgid "ID" msgstr "ID" -#: api/models.py:14 +#: api/models.py:15 msgid "full name" msgstr "nama lengkap" -#: api/models.py:15 +#: api/models.py:16 msgid "phone number" msgstr "nomor telepon" -#: api/models.py:16 +#: api/models.py:17 msgid "address" msgstr "alamat" -#: api/models.py:20 +#: api/models.py:21 msgid "neighborhood" msgstr "RT" -#: api/models.py:25 +#: api/models.py:26 msgid "hamlet" msgstr "RW" -#: api/models.py:27 +#: api/models.py:28 msgid "urban village" msgstr "kelurahan" -#: api/models.py:28 +#: api/models.py:29 msgid "sub-district" msgstr "kecamatan" -#: api/models.py:33 +#: api/models.py:34 msgid "profile picture" msgstr "foto profil" -#: api/models.py:35 +#: api/models.py:36 msgid "OTP" msgstr "OTP" -#: api/models.py:39 +#: api/models.py:40 msgid "user" msgstr "pengguna" -#: api/models.py:40 +#: api/models.py:41 msgid "users" msgstr "pengguna-pengguna" +#: api/models.py:49 +msgid "send SMS" +msgstr "kirim SMS" + +#: api/models.py:52 +msgid "config" +msgstr "konfigurasi" + +#: api/models.py:53 +msgid "configs" +msgstr "konfigurasi-konfigurasi" + +#: home_industry/utils.py:14 +msgid "A bad configuration error occurred." +msgstr "Terjadi kesalahan konfigurasi." + +#: home_industry/utils.py:16 +msgid "Server is currently unable to send SMS." +msgstr "Server saat ini tidak dapat mengirim SMS." + msgid "Not a valid string." msgstr "Bukan string yang valid." diff --git a/requirements.txt b/requirements.txt index 7a38b51..63eab17 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,6 +17,7 @@ django-cors-headers==3.2.1 django-filter==2.2.0 django-phonenumber-field==4.0.0 django-rest-knox==4.1.0 +django-solo==1.1.3 django-storages==1.9.1 djangorestframework==3.11.0 docutils==0.15.2 -- GitLab From a94de10816e2a53da2011f7d77f4417ced72ae4f Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Thu, 5 Mar 2020 00:15:36 +0700 Subject: [PATCH 45/90] [CHORES] Update sonar-project.properties --- sonar-project.properties | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/sonar-project.properties b/sonar-project.properties index eab118e..813fdb6 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,5 +1,9 @@ +sonar.exclusions=**/*.tests.py,**/migrations sonar.python.coverage.reportPaths=coverage.xml sonar.scm.provider=git sonar.sourceEncoding=UTF-8 sonar.sources=api -sonar.test.inclusions=**/*.tests.py + +sonar.issue.ignore.multicriteria=p1 +sonar.issue.ignore.multicriteria.p1.ruleKey=python:S2245 +sonar.issue.ignore.multicriteria.p1.resourceKey=**/*.py -- GitLab From 2501ea7cae295ba49acc716d38ac37fd76b1107a Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Thu, 5 Mar 2020 00:17:35 +0700 Subject: [PATCH 46/90] [CHORES] Update sonar-project.properties --- sonar-project.properties | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/sonar-project.properties b/sonar-project.properties index 813fdb6..d8752b1 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,9 +1,8 @@ -sonar.exclusions=**/*.tests.py,**/migrations +sonar.exclusions=**/tests.py,**/migrations sonar.python.coverage.reportPaths=coverage.xml sonar.scm.provider=git sonar.sourceEncoding=UTF-8 sonar.sources=api - sonar.issue.ignore.multicriteria=p1 sonar.issue.ignore.multicriteria.p1.ruleKey=python:S2245 sonar.issue.ignore.multicriteria.p1.resourceKey=**/*.py -- GitLab From 73671bbb29affc6090f1cb98fe8d484dc7ab3ebd Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Thu, 5 Mar 2020 00:33:11 +0700 Subject: [PATCH 47/90] [CHORES] Update sonar-project.properties --- sonar-project.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sonar-project.properties b/sonar-project.properties index d8752b1..e07163f 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,4 +1,4 @@ -sonar.exclusions=**/tests.py,**/migrations +sonar.exclusions=**/tests.py,**/migrations/** sonar.python.coverage.reportPaths=coverage.xml sonar.scm.provider=git sonar.sourceEncoding=UTF-8 -- GitLab From d2c97510f49dcf4fea90fb464f20862a519f9bf9 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Thu, 5 Mar 2020 12:21:22 +0700 Subject: [PATCH 48/90] [CHORES] Fix string representation of phone number --- api/views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/views.py b/api/views.py index 1e7325c..04aff1d 100644 --- a/api/views.py +++ b/api/views.py @@ -52,7 +52,7 @@ class AuthPhoneNumberLogin(rest_framework_views.APIView): ) user.otp = utils.generate_otp() user.save() - utils.send_otp(user.phone_number, user.otp) + utils.send_otp(str(user.phone_number), user.otp) encoded_jwt = jwt.encode( {'exp': datetime.utcnow() + timedelta(hours=1), 'username': user.username}, conf.settings.SECRET_KEY, @@ -91,7 +91,7 @@ class AuthResendOTP(rest_framework_views.APIView): user = shortcuts.get_object_or_404(models.User, username=username) user.otp = utils.generate_otp() user.save() - utils.send_otp(user.phone_number, user.otp) + utils.send_otp(str(user.phone_number), user.otp) return response.Response(status=status.HTTP_204_NO_CONTENT) -- GitLab From 7efd370dc2bd9850308e6b1680dd8b3fb17d0a26 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Thu, 5 Mar 2020 14:17:06 +0700 Subject: [PATCH 49/90] [CHORES] Update README.md --- README.md | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/README.md b/README.md index 3946cf3..dee7140 100644 --- a/README.md +++ b/README.md @@ -2,3 +2,54 @@ [![pipeline status](https://gitlab.cs.ui.ac.id/ppl-fasilkom-ui/2020/ppl-c/diskominfo-depok-tpu-online/post-rpl-backend/badges/master/pipeline.svg)](https://gitlab.cs.ui.ac.id/ppl-fasilkom-ui/2020/ppl-c/diskominfo-depok-tpu-online/post-rpl-backend/commits/master) [![coverage report](https://gitlab.cs.ui.ac.id/ppl-fasilkom-ui/2020/ppl-c/diskominfo-depok-tpu-online/post-rpl-backend/badges/master/coverage.svg)](https://gitlab.cs.ui.ac.id/ppl-fasilkom-ui/2020/ppl-c/diskominfo-depok-tpu-online/post-rpl-backend/commits/master) + +## Table of Contents + +- [Local Configuration](#local-configuration) + +## Local Configuration + +- Install Python 3.7 and PostgreSQL + +- Create Python virtual environment + +``` +cd /path/to/project/directory +python3 -m venv env +source env/bin/activate +pip3 install -r requirements.txt +``` + +- Create PostgreSQL postgres user with password postgres + +- Create PostgreSQL database + +``` +psql +CREATE DATABASE home_industry; +\q +``` + +- Set up environment variables + +``` +export DATABASE_HOST="127.0.0.1" +export DATABASE_NAME="home_industry" +export DATABASE_PASSWORD="postgres" +export DATABASE_PORT="5432" +export DATABASE_USER="postgres" +export DJANGO_SETTINGS_MODULE="home_industry.settings.local" +export SECRET_KEY="7&s33ax$lxxzti1)0y=8#tu!$7bdy)p$1@kn06tp&8x8i9#h2u" +``` + +- Migrate the database + +``` +python3 manage.py migrate +``` + +- Run server + +``` +python3 manage.py runserver +``` -- GitLab From 89d055a161996f85da98050522abda6a0d6ba11f Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Thu, 5 Mar 2020 14:25:06 +0700 Subject: [PATCH 50/90] [CHORES] Update README.md --- README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/README.md b/README.md index dee7140..f3e60ba 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ ## Table of Contents - [Local Configuration](#local-configuration) +- [Deployed API URLs](#deployed-api-urls) +- [API Documentation](#api-documentation) ## Local Configuration @@ -53,3 +55,12 @@ python3 manage.py migrate ``` python3 manage.py runserver ``` + +## Deployed API URLs + +- Staging: https://industripilar-staging.herokuapp.com +- Production: https://api.industripilar.com + +## API Documentation + +https://industripilar-staging.herokuapp.com/docs/ -- GitLab From 47cc1a120097f51c6e7ac3a457b899a8463e4b2c Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Sat, 7 Mar 2020 20:24:23 +0700 Subject: [PATCH 51/90] [CHORES] Remove query parameter authentication for S3 bucket --- home_industry/settings/production.py | 2 ++ home_industry/settings/staging.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/home_industry/settings/production.py b/home_industry/settings/production.py index 188fec9..f6cb57f 100644 --- a/home_industry/settings/production.py +++ b/home_industry/settings/production.py @@ -182,6 +182,8 @@ AWS_S3_OBJECT_PARAMETERS = { 'CacheControl': 'max-age=86400', } +AWS_QUERYSTRING_AUTH = False + MEDIA_LOCATION = 'media' STATIC_LOCATION = 'static' diff --git a/home_industry/settings/staging.py b/home_industry/settings/staging.py index 16b7e2d..0945f93 100644 --- a/home_industry/settings/staging.py +++ b/home_industry/settings/staging.py @@ -176,6 +176,8 @@ AWS_S3_OBJECT_PARAMETERS = { 'CacheControl': 'max-age=86400', } +AWS_QUERYSTRING_AUTH = False + MEDIA_LOCATION = 'media' STATIC_LOCATION = 'static' -- GitLab From 26e29f979a4babfaefbe2fdd63087735672cc2c9 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Mon, 9 Mar 2020 21:07:30 +0700 Subject: [PATCH 52/90] Complete Program API --- api/migrations/0003_program.py | 32 ++++++++ api/migrations/0004_auto_20200309_1953.py | 18 +++++ api/models.py | 40 +++++++++- api/permissions.py | 15 +++- api/serializers.py | 29 +++++++ api/tests.py | 90 ++++++++++++++++++++++ api/urls.py | 2 + api/utils.py | 11 +-- api/views.py | 45 ++++++++--- locale/id/LC_MESSAGES/django.mo | Bin 2203 -> 2856 bytes locale/id/LC_MESSAGES/django.po | 62 +++++++++++++-- 11 files changed, 320 insertions(+), 24 deletions(-) create mode 100644 api/migrations/0003_program.py create mode 100644 api/migrations/0004_auto_20200309_1953.py diff --git a/api/migrations/0003_program.py b/api/migrations/0003_program.py new file mode 100644 index 0000000..7b4bd19 --- /dev/null +++ b/api/migrations/0003_program.py @@ -0,0 +1,32 @@ +# Generated by Django 3.0.3 on 2020-03-09 12:53 + +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0002_config'), + ] + + operations = [ + migrations.CreateModel( + name='Program', + 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')), + ('start_date_time', models.DateTimeField(blank=True, db_index=True, null=True, verbose_name='start date and time')), + ('end_date_time', models.DateTimeField(blank=True, null=True, verbose_name='end date and time')), + ('location', models.CharField(blank=True, max_length=200, null=True, verbose_name='location')), + ('speaker', models.CharField(blank=True, max_length=200, null=True, verbose_name='speaker')), + ('poster_image', models.ImageField(blank=True, null=True, upload_to='uploads/programs/', verbose_name='poster image')), + ], + options={ + 'verbose_name': 'program', + 'verbose_name_plural': 'programs', + 'ordering': ['start_date_time', 'name'], + }, + ), + ] diff --git a/api/migrations/0004_auto_20200309_1953.py b/api/migrations/0004_auto_20200309_1953.py new file mode 100644 index 0000000..157fd5e --- /dev/null +++ b/api/migrations/0004_auto_20200309_1953.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.3 on 2020-03-09 12:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0003_program'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='profile_picture', + field=models.ImageField(blank=True, null=True, upload_to='uploads/users/', verbose_name='profile picture'), + ), + ] diff --git a/api/models.py b/api/models.py index e26d453..1eb8b6d 100644 --- a/api/models.py +++ b/api/models.py @@ -30,7 +30,7 @@ class User(auth_models.AbstractUser): profile_picture = db_models.ImageField( blank=True, null=True, - upload_to='uploads/profile/', + upload_to='uploads/users/', verbose_name=_('profile picture') ) otp = db_models.CharField(blank=True, max_length=6, null=True, verbose_name=_('OTP')) @@ -44,6 +44,44 @@ class User(auth_models.AbstractUser): return self.username +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')) + description = db_models.TextField(verbose_name=_('description')) + start_date_time = db_models.DateTimeField( + blank=True, + db_index=True, + null=True, + verbose_name=_('start date and time') + ) + end_date_time = db_models.DateTimeField( + blank=True, + null=True, + verbose_name=_('end date and time') + ) + location = db_models.CharField( + blank=True, + max_length=200, + null=True, + verbose_name=_('location') + ) + speaker = db_models.CharField(blank=True, max_length=200, null=True, verbose_name=_('speaker')) + poster_image = db_models.ImageField( + blank=True, + null=True, + upload_to='uploads/programs/', + verbose_name=_('poster image') + ) + + class Meta: + ordering = ['start_date_time', 'name'] + verbose_name = _('program') + verbose_name_plural = _('programs') + + def __str__(self): + return self.name + + class Config(solo_models.SingletonModel): id = db_models.UUIDField(default=uuid.uuid4, primary_key=True, verbose_name=_('ID')) send_sms = db_models.BooleanField(default=False, verbose_name=_('send SMS')) diff --git a/api/permissions.py b/api/permissions.py index 9e5f90b..d1a5e98 100644 --- a/api/permissions.py +++ b/api/permissions.py @@ -1,13 +1,22 @@ from rest_framework import permissions +class IsAdminUserOrReadOnly(permissions.BasePermission): + def has_permission(self, request, _view): + return bool( + ((request.user) and (request.user.is_staff)) or + (request.method in permissions.SAFE_METHODS) + ) + + class IsAdminUserOrSelf(permissions.BasePermission): - def has_object_permission(self, request, _, obj): + def has_object_permission(self, request, _view, obj): return bool( - (request.user.is_authenticated) and ((request.user.is_staff) or (obj == request.user)) + ((request.user) and (request.user.is_staff)) or + ((request.user) and (obj == request.user)) ) class IsAnonymousUser(permissions.BasePermission): - def has_permission(self, request, _): + def has_permission(self, request, _view): return bool(request.user.is_anonymous) diff --git a/api/serializers.py b/api/serializers.py index 8362cbf..7a590af 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -1,5 +1,6 @@ from django.contrib.auth import password_validation from django.core import exceptions +from django.utils.translation import gettext_lazy as _ from phonenumber_field import serializerfields from rest_framework import serializers @@ -64,6 +65,34 @@ class UserSerializer(serializers.ModelSerializer): return super().validate(attrs) +class ProgramSerializer(serializers.ModelSerializer): + class Meta: + fields = [ + 'id', + 'name', + 'description', + 'start_date_time', + 'end_date_time', + 'location', + 'speaker', + 'poster_image', + ] + model = models.Program + read_only_fields = ['id'] + + def validate(self, attrs): + instance = self.instance + start_date_time = attrs.get('start_date_time', getattr(instance, 'start_date_time', None)) + end_date_time = attrs.get('end_date_time', getattr(instance, 'end_date_time', None)) + errors = {} + if (start_date_time is not None) and (end_date_time is not None): + if end_date_time <= start_date_time: + errors['end_date_time'] = _('End date time should be greater than start date time.') + if errors: + raise serializers.ValidationError(errors) + return super().validate(attrs) + + class ConfigSerializer(serializers.ModelSerializer): class Meta: fields = ['id', 'send_sms'] diff --git a/api/tests.py b/api/tests.py index 1a139be..d28f288 100644 --- a/api/tests.py +++ b/api/tests.py @@ -25,6 +25,15 @@ USER_DATA = { 'sub_district': 'Dummy Sub-District', } +PROGRAM_DATA = { + 'name': 'Dummy Program', + 'description': 'Dummy description.', + 'start_date_time': '2020-01-01T00:00:00+07:00', + 'end_date_time': '2020-02-02T00:00:00+07:00', + 'location': 'Dummy Location', + 'speaker': 'Dummy Speaker', +} + def get_http_authorization(username, password): client = rest_framework_test.APIClient() @@ -244,6 +253,87 @@ class UserTest(rest_framework_test.APITestCase): 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) + self.superuser_http_authorization = get_http_authorization( + SUPERUSER_DATA['username'], + SUPERUSER_DATA['password'] + ) + + def test_program_model_string_representation(self): + program = models.Program.objects.create(**PROGRAM_DATA) + self.assertEqual(str(program), PROGRAM_DATA['name']) + + def test_program_list_success(self): + http_authorization = self.superuser_http_authorization + url = urls.reverse('program-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_program_detail_success(self): + program = models.Program.objects.create(**PROGRAM_DATA) + program_id = str(program.id) + http_authorization = self.superuser_http_authorization + url = urls.reverse('program-detail', args=[program_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_program_success(self): + http_authorization = self.superuser_http_authorization + data = PROGRAM_DATA + url = urls.reverse('program-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.Program.objects.count(), 1) + program_id = response.json()['id'] + self.assertEqual(models.Program.objects.get(id=program_id).name, data['name']) + + def test_create_program_fail(self): + http_authorization = self.superuser_http_authorization + url = urls.reverse('program-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.Program.objects.count(), 0) + + def test_update_program_success(self): + http_authorization = self.superuser_http_authorization + data = PROGRAM_DATA + url = urls.reverse('program-list') + self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member + response = self.client.post(url, data, format='json') + program_id = response.json()['id'] + data = { + 'name': 'Dummy', + } + url = urls.reverse('program-detail', args=[program_id]) + response = self.client.patch(url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(models.Program.objects.get(id=program_id).name, data['name']) + data = PROGRAM_DATA + response = self.client.put(url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(models.Program.objects.get(id=program_id).name, data['name']) + + def test_update_program_fail(self): + http_authorization = self.superuser_http_authorization + data = PROGRAM_DATA + url = urls.reverse('program-list') + self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member + response = self.client.post(url, data, format='json') + program_id = response.json()['id'] + data = { + 'end_date_time': '2020-01-01T00:00:00+07:00', + } + url = urls.reverse('program-detail', args=[program_id]) + response = self.client.patch(url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + class ConfigTest(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 e02bcf6..c7db5ec 100644 --- a/api/urls.py +++ b/api/urls.py @@ -17,5 +17,7 @@ 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//', api_views.UserDetail.as_view(), name='user-detail'), + urls.path('programs/', api_views.ProgramList.as_view(), name='program-list'), + urls.path('programs//', api_views.ProgramDetail.as_view(), name='program-detail'), urls.path('config/', api_views.ConfigDetail.as_view(), name='config-detail'), ] diff --git a/api/utils.py b/api/utils.py index cc23129..d4aa69a 100644 --- a/api/utils.py +++ b/api/utils.py @@ -4,6 +4,7 @@ import random import jwt from jwt import exceptions as jwt_exceptions from django import conf +from django.utils.translation import gettext_lazy as _ from rest_framework import exceptions as rest_framework_exceptions from home_industry import utils @@ -11,22 +12,22 @@ from home_industry import utils def generate_otp(): digits = '0123456789' - otp = ''.join([digits[math.floor(random.random() * 10)] for _ in range(6)]) + otp = ''.join([digits[math.floor(random.random() * 10)] for _index in range(6)]) return otp def get_username_from_bearer_token(http_authorization): if http_authorization is None: raise rest_framework_exceptions.NotAuthenticated() - bearer, _, encoded_jwt = http_authorization.partition(' ') + bearer, _sep, encoded_jwt = http_authorization.partition(' ') if bearer != 'Bearer': - raise rest_framework_exceptions.ValidationError() + raise rest_framework_exceptions.ValidationError(_('Invalid authorization header format.')) try: decoded_jwt = jwt.decode(encoded_jwt, conf.settings.SECRET_KEY, algorithms=['HS256']) except (jwt_exceptions.DecodeError, jwt_exceptions.ExpiredSignatureError): - raise rest_framework_exceptions.ValidationError() + raise rest_framework_exceptions.ValidationError(_('Invalid token.')) if (decoded_jwt.get('exp') is None) or (decoded_jwt.get('username') is None): - raise rest_framework_exceptions.ValidationError() + raise rest_framework_exceptions.ValidationError(_('Invalid token.')) return decoded_jwt['username'] diff --git a/api/views.py b/api/views.py index 04aff1d..2cdd996 100644 --- a/api/views.py +++ b/api/views.py @@ -43,7 +43,7 @@ class AuthPhoneNumberLogin(rest_framework_views.APIView): def get_serializer(self, *args, **kwargs): return self.serializer_class(*args, **kwargs) - def post(self, request, _=None): + def post(self, request, _format=None): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) user = shortcuts.get_object_or_404( @@ -85,7 +85,7 @@ class AuthOTPLogin(knox_views.LoginView): class AuthResendOTP(rest_framework_views.APIView): permission_classes = [rest_framework_permissions.AllowAny] - def post(self, request, _=None): + def post(self, request, _format=None): http_authorization = self.request.META.get('HTTP_AUTHORIZATION') username = utils.get_username_from_bearer_token(http_authorization) user = shortcuts.get_object_or_404(models.User, username=username) @@ -95,6 +95,21 @@ class AuthResendOTP(rest_framework_views.APIView): return response.Response(status=status.HTTP_204_NO_CONTENT) +class UserList(generics.ListCreateAPIView): + filter_backends = [ + filters.OrderingFilter, + filters.SearchFilter, + rest_framework.DjangoFilterBackend, + ] + filterset_fields = ['username', 'full_name', 'phone_number'] + ordering_fields = ['username', 'full_name', 'phone_number'] + pagination_class = paginations.SmallResultsSetPagination + permission_classes = [rest_framework_permissions.IsAdminUser] + queryset = models.User.objects.all() + search_fields = ['username', 'full_name', 'phone_number'] + serializer_class = api_serializers.UserSerializer + + class UserDetail(generics.RetrieveUpdateDestroyAPIView): permission_classes = [api_permissions.IsAdminUserOrSelf] queryset = models.User.objects.all() @@ -106,19 +121,31 @@ class UserDetail(generics.RetrieveUpdateDestroyAPIView): return super().get_object() -class UserList(generics.ListCreateAPIView): +class ProgramList(generics.ListCreateAPIView): filter_backends = [ filters.OrderingFilter, filters.SearchFilter, rest_framework.DjangoFilterBackend, ] - filterset_fields = ['username', 'full_name', 'phone_number'] - ordering_fields = ['username', 'full_name', 'phone_number'] + filterset_fields = ['name'] + ordering_fields = ['name', 'start_date_time'] pagination_class = paginations.SmallResultsSetPagination - permission_classes = [rest_framework_permissions.IsAdminUser] - queryset = models.User.objects.all() - search_fields = ['username', 'full_name', 'phone_number'] - serializer_class = api_serializers.UserSerializer + permission_classes = [ + rest_framework_permissions.IsAuthenticated, + api_permissions.IsAdminUserOrReadOnly + ] + queryset = models.Program.objects.all() + search_fields = ['name'] + serializer_class = api_serializers.ProgramSerializer + + +class ProgramDetail(generics.RetrieveUpdateDestroyAPIView): + permission_classes = [ + rest_framework_permissions.IsAuthenticated, + api_permissions.IsAdminUserOrReadOnly + ] + queryset = models.Program.objects.all() + serializer_class = api_serializers.ProgramSerializer class ConfigDetail(generics.RetrieveUpdateAPIView): diff --git a/locale/id/LC_MESSAGES/django.mo b/locale/id/LC_MESSAGES/django.mo index 953a869aeecfe5c2cd08584a6178e27553af2faf..33c40fff60af4b9bc07ced661c80e5cf57721eeb 100644 GIT binary patch literal 2856 zcmbuAO^6&t6vxXLHLlTU)Mzxi6{Go3dQ1>8&L$zV*-2cS*&TOh6ZI0Rr)#Dw-CfmG zRd06P5$_7#{J)-_-5pFsv7q+XRa5V?UcLQs z=hhDy#(vB_n5{b)dl|g61%EI)cQUpQd;_HY0(=Gh44eXg0R=d@m9dw=cflvYFF;c9 z9r!HxJ@^3lC-^Y9?JmY3%JzWUK@J`O1CV@K0?D6C;630+;N9TWYW*pAAJ*5vhrqAE zd%+tZo%;dY2L1$+{$DHpUU3T!?ZEyHkn~T2bpAla!`1$)Ao<$>NzW2^KNx}th2
mb?x8axDk3qBA20g~Ol6T|pVf|Q>#6&FDqVdudoKm}4f?}23Z0}!sWE7kgAknDU? z@pF*$T?Z*&-&DK-lHQvYe+D1J`nPJm1w!=uc98PBtKuYxbd|mUh@!j&@T6c-Ju(|wpvVN%Zb zWA4Iy4wG_5^+vJm#-tq4pt~wZgNGvGJ)`?-P*~xW(Og<6&5nFa=fo3L}KB%@H~~mdA^n@eLj_X;N#1@ z7Yi$T(B*C?rwz|csSdW*GO=mm!Wc~I5{HNGZSM3$%W{BZa3 zA&yAJ=*&gsV-EdFLyazzAyVEGY07ge`)Y-}`>z)MNv6hTXV|PBx+@AF8>`+KUP3HI z1h@OfX2RoKb;z4U>KdCfemzX*t*QQBpwK>o{{)IMG1g%)9oTQS^8 zTu=-B#AW@5Gc1w|01LMJsQqR2|;*szi+I2K9Qvnr>u zVq~u@BY_VpCDt#HSuOAti!7C%rKVR#&$Lv7I5ajkCSsfKk>$qG4X8{EB+ISoD}J(>V2d<4lvEZ7wX%&d*IO$lO>TwB0~O!E`ZjLD$szL}xx&kjsi>f*CxS zI)AI#SP0tl!Ayg{HeYYgzVP<^Tyvs@hY@tG&@L6;*g8MG)N1j$M!U(6HQSBZ*2%`q z%tCW<@dS?QK>-hgt`u3Fw;FS2mdKN1t=7p@)r=i%oPh`J*>-co?IX3r6Q>L=S;O3F z4u8B{@h|47PiYL;ER8YG}Mjgkg8apQvUvQ6*26%6Hts;q)MZd6xL%Rr7 zj7=vj%Eu~xaqM(f!0Q_8AayE%;!s7d$h_T&WY&~*(}Mt7nDYUXOAlMu>LLgdL+1cA!+1iK}{$Fl0?3>@tEc<5O%Hl}Eq{5@ zg{S*Oj^cH!;mvY<3kMjlm-8E_iM-MbPcVLjb$nUQzs6&X-yx5Dr0d5|I8Sl=IK}$% zkJ)Q{m}FJ0>@Hf|L7lI$t7%+Bt<1-9>>zuWC#VZO!%Mh>v$&5__zSh52D=-?CZ5J~ zIK=vLg%?eHqjV89;S%Z(>!>HtDh*2?AdftxYvOa%Tbm%XHF_N6*8fM#G5a~V?&Ij{ z0(v~B2u-Ft)20=kBy=Nsk6M9Ndxp^C*P}day7*xmE?@LYw2c+Z)!E`_b+r4rI#Ma> z{kse8JPe)Kjg0ti6q-z86L@~hroOeClK3_@PMk+AlS-1>b`TmT3GQckYGljAo6hR; zYIm*nxYC`d->wvI2lf``IyP\n" "Language-Team: LANGUAGE \n" @@ -18,7 +18,7 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: api/models.py:12 api/models.py:48 +#: api/models.py:12 api/models.py:48 api/models.py:86 msgid "ID" msgstr "ID" @@ -64,19 +64,69 @@ msgstr "pengguna" #: api/models.py:41 msgid "users" -msgstr "pengguna-pengguna" +msgstr "pengguna" #: api/models.py:49 +#, fuzzy +#| msgid "full name" +msgid "name" +msgstr "nama lengkap" + +#: api/models.py:50 +msgid "description" +msgstr "deskripsi" + +#: api/models.py:55 +msgid "start date and time" +msgstr "tanggal dan waktu mulai" + +#: api/models.py:60 +msgid "end date and time" +msgstr "tanggal dan waktu berakhir" + +#: api/models.py:66 +msgid "location" +msgstr "lokasi" + +#: api/models.py:68 +msgid "speaker" +msgstr "pembicara" + +#: api/models.py:73 +msgid "poster image" +msgstr "gambar poster" + +#: api/models.py:78 +msgid "program" +msgstr "program" + +#: api/models.py:79 +msgid "programs" +msgstr "program" + +#: api/models.py:87 msgid "send SMS" msgstr "kirim SMS" -#: api/models.py:52 +#: api/models.py:90 msgid "config" msgstr "konfigurasi" -#: api/models.py:53 +#: api/models.py:91 msgid "configs" -msgstr "konfigurasi-konfigurasi" +msgstr "konfigurasi" + +#: api/serializers.py:90 +msgid "End date time should be greater than start date time." +msgstr "Waktu tanggal berakhir harus lebih besar dari waktu tanggal mulai." + +#: api/utils.py:24 +msgid "Invalid authorization header format." +msgstr "Format header otorisasi tidak valid." + +#: api/utils.py:28 api/utils.py:30 +msgid "Invalid token." +msgstr "Token tidak valid." #: home_industry/utils.py:14 msgid "A bad configuration error occurred." -- GitLab From e19be139aea2b32e50970676b6b4f689866522e8 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Mon, 9 Mar 2020 22:08:03 +0700 Subject: [PATCH 53/90] Fix Code Smells --- api/serializers.py | 6 +++--- api/utils.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/api/serializers.py b/api/serializers.py index 7a590af..84d2b7b 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -85,9 +85,9 @@ class ProgramSerializer(serializers.ModelSerializer): start_date_time = attrs.get('start_date_time', getattr(instance, 'start_date_time', None)) end_date_time = attrs.get('end_date_time', getattr(instance, 'end_date_time', None)) errors = {} - if (start_date_time is not None) and (end_date_time is not None): - if end_date_time <= start_date_time: - errors['end_date_time'] = _('End date time should be greater than start date time.') + if ((start_date_time is not None) and (end_date_time is not None) and + (end_date_time <= start_date_time)): + errors['end_date_time'] = _('End date time should be greater than start date time.') if errors: raise serializers.ValidationError(errors) return super().validate(attrs) diff --git a/api/utils.py b/api/utils.py index d4aa69a..15ce358 100644 --- a/api/utils.py +++ b/api/utils.py @@ -12,7 +12,7 @@ from home_industry import utils def generate_otp(): digits = '0123456789' - otp = ''.join([digits[math.floor(random.random() * 10)] for _index in range(6)]) + otp = ''.join([digits[math.floor(random.random() * 10)] for _ in range(6)]) return otp -- GitLab From 8c232c54cf1f1eec9bbfb2fae8b46f4a0977e4f1 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Mon, 16 Mar 2020 19:40:37 +0700 Subject: [PATCH 54/90] Complete Product API --- .../0005_category_product_subcategory.py | 61 ++++++++++ api/models.py | 80 ++++++++++++ api/serializers.py | 38 ++++++ api/tests.py | 115 ++++++++++++++++++ api/urls.py | 10 ++ api/views.py | 81 ++++++++++++ locale/id/LC_MESSAGES/django.mo | Bin 2856 -> 3211 bytes locale/id/LC_MESSAGES/django.po | 89 ++++++++++---- 8 files changed, 448 insertions(+), 26 deletions(-) create mode 100644 api/migrations/0005_category_product_subcategory.py diff --git a/api/migrations/0005_category_product_subcategory.py b/api/migrations/0005_category_product_subcategory.py new file mode 100644 index 0000000..c4bbd25 --- /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 1eb8b6d..b5b99ee 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 84d2b7b..de6722b 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 d28f288..91aa600 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 c7db5ec..7357c4d 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//', api_views.UserDetail.as_view(), name='user-detail'), + urls.path('categories/', api_views.CategoryList.as_view(), name='category-list'), + urls.path('categories//', api_views.CategoryDetail.as_view(), name='category-detail'), + urls.path('subcategories/', api_views.SubcategoryList.as_view(), name='subcategory-list'), + urls.path( + 'subcategories//', + api_views.SubcategoryDetail.as_view(), + name='subcategory-detail' + ), + urls.path('products/', api_views.ProductList.as_view(), name='product-list'), + urls.path('products//', api_views.ProductDetail.as_view(), name='product-detail'), urls.path('programs/', api_views.ProgramList.as_view(), name='program-list'), urls.path('programs//', 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 2cdd996..8543784 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 GIT binary patch delta 1269 zcmZA0OGs2v9LMpKI+M*zK1MSwr|dOmW`z=gQb9%NLRUouEjo_FbQ$c{%yl7Q1Jb4k zZLUER!cB`H5L&o%W!NH!f{0f2{f%d6=y3n{bIzH$=kY)HK54t%QCd#c z-7}PfL?417$BFK;ilhjU>xsYGv3Dy_!PO!OKuEp z-k_dezy$M~59Nw6OU@P4AHSmp{)Kwr4>n>wo$7Drrv5HxkL&M8?Qjq^&KPQgXK*80 z)Ixo=ncrNaLiWr})B|^Mh+~+;ZL}Aexm|M67=TJL-fm+C0 z)Hv^)?_K}LIQiEC7U_`7s0@5{fBfP6ja=nXNs37@x@<*F-04i?X4;2P869^0qp0W4 zIWw-`Pmq7@Y?=-^i)6lh&LpG!CPTUCOtr`~p)W!^Z6bP!O++iPm*^v^ix#SqBD5%F zxQkH6n|1y=v;Bm2QYqSnj$nuD&^c;>mC{|Q{O9(nHhU?5*6PxE>j-oldIvfPm6X2u zHbUvq_j!QO5$qw7^P{zAYmzp|=KO+}Ethz|D#ekN~lsw>>jQM_+d=dn04bc>V5(5<2DZCFHB=1Xm%H0;!XU7N^ycc_!Y0>KfH#um(6&! zXl-0XxY2=qIDjf9g(_$r!}uOUSaS0+ddzpQ5%;kUk5SKk!&>}-%75nk;|%anl=Usj zg%(JlUg&e)arcK%6_243rSJ-7kj>arB&QW{nC?BtTg*R`^dX+&2*ydSjy=H@+{6*q zw;*MA;W(;g3#gXmunFH_E0&O!*&eEZ1MJ5myp4ZR3wPH4@4zUkLyw#@$Rjq3H!z1q z?dTO3T6hB~-ZtI*18Siy=SNh+JygdIoyVxer_P_)!u;IL1H7dFBd89?oC%Nq)3Hi5 zP#Jo$*P*zlRiqAAb*TLfgpzBhc#TeiwJYaGpC&<6hdS9&o!E@obtd}0+L_v} zooVQ_b`$M{+MXn0L@%MP>CkC6aY9|v(C4z<=3pw|zX(0>zk~+;`tXET$PCQoR`btt i+5GNW_;-!J>P`BGUh-|^aoys4HocV2r\n" "Language-Team: LANGUAGE \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." -- GitLab From 7b8a91ba96056690d6b670c39a48118165bd4eb1 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Mon, 16 Mar 2020 21:04:33 +0700 Subject: [PATCH 55/90] Change Optional Subcategory to Required --- api/migrations/0006_auto_20200316_2058.py | 19 +++++++++++++++++++ api/models.py | 4 +--- 2 files changed, 20 insertions(+), 3 deletions(-) create mode 100644 api/migrations/0006_auto_20200316_2058.py diff --git a/api/migrations/0006_auto_20200316_2058.py b/api/migrations/0006_auto_20200316_2058.py new file mode 100644 index 0000000..1f6449f --- /dev/null +++ b/api/migrations/0006_auto_20200316_2058.py @@ -0,0 +1,19 @@ +# Generated by Django 3.0.3 on 2020-03-16 13:58 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0005_category_product_subcategory'), + ] + + operations = [ + migrations.AlterField( + model_name='product', + name='subcategory', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='products', to='api.Subcategory', verbose_name='subcategory'), + ), + ] diff --git a/api/models.py b/api/models.py index b5b99ee..4833f7b 100644 --- a/api/models.py +++ b/api/models.py @@ -94,9 +94,7 @@ class Product(db_models.Model): 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, + on_delete=db_models.PROTECT, related_name='products', verbose_name=_('subcategory') ) -- GitLab From ea8430597e342341983ef0a8f67edad91c5bf033 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Mon, 16 Mar 2020 23:21:01 +0700 Subject: [PATCH 56/90] Fix 500 Response Code to 409 for Integrity Error --- api/serializers.py | 32 ++++----- api/tests.py | 123 ++++++++++++++++++++++++++++++-- api/views.py | 24 +++++++ locale/id/LC_MESSAGES/django.mo | Bin 3211 -> 3463 bytes locale/id/LC_MESSAGES/django.po | 50 +++++++------ 5 files changed, 187 insertions(+), 42 deletions(-) diff --git a/api/serializers.py b/api/serializers.py index de6722b..d428fe0 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -65,6 +65,22 @@ class UserSerializer(serializers.ModelSerializer): return super().validate(attrs) +class CategorySerializer(serializers.ModelSerializer): + class Meta: + fields = ['id', 'name', 'image'] + model = models.Category + 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 ProductSerializer(serializers.ModelSerializer): category = serializers.ReadOnlyField(source='subcategory.category.pk') category_name = serializers.ReadOnlyField(source='subcategory.category.name') @@ -87,22 +103,6 @@ class ProductSerializer(serializers.ModelSerializer): 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 91aa600..7de06ee 100644 --- a/api/tests.py +++ b/api/tests.py @@ -268,27 +268,140 @@ class UserTest(rest_framework_test.APITestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) -class ProductTest(rest_framework_test.APITestCase): +class CategoryTest(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_create_category_success(self): + http_authorization = self.superuser_http_authorization + data = CATEGORY_DATA + url = urls.reverse('category-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.Category.objects.count(), 1) + category_id = response.json()['id'] + self.assertEqual(models.Category.objects.get(id=category_id).name, data['name']) + + def test_create_category_fail(self): + http_authorization = self.superuser_http_authorization + url = urls.reverse('category-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.Category.objects.count(), 0) + + def test_delete_category_success(self): + http_authorization = self.superuser_http_authorization + data = CATEGORY_DATA + url = urls.reverse('category-list') + self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member + response = self.client.post(url, data, format='json') + category_id = response.json()['id'] + url = urls.reverse('category-detail', args=[category_id]) + response = self.client.delete(url) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(models.Category.objects.count(), 0) + + def test_delete_category_fail(self): + http_authorization = self.superuser_http_authorization + data = CATEGORY_DATA + url = urls.reverse('category-list') + self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member + response = self.client.post(url, data, format='json') + category_id = response.json()['id'] + category = models.Category.objects.get(id=category_id) + subcategory = models.Subcategory.objects.create(**dict(SUBCATEGORY_DATA, category=category)) + models.Product.objects.create(**dict(PRODUCT_DATA, subcategory=subcategory)) + url = urls.reverse('category-detail', args=[category_id]) + response = self.client.delete(url) + self.assertEqual(response.status_code, status.HTTP_409_CONFLICT) + self.assertEqual(models.Category.objects.count(), 1) + + +class SubcategoryTest(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) + 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_create_subcategory_success(self): + http_authorization = self.superuser_http_authorization + data = SUBCATEGORY_DATA + data['category'] = self.category.id + url = urls.reverse('subcategory-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.Subcategory.objects.count(), 1) + subcategory_id = response.json()['id'] + self.assertEqual(models.Subcategory.objects.get(id=subcategory_id).name, data['name']) + + def test_create_subcategory_fail(self): + http_authorization = self.superuser_http_authorization + url = urls.reverse('subcategory-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.Subcategory.objects.count(), 0) + + def test_delete_subcategory_success(self): + http_authorization = self.superuser_http_authorization + data = SUBCATEGORY_DATA + data['category'] = self.category.id + url = urls.reverse('subcategory-list') + self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member + response = self.client.post(url, data, format='json') + subcategory_id = response.json()['id'] + url = urls.reverse('subcategory-detail', args=[subcategory_id]) + response = self.client.delete(url) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(models.Subcategory.objects.count(), 0) + + def test_delete_subcategory_fail(self): + http_authorization = self.superuser_http_authorization + data = SUBCATEGORY_DATA + data['category'] = self.category.id + url = urls.reverse('subcategory-list') + self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member + response = self.client.post(url, data, format='json') + subcategory_id = response.json()['id'] + subcategory = models.Subcategory.objects.get(id=subcategory_id) + models.Product.objects.create(**dict(PRODUCT_DATA, subcategory=subcategory)) + url = urls.reverse('subcategory-detail', args=[subcategory_id]) + response = self.client.delete(url) + self.assertEqual(response.status_code, status.HTTP_409_CONFLICT) + self.assertEqual(models.Subcategory.objects.count(), 1) + + +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_product_model_string_representation(self): category = models.Category.objects.create(**CATEGORY_DATA) subcategory = models.Subcategory.objects.create(**dict(SUBCATEGORY_DATA, category=category)) diff --git a/api/views.py b/api/views.py index 8543784..d1b2d66 100644 --- a/api/views.py +++ b/api/views.py @@ -3,6 +3,8 @@ from datetime import datetime, timedelta import jwt from django import conf, shortcuts from django.contrib import auth +from django.db.models import deletion +from django.utils.translation import gettext_lazy as _ from django_filters import rest_framework from knox import views as knox_views from rest_framework import ( @@ -147,6 +149,17 @@ class CategoryDetail(generics.RetrieveUpdateDestroyAPIView): queryset = models.Category.objects.all() serializer_class = api_serializers.CategorySerializer + def destroy(self, request, *_args, **_kwargs): + obj = self.get_object() + try: + obj.delete() + except deletion.ProtectedError: + return response.Response( + {'detail': _('Cannot delete category due to integrity error.')}, + status=status.HTTP_409_CONFLICT + ) + return response.Response(status=status.HTTP_204_NO_CONTENT) + class SubcategoryList(generics.ListCreateAPIView): filter_backends = [ @@ -174,6 +187,17 @@ class SubcategoryDetail(generics.RetrieveUpdateDestroyAPIView): queryset = models.Subcategory.objects.all() serializer_class = api_serializers.SubcategorySerializer + def destroy(self, request, *_args, **_kwargs): + obj = self.get_object() + try: + obj.delete() + except deletion.ProtectedError: + return response.Response( + {'detail': _('Cannot delete subcategory due to integrity error.')}, + status=status.HTTP_409_CONFLICT + ) + return response.Response(status=status.HTTP_204_NO_CONTENT) + class ProductList(generics.ListCreateAPIView): filter_backends = [ diff --git a/locale/id/LC_MESSAGES/django.mo b/locale/id/LC_MESSAGES/django.mo index 42cb481a339fe44c7014cf8ddecc5110eee0fff7..b6bbe2a2f83f6f518c84702dadea5acd727b9a45 100644 GIT binary patch delta 1328 zcma*mO-K}B9LMpeT+4h(vn*G$CM#cZ+lsIkwmN7R=_D0-DCDUfbXlF1+1bbfw^I>y z2qLIU5OwM0W<(%#2s#vjhv?EF$Ws(VP~JrP{`6@n2 zP#?Q#XuF8@#Qi14^kSln18rZ-n9aB!lXwa>_ZoKNbKHwRFolV7V=NBiZhVc+IFC!Q zX_+xwumx8dQ#3tvRx{9#%IRY@X0RR4VjJE;E$}5S#~G}|&sc%8$j{7k(EsHes;~xE zVH{Ur7iwNNmXqHcprae~pb{T(4mf>e@g_um<{SsjzlgPX#f{%~<9D48-S{Nx0jE&+ znMUP#hc)CkGjz1lZyLZkq?)FZEV`f;Q*28EZs5G1$55%q@DM)69{l3YlaxjOk75oV zpytI%(uxD9t+;?i?ZpJ0dc22?_!PAj)2Ic##ZLT)+pvn|Yax43r96(R$VsPrl)_DmAQ))j&XrN15C##g?E8D(0rB$A`@DDb-FNRf^Ol;P#j^Vm@1-&N zh-M;x!R#LTrJNX1&#WC|IDmKY4nD;pe2ppmfwyt=qFEEpV+pR~b=<%j+{HR9Ag{>U z0T(`rpV*0iFoE&1(*@nfa_&=j3Da1CGst1{ob>latin|c;~G}tE99{EoS53SQP1z9 z&-?95aACIRJV5>9F>2z!s0U6kgcT%}k8)DJ)!F6p{iq!dqUISxZEzf`F@;)aM)$nm z=D47Gwv2kY-kH4M&kRzCtqL|O@vL5x~W@iE~bAJm}(FZR75cT|o^NGu6eCn^A z&61Egr1C+ZDMs*1hG3|tT4aLI7onZj65T`tQBNd^o5b0ng=)kJElL$`B~M2|jD3)Ni#w?a*%^Mrg$K%{LM%kG{`dLM`YZ TA}\n" "Language-Team: LANGUAGE \n" @@ -19,7 +19,7 @@ msgstr "" "Plural-Forms: nplurals=1; plural=0;\n" #: api/models.py:13 api/models.py:49 api/models.py:68 api/models.py:93 -#: api/models.py:128 api/models.py:166 +#: api/models.py:126 api/models.py:164 msgid "ID" msgstr "ID" @@ -67,13 +67,13 @@ msgstr "pengguna" msgid "users" msgstr "pengguna" -#: api/models.py:50 api/models.py:69 api/models.py:94 api/models.py:129 +#: api/models.py:50 api/models.py:69 api/models.py:94 api/models.py:127 #, fuzzy #| msgid "full name" msgid "name" msgstr "nama lengkap" -#: api/models.py:55 api/models.py:80 api/models.py:115 +#: api/models.py:55 api/models.py:80 api/models.py:113 msgid "image" msgstr "gambar" @@ -85,7 +85,7 @@ msgstr "kategori" msgid "categories" msgstr "kategori" -#: api/models.py:85 api/models.py:101 +#: api/models.py:85 api/models.py:99 msgid "subcategory" msgstr "subkategori" @@ -93,67 +93,67 @@ msgstr "subkategori" msgid "subcategories" msgstr "subkategori" -#: api/models.py:103 api/models.py:130 +#: api/models.py:101 api/models.py:128 msgid "description" msgstr "deskripsi" -#: api/models.py:108 +#: api/models.py:106 msgid "price" msgstr "harga" -#: api/models.py:110 +#: api/models.py:108 msgid "stock" msgstr "stok" -#: api/models.py:120 +#: api/models.py:118 msgid "product" msgstr "produk" -#: api/models.py:121 +#: api/models.py:119 msgid "products" msgstr "produk" -#: api/models.py:135 +#: api/models.py:133 msgid "start date and time" msgstr "tanggal dan waktu mulai" -#: api/models.py:140 +#: api/models.py:138 msgid "end date and time" msgstr "tanggal dan waktu berakhir" -#: api/models.py:146 +#: api/models.py:144 msgid "location" msgstr "lokasi" -#: api/models.py:148 +#: api/models.py:146 msgid "speaker" msgstr "pembicara" -#: api/models.py:153 +#: api/models.py:151 msgid "poster image" msgstr "gambar poster" -#: api/models.py:158 +#: api/models.py:156 msgid "program" msgstr "program" -#: api/models.py:159 +#: api/models.py:157 msgid "programs" msgstr "program" -#: api/models.py:167 +#: api/models.py:165 msgid "send SMS" msgstr "kirim SMS" -#: api/models.py:170 +#: api/models.py:168 msgid "config" msgstr "konfigurasi" -#: api/models.py:171 +#: api/models.py:169 msgid "configs" msgstr "konfigurasi" -#: api/serializers.py:126 +#: api/serializers.py:128 msgid "End date time should be greater than start date time." msgstr "Waktu tanggal berakhir harus lebih besar dari waktu tanggal mulai." @@ -165,6 +165,14 @@ msgstr "Format header otorisasi tidak valid." msgid "Invalid token." msgstr "Token tidak valid." +#: api/views.py:158 +msgid "Cannot delete category due to integrity error." +msgstr "Tidak dapat menghapus kategori karena kesalahan integritas." + +#: api/views.py:196 +msgid "Cannot delete subcategory due to integrity error." +msgstr "Tidak dapat menghapus subkategori karena kesalahan integritas." + #: home_industry/utils.py:14 msgid "A bad configuration error occurred." msgstr "Terjadi kesalahan konfigurasi." -- GitLab From bcafc594c7a824230a6e4dd8c392962408ef7079 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Fri, 10 Apr 2020 14:25:01 +0700 Subject: [PATCH 57/90] Change Config Primary Key Type --- api/migrations/0002_config.py | 3 +-- api/models.py | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/api/migrations/0002_config.py b/api/migrations/0002_config.py index 44cf862..2e0db98 100644 --- a/api/migrations/0002_config.py +++ b/api/migrations/0002_config.py @@ -1,7 +1,6 @@ # Generated by Django 3.0.3 on 2020-03-04 07:45 from django.db import migrations, models -import uuid class Migration(migrations.Migration): @@ -14,7 +13,7 @@ class Migration(migrations.Migration): migrations.CreateModel( name='Config', fields=[ - ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='ID')), + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('send_sms', models.BooleanField(default=False, verbose_name='send SMS')), ], options={ diff --git a/api/models.py b/api/models.py index 4833f7b..3df1ccf 100644 --- a/api/models.py +++ b/api/models.py @@ -161,7 +161,6 @@ class Program(db_models.Model): class Config(solo_models.SingletonModel): - id = db_models.UUIDField(default=uuid.uuid4, primary_key=True, verbose_name=_('ID')) send_sms = db_models.BooleanField(default=False, verbose_name=_('send SMS')) class Meta: -- GitLab From 1e4dc75a40ccb628c171ef3976ce8b6de8275729 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Fri, 10 Apr 2020 15:54:28 +0700 Subject: [PATCH 58/90] Alter Category, Subcategory, Product, and Program Table --- api/migrations/0007_product_code.py | 19 +++++++++++++++++ api/migrations/0008_auto_20200410_1508.py | 26 +++++++++++++++++++++++ api/migrations/0009_program_code.py | 19 +++++++++++++++++ api/models.py | 19 +++++++++++++++-- api/serializers.py | 9 ++++---- api/tests.py | 9 ++++---- api/utils.py | 7 ++++++ requirements.txt | 1 + 8 files changed, 98 insertions(+), 11 deletions(-) create mode 100644 api/migrations/0007_product_code.py create mode 100644 api/migrations/0008_auto_20200410_1508.py create mode 100644 api/migrations/0009_program_code.py diff --git a/api/migrations/0007_product_code.py b/api/migrations/0007_product_code.py new file mode 100644 index 0000000..42be50b --- /dev/null +++ b/api/migrations/0007_product_code.py @@ -0,0 +1,19 @@ +# Generated by Django 3.0.3 on 2020-04-10 07:55 + +import api.utils +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0006_auto_20200316_2058'), + ] + + operations = [ + migrations.AddField( + model_name='product', + name='code', + field=models.CharField(default=api.utils.generate_code, max_length=6, unique=True, verbose_name='code'), + ), + ] diff --git a/api/migrations/0008_auto_20200410_1508.py b/api/migrations/0008_auto_20200410_1508.py new file mode 100644 index 0000000..52acb98 --- /dev/null +++ b/api/migrations/0008_auto_20200410_1508.py @@ -0,0 +1,26 @@ +# Generated by Django 3.0.3 on 2020-04-10 08:08 + +import django.contrib.postgres.fields.citext +from django.contrib.postgres import operations as postgres_operations +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0007_product_code'), + ] + + operations = [ + postgres_operations.CITextExtension(), + migrations.AlterField( + model_name='category', + name='name', + field=django.contrib.postgres.fields.citext.CICharField(max_length=50, unique=True, verbose_name='name'), + ), + migrations.AlterField( + model_name='subcategory', + name='name', + field=django.contrib.postgres.fields.citext.CICharField(max_length=50, unique=True, verbose_name='name'), + ), + ] diff --git a/api/migrations/0009_program_code.py b/api/migrations/0009_program_code.py new file mode 100644 index 0000000..1e7997f --- /dev/null +++ b/api/migrations/0009_program_code.py @@ -0,0 +1,19 @@ +# Generated by Django 3.0.3 on 2020-04-10 08:40 + +import api.utils +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0008_auto_20200410_1508'), + ] + + operations = [ + migrations.AddField( + model_name='program', + name='code', + field=models.CharField(default=api.utils.generate_code, max_length=6, unique=True, verbose_name='code'), + ), + ] diff --git a/api/models.py b/api/models.py index 3df1ccf..3771d4c 100644 --- a/api/models.py +++ b/api/models.py @@ -2,12 +2,15 @@ import decimal import uuid from django.contrib.auth import models as auth_models +from django.contrib.postgres import fields from django.core import validators from django.db import models as db_models from django.utils.translation import gettext_lazy as _ from phonenumber_field import modelfields from solo import models as solo_models +from api import utils + class User(auth_models.AbstractUser): id = db_models.UUIDField(default=uuid.uuid4, primary_key=True, verbose_name=_('ID')) @@ -47,7 +50,7 @@ class User(auth_models.AbstractUser): 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')) + name = fields.CICharField(max_length=50, unique=True, verbose_name=_('name')) image = db_models.ImageField( blank=True, null=True, @@ -66,7 +69,7 @@ class Category(db_models.Model): 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')) + name = fields.CICharField(max_length=50, unique=True, verbose_name=_('name')) category = db_models.ForeignKey( Category, on_delete=db_models.CASCADE, @@ -91,6 +94,12 @@ class Subcategory(db_models.Model): class Product(db_models.Model): id = db_models.UUIDField(default=uuid.uuid4, primary_key=True, verbose_name=_('ID')) + code = db_models.CharField( + default=utils.generate_code, + max_length=6, + unique=True, + verbose_name=_('code') + ) name = db_models.CharField(max_length=200, verbose_name=_('name')) subcategory = db_models.ForeignKey( Subcategory, @@ -124,6 +133,12 @@ class Product(db_models.Model): class Program(db_models.Model): id = db_models.UUIDField(default=uuid.uuid4, primary_key=True, verbose_name=_('ID')) + code = db_models.CharField( + default=utils.generate_code, + max_length=6, + unique=True, + verbose_name=_('code') + ) name = db_models.CharField(max_length=200, verbose_name=_('name')) description = db_models.TextField(verbose_name=_('description')) start_date_time = db_models.DateTimeField( diff --git a/api/serializers.py b/api/serializers.py index d428fe0..56a0739 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -89,6 +89,7 @@ class ProductSerializer(serializers.ModelSerializer): class Meta: fields = [ 'id', + 'code', 'name', 'category', 'category_name', @@ -100,13 +101,14 @@ class ProductSerializer(serializers.ModelSerializer): 'image', ] model = models.Product - read_only_fields = ['id'] + read_only_fields = ['id', 'code'] class ProgramSerializer(serializers.ModelSerializer): class Meta: fields = [ 'id', + 'code', 'name', 'description', 'start_date_time', @@ -116,7 +118,7 @@ class ProgramSerializer(serializers.ModelSerializer): 'poster_image', ] model = models.Program - read_only_fields = ['id'] + read_only_fields = ['id', 'code'] def validate(self, attrs): instance = self.instance @@ -133,6 +135,5 @@ class ProgramSerializer(serializers.ModelSerializer): class ConfigSerializer(serializers.ModelSerializer): class Meta: - fields = ['id', 'send_sms'] + fields = ['send_sms'] model = models.Config - read_only_fields = ['id'] diff --git a/api/tests.py b/api/tests.py index 7de06ee..6f94a53 100644 --- a/api/tests.py +++ b/api/tests.py @@ -337,8 +337,9 @@ class SubcategoryTest(rest_framework_test.APITestCase): self.category = models.Category.objects.create(**CATEGORY_DATA) def test_subcategory_model_string_representation(self): - category = models.Category.objects.create(**CATEGORY_DATA) - subcategory = models.Subcategory.objects.create(**dict(SUBCATEGORY_DATA, category=category)) + subcategory = models.Subcategory.objects.create( + **dict(SUBCATEGORY_DATA, category=self.category) + ) self.assertEqual(str(subcategory), SUBCATEGORY_DATA['name']) def test_create_subcategory_success(self): @@ -403,9 +404,7 @@ class ProductTest(rest_framework_test.APITestCase): ) 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)) + product = models.Product.objects.create(**dict(PRODUCT_DATA, subcategory=self.subcategory)) self.assertEqual(str(product), PRODUCT_DATA['name']) def test_product_list_success(self): diff --git a/api/utils.py b/api/utils.py index 15ce358..9cc9ad4 100644 --- a/api/utils.py +++ b/api/utils.py @@ -2,6 +2,7 @@ import math import random import jwt +import shortuuid from jwt import exceptions as jwt_exceptions from django import conf from django.utils.translation import gettext_lazy as _ @@ -10,6 +11,12 @@ from rest_framework import exceptions as rest_framework_exceptions from home_industry import utils +def generate_code(): + alphabet = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ' + code = shortuuid.ShortUUID(alphabet=alphabet).random(length=6) + return code + + def generate_otp(): digits = '0123456789' otp = ''.join([digits[math.floor(random.random() * 10)] for _ in range(6)]) diff --git a/requirements.txt b/requirements.txt index 63eab17..fdd7c38 100644 --- a/requirements.txt +++ b/requirements.txt @@ -44,6 +44,7 @@ python-dateutil==2.8.1 pytz==2019.3 requests==2.23.0 s3transfer==0.3.3 +shortuuid==1.0.1 six==1.14.0 sqlparse==0.3.0 typed-ast==1.4.1 -- GitLab From b02441eea3752fd0cde0b8451caf3a4cb7798f32 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Fri, 10 Apr 2020 16:06:50 +0700 Subject: [PATCH 59/90] Edit Translations --- locale/id/LC_MESSAGES/django.mo | Bin 3463 -> 3489 bytes locale/id/LC_MESSAGES/django.po | 80 +++++++++++++++++--------------- 2 files changed, 42 insertions(+), 38 deletions(-) diff --git a/locale/id/LC_MESSAGES/django.mo b/locale/id/LC_MESSAGES/django.mo index b6bbe2a2f83f6f518c84702dadea5acd727b9a45..a63c4798c11a8cf2658e065bf0b5651c6d40b929 100644 GIT binary patch delta 1074 zcmX}rPe@cz6vy%7{7Y*b|AFR=rH-aK>Xb^jaHvp-%9U;0Nn*JW9uX0wn*l{_69tpd zLPWO_ZUk|qxCn$GBXA?pCW&YnVITy3e{!bFocDR}%$s-5J9qA4YAu!jS{+$3MlaDo zyelyq!d#dG<95WX1@B-M1MIs(|}$;Lf1#TSPwgf`jf~!U|mR{JQ5i-5t;GpN!UkXMX$1Njd&O zRT|}~x~@XKQ6uUHtvJB)Wbh)_le~snK9ARN7Y9*o(e)7O`&oR1+n&!dNt){b^IDTv zoM=r7SdCkF9zUR#^%n+^cnTQ1NV#Pzo82G?*2w~CPY&Am%0_G32Tw2TH4K^Chm1}akUVH>+(&UsmN|ZJF1J& z|KSpGh0uJ(p{A&_S{2pRPOy6ahq_-Js=wJUw5-L!?mFGox~56l)56tVZAa}SjYguL f;C-x;(AHMRE^qWi_Cuiu4>$hCo6jAGJVUYE0 zhmJBm&;TAI)wD`h(GAs@WLxU+GS{QLhDx2mJGg==+;`V;%A(&N<5T>Mnipn~RvbfZ z#VqEu7g;*B_z@fM8)_?xsDw80D*nc6SVj6uYo|@sKigW|MfA)dU(?B#sUeCKKS^9wRLS)Ts%hJ_ c#k`Nz5H|=_sw+R_E0*Q!$}|4_Vj$%C3qFcZb^rhX diff --git a/locale/id/LC_MESSAGES/django.po b/locale/id/LC_MESSAGES/django.po index bbef71d..3a3d363 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-16 22:41+0700\n" +"POT-Creation-Date: 2020-04-10 16:04+0700\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,150 +18,154 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: api/models.py:13 api/models.py:49 api/models.py:68 api/models.py:93 -#: api/models.py:126 api/models.py:164 +#: api/models.py:16 api/models.py:52 api/models.py:71 api/models.py:96 +#: api/models.py:135 msgid "ID" msgstr "ID" -#: api/models.py:16 +#: api/models.py:19 msgid "full name" msgstr "nama lengkap" -#: api/models.py:17 +#: api/models.py:20 msgid "phone number" msgstr "nomor telepon" -#: api/models.py:18 +#: api/models.py:21 msgid "address" msgstr "alamat" -#: api/models.py:22 +#: api/models.py:25 msgid "neighborhood" msgstr "RT" -#: api/models.py:27 +#: api/models.py:30 msgid "hamlet" msgstr "RW" -#: api/models.py:29 +#: api/models.py:32 msgid "urban village" msgstr "kelurahan" -#: api/models.py:30 +#: api/models.py:33 msgid "sub-district" msgstr "kecamatan" -#: api/models.py:35 +#: api/models.py:38 msgid "profile picture" msgstr "foto profil" -#: api/models.py:37 +#: api/models.py:40 msgid "OTP" msgstr "OTP" -#: api/models.py:41 +#: api/models.py:44 msgid "user" msgstr "pengguna" -#: api/models.py:42 +#: api/models.py:45 msgid "users" msgstr "pengguna" -#: api/models.py:50 api/models.py:69 api/models.py:94 api/models.py:127 +#: api/models.py:53 api/models.py:72 api/models.py:103 api/models.py:142 #, fuzzy #| msgid "full name" msgid "name" msgstr "nama lengkap" -#: api/models.py:55 api/models.py:80 api/models.py:113 +#: api/models.py:58 api/models.py:83 api/models.py:122 msgid "image" msgstr "gambar" -#: api/models.py:60 api/models.py:74 +#: api/models.py:63 api/models.py:77 msgid "category" msgstr "kategori" -#: api/models.py:61 +#: api/models.py:64 msgid "categories" msgstr "kategori" -#: api/models.py:85 api/models.py:99 +#: api/models.py:88 api/models.py:108 msgid "subcategory" msgstr "subkategori" -#: api/models.py:86 +#: api/models.py:89 msgid "subcategories" msgstr "subkategori" -#: api/models.py:101 api/models.py:128 +#: api/models.py:101 api/models.py:140 +msgid "code" +msgstr "kode" + +#: api/models.py:110 api/models.py:143 msgid "description" msgstr "deskripsi" -#: api/models.py:106 +#: api/models.py:115 msgid "price" msgstr "harga" -#: api/models.py:108 +#: api/models.py:117 msgid "stock" msgstr "stok" -#: api/models.py:118 +#: api/models.py:127 msgid "product" msgstr "produk" -#: api/models.py:119 +#: api/models.py:128 msgid "products" msgstr "produk" -#: api/models.py:133 +#: api/models.py:148 msgid "start date and time" msgstr "tanggal dan waktu mulai" -#: api/models.py:138 +#: api/models.py:153 msgid "end date and time" msgstr "tanggal dan waktu berakhir" -#: api/models.py:144 +#: api/models.py:159 msgid "location" msgstr "lokasi" -#: api/models.py:146 +#: api/models.py:161 msgid "speaker" msgstr "pembicara" -#: api/models.py:151 +#: api/models.py:166 msgid "poster image" msgstr "gambar poster" -#: api/models.py:156 +#: api/models.py:171 msgid "program" msgstr "program" -#: api/models.py:157 +#: api/models.py:172 msgid "programs" msgstr "program" -#: api/models.py:165 +#: api/models.py:179 msgid "send SMS" msgstr "kirim SMS" -#: api/models.py:168 +#: api/models.py:182 msgid "config" msgstr "konfigurasi" -#: api/models.py:169 +#: api/models.py:183 msgid "configs" msgstr "konfigurasi" -#: api/serializers.py:128 +#: api/serializers.py:130 msgid "End date time should be greater than start date time." msgstr "Waktu tanggal berakhir harus lebih besar dari waktu tanggal mulai." -#: api/utils.py:24 +#: api/utils.py:31 msgid "Invalid authorization header format." msgstr "Format header otorisasi tidak valid." -#: api/utils.py:28 api/utils.py:30 +#: api/utils.py:35 api/utils.py:37 msgid "Invalid token." msgstr "Token tidak valid." -- GitLab From 980e21fd9bb4b5ed281f5fe66f34ec9dcc149dd3 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Fri, 10 Apr 2020 16:41:28 +0700 Subject: [PATCH 60/90] Fix Migrations --- api/migrations/0007_auto_20200410_1629.py | 37 +++++++++++++++++++++++ api/migrations/0007_product_code.py | 19 ------------ api/migrations/0008_auto_20200410_1508.py | 26 ---------------- api/migrations/0008_auto_20200410_1638.py | 35 +++++++++++++++++++++ api/migrations/0009_program_code.py | 19 ------------ 5 files changed, 72 insertions(+), 64 deletions(-) create mode 100644 api/migrations/0007_auto_20200410_1629.py delete mode 100644 api/migrations/0007_product_code.py delete mode 100644 api/migrations/0008_auto_20200410_1508.py create mode 100644 api/migrations/0008_auto_20200410_1638.py delete mode 100644 api/migrations/0009_program_code.py diff --git a/api/migrations/0007_auto_20200410_1629.py b/api/migrations/0007_auto_20200410_1629.py new file mode 100644 index 0000000..c4e60b2 --- /dev/null +++ b/api/migrations/0007_auto_20200410_1629.py @@ -0,0 +1,37 @@ +# Generated by Django 3.0.3 on 2020-04-10 09:29 + +import api.utils +import django.contrib.postgres.fields.citext +from django.contrib.postgres import operations as postgres_operations +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0006_auto_20200316_2058'), + ] + + operations = [ + postgres_operations.CITextExtension(), + migrations.AddField( + model_name='product', + name='code', + field=models.CharField(default=api.utils.generate_code, max_length=6, verbose_name='code'), + ), + migrations.AddField( + model_name='program', + name='code', + field=models.CharField(default=api.utils.generate_code, max_length=6, verbose_name='code'), + ), + migrations.AlterField( + model_name='category', + name='name', + field=django.contrib.postgres.fields.citext.CICharField(max_length=50, verbose_name='name'), + ), + migrations.AlterField( + model_name='subcategory', + name='name', + field=django.contrib.postgres.fields.citext.CICharField(max_length=50, verbose_name='name'), + ), + ] diff --git a/api/migrations/0007_product_code.py b/api/migrations/0007_product_code.py deleted file mode 100644 index 42be50b..0000000 --- a/api/migrations/0007_product_code.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 3.0.3 on 2020-04-10 07:55 - -import api.utils -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0006_auto_20200316_2058'), - ] - - operations = [ - migrations.AddField( - model_name='product', - name='code', - field=models.CharField(default=api.utils.generate_code, max_length=6, unique=True, verbose_name='code'), - ), - ] diff --git a/api/migrations/0008_auto_20200410_1508.py b/api/migrations/0008_auto_20200410_1508.py deleted file mode 100644 index 52acb98..0000000 --- a/api/migrations/0008_auto_20200410_1508.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 3.0.3 on 2020-04-10 08:08 - -import django.contrib.postgres.fields.citext -from django.contrib.postgres import operations as postgres_operations -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0007_product_code'), - ] - - operations = [ - postgres_operations.CITextExtension(), - migrations.AlterField( - model_name='category', - name='name', - field=django.contrib.postgres.fields.citext.CICharField(max_length=50, unique=True, verbose_name='name'), - ), - migrations.AlterField( - model_name='subcategory', - name='name', - field=django.contrib.postgres.fields.citext.CICharField(max_length=50, unique=True, verbose_name='name'), - ), - ] diff --git a/api/migrations/0008_auto_20200410_1638.py b/api/migrations/0008_auto_20200410_1638.py new file mode 100644 index 0000000..56d3578 --- /dev/null +++ b/api/migrations/0008_auto_20200410_1638.py @@ -0,0 +1,35 @@ +# Generated by Django 3.0.3 on 2020-04-10 09:38 + +import api.utils +import django.contrib.postgres.fields.citext +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0007_auto_20200410_1629'), + ] + + operations = [ + migrations.AlterField( + model_name='category', + name='name', + field=django.contrib.postgres.fields.citext.CICharField(max_length=50, unique=True, verbose_name='name'), + ), + migrations.AlterField( + model_name='product', + name='code', + field=models.CharField(default=api.utils.generate_code, max_length=6, unique=True, verbose_name='code'), + ), + migrations.AlterField( + model_name='program', + name='code', + field=models.CharField(default=api.utils.generate_code, max_length=6, unique=True, verbose_name='code'), + ), + migrations.AlterField( + model_name='subcategory', + name='name', + field=django.contrib.postgres.fields.citext.CICharField(max_length=50, unique=True, verbose_name='name'), + ), + ] diff --git a/api/migrations/0009_program_code.py b/api/migrations/0009_program_code.py deleted file mode 100644 index 1e7997f..0000000 --- a/api/migrations/0009_program_code.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 3.0.3 on 2020-04-10 08:40 - -import api.utils -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0008_auto_20200410_1508'), - ] - - operations = [ - migrations.AddField( - model_name='program', - name='code', - field=models.CharField(default=api.utils.generate_code, max_length=6, unique=True, verbose_name='code'), - ), - ] -- GitLab From bdb7b5c4dce71d287d7ac9b127de8e2705f176c9 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Fri, 10 Apr 2020 17:11:29 +0700 Subject: [PATCH 61/90] Add code to Filterset --- api/views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/views.py b/api/views.py index d1b2d66..a6ed6b6 100644 --- a/api/views.py +++ b/api/views.py @@ -205,7 +205,7 @@ class ProductList(generics.ListCreateAPIView): filters.SearchFilter, rest_framework.DjangoFilterBackend, ] - filterset_fields = ['name', 'subcategory', 'subcategory__category'] + filterset_fields = ['code', 'name', 'subcategory', 'subcategory__category'] ordering_fields = ['name', 'price', 'stock'] pagination_class = paginations.SmallResultsSetPagination permission_classes = [ @@ -232,7 +232,7 @@ class ProgramList(generics.ListCreateAPIView): filters.SearchFilter, rest_framework.DjangoFilterBackend, ] - filterset_fields = ['name'] + filterset_fields = ['code', 'name'] ordering_fields = ['name', 'start_date_time'] pagination_class = paginations.SmallResultsSetPagination permission_classes = [ -- GitLab From 3905ff66326fff9d16bf5d286dae52cd5e6bf1c8 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Wed, 15 Apr 2020 21:35:02 +0700 Subject: [PATCH 62/90] Complete Configuration API --- .pylintrc | 2 + api/constants.py | 16 + api/migrations/0009_auto_20200411_1153.py | 113 +++++++ api/migrations/0010_auto_20200411_1311.py | 18 + .../0011_transactionitem_quantity.py | 18 + api/migrations/0012_auto_20200411_1455.py | 18 + api/migrations/0013_bankaccountconfig.py | 25 ++ api/migrations/0014_auto_20200414_1506.py | 21 ++ api/models.py | 308 ++++++++++++++++-- api/serializers.py | 23 +- api/tests.py | 225 ++++++++++++- api/urls.py | 12 +- api/utils.py | 22 +- api/views.py | 68 ++-- home_industry/utils.py | 30 +- 15 files changed, 832 insertions(+), 87 deletions(-) create mode 100644 api/constants.py create mode 100644 api/migrations/0009_auto_20200411_1153.py create mode 100644 api/migrations/0010_auto_20200411_1311.py create mode 100644 api/migrations/0011_transactionitem_quantity.py create mode 100644 api/migrations/0012_auto_20200411_1455.py create mode 100644 api/migrations/0013_bankaccountconfig.py create mode 100644 api/migrations/0014_auto_20200414_1506.py diff --git a/.pylintrc b/.pylintrc index c41fc3d..10363fc 100644 --- a/.pylintrc +++ b/.pylintrc @@ -1,5 +1,6 @@ [MASTER] disable= + cyclic-import, missing-class-docstring, missing-function-docstring, missing-module-docstring @@ -9,4 +10,5 @@ load-plugins= pylint_django [DESIGN] +max-attributes=10 max-parents=8 diff --git a/api/constants.py b/api/constants.py new file mode 100644 index 0000000..8ed11ee --- /dev/null +++ b/api/constants.py @@ -0,0 +1,16 @@ +from django.utils.translation import gettext_lazy as _ + +PAYMENT_METHOD_CHOICES = [ + ('TRF', _('Transfer')), + ('COD', _('Cash on delivery')), +] + +TRANSACTION_STATUS_CHOICES = [ + ('001', _('Waiting for proof of payment')), + ('002', _('Waiting for seller confirmation')), + ('003', _('In process')), + ('004', _('Being shipped')), + ('005', _('Completed')), + ('006', _('Canceled')), + ('007', _('Failed')), +] diff --git a/api/migrations/0009_auto_20200411_1153.py b/api/migrations/0009_auto_20200411_1153.py new file mode 100644 index 0000000..d40f9a4 --- /dev/null +++ b/api/migrations/0009_auto_20200411_1153.py @@ -0,0 +1,113 @@ +# Generated by Django 3.0.3 on 2020-04-11 04:53 + +import api.utils +from decimal import Decimal +from django.conf import settings +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import phonenumber_field.modelfields +import re +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0008_auto_20200410_1638'), + ] + + operations = [ + migrations.CreateModel( + name='ShipmentConfig', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('hamlet', models.CharField(max_length=3, validators=[django.core.validators.RegexValidator(re.compile('^\\d+(?:\\d+)*\\Z'), code='invalid', message=None), django.core.validators.MinLengthValidator(3)], verbose_name='hamlet')), + ('urban_village', models.CharField(max_length=100, verbose_name='urban village')), + ('sub_district', models.CharField(max_length=100, verbose_name='sub-district')), + ('same_hamlet_costs', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=12, validators=[django.core.validators.MinValueValidator(Decimal('0'))], verbose_name='shipping costs if the hamlet, urban village, and sub-district are the same as seller')), + ('same_urban_village_different_hamlet_costs', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=12, validators=[django.core.validators.MinValueValidator(Decimal('0'))], verbose_name='shipping costs if the urban village and sub-district are the same as seller')), + ('same_sub_district_different_urban_village_costs', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=12, validators=[django.core.validators.MinValueValidator(Decimal('0'))], verbose_name='shipping costs if the sub-district is the same as seller')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Transaction', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='ID')), + ('transaction_number', models.CharField(default=api.utils.generate_transaction_number, max_length=8, unique=True, verbose_name='transaction number')), + ('user_full_name', models.CharField(max_length=200, verbose_name='user full name')), + ('user_phone_number', phonenumber_field.modelfields.PhoneNumberField(max_length=128, region=None, verbose_name='user phone number')), + ('shipping_address', models.CharField(max_length=200, verbose_name='shipping address')), + ('shipping_neighborhood', models.CharField(max_length=3, validators=[django.core.validators.RegexValidator(re.compile('^\\d+(?:\\d+)*\\Z'), code='invalid', message=None), django.core.validators.MinLengthValidator(3)], verbose_name='shipping neighborhood')), + ('shipping_hamlet', models.CharField(max_length=3, validators=[django.core.validators.RegexValidator(re.compile('^\\d+(?:\\d+)*\\Z'), code='invalid', message=None), django.core.validators.MinLengthValidator(3)], verbose_name='shipping hamlet')), + ('shipping_urban_village', models.CharField(max_length=100, verbose_name='shipping urban village')), + ('shipping_sub_district', models.CharField(max_length=100, verbose_name='shipping sub-district')), + ('shipping_costs', models.DecimalField(decimal_places=2, max_digits=12, validators=[django.core.validators.MinValueValidator(Decimal('0'))], verbose_name='shipping costs')), + ('payment_method', models.CharField(choices=[('TRF', 'Transfer'), ('COD', 'Cash on delivery')], max_length=3, verbose_name='payment method')), + ('donation', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=12, validators=[django.core.validators.MinValueValidator(Decimal('0'))], verbose_name='donation')), + ('transaction_status', models.CharField(choices=[('001', 'Waiting for proof of payment'), ('002', 'Waiting for seller confirmation'), ('003', 'In process'), ('004', 'Being shipped'), ('005', 'Completed'), ('006', 'Canceled')], max_length=3, verbose_name='transaction status')), + ('proof_of_payment', models.ImageField(blank=True, null=True, upload_to='uploads/transactions/%Y/%m/%d/', verbose_name='proof of payment')), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='created at')), + ('updated_at', models.DateTimeField(auto_now=True, db_index=True, verbose_name='updated at')), + ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transactions', to=settings.AUTH_USER_MODEL, verbose_name='user')), + ], + options={ + 'verbose_name': 'transaction', + 'verbose_name_plural': 'transactions', + 'ordering': ['-updated_at', '-created_at'], + }, + ), + migrations.RenameModel( + old_name='Config', + new_name='AppConfig', + ), + migrations.AlterModelOptions( + name='appconfig', + options={'verbose_name': 'application config', 'verbose_name_plural': 'application configs'}, + ), + migrations.CreateModel( + name='TransactionItem', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='ID')), + ('product_name', models.CharField(max_length=200, verbose_name='product name')), + ('product_price', models.DecimalField(decimal_places=2, max_digits=12, validators=[django.core.validators.MinValueValidator(Decimal('0.01'))], verbose_name='product price')), + ('product', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transaction_items', to='api.Product', verbose_name='product')), + ('transaction', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transaction_items', to='api.Transaction', verbose_name='transaction')), + ], + options={ + 'verbose_name': 'transaction item', + 'verbose_name_plural': 'transaction items', + 'ordering': ['transaction'], + }, + ), + migrations.CreateModel( + name='ShoppingCart', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='ID')), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='user')), + ], + options={ + 'verbose_name': 'shopping cart', + 'verbose_name_plural': 'shopping carts', + 'ordering': ['user'], + }, + ), + migrations.CreateModel( + name='CartItem', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='ID')), + ('quantity', models.PositiveIntegerField(default=0, verbose_name='quantity')), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cart_items', to='api.Product', verbose_name='product')), + ('shopping_cart', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cart_items', to='api.ShoppingCart', verbose_name='shopping cart')), + ], + options={ + 'verbose_name': 'cart item', + 'verbose_name_plural': 'cart items', + 'ordering': ['shopping_cart', 'product'], + 'unique_together': {('shopping_cart', 'product')}, + }, + ), + ] diff --git a/api/migrations/0010_auto_20200411_1311.py b/api/migrations/0010_auto_20200411_1311.py new file mode 100644 index 0000000..f8f918e --- /dev/null +++ b/api/migrations/0010_auto_20200411_1311.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.3 on 2020-04-11 06:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0009_auto_20200411_1153'), + ] + + operations = [ + migrations.AlterField( + model_name='transaction', + name='transaction_status', + field=models.CharField(choices=[('001', 'Waiting for proof of payment'), ('002', 'Waiting for seller confirmation'), ('003', 'In process'), ('004', 'Being shipped'), ('005', 'Completed'), ('006', 'Canceled'), ('007', 'Failed')], max_length=3, verbose_name='transaction status'), + ), + ] diff --git a/api/migrations/0011_transactionitem_quantity.py b/api/migrations/0011_transactionitem_quantity.py new file mode 100644 index 0000000..68e9532 --- /dev/null +++ b/api/migrations/0011_transactionitem_quantity.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.3 on 2020-04-11 06:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0010_auto_20200411_1311'), + ] + + operations = [ + migrations.AddField( + model_name='transactionitem', + name='quantity', + field=models.PositiveIntegerField(default=0, verbose_name='quantity'), + ), + ] diff --git a/api/migrations/0012_auto_20200411_1455.py b/api/migrations/0012_auto_20200411_1455.py new file mode 100644 index 0000000..c3b4cdb --- /dev/null +++ b/api/migrations/0012_auto_20200411_1455.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.3 on 2020-04-11 07:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0011_transactionitem_quantity'), + ] + + operations = [ + migrations.AlterField( + model_name='transactionitem', + name='quantity', + field=models.PositiveIntegerField(verbose_name='quantity'), + ), + ] diff --git a/api/migrations/0013_bankaccountconfig.py b/api/migrations/0013_bankaccountconfig.py new file mode 100644 index 0000000..d942f30 --- /dev/null +++ b/api/migrations/0013_bankaccountconfig.py @@ -0,0 +1,25 @@ +# Generated by Django 3.0.3 on 2020-04-14 07:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0012_auto_20200411_1455'), + ] + + operations = [ + migrations.CreateModel( + name='BankAccountConfig', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('bank_name', models.CharField(max_length=100, verbose_name='bank name')), + ('account_number', models.CharField(max_length=100, verbose_name='account number')), + ('account_owner', models.CharField(max_length=200, verbose_name='account owner')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/api/migrations/0014_auto_20200414_1506.py b/api/migrations/0014_auto_20200414_1506.py new file mode 100644 index 0000000..94e6bef --- /dev/null +++ b/api/migrations/0014_auto_20200414_1506.py @@ -0,0 +1,21 @@ +# Generated by Django 3.0.3 on 2020-04-14 08:06 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0013_bankaccountconfig'), + ] + + operations = [ + migrations.AlterModelOptions( + name='bankaccountconfig', + options={'verbose_name': 'bank account config', 'verbose_name_plural': 'bank account configs'}, + ), + migrations.AlterModelOptions( + name='shipmentconfig', + options={'verbose_name': 'shipment config', 'verbose_name_plural': 'shipment configs'}, + ), + ] diff --git a/api/models.py b/api/models.py index 3771d4c..d0e04dc 100644 --- a/api/models.py +++ b/api/models.py @@ -1,51 +1,81 @@ import decimal import uuid +from django import dispatch from django.contrib.auth import models as auth_models from django.contrib.postgres import fields from django.core import validators from django.db import models as db_models +from django.db.models import signals from django.utils.translation import gettext_lazy as _ from phonenumber_field import modelfields from solo import models as solo_models -from api import utils +from api import constants, utils as api_utils +from home_industry import utils as home_industry_utils -class User(auth_models.AbstractUser): - id = db_models.UUIDField(default=uuid.uuid4, primary_key=True, verbose_name=_('ID')) - first_name = None - last_name = None - full_name = db_models.CharField(max_length=200, verbose_name=_('full name')) - phone_number = modelfields.PhoneNumberField(unique=True, verbose_name=_('phone number')) - address = db_models.CharField(max_length=200, verbose_name=_('address')) - neighborhood = db_models.CharField( - max_length=3, - validators=[validators.int_list_validator(sep=''), validators.MinLengthValidator(3)], - verbose_name=_('neighborhood') - ) +class AppConfig(solo_models.SingletonModel): + send_sms = db_models.BooleanField(default=False, verbose_name=_('send SMS')) + + class Meta: + verbose_name = _('application config') + verbose_name_plural = _('application configs') + + +class ShipmentConfig(solo_models.SingletonModel): hamlet = db_models.CharField( max_length=3, validators=[validators.int_list_validator(sep=''), validators.MinLengthValidator(3)], verbose_name=_('hamlet') ) - urban_village = db_models.CharField(max_length=100, verbose_name=_('urban village')) - sub_district = db_models.CharField(max_length=100, verbose_name=_('sub-district')) - profile_picture = db_models.ImageField( - blank=True, - null=True, - upload_to='uploads/users/', - verbose_name=_('profile picture') + urban_village = db_models.CharField( + max_length=100, + verbose_name=_('urban village') + ) + sub_district = db_models.CharField( + max_length=100, + verbose_name=_('sub-district') + ) + same_hamlet_costs = db_models.DecimalField( + decimal_places=2, + default=decimal.Decimal('0.00'), + max_digits=12, + validators=[validators.MinValueValidator(decimal.Decimal('0'))], + verbose_name=_( + 'shipping costs if the hamlet, urban village, and sub-district are the same as seller' + ) + ) + same_urban_village_different_hamlet_costs = db_models.DecimalField( + decimal_places=2, + default=decimal.Decimal('0.00'), + max_digits=12, + validators=[validators.MinValueValidator(decimal.Decimal('0'))], + verbose_name=_( + 'shipping costs if the urban village and sub-district are the same as seller' + ) + ) + same_sub_district_different_urban_village_costs = db_models.DecimalField( + decimal_places=2, + default=decimal.Decimal('0.00'), + max_digits=12, + validators=[validators.MinValueValidator(decimal.Decimal('0'))], + verbose_name=_('shipping costs if the sub-district is the same as seller') ) - otp = db_models.CharField(blank=True, max_length=6, null=True, verbose_name=_('OTP')) class Meta: - ordering = ['username'] - verbose_name = _('user') - verbose_name_plural = _('users') + verbose_name = _('shipment config') + verbose_name_plural = _('shipment configs') - def __str__(self): - return self.username + +class BankAccountConfig(solo_models.SingletonModel): + bank_name = db_models.CharField(max_length=100, verbose_name=_('bank name')) + account_number = db_models.CharField(max_length=100, verbose_name=_('account number')) + account_owner = db_models.CharField(max_length=200, verbose_name=_('account owner')) + + class Meta: + verbose_name = _('bank account config') + verbose_name_plural = _('bank account configs') class Category(db_models.Model): @@ -71,7 +101,7 @@ class Subcategory(db_models.Model): id = db_models.UUIDField(default=uuid.uuid4, primary_key=True, verbose_name=_('ID')) name = fields.CICharField(max_length=50, unique=True, verbose_name=_('name')) category = db_models.ForeignKey( - Category, + 'api.Category', on_delete=db_models.CASCADE, related_name='subcategories', verbose_name=_('category') @@ -95,14 +125,14 @@ class Subcategory(db_models.Model): class Product(db_models.Model): id = db_models.UUIDField(default=uuid.uuid4, primary_key=True, verbose_name=_('ID')) code = db_models.CharField( - default=utils.generate_code, + default=api_utils.generate_code, max_length=6, unique=True, verbose_name=_('code') ) name = db_models.CharField(max_length=200, verbose_name=_('name')) subcategory = db_models.ForeignKey( - Subcategory, + 'api.Subcategory', on_delete=db_models.PROTECT, related_name='products', verbose_name=_('subcategory') @@ -128,13 +158,185 @@ class Product(db_models.Model): verbose_name_plural = _('products') def __str__(self): - return self.name + return self.code + + +class ShoppingCart(db_models.Model): + id = db_models.UUIDField(default=uuid.uuid4, primary_key=True, verbose_name=_('ID')) + user = db_models.OneToOneField('api.User', on_delete=db_models.CASCADE, verbose_name=_('user')) + + class Meta: + ordering = ['user'] + verbose_name = _('shopping cart') + verbose_name_plural = _('shopping carts') + + def __str__(self): + return self.user.username + + +class CartItem(db_models.Model): + id = db_models.UUIDField(default=uuid.uuid4, primary_key=True, verbose_name=_('ID')) + shopping_cart = db_models.ForeignKey( + 'api.ShoppingCart', + on_delete=db_models.CASCADE, + related_name='cart_items', + verbose_name=_('shopping cart') + ) + product = db_models.ForeignKey( + 'api.Product', + on_delete=db_models.CASCADE, + related_name='cart_items', + verbose_name=_('product') + ) + quantity = db_models.PositiveIntegerField(default=0, verbose_name=_('quantity')) + + class Meta: + ordering = ['shopping_cart', 'product'] + unique_together = ['shopping_cart', 'product'] + verbose_name = _('cart item') + verbose_name_plural = _('cart items') + + def __str__(self): + return self.product.code + + +class Transaction(db_models.Model): + id = db_models.UUIDField(default=uuid.uuid4, primary_key=True, verbose_name=_('ID')) + transaction_number = db_models.CharField( + default=api_utils.generate_transaction_number, + max_length=8, + unique=True, + verbose_name=_('transaction number') + ) + user = db_models.ForeignKey( + 'api.User', + null=True, + on_delete=db_models.SET_NULL, + related_name='transactions', + verbose_name=_('user') + ) + user_full_name = db_models.CharField(max_length=200, verbose_name=_('user full name')) + user_phone_number = modelfields.PhoneNumberField(verbose_name=_('user phone number')) + shipping_address = db_models.CharField(max_length=200, verbose_name=_('shipping address')) + shipping_neighborhood = db_models.CharField( + max_length=3, + validators=[validators.int_list_validator(sep=''), validators.MinLengthValidator(3)], + verbose_name=_('shipping neighborhood') + ) + shipping_hamlet = db_models.CharField( + max_length=3, + validators=[validators.int_list_validator(sep=''), validators.MinLengthValidator(3)], + verbose_name=_('shipping hamlet') + ) + shipping_urban_village = db_models.CharField( + max_length=100, + verbose_name=_('shipping urban village') + ) + shipping_sub_district = db_models.CharField( + max_length=100, + verbose_name=_('shipping sub-district') + ) + shipping_costs = db_models.DecimalField( + decimal_places=2, + max_digits=12, + validators=[validators.MinValueValidator(decimal.Decimal('0'))], + verbose_name=_('shipping costs') + ) + payment_method = db_models.CharField( + choices=constants.PAYMENT_METHOD_CHOICES, + max_length=3, + verbose_name=_('payment method') + ) + donation = db_models.DecimalField( + decimal_places=2, + default=decimal.Decimal('0.00'), + max_digits=12, + validators=[validators.MinValueValidator(decimal.Decimal('0'))], + verbose_name=_('donation') + ) + transaction_status = db_models.CharField( + choices=constants.TRANSACTION_STATUS_CHOICES, + max_length=3, + verbose_name=_('transaction status') + ) + proof_of_payment = db_models.ImageField( + blank=True, + null=True, + upload_to='uploads/transactions/%Y/%m/%d/', + verbose_name=_('proof of payment') + ) + created_at = db_models.DateTimeField( + auto_now_add=True, + db_index=True, + verbose_name=_('created at') + ) + updated_at = db_models.DateTimeField(auto_now=True, db_index=True, verbose_name=_('updated at')) + + class Meta: + ordering = ['-updated_at', '-created_at'] + verbose_name = _('transaction') + verbose_name_plural = _('transactions') + + def save(self, *args, **kwargs): # pylint: disable=arguments-differ + if self._state.adding: + self.user_full_name = self.user.full_name + self.user_phone_number = self.user.phone_number + self.shipping_address = self.user.address + self.shipping_neighborhood = self.user.neighborhood + self.shipping_hamlet = self.user.hamlet + self.shipping_urban_village = self.user.urban_village + self.shipping_sub_district = self.user.sub_district + self.transaction_status = '001' if self.payment_method == 'TRF' else '002' + self.shipping_costs = home_industry_utils.get_shipping_costs(self.user) + super().save(*args, **kwargs) + + def __str__(self): + return self.transaction_number + + +class TransactionItem(db_models.Model): + id = db_models.UUIDField(default=uuid.uuid4, primary_key=True, verbose_name=_('ID')) + transaction = db_models.ForeignKey( + 'api.Transaction', + on_delete=db_models.CASCADE, + related_name='transaction_items', + verbose_name=_('transaction') + ) + product = db_models.ForeignKey( + 'api.Product', + null=True, + on_delete=db_models.SET_NULL, + related_name='transaction_items', + verbose_name=_('product') + ) + product_name = db_models.CharField(max_length=200, verbose_name=_('product name')) + product_price = db_models.DecimalField( + decimal_places=2, + max_digits=12, + validators=[validators.MinValueValidator(decimal.Decimal('0.01'))], + verbose_name=_('product price') + ) + quantity = db_models.PositiveIntegerField(verbose_name=_('quantity')) + + class Meta: + ordering = ['transaction'] + verbose_name = _('transaction item') + verbose_name_plural = _('transaction items') + + def save(self, *args, **kwargs): # pylint: disable=arguments-differ + if self._state.adding: + self.product_name = self.product.name + self.product_price = self.product.price + super().save(*args, **kwargs) + + def __str__(self): + return self.product_name class Program(db_models.Model): id = db_models.UUIDField(default=uuid.uuid4, primary_key=True, verbose_name=_('ID')) code = db_models.CharField( - default=utils.generate_code, + default=api_utils.generate_code, max_length=6, unique=True, verbose_name=_('code') @@ -172,12 +374,46 @@ class Program(db_models.Model): verbose_name_plural = _('programs') def __str__(self): - return self.name + return self.code -class Config(solo_models.SingletonModel): - send_sms = db_models.BooleanField(default=False, verbose_name=_('send SMS')) +class User(auth_models.AbstractUser): + id = db_models.UUIDField(default=uuid.uuid4, primary_key=True, verbose_name=_('ID')) + first_name = None + last_name = None + full_name = db_models.CharField(max_length=200, verbose_name=_('full name')) + phone_number = modelfields.PhoneNumberField(unique=True, verbose_name=_('phone number')) + address = db_models.CharField(max_length=200, verbose_name=_('address')) + neighborhood = db_models.CharField( + max_length=3, + validators=[validators.int_list_validator(sep=''), validators.MinLengthValidator(3)], + verbose_name=_('neighborhood') + ) + hamlet = db_models.CharField( + max_length=3, + validators=[validators.int_list_validator(sep=''), validators.MinLengthValidator(3)], + verbose_name=_('hamlet') + ) + urban_village = db_models.CharField(max_length=100, verbose_name=_('urban village')) + sub_district = db_models.CharField(max_length=100, verbose_name=_('sub-district')) + profile_picture = db_models.ImageField( + blank=True, + null=True, + upload_to='uploads/users/', + verbose_name=_('profile picture') + ) + otp = db_models.CharField(blank=True, max_length=6, null=True, verbose_name=_('OTP')) class Meta: - verbose_name = _('config') - verbose_name_plural = _('configs') + ordering = ['username'] + verbose_name = _('user') + verbose_name_plural = _('users') + + def __str__(self): + return self.username + + +@dispatch.receiver(signals.post_save, sender=User) +def create_shopping_cart(sender, created, instance, **_kwargs): # pylint: disable=unused-argument + if created: + ShoppingCart.objects.create(user=instance) diff --git a/api/serializers.py b/api/serializers.py index 56a0739..8b75dbd 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -133,7 +133,26 @@ class ProgramSerializer(serializers.ModelSerializer): return super().validate(attrs) -class ConfigSerializer(serializers.ModelSerializer): +class AppConfigSerializer(serializers.ModelSerializer): class Meta: fields = ['send_sms'] - model = models.Config + model = models.AppConfig + + +class ShipmentConfigSerializer(serializers.ModelSerializer): + class Meta: + fields = [ + 'hamlet', + 'urban_village', + 'sub_district', + 'same_hamlet_costs', + 'same_urban_village_different_hamlet_costs', + 'same_sub_district_different_urban_village_costs', + ] + model = models.ShipmentConfig + + +class BankAccountConfigSerializer(serializers.ModelSerializer): + class Meta: + fields = ['bank_name', 'account_number', 'account_owner'] + model = models.BankAccountConfig diff --git a/api/tests.py b/api/tests.py index 6f94a53..e7c3905 100644 --- a/api/tests.py +++ b/api/tests.py @@ -19,8 +19,8 @@ USER_DATA = { 'full_name': 'Dummy User', 'phone_number': '+6285212345678', 'address': 'Jl. Dummy No.1', - 'neighborhood': '000', - 'hamlet': '000', + 'neighborhood': '001', + 'hamlet': '001', 'urban_village': 'Dummy Urban Village', 'sub_district': 'Dummy Sub-District', } @@ -49,6 +49,29 @@ PROGRAM_DATA = { 'speaker': 'Dummy Speaker', } +TRANSACTION_DATA = { + 'payment_method': 'TRF', +} + +TRANSACTION_ITEM_DATA = { + 'quantity': '1', +} + +SHIPMENT_CONFIG_DATA = { + 'hamlet': '001', + 'urban_village': 'Dummy Urban Village', + 'sub_district': 'Dummy Sub-District', + 'same_hamlet_costs': '1.00', + 'same_urban_village_different_hamlet_costs': '2.00', + 'same_sub_district_different_urban_village_costs': '3.00', +} + +BANK_ACCOUNT_CONFIG_DATA = { + 'bank_name': 'Dummy Bank Name', + 'account_number': '1234567890', + 'account_owner': 'Dummy Account Owner', +} + def get_http_authorization(username, password): client = rest_framework_test.APIClient() @@ -81,10 +104,10 @@ class UtilsTest(django_test.TestCase): with self.assertRaises(exceptions.NotAuthenticated): utils.get_username_from_bearer_token(http_authorization) http_authorization = 'Bearers token' - with self.assertRaises(exceptions.ValidationError): + with self.assertRaises(exceptions.AuthenticationFailed): utils.get_username_from_bearer_token(http_authorization) http_authorization = 'Bearer token' - with self.assertRaises(exceptions.ValidationError): + with self.assertRaises(exceptions.AuthenticationFailed): utils.get_username_from_bearer_token(http_authorization) encoded_jwt = jwt.encode( {'username': USER_DATA['username']}, @@ -92,7 +115,7 @@ class UtilsTest(django_test.TestCase): algorithm='HS256' ).decode('utf-8') http_authorization = 'Bearer {}'.format(encoded_jwt) - with self.assertRaises(exceptions.ValidationError): + with self.assertRaises(exceptions.AuthenticationFailed): utils.get_username_from_bearer_token(http_authorization) @@ -405,7 +428,7 @@ class ProductTest(rest_framework_test.APITestCase): def test_product_model_string_representation(self): product = models.Product.objects.create(**dict(PRODUCT_DATA, subcategory=self.subcategory)) - self.assertEqual(str(product), PRODUCT_DATA['name']) + self.assertEqual(len(str(product)), 6) def test_product_list_success(self): http_authorization = self.superuser_http_authorization @@ -480,6 +503,92 @@ class ProductTest(rest_framework_test.APITestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) +class ShoppingCartTest(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.user = models.User.objects.create(**USER_DATA) + + def test_shopping_cart_model_string_representation(self): + shopping_cart = models.ShoppingCart.objects.get(user=self.user) + self.assertEqual(str(shopping_cart), self.user.username) + + +class CartItemTest(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.user = models.User.objects.create(**USER_DATA) + self.shopping_cart = models.ShoppingCart.objects.get(user=self.user) + self.category = models.Category.objects.create(**CATEGORY_DATA) + self.subcategory = models.Subcategory.objects.create( + **dict(CATEGORY_DATA, category=self.category) + ) + self.product = models.Product.objects.create(**dict( + PRODUCT_DATA, + subcategory=self.subcategory + )) + + def test_cart_item_model_string_representation(self): + cart_item = models.CartItem.objects.create( + shopping_cart=self.shopping_cart, + product=self.product + ) + self.assertEqual(str(cart_item), self.product.code) + + +class TransactionTest(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'] + ) + models.ShipmentConfig.objects.create(**SHIPMENT_CONFIG_DATA) + self.user = models.User.objects.create(**USER_DATA) + + def test_transaction_model_string_representation(self): + transaction = models.Transaction.objects.create(**dict(TRANSACTION_DATA, user=self.user)) + self.assertEqual(len(str(transaction)), 8) + + +class TransactionItemTest(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'] + ) + models.ShipmentConfig.objects.create(**SHIPMENT_CONFIG_DATA) + self.user = models.User.objects.create(**USER_DATA) + self.category = models.Category.objects.create(**CATEGORY_DATA) + self.subcategory = models.Subcategory.objects.create( + **dict(CATEGORY_DATA, category=self.category) + ) + self.product = models.Product.objects.create(**dict( + PRODUCT_DATA, + subcategory=self.subcategory + )) + self.transaction = models.Transaction.objects.create(**dict( + TRANSACTION_DATA, + user=self.user + )) + + def test_transaction_model_string_representation(self): + transaction_item = models.TransactionItem.objects.create(**dict( + TRANSACTION_ITEM_DATA, + product=self.product, + transaction=self.transaction + )) + self.assertEqual(str(transaction_item), self.product.name) + + class ProgramTest(rest_framework_test.APITestCase): def setUp(self): self.superuser = models.User.objects.create_superuser(**SUPERUSER_DATA) @@ -490,7 +599,7 @@ class ProgramTest(rest_framework_test.APITestCase): def test_program_model_string_representation(self): program = models.Program.objects.create(**PROGRAM_DATA) - self.assertEqual(str(program), PROGRAM_DATA['name']) + self.assertEqual(len(str(program)), 6) def test_program_list_success(self): http_authorization = self.superuser_http_authorization @@ -561,7 +670,87 @@ class ProgramTest(rest_framework_test.APITestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) -class ConfigTest(rest_framework_test.APITestCase): +class BankAccountConfigTest(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'] + ) + + def test_bank_account_config_detail_success(self): + models.BankAccountConfig.objects.create(**BANK_ACCOUNT_CONFIG_DATA) + http_authorization = self.superuser_http_authorization + url = urls.reverse('bank-account-config-detail') + 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_update_bank_account_config_success(self): + models.BankAccountConfig.objects.create(**BANK_ACCOUNT_CONFIG_DATA) + http_authorization = self.superuser_http_authorization + data = { + 'bank_name': 'Dummy', + } + url = urls.reverse('bank-account-config-detail') + self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member + response = self.client.patch(url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(models.BankAccountConfig.objects.get().bank_name, data['bank_name']) + + def test_update_bank_account_config_fail(self): + models.BankAccountConfig.objects.create(**BANK_ACCOUNT_CONFIG_DATA) + http_authorization = self.superuser_http_authorization + data = { + 'bank_name': '', + } + url = urls.reverse('bank-account-config-detail') + self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member + response = self.client.patch(url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + +class ShipmentConfigTest(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'] + ) + + def test_shipment_config_detail_success(self): + models.ShipmentConfig.objects.create(**SHIPMENT_CONFIG_DATA) + http_authorization = self.superuser_http_authorization + url = urls.reverse('shipment-config-detail') + 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_update_shipment_config_success(self): + models.ShipmentConfig.objects.create(**SHIPMENT_CONFIG_DATA) + http_authorization = self.superuser_http_authorization + data = { + 'hamlet': '001', + } + url = urls.reverse('shipment-config-detail') + self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member + response = self.client.patch(url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(models.ShipmentConfig.objects.get().hamlet, data['hamlet']) + + def test_update_shipment_config_fail(self): + models.ShipmentConfig.objects.create(**SHIPMENT_CONFIG_DATA) + http_authorization = self.superuser_http_authorization + data = { + 'hamlet': '0001', + } + url = urls.reverse('shipment-config-detail') + self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member + response = self.client.patch(url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + +class AppConfigTest(rest_framework_test.APITestCase): def setUp(self): self.superuser = models.User.objects.create_superuser(**SUPERUSER_DATA) self.superuser_http_authorization = get_http_authorization( @@ -569,33 +758,33 @@ class ConfigTest(rest_framework_test.APITestCase): SUPERUSER_DATA['password'] ) - def test_config_detail_success(self): - models.Config.objects.create() + def test_app_config_detail_success(self): + models.AppConfig.objects.create() http_authorization = self.superuser_http_authorization - url = urls.reverse('config-detail') + url = urls.reverse('app-config-detail') 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_update_config_success(self): - models.Config.objects.create() + def test_update_app_config_success(self): + models.AppConfig.objects.create() http_authorization = self.superuser_http_authorization data = { 'send_sms': True, } - url = urls.reverse('config-detail') + url = urls.reverse('app-config-detail') self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member response = self.client.patch(url, data, format='json') self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(models.Config.objects.get().send_sms, data['send_sms']) + self.assertEqual(models.AppConfig.objects.get().send_sms, data['send_sms']) - def test_update_config_fail(self): - models.Config.objects.create() + def test_update_app_config_fail(self): + models.AppConfig.objects.create() http_authorization = self.superuser_http_authorization data = { 'send_sms': None, } - url = urls.reverse('config-detail') + url = urls.reverse('app-config-detail') self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member response = self.client.patch(url, data, format='json') self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/api/urls.py b/api/urls.py index 7357c4d..d2cf10e 100644 --- a/api/urls.py +++ b/api/urls.py @@ -29,5 +29,15 @@ urlpatterns = [ urls.path('products//', api_views.ProductDetail.as_view(), name='product-detail'), urls.path('programs/', api_views.ProgramList.as_view(), name='program-list'), urls.path('programs//', api_views.ProgramDetail.as_view(), name='program-detail'), - urls.path('config/', api_views.ConfigDetail.as_view(), name='config-detail'), + urls.path('configs/app/', api_views.AppConfigDetail.as_view(), name='app-config-detail'), + urls.path( + 'configs/shipment/', + api_views.ShipmentConfigDetail.as_view(), + name='shipment-config-detail' + ), + urls.path( + 'configs/bank-account/', + api_views.BankAccountConfigDetail.as_view(), + name='bank-account-config-detail' + ), ] diff --git a/api/utils.py b/api/utils.py index 9cc9ad4..85452ed 100644 --- a/api/utils.py +++ b/api/utils.py @@ -5,6 +5,7 @@ import jwt import shortuuid from jwt import exceptions as jwt_exceptions from django import conf +from django.utils import text from django.utils.translation import gettext_lazy as _ from rest_framework import exceptions as rest_framework_exceptions @@ -23,22 +24,33 @@ def generate_otp(): return otp +def generate_transaction_number(): + alphabet = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ' + transaction_number = shortuuid.ShortUUID(alphabet=alphabet).random(length=8) + return transaction_number + + def get_username_from_bearer_token(http_authorization): if http_authorization is None: raise rest_framework_exceptions.NotAuthenticated() bearer, _sep, encoded_jwt = http_authorization.partition(' ') if bearer != 'Bearer': - raise rest_framework_exceptions.ValidationError(_('Invalid authorization header format.')) + raise rest_framework_exceptions.AuthenticationFailed(_( + 'Invalid authorization header format.' + )) try: decoded_jwt = jwt.decode(encoded_jwt, conf.settings.SECRET_KEY, algorithms=['HS256']) except (jwt_exceptions.DecodeError, jwt_exceptions.ExpiredSignatureError): - raise rest_framework_exceptions.ValidationError(_('Invalid token.')) + raise rest_framework_exceptions.AuthenticationFailed(_('Invalid token.')) if (decoded_jwt.get('exp') is None) or (decoded_jwt.get('username') is None): - raise rest_framework_exceptions.ValidationError(_('Invalid token.')) + raise rest_framework_exceptions.AuthenticationFailed(_('Invalid token.')) return decoded_jwt['username'] def send_otp(phone_number, otp): - message = 'Kode otentikasi akun Industri Pilar Anda adalah {}. ' \ - 'RAHASIAKAN kode otentikasi Anda.'.format(otp) + message = text.format_lazy( + 'Your Industri Pilar account authentication code is {otp}. ' + 'Keep your authentication code SECRET.', + otp=otp + ) utils.send_sms(phone_number, message) diff --git a/api/views.py b/api/views.py index a6ed6b6..b43fe9c 100644 --- a/api/views.py +++ b/api/views.py @@ -14,7 +14,8 @@ from rest_framework import ( from rest_framework.authtoken import serializers as authtoken_serializers from api import ( - models, paginations, permissions as api_permissions, serializers as api_serializers, utils + models, paginations, permissions as api_permissions, serializers as api_serializers, + utils as api_utils ) @@ -52,9 +53,9 @@ class AuthPhoneNumberLogin(rest_framework_views.APIView): models.User, phone_number=serializer.validated_data['phone_number'] ) - user.otp = utils.generate_otp() + user.otp = api_utils.generate_otp() user.save() - utils.send_otp(str(user.phone_number), user.otp) + api_utils.send_otp(str(user.phone_number), user.otp) encoded_jwt = jwt.encode( {'exp': datetime.utcnow() + timedelta(hours=1), 'username': user.username}, conf.settings.SECRET_KEY, @@ -72,7 +73,7 @@ class AuthOTPLogin(knox_views.LoginView): def post(self, request, format=None): # pylint: disable=redefined-builtin http_authorization = self.request.META.get('HTTP_AUTHORIZATION') - username = utils.get_username_from_bearer_token(http_authorization) + username = api_utils.get_username_from_bearer_token(http_authorization) serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) user = shortcuts.get_object_or_404(models.User, username=username) @@ -89,11 +90,11 @@ class AuthResendOTP(rest_framework_views.APIView): def post(self, request, _format=None): http_authorization = self.request.META.get('HTTP_AUTHORIZATION') - username = utils.get_username_from_bearer_token(http_authorization) + username = api_utils.get_username_from_bearer_token(http_authorization) user = shortcuts.get_object_or_404(models.User, username=username) - user.otp = utils.generate_otp() + user.otp = api_utils.generate_otp() user.save() - utils.send_otp(str(user.phone_number), user.otp) + api_utils.send_otp(str(user.phone_number), user.otp) return response.Response(status=status.HTTP_204_NO_CONTENT) @@ -103,7 +104,7 @@ class UserList(generics.ListCreateAPIView): filters.SearchFilter, rest_framework.DjangoFilterBackend, ] - filterset_fields = ['username', 'full_name', 'phone_number'] + filterset_fields = ['username', 'phone_number'] ordering_fields = ['username', 'full_name', 'phone_number'] pagination_class = paginations.SmallResultsSetPagination permission_classes = [rest_framework_permissions.IsAdminUser] @@ -113,7 +114,10 @@ class UserList(generics.ListCreateAPIView): class UserDetail(generics.RetrieveUpdateDestroyAPIView): - permission_classes = [api_permissions.IsAdminUserOrSelf] + permission_classes = [ + api_permissions.IsAdminUserOrSelf, + rest_framework_permissions.IsAuthenticated, + ] queryset = models.User.objects.all() serializer_class = api_serializers.UserSerializer @@ -133,8 +137,8 @@ class CategoryList(generics.ListCreateAPIView): ordering_fields = ['name'] pagination_class = paginations.SmallResultsSetPagination permission_classes = [ + api_permissions.IsAdminUserOrReadOnly, rest_framework_permissions.IsAuthenticated, - api_permissions.IsAdminUserOrReadOnly ] queryset = models.Category.objects.all() search_fields = ['name'] @@ -143,8 +147,8 @@ class CategoryList(generics.ListCreateAPIView): class CategoryDetail(generics.RetrieveUpdateDestroyAPIView): permission_classes = [ + api_permissions.IsAdminUserOrReadOnly, rest_framework_permissions.IsAuthenticated, - api_permissions.IsAdminUserOrReadOnly ] queryset = models.Category.objects.all() serializer_class = api_serializers.CategorySerializer @@ -171,8 +175,8 @@ class SubcategoryList(generics.ListCreateAPIView): ordering_fields = ['name'] pagination_class = paginations.SmallResultsSetPagination permission_classes = [ + api_permissions.IsAdminUserOrReadOnly, rest_framework_permissions.IsAuthenticated, - api_permissions.IsAdminUserOrReadOnly ] queryset = models.Subcategory.objects.all() search_fields = ['name'] @@ -181,8 +185,8 @@ class SubcategoryList(generics.ListCreateAPIView): class SubcategoryDetail(generics.RetrieveUpdateDestroyAPIView): permission_classes = [ + api_permissions.IsAdminUserOrReadOnly, rest_framework_permissions.IsAuthenticated, - api_permissions.IsAdminUserOrReadOnly ] queryset = models.Subcategory.objects.all() serializer_class = api_serializers.SubcategorySerializer @@ -205,12 +209,12 @@ class ProductList(generics.ListCreateAPIView): filters.SearchFilter, rest_framework.DjangoFilterBackend, ] - filterset_fields = ['code', 'name', 'subcategory', 'subcategory__category'] + filterset_fields = ['code', 'subcategory', 'subcategory__category'] ordering_fields = ['name', 'price', 'stock'] pagination_class = paginations.SmallResultsSetPagination permission_classes = [ + api_permissions.IsAdminUserOrReadOnly, rest_framework_permissions.IsAuthenticated, - api_permissions.IsAdminUserOrReadOnly ] queryset = models.Product.objects.all() search_fields = ['name'] @@ -219,8 +223,8 @@ class ProductList(generics.ListCreateAPIView): class ProductDetail(generics.RetrieveUpdateDestroyAPIView): permission_classes = [ + api_permissions.IsAdminUserOrReadOnly, rest_framework_permissions.IsAuthenticated, - api_permissions.IsAdminUserOrReadOnly ] queryset = models.Product.objects.all() serializer_class = api_serializers.ProductSerializer @@ -236,8 +240,8 @@ class ProgramList(generics.ListCreateAPIView): ordering_fields = ['name', 'start_date_time'] pagination_class = paginations.SmallResultsSetPagination permission_classes = [ + api_permissions.IsAdminUserOrReadOnly, rest_framework_permissions.IsAuthenticated, - api_permissions.IsAdminUserOrReadOnly ] queryset = models.Program.objects.all() search_fields = ['name'] @@ -246,18 +250,38 @@ class ProgramList(generics.ListCreateAPIView): class ProgramDetail(generics.RetrieveUpdateDestroyAPIView): permission_classes = [ + api_permissions.IsAdminUserOrReadOnly, rest_framework_permissions.IsAuthenticated, - api_permissions.IsAdminUserOrReadOnly ] queryset = models.Program.objects.all() serializer_class = api_serializers.ProgramSerializer -class ConfigDetail(generics.RetrieveUpdateAPIView): +class AppConfigDetail(generics.RetrieveUpdateAPIView): permission_classes = [rest_framework_permissions.IsAdminUser] - queryset = models.Config.objects.all() - serializer_class = api_serializers.ConfigSerializer + queryset = models.AppConfig.objects.all() + serializer_class = api_serializers.AppConfigSerializer + + def get_object(self): + obj = shortcuts.get_object_or_404(models.AppConfig) + return obj + + +class ShipmentConfigDetail(generics.RetrieveUpdateAPIView): + permission_classes = [api_permissions.IsAdminUserOrReadOnly] + queryset = models.ShipmentConfig.objects.all() + serializer_class = api_serializers.ShipmentConfigSerializer + + def get_object(self): + obj = shortcuts.get_object_or_404(models.ShipmentConfig) + return obj + + +class BankAccountConfigDetail(generics.RetrieveUpdateAPIView): + permission_classes = [api_permissions.IsAdminUserOrReadOnly] + queryset = models.BankAccountConfig.objects.all() + serializer_class = api_serializers.BankAccountConfigSerializer def get_object(self): - obj = shortcuts.get_object_or_404(models.Config) + obj = shortcuts.get_object_or_404(models.BankAccountConfig) return obj diff --git a/home_industry/utils.py b/home_industry/utils.py index e566248..81e204f 100644 --- a/home_industry/utils.py +++ b/home_industry/utils.py @@ -6,13 +6,37 @@ from rest_framework import exceptions as rest_framework_exceptions from api import models +def get_shipping_costs(user): + try: + shipment_config = models.ShipmentConfig.objects.get() + except models.ShipmentConfig.DoesNotExist: + raise rest_framework_exceptions.APIException(_( + 'A bad configuration error occurred.' + )) + if user.sub_district.lower() == shipment_config.sub_district.lower(): + if user.urban_village.lower() == shipment_config.urban_village.lower(): + if user.hamlet == shipment_config.hamlet: + shipping_costs = shipment_config.same_hamlet_costs + else: + shipping_costs = ( + shipment_config.same_urban_village_different_hamlet_costs + ) + else: + shipping_costs = ( + shipment_config.same_sub_district_different_urban_village_costs + ) + else: + shipping_costs = None + return shipping_costs + + def send_sms(phone_number, message): try: aws_settings = conf.settings.AWS - config = models.Config.objects.get() - except (AttributeError, models.Config.DoesNotExist): + app_config = models.AppConfig.objects.get() + except (AttributeError, models.AppConfig.DoesNotExist): raise rest_framework_exceptions.APIException(_('A bad configuration error occurred.')) - if not config.send_sms: + if not app_config.send_sms: raise rest_framework_exceptions.APIException(_('Server is currently unable to send SMS.')) client = boto3.client( 'sns', -- GitLab From 2196c9b4db462ce4949433bd7d23d6c7f81aad87 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Tue, 21 Apr 2020 19:44:33 +0700 Subject: [PATCH 63/90] Complete Transaction API --- .coveragerc | 3 + .gitlab-ci.yml | 6 +- .pylintrc | 4 +- README.md | 38 +- api/apps.py | 3 + api/exceptions.py | 5 + api/filters.py | 17 + api/management/__init__.py | 0 api/management/commands/__init__.py | 0 .../commands/createorupdateapiconfig.py | 19 + api/migrations/0001_initial.py | 191 ++- api/migrations/0002_auto_20200419_0448.py | 51 + api/migrations/0002_config.py | 24 - api/migrations/0003_auto_20200419_1707.py | 19 + api/migrations/0003_program.py | 32 - api/migrations/0004_auto_20200309_1953.py | 18 - api/migrations/0004_auto_20200420_1446.py | 49 + .../0005_category_product_subcategory.py | 61 - api/migrations/0006_auto_20200316_2058.py | 19 - api/migrations/0007_auto_20200410_1629.py | 37 - api/migrations/0008_auto_20200410_1638.py | 35 - api/migrations/0009_auto_20200411_1153.py | 113 -- api/migrations/0010_auto_20200411_1311.py | 18 - .../0011_transactionitem_quantity.py | 18 - api/migrations/0012_auto_20200411_1455.py | 18 - api/migrations/0013_bankaccountconfig.py | 25 - api/migrations/0014_auto_20200414_1506.py | 21 - api/models.py | 220 +-- api/paginations.py | 12 +- api/permissions.py | 16 + api/schemas.py | 27 + api/seeds.py | 65 + api/serializers.py | 169 +- api/signals.py | 10 + api/tests.py | 1483 ++++++++++++----- api/urls.py | 44 +- api/utils.py | 58 +- api/views.py | 337 +++- api_config.yaml | 13 + home_industry/utils.py | 45 +- requirements.txt | 1 + sonar-project.properties | 5 +- 42 files changed, 2243 insertions(+), 1106 deletions(-) create mode 100644 api/exceptions.py create mode 100644 api/filters.py create mode 100644 api/management/__init__.py create mode 100644 api/management/commands/__init__.py create mode 100644 api/management/commands/createorupdateapiconfig.py create mode 100644 api/migrations/0002_auto_20200419_0448.py delete mode 100644 api/migrations/0002_config.py create mode 100644 api/migrations/0003_auto_20200419_1707.py delete mode 100644 api/migrations/0003_program.py delete mode 100644 api/migrations/0004_auto_20200309_1953.py create mode 100644 api/migrations/0004_auto_20200420_1446.py delete mode 100644 api/migrations/0005_category_product_subcategory.py delete mode 100644 api/migrations/0006_auto_20200316_2058.py delete mode 100644 api/migrations/0007_auto_20200410_1629.py delete mode 100644 api/migrations/0008_auto_20200410_1638.py delete mode 100644 api/migrations/0009_auto_20200411_1153.py delete mode 100644 api/migrations/0010_auto_20200411_1311.py delete mode 100644 api/migrations/0011_transactionitem_quantity.py delete mode 100644 api/migrations/0012_auto_20200411_1455.py delete mode 100644 api/migrations/0013_bankaccountconfig.py delete mode 100644 api/migrations/0014_auto_20200414_1506.py create mode 100644 api/schemas.py create mode 100644 api/seeds.py create mode 100644 api/signals.py create mode 100644 api_config.yaml diff --git a/.coveragerc b/.coveragerc index 3ccf6a5..18aae54 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,3 +1,6 @@ [run] +omit = + */schemas.py + */management/* source = api diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index d3db582..17a591a 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -12,7 +12,7 @@ lint: SECRET_KEY: $CI_TEST_SECRET_KEY script: - pip install -r requirements.txt - - python manage.py migrate + - python3 manage.py migrate - pylint --exit-zero api test: @@ -27,7 +27,7 @@ test: SECRET_KEY: $CI_TEST_SECRET_KEY script: - pip install -r requirements.txt - - python manage.py migrate + - python3 manage.py migrate - coverage run manage.py test - coverage xml - coverage report -m @@ -50,7 +50,7 @@ staging: HEROKU_APP: $STAGING_HEROKU_APP script: - gem install dpl - - dpl --provider=heroku --api-key=$HEROKU_API_KEY --app=$HEROKU_APP --run="python manage.py migrate && python manage.py collectstatic --noinput" + - dpl --provider=heroku --api-key=$HEROKU_API_KEY --app=$HEROKU_APP --run="python3 manage.py migrate" only: - staging diff --git a/.pylintrc b/.pylintrc index 10363fc..b748bb1 100644 --- a/.pylintrc +++ b/.pylintrc @@ -1,9 +1,9 @@ [MASTER] disable= - cyclic-import, missing-class-docstring, missing-function-docstring, - missing-module-docstring + missing-module-docstring, + too-many-lines ignore= env load-plugins= diff --git a/README.md b/README.md index f3e60ba..6614ebe 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,10 @@ - Create Python virtual environment ``` -cd /path/to/project/directory -python3 -m venv env -source env/bin/activate -pip3 install -r requirements.txt +$ cd /path/to/project/directory +$ python3 -m venv env +$ source env/bin/activate +$ pip3 install -r requirements.txt ``` - Create PostgreSQL postgres user with password postgres @@ -27,33 +27,39 @@ pip3 install -r requirements.txt - Create PostgreSQL database ``` -psql -CREATE DATABASE home_industry; -\q +$ createdb home_industry ``` - Set up environment variables ``` -export DATABASE_HOST="127.0.0.1" -export DATABASE_NAME="home_industry" -export DATABASE_PASSWORD="postgres" -export DATABASE_PORT="5432" -export DATABASE_USER="postgres" -export DJANGO_SETTINGS_MODULE="home_industry.settings.local" -export SECRET_KEY="7&s33ax$lxxzti1)0y=8#tu!$7bdy)p$1@kn06tp&8x8i9#h2u" +$ export DATABASE_HOST="127.0.0.1" +$ export DATABASE_NAME="home_industry" +$ export DATABASE_PASSWORD="postgres" +$ export DATABASE_PORT="5432" +$ export DATABASE_USER="postgres" +$ export DJANGO_SETTINGS_MODULE="home_industry.settings.local" +$ export SECRET_KEY="7&s33ax$lxxzti1)0y=8#tu!$7bdy)p$1@kn06tp&8x8i9#h2u" ``` - Migrate the database ``` -python3 manage.py migrate +$ python3 manage.py migrate +``` + +- Set up API configuration by modifying `api_config.yaml` + +- Create or update the API configuration in database + +``` +$ python3 manage.py createorupdateapiconfig ``` - Run server ``` -python3 manage.py runserver +$ python3 manage.py runserver ``` ## Deployed API URLs diff --git a/api/apps.py b/api/apps.py index c14fe73..f729a19 100644 --- a/api/apps.py +++ b/api/apps.py @@ -3,3 +3,6 @@ from django import apps class ApiConfig(apps.AppConfig): name = 'api' + + def ready(self): + from api import signals # pylint: disable=import-outside-toplevel,unused-import diff --git a/api/exceptions.py b/api/exceptions.py new file mode 100644 index 0000000..3d6c23b --- /dev/null +++ b/api/exceptions.py @@ -0,0 +1,5 @@ +from rest_framework import exceptions, status + + +class IntegrityError(exceptions.APIException): + status_code = status.HTTP_409_CONFLICT diff --git a/api/filters.py b/api/filters.py new file mode 100644 index 0000000..cf4639b --- /dev/null +++ b/api/filters.py @@ -0,0 +1,17 @@ +import django_filters + +from api import models + + +class TransactionFilter(django_filters.FilterSet): + updated_at_date_range = django_filters.DateFromToRangeFilter(field_name='updated_at') + + class Meta: + fields = [ + 'transaction_number', + 'user', + 'payment_method', + 'transaction_status', + 'updated_at_date_range', + ] + model = models.Transaction diff --git a/api/management/__init__.py b/api/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/management/commands/__init__.py b/api/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/management/commands/createorupdateapiconfig.py b/api/management/commands/createorupdateapiconfig.py new file mode 100644 index 0000000..3e3c859 --- /dev/null +++ b/api/management/commands/createorupdateapiconfig.py @@ -0,0 +1,19 @@ +import os + +import yaml +from django.core.management import base + +from api import models + + +class Command(base.BaseCommand): + def handle(self, *args, **options): + with open(os.environ.get('API_CONFIG_PATH', 'api_config.yaml')) as file: + api_config = yaml.load(file, Loader=yaml.FullLoader) + config_key_to_model_class_map = { + 'app_config': models.AppConfig, + 'bank_account_config': models.BankAccountConfig, + 'shipment_config': models.ShipmentConfig, + } + for key, model_class in config_key_to_model_class_map.items(): + model_class.objects.update_or_create(defaults=api_config[key]) diff --git a/api/migrations/0001_initial.py b/api/migrations/0001_initial.py index 74229fb..91f6e60 100644 --- a/api/migrations/0001_initial.py +++ b/api/migrations/0001_initial.py @@ -1,9 +1,15 @@ -# Generated by Django 3.0.3 on 2020-03-02 12:15 +# Generated by Django 3.0.3 on 2020-04-17 15:47 +import api.utils +from decimal import Decimal +from django.conf import settings +from django.contrib.postgres import operations as postgres_operations import django.contrib.auth.models import django.contrib.auth.validators +import django.contrib.postgres.fields.citext import django.core.validators from django.db import migrations, models +import django.db.models.deletion import django.utils.timezone import phonenumber_field.modelfields import re @@ -19,6 +25,7 @@ class Migration(migrations.Migration): ] operations = [ + postgres_operations.CITextExtension(), migrations.CreateModel( name='User', fields=[ @@ -38,7 +45,7 @@ class Migration(migrations.Migration): ('hamlet', models.CharField(max_length=3, validators=[django.core.validators.RegexValidator(re.compile('^\\d+(?:\\d+)*\\Z'), code='invalid', message=None), django.core.validators.MinLengthValidator(3)], verbose_name='hamlet')), ('urban_village', models.CharField(max_length=100, verbose_name='urban village')), ('sub_district', models.CharField(max_length=100, verbose_name='sub-district')), - ('profile_picture', models.ImageField(blank=True, null=True, upload_to='uploads/profile/', verbose_name='profile picture')), + ('profile_picture', models.ImageField(blank=True, null=True, upload_to='uploads/users/', verbose_name='profile picture')), ('otp', models.CharField(blank=True, max_length=6, null=True, verbose_name='OTP')), ('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')), @@ -52,4 +59,184 @@ class Migration(migrations.Migration): ('objects', django.contrib.auth.models.UserManager()), ], ), + migrations.CreateModel( + name='AppConfig', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('send_sms', models.BooleanField(default=False, verbose_name='send SMS')), + ], + options={ + 'verbose_name': 'application config', + 'verbose_name_plural': 'application configs', + }, + ), + migrations.CreateModel( + name='BankAccountConfig', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('bank_name', models.CharField(max_length=100, verbose_name='bank name')), + ('account_number', models.CharField(max_length=100, verbose_name='account number')), + ('account_owner', models.CharField(max_length=200, verbose_name='account owner')), + ], + options={ + 'verbose_name': 'bank account config', + 'verbose_name_plural': 'bank account configs', + }, + ), + migrations.CreateModel( + name='Category', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='ID')), + ('name', django.contrib.postgres.fields.citext.CICharField(max_length=50, unique=True, 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='Product', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='ID')), + ('code', models.CharField(default=api.utils.generate_code, max_length=6, unique=True, verbose_name='code')), + ('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')), + ], + options={ + 'verbose_name': 'product', + 'verbose_name_plural': 'products', + 'ordering': ['subcategory', 'name'], + }, + ), + migrations.CreateModel( + name='Program', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='ID')), + ('code', models.CharField(default=api.utils.generate_code, max_length=6, unique=True, verbose_name='code')), + ('name', models.CharField(max_length=200, verbose_name='name')), + ('description', models.TextField(verbose_name='description')), + ('start_date_time', models.DateTimeField(blank=True, db_index=True, null=True, verbose_name='start date and time')), + ('end_date_time', models.DateTimeField(blank=True, null=True, verbose_name='end date and time')), + ('location', models.CharField(blank=True, max_length=200, null=True, verbose_name='location')), + ('speaker', models.CharField(blank=True, max_length=200, null=True, verbose_name='speaker')), + ('poster_image', models.ImageField(blank=True, null=True, upload_to='uploads/programs/', verbose_name='poster image')), + ], + options={ + 'verbose_name': 'program', + 'verbose_name_plural': 'programs', + 'ordering': ['-start_date_time', '-end_date_time', 'name'], + }, + ), + migrations.CreateModel( + name='ShipmentConfig', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('hamlet', models.CharField(max_length=3, validators=[django.core.validators.RegexValidator(re.compile('^\\d+(?:\\d+)*\\Z'), code='invalid', message=None), django.core.validators.MinLengthValidator(3)], verbose_name='hamlet')), + ('urban_village', models.CharField(max_length=100, verbose_name='urban village')), + ('sub_district', models.CharField(max_length=100, verbose_name='sub-district')), + ('same_hamlet_costs', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=12, validators=[django.core.validators.MinValueValidator(Decimal('0'))], verbose_name='shipping costs if the hamlet, urban village, and sub-district are the same as seller')), + ('same_urban_village_different_hamlet_costs', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=12, validators=[django.core.validators.MinValueValidator(Decimal('0'))], verbose_name='shipping costs if the urban village and sub-district are the same as seller')), + ('same_sub_district_different_urban_village_costs', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=12, validators=[django.core.validators.MinValueValidator(Decimal('0'))], verbose_name='shipping costs if the sub-district is the same as seller')), + ], + options={ + 'verbose_name': 'shipment config', + 'verbose_name_plural': 'shipment configs', + }, + ), + migrations.CreateModel( + name='Transaction', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='ID')), + ('transaction_number', models.CharField(default=api.utils.generate_transaction_number, max_length=8, unique=True, verbose_name='transaction number')), + ('user_full_name', models.CharField(max_length=200, verbose_name='user full name')), + ('user_phone_number', phonenumber_field.modelfields.PhoneNumberField(max_length=128, region=None, verbose_name='user phone number')), + ('shipping_address', models.CharField(max_length=200, verbose_name='shipping address')), + ('shipping_neighborhood', models.CharField(max_length=3, validators=[django.core.validators.RegexValidator(re.compile('^\\d+(?:\\d+)*\\Z'), code='invalid', message=None), django.core.validators.MinLengthValidator(3)], verbose_name='shipping neighborhood')), + ('shipping_hamlet', models.CharField(max_length=3, validators=[django.core.validators.RegexValidator(re.compile('^\\d+(?:\\d+)*\\Z'), code='invalid', message=None), django.core.validators.MinLengthValidator(3)], verbose_name='shipping hamlet')), + ('shipping_urban_village', models.CharField(max_length=100, verbose_name='shipping urban village')), + ('shipping_sub_district', models.CharField(max_length=100, verbose_name='shipping sub-district')), + ('shipping_costs', models.DecimalField(decimal_places=2, max_digits=12, validators=[django.core.validators.MinValueValidator(Decimal('0'))], verbose_name='shipping costs')), + ('payment_method', models.CharField(choices=[('TRF', 'Transfer'), ('COD', 'Cash on delivery')], max_length=3, verbose_name='payment method')), + ('donation', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=12, validators=[django.core.validators.MinValueValidator(Decimal('0'))], verbose_name='donation')), + ('transaction_status', models.CharField(choices=[('001', 'Waiting for proof of payment'), ('002', 'Waiting for seller confirmation'), ('003', 'In process'), ('004', 'Being shipped'), ('005', 'Completed'), ('006', 'Canceled'), ('007', 'Failed')], max_length=3, verbose_name='transaction status')), + ('proof_of_payment', models.ImageField(blank=True, null=True, upload_to='uploads/transactions/%Y/%m/%d/', verbose_name='proof of payment')), + ('user_bank_account_name', models.CharField(blank=True, max_length=200, null=True, verbose_name='user bank account name')), + ('user_bank_account_number', models.CharField(blank=True, max_length=100, null=True, verbose_name='user bank account number')), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='created at')), + ('updated_at', models.DateTimeField(auto_now=True, db_index=True, verbose_name='updated at')), + ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transactions', to=settings.AUTH_USER_MODEL, verbose_name='user')), + ], + options={ + 'verbose_name': 'transaction', + 'verbose_name_plural': 'transactions', + 'ordering': ['-updated_at', '-created_at'], + }, + ), + migrations.CreateModel( + name='TransactionItem', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='ID')), + ('product_name', models.CharField(max_length=200, verbose_name='product name')), + ('product_price', models.DecimalField(decimal_places=2, max_digits=12, validators=[django.core.validators.MinValueValidator(Decimal('0.01'))], verbose_name='product price')), + ('quantity', models.PositiveIntegerField(verbose_name='quantity')), + ('product', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transaction_items', to='api.Product', verbose_name='product')), + ('transaction', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transaction_items', to='api.Transaction', verbose_name='transaction')), + ], + options={ + 'verbose_name': 'transaction item', + 'verbose_name_plural': 'transaction items', + 'ordering': ['transaction'], + }, + ), + migrations.CreateModel( + name='Subcategory', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='ID')), + ('name', django.contrib.postgres.fields.citext.CICharField(max_length=50, unique=True, 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='ShoppingCart', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='ID')), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='user')), + ], + options={ + 'verbose_name': 'shopping cart', + 'verbose_name_plural': 'shopping carts', + 'ordering': ['user'], + }, + ), + migrations.AddField( + model_name='product', + name='subcategory', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='products', to='api.Subcategory', verbose_name='subcategory'), + ), + migrations.CreateModel( + name='CartItem', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='ID')), + ('quantity', models.PositiveIntegerField(default=0, verbose_name='quantity')), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cart_items', to='api.Product', verbose_name='product')), + ('shopping_cart', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cart_items', to='api.ShoppingCart', verbose_name='shopping cart')), + ], + options={ + 'verbose_name': 'cart item', + 'verbose_name_plural': 'cart items', + 'ordering': ['shopping_cart', 'product'], + 'unique_together': {('shopping_cart', 'product')}, + }, + ), ] diff --git a/api/migrations/0002_auto_20200419_0448.py b/api/migrations/0002_auto_20200419_0448.py new file mode 100644 index 0000000..7bd30cf --- /dev/null +++ b/api/migrations/0002_auto_20200419_0448.py @@ -0,0 +1,51 @@ +# Generated by Django 3.0.3 on 2020-04-18 21:48 + +import api.utils +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0001_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='appconfig', + options={'verbose_name': 'application configuration', 'verbose_name_plural': 'application configurations'}, + ), + migrations.AlterModelOptions( + name='bankaccountconfig', + options={'verbose_name': 'bank account configuration', 'verbose_name_plural': 'bank account configurations'}, + ), + migrations.AlterModelOptions( + name='shipmentconfig', + options={'verbose_name': 'shipment configuration', 'verbose_name_plural': 'shipment configurations'}, + ), + migrations.AlterField( + model_name='category', + name='image', + field=models.ImageField(blank=True, null=True, upload_to=api.utils.get_upload_file_path, verbose_name='image'), + ), + migrations.AlterField( + model_name='product', + name='image', + field=models.ImageField(blank=True, null=True, upload_to=api.utils.get_upload_file_path, verbose_name='image'), + ), + migrations.AlterField( + model_name='program', + name='poster_image', + field=models.ImageField(blank=True, null=True, upload_to=api.utils.get_upload_file_path, verbose_name='poster image'), + ), + migrations.AlterField( + model_name='subcategory', + name='image', + field=models.ImageField(blank=True, null=True, upload_to=api.utils.get_upload_file_path, verbose_name='image'), + ), + migrations.AlterField( + model_name='user', + name='profile_picture', + field=models.ImageField(blank=True, null=True, upload_to=api.utils.get_upload_file_path, verbose_name='profile picture'), + ), + ] diff --git a/api/migrations/0002_config.py b/api/migrations/0002_config.py deleted file mode 100644 index 2e0db98..0000000 --- a/api/migrations/0002_config.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 3.0.3 on 2020-03-04 07:45 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0001_initial'), - ] - - operations = [ - migrations.CreateModel( - name='Config', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('send_sms', models.BooleanField(default=False, verbose_name='send SMS')), - ], - options={ - 'verbose_name': 'config', - 'verbose_name_plural': 'configs', - }, - ), - ] diff --git a/api/migrations/0003_auto_20200419_1707.py b/api/migrations/0003_auto_20200419_1707.py new file mode 100644 index 0000000..d97ef2a --- /dev/null +++ b/api/migrations/0003_auto_20200419_1707.py @@ -0,0 +1,19 @@ +# Generated by Django 3.0.3 on 2020-04-19 10:07 + +import api.utils +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0002_auto_20200419_0448'), + ] + + operations = [ + migrations.AlterField( + model_name='transaction', + name='proof_of_payment', + field=models.ImageField(blank=True, null=True, upload_to=api.utils.get_upload_file_path, verbose_name='proof of payment'), + ), + ] diff --git a/api/migrations/0003_program.py b/api/migrations/0003_program.py deleted file mode 100644 index 7b4bd19..0000000 --- a/api/migrations/0003_program.py +++ /dev/null @@ -1,32 +0,0 @@ -# Generated by Django 3.0.3 on 2020-03-09 12:53 - -from django.db import migrations, models -import uuid - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0002_config'), - ] - - operations = [ - migrations.CreateModel( - name='Program', - 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')), - ('start_date_time', models.DateTimeField(blank=True, db_index=True, null=True, verbose_name='start date and time')), - ('end_date_time', models.DateTimeField(blank=True, null=True, verbose_name='end date and time')), - ('location', models.CharField(blank=True, max_length=200, null=True, verbose_name='location')), - ('speaker', models.CharField(blank=True, max_length=200, null=True, verbose_name='speaker')), - ('poster_image', models.ImageField(blank=True, null=True, upload_to='uploads/programs/', verbose_name='poster image')), - ], - options={ - 'verbose_name': 'program', - 'verbose_name_plural': 'programs', - 'ordering': ['start_date_time', 'name'], - }, - ), - ] diff --git a/api/migrations/0004_auto_20200309_1953.py b/api/migrations/0004_auto_20200309_1953.py deleted file mode 100644 index 157fd5e..0000000 --- a/api/migrations/0004_auto_20200309_1953.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.0.3 on 2020-03-09 12:53 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0003_program'), - ] - - operations = [ - migrations.AlterField( - model_name='user', - name='profile_picture', - field=models.ImageField(blank=True, null=True, upload_to='uploads/users/', verbose_name='profile picture'), - ), - ] diff --git a/api/migrations/0004_auto_20200420_1446.py b/api/migrations/0004_auto_20200420_1446.py new file mode 100644 index 0000000..d96549f --- /dev/null +++ b/api/migrations/0004_auto_20200420_1446.py @@ -0,0 +1,49 @@ +# Generated by Django 3.0.3 on 2020-04-20 07:46 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0003_auto_20200419_1707'), + ] + + operations = [ + migrations.AlterModelOptions( + name='cartitem', + options={'ordering': ['shopping_cart', 'product', 'id'], 'verbose_name': 'cart item', 'verbose_name_plural': 'cart items'}, + ), + migrations.AlterModelOptions( + name='category', + options={'ordering': ['name', 'id'], 'verbose_name': 'category', 'verbose_name_plural': 'categories'}, + ), + migrations.AlterModelOptions( + name='product', + options={'ordering': ['subcategory', 'name', 'code', 'id'], 'verbose_name': 'product', 'verbose_name_plural': 'products'}, + ), + migrations.AlterModelOptions( + name='program', + options={'ordering': ['-start_date_time', '-end_date_time', 'name', 'code', 'id'], 'verbose_name': 'program', 'verbose_name_plural': 'programs'}, + ), + migrations.AlterModelOptions( + name='shoppingcart', + options={'ordering': ['user', 'id'], 'verbose_name': 'shopping cart', 'verbose_name_plural': 'shopping carts'}, + ), + migrations.AlterModelOptions( + name='subcategory', + options={'ordering': ['category', 'name', 'id'], 'verbose_name': 'subcategory', 'verbose_name_plural': 'subcategories'}, + ), + migrations.AlterModelOptions( + name='transaction', + options={'ordering': ['-updated_at', '-created_at', 'transaction_number', 'id'], 'verbose_name': 'transaction', 'verbose_name_plural': 'transactions'}, + ), + migrations.AlterModelOptions( + name='transactionitem', + options={'ordering': ['transaction', 'product', 'id'], 'verbose_name': 'transaction item', 'verbose_name_plural': 'transaction items'}, + ), + migrations.AlterModelOptions( + name='user', + options={'ordering': ['username', 'id'], 'verbose_name': 'user', 'verbose_name_plural': 'users'}, + ), + ] diff --git a/api/migrations/0005_category_product_subcategory.py b/api/migrations/0005_category_product_subcategory.py deleted file mode 100644 index c4bbd25..0000000 --- a/api/migrations/0005_category_product_subcategory.py +++ /dev/null @@ -1,61 +0,0 @@ -# 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/migrations/0006_auto_20200316_2058.py b/api/migrations/0006_auto_20200316_2058.py deleted file mode 100644 index 1f6449f..0000000 --- a/api/migrations/0006_auto_20200316_2058.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 3.0.3 on 2020-03-16 13:58 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0005_category_product_subcategory'), - ] - - operations = [ - migrations.AlterField( - model_name='product', - name='subcategory', - field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='products', to='api.Subcategory', verbose_name='subcategory'), - ), - ] diff --git a/api/migrations/0007_auto_20200410_1629.py b/api/migrations/0007_auto_20200410_1629.py deleted file mode 100644 index c4e60b2..0000000 --- a/api/migrations/0007_auto_20200410_1629.py +++ /dev/null @@ -1,37 +0,0 @@ -# Generated by Django 3.0.3 on 2020-04-10 09:29 - -import api.utils -import django.contrib.postgres.fields.citext -from django.contrib.postgres import operations as postgres_operations -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0006_auto_20200316_2058'), - ] - - operations = [ - postgres_operations.CITextExtension(), - migrations.AddField( - model_name='product', - name='code', - field=models.CharField(default=api.utils.generate_code, max_length=6, verbose_name='code'), - ), - migrations.AddField( - model_name='program', - name='code', - field=models.CharField(default=api.utils.generate_code, max_length=6, verbose_name='code'), - ), - migrations.AlterField( - model_name='category', - name='name', - field=django.contrib.postgres.fields.citext.CICharField(max_length=50, verbose_name='name'), - ), - migrations.AlterField( - model_name='subcategory', - name='name', - field=django.contrib.postgres.fields.citext.CICharField(max_length=50, verbose_name='name'), - ), - ] diff --git a/api/migrations/0008_auto_20200410_1638.py b/api/migrations/0008_auto_20200410_1638.py deleted file mode 100644 index 56d3578..0000000 --- a/api/migrations/0008_auto_20200410_1638.py +++ /dev/null @@ -1,35 +0,0 @@ -# Generated by Django 3.0.3 on 2020-04-10 09:38 - -import api.utils -import django.contrib.postgres.fields.citext -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0007_auto_20200410_1629'), - ] - - operations = [ - migrations.AlterField( - model_name='category', - name='name', - field=django.contrib.postgres.fields.citext.CICharField(max_length=50, unique=True, verbose_name='name'), - ), - migrations.AlterField( - model_name='product', - name='code', - field=models.CharField(default=api.utils.generate_code, max_length=6, unique=True, verbose_name='code'), - ), - migrations.AlterField( - model_name='program', - name='code', - field=models.CharField(default=api.utils.generate_code, max_length=6, unique=True, verbose_name='code'), - ), - migrations.AlterField( - model_name='subcategory', - name='name', - field=django.contrib.postgres.fields.citext.CICharField(max_length=50, unique=True, verbose_name='name'), - ), - ] diff --git a/api/migrations/0009_auto_20200411_1153.py b/api/migrations/0009_auto_20200411_1153.py deleted file mode 100644 index d40f9a4..0000000 --- a/api/migrations/0009_auto_20200411_1153.py +++ /dev/null @@ -1,113 +0,0 @@ -# Generated by Django 3.0.3 on 2020-04-11 04:53 - -import api.utils -from decimal import Decimal -from django.conf import settings -import django.core.validators -from django.db import migrations, models -import django.db.models.deletion -import phonenumber_field.modelfields -import re -import uuid - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0008_auto_20200410_1638'), - ] - - operations = [ - migrations.CreateModel( - name='ShipmentConfig', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('hamlet', models.CharField(max_length=3, validators=[django.core.validators.RegexValidator(re.compile('^\\d+(?:\\d+)*\\Z'), code='invalid', message=None), django.core.validators.MinLengthValidator(3)], verbose_name='hamlet')), - ('urban_village', models.CharField(max_length=100, verbose_name='urban village')), - ('sub_district', models.CharField(max_length=100, verbose_name='sub-district')), - ('same_hamlet_costs', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=12, validators=[django.core.validators.MinValueValidator(Decimal('0'))], verbose_name='shipping costs if the hamlet, urban village, and sub-district are the same as seller')), - ('same_urban_village_different_hamlet_costs', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=12, validators=[django.core.validators.MinValueValidator(Decimal('0'))], verbose_name='shipping costs if the urban village and sub-district are the same as seller')), - ('same_sub_district_different_urban_village_costs', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=12, validators=[django.core.validators.MinValueValidator(Decimal('0'))], verbose_name='shipping costs if the sub-district is the same as seller')), - ], - options={ - 'abstract': False, - }, - ), - migrations.CreateModel( - name='Transaction', - fields=[ - ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='ID')), - ('transaction_number', models.CharField(default=api.utils.generate_transaction_number, max_length=8, unique=True, verbose_name='transaction number')), - ('user_full_name', models.CharField(max_length=200, verbose_name='user full name')), - ('user_phone_number', phonenumber_field.modelfields.PhoneNumberField(max_length=128, region=None, verbose_name='user phone number')), - ('shipping_address', models.CharField(max_length=200, verbose_name='shipping address')), - ('shipping_neighborhood', models.CharField(max_length=3, validators=[django.core.validators.RegexValidator(re.compile('^\\d+(?:\\d+)*\\Z'), code='invalid', message=None), django.core.validators.MinLengthValidator(3)], verbose_name='shipping neighborhood')), - ('shipping_hamlet', models.CharField(max_length=3, validators=[django.core.validators.RegexValidator(re.compile('^\\d+(?:\\d+)*\\Z'), code='invalid', message=None), django.core.validators.MinLengthValidator(3)], verbose_name='shipping hamlet')), - ('shipping_urban_village', models.CharField(max_length=100, verbose_name='shipping urban village')), - ('shipping_sub_district', models.CharField(max_length=100, verbose_name='shipping sub-district')), - ('shipping_costs', models.DecimalField(decimal_places=2, max_digits=12, validators=[django.core.validators.MinValueValidator(Decimal('0'))], verbose_name='shipping costs')), - ('payment_method', models.CharField(choices=[('TRF', 'Transfer'), ('COD', 'Cash on delivery')], max_length=3, verbose_name='payment method')), - ('donation', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=12, validators=[django.core.validators.MinValueValidator(Decimal('0'))], verbose_name='donation')), - ('transaction_status', models.CharField(choices=[('001', 'Waiting for proof of payment'), ('002', 'Waiting for seller confirmation'), ('003', 'In process'), ('004', 'Being shipped'), ('005', 'Completed'), ('006', 'Canceled')], max_length=3, verbose_name='transaction status')), - ('proof_of_payment', models.ImageField(blank=True, null=True, upload_to='uploads/transactions/%Y/%m/%d/', verbose_name='proof of payment')), - ('created_at', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='created at')), - ('updated_at', models.DateTimeField(auto_now=True, db_index=True, verbose_name='updated at')), - ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transactions', to=settings.AUTH_USER_MODEL, verbose_name='user')), - ], - options={ - 'verbose_name': 'transaction', - 'verbose_name_plural': 'transactions', - 'ordering': ['-updated_at', '-created_at'], - }, - ), - migrations.RenameModel( - old_name='Config', - new_name='AppConfig', - ), - migrations.AlterModelOptions( - name='appconfig', - options={'verbose_name': 'application config', 'verbose_name_plural': 'application configs'}, - ), - migrations.CreateModel( - name='TransactionItem', - fields=[ - ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='ID')), - ('product_name', models.CharField(max_length=200, verbose_name='product name')), - ('product_price', models.DecimalField(decimal_places=2, max_digits=12, validators=[django.core.validators.MinValueValidator(Decimal('0.01'))], verbose_name='product price')), - ('product', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transaction_items', to='api.Product', verbose_name='product')), - ('transaction', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transaction_items', to='api.Transaction', verbose_name='transaction')), - ], - options={ - 'verbose_name': 'transaction item', - 'verbose_name_plural': 'transaction items', - 'ordering': ['transaction'], - }, - ), - migrations.CreateModel( - name='ShoppingCart', - fields=[ - ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='ID')), - ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='user')), - ], - options={ - 'verbose_name': 'shopping cart', - 'verbose_name_plural': 'shopping carts', - 'ordering': ['user'], - }, - ), - migrations.CreateModel( - name='CartItem', - fields=[ - ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='ID')), - ('quantity', models.PositiveIntegerField(default=0, verbose_name='quantity')), - ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cart_items', to='api.Product', verbose_name='product')), - ('shopping_cart', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cart_items', to='api.ShoppingCart', verbose_name='shopping cart')), - ], - options={ - 'verbose_name': 'cart item', - 'verbose_name_plural': 'cart items', - 'ordering': ['shopping_cart', 'product'], - 'unique_together': {('shopping_cart', 'product')}, - }, - ), - ] diff --git a/api/migrations/0010_auto_20200411_1311.py b/api/migrations/0010_auto_20200411_1311.py deleted file mode 100644 index f8f918e..0000000 --- a/api/migrations/0010_auto_20200411_1311.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.0.3 on 2020-04-11 06:11 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0009_auto_20200411_1153'), - ] - - operations = [ - migrations.AlterField( - model_name='transaction', - name='transaction_status', - field=models.CharField(choices=[('001', 'Waiting for proof of payment'), ('002', 'Waiting for seller confirmation'), ('003', 'In process'), ('004', 'Being shipped'), ('005', 'Completed'), ('006', 'Canceled'), ('007', 'Failed')], max_length=3, verbose_name='transaction status'), - ), - ] diff --git a/api/migrations/0011_transactionitem_quantity.py b/api/migrations/0011_transactionitem_quantity.py deleted file mode 100644 index 68e9532..0000000 --- a/api/migrations/0011_transactionitem_quantity.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.0.3 on 2020-04-11 06:58 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0010_auto_20200411_1311'), - ] - - operations = [ - migrations.AddField( - model_name='transactionitem', - name='quantity', - field=models.PositiveIntegerField(default=0, verbose_name='quantity'), - ), - ] diff --git a/api/migrations/0012_auto_20200411_1455.py b/api/migrations/0012_auto_20200411_1455.py deleted file mode 100644 index c3b4cdb..0000000 --- a/api/migrations/0012_auto_20200411_1455.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.0.3 on 2020-04-11 07:55 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0011_transactionitem_quantity'), - ] - - operations = [ - migrations.AlterField( - model_name='transactionitem', - name='quantity', - field=models.PositiveIntegerField(verbose_name='quantity'), - ), - ] diff --git a/api/migrations/0013_bankaccountconfig.py b/api/migrations/0013_bankaccountconfig.py deleted file mode 100644 index d942f30..0000000 --- a/api/migrations/0013_bankaccountconfig.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 3.0.3 on 2020-04-14 07:23 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0012_auto_20200411_1455'), - ] - - operations = [ - migrations.CreateModel( - name='BankAccountConfig', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('bank_name', models.CharField(max_length=100, verbose_name='bank name')), - ('account_number', models.CharField(max_length=100, verbose_name='account number')), - ('account_owner', models.CharField(max_length=200, verbose_name='account owner')), - ], - options={ - 'abstract': False, - }, - ), - ] diff --git a/api/migrations/0014_auto_20200414_1506.py b/api/migrations/0014_auto_20200414_1506.py deleted file mode 100644 index 94e6bef..0000000 --- a/api/migrations/0014_auto_20200414_1506.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 3.0.3 on 2020-04-14 08:06 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0013_bankaccountconfig'), - ] - - operations = [ - migrations.AlterModelOptions( - name='bankaccountconfig', - options={'verbose_name': 'bank account config', 'verbose_name_plural': 'bank account configs'}, - ), - migrations.AlterModelOptions( - name='shipmentconfig', - options={'verbose_name': 'shipment config', 'verbose_name_plural': 'shipment configs'}, - ), - ] diff --git a/api/models.py b/api/models.py index d0e04dc..9a9c234 100644 --- a/api/models.py +++ b/api/models.py @@ -1,81 +1,51 @@ import decimal import uuid -from django import dispatch from django.contrib.auth import models as auth_models from django.contrib.postgres import fields from django.core import validators from django.db import models as db_models -from django.db.models import signals from django.utils.translation import gettext_lazy as _ from phonenumber_field import modelfields from solo import models as solo_models -from api import constants, utils as api_utils -from home_industry import utils as home_industry_utils +from api import constants, utils -class AppConfig(solo_models.SingletonModel): - send_sms = db_models.BooleanField(default=False, verbose_name=_('send SMS')) - - class Meta: - verbose_name = _('application config') - verbose_name_plural = _('application configs') - - -class ShipmentConfig(solo_models.SingletonModel): +class User(auth_models.AbstractUser): + id = db_models.UUIDField(default=uuid.uuid4, primary_key=True, verbose_name=_('ID')) + first_name = None + last_name = None + full_name = db_models.CharField(max_length=200, verbose_name=_('full name')) + phone_number = modelfields.PhoneNumberField(unique=True, verbose_name=_('phone number')) + address = db_models.CharField(max_length=200, verbose_name=_('address')) + neighborhood = db_models.CharField( + max_length=3, + validators=[validators.int_list_validator(sep=''), validators.MinLengthValidator(3)], + verbose_name=_('neighborhood') + ) hamlet = db_models.CharField( max_length=3, validators=[validators.int_list_validator(sep=''), validators.MinLengthValidator(3)], verbose_name=_('hamlet') ) - urban_village = db_models.CharField( - max_length=100, - verbose_name=_('urban village') - ) - sub_district = db_models.CharField( - max_length=100, - verbose_name=_('sub-district') - ) - same_hamlet_costs = db_models.DecimalField( - decimal_places=2, - default=decimal.Decimal('0.00'), - max_digits=12, - validators=[validators.MinValueValidator(decimal.Decimal('0'))], - verbose_name=_( - 'shipping costs if the hamlet, urban village, and sub-district are the same as seller' - ) - ) - same_urban_village_different_hamlet_costs = db_models.DecimalField( - decimal_places=2, - default=decimal.Decimal('0.00'), - max_digits=12, - validators=[validators.MinValueValidator(decimal.Decimal('0'))], - verbose_name=_( - 'shipping costs if the urban village and sub-district are the same as seller' - ) - ) - same_sub_district_different_urban_village_costs = db_models.DecimalField( - decimal_places=2, - default=decimal.Decimal('0.00'), - max_digits=12, - validators=[validators.MinValueValidator(decimal.Decimal('0'))], - verbose_name=_('shipping costs if the sub-district is the same as seller') + urban_village = db_models.CharField(max_length=100, verbose_name=_('urban village')) + sub_district = db_models.CharField(max_length=100, verbose_name=_('sub-district')) + profile_picture = db_models.ImageField( + blank=True, + null=True, + upload_to=utils.get_upload_file_path, + verbose_name=_('profile picture') ) + otp = db_models.CharField(blank=True, max_length=6, null=True, verbose_name=_('OTP')) class Meta: - verbose_name = _('shipment config') - verbose_name_plural = _('shipment configs') - - -class BankAccountConfig(solo_models.SingletonModel): - bank_name = db_models.CharField(max_length=100, verbose_name=_('bank name')) - account_number = db_models.CharField(max_length=100, verbose_name=_('account number')) - account_owner = db_models.CharField(max_length=200, verbose_name=_('account owner')) + ordering = ['username', 'id'] + verbose_name = _('user') + verbose_name_plural = _('users') - class Meta: - verbose_name = _('bank account config') - verbose_name_plural = _('bank account configs') + def __str__(self): + return self.username class Category(db_models.Model): @@ -84,12 +54,12 @@ class Category(db_models.Model): image = db_models.ImageField( blank=True, null=True, - upload_to='uploads/categories/', + upload_to=utils.get_upload_file_path, verbose_name=_('image') ) class Meta: - ordering = ['name'] + ordering = ['name', 'id'] verbose_name = _('category') verbose_name_plural = _('categories') @@ -109,12 +79,12 @@ class Subcategory(db_models.Model): image = db_models.ImageField( blank=True, null=True, - upload_to='uploads/subcategories/', + upload_to=utils.get_upload_file_path, verbose_name=_('image') ) class Meta: - ordering = ['name'] + ordering = ['category', 'name', 'id'] verbose_name = _('subcategory') verbose_name_plural = _('subcategories') @@ -125,7 +95,7 @@ class Subcategory(db_models.Model): class Product(db_models.Model): id = db_models.UUIDField(default=uuid.uuid4, primary_key=True, verbose_name=_('ID')) code = db_models.CharField( - default=api_utils.generate_code, + default=utils.generate_code, max_length=6, unique=True, verbose_name=_('code') @@ -148,12 +118,12 @@ class Product(db_models.Model): image = db_models.ImageField( blank=True, null=True, - upload_to='uploads/products/', + upload_to=utils.get_upload_file_path, verbose_name=_('image') ) class Meta: - ordering = ['subcategory', 'name'] + ordering = ['subcategory', 'name', 'code', 'id'] verbose_name = _('product') verbose_name_plural = _('products') @@ -166,7 +136,7 @@ class ShoppingCart(db_models.Model): user = db_models.OneToOneField('api.User', on_delete=db_models.CASCADE, verbose_name=_('user')) class Meta: - ordering = ['user'] + ordering = ['user', 'id'] verbose_name = _('shopping cart') verbose_name_plural = _('shopping carts') @@ -191,7 +161,7 @@ class CartItem(db_models.Model): quantity = db_models.PositiveIntegerField(default=0, verbose_name=_('quantity')) class Meta: - ordering = ['shopping_cart', 'product'] + ordering = ['shopping_cart', 'product', 'id'] unique_together = ['shopping_cart', 'product'] verbose_name = _('cart item') verbose_name_plural = _('cart items') @@ -203,7 +173,7 @@ class CartItem(db_models.Model): class Transaction(db_models.Model): id = db_models.UUIDField(default=uuid.uuid4, primary_key=True, verbose_name=_('ID')) transaction_number = db_models.CharField( - default=api_utils.generate_transaction_number, + default=utils.generate_transaction_number, max_length=8, unique=True, verbose_name=_('transaction number') @@ -249,7 +219,7 @@ class Transaction(db_models.Model): ) donation = db_models.DecimalField( decimal_places=2, - default=decimal.Decimal('0.00'), + default=decimal.Decimal('0'), max_digits=12, validators=[validators.MinValueValidator(decimal.Decimal('0'))], verbose_name=_('donation') @@ -262,9 +232,21 @@ class Transaction(db_models.Model): proof_of_payment = db_models.ImageField( blank=True, null=True, - upload_to='uploads/transactions/%Y/%m/%d/', + upload_to=utils.get_upload_file_path, verbose_name=_('proof of payment') ) + user_bank_account_name = db_models.CharField( + blank=True, + max_length=200, + null=True, + verbose_name=_('user bank account name') + ) + user_bank_account_number = db_models.CharField( + blank=True, + max_length=100, + null=True, + verbose_name=_('user bank account number') + ) created_at = db_models.DateTimeField( auto_now_add=True, db_index=True, @@ -273,7 +255,7 @@ class Transaction(db_models.Model): updated_at = db_models.DateTimeField(auto_now=True, db_index=True, verbose_name=_('updated at')) class Meta: - ordering = ['-updated_at', '-created_at'] + ordering = ['-updated_at', '-created_at', 'transaction_number', 'id'] verbose_name = _('transaction') verbose_name_plural = _('transactions') @@ -287,7 +269,7 @@ class Transaction(db_models.Model): self.shipping_urban_village = self.user.urban_village self.shipping_sub_district = self.user.sub_district self.transaction_status = '001' if self.payment_method == 'TRF' else '002' - self.shipping_costs = home_industry_utils.get_shipping_costs(self.user) + self.shipping_costs = utils.get_shipping_costs(self.user) super().save(*args, **kwargs) def __str__(self): @@ -319,7 +301,7 @@ class TransactionItem(db_models.Model): quantity = db_models.PositiveIntegerField(verbose_name=_('quantity')) class Meta: - ordering = ['transaction'] + ordering = ['transaction', 'product', 'id'] verbose_name = _('transaction item') verbose_name_plural = _('transaction items') @@ -336,7 +318,7 @@ class TransactionItem(db_models.Model): class Program(db_models.Model): id = db_models.UUIDField(default=uuid.uuid4, primary_key=True, verbose_name=_('ID')) code = db_models.CharField( - default=api_utils.generate_code, + default=utils.generate_code, max_length=6, unique=True, verbose_name=_('code') @@ -364,12 +346,12 @@ class Program(db_models.Model): poster_image = db_models.ImageField( blank=True, null=True, - upload_to='uploads/programs/', + upload_to=utils.get_upload_file_path, verbose_name=_('poster image') ) class Meta: - ordering = ['start_date_time', 'name'] + ordering = ['-start_date_time', '-end_date_time', 'name', 'code', 'id'] verbose_name = _('program') verbose_name_plural = _('programs') @@ -377,43 +359,73 @@ class Program(db_models.Model): return self.code -class User(auth_models.AbstractUser): - id = db_models.UUIDField(default=uuid.uuid4, primary_key=True, verbose_name=_('ID')) - first_name = None - last_name = None - full_name = db_models.CharField(max_length=200, verbose_name=_('full name')) - phone_number = modelfields.PhoneNumberField(unique=True, verbose_name=_('phone number')) - address = db_models.CharField(max_length=200, verbose_name=_('address')) - neighborhood = db_models.CharField( - max_length=3, - validators=[validators.int_list_validator(sep=''), validators.MinLengthValidator(3)], - verbose_name=_('neighborhood') - ) +class AppConfig(solo_models.SingletonModel): + send_sms = db_models.BooleanField(default=False, verbose_name=_('send SMS')) + + class Meta: + verbose_name = _('application configuration') + verbose_name_plural = _('application configurations') + + def __str__(self): + return 'Application Configuration' + + +class BankAccountConfig(solo_models.SingletonModel): + bank_name = db_models.CharField(max_length=100, verbose_name=_('bank name')) + account_number = db_models.CharField(max_length=100, verbose_name=_('account number')) + account_owner = db_models.CharField(max_length=200, verbose_name=_('account owner')) + + class Meta: + verbose_name = _('bank account configuration') + verbose_name_plural = _('bank account configurations') + + def __str__(self): + return 'Bank Account Configuration' + + +class ShipmentConfig(solo_models.SingletonModel): hamlet = db_models.CharField( max_length=3, validators=[validators.int_list_validator(sep=''), validators.MinLengthValidator(3)], verbose_name=_('hamlet') ) - urban_village = db_models.CharField(max_length=100, verbose_name=_('urban village')) - sub_district = db_models.CharField(max_length=100, verbose_name=_('sub-district')) - profile_picture = db_models.ImageField( - blank=True, - null=True, - upload_to='uploads/users/', - verbose_name=_('profile picture') + urban_village = db_models.CharField( + max_length=100, + verbose_name=_('urban village') + ) + sub_district = db_models.CharField( + max_length=100, + verbose_name=_('sub-district') + ) + same_hamlet_costs = db_models.DecimalField( + decimal_places=2, + default=decimal.Decimal('0'), + max_digits=12, + validators=[validators.MinValueValidator(decimal.Decimal('0'))], + verbose_name=_( + 'shipping costs if the hamlet, urban village, and sub-district are the same as seller' + ) + ) + same_urban_village_different_hamlet_costs = db_models.DecimalField( + decimal_places=2, + default=decimal.Decimal('0'), + max_digits=12, + validators=[validators.MinValueValidator(decimal.Decimal('0'))], + verbose_name=_( + 'shipping costs if the urban village and sub-district are the same as seller' + ) + ) + same_sub_district_different_urban_village_costs = db_models.DecimalField( + decimal_places=2, + default=decimal.Decimal('0'), + max_digits=12, + validators=[validators.MinValueValidator(decimal.Decimal('0'))], + verbose_name=_('shipping costs if the sub-district is the same as seller') ) - otp = db_models.CharField(blank=True, max_length=6, null=True, verbose_name=_('OTP')) class Meta: - ordering = ['username'] - verbose_name = _('user') - verbose_name_plural = _('users') + verbose_name = _('shipment configuration') + verbose_name_plural = _('shipment configurations') def __str__(self): - return self.username - - -@dispatch.receiver(signals.post_save, sender=User) -def create_shopping_cart(sender, created, instance, **_kwargs): # pylint: disable=unused-argument - if created: - ShoppingCart.objects.create(user=instance) + return 'Shipment Configuration' diff --git a/api/paginations.py b/api/paginations.py index dad60b6..5f6b377 100644 --- a/api/paginations.py +++ b/api/paginations.py @@ -1,6 +1,12 @@ from rest_framework import pagination +class LargeResultsSetPagination(pagination.PageNumberPagination): + max_page_size = 10000 + page_size = 1000 + page_size_query_param = 'page_size' + + class SmallResultsSetPagination(pagination.PageNumberPagination): max_page_size = 100 page_size = 10 @@ -11,9 +17,3 @@ class StandardResultsSetPagination(pagination.PageNumberPagination): max_page_size = 1000 page_size = 100 page_size_query_param = 'page_size' - - -class LargeResultsSetPagination(pagination.PageNumberPagination): - max_page_size = 10000 - page_size = 1000 - page_size_query_param = 'page_size' diff --git a/api/permissions.py b/api/permissions.py index d1a5e98..0c9e897 100644 --- a/api/permissions.py +++ b/api/permissions.py @@ -1,6 +1,22 @@ from rest_framework import permissions +class IsAdminUserOrOwner(permissions.BasePermission): + def has_object_permission(self, request, _view, obj): + return bool( + ((request.user) and (request.user.is_staff)) or + (obj.user == request.user) + ) + + +class IsAdminUserOrOwnerReadOnly(permissions.BasePermission): + def has_object_permission(self, request, _view, obj): + return bool( + ((request.user) and (request.user.is_staff)) or + ((obj.user == request.user) and (request.method in permissions.SAFE_METHODS)) + ) + + class IsAdminUserOrReadOnly(permissions.BasePermission): def has_permission(self, request, _view): return bool( diff --git a/api/schemas.py b/api/schemas.py new file mode 100644 index 0000000..c63c57a --- /dev/null +++ b/api/schemas.py @@ -0,0 +1,27 @@ +import coreapi +import coreschema +from rest_framework import schemas + + +class TransactionListViewSchema(schemas.AutoSchema): + def get_filter_fields(self, path, method): + filter_fields = super().get_filter_fields(path, method) + exclude = ['updated_at_date_range'] + for filter_field in filter_fields: + if filter_field.name in exclude: + filter_fields.remove(filter_field) + filter_fields += [ + coreapi.Field( + 'updated_at_date_range_after', + location='query', + required=False, + schema=coreschema.String() + ), + coreapi.Field( + 'updated_at_date_range_before', + location='query', + required=False, + schema=coreschema.String() + ), + ] + return filter_fields diff --git a/api/seeds.py b/api/seeds.py new file mode 100644 index 0000000..b437e6c --- /dev/null +++ b/api/seeds.py @@ -0,0 +1,65 @@ +SUPERUSER_DATA = { + 'username': 'admin', + 'email': 'admin@example.com', + 'password': 'admin', +} + +USER_DATA = { + 'username': 'dummyuser', + 'password': 'dummypassword', + 'full_name': 'Dummy Full Name', + 'phone_number': '+6285212345678', + 'address': 'Dummy Address', + 'neighborhood': '001', + 'hamlet': '001', + 'urban_village': 'Dummy Urban Village', + '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', + 'stock': 10, +} + +TRANSACTION_DATA = { + 'payment_method': 'TRF', + 'donation': '1000', +} + +TRANSACTION_ITEM_DATA = { + 'quantity': 1, +} + +PROGRAM_DATA = { + 'name': 'Dummy Program', + 'description': 'Dummy description.', + 'start_date_time': '2020-01-01T00:00:00+07:00', + 'end_date_time': '2020-02-02T00:00:00+07:00', + 'location': 'Dummy Location', + 'speaker': 'Dummy Speaker', +} + +BANK_ACCOUNT_CONFIG_DATA = { + 'bank_name': 'Dummy Bank Name', + 'account_number': '0123456789', + 'account_owner': 'Dummy Account Owner', +} + +SHIPMENT_CONFIG_DATA = { + 'hamlet': '001', + 'urban_village': 'Dummy Urban Village', + 'sub_district': 'Dummy Sub-District', + 'same_hamlet_costs': '1000', + 'same_urban_village_different_hamlet_costs': '2000', + 'same_sub_district_different_urban_village_costs': '3000', +} diff --git a/api/serializers.py b/api/serializers.py index 8b75dbd..4dbc6f5 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -1,10 +1,12 @@ +import decimal + from django.contrib.auth import password_validation from django.core import exceptions from django.utils.translation import gettext_lazy as _ from phonenumber_field import serializerfields from rest_framework import serializers -from api import models +from api import constants, models class PhoneNumberSerializer(serializers.Serializer): # pylint: disable=abstract-method @@ -15,6 +17,32 @@ class OTPSerializer(serializers.Serializer): # pylint: disable=abstract-method otp = serializers.CharField(max_length=6) +class CartUpdateSerializer(serializers.Serializer): # pylint: disable=abstract-method + product = serializers.UUIDField() + quantity = serializers.IntegerField(min_value=0) + + +class CartCheckoutSerializer(serializers.Serializer): # pylint: disable=abstract-method + payment_method = serializers.ChoiceField(choices=constants.PAYMENT_METHOD_CHOICES) + donation = serializers.DecimalField( + decimal_places=2, + default=decimal.Decimal('0'), + max_digits=12, + min_value=decimal.Decimal('0') + ) + + +class CartUploadPOPSerializer(serializers.Serializer): # pylint: disable=abstract-method + transaction = serializers.UUIDField() + proof_of_payment = serializers.ImageField() + user_bank_account_name = serializers.CharField(max_length=200) + user_bank_account_number = serializers.CharField(max_length=100) + + +class CartCompleteOrCancelTransactionSerializer(serializers.Serializer): # pylint: disable=abstract-method + transaction = serializers.UUIDField() + + class UserSerializer(serializers.ModelSerializer): class Meta: extra_kwargs = {'password': {'write_only': True}} @@ -104,6 +132,133 @@ class ProductSerializer(serializers.ModelSerializer): read_only_fields = ['id', 'code'] +class CartItemSerializer(serializers.ModelSerializer): + product = ProductSerializer(read_only=True) + + class Meta: + fields = ['id', 'product', 'quantity'] + model = models.CartItem + read_only_fields = ['id', 'quantity'] + + +class ShoppingCartSerializer(serializers.ModelSerializer): + user_username = serializers.ReadOnlyField(source='user.username') + cart_items = CartItemSerializer(many=True, read_only=True) + cart_item_subtotal = serializers.SerializerMethodField('get_cart_item_subtotal') + + class Meta: + fields = ['id', 'user', 'user_username', 'cart_items', 'cart_item_subtotal'] + model = models.ShoppingCart + read_only_fields = ['id', 'user'] + + def get_cart_item_subtotal(self, obj): # pylint: disable=no-self-use + cart_item_subtotal = sum( + cart_item.product.price * cart_item.quantity for cart_item in obj.cart_items.all() + ) + return str(cart_item_subtotal) + + +class TransactionItemSerializer(serializers.ModelSerializer): + product_code = serializers.ReadOnlyField(source='product.code') + + class Meta: + fields = [ + 'id', + 'product', + 'product_code', + 'product_name', + 'product_price', + 'quantity', + ] + model = models.TransactionItem + read_only_fields = [ + 'id', + 'product', + 'product_name', + 'product_price', + 'quantity', + ] + + +class TransactionSerializer(serializers.ModelSerializer): + user_username = serializers.ReadOnlyField(source='user.username') + transaction_items = TransactionItemSerializer(many=True, read_only=True) + transaction_item_subtotal = serializers.SerializerMethodField('get_transaction_item_subtotal') + readable_payment_method = serializers.SerializerMethodField('get_readable_payment_method') + readable_transaction_status = serializers.SerializerMethodField( + 'get_readable_transaction_status' + ) + subtotal = serializers.SerializerMethodField('get_subtotal') + + class Meta: + fields = [ + 'id', + 'transaction_number', + 'user', + 'user_username', + 'user_full_name', + 'user_phone_number', + 'shipping_address', + 'shipping_neighborhood', + 'shipping_hamlet', + 'shipping_urban_village', + 'shipping_sub_district', + 'transaction_items', + 'transaction_item_subtotal', + 'shipping_costs', + 'payment_method', + 'readable_payment_method', + 'donation', + 'transaction_status', + 'readable_transaction_status', + 'proof_of_payment', + 'user_bank_account_name', + 'user_bank_account_number', + 'created_at', + 'updated_at', + 'subtotal', + ] + model = models.Transaction + read_only_fields = [ + 'id', + 'transaction_number', + 'user', + 'user_full_name', + 'user_phone_number', + 'shipping_address', + 'shipping_neighborhood', + 'shipping_hamlet', + 'shipping_urban_village', + 'shipping_sub_district', + 'shipping_costs', + 'payment_method', + 'donation', + 'proof_of_payment', + 'user_bank_account_name', + 'user_bank_account_number', + 'created_at', + 'updated_at', + ] + + def get_transaction_item_subtotal(self, obj): # pylint: disable=no-self-use + transaction_item_subtotal = sum( + transaction_item.product_price * transaction_item.quantity + for transaction_item in obj.transaction_items.all() + ) + return str(transaction_item_subtotal) + + def get_readable_payment_method(self, obj): # pylint: disable=no-self-use + return obj.get_payment_method_display() + + def get_readable_transaction_status(self, obj): # pylint: disable=no-self-use + return obj.get_transaction_status_display() + + def get_subtotal(self, obj): + subtotal = (decimal.Decimal(self.get_transaction_item_subtotal(obj)) + obj.shipping_costs + + obj.donation) + return str(subtotal) + + class ProgramSerializer(serializers.ModelSerializer): class Meta: fields = [ @@ -139,6 +294,12 @@ class AppConfigSerializer(serializers.ModelSerializer): model = models.AppConfig +class BankAccountConfigSerializer(serializers.ModelSerializer): + class Meta: + fields = ['bank_name', 'account_number', 'account_owner'] + model = models.BankAccountConfig + + class ShipmentConfigSerializer(serializers.ModelSerializer): class Meta: fields = [ @@ -150,9 +311,3 @@ class ShipmentConfigSerializer(serializers.ModelSerializer): 'same_sub_district_different_urban_village_costs', ] model = models.ShipmentConfig - - -class BankAccountConfigSerializer(serializers.ModelSerializer): - class Meta: - fields = ['bank_name', 'account_number', 'account_owner'] - model = models.BankAccountConfig diff --git a/api/signals.py b/api/signals.py new file mode 100644 index 0000000..6ce525c --- /dev/null +++ b/api/signals.py @@ -0,0 +1,10 @@ +from django import dispatch +from django.db.models import signals + +from api import models + + +@dispatch.receiver(signals.post_save, sender=models.User) +def create_shopping_cart(sender, created, instance, **_kwargs): # pylint: disable=unused-argument + if created: + models.ShoppingCart.objects.create(user=instance) diff --git a/api/tests.py b/api/tests.py index e7c3905..47c30be 100644 --- a/api/tests.py +++ b/api/tests.py @@ -1,76 +1,27 @@ -from datetime import datetime, timedelta +import decimal +import tempfile from unittest import mock import jwt from django import conf, test as django_test, urls +from PIL import Image from rest_framework import exceptions, status, test as rest_framework_test -from api import models, utils, views - -SUPERUSER_DATA = { - 'username': 'admin', - 'email': 'admin@example.com', - 'password': 'admin', -} - -USER_DATA = { - 'username': 'dummyuser', - 'password': 'dummypassword', - 'full_name': 'Dummy User', - 'phone_number': '+6285212345678', - 'address': 'Jl. Dummy No.1', - 'neighborhood': '001', - 'hamlet': '001', - 'urban_village': 'Dummy Urban Village', - '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.', - 'start_date_time': '2020-01-01T00:00:00+07:00', - 'end_date_time': '2020-02-02T00:00:00+07:00', - 'location': 'Dummy Location', - 'speaker': 'Dummy Speaker', -} - -TRANSACTION_DATA = { - 'payment_method': 'TRF', -} - -TRANSACTION_ITEM_DATA = { - 'quantity': '1', -} - -SHIPMENT_CONFIG_DATA = { - 'hamlet': '001', - 'urban_village': 'Dummy Urban Village', - 'sub_district': 'Dummy Sub-District', - 'same_hamlet_costs': '1.00', - 'same_urban_village_different_hamlet_costs': '2.00', - 'same_sub_district_different_urban_village_costs': '3.00', -} - -BANK_ACCOUNT_CONFIG_DATA = { - 'bank_name': 'Dummy Bank Name', - 'account_number': '1234567890', - 'account_owner': 'Dummy Account Owner', -} +from api import models, seeds, utils + + +def create_tmp_image(): + image = Image.new('RGBA', size=(10, 10), color=(0, 0, 0)) + tmp_file = tempfile.NamedTemporaryFile(suffix='.png') + image.save(tmp_file) + return tmp_file + + +def create_user(user_data): + user = models.User.objects.create(**user_data) + user.set_password(user_data['password']) + user.save() + return user def get_http_authorization(username, password): @@ -81,23 +32,84 @@ def get_http_authorization(username, password): } url = urls.reverse('auth-cred-login') response = client.post(url, data, format='json') - return 'Token {}'.format(response.json()['token']) + return 'Token {}'.format(response.data['token']) + + +def request(method, url_name, data=None, format='json', http_authorization=None, url_args=None): # pylint: disable=redefined-builtin,too-many-arguments + client = rest_framework_test.APIClient() + response = None + url = urls.reverse(url_name, args=url_args) + if http_authorization is not None: + client.credentials(HTTP_AUTHORIZATION=http_authorization) + if method == 'GET': + response = client.get(url) + elif method == 'POST': + response = client.post(url, data, format=format) + elif method == 'PUT': + response = client.put(url, data, format=format) + elif method == 'PATCH': + response = client.patch(url, data, format=format) + elif method == 'DELETE': + response = client.delete(url) + return response class UtilsTest(django_test.TestCase): + def test_generate_bearer_token(self): + user = models.User.objects.create(**seeds.USER_DATA) + token = utils.generate_bearer_token(user) + decoded_jwt = jwt.decode(token, conf.settings.SECRET_KEY, algorithms=['HS256']) + self.assertTrue('exp' in decoded_jwt) + self.assertTrue('username' in decoded_jwt) + + def test_generate_code(self): + code = utils.generate_code() + self.assertEqual(len(code), 6) + def test_generate_otp(self): otp = utils.generate_otp() self.assertEqual(len(otp), 6) + def test_generate_transaction_number(self): + transaction_number = utils.generate_transaction_number() + self.assertEqual(len(transaction_number), 8) + def test_get_username_from_bearer_token_success(self): - encoded_jwt = jwt.encode( - {'exp': datetime.now() + timedelta(hours=1), 'username': USER_DATA['username']}, - conf.settings.SECRET_KEY, - algorithm='HS256' - ).decode('utf-8') - http_authorization = 'Bearer {}'.format(encoded_jwt) + user = models.User.objects.create(**seeds.USER_DATA) + token = utils.generate_bearer_token(user) + http_authorization = 'Bearer {}'.format(token) username = utils.get_username_from_bearer_token(http_authorization) - self.assertEqual(username, USER_DATA['username']) + self.assertEqual(username, seeds.USER_DATA['username']) + + def test_get_shipping_costs(self): + shipment_config = models.ShipmentConfig.objects.create(**seeds.SHIPMENT_CONFIG_DATA) + user = create_user(seeds.USER_DATA) + shipping_costs = utils.get_shipping_costs(user) + self.assertEqual(shipping_costs, decimal.Decimal(shipment_config.same_hamlet_costs)) + user.hamlet = '002' + user.save() + shipping_costs = utils.get_shipping_costs(user) + self.assertEqual( + shipping_costs, + decimal.Decimal(shipment_config.same_urban_village_different_hamlet_costs) + ) + user.urban_village = 'Another Urban Village' + user.save() + shipping_costs = utils.get_shipping_costs(user) + self.assertEqual( + shipping_costs, + decimal.Decimal(shipment_config.same_sub_district_different_urban_village_costs) + ) + user.sub_district = 'Another Sub-District' + user.save() + shipping_costs = utils.get_shipping_costs(user) + self.assertIsNone(shipping_costs) + + def test_get_upload_file_path(self): + instance = models.User.objects.create(**seeds.USER_DATA) + filename = 'dummy' + upload_file_path = utils.get_upload_file_path(instance, filename) + self.assertRegex(upload_file_path, r'^uploads/user/.+_dummy$') def test_get_username_from_bearer_token_fail(self): http_authorization = None @@ -110,7 +122,7 @@ class UtilsTest(django_test.TestCase): with self.assertRaises(exceptions.AuthenticationFailed): utils.get_username_from_bearer_token(http_authorization) encoded_jwt = jwt.encode( - {'username': USER_DATA['username']}, + {'username': seeds.USER_DATA['username']}, conf.settings.SECRET_KEY, algorithm='HS256' ).decode('utf-8') @@ -118,673 +130,1246 @@ class UtilsTest(django_test.TestCase): with self.assertRaises(exceptions.AuthenticationFailed): utils.get_username_from_bearer_token(http_authorization) + def test_map_choices(self): + choices = [ + ('1', 'Dummy description 1'), + ('2', 'Dummy description 2'), + ] + mapped_choices = utils.map_choices(choices) + self.assertIsInstance(mapped_choices, list) + self.assertEqual(len(mapped_choices), len(choices)) + class AuthTest(rest_framework_test.APITestCase): def setUp(self): - data = USER_DATA + data = seeds.USER_DATA url = urls.reverse('auth-register') self.client.post(url, data, format='json') - def test_auth_cred_login_serializer(self): - serializer = views.AuthCredLogin().get_serializer() - self.assertIsInstance(serializer, views.AuthCredLogin.serializer_class) - - def test_auth_otp_login_serializer(self): - serializer = views.AuthOTPLogin().get_serializer() - self.assertIsInstance(serializer, views.AuthOTPLogin.serializer_class) - - def test_auth_phone_number_login_serializer(self): - serializer = views.AuthPhoneNumberLogin().get_serializer() - self.assertIsInstance(serializer, views.AuthPhoneNumberLogin.serializer_class) - def test_user_authentication_with_username_password_success(self): data = { - 'username': USER_DATA['username'], - 'password': USER_DATA['password'], + 'username': seeds.USER_DATA['username'], + 'password': seeds.USER_DATA['password'], } - url = urls.reverse('auth-cred-login') - response = self.client.post(url, data, format='json') + response = request( + 'POST', + 'auth-cred-login', + data + ) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertIsNotNone(response.json().get('token')) + self.assertIsNotNone(response.data.get('token')) @mock.patch('home_industry.utils.send_sms', return_value=None) def test_user_authentication_with_phone_number_success(self, mock_send_sms): data = { - 'phone_number': USER_DATA['phone_number'], + 'phone_number': seeds.USER_DATA['phone_number'], } - url = urls.reverse('auth-phone-number-login') - response = self.client.post(url, data, format='json') + response = request( + 'POST', + 'auth-phone-number-login', + data + ) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertIsNotNone(response.json().get('token')) + self.assertIsNotNone(response.data.get('token')) self.assertEqual(mock_send_sms.call_count, 1) - user = models.User.objects.get(username=USER_DATA['username']) + user = models.User.objects.get(username=seeds.USER_DATA['username']) user.otp = '123456' user.save() - http_authorization = 'Bearer {}'.format(response.json()['token']) data = { 'otp': '123456', } - url = urls.reverse('auth-otp-login') - self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member - response = self.client.post(url, data, format='json') + response = request( + 'POST', + 'auth-otp-login', + data, + http_authorization='Bearer {}'.format(response.data['token']) + ) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertIsNotNone(response.json().get('token')) + self.assertIsNotNone(response.data.get('token')) @mock.patch('home_industry.utils.send_sms', return_value=None) def test_user_authentication_fail(self, mock_send_sms): data = { - 'phone_number': USER_DATA['phone_number'], + 'phone_number': seeds.USER_DATA['phone_number'], } - url = urls.reverse('auth-phone-number-login') - response = self.client.post(url, data, format='json') + response = request( + 'POST', + 'auth-phone-number-login', + data + ) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertIsNotNone(response.json().get('token')) + self.assertIsNotNone(response.data.get('token')) self.assertEqual(mock_send_sms.call_count, 1) - user = models.User.objects.get(username=USER_DATA['username']) + user = models.User.objects.get(username=seeds.USER_DATA['username']) user.otp = '123456' user.save() - http_authorization = 'Bearer {}'.format(response.json()['token']) data = { 'otp': '654321', } - url = urls.reverse('auth-otp-login') - self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member - response = self.client.post(url, data, format='json') + response = request( + 'POST', + 'auth-otp-login', + data, + http_authorization='Bearer {}'.format(response.data['token']) + ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) @mock.patch('home_industry.utils.send_sms', return_value=None) def test_resend_otp_success(self, mock_send_sms): data = { - 'phone_number': USER_DATA['phone_number'], + 'phone_number': seeds.USER_DATA['phone_number'], } - url = urls.reverse('auth-phone-number-login') - response = self.client.post(url, data, format='json') - http_authorization = 'Bearer {}'.format(response.json()['token']) - url = urls.reverse('auth-resend-otp') - self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member - response = self.client.post(url) + response = request( + 'POST', + 'auth-phone-number-login', + data + ) + response = request( + 'POST', + 'auth-resend-otp', + http_authorization='Bearer {}'.format(response.data['token']) + ) self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) self.assertEqual(mock_send_sms.call_count, 2) def test_resend_otp_fail(self): - url = urls.reverse('auth-resend-otp') - response = self.client.post(url) + response = request( + 'POST', + 'auth-resend-otp' + ) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) +class CartTest(rest_framework_test.APITestCase): + def setUp(self): + models.ShipmentConfig.objects.create(**seeds.SHIPMENT_CONFIG_DATA) + self.user = create_user(seeds.USER_DATA) + self.user_http_authorization = get_http_authorization( + seeds.USER_DATA['username'], + seeds.USER_DATA['password'] + ) + self.category = models.Category.objects.create(**seeds.CATEGORY_DATA) + self.subcategory = models.Subcategory.objects.create(**dict( + seeds.CATEGORY_DATA, + category=self.category + )) + self.product = models.Product.objects.create(**dict( + seeds.PRODUCT_DATA, + subcategory=self.subcategory + )) + self.shopping_cart = models.ShoppingCart.objects.get(user=self.user) + self.proof_of_payment_file = create_tmp_image() + + def test_cart_checkout_success(self): + data = { + 'product': self.product.id, + 'quantity': 1, + } + response = request( + 'POST', + 'cart-update', + data, + http_authorization=self.user_http_authorization + ) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(self.shopping_cart.cart_items.count(), 1) + response = request( + 'GET', + 'cart-overview', + http_authorization=self.user_http_authorization + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue('item_subtotal' in response.data) + self.assertTrue('shipping_costs' in response.data) + data = { + 'payment_method': 'TRF', + 'donation': '1000', + } + response = request( + 'POST', + 'cart-checkout', + data, + http_authorization=self.user_http_authorization + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(models.Transaction.objects.count(), 1) + + def test_cart_checkout_fail(self): + data = { + 'product': self.product.id, + 'quantity': 1, + } + response = request( + 'POST', + 'cart-update', + data, + http_authorization=self.user_http_authorization + ) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(self.shopping_cart.cart_items.count(), 1) + data = { + 'product': self.product.id, + 'quantity': 0, + } + response = request( + 'POST', + 'cart-update', + data, + http_authorization=self.user_http_authorization + ) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(self.shopping_cart.cart_items.count(), 0) + self.user.sub_district = 'Another Sub-District' + self.user.save() + response = request( + 'GET', + 'cart-overview', + http_authorization=self.user_http_authorization + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue('item_subtotal' in response.data) + self.assertFalse('shipping_costs' in response.data) + data = { + 'payment_method': 'TRF', + 'donation': '1000', + } + response = request( + 'POST', + 'cart-checkout', + data, + http_authorization=self.user_http_authorization + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.user.sub_district = seeds.USER_DATA['sub_district'] + self.user.save() + response = request( + 'POST', + 'cart-checkout', + data, + http_authorization=self.user_http_authorization + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + data = { + 'product': self.product.id, + 'quantity': 20, + } + request( + 'POST', + 'cart-update', + data, + http_authorization=self.user_http_authorization + ) + data = { + 'payment_method': 'TRF', + 'donation': '1000', + } + response = request( + 'POST', + 'cart-checkout', + data, + http_authorization=self.user_http_authorization + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(models.Transaction.objects.count(), 0) + + def test_cart_complete_transaction(self): + data = { + 'product': self.product.id, + 'quantity': 1, + } + request( + 'POST', + 'cart-update', + data, + http_authorization=self.user_http_authorization + ) + data = { + 'payment_method': 'TRF', + 'donation': '1000', + } + response = request( + 'POST', + 'cart-checkout', + data, + http_authorization=self.user_http_authorization + ) + transaction = models.Transaction.objects.get(id=response.data['transaction']) + transaction.transaction_status = '004' + transaction.save() + data = { + 'transaction': transaction.id, + } + response = request( + 'POST', + 'cart-complete-transaction', + data, + http_authorization=self.user_http_authorization + ) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + response = request( + 'POST', + 'cart-complete-transaction', + data, + http_authorization=self.user_http_authorization + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_cart_cancel_transaction(self): + data = { + 'product': self.product.id, + 'quantity': 1, + } + request( + 'POST', + 'cart-update', + data, + http_authorization=self.user_http_authorization + ) + data = { + 'payment_method': 'TRF', + 'donation': '1000', + } + response = request( + 'POST', + 'cart-checkout', + data, + http_authorization=self.user_http_authorization + ) + data = { + 'transaction': response.data['transaction'], + } + response = request( + 'POST', + 'cart-cancel-transaction', + data, + http_authorization=self.user_http_authorization + ) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + response = request( + 'POST', + 'cart-cancel-transaction', + data, + http_authorization=self.user_http_authorization + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_cart_upload_pop_success(self): + data = { + 'product': self.product.id, + 'quantity': 1, + } + request( + 'POST', + 'cart-update', + data, + http_authorization=self.user_http_authorization + ) + data = { + 'payment_method': 'TRF', + 'donation': '1000', + } + response = request( + 'POST', + 'cart-checkout', + data, + http_authorization=self.user_http_authorization + ) + with open(self.proof_of_payment_file.name, 'rb') as proof_of_payment: + data = { + 'transaction': response.data['transaction'], + 'proof_of_payment': proof_of_payment, + 'user_bank_account_name': 'Dummy User Bank Account Name', + 'user_bank_account_number': '0123456789', + } + response = request( + 'POST', + 'cart-upload-pop', + data, + format='multipart', + http_authorization=self.user_http_authorization + ) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + + def test_cart_upload_pop_fail(self): + data = { + 'product': self.product.id, + 'quantity': 1, + } + request( + 'POST', + 'cart-update', + data, + http_authorization=self.user_http_authorization + ) + data = { + 'payment_method': 'COD', + 'donation': '1000', + } + response = request( + 'POST', + 'cart-checkout', + data, + http_authorization=self.user_http_authorization + ) + with open(self.proof_of_payment_file.name, 'rb') as proof_of_payment: + data = { + 'transaction': response.data['transaction'], + 'proof_of_payment': proof_of_payment, + 'user_bank_account_name': 'Dummy User Bank Account Name', + 'user_bank_account_number': '0123456789', + } + response = request( + 'POST', + 'cart-upload-pop', + data, + format='multipart', + http_authorization=self.user_http_authorization + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + data = { + 'product': self.product.id, + 'quantity': 1, + } + request( + 'POST', + 'cart-update', + data, + http_authorization=self.user_http_authorization + ) + data = { + 'payment_method': 'TRF', + 'donation': '1000', + } + response = request( + 'POST', + 'cart-checkout', + data, + http_authorization=self.user_http_authorization + ) + transaction = models.Transaction.objects.get(id=response.data['transaction']) + transaction.transaction_status = '003' + transaction.save() + with open(self.proof_of_payment_file.name, 'rb') as proof_of_payment: + data = { + 'transaction': response.data['transaction'], + 'proof_of_payment': proof_of_payment, + 'user_bank_account_name': 'Dummy User Bank Account Name', + 'user_bank_account_number': '0123456789', + } + response = request( + 'POST', + 'cart-upload-pop', + data, + format='multipart', + http_authorization=self.user_http_authorization + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + @mock.patch('api.utils.validate_product_stock', return_value=None) + def test_cart_checkout_race_condition(self, mock_validate_product_stock): + data = { + 'product': self.product.id, + 'quantity': 20, + } + request( + 'POST', + 'cart-update', + data, + http_authorization=self.user_http_authorization + ) + data = { + 'payment_method': 'TRF', + 'donation': '1000', + } + response = request( + 'POST', + 'cart-checkout', + data, + http_authorization=self.user_http_authorization, + ) + self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) + self.assertEqual(mock_validate_product_stock.call_count, 1) + + class UserTest(rest_framework_test.APITestCase): def setUp(self): - self.superuser = models.User.objects.create_superuser(**SUPERUSER_DATA) + self.superuser = models.User.objects.create_superuser(**seeds.SUPERUSER_DATA) self.superuser_http_authorization = get_http_authorization( - SUPERUSER_DATA['username'], - SUPERUSER_DATA['password'] + seeds.SUPERUSER_DATA['username'], + seeds.SUPERUSER_DATA['password'] ) def test_user_model_string_representation(self): - user = models.User.objects.create(**USER_DATA) - self.assertEqual(str(user), USER_DATA['username']) + user = create_user(seeds.USER_DATA) + self.assertEqual(str(user), seeds.USER_DATA['username']) def test_user_list_success(self): - http_authorization = self.superuser_http_authorization - url = urls.reverse('user-list') - self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member - response = self.client.get(url) + response = request( + 'GET', + 'user-list', + http_authorization=self.superuser_http_authorization + ) self.assertEqual(response.status_code, status.HTTP_200_OK) def test_user_detail_success(self): - http_authorization = self.superuser_http_authorization - url = urls.reverse('user-detail', args=['self']) - self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member - response = self.client.get(url) + response = request( + 'GET', + 'user-detail', + http_authorization=self.superuser_http_authorization, + url_args=['self'] + ) self.assertEqual(response.status_code, status.HTTP_200_OK) def test_create_user_success(self): - http_authorization = self.superuser_http_authorization - data = USER_DATA - url = urls.reverse('user-list') - self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member - response = self.client.post(url, data, format='json') + data = seeds.USER_DATA + response = request( + 'POST', + 'user-list', + data, + http_authorization=self.superuser_http_authorization + ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(models.User.objects.count(), 2) - user_id = response.json()['id'] - self.assertEqual(models.User.objects.get(id=user_id).full_name, data['full_name']) + self.assertEqual( + models.User.objects.get(id=response.data['id']).full_name, + data['full_name'] + ) def test_create_user_fail(self): - http_authorization = self.superuser_http_authorization - url = urls.reverse('user-list') - self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member - response = self.client.post(url) + response = request( + 'POST', + 'user-list', + http_authorization=self.superuser_http_authorization + ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(models.User.objects.count(), 1) def test_update_user_success(self): - data = USER_DATA - url = urls.reverse('auth-register') - response = self.client.post(url, data, format='json') - user_id = response.json()['id'] - http_authorization = get_http_authorization(USER_DATA['username'], USER_DATA['password']) + user = create_user(seeds.USER_DATA) data = { 'full_name': 'Dummy', } - url = urls.reverse('user-detail', args=[user_id]) - self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member - response = self.client.patch(url, data, format='json') + response = request( + 'PATCH', + 'user-detail', + data, + http_authorization=get_http_authorization( + seeds.USER_DATA['username'], + seeds.USER_DATA['password'] + ), + url_args=[user.id] + ) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(models.User.objects.get(id=user_id).full_name, data['full_name']) - data = USER_DATA - response = self.client.put(url, data, format='json') + self.assertEqual(models.User.objects.get(id=user.id).full_name, data['full_name']) + data = seeds.USER_DATA + response = request( + 'PUT', + 'user-detail', + data, + http_authorization=get_http_authorization( + seeds.USER_DATA['username'], + seeds.USER_DATA['password'] + ), + url_args=[user.id] + ) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(models.User.objects.get(id=user_id).full_name, data['full_name']) + self.assertEqual(models.User.objects.get(id=user.id).full_name, data['full_name']) def test_update_user_fail(self): - data = USER_DATA - url = urls.reverse('auth-register') - response = self.client.post(url, data, format='json') - user_id = response.json()['id'] - http_authorization = get_http_authorization(USER_DATA['username'], USER_DATA['password']) + user = create_user(seeds.USER_DATA) data = { 'password': 'p', } - url = urls.reverse('user-detail', args=[user_id]) - self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member - response = self.client.patch(url, data, format='json') + response = request( + 'PATCH', + 'user-detail', + data, + http_authorization=get_http_authorization( + seeds.USER_DATA['username'], + seeds.USER_DATA['password'] + ), + url_args=[user.id] + ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) class CategoryTest(rest_framework_test.APITestCase): def setUp(self): - self.superuser = models.User.objects.create_superuser(**SUPERUSER_DATA) + self.superuser = models.User.objects.create_superuser(**seeds.SUPERUSER_DATA) self.superuser_http_authorization = get_http_authorization( - SUPERUSER_DATA['username'], - SUPERUSER_DATA['password'] + seeds.SUPERUSER_DATA['username'], + seeds.SUPERUSER_DATA['password'] ) def test_category_model_string_representation(self): - category = models.Category.objects.create(**CATEGORY_DATA) - self.assertEqual(str(category), CATEGORY_DATA['name']) + category = models.Category.objects.create(**seeds.CATEGORY_DATA) + self.assertEqual(str(category), seeds.CATEGORY_DATA['name']) + + def test_category_list_success(self): + response = request( + 'GET', + 'category-list', + http_authorization=self.superuser_http_authorization + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_category_detail_success(self): + category = models.Category.objects.create(**seeds.CATEGORY_DATA) + response = request( + 'GET', + 'category-detail', + http_authorization=self.superuser_http_authorization, + url_args=[category.id] + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) def test_create_category_success(self): - http_authorization = self.superuser_http_authorization - data = CATEGORY_DATA - url = urls.reverse('category-list') - self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member - response = self.client.post(url, data, format='json') + data = seeds.CATEGORY_DATA + response = request( + 'POST', + 'category-list', + data, + http_authorization=self.superuser_http_authorization + ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(models.Category.objects.count(), 1) - category_id = response.json()['id'] - self.assertEqual(models.Category.objects.get(id=category_id).name, data['name']) + self.assertEqual(models.Category.objects.get(id=response.data['id']).name, data['name']) def test_create_category_fail(self): - http_authorization = self.superuser_http_authorization - url = urls.reverse('category-list') - self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member - response = self.client.post(url) + response = request( + 'POST', + 'category-list', + http_authorization=self.superuser_http_authorization + ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(models.Category.objects.count(), 0) def test_delete_category_success(self): - http_authorization = self.superuser_http_authorization - data = CATEGORY_DATA - url = urls.reverse('category-list') - self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member - response = self.client.post(url, data, format='json') - category_id = response.json()['id'] - url = urls.reverse('category-detail', args=[category_id]) - response = self.client.delete(url) + category = models.Category.objects.create(**seeds.CATEGORY_DATA) + response = request( + 'DELETE', + 'category-detail', + http_authorization=self.superuser_http_authorization, + url_args=[category.id] + ) self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) self.assertEqual(models.Category.objects.count(), 0) def test_delete_category_fail(self): - http_authorization = self.superuser_http_authorization - data = CATEGORY_DATA - url = urls.reverse('category-list') - self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member - response = self.client.post(url, data, format='json') - category_id = response.json()['id'] - category = models.Category.objects.get(id=category_id) - subcategory = models.Subcategory.objects.create(**dict(SUBCATEGORY_DATA, category=category)) - models.Product.objects.create(**dict(PRODUCT_DATA, subcategory=subcategory)) - url = urls.reverse('category-detail', args=[category_id]) - response = self.client.delete(url) + category = models.Category.objects.create(**seeds.CATEGORY_DATA) + subcategory = models.Subcategory.objects.create(**dict( + seeds.SUBCATEGORY_DATA, category=category + )) + models.Product.objects.create(**dict(seeds.PRODUCT_DATA, subcategory=subcategory)) + response = request( + 'DELETE', + 'category-detail', + http_authorization=self.superuser_http_authorization, + url_args=[category.id] + ) self.assertEqual(response.status_code, status.HTTP_409_CONFLICT) self.assertEqual(models.Category.objects.count(), 1) class SubcategoryTest(rest_framework_test.APITestCase): def setUp(self): - self.superuser = models.User.objects.create_superuser(**SUPERUSER_DATA) + self.superuser = models.User.objects.create_superuser(**seeds.SUPERUSER_DATA) self.superuser_http_authorization = get_http_authorization( - SUPERUSER_DATA['username'], - SUPERUSER_DATA['password'] + seeds.SUPERUSER_DATA['username'], + seeds.SUPERUSER_DATA['password'] ) - self.category = models.Category.objects.create(**CATEGORY_DATA) + self.category = models.Category.objects.create(**seeds.CATEGORY_DATA) def test_subcategory_model_string_representation(self): - subcategory = models.Subcategory.objects.create( - **dict(SUBCATEGORY_DATA, category=self.category) + subcategory = models.Subcategory.objects.create(**dict( + seeds.SUBCATEGORY_DATA, + category=self.category + )) + self.assertEqual(str(subcategory), seeds.SUBCATEGORY_DATA['name']) + + def test_subcategory_list_success(self): + response = request( + 'GET', + 'subcategory-list', + http_authorization=self.superuser_http_authorization + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_subcategory_detail_success(self): + subcategory = models.Subcategory.objects.create(**dict( + seeds.SUBCATEGORY_DATA, + category=self.category + )) + response = request( + 'GET', + 'subcategory-detail', + http_authorization=self.superuser_http_authorization, + url_args=[subcategory.id] ) - self.assertEqual(str(subcategory), SUBCATEGORY_DATA['name']) + self.assertEqual(response.status_code, status.HTTP_200_OK) def test_create_subcategory_success(self): - http_authorization = self.superuser_http_authorization - data = SUBCATEGORY_DATA - data['category'] = self.category.id - url = urls.reverse('subcategory-list') - self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member - response = self.client.post(url, data, format='json') + data = dict(seeds.SUBCATEGORY_DATA, category=self.category.id) + response = request( + 'POST', + 'subcategory-list', + data, + http_authorization=self.superuser_http_authorization + ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(models.Subcategory.objects.count(), 1) - subcategory_id = response.json()['id'] - self.assertEqual(models.Subcategory.objects.get(id=subcategory_id).name, data['name']) + self.assertEqual(models.Subcategory.objects.get(id=response.data['id']).name, data['name']) def test_create_subcategory_fail(self): - http_authorization = self.superuser_http_authorization - url = urls.reverse('subcategory-list') - self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member - response = self.client.post(url) + response = request( + 'POST', + 'subcategory-list', + http_authorization=self.superuser_http_authorization + ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(models.Subcategory.objects.count(), 0) def test_delete_subcategory_success(self): - http_authorization = self.superuser_http_authorization - data = SUBCATEGORY_DATA - data['category'] = self.category.id - url = urls.reverse('subcategory-list') - self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member - response = self.client.post(url, data, format='json') - subcategory_id = response.json()['id'] - url = urls.reverse('subcategory-detail', args=[subcategory_id]) - response = self.client.delete(url) + subcategory = models.Subcategory.objects.create(**dict( + seeds.SUBCATEGORY_DATA, + category=self.category + )) + response = request( + 'DELETE', + 'subcategory-detail', + http_authorization=self.superuser_http_authorization, + url_args=[subcategory.id] + ) self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) self.assertEqual(models.Subcategory.objects.count(), 0) def test_delete_subcategory_fail(self): - http_authorization = self.superuser_http_authorization - data = SUBCATEGORY_DATA - data['category'] = self.category.id - url = urls.reverse('subcategory-list') - self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member - response = self.client.post(url, data, format='json') - subcategory_id = response.json()['id'] - subcategory = models.Subcategory.objects.get(id=subcategory_id) - models.Product.objects.create(**dict(PRODUCT_DATA, subcategory=subcategory)) - url = urls.reverse('subcategory-detail', args=[subcategory_id]) - response = self.client.delete(url) + subcategory = models.Subcategory.objects.create(**dict( + seeds.SUBCATEGORY_DATA, + category=self.category + )) + models.Product.objects.create(**dict(seeds.PRODUCT_DATA, subcategory=subcategory)) + response = request( + 'DELETE', + 'subcategory-detail', + http_authorization=self.superuser_http_authorization, + url_args=[subcategory.id] + ) self.assertEqual(response.status_code, status.HTTP_409_CONFLICT) self.assertEqual(models.Subcategory.objects.count(), 1) class ProductTest(rest_framework_test.APITestCase): def setUp(self): - self.superuser = models.User.objects.create_superuser(**SUPERUSER_DATA) + self.superuser = models.User.objects.create_superuser(**seeds.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) + seeds.SUPERUSER_DATA['username'], + seeds.SUPERUSER_DATA['password'] ) + self.category = models.Category.objects.create(**seeds.CATEGORY_DATA) + self.subcategory = models.Subcategory.objects.create(**dict( + seeds.CATEGORY_DATA, + category=self.category + )) def test_product_model_string_representation(self): - product = models.Product.objects.create(**dict(PRODUCT_DATA, subcategory=self.subcategory)) + product = models.Product.objects.create(**dict( + seeds.PRODUCT_DATA, subcategory=self.subcategory + )) self.assertEqual(len(str(product)), 6) 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) + response = request( + 'GET', + 'product-list', + http_authorization=self.superuser_http_authorization + ) 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) + product = models.Product.objects.create(**dict( + seeds.PRODUCT_DATA, subcategory=self.subcategory + )) + response = request( + 'GET', + 'product-detail', + http_authorization=self.superuser_http_authorization, + url_args=[product.id] + ) 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') + data = dict(seeds.PRODUCT_DATA, subcategory=self.subcategory.id) + response = request( + 'POST', + 'product-list', + data, + http_authorization=self.superuser_http_authorization + ) 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']) + self.assertEqual(models.Product.objects.get(id=response.data['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) + response = request( + 'POST', + 'product-list', + http_authorization=self.superuser_http_authorization + ) 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'] + product = models.Product.objects.create(**dict( + seeds.PRODUCT_DATA, subcategory=self.subcategory + )) data = { 'name': 'Dummy', } - url = urls.reverse('product-detail', args=[product_id]) - response = self.client.patch(url, data, format='json') + response = request( + 'PATCH', + 'product-detail', + data, + http_authorization=self.superuser_http_authorization, + url_args=[product.id] + ) 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(models.Product.objects.get(id=product.id).name, data['name']) + data = dict(seeds.PRODUCT_DATA, subcategory=self.subcategory.id) + response = request( + 'PUT', + 'product-detail', + data, + http_authorization=self.superuser_http_authorization, + url_args=[product.id] + ) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(models.Product.objects.get(id=product_id).name, data['name']) + 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'] + product = models.Product.objects.create(**dict( + seeds.PRODUCT_DATA, subcategory=self.subcategory + )) data = { 'name': '', } - url = urls.reverse('product-detail', args=[product_id]) - response = self.client.patch(url, data, format='json') + response = request( + 'PATCH', + 'product-detail', + data, + http_authorization=self.superuser_http_authorization, + url_args=[product.id] + ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) class ShoppingCartTest(rest_framework_test.APITestCase): def setUp(self): - self.superuser = models.User.objects.create_superuser(**SUPERUSER_DATA) + self.superuser = models.User.objects.create_superuser(**seeds.SUPERUSER_DATA) self.superuser_http_authorization = get_http_authorization( - SUPERUSER_DATA['username'], - SUPERUSER_DATA['password'] + seeds.SUPERUSER_DATA['username'], + seeds.SUPERUSER_DATA['password'] ) - self.user = models.User.objects.create(**USER_DATA) + self.user = create_user(seeds.USER_DATA) def test_shopping_cart_model_string_representation(self): shopping_cart = models.ShoppingCart.objects.get(user=self.user) self.assertEqual(str(shopping_cart), self.user.username) + def test_shopping_cart_list_success(self): + response = request( + 'GET', + 'shopping-cart-list', + http_authorization=self.superuser_http_authorization + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_shopping_cart_detail_success(self): + response = request( + 'GET', + 'shopping-cart-detail', + http_authorization=self.superuser_http_authorization, + url_args=['self'] + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + class CartItemTest(rest_framework_test.APITestCase): def setUp(self): - self.superuser = models.User.objects.create_superuser(**SUPERUSER_DATA) + self.superuser = models.User.objects.create_superuser(**seeds.SUPERUSER_DATA) self.superuser_http_authorization = get_http_authorization( - SUPERUSER_DATA['username'], - SUPERUSER_DATA['password'] - ) - self.user = models.User.objects.create(**USER_DATA) - self.shopping_cart = models.ShoppingCart.objects.get(user=self.user) - self.category = models.Category.objects.create(**CATEGORY_DATA) - self.subcategory = models.Subcategory.objects.create( - **dict(CATEGORY_DATA, category=self.category) + seeds.SUPERUSER_DATA['username'], + seeds.SUPERUSER_DATA['password'] ) + self.user = create_user(seeds.USER_DATA) + self.category = models.Category.objects.create(**seeds.CATEGORY_DATA) + self.subcategory = models.Subcategory.objects.create(**dict( + seeds.CATEGORY_DATA, + category=self.category + )) self.product = models.Product.objects.create(**dict( - PRODUCT_DATA, + seeds.PRODUCT_DATA, subcategory=self.subcategory )) + self.shopping_cart = models.ShoppingCart.objects.get(user=self.user) def test_cart_item_model_string_representation(self): + product_code = self.product.code cart_item = models.CartItem.objects.create( shopping_cart=self.shopping_cart, product=self.product ) - self.assertEqual(str(cart_item), self.product.code) + self.assertEqual(str(cart_item), product_code) class TransactionTest(rest_framework_test.APITestCase): def setUp(self): - self.superuser = models.User.objects.create_superuser(**SUPERUSER_DATA) + self.superuser = models.User.objects.create_superuser(**seeds.SUPERUSER_DATA) self.superuser_http_authorization = get_http_authorization( - SUPERUSER_DATA['username'], - SUPERUSER_DATA['password'] + seeds.SUPERUSER_DATA['username'], + seeds.SUPERUSER_DATA['password'] + ) + models.ShipmentConfig.objects.create(**seeds.SHIPMENT_CONFIG_DATA) + self.user = create_user(seeds.USER_DATA) + self.user_http_authorization = get_http_authorization( + seeds.USER_DATA['username'], + seeds.USER_DATA['password'] ) - models.ShipmentConfig.objects.create(**SHIPMENT_CONFIG_DATA) - self.user = models.User.objects.create(**USER_DATA) def test_transaction_model_string_representation(self): - transaction = models.Transaction.objects.create(**dict(TRANSACTION_DATA, user=self.user)) + transaction = models.Transaction.objects.create(**dict( + seeds.TRANSACTION_DATA, user=self.user + )) self.assertEqual(len(str(transaction)), 8) + def test_transaction_list_success(self): + response = request( + 'GET', + 'transaction-list', + http_authorization=self.superuser_http_authorization + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response = request( + 'GET', + 'transaction-list', + http_authorization=self.user_http_authorization + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_transaction_detail_success(self): + transaction = models.Transaction.objects.create(**dict( + seeds.TRANSACTION_DATA, user=self.user + )) + response = request( + 'GET', + 'transaction-detail', + http_authorization=self.superuser_http_authorization, + url_args=[transaction.id] + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + class TransactionItemTest(rest_framework_test.APITestCase): def setUp(self): - self.superuser = models.User.objects.create_superuser(**SUPERUSER_DATA) + self.superuser = models.User.objects.create_superuser(**seeds.SUPERUSER_DATA) self.superuser_http_authorization = get_http_authorization( - SUPERUSER_DATA['username'], - SUPERUSER_DATA['password'] - ) - models.ShipmentConfig.objects.create(**SHIPMENT_CONFIG_DATA) - self.user = models.User.objects.create(**USER_DATA) - self.category = models.Category.objects.create(**CATEGORY_DATA) - self.subcategory = models.Subcategory.objects.create( - **dict(CATEGORY_DATA, category=self.category) + seeds.SUPERUSER_DATA['username'], + seeds.SUPERUSER_DATA['password'] ) + models.ShipmentConfig.objects.create(**seeds.SHIPMENT_CONFIG_DATA) + self.user = create_user(seeds.USER_DATA) + self.category = models.Category.objects.create(**seeds.CATEGORY_DATA) + self.subcategory = models.Subcategory.objects.create(**dict( + seeds.CATEGORY_DATA, + category=self.category + )) self.product = models.Product.objects.create(**dict( - PRODUCT_DATA, + seeds.PRODUCT_DATA, subcategory=self.subcategory )) self.transaction = models.Transaction.objects.create(**dict( - TRANSACTION_DATA, + seeds.TRANSACTION_DATA, user=self.user )) - def test_transaction_model_string_representation(self): + def test_transaction_item_model_string_representation(self): transaction_item = models.TransactionItem.objects.create(**dict( - TRANSACTION_ITEM_DATA, - product=self.product, - transaction=self.transaction + seeds.TRANSACTION_ITEM_DATA, + transaction=self.transaction, + product=self.product )) self.assertEqual(str(transaction_item), self.product.name) class ProgramTest(rest_framework_test.APITestCase): def setUp(self): - self.superuser = models.User.objects.create_superuser(**SUPERUSER_DATA) + self.superuser = models.User.objects.create_superuser(**seeds.SUPERUSER_DATA) self.superuser_http_authorization = get_http_authorization( - SUPERUSER_DATA['username'], - SUPERUSER_DATA['password'] + seeds.SUPERUSER_DATA['username'], + seeds.SUPERUSER_DATA['password'] ) def test_program_model_string_representation(self): - program = models.Program.objects.create(**PROGRAM_DATA) + program = models.Program.objects.create(**seeds.PROGRAM_DATA) self.assertEqual(len(str(program)), 6) def test_program_list_success(self): - http_authorization = self.superuser_http_authorization - url = urls.reverse('program-list') - self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member - response = self.client.get(url) + response = request( + 'GET', + 'program-list', + http_authorization=self.superuser_http_authorization, + ) self.assertEqual(response.status_code, status.HTTP_200_OK) def test_program_detail_success(self): - program = models.Program.objects.create(**PROGRAM_DATA) - program_id = str(program.id) - http_authorization = self.superuser_http_authorization - url = urls.reverse('program-detail', args=[program_id]) - self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member - response = self.client.get(url) + program = models.Program.objects.create(**seeds.PROGRAM_DATA) + response = request( + 'GET', + 'program-detail', + http_authorization=self.superuser_http_authorization, + url_args=[program.id] + ) self.assertEqual(response.status_code, status.HTTP_200_OK) def test_create_program_success(self): - http_authorization = self.superuser_http_authorization - data = PROGRAM_DATA - url = urls.reverse('program-list') - self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member - response = self.client.post(url, data, format='json') + data = seeds.PROGRAM_DATA + response = request( + 'POST', + 'program-list', + data, + http_authorization=self.superuser_http_authorization + ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(models.Program.objects.count(), 1) - program_id = response.json()['id'] - self.assertEqual(models.Program.objects.get(id=program_id).name, data['name']) + self.assertEqual(models.Program.objects.get(id=response.data['id']).name, data['name']) def test_create_program_fail(self): - http_authorization = self.superuser_http_authorization - url = urls.reverse('program-list') - self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member - response = self.client.post(url) + response = request( + 'POST', + 'program-list', + http_authorization=self.superuser_http_authorization + ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(models.Program.objects.count(), 0) def test_update_program_success(self): - http_authorization = self.superuser_http_authorization - data = PROGRAM_DATA - url = urls.reverse('program-list') - self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member - response = self.client.post(url, data, format='json') - program_id = response.json()['id'] + program = models.Program.objects.create(**seeds.PROGRAM_DATA) data = { 'name': 'Dummy', } - url = urls.reverse('program-detail', args=[program_id]) - response = self.client.patch(url, data, format='json') + response = request( + 'PATCH', + 'program-detail', + data, + http_authorization=self.superuser_http_authorization, + url_args=[program.id] + ) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(models.Program.objects.get(id=program_id).name, data['name']) - data = PROGRAM_DATA - response = self.client.put(url, data, format='json') + self.assertEqual(models.Program.objects.get(id=program.id).name, data['name']) + data = seeds.PROGRAM_DATA + response = request( + 'PUT', + 'program-detail', + data, + http_authorization=self.superuser_http_authorization, + url_args=[program.id] + ) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(models.Program.objects.get(id=program_id).name, data['name']) + self.assertEqual(models.Program.objects.get(id=program.id).name, data['name']) def test_update_program_fail(self): - http_authorization = self.superuser_http_authorization - data = PROGRAM_DATA - url = urls.reverse('program-list') - self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member - response = self.client.post(url, data, format='json') - program_id = response.json()['id'] + program = models.Program.objects.create(**seeds.PROGRAM_DATA) data = { 'end_date_time': '2020-01-01T00:00:00+07:00', } - url = urls.reverse('program-detail', args=[program_id]) - response = self.client.patch(url, data, format='json') + response = request( + 'PATCH', + 'program-detail', + data, + http_authorization=self.superuser_http_authorization, + url_args=[program.id] + ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) -class BankAccountConfigTest(rest_framework_test.APITestCase): +class PaymentMethodTest(rest_framework_test.APITestCase): def setUp(self): - self.superuser = models.User.objects.create_superuser(**SUPERUSER_DATA) + self.superuser = models.User.objects.create_superuser(**seeds.SUPERUSER_DATA) self.superuser_http_authorization = get_http_authorization( - SUPERUSER_DATA['username'], - SUPERUSER_DATA['password'] + seeds.SUPERUSER_DATA['username'], + seeds.SUPERUSER_DATA['password'] ) - def test_bank_account_config_detail_success(self): - models.BankAccountConfig.objects.create(**BANK_ACCOUNT_CONFIG_DATA) - http_authorization = self.superuser_http_authorization - url = urls.reverse('bank-account-config-detail') - self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member - response = self.client.get(url) + def test_payment_method_choices_success(self): + response = request( + 'GET', + 'payment-method-choices', + http_authorization=self.superuser_http_authorization + ) self.assertEqual(response.status_code, status.HTTP_200_OK) - def test_update_bank_account_config_success(self): - models.BankAccountConfig.objects.create(**BANK_ACCOUNT_CONFIG_DATA) - http_authorization = self.superuser_http_authorization + +class TransactionStatusTest(rest_framework_test.APITestCase): + def setUp(self): + self.superuser = models.User.objects.create_superuser(**seeds.SUPERUSER_DATA) + self.superuser_http_authorization = get_http_authorization( + seeds.SUPERUSER_DATA['username'], + seeds.SUPERUSER_DATA['password'] + ) + + def test_transaction_status_choices_success(self): + response = request( + 'GET', + 'transaction-status-choices', + http_authorization=self.superuser_http_authorization + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + +class AppConfigTest(rest_framework_test.APITestCase): + def setUp(self): + self.superuser = models.User.objects.create_superuser(**seeds.SUPERUSER_DATA) + self.superuser_http_authorization = get_http_authorization( + seeds.SUPERUSER_DATA['username'], + seeds.SUPERUSER_DATA['password'] + ) + + def test_app_config_model_string_representation(self): + app_config = models.AppConfig.objects.create() + self.assertTrue(len(str(app_config)) > 0) + + def test_app_config_detail_success(self): + models.AppConfig.objects.create() + response = request( + 'GET', + 'app-config-detail', + http_authorization=self.superuser_http_authorization + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_update_app_config_success(self): + models.AppConfig.objects.create() data = { - 'bank_name': 'Dummy', + 'send_sms': True, } - url = urls.reverse('bank-account-config-detail') - self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member - response = self.client.patch(url, data, format='json') + response = request( + 'PATCH', + 'app-config-detail', + data, + http_authorization=self.superuser_http_authorization + ) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(models.BankAccountConfig.objects.get().bank_name, data['bank_name']) + self.assertEqual(models.AppConfig.objects.get().send_sms, data['send_sms']) - def test_update_bank_account_config_fail(self): - models.BankAccountConfig.objects.create(**BANK_ACCOUNT_CONFIG_DATA) - http_authorization = self.superuser_http_authorization + def test_update_app_config_fail(self): + models.AppConfig.objects.create() data = { - 'bank_name': '', + 'send_sms': None, } - url = urls.reverse('bank-account-config-detail') - self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member - response = self.client.patch(url, data, format='json') + response = request( + 'PATCH', + 'app-config-detail', + data, + http_authorization=self.superuser_http_authorization + ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) -class ShipmentConfigTest(rest_framework_test.APITestCase): +class BankAccountConfigTest(rest_framework_test.APITestCase): def setUp(self): - self.superuser = models.User.objects.create_superuser(**SUPERUSER_DATA) + self.superuser = models.User.objects.create_superuser(**seeds.SUPERUSER_DATA) self.superuser_http_authorization = get_http_authorization( - SUPERUSER_DATA['username'], - SUPERUSER_DATA['password'] + seeds.SUPERUSER_DATA['username'], + seeds.SUPERUSER_DATA['password'] ) - def test_shipment_config_detail_success(self): - models.ShipmentConfig.objects.create(**SHIPMENT_CONFIG_DATA) - http_authorization = self.superuser_http_authorization - url = urls.reverse('shipment-config-detail') - self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member - response = self.client.get(url) + def test_bank_account_config_model_string_representation(self): + bank_account_config = models.BankAccountConfig.objects.create( + **seeds.BANK_ACCOUNT_CONFIG_DATA + ) + self.assertTrue(len(str(bank_account_config)) > 0) + + def test_bank_account_config_detail_success(self): + models.BankAccountConfig.objects.create(**seeds.BANK_ACCOUNT_CONFIG_DATA) + response = request( + 'GET', + 'bank-account-config-detail', + http_authorization=self.superuser_http_authorization + ) self.assertEqual(response.status_code, status.HTTP_200_OK) - def test_update_shipment_config_success(self): - models.ShipmentConfig.objects.create(**SHIPMENT_CONFIG_DATA) - http_authorization = self.superuser_http_authorization + def test_update_bank_account_config_success(self): + models.BankAccountConfig.objects.create(**seeds.BANK_ACCOUNT_CONFIG_DATA) data = { - 'hamlet': '001', + 'bank_name': 'Dummy', } - url = urls.reverse('shipment-config-detail') - self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member - response = self.client.patch(url, data, format='json') + response = request( + 'PATCH', + 'bank-account-config-detail', + data, + http_authorization=self.superuser_http_authorization + ) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(models.ShipmentConfig.objects.get().hamlet, data['hamlet']) + self.assertEqual(models.BankAccountConfig.objects.get().bank_name, data['bank_name']) - def test_update_shipment_config_fail(self): - models.ShipmentConfig.objects.create(**SHIPMENT_CONFIG_DATA) - http_authorization = self.superuser_http_authorization + def test_update_bank_account_config_fail(self): + models.BankAccountConfig.objects.create(**seeds.BANK_ACCOUNT_CONFIG_DATA) data = { - 'hamlet': '0001', + 'bank_name': '', } - url = urls.reverse('shipment-config-detail') - self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member - response = self.client.patch(url, data, format='json') + response = request( + 'PATCH', + 'bank-account-config-detail', + data, + http_authorization=self.superuser_http_authorization + ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) -class AppConfigTest(rest_framework_test.APITestCase): +class ShipmentConfigTest(rest_framework_test.APITestCase): def setUp(self): - self.superuser = models.User.objects.create_superuser(**SUPERUSER_DATA) + self.superuser = models.User.objects.create_superuser(**seeds.SUPERUSER_DATA) self.superuser_http_authorization = get_http_authorization( - SUPERUSER_DATA['username'], - SUPERUSER_DATA['password'] + seeds.SUPERUSER_DATA['username'], + seeds.SUPERUSER_DATA['password'] ) - def test_app_config_detail_success(self): - models.AppConfig.objects.create() - http_authorization = self.superuser_http_authorization - url = urls.reverse('app-config-detail') - self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member - response = self.client.get(url) + def test_shipment_config_model_string_representation(self): + shipment_config = models.ShipmentConfig.objects.create(**seeds.SHIPMENT_CONFIG_DATA) + self.assertTrue(len(str(shipment_config)) > 0) + + def test_shipment_config_detail_success(self): + models.ShipmentConfig.objects.create(**seeds.SHIPMENT_CONFIG_DATA) + response = request( + 'GET', + 'shipment-config-detail', + http_authorization=self.superuser_http_authorization + ) self.assertEqual(response.status_code, status.HTTP_200_OK) - def test_update_app_config_success(self): - models.AppConfig.objects.create() - http_authorization = self.superuser_http_authorization + def test_update_shipment_config_success(self): + models.ShipmentConfig.objects.create(**seeds.SHIPMENT_CONFIG_DATA) data = { - 'send_sms': True, + 'hamlet': '001', } - url = urls.reverse('app-config-detail') - self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member - response = self.client.patch(url, data, format='json') + response = request( + 'PATCH', + 'shipment-config-detail', + data, + http_authorization=self.superuser_http_authorization + ) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(models.AppConfig.objects.get().send_sms, data['send_sms']) + self.assertEqual(models.ShipmentConfig.objects.get().hamlet, data['hamlet']) - def test_update_app_config_fail(self): - models.AppConfig.objects.create() - http_authorization = self.superuser_http_authorization + def test_update_shipment_config_fail(self): + models.ShipmentConfig.objects.create(**seeds.SHIPMENT_CONFIG_DATA) data = { - 'send_sms': None, + 'hamlet': '1', } - url = urls.reverse('app-config-detail') - self.client.credentials(HTTP_AUTHORIZATION=http_authorization) # pylint: disable=no-member - response = self.client.patch(url, data, format='json') + response = request( + 'PATCH', + 'shipment-config-detail', + data, + http_authorization=self.superuser_http_authorization + ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/api/urls.py b/api/urls.py index d2cf10e..b2015d7 100644 --- a/api/urls.py +++ b/api/urls.py @@ -15,6 +15,20 @@ urlpatterns = [ urls.path('auth/resend-otp/', api_views.AuthResendOTP.as_view(), name='auth-resend-otp'), urls.path('auth/logout/', knox_views.LogoutView.as_view(), name='auth-logout'), urls.path('auth/logout-all/', knox_views.LogoutAllView.as_view(), name='auth-logout-all'), + urls.path('cart/update/', api_views.CartUpdate.as_view(), name='cart-update'), + urls.path('cart/overview/', api_views.CartOverview.as_view(), name='cart-overview'), + urls.path('cart/checkout/', api_views.CartCheckout.as_view(), name='cart-checkout'), + urls.path('cart/upload-pop/', api_views.CartUploadPOP.as_view(), name='cart-upload-pop'), + urls.path( + 'cart/complete-transaction/', + api_views.CartCompleteTransaction.as_view(), + name='cart-complete-transaction' + ), + urls.path( + 'cart/cancel-transaction/', + api_views.CartCancelTransaction.as_view(), + name='cart-cancel-transaction' + ), urls.path('users/', api_views.UserList.as_view(), name='user-list'), urls.path('users//', api_views.UserDetail.as_view(), name='user-detail'), urls.path('categories/', api_views.CategoryList.as_view(), name='category-list'), @@ -27,17 +41,39 @@ urlpatterns = [ ), urls.path('products/', api_views.ProductList.as_view(), name='product-list'), urls.path('products//', api_views.ProductDetail.as_view(), name='product-detail'), + urls.path('shopping-carts/', api_views.ShoppingCartList.as_view(), name='shopping-cart-list'), + urls.path( + 'shopping-carts//', + api_views.ShoppingCartDetail.as_view(), + name='shopping-cart-detail' + ), + urls.path('transactions/', api_views.TransactionList.as_view(), name='transaction-list'), + urls.path( + 'transactions/', + api_views.TransactionDetail.as_view(), + name='transaction-detail' + ), urls.path('programs/', api_views.ProgramList.as_view(), name='program-list'), urls.path('programs//', api_views.ProgramDetail.as_view(), name='program-detail'), - urls.path('configs/app/', api_views.AppConfigDetail.as_view(), name='app-config-detail'), urls.path( - 'configs/shipment/', - api_views.ShipmentConfigDetail.as_view(), - name='shipment-config-detail' + 'choices/payment-method/', + api_views.PaymentMethodChoices.as_view(), + name='payment-method-choices' ), + urls.path( + 'choices/transaction-status/', + api_views.TransactionStatusChoices.as_view(), + name='transaction-status-choices' + ), + urls.path('configs/app/', api_views.AppConfigDetail.as_view(), name='app-config-detail'), urls.path( 'configs/bank-account/', api_views.BankAccountConfigDetail.as_view(), name='bank-account-config-detail' ), + urls.path( + 'configs/shipment/', + api_views.ShipmentConfigDetail.as_view(), + name='shipment-config-detail' + ), ] diff --git a/api/utils.py b/api/utils.py index 85452ed..e209f09 100644 --- a/api/utils.py +++ b/api/utils.py @@ -1,5 +1,4 @@ -import math -import random +import datetime import jwt import shortuuid @@ -12,6 +11,18 @@ from rest_framework import exceptions as rest_framework_exceptions from home_industry import utils +def generate_bearer_token(user): + encoded_jwt = jwt.encode( + { + 'exp': datetime.datetime.utcnow() + datetime.timedelta(hours=1), + 'username': user.username, + }, + conf.settings.SECRET_KEY, + algorithm='HS256' + ).decode('utf-8') + return encoded_jwt + + def generate_code(): alphabet = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ' code = shortuuid.ShortUUID(alphabet=alphabet).random(length=6) @@ -19,8 +30,8 @@ def generate_code(): def generate_otp(): - digits = '0123456789' - otp = ''.join([digits[math.floor(random.random() * 10)] for _ in range(6)]) + alphabet = '0123456789' + otp = shortuuid.ShortUUID(alphabet=alphabet).random(length=6) return otp @@ -30,6 +41,31 @@ def generate_transaction_number(): return transaction_number +def get_shipping_costs(user): + shipment_config = utils.get_shipment_config() + if user.sub_district.lower() == shipment_config.sub_district.lower(): + if user.urban_village.lower() == shipment_config.urban_village.lower(): + if user.hamlet == shipment_config.hamlet: + shipping_costs = shipment_config.same_hamlet_costs + else: + shipping_costs = ( + shipment_config.same_urban_village_different_hamlet_costs + ) + else: + shipping_costs = ( + shipment_config.same_sub_district_different_urban_village_costs + ) + else: + shipping_costs = None + return shipping_costs + + +def get_upload_file_path(instance, filename): + directory = type(instance).__name__.lower() + file_name = '{}_{}'.format(shortuuid.uuid(), filename) + return 'uploads/{}/{}'.format(directory, file_name) + + def get_username_from_bearer_token(http_authorization): if http_authorization is None: raise rest_framework_exceptions.NotAuthenticated() @@ -47,6 +83,10 @@ def get_username_from_bearer_token(http_authorization): return decoded_jwt['username'] +def map_choices(choices): + return [{'code': choice[0], 'description': choice[1]} for choice in choices] + + def send_otp(phone_number, otp): message = text.format_lazy( 'Your Industri Pilar account authentication code is {otp}. ' @@ -54,3 +94,13 @@ def send_otp(phone_number, otp): otp=otp ) utils.send_sms(phone_number, message) + + +def validate_product_stock(cart_items): + for cart_item in cart_items: + product = cart_item.product + if cart_item.quantity > product.stock: + raise rest_framework_exceptions.ParseError(_( + 'Failed to checkout because the purchased quantity of certain items exceeds ' + 'the available stock.' + )) diff --git a/api/views.py b/api/views.py index b43fe9c..12e5b77 100644 --- a/api/views.py +++ b/api/views.py @@ -1,22 +1,22 @@ -from datetime import datetime, timedelta - -import jwt -from django import conf, shortcuts +from django import shortcuts from django.contrib import auth +from django.db import utils as db_utils, transaction as db_transaction from django.db.models import deletion +from django.utils import text from django.utils.translation import gettext_lazy as _ from django_filters import rest_framework from knox import views as knox_views from rest_framework import ( - filters, generics, permissions as rest_framework_permissions, response, status, - views as rest_framework_views + exceptions as rest_framework_exceptions, filters as rest_framework_filters, generics, + permissions as rest_framework_permissions, response, status, views as rest_framework_views ) from rest_framework.authtoken import serializers as authtoken_serializers from api import ( - models, paginations, permissions as api_permissions, serializers as api_serializers, - utils as api_utils + constants, exceptions as api_exceptions, filters as api_filters, models, paginations, + permissions as api_permissions, serializers as api_serializers, schemas, utils as api_utils ) +from home_industry import utils as home_industry_utils class AuthRegister(generics.CreateAPIView): @@ -56,12 +56,8 @@ class AuthPhoneNumberLogin(rest_framework_views.APIView): user.otp = api_utils.generate_otp() user.save() api_utils.send_otp(str(user.phone_number), user.otp) - encoded_jwt = jwt.encode( - {'exp': datetime.utcnow() + timedelta(hours=1), 'username': user.username}, - conf.settings.SECRET_KEY, - algorithm='HS256' - ).decode('utf-8') - return response.Response({'token': encoded_jwt}, status=status.HTTP_200_OK) + token = api_utils.generate_bearer_token(user) + return response.Response({'token': token}, status=status.HTTP_200_OK) class AuthOTPLogin(knox_views.LoginView): @@ -98,11 +94,196 @@ class AuthResendOTP(rest_framework_views.APIView): return response.Response(status=status.HTTP_204_NO_CONTENT) +class CartUpdate(rest_framework_views.APIView): + permission_classes = [rest_framework_permissions.IsAuthenticated] + serializer_class = api_serializers.CartUpdateSerializer + + def get_serializer(self, *args, **kwargs): + return self.serializer_class(*args, **kwargs) + + def post(self, request, _format=None): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + user = request.user + product = shortcuts.get_object_or_404( + models.Product, + id=serializer.validated_data['product'] + ) + shopping_cart = models.ShoppingCart.objects.get(user=user) + cart_item, _created = models.CartItem.objects.get_or_create( + product=product, + shopping_cart=shopping_cart + ) + if serializer.validated_data['quantity'] == 0: + cart_item.delete() + else: + cart_item.quantity = serializer.validated_data['quantity'] + cart_item.save() + return response.Response(status=status.HTTP_204_NO_CONTENT) + + +class CartOverview(rest_framework_views.APIView): + permission_classes = [rest_framework_permissions.IsAuthenticated] + + def get(self, request, _format=None): + user = request.user + shopping_cart = models.ShoppingCart.objects.get(user=user) + item_subtotal = sum( + cart_item.product.price * cart_item.quantity + for cart_item in shopping_cart.cart_items.all() + ) + shipping_costs = api_utils.get_shipping_costs(user) + if shipping_costs is None: + return response.Response( + {'item_subtotal': str(item_subtotal)}, + status=status.HTTP_200_OK + ) + return response.Response( + {'item_subtotal': str(item_subtotal), 'shipping_costs': str(shipping_costs)}, + status=status.HTTP_200_OK + ) + + +class CartCheckout(rest_framework_views.APIView): + permission_classes = [rest_framework_permissions.IsAuthenticated] + serializer_class = api_serializers.CartCheckoutSerializer + + def get_serializer(self, *args, **kwargs): + return self.serializer_class(*args, **kwargs) + + def post(self, request, _format=None): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + user = request.user + shipment_config = home_industry_utils.get_shipment_config() + if user.sub_district.lower() != shipment_config.sub_district.lower(): + raise rest_framework_exceptions.ParseError(text.format_lazy( + 'Cannot process shipment to other sub-districts other than {sub_district}.', + sub_district=shipment_config.sub_district + )) + shopping_cart = models.ShoppingCart.objects.get(user=user) + cart_items = shopping_cart.cart_items.all() + if not cart_items.exists(): + raise rest_framework_exceptions.ParseError(_( + 'Unable to checkout because there are no items purchased.' + )) + api_utils.validate_product_stock(cart_items) + transaction = models.Transaction.objects.create( + user=user, + payment_method=serializer.validated_data['payment_method'], + donation=serializer.validated_data['donation'] + ) + is_success = True + for cart_item in cart_items: + product = cart_item.product + try: + with db_transaction.atomic(): + product.stock -= cart_item.quantity + product.save() + except db_utils.IntegrityError: + is_success = False + models.TransactionItem.objects.create( + transaction=transaction, + product=product, + quantity=cart_item.quantity + ) + cart_item.delete() + if not is_success: + transaction.transaction_status = '007' + transaction.save() + raise rest_framework_exceptions.APIException(_('Checkout failed.')) + return response.Response({'transaction': transaction.id}, status=status.HTTP_200_OK) + + +class CartUploadPOP(rest_framework_views.APIView): + permission_classes = [rest_framework_permissions.IsAuthenticated] + serializer_class = api_serializers.CartUploadPOPSerializer + + def get_serializer(self, *args, **kwargs): + return self.serializer_class(*args, **kwargs) + + def post(self, request, _format=None): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + user = request.user + transaction = shortcuts.get_object_or_404( + models.Transaction, + id=serializer.validated_data['transaction'], + user=user + ) + if transaction.payment_method != 'TRF': + raise rest_framework_exceptions.PermissionDenied( + _('The payment method for this transaction is not a transfer.') + ) + if transaction.transaction_status not in ('001', '002'): + raise rest_framework_exceptions.PermissionDenied( + _('Cannot upload proof of payment at this stage.') + ) + transaction.proof_of_payment = serializer.validated_data['proof_of_payment'] + transaction.user_bank_account_name = serializer.validated_data['user_bank_account_name'] + transaction.user_bank_account_number = ( + serializer.validated_data['user_bank_account_number'] + ) + transaction.transaction_status = '002' + transaction.save() + return response.Response(status=status.HTTP_204_NO_CONTENT) + + +class CartCompleteTransaction(rest_framework_views.APIView): + permission_classes = [rest_framework_permissions.IsAuthenticated] + serializer_class = api_serializers.CartCompleteOrCancelTransactionSerializer + + def get_serializer(self, *args, **kwargs): + return self.serializer_class(*args, **kwargs) + + def post(self, request, _format=None): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + user = request.user + transaction = shortcuts.get_object_or_404( + models.Transaction, + id=serializer.validated_data['transaction'], + user=user + ) + if transaction.transaction_status != '004': + raise rest_framework_exceptions.PermissionDenied( + _('Transaction cannot be completed unless the status is \"Being shipped\".') + ) + transaction.transaction_status = '005' + transaction.save() + return response.Response(status=status.HTTP_204_NO_CONTENT) + + +class CartCancelTransaction(rest_framework_views.APIView): + permission_classes = [rest_framework_permissions.IsAuthenticated] + serializer_class = api_serializers.CartCompleteOrCancelTransactionSerializer + + def get_serializer(self, *args, **kwargs): + return self.serializer_class(*args, **kwargs) + + def post(self, request, _format=None): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + user = request.user + transaction = shortcuts.get_object_or_404( + models.Transaction, + id=serializer.validated_data['transaction'], + user=user + ) + if transaction.transaction_status not in ('001', '002'): + raise rest_framework_exceptions.PermissionDenied( + _('Transaction cannot be canceled at this stage.') + ) + transaction.transaction_status = '006' + transaction.save() + return response.Response(status=status.HTTP_204_NO_CONTENT) + + class UserList(generics.ListCreateAPIView): filter_backends = [ - filters.OrderingFilter, - filters.SearchFilter, rest_framework.DjangoFilterBackend, + rest_framework_filters.OrderingFilter, + rest_framework_filters.SearchFilter, ] filterset_fields = ['username', 'phone_number'] ordering_fields = ['username', 'full_name', 'phone_number'] @@ -129,9 +310,9 @@ class UserDetail(generics.RetrieveUpdateDestroyAPIView): class CategoryList(generics.ListCreateAPIView): filter_backends = [ - filters.OrderingFilter, - filters.SearchFilter, rest_framework.DjangoFilterBackend, + rest_framework_filters.OrderingFilter, + rest_framework_filters.SearchFilter, ] filterset_fields = ['name'] ordering_fields = ['name'] @@ -158,18 +339,17 @@ class CategoryDetail(generics.RetrieveUpdateDestroyAPIView): try: obj.delete() except deletion.ProtectedError: - return response.Response( - {'detail': _('Cannot delete category due to integrity error.')}, - status=status.HTTP_409_CONFLICT - ) + raise api_exceptions.IntegrityError(_( + 'Cannot delete category due to integrity error.' + )) return response.Response(status=status.HTTP_204_NO_CONTENT) class SubcategoryList(generics.ListCreateAPIView): filter_backends = [ - filters.OrderingFilter, - filters.SearchFilter, rest_framework.DjangoFilterBackend, + rest_framework_filters.OrderingFilter, + rest_framework_filters.SearchFilter, ] filterset_fields = ['name', 'category'] ordering_fields = ['name'] @@ -196,18 +376,17 @@ class SubcategoryDetail(generics.RetrieveUpdateDestroyAPIView): try: obj.delete() except deletion.ProtectedError: - return response.Response( - {'detail': _('Cannot delete subcategory due to integrity error.')}, - status=status.HTTP_409_CONFLICT - ) + raise api_exceptions.IntegrityError(_( + 'Cannot delete subcategory due to integrity error.' + )) return response.Response(status=status.HTTP_204_NO_CONTENT) class ProductList(generics.ListCreateAPIView): filter_backends = [ - filters.OrderingFilter, - filters.SearchFilter, rest_framework.DjangoFilterBackend, + rest_framework_filters.OrderingFilter, + rest_framework_filters.SearchFilter, ] filterset_fields = ['code', 'subcategory', 'subcategory__category'] ordering_fields = ['name', 'price', 'stock'] @@ -230,14 +409,68 @@ class ProductDetail(generics.RetrieveUpdateDestroyAPIView): serializer_class = api_serializers.ProductSerializer +class ShoppingCartList(generics.ListAPIView): + filter_backends = [rest_framework.DjangoFilterBackend] + filterset_fields = ['user'] + pagination_class = paginations.SmallResultsSetPagination + permission_classes = [rest_framework_permissions.IsAdminUser] + queryset = models.ShoppingCart.objects.all() + serializer_class = api_serializers.ShoppingCartSerializer + + +class ShoppingCartDetail(generics.RetrieveAPIView): + permission_classes = [ + api_permissions.IsAdminUserOrOwner, + rest_framework_permissions.IsAuthenticated, + ] + queryset = models.ShoppingCart.objects.all() + serializer_class = api_serializers.ShoppingCartSerializer + + def get_object(self): + if (self.kwargs.get('pk') == 'self') and (self.request.user): + self.kwargs['pk'] = models.ShoppingCart.objects.get(user=self.request.user).pk + return super().get_object() + + +class TransactionList(generics.ListAPIView): + filter_backends = [ + rest_framework.DjangoFilterBackend, + rest_framework_filters.OrderingFilter, + rest_framework_filters.SearchFilter, + ] + filterset_class = api_filters.TransactionFilter + ordering_fields = ['created_at', 'updated_at'] + pagination_class = paginations.SmallResultsSetPagination + permission_classes = [rest_framework_permissions.IsAuthenticated] + queryset = models.Transaction.objects.all() + schema = schemas.TransactionListViewSchema() + search_fields = ['transaction_number', 'user_full_name'] + serializer_class = api_serializers.TransactionSerializer + + def filter_queryset(self, queryset): + queryset = super().filter_queryset(queryset) + if not self.request.user.is_staff: + return queryset.filter(user=self.request.user) + return queryset + + +class TransactionDetail(generics.RetrieveUpdateAPIView): + permission_classes = [ + api_permissions.IsAdminUserOrOwnerReadOnly, + rest_framework_permissions.IsAuthenticated, + ] + queryset = models.Transaction.objects.all() + serializer_class = api_serializers.TransactionSerializer + + class ProgramList(generics.ListCreateAPIView): filter_backends = [ - filters.OrderingFilter, - filters.SearchFilter, rest_framework.DjangoFilterBackend, + rest_framework_filters.OrderingFilter, + rest_framework_filters.SearchFilter, ] - filterset_fields = ['code', 'name'] - ordering_fields = ['name', 'start_date_time'] + filterset_fields = ['code'] + ordering_fields = ['name', 'start_date_time', 'end_date_time'] pagination_class = paginations.SmallResultsSetPagination permission_classes = [ api_permissions.IsAdminUserOrReadOnly, @@ -257,6 +490,26 @@ class ProgramDetail(generics.RetrieveUpdateDestroyAPIView): serializer_class = api_serializers.ProgramSerializer +class PaymentMethodChoices(rest_framework_views.APIView): + permission_classes = [rest_framework_permissions.IsAuthenticated] + + def get(self, request, _format=None): + return response.Response( + api_utils.map_choices(constants.PAYMENT_METHOD_CHOICES), + status=status.HTTP_200_OK + ) + + +class TransactionStatusChoices(rest_framework_views.APIView): + permission_classes = [rest_framework_permissions.IsAuthenticated] + + def get(self, request, _format=None): + return response.Response( + api_utils.map_choices(constants.TRANSACTION_STATUS_CHOICES), + status=status.HTTP_200_OK + ) + + class AppConfigDetail(generics.RetrieveUpdateAPIView): permission_classes = [rest_framework_permissions.IsAdminUser] queryset = models.AppConfig.objects.all() @@ -267,21 +520,21 @@ class AppConfigDetail(generics.RetrieveUpdateAPIView): return obj -class ShipmentConfigDetail(generics.RetrieveUpdateAPIView): +class BankAccountConfigDetail(generics.RetrieveUpdateAPIView): permission_classes = [api_permissions.IsAdminUserOrReadOnly] - queryset = models.ShipmentConfig.objects.all() - serializer_class = api_serializers.ShipmentConfigSerializer + queryset = models.BankAccountConfig.objects.all() + serializer_class = api_serializers.BankAccountConfigSerializer def get_object(self): - obj = shortcuts.get_object_or_404(models.ShipmentConfig) + obj = shortcuts.get_object_or_404(models.BankAccountConfig) return obj -class BankAccountConfigDetail(generics.RetrieveUpdateAPIView): +class ShipmentConfigDetail(generics.RetrieveUpdateAPIView): permission_classes = [api_permissions.IsAdminUserOrReadOnly] - queryset = models.BankAccountConfig.objects.all() - serializer_class = api_serializers.BankAccountConfigSerializer + queryset = models.ShipmentConfig.objects.all() + serializer_class = api_serializers.ShipmentConfigSerializer def get_object(self): - obj = shortcuts.get_object_or_404(models.BankAccountConfig) + obj = shortcuts.get_object_or_404(models.ShipmentConfig) return obj diff --git a/api_config.yaml b/api_config.yaml new file mode 100644 index 0000000..d53a0d9 --- /dev/null +++ b/api_config.yaml @@ -0,0 +1,13 @@ +app_config: + send_sms: no +bank_account_config: + bank_name: "Dummy Bank Name" + account_number: "0123456789" + account_owner: "Dummy Account Owner" +shipment_config: + hamlet: "001" + urban_village: "Dummy Urban Village" + sub_district: "Dummy Sub-District" + same_hamlet_costs: "1000" + same_urban_village_different_hamlet_costs: "2000" + same_sub_district_different_urban_village_costs: "3000" diff --git a/home_industry/utils.py b/home_industry/utils.py index 81e204f..73516da 100644 --- a/home_industry/utils.py +++ b/home_industry/utils.py @@ -6,36 +6,33 @@ from rest_framework import exceptions as rest_framework_exceptions from api import models -def get_shipping_costs(user): +def get_app_config(): try: - shipment_config = models.ShipmentConfig.objects.get() - except models.ShipmentConfig.DoesNotExist: - raise rest_framework_exceptions.APIException(_( - 'A bad configuration error occurred.' - )) - if user.sub_district.lower() == shipment_config.sub_district.lower(): - if user.urban_village.lower() == shipment_config.urban_village.lower(): - if user.hamlet == shipment_config.hamlet: - shipping_costs = shipment_config.same_hamlet_costs - else: - shipping_costs = ( - shipment_config.same_urban_village_different_hamlet_costs - ) - else: - shipping_costs = ( - shipment_config.same_sub_district_different_urban_village_costs - ) - else: - shipping_costs = None - return shipping_costs + app_config = models.AppConfig.objects.get() + except models.AppConfig.DoesNotExist: + raise rest_framework_exceptions.APIException(_('A bad configuration error occurred.')) + return app_config -def send_sms(phone_number, message): +def get_aws_settings(): try: aws_settings = conf.settings.AWS - app_config = models.AppConfig.objects.get() - except (AttributeError, models.AppConfig.DoesNotExist): + except AttributeError: raise rest_framework_exceptions.APIException(_('A bad configuration error occurred.')) + return aws_settings + + +def get_shipment_config(): + try: + shipment_config = models.ShipmentConfig.objects.get() + except models.ShipmentConfig.DoesNotExist: + raise rest_framework_exceptions.APIException(_('A bad configuration error occurred.')) + return shipment_config + + +def send_sms(phone_number, message): + aws_settings = get_aws_settings() + app_config = get_app_config() if not app_config.send_sms: raise rest_framework_exceptions.APIException(_('Server is currently unable to send SMS.')) client = boto3.client( diff --git a/requirements.txt b/requirements.txt index fdd7c38..2ddef89 100644 --- a/requirements.txt +++ b/requirements.txt @@ -42,6 +42,7 @@ pylint-django==2.0.13 pylint-plugin-utils==0.6 python-dateutil==2.8.1 pytz==2019.3 +PyYAML==5.3.1 requests==2.23.0 s3transfer==0.3.3 shortuuid==1.0.1 diff --git a/sonar-project.properties b/sonar-project.properties index e07163f..bdbc0ee 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,8 +1,5 @@ -sonar.exclusions=**/tests.py,**/migrations/** +sonar.exclusions=**/schemas.py,**/seeds.py,**/tests.py,**/management/**,**/migrations/** sonar.python.coverage.reportPaths=coverage.xml sonar.scm.provider=git sonar.sourceEncoding=UTF-8 sonar.sources=api -sonar.issue.ignore.multicriteria=p1 -sonar.issue.ignore.multicriteria.p1.ruleKey=python:S2245 -sonar.issue.ignore.multicriteria.p1.resourceKey=**/*.py -- GitLab From 4b5feb888616da658aa20c3f57514d64c5c9b244 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Tue, 21 Apr 2020 23:47:38 +0700 Subject: [PATCH 64/90] Add total_transactions to UserSerializer --- api/serializers.py | 7 +++++++ api/views.py | 6 +++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/api/serializers.py b/api/serializers.py index 4dbc6f5..536704e 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -44,6 +44,8 @@ class CartCompleteOrCancelTransactionSerializer(serializers.Serializer): # pylin class UserSerializer(serializers.ModelSerializer): + total_transactions = serializers.SerializerMethodField('get_total_transactions') + class Meta: extra_kwargs = {'password': {'write_only': True}} fields = [ @@ -58,10 +60,15 @@ class UserSerializer(serializers.ModelSerializer): 'urban_village', 'sub_district', 'profile_picture', + 'total_transactions', ] model = models.User read_only_fields = ['id'] + def get_total_transactions(self, obj): # pylint: disable=no-self-use + total_transactions = obj.transactions.count() + return total_transactions + def create(self, validated_data): password = validated_data.pop('password', None) instance = self.Meta.model(**validated_data) diff --git a/api/views.py b/api/views.py index 12e5b77..69997de 100644 --- a/api/views.py +++ b/api/views.py @@ -1,6 +1,6 @@ from django import shortcuts from django.contrib import auth -from django.db import utils as db_utils, transaction as db_transaction +from django.db import transaction as db_transaction, utils as db_utils from django.db.models import deletion from django.utils import text from django.utils.translation import gettext_lazy as _ @@ -304,7 +304,7 @@ class UserDetail(generics.RetrieveUpdateDestroyAPIView): def get_object(self): if (self.kwargs.get('pk') == 'self') and (self.request.user): - self.kwargs['pk'] = self.request.user.pk + self.kwargs['pk'] = self.request.user.id return super().get_object() @@ -428,7 +428,7 @@ class ShoppingCartDetail(generics.RetrieveAPIView): def get_object(self): if (self.kwargs.get('pk') == 'self') and (self.request.user): - self.kwargs['pk'] = models.ShoppingCart.objects.get(user=self.request.user).pk + self.kwargs['pk'] = models.ShoppingCart.objects.get(user=self.request.user).id return super().get_object() -- GitLab From c8af591069569626f30facdeeaedb92396dd595b Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Sun, 26 Apr 2020 02:33:15 +0700 Subject: [PATCH 65/90] Update Product and Transaction API --- api/migrations/0005_auto_20200426_0108.py | 23 ++++++++++ api/migrations/0006_auto_20200426_0113.py | 18 ++++++++ api/models.py | 3 +- api/serializers.py | 21 +++++++++ api/tests.py | 54 +++++++++++++++++++++++ api/utils.py | 7 +++ api/views.py | 23 ++++++++-- home_industry/settings/local.py | 2 +- home_industry/settings/production.py | 7 +++ home_industry/settings/staging.py | 7 +++ 10 files changed, 159 insertions(+), 6 deletions(-) create mode 100644 api/migrations/0005_auto_20200426_0108.py create mode 100644 api/migrations/0006_auto_20200426_0113.py diff --git a/api/migrations/0005_auto_20200426_0108.py b/api/migrations/0005_auto_20200426_0108.py new file mode 100644 index 0000000..d61102d --- /dev/null +++ b/api/migrations/0005_auto_20200426_0108.py @@ -0,0 +1,23 @@ +# Generated by Django 3.0.3 on 2020-04-25 18:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0004_auto_20200420_1446'), + ] + + operations = [ + migrations.AddField( + model_name='product', + name='po', + field=models.BooleanField(default=False, verbose_name='pre-order'), + ), + migrations.AlterField( + model_name='product', + name='stock', + field=models.PositiveIntegerField(blank=True, null=True, verbose_name='stock'), + ), + ] diff --git a/api/migrations/0006_auto_20200426_0113.py b/api/migrations/0006_auto_20200426_0113.py new file mode 100644 index 0000000..0823737 --- /dev/null +++ b/api/migrations/0006_auto_20200426_0113.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.3 on 2020-04-25 18:13 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0005_auto_20200426_0108'), + ] + + operations = [ + migrations.RenameField( + model_name='product', + old_name='po', + new_name='pre_order', + ), + ] diff --git a/api/models.py b/api/models.py index 9a9c234..2b42bc3 100644 --- a/api/models.py +++ b/api/models.py @@ -114,7 +114,8 @@ class Product(db_models.Model): validators=[validators.MinValueValidator(decimal.Decimal('0.01'))], verbose_name=_('price') ) - stock = db_models.PositiveIntegerField(verbose_name=_('stock')) + stock = db_models.PositiveIntegerField(blank=True, null=True, verbose_name=_('stock')) + pre_order = db_models.BooleanField(default=False, verbose_name=_('pre-order')) image = db_models.ImageField( blank=True, null=True, diff --git a/api/serializers.py b/api/serializers.py index 536704e..6d72273 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -133,11 +133,23 @@ class ProductSerializer(serializers.ModelSerializer): 'description', 'price', 'stock', + 'pre_order', 'image', ] model = models.Product read_only_fields = ['id', 'code'] + def validate(self, attrs): + instance = self.instance + stock = attrs.get('stock', getattr(instance, 'stock', None)) + pre_order = attrs.get('pre_order', getattr(instance, 'pre_order', None)) + errors = {} + if (pre_order) and (stock is not None): + errors['pre_order'] = _('Pre-order products cannot have stock data.') + if errors: + raise serializers.ValidationError(errors) + return super().validate(attrs) + class CartItemSerializer(serializers.ModelSerializer): product = ProductSerializer(read_only=True) @@ -265,6 +277,15 @@ class TransactionSerializer(serializers.ModelSerializer): obj.donation) return str(subtotal) + def validate(self, attrs): + transaction_status = attrs['transaction_status'] + errors = {} + if transaction_status == '007': + errors['transaction_status'] = _('Cannot update transaction status to failed.') + if errors: + raise serializers.ValidationError(errors) + return super().validate(attrs) + class ProgramSerializer(serializers.ModelSerializer): class Meta: diff --git a/api/tests.py b/api/tests.py index 47c30be..361ce11 100644 --- a/api/tests.py +++ b/api/tests.py @@ -911,9 +911,12 @@ class ProductTest(rest_framework_test.APITestCase): self.assertEqual(models.Product.objects.get(id=response.data['id']).name, data['name']) def test_create_product_fail(self): + data = dict(seeds.PRODUCT_DATA, subcategory=self.subcategory.id) + data['pre_order'] = True response = request( 'POST', 'product-list', + data, http_authorization=self.superuser_http_authorization ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) @@ -1068,6 +1071,57 @@ class TransactionTest(rest_framework_test.APITestCase): ) self.assertEqual(response.status_code, status.HTTP_200_OK) + def test_update_transaction_success(self): + transaction = models.Transaction.objects.create(**dict( + seeds.TRANSACTION_DATA, user=self.user + )) + data = { + 'transaction_status': '006', + } + response = request( + 'PATCH', + 'transaction-detail', + data, + http_authorization=self.superuser_http_authorization, + url_args=[transaction.id] + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + models.Transaction.objects.get(id=transaction.id).transaction_status, + data['transaction_status'] + ) + + def test_update_transaction_fail(self): + transaction = models.Transaction.objects.create(**dict( + seeds.TRANSACTION_DATA, user=self.user + )) + transaction.transaction_status = '005' + transaction.save() + data = { + 'transaction_status': '006', + } + response = request( + 'PATCH', + 'transaction-detail', + data, + http_authorization=self.superuser_http_authorization, + url_args=[transaction.id] + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + transaction.transaction_status = '003' + transaction.save() + data = { + 'transaction_status': '007', + } + response = request( + 'PATCH', + 'transaction-detail', + data, + http_authorization=self.superuser_http_authorization, + url_args=[transaction.id] + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + class TransactionItemTest(rest_framework_test.APITestCase): def setUp(self): diff --git a/api/utils.py b/api/utils.py index e209f09..6b7d15f 100644 --- a/api/utils.py +++ b/api/utils.py @@ -87,6 +87,13 @@ def map_choices(choices): return [{'code': choice[0], 'description': choice[1]} for choice in choices] +def return_transaction_items_to_product_stock(transaction_items): + for transaction_item in transaction_items: + if transaction_item.product is not None: + transaction_item.product.stock += transaction_item.quantity + transaction_item.product.save() + + def send_otp(phone_number, otp): message = text.format_lazy( 'Your Industri Pilar account authentication code is {otp}. ' diff --git a/api/views.py b/api/views.py index 69997de..e2693bd 100644 --- a/api/views.py +++ b/api/views.py @@ -274,6 +274,8 @@ class CartCancelTransaction(rest_framework_views.APIView): raise rest_framework_exceptions.PermissionDenied( _('Transaction cannot be canceled at this stage.') ) + transaction_items = transaction.transaction_items.all() + api_utils.return_transaction_items_to_product_stock(transaction_items) transaction.transaction_status = '006' transaction.save() return response.Response(status=status.HTTP_204_NO_CONTENT) @@ -335,9 +337,9 @@ class CategoryDetail(generics.RetrieveUpdateDestroyAPIView): serializer_class = api_serializers.CategorySerializer def destroy(self, request, *_args, **_kwargs): - obj = self.get_object() + instance = self.get_object() try: - obj.delete() + instance.delete() except deletion.ProtectedError: raise api_exceptions.IntegrityError(_( 'Cannot delete category due to integrity error.' @@ -372,9 +374,9 @@ class SubcategoryDetail(generics.RetrieveUpdateDestroyAPIView): serializer_class = api_serializers.SubcategorySerializer def destroy(self, request, *_args, **_kwargs): - obj = self.get_object() + instance = self.get_object() try: - obj.delete() + instance.delete() except deletion.ProtectedError: raise api_exceptions.IntegrityError(_( 'Cannot delete subcategory due to integrity error.' @@ -462,6 +464,19 @@ class TransactionDetail(generics.RetrieveUpdateAPIView): queryset = models.Transaction.objects.all() serializer_class = api_serializers.TransactionSerializer + def update(self, request, *args, **kwargs): + instance = self.get_object() + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + if instance.transaction_status in ('005', '006', '007'): + raise rest_framework_exceptions.PermissionDenied(_( + 'Cannot update transaction because it has a completed, canceled, or failed status.' + )) + if serializer.validated_data['transaction_status'] == '006': + transaction_items = instance.transaction_items.all() + api_utils.return_transaction_items_to_product_stock(transaction_items) + return super().update(request, *args, **kwargs) # pylint: disable=no-member + class ProgramList(generics.ListCreateAPIView): filter_backends = [ diff --git a/home_industry/settings/local.py b/home_industry/settings/local.py index 54e47c5..d886c94 100644 --- a/home_industry/settings/local.py +++ b/home_industry/settings/local.py @@ -11,7 +11,7 @@ SECRET_KEY = os.environ['SECRET_KEY'] DEBUG = os.environ.get('DEBUG', True) != "False" -ALLOWED_HOSTS = ['.ngrok.io', '127.0.0.1', 'localhost'] +ALLOWED_HOSTS = ['127.0.0.1', 'localhost'] # Application definition diff --git a/home_industry/settings/production.py b/home_industry/settings/production.py index f6cb57f..e67ddb6 100644 --- a/home_industry/settings/production.py +++ b/home_industry/settings/production.py @@ -111,6 +111,13 @@ LOCALE_PATHS = [ os.path.join(BASE_DIR, 'locale'), ] +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/3.0/howto/static-files/ + +MEDIA_URL = '/media/' + +STATIC_URL = '/static/' + # Authentication # https://docs.djangoproject.com/en/3.0/topics/auth/ diff --git a/home_industry/settings/staging.py b/home_industry/settings/staging.py index 0945f93..b6fd230 100644 --- a/home_industry/settings/staging.py +++ b/home_industry/settings/staging.py @@ -105,6 +105,13 @@ LOCALE_PATHS = [ os.path.join(BASE_DIR, 'locale'), ] +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/3.0/howto/static-files/ + +MEDIA_URL = '/media/' + +STATIC_URL = '/static/' + # Authentication # https://docs.djangoproject.com/en/3.0/topics/auth/ -- GitLab From 169f5d90c95ac6cc54de4206aa9391ccb2d22b1f Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Sun, 26 Apr 2020 05:11:27 +0700 Subject: [PATCH 66/90] Complete Donation API --- api/constants.py | 5 + api/filters.py | 13 ++ api/migrations/0007_programdonation.py | 44 +++++++ api/migrations/0008_auto_20200426_0325.py | 19 +++ api/models.py | 72 +++++++++++ api/schemas.py | 47 +++++--- api/seeds.py | 6 + api/serializers.py | 66 ++++++++++ api/tests.py | 141 +++++++++++++++++++++- api/urls.py | 16 +++ api/utils.py | 10 +- api/views.py | 109 ++++++++++++++--- 12 files changed, 506 insertions(+), 42 deletions(-) create mode 100644 api/migrations/0007_programdonation.py create mode 100644 api/migrations/0008_auto_20200426_0325.py diff --git a/api/constants.py b/api/constants.py index 8ed11ee..30337da 100644 --- a/api/constants.py +++ b/api/constants.py @@ -1,5 +1,10 @@ from django.utils.translation import gettext_lazy as _ +DONATION_STATUS_CHOICES = [ + ('001', _('Waiting for admin confirmation')), + ('002', _('Completed')), +] + PAYMENT_METHOD_CHOICES = [ ('TRF', _('Transfer')), ('COD', _('Cash on delivery')), diff --git a/api/filters.py b/api/filters.py index cf4639b..4527b36 100644 --- a/api/filters.py +++ b/api/filters.py @@ -15,3 +15,16 @@ class TransactionFilter(django_filters.FilterSet): 'updated_at_date_range', ] model = models.Transaction + + +class ProgramDonationFilter(django_filters.FilterSet): + updated_at_date_range = django_filters.DateFromToRangeFilter(field_name='updated_at') + + class Meta: + fields = [ + 'donation_number', + 'user', + 'donation_status', + 'updated_at_date_range', + ] + model = models.ProgramDonation diff --git a/api/migrations/0007_programdonation.py b/api/migrations/0007_programdonation.py new file mode 100644 index 0000000..e2d0f06 --- /dev/null +++ b/api/migrations/0007_programdonation.py @@ -0,0 +1,44 @@ +# Generated by Django 3.0.3 on 2020-04-25 20:21 + +import api.utils +from decimal import Decimal +from django.conf import settings +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import phonenumber_field.modelfields +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0006_auto_20200426_0113'), + ] + + operations = [ + migrations.CreateModel( + name='ProgramDonation', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='ID')), + ('donation_number', models.CharField(default=api.utils.generate_transaction_number, max_length=8, unique=True, verbose_name='donation number')), + ('user_full_name', models.CharField(max_length=200, verbose_name='user full name')), + ('user_phone_number', phonenumber_field.modelfields.PhoneNumberField(max_length=128, region=None, verbose_name='user phone number')), + ('program_name', models.CharField(max_length=200, verbose_name='program name')), + ('amount', models.DecimalField(decimal_places=2, max_digits=12, validators=[django.core.validators.MinValueValidator(Decimal('0.01'))], verbose_name='amount')), + ('donation_status', models.CharField(choices=[('001', 'Waiting for admin confirmation'), ('002', 'Completed')], default='001', max_length=3, verbose_name='donation status')), + ('proof_of_bank_transfer', models.ImageField(upload_to=api.utils.get_upload_file_path, verbose_name='proof of bank transfer')), + ('user_bank_account_name', models.CharField(max_length=200, verbose_name='user bank account name')), + ('user_bank_account_number', models.CharField(max_length=100, verbose_name='user bank account number')), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='created at')), + ('updated_at', models.DateTimeField(auto_now=True, db_index=True, verbose_name='updated at')), + ('program', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='program_donations', to='api.Program', verbose_name='program')), + ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='program_donations', to=settings.AUTH_USER_MODEL, verbose_name='user')), + ], + options={ + 'verbose_name': 'program donation', + 'verbose_name_plural': 'program donations', + 'ordering': ['-updated_at', '-created_at', 'donation_number', 'id'], + }, + ), + ] diff --git a/api/migrations/0008_auto_20200426_0325.py b/api/migrations/0008_auto_20200426_0325.py new file mode 100644 index 0000000..6c7e5e6 --- /dev/null +++ b/api/migrations/0008_auto_20200426_0325.py @@ -0,0 +1,19 @@ +# Generated by Django 3.0.3 on 2020-04-25 20:25 + +import api.utils +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0007_programdonation'), + ] + + operations = [ + migrations.AlterField( + model_name='programdonation', + name='donation_number', + field=models.CharField(default=api.utils.generate_donation_number, max_length=6, unique=True, verbose_name='donation number'), + ), + ] diff --git a/api/models.py b/api/models.py index 2b42bc3..9275634 100644 --- a/api/models.py +++ b/api/models.py @@ -360,6 +360,78 @@ class Program(db_models.Model): return self.code +class ProgramDonation(db_models.Model): + id = db_models.UUIDField(default=uuid.uuid4, primary_key=True, verbose_name=_('ID')) + donation_number = db_models.CharField( + default=utils.generate_donation_number, + max_length=6, + unique=True, + verbose_name=_('donation number') + ) + user = db_models.ForeignKey( + 'api.User', + null=True, + on_delete=db_models.SET_NULL, + related_name='program_donations', + verbose_name=_('user') + ) + program = db_models.ForeignKey( + 'api.Program', + null=True, + on_delete=db_models.SET_NULL, + related_name='program_donations', + verbose_name=_('program') + ) + user_full_name = db_models.CharField(max_length=200, verbose_name=_('user full name')) + user_phone_number = modelfields.PhoneNumberField(verbose_name=_('user phone number')) + program_name = db_models.CharField(max_length=200, verbose_name=_('program name')) + amount = db_models.DecimalField( + decimal_places=2, + max_digits=12, + validators=[validators.MinValueValidator(decimal.Decimal('0.01'))], + verbose_name=_('amount') + ) + donation_status = db_models.CharField( + choices=constants.DONATION_STATUS_CHOICES, + default='001', + max_length=3, + verbose_name=_('donation status') + ) + proof_of_bank_transfer = db_models.ImageField( + upload_to=utils.get_upload_file_path, + verbose_name=_('proof of bank transfer') + ) + user_bank_account_name = db_models.CharField( + max_length=200, + verbose_name=_('user bank account name') + ) + user_bank_account_number = db_models.CharField( + max_length=100, + verbose_name=_('user bank account number') + ) + created_at = db_models.DateTimeField( + auto_now_add=True, + db_index=True, + verbose_name=_('created at') + ) + updated_at = db_models.DateTimeField(auto_now=True, db_index=True, verbose_name=_('updated at')) + + class Meta: + ordering = ['-updated_at', '-created_at', 'donation_number', 'id'] + verbose_name = _('program donation') + verbose_name_plural = _('program donations') + + def save(self, *args, **kwargs): # pylint: disable=arguments-differ + if self._state.adding: + self.user_full_name = self.user.full_name + self.user_phone_number = self.user.phone_number + self.program_name = self.program.name + super().save(*args, **kwargs) + + def __str__(self): + return self.donation_number + + class AppConfig(solo_models.SingletonModel): send_sms = db_models.BooleanField(default=False, verbose_name=_('send SMS')) diff --git a/api/schemas.py b/api/schemas.py index c63c57a..5d12865 100644 --- a/api/schemas.py +++ b/api/schemas.py @@ -3,25 +3,38 @@ import coreschema from rest_framework import schemas -class TransactionListViewSchema(schemas.AutoSchema): +class AutoSchemaWithDateRange(schemas.AutoSchema): + date_range_fields = [] + def get_filter_fields(self, path, method): + assert len(self.date_range_fields) != 0, ( + '{} should include a `date_range_fields` attribute.'.format(self.__class__.__name__) + ) filter_fields = super().get_filter_fields(path, method) - exclude = ['updated_at_date_range'] for filter_field in filter_fields: - if filter_field.name in exclude: + if filter_field.name in self.date_range_fields: filter_fields.remove(filter_field) - filter_fields += [ - coreapi.Field( - 'updated_at_date_range_after', - location='query', - required=False, - schema=coreschema.String() - ), - coreapi.Field( - 'updated_at_date_range_before', - location='query', - required=False, - schema=coreschema.String() - ), - ] + for date_range_field in self.date_range_fields: + filter_fields += [ + coreapi.Field( + '{}_after'.format(date_range_field), + location='query', + required=False, + schema=coreschema.String() + ), + coreapi.Field( + '{}_before'.format(date_range_field), + location='query', + required=False, + schema=coreschema.String() + ), + ] return filter_fields + + +class TransactionListSchema(AutoSchemaWithDateRange): + date_range_fields = ['updated_at_date_range'] + + +class ProgramDonationListSchema(AutoSchemaWithDateRange): + date_range_fields = ['updated_at_date_range'] diff --git a/api/seeds.py b/api/seeds.py index b437e6c..cef5665 100644 --- a/api/seeds.py +++ b/api/seeds.py @@ -49,6 +49,12 @@ PROGRAM_DATA = { 'speaker': 'Dummy Speaker', } +PROGRAM_DONATION_DATA = { + 'amount': '1000', + 'user_bank_account_name': 'Dummy User Bank Account Name', + 'user_bank_account_number': '0123456789', +} + BANK_ACCOUNT_CONFIG_DATA = { 'bank_name': 'Dummy Bank Name', 'account_number': '0123456789', diff --git a/api/serializers.py b/api/serializers.py index 6d72273..423e9f9 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -43,8 +43,21 @@ class CartCompleteOrCancelTransactionSerializer(serializers.Serializer): # pylin transaction = serializers.UUIDField() +class DonationCreateSerializer(serializers.Serializer): # pylint: disable=abstract-method + program = serializers.UUIDField() + amount = serializers.DecimalField( + decimal_places=2, + max_digits=12, + min_value=decimal.Decimal('0.01') + ) + proof_of_bank_transfer = serializers.ImageField() + user_bank_account_name = serializers.CharField(max_length=200) + user_bank_account_number = serializers.CharField(max_length=100) + + class UserSerializer(serializers.ModelSerializer): total_transactions = serializers.SerializerMethodField('get_total_transactions') + total_program_donations = serializers.SerializerMethodField('get_total_program_donations') class Meta: extra_kwargs = {'password': {'write_only': True}} @@ -61,6 +74,7 @@ class UserSerializer(serializers.ModelSerializer): 'sub_district', 'profile_picture', 'total_transactions', + 'total_program_donations', ] model = models.User read_only_fields = ['id'] @@ -69,6 +83,10 @@ class UserSerializer(serializers.ModelSerializer): total_transactions = obj.transactions.count() return total_transactions + def get_total_program_donations(self, obj): # pylint: disable=no-self-use + total_program_donations = obj.program_donations.count() + return total_program_donations + def create(self, validated_data): password = validated_data.pop('password', None) instance = self.Meta.model(**validated_data) @@ -316,6 +334,54 @@ class ProgramSerializer(serializers.ModelSerializer): return super().validate(attrs) +class ProgramDonationSerializer(serializers.ModelSerializer): + user_username = serializers.ReadOnlyField(source='user.username') + program_code = serializers.ReadOnlyField(source='program.code') + readable_donation_status = serializers.SerializerMethodField( + 'get_readable_donation_status' + ) + + class Meta: + fields = [ + 'id', + 'donation_number', + 'user', + 'program', + 'program_code', + 'user_username', + 'user_full_name', + 'user_phone_number', + 'program_name', + 'amount', + 'donation_status', + 'readable_donation_status', + 'proof_of_bank_transfer', + 'user_bank_account_name', + 'user_bank_account_number', + 'created_at', + 'updated_at', + ] + model = models.ProgramDonation + read_only_fields = [ + 'id', + 'donation_number', + 'user', + 'program', + 'user_full_name', + 'user_phone_number', + 'program_name', + 'amount', + 'proof_of_bank_transfer', + 'user_bank_account_name', + 'user_bank_account_number', + 'created_at', + 'updated_at', + ] + + def get_readable_donation_status(self, obj): # pylint: disable=no-self-use + return obj.get_donation_status_display() + + class AppConfigSerializer(serializers.ModelSerializer): class Meta: fields = ['send_sms'] diff --git a/api/tests.py b/api/tests.py index 361ce11..5e3492d 100644 --- a/api/tests.py +++ b/api/tests.py @@ -591,6 +591,45 @@ class CartTest(rest_framework_test.APITestCase): self.assertEqual(mock_validate_product_stock.call_count, 1) +class DonationTest(rest_framework_test.APITestCase): + def setUp(self): + self.user = create_user(seeds.USER_DATA) + self.user_http_authorization = get_http_authorization( + seeds.USER_DATA['username'], + seeds.USER_DATA['password'] + ) + self.proof_of_bank_transfer_file = create_tmp_image() + + def test_donation_create_success(self): + program = models.Program.objects.create(**seeds.PROGRAM_DATA) + with open(self.proof_of_bank_transfer_file.name, 'rb') as proof_of_bank_transfer: + data = { + 'program': program.id, + 'amount': '1000', + 'proof_of_bank_transfer': proof_of_bank_transfer, + 'user_bank_account_name': 'Dummy User Bank Account Name', + 'user_bank_account_number': '0123456789', + } + response = request( + 'POST', + 'donation-create', + data, + format='multipart', + http_authorization=self.user_http_authorization + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(models.ProgramDonation.objects.count(), 1) + + def test_donation_create_fail(self): + response = request( + 'POST', + 'donation-create', + http_authorization=self.user_http_authorization + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(models.ProgramDonation.objects.count(), 0) + + class UserTest(rest_framework_test.APITestCase): def setUp(self): self.superuser = models.User.objects.create_superuser(**seeds.SUPERUSER_DATA) @@ -1246,24 +1285,100 @@ class ProgramTest(rest_framework_test.APITestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) -class PaymentMethodTest(rest_framework_test.APITestCase): +class ProgramDonationTest(rest_framework_test.APITestCase): def setUp(self): self.superuser = models.User.objects.create_superuser(**seeds.SUPERUSER_DATA) self.superuser_http_authorization = get_http_authorization( seeds.SUPERUSER_DATA['username'], seeds.SUPERUSER_DATA['password'] ) + self.user = create_user(seeds.USER_DATA) + self.user_http_authorization = get_http_authorization( + seeds.USER_DATA['username'], + seeds.USER_DATA['password'] + ) + self.program = models.Program.objects.create(**seeds.PROGRAM_DATA) - def test_payment_method_choices_success(self): + def test_program_donation_model_string_representation(self): + program_donation = models.ProgramDonation.objects.create(**dict( + seeds.PROGRAM_DONATION_DATA, + user=self.user, + program=self.program + )) + self.assertEqual(len(str(program_donation)), 6) + + def test_program_donation_list_success(self): response = request( 'GET', - 'payment-method-choices', - http_authorization=self.superuser_http_authorization + 'program-donation-list', + http_authorization=self.superuser_http_authorization, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response = request( + 'GET', + 'program-donation-list', + http_authorization=self.user_http_authorization, ) self.assertEqual(response.status_code, status.HTTP_200_OK) + def test_program_donation_detail_success(self): + program_donation = models.ProgramDonation.objects.create(**dict( + seeds.PROGRAM_DONATION_DATA, + user=self.user, + program=self.program + )) + response = request( + 'GET', + 'program-donation-detail', + http_authorization=self.superuser_http_authorization, + url_args=[program_donation.id] + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_update_program_donation_success(self): + program_donation = models.ProgramDonation.objects.create(**dict( + seeds.PROGRAM_DONATION_DATA, + user=self.user, + program=self.program + )) + data = { + 'donation_status': '002', + } + response = request( + 'PATCH', + 'program-donation-detail', + data, + http_authorization=self.superuser_http_authorization, + url_args=[program_donation.id] + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + models.ProgramDonation.objects.get(id=program_donation.id).donation_status, + data['donation_status'] + ) + + def test_update_program_donation_fail(self): + program_donation = models.ProgramDonation.objects.create(**dict( + seeds.PROGRAM_DONATION_DATA, + user=self.user, + program=self.program + )) + program_donation.donation_status = '002' + program_donation.save() + data = { + 'donation_status': '001', + } + response = request( + 'PATCH', + 'program-donation-detail', + data, + http_authorization=self.superuser_http_authorization, + url_args=[program_donation.id] + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) -class TransactionStatusTest(rest_framework_test.APITestCase): + +class ChoicesViewsTest(rest_framework_test.APITestCase): def setUp(self): self.superuser = models.User.objects.create_superuser(**seeds.SUPERUSER_DATA) self.superuser_http_authorization = get_http_authorization( @@ -1271,6 +1386,14 @@ class TransactionStatusTest(rest_framework_test.APITestCase): seeds.SUPERUSER_DATA['password'] ) + def test_payment_method_choices_success(self): + response = request( + 'GET', + 'payment-method-choices', + http_authorization=self.superuser_http_authorization + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + def test_transaction_status_choices_success(self): response = request( 'GET', @@ -1279,6 +1402,14 @@ class TransactionStatusTest(rest_framework_test.APITestCase): ) self.assertEqual(response.status_code, status.HTTP_200_OK) + def test_donation_status_choices_success(self): + response = request( + 'GET', + 'donation-status-choices', + http_authorization=self.superuser_http_authorization + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + class AppConfigTest(rest_framework_test.APITestCase): def setUp(self): diff --git a/api/urls.py b/api/urls.py index b2015d7..39eff93 100644 --- a/api/urls.py +++ b/api/urls.py @@ -29,6 +29,7 @@ urlpatterns = [ api_views.CartCancelTransaction.as_view(), name='cart-cancel-transaction' ), + urls.path('donation/create/', api_views.DonationCreate.as_view(), name='donation-create'), urls.path('users/', api_views.UserList.as_view(), name='user-list'), urls.path('users//', api_views.UserDetail.as_view(), name='user-detail'), urls.path('categories/', api_views.CategoryList.as_view(), name='category-list'), @@ -55,6 +56,16 @@ urlpatterns = [ ), urls.path('programs/', api_views.ProgramList.as_view(), name='program-list'), urls.path('programs//', api_views.ProgramDetail.as_view(), name='program-detail'), + urls.path( + 'program-donations/', + api_views.ProgramDonationList.as_view(), + name='program-donation-list' + ), + urls.path( + 'program-donations//', + api_views.ProgramDonationDetail.as_view(), + name='program-donation-detail' + ), urls.path( 'choices/payment-method/', api_views.PaymentMethodChoices.as_view(), @@ -65,6 +76,11 @@ urlpatterns = [ api_views.TransactionStatusChoices.as_view(), name='transaction-status-choices' ), + urls.path( + 'choices/donation-status/', + api_views.DonationStatusChoices.as_view(), + name='donation-status-choices' + ), urls.path('configs/app/', api_views.AppConfigDetail.as_view(), name='app-config-detail'), urls.path( 'configs/bank-account/', diff --git a/api/utils.py b/api/utils.py index 6b7d15f..ce6de53 100644 --- a/api/utils.py +++ b/api/utils.py @@ -29,6 +29,12 @@ def generate_code(): return code +def generate_donation_number(): + alphabet = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ' + donation_number = shortuuid.ShortUUID(alphabet=alphabet).random(length=6) + return donation_number + + def generate_otp(): alphabet = '0123456789' otp = shortuuid.ShortUUID(alphabet=alphabet).random(length=6) @@ -89,7 +95,7 @@ def map_choices(choices): def return_transaction_items_to_product_stock(transaction_items): for transaction_item in transaction_items: - if transaction_item.product is not None: + if (transaction_item.product is not None) and (transaction_item.product.stock is not None): transaction_item.product.stock += transaction_item.quantity transaction_item.product.save() @@ -106,7 +112,7 @@ def send_otp(phone_number, otp): def validate_product_stock(cart_items): for cart_item in cart_items: product = cart_item.product - if cart_item.quantity > product.stock: + if (product.stock is not None) and (cart_item.quantity > product.stock): raise rest_framework_exceptions.ParseError(_( 'Failed to checkout because the purchased quantity of certain items exceeds ' 'the available stock.' diff --git a/api/views.py b/api/views.py index e2693bd..571a292 100644 --- a/api/views.py +++ b/api/views.py @@ -176,12 +176,13 @@ class CartCheckout(rest_framework_views.APIView): is_success = True for cart_item in cart_items: product = cart_item.product - try: - with db_transaction.atomic(): - product.stock -= cart_item.quantity - product.save() - except db_utils.IntegrityError: - is_success = False + if product.stock is not None: + try: + with db_transaction.atomic(): + product.stock -= cart_item.quantity + product.save() + except db_utils.IntegrityError: + is_success = False models.TransactionItem.objects.create( transaction=transaction, product=product, @@ -281,6 +282,34 @@ class CartCancelTransaction(rest_framework_views.APIView): return response.Response(status=status.HTTP_204_NO_CONTENT) +class DonationCreate(rest_framework_views.APIView): + permission_classes = [rest_framework_permissions.IsAuthenticated] + serializer_class = api_serializers.DonationCreateSerializer + + def get_serializer(self, *args, **kwargs): + return self.serializer_class(*args, **kwargs) + + def post(self, request, _format=None): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + user = request.user + program = shortcuts.get_object_or_404( + models.Program, + id=serializer.validated_data['program'] + ) + program_donation = models.ProgramDonation.objects.create( + user=user, + program=program, + amount=serializer.validated_data['amount'], + user_bank_account_name=serializer.validated_data['user_bank_account_name'], + user_bank_account_number=serializer.validated_data['user_bank_account_number'] + ) + return response.Response( + {'program_donation': program_donation.id}, + status=status.HTTP_200_OK + ) + + class UserList(generics.ListCreateAPIView): filter_backends = [ rest_framework.DjangoFilterBackend, @@ -445,7 +474,7 @@ class TransactionList(generics.ListAPIView): pagination_class = paginations.SmallResultsSetPagination permission_classes = [rest_framework_permissions.IsAuthenticated] queryset = models.Transaction.objects.all() - schema = schemas.TransactionListViewSchema() + schema = schemas.TransactionListSchema() search_fields = ['transaction_number', 'user_full_name'] serializer_class = api_serializers.TransactionSerializer @@ -496,6 +525,47 @@ class ProgramList(generics.ListCreateAPIView): serializer_class = api_serializers.ProgramSerializer +class ProgramDonationList(generics.ListAPIView): + filter_backends = [ + rest_framework.DjangoFilterBackend, + rest_framework_filters.OrderingFilter, + rest_framework_filters.SearchFilter, + ] + filterset_class = api_filters.ProgramDonationFilter + ordering_fields = ['created_at', 'updated_at'] + pagination_class = paginations.SmallResultsSetPagination + permission_classes = [rest_framework_permissions.IsAuthenticated] + queryset = models.ProgramDonation.objects.all() + schema = schemas.ProgramDonationListSchema() + search_fields = ['program_donation_number', 'user_full_name'] + serializer_class = api_serializers.ProgramDonationSerializer + + def filter_queryset(self, queryset): + queryset = super().filter_queryset(queryset) + if not self.request.user.is_staff: + return queryset.filter(user=self.request.user) + return queryset + + +class ProgramDonationDetail(generics.RetrieveUpdateAPIView): + permission_classes = [ + api_permissions.IsAdminUserOrOwnerReadOnly, + rest_framework_permissions.IsAuthenticated, + ] + queryset = models.ProgramDonation.objects.all() + serializer_class = api_serializers.ProgramDonationSerializer + + def update(self, request, *args, **kwargs): + instance = self.get_object() + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + if instance.donation_status == '002': + raise rest_framework_exceptions.PermissionDenied(_( + 'Cannot update donation because it has a completed status.' + )) + return super().update(request, *args, **kwargs) # pylint: disable=no-member + + class ProgramDetail(generics.RetrieveUpdateDestroyAPIView): permission_classes = [ api_permissions.IsAdminUserOrReadOnly, @@ -505,24 +575,27 @@ class ProgramDetail(generics.RetrieveUpdateDestroyAPIView): serializer_class = api_serializers.ProgramSerializer -class PaymentMethodChoices(rest_framework_views.APIView): +class ChoicesAPIView(rest_framework_views.APIView): + choices = None permission_classes = [rest_framework_permissions.IsAuthenticated] def get(self, request, _format=None): - return response.Response( - api_utils.map_choices(constants.PAYMENT_METHOD_CHOICES), - status=status.HTTP_200_OK + assert self.choices is not None, ( + '{} should include a `choices` attribute.'.format(self.__class__.__name__) ) + return response.Response(api_utils.map_choices(self.choices), status=status.HTTP_200_OK) -class TransactionStatusChoices(rest_framework_views.APIView): - permission_classes = [rest_framework_permissions.IsAuthenticated] +class PaymentMethodChoices(ChoicesAPIView): + choices = constants.PAYMENT_METHOD_CHOICES - def get(self, request, _format=None): - return response.Response( - api_utils.map_choices(constants.TRANSACTION_STATUS_CHOICES), - status=status.HTTP_200_OK - ) + +class TransactionStatusChoices(ChoicesAPIView): + choices = constants.TRANSACTION_STATUS_CHOICES + + +class DonationStatusChoices(ChoicesAPIView): + choices = constants.DONATION_STATUS_CHOICES class AppConfigDetail(generics.RetrieveUpdateAPIView): -- GitLab From 4db32557331ec8173068d0eee8f791c5cb7ac7c1 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Sun, 26 Apr 2020 06:40:21 +0700 Subject: [PATCH 67/90] Add Translation --- api/serializers.py | 49 +++- api/utils.py | 8 +- api/views.py | 8 +- locale/id/LC_MESSAGES/django.mo | Bin 3489 -> 9263 bytes locale/id/LC_MESSAGES/django.po | 424 ++++++++++++++++++++++++++++---- sonar-project.properties | 3 + 6 files changed, 430 insertions(+), 62 deletions(-) diff --git a/api/serializers.py b/api/serializers.py index 423e9f9..9307f3d 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -10,37 +10,66 @@ from api import constants, models class PhoneNumberSerializer(serializers.Serializer): # pylint: disable=abstract-method - phone_number = serializerfields.PhoneNumberField() + phone_number = serializerfields.PhoneNumberField(label=_('Phone number')) class OTPSerializer(serializers.Serializer): # pylint: disable=abstract-method - otp = serializers.CharField(max_length=6) + otp = serializers.CharField(label=_('OTP'), max_length=6) class CartUpdateSerializer(serializers.Serializer): # pylint: disable=abstract-method - product = serializers.UUIDField() - quantity = serializers.IntegerField(min_value=0) + product = serializers.UUIDField(label=_('Product')) + quantity = serializers.IntegerField(label=_('Quantity'), min_value=0) class CartCheckoutSerializer(serializers.Serializer): # pylint: disable=abstract-method - payment_method = serializers.ChoiceField(choices=constants.PAYMENT_METHOD_CHOICES) + payment_method = serializers.ChoiceField( + choices=constants.PAYMENT_METHOD_CHOICES, + label=_('Payment method') + ) donation = serializers.DecimalField( decimal_places=2, default=decimal.Decimal('0'), + label=_('Donation'), max_digits=12, min_value=decimal.Decimal('0') ) class CartUploadPOPSerializer(serializers.Serializer): # pylint: disable=abstract-method - transaction = serializers.UUIDField() - proof_of_payment = serializers.ImageField() - user_bank_account_name = serializers.CharField(max_length=200) - user_bank_account_number = serializers.CharField(max_length=100) + transaction = serializers.UUIDField(label=_('Transaction')) + proof_of_payment = serializers.ImageField(label=_('Proof of payment')) + user_bank_account_name = serializers.CharField( + label=_('User bank account name'), + max_length=200 + ) + user_bank_account_number = serializers.CharField( + label=_('User bank account number'), + max_length=100 + ) class CartCompleteOrCancelTransactionSerializer(serializers.Serializer): # pylint: disable=abstract-method - transaction = serializers.UUIDField() + transaction = serializers.UUIDField(label=_('Transaction')) + + +class DonationCreateSerializer(serializers.Serializer): # pylint: disable=abstract-method + program = serializers.UUIDField(label=_('Program')) + amount = serializers.DecimalField( + decimal_places=2, + label=_('Amount'), + max_digits=12, + min_value=decimal.Decimal('0.01') + ) + proof_of_bank_transfer = serializers.ImageField(label=_('Proof of bank transfer')) + user_bank_account_name = serializers.CharField( + label=_('User bank account name'), + max_length=200 + ) + user_bank_account_number = serializers.CharField( + label=_('User bank account number'), + max_length=100 + ) class DonationCreateSerializer(serializers.Serializer): # pylint: disable=abstract-method diff --git a/api/utils.py b/api/utils.py index ce6de53..fbe6033 100644 --- a/api/utils.py +++ b/api/utils.py @@ -4,7 +4,6 @@ import jwt import shortuuid from jwt import exceptions as jwt_exceptions from django import conf -from django.utils import text from django.utils.translation import gettext_lazy as _ from rest_framework import exceptions as rest_framework_exceptions @@ -101,11 +100,10 @@ def return_transaction_items_to_product_stock(transaction_items): def send_otp(phone_number, otp): - message = text.format_lazy( + message = _( # pylint: disable=no-member 'Your Industri Pilar account authentication code is {otp}. ' - 'Keep your authentication code SECRET.', - otp=otp - ) + 'Keep your authentication code SECRET.' + ).format(otp=otp) utils.send_sms(phone_number, message) diff --git a/api/views.py b/api/views.py index 571a292..98ce988 100644 --- a/api/views.py +++ b/api/views.py @@ -2,7 +2,6 @@ from django import shortcuts from django.contrib import auth from django.db import transaction as db_transaction, utils as db_utils from django.db.models import deletion -from django.utils import text from django.utils.translation import gettext_lazy as _ from django_filters import rest_framework from knox import views as knox_views @@ -157,10 +156,9 @@ class CartCheckout(rest_framework_views.APIView): user = request.user shipment_config = home_industry_utils.get_shipment_config() if user.sub_district.lower() != shipment_config.sub_district.lower(): - raise rest_framework_exceptions.ParseError(text.format_lazy( - 'Cannot process shipment to other sub-districts other than {sub_district}.', - sub_district=shipment_config.sub_district - )) + raise rest_framework_exceptions.ParseError(_( # pylint: disable=no-member + 'Cannot process shipment to other sub-districts other than {sub_district}.' + ).format(sub_district=shipment_config.sub_district)) shopping_cart = models.ShoppingCart.objects.get(user=user) cart_items = shopping_cart.cart_items.all() if not cart_items.exists(): diff --git a/locale/id/LC_MESSAGES/django.mo b/locale/id/LC_MESSAGES/django.mo index a63c4798c11a8cf2658e065bf0b5651c6d40b929..9d92140df9a70dc732b1c1bff56776807acb2952 100644 GIT binary patch literal 9263 zcmbuDZH!&VS;r@DBsDZCX__{nB!k8`8vUC-ttqPB!-XWp{1IX^T7ep0oEJ z-+RxwJ?C88Yu5xyg?KL#Bz#C)Dj-52A)!j42$dFzhBrm&2ZRt*4Mm`~d?}6c0g43T z_n$N8zO2`g3r9QmcV^DKJoC)+JTtSe-Fp3VhUXKccawHpZ_FRSJvZ^g^W81Rya&Dx ze*nJY2aS0vd^c2;`(Oh;1b4zFT!g<4cfr@-G5D?@GUk4G2F}3e;oIO>0{;%ah5R?6 z`u#3^7=8~v0v}+|-LMTE{B8IUyy0eJegy7{8g z8VF$b->*Z}yXm&7{Eon_P~Y!|?}YPE^`3!}w+mO{ z1*rA=Dm(~pdPgP4D&)^Zffu0q`&B5pe;2Bsm!RbR3Pcq13e~R?pxQT~)-{9b=K_@ee-83zewiQH+wVZh^QTb#y#gi2YjF29 z#{4UkJz6SjT|Wp_{{U3~$D#c6qwsO~X}Ac#7V`ICjHk#)@cr;hQ2zZo)VgiQNX1=H z`dEY-=NO!aPeb+l0@OJF2s3y)#;o$6g%R9p4Xw|`%kF#GCx|a!);LZdJo(VAAk?TQ&92bQ&9T* z6R3Ls0M+lmzyt6$KFTgX1b4v*%I-c3CEp)GRAs&z_-~M@nd=A&C*j>t>u?Tgo}Yk{ z_fvt-Lrm0sCh+qRlQMr9_;o0`-hi^J{|>y_R&w1PxDCpFc0$?V2cX88hZ=VgN^d71 zOJpLb@@Jve;g_J7EAV-!{yqcs{kNd>{~}aBUkH2|O1{4h{5n)W--43kJ5b~N7gYI; zKUS6B47FZ&LfPxqz@1R__lECFQ0sXdYW@k-cozac32_1QD}gUU$@xX7`me$R@SosA z@Lr5@FMI}S9xnv`9@IK~A@CKbe*QV|4XAN%cu%$dw?OIVeNg2)px1lgd?-H*CCAf& z36wuw2>D-tYX3qge=+1=g0j0W!*{~Jg0i=7K-tIlpyqwcd#if)!fVLu(Y#jtTeS!3 zbstIk*Yh|@Iw_Um(fA+tZ|r0666q&NheFwh;9Vs7uWViRa~(;~u?oh*grv1RONvN( zK0w+*>M6sslXQgCAW2^zCF#*z#pZ?1@jnOCCM)J$d2mg zUS5VF(}J?Ojn7^RE|3;TxPyOY_?6vfB-zz&QvJwZo*>PUK1On+nDi5*hesBQkyg&oguA}>Svjk?WCtj7gacCn=Z1gEL}_5!`zigmfASav)pE_)-ccG zsA1;%*)T25e4M0hTXd4aAdbvFm$u?w^^#_#jmVU-ZMiaTXZgB~hOsR(o22CPq+G9h zZK&Nu^7h^Q2W4p{(q<)L!Jv>H5p1(b&U5 zgp@YQ(rUiVxaEdLY?IPV8QXRqqpO?&&?f`rthAJ;Y7JTy!<>vpUrS<| zcmXtJ^S(`^{o1>yVr9S}Dj4%TAUj7b;Wl#4bWnYgyiRB~3!5GV8`^!yLws zYsJc)(ssifSv_iw21Dz|^c9(-6FN6X^LQr9)hQ!KLwO)(FBk8)vq3hQjB8NUR}kB| z>x-2kH(lDb-l^8&d|Vvt+Z+q}Fe`D6L0gQ?ho-dbt=nPhb%0ij7#m($T4|V7sdGwZ z&A2ktN#CU-3F<~;ho%$rvqYMhD5ybI*DF=kMi$?9>;4PQ((Ex2K90k~cV)6Yg+MKTh!Tjgbt*7#4dY9b$49!^%;NAgN5bnW;~E)Y!yGSAvtJe0 zYAN{J)b-=dioN5RlP)Q>IY`Sc!t1LYMMlPkGW8iNnTlRhAB(uxV`RS_)s0VO!`v>W z(NGp?k7DzT5u}oFvp@-Zh&HZP77@h@doe2qmm2n991rZenr~KbWntg)!fM0ReO1w7 zl+DhiXwXGbE^l(x7U%|p-gvNyJ=0wAOJSz;IJMKh$*0iFtG%VDt2HvCw}Mx{(I)Ze zb=_$GrsX4cglU<2ha0_wAUMufR%GNkKCC-l6xz+&kbvk%@3=mDp-K8|E~b}Nsy6;p z8Yk^eGs`;}QDNHQ%w%9TgRD?Ut@suZfb#x`mPZ1wCs z%KK0fINHdaE8|Nyi$U}^ZKZWgHg%LSItcmgcX~-TFD`ubfqbMkDSwbs zK?~(feD_(Kta&f5l)u9cbF9doO?o|6VF&vc2Z@POk9TAr6>w9_8K!ci@t{+kPPN2c zuDaor6yLuP!IDN3pE93O>7;by2}})5aW?SEltWDC(BvR?U3_=qv@$Ylzg3#)Ax1|~ z`4XS%1F4zVDkk46SDg4*#m}iKA(%{73a&O$RG8`{>V0*3f*eP4qn}Np@FvAo6!ss+ zj_b2iZm5z%`|FvwRn9C%GbhLsIA`tAxqS!co>;Ia7M52Qj~w2z91pU*oLMT`Ni;J* zY!@@D*{t1i^vKL|d^S;=nf(NyS$k?>Zh2O`cxdn3{{71fD=UvvHEjY3Hp^=Iv&xx-H!mn3@* z9opMV8d=^R*Fl1%#ifO@*gG2!ZrPWyL#GV4K0ux_K3_gC=(!}_?N6LVTt0Su^}x)d z<9ZT{-En3iC0^oyv-Z(uQf@igG^XN$*n?zYwIV-N1$Vpik)jEfT6 zjSJUvIum#6t5cCQ%u~ZYrJPeDW@V$Ko8(E~>`$7Ue|lY)n$;w7T|(`Eji2Dr?zq9Q zu-y>LdCB9{P4r%0pB#tg&D&zI|Ci={B3uy{c7PBPQZtG9YdQ98GSZ-;h_LY*H7!Vg z5EGp8Vd7(Lfo`fvRQ8^(J0ex&I_eCip`j+j>tL7-;0|GL!upebR2TdP1WuX zSp`nd_*8-o*(NrGbs+{ORBI?MWm(ep)TN8HpszUJyt!`^hW7(cUAo3?ms~OIYUniM zg2t44goo6wON{E4v8|ur5PtZ}ZeMYj zm?yOQ)$jN=77X`{cVYrB>)x&=iNSH1M%{j?WRO|6`hsq`vL)PZxPI+tT76r2mQf(V z%fpQ;BMhPz(zLZs? z)d441i>_mS4&hGLRXcxPmK* z;M`?OBpo}w+T(W2y&hf{(uv8{OVO{baY~c(W3Q;nn^`aJ5T1)n*Vt7>efj?QZA#j4 zNV&|_M@=_02;oQ)6E}zaJ0b)sxxv=yJz(-ywmPu~jP7N@_A1d8Yc4lG)Io5zt~(?K z=*FzrO~7(NO`CjJicx>)Fi*pC(4kO5P531cTRdi5q20K&G_gx$L6T!9eNHek3 zTE|69B3wmbt*_~-qf%}%(stT4LFJqsU3v!Jzr3K{Y0daWS1ycahzDqo@K-Cu3h|D3 zMkee(x{i^ncLAb!i1hV-pj(wA9;fZD8<^#jrtR2(a&BFIs`_e{R}KG$*$@P((L!7f zt!#Da;wDsIx1zY{=E(pN2vLNIYPj&uR@8QiUHTBPM$}RfS1uL#2vJQ!rJH5_1nO@! zD-)}|I_P?%*>y)j#UGj!HKrD9d{DVkab+ytE2U_^HLtZgK=}- zVCOyK%*fnFB?|TYmm_zZn5sXFV9;5YhT*WNOVh|9Cg1&%`=A?K>rD!R36H|`#xt84 sw~dm~k<+`}Xzh$IM8@A^qLtr2%a}lI<^0ddLO;~FZta7lmGw# delta 1431 zcmYk+U1$_n6u|M5G}&stKQxW`oW%GQ>sC$02ZM?co2)2k#P*>SZ+6GsX?AAT*Bq@)jl;*+IFp+yiW_)?`WN|8ceO0npZ|Fhg^E_>!Tb9eUMbI+aG zAI5KY&;Hp~b4_Sli5}wnDv>c9{eS~)yhda-PGAy!+=AC}8$QDASW_#q14q%|DICE+ zup65aBGovIL%0c>L}ukMC+&0?)H%!8fHSxb&*NI0M@{eveu)2K9lpV3SW73lNE6Z} z?Wp&4aj3^ZY{eul$K9Acm^Eqm9)6BRclcp_zVVzeuAF;9F;G|2fnZ&RM@hcBJxP zkxQjoP-!bU>T{tLTTAp3T3vmMw7MO{M&19-#L}i2YI6u()h0q;!H)@jfYuP75Srj( z(<nBzbEL>U$ z{V&iGuP0W-|J1FIN9xVO>xM6@sweiwzcn4K&xL{O1Z989jp7^4$vDxnD`7I3$SuZq z+Im)n!ErAu=?X>3xW#nj6-r(h#IM>uUG93>+*BCl!uYfH{(3L%N+Aj}<#Z|jwtYig zQHpMmaV8H;#t&AeTFTLs37nH&K5w$Fl#2^bJ2F-AvCcCKUvyorVJqM%NrZBEiyeBy0RB6FG&j vSaKuDnJ8=I%D`YN>1HG2%d{7HekIeI)v2CoH_!Mv6G*`gve|NA-k-b$66Cs~ diff --git a/locale/id/LC_MESSAGES/django.po b/locale/id/LC_MESSAGES/django.po index 3a3d363..16f2449 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-04-10 16:04+0700\n" +"POT-Creation-Date: 2020-04-26 06:12+0700\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,8 +18,49 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" +#: api/constants.py:4 +msgid "Waiting for admin confirmation" +msgstr "Menunggu konfirmasi admin" + +#: api/constants.py:5 api/constants.py:18 +msgid "Completed" +msgstr "Selesai" + +#: api/constants.py:9 +msgid "Transfer" +msgstr "Transfer" + +#: api/constants.py:10 +msgid "Cash on delivery" +msgstr "Bayar di tempat" + +#: api/constants.py:14 +msgid "Waiting for proof of payment" +msgstr "Menunggu bukti pembayaran" + +#: api/constants.py:15 +msgid "Waiting for seller confirmation" +msgstr "Menunggu konfirmasi penjual" + +#: api/constants.py:16 +msgid "In process" +msgstr "Dalam proses" + +#: api/constants.py:17 +msgid "Being shipped" +msgstr "Sedang dikirim" + +#: api/constants.py:19 +msgid "Canceled" +msgstr "Dibatalkan" + +#: api/constants.py:20 +msgid "Failed" +msgstr "Gagal" + #: api/models.py:16 api/models.py:52 api/models.py:71 api/models.py:96 -#: api/models.py:135 +#: api/models.py:136 api/models.py:149 api/models.py:175 api/models.py:281 +#: api/models.py:320 api/models.py:364 msgid "ID" msgstr "ID" @@ -39,15 +80,15 @@ msgstr "alamat" msgid "neighborhood" msgstr "RT" -#: api/models.py:30 +#: api/models.py:30 api/models.py:463 msgid "hamlet" msgstr "RW" -#: api/models.py:32 +#: api/models.py:32 api/models.py:467 msgid "urban village" msgstr "kelurahan" -#: api/models.py:33 +#: api/models.py:33 api/models.py:471 msgid "sub-district" msgstr "kecamatan" @@ -55,11 +96,11 @@ msgstr "kecamatan" msgid "profile picture" msgstr "foto profil" -#: api/models.py:40 +#: api/models.py:40 api/serializers.py:17 msgid "OTP" msgstr "OTP" -#: api/models.py:44 +#: api/models.py:44 api/models.py:137 api/models.py:187 api/models.py:376 msgid "user" msgstr "pengguna" @@ -67,13 +108,11 @@ msgstr "pengguna" msgid "users" msgstr "pengguna" -#: api/models.py:53 api/models.py:72 api/models.py:103 api/models.py:142 -#, fuzzy -#| msgid "full name" +#: api/models.py:53 api/models.py:72 api/models.py:103 api/models.py:327 msgid "name" -msgstr "nama lengkap" +msgstr "nama" -#: api/models.py:58 api/models.py:83 api/models.py:122 +#: api/models.py:58 api/models.py:83 api/models.py:123 msgid "image" msgstr "gambar" @@ -93,95 +132,399 @@ msgstr "subkategori" msgid "subcategories" msgstr "subkategori" -#: api/models.py:101 api/models.py:140 +#: api/models.py:101 api/models.py:325 msgid "code" msgstr "kode" -#: api/models.py:110 api/models.py:143 +#: api/models.py:110 api/models.py:328 msgid "description" -msgstr "deskripsi" +msgstr "" #: api/models.py:115 msgid "price" -msgstr "harga" +msgstr "deskripsi" #: api/models.py:117 msgid "stock" msgstr "stok" -#: api/models.py:127 +#: api/models.py:118 +msgid "pre-order" +msgstr "pre-order" + +#: api/models.py:128 api/models.py:160 api/models.py:293 msgid "product" msgstr "produk" -#: api/models.py:128 +#: api/models.py:129 msgid "products" msgstr "produk" -#: api/models.py:148 +#: api/models.py:141 api/models.py:154 +msgid "shopping cart" +msgstr "keranjang belanja" + +#: api/models.py:142 +msgid "shopping carts" +msgstr "keranjang belanja" + +#: api/models.py:162 api/models.py:302 +msgid "quantity" +msgstr "kuantitas" + +#: api/models.py:167 +msgid "cart item" +msgstr "barang keranjang" + +#: api/models.py:168 +msgid "cart items" +msgstr "barang keranjang" + +#: api/models.py:180 +msgid "transaction number" +msgstr "nomor transaksi" + +#: api/models.py:189 api/models.py:385 +msgid "user full name" +msgstr "nama lengkap pengguna" + +#: api/models.py:190 api/models.py:386 +msgid "user phone number" +msgstr "nomor telepon pengguna" + +#: api/models.py:191 +msgid "shipping address" +msgstr "alamat pengiriman" + +#: api/models.py:195 +msgid "shipping neighborhood" +msgstr "RT pengiriman" + +#: api/models.py:200 +msgid "shipping hamlet" +msgstr "RW pengiriman" + +#: api/models.py:204 +msgid "shipping urban village" +msgstr "kelurahan pengiriman" + +#: api/models.py:208 +msgid "shipping sub-district" +msgstr "kecamatan pengiriman" + +#: api/models.py:214 +msgid "shipping costs" +msgstr "biaya pengiriman" + +#: api/models.py:219 +msgid "payment method" +msgstr "metode pembayaran" + +#: api/models.py:226 +msgid "donation" +msgstr "donasi" + +#: api/models.py:231 +msgid "transaction status" +msgstr "status transaksi" + +#: api/models.py:237 +msgid "proof of payment" +msgstr "bukti pembayaran" + +#: api/models.py:243 api/models.py:406 +msgid "user bank account name" +msgstr "nama akun bank pengguna" + +#: api/models.py:249 api/models.py:410 +msgid "user bank account number" +msgstr "nomor akun bank pengguna" + +#: api/models.py:254 api/models.py:415 +msgid "created at" +msgstr "dibuat pada" + +#: api/models.py:256 api/models.py:417 +msgid "updated at" +msgstr "diperbarui pada" + +#: api/models.py:260 api/models.py:286 +msgid "transaction" +msgstr "transaksi" + +#: api/models.py:261 +msgid "transactions" +msgstr "transaksi" + +#: api/models.py:295 +msgid "product name" +msgstr "nama produk" + +#: api/models.py:300 +msgid "product price" +msgstr "harga produk" + +#: api/models.py:306 +msgid "transaction item" +msgstr "barang transaksi" + +#: api/models.py:307 +msgid "transaction items" +msgstr "barang transaksi" + +#: api/models.py:333 msgid "start date and time" msgstr "tanggal dan waktu mulai" -#: api/models.py:153 +#: api/models.py:338 msgid "end date and time" msgstr "tanggal dan waktu berakhir" -#: api/models.py:159 +#: api/models.py:344 msgid "location" msgstr "lokasi" -#: api/models.py:161 +#: api/models.py:346 msgid "speaker" msgstr "pembicara" -#: api/models.py:166 +#: api/models.py:351 msgid "poster image" msgstr "gambar poster" -#: api/models.py:171 +#: api/models.py:356 api/models.py:383 msgid "program" msgstr "program" -#: api/models.py:172 +#: api/models.py:357 msgid "programs" msgstr "program" -#: api/models.py:179 +#: api/models.py:369 +msgid "donation number" +msgstr "nomor donasi" + +#: api/models.py:387 +msgid "program name" +msgstr "nama program" + +#: api/models.py:392 +msgid "amount" +msgstr "jumlah" + +#: api/models.py:398 +msgid "donation status" +msgstr "status donasi" + +#: api/models.py:402 +msgid "proof of bank transfer" +msgstr "bukti transfer bank" + +#: api/models.py:421 +msgid "program donation" +msgstr "donasi program" + +#: api/models.py:422 +msgid "program donations" +msgstr "donasi program" + +#: api/models.py:436 msgid "send SMS" msgstr "kirim SMS" -#: api/models.py:182 -msgid "config" -msgstr "konfigurasi" +#: api/models.py:439 +msgid "application configuration" +msgstr "konfigurasi aplikasi" + +#: api/models.py:440 +msgid "application configurations" +msgstr "konfigurasi aplikasi" -#: api/models.py:183 -msgid "configs" -msgstr "konfigurasi" +#: api/models.py:447 +msgid "bank name" +msgstr "nama bank" -#: api/serializers.py:130 +#: api/models.py:448 +msgid "account number" +msgstr "nomor akun" + +#: api/models.py:449 +msgid "account owner" +msgstr "pemilik akun" + +#: api/models.py:452 +msgid "bank account configuration" +msgstr "konfigurasi akun bank" + +#: api/models.py:453 +msgid "bank account configurations" +msgstr "konfigurasi akun bank" + +#: api/models.py:479 +msgid "" +"shipping costs if the hamlet, urban village, and sub-district are the same " +"as seller" +msgstr "biaya pengiriman jika RW, kelurahan, dan kecamatan sama dengan penjual" + +#: api/models.py:488 +msgid "" +"shipping costs if the urban village and sub-district are the same as seller" +msgstr "biaya pengirima jika kelurahan dan kecamatan sama dengan penjual" + +#: api/models.py:496 +msgid "shipping costs if the sub-district is the same as seller" +msgstr "biaya pengiriman jika kecamatan sama dengan penjual" + +#: api/models.py:500 +msgid "shipment configuration" +msgstr "konfigurasi pengiriman" + +#: api/models.py:501 +msgid "shipment configurations" +msgstr "konfigurasi pengiriman" + +#: api/serializers.py:13 +msgid "Phone number" +msgstr "Nomor telepon" + +#: api/serializers.py:21 +msgid "Product" +msgstr "Produk" + +#: api/serializers.py:22 +msgid "Quantity" +msgstr "Kuantitas" + +#: api/serializers.py:28 +msgid "Payment method" +msgstr "Metode pembayaran" + +#: api/serializers.py:33 +msgid "Donation" +msgstr "Donasi" + +#: api/serializers.py:40 api/serializers.py:53 +msgid "Transaction" +msgstr "Transaksi" + +#: api/serializers.py:41 +msgid "Proof of payment" +msgstr "Bukti pembayaran" + +#: api/serializers.py:43 api/serializers.py:66 +msgid "User bank account name" +msgstr "Nama akun bank pengguna" + +#: api/serializers.py:47 api/serializers.py:70 +msgid "User bank account number" +msgstr "Nomor akun bank pengguna" + +#: api/serializers.py:57 +msgid "Program" +msgstr "Program" + +#: api/serializers.py:60 +msgid "Amount" +msgstr "Jumlah" + +#: api/serializers.py:64 +msgid "Proof of bank transfer" +msgstr "Bukti transfer bank" + +#: api/serializers.py:183 +msgid "Pre-order products cannot have stock data." +msgstr "Produk pre-order tidak dapat memiliki data stok." + +#: api/serializers.py:319 +msgid "Cannot update transaction status to failed." +msgstr "Tidak dapat memperbarui status transaksi ke gagal." + +#: api/serializers.py:348 msgid "End date time should be greater than start date time." msgstr "Waktu tanggal berakhir harus lebih besar dari waktu tanggal mulai." -#: api/utils.py:31 +#: api/utils.py:80 msgid "Invalid authorization header format." msgstr "Format header otorisasi tidak valid." -#: api/utils.py:35 api/utils.py:37 +#: api/utils.py:85 api/utils.py:87 msgid "Invalid token." msgstr "Token tidak valid." -#: api/views.py:158 +#: api/utils.py:104 +#, python-brace-format +msgid "" +"Your Industri Pilar account authentication code is {otp}. Keep your " +"authentication code SECRET." +msgstr "" +"Kode otentikasi akun Industri Pilar Anda adalah {otp}. RAHASIAKAN kode " +"otentikasi Anda." + +#: api/utils.py:115 +msgid "" +"Failed to checkout because the purchased quantity of certain items exceeds " +"the available stock." +msgstr "" +"Gagal checkout karena jumlah barang yang dibeli melebihi stok yang tersedia." + +#: api/views.py:160 +#, python-brace-format +msgid "" +"Cannot process shipment to other sub-districts other than {sub_district}." +msgstr "" +"Tidak dapat memproses pengiriman ke kecamatan lain selain {sub_district}." + +#: api/views.py:166 +msgid "Unable to checkout because there are no items purchased." +msgstr "Tidak dapat checkout karena tidak ada barang yang dibeli." + +#: api/views.py:193 +msgid "Checkout failed." +msgstr "Checkout gagal." + +#: api/views.py:215 +msgid "The payment method for this transaction is not a transfer." +msgstr "Metode pembayaran untuk transaksi ini bukan transfer." + +#: api/views.py:219 +msgid "Cannot upload proof of payment at this stage." +msgstr "Tidak dapat mengunggah bukti pembayaran pada tahap ini." + +#: api/views.py:249 +msgid "Transaction cannot be completed unless the status is \"Being shipped\"." +msgstr "" +"Transaksi tidak dapat diselesaikan kecuali statusnya \"Sedang dikirim\"." + +#: api/views.py:274 +msgid "Transaction cannot be canceled at this stage." +msgstr "Transaksi tidak dapat dibatalkan pada tahap ini." + +#: api/views.py:372 msgid "Cannot delete category due to integrity error." msgstr "Tidak dapat menghapus kategori karena kesalahan integritas." -#: api/views.py:196 +#: api/views.py:409 msgid "Cannot delete subcategory due to integrity error." msgstr "Tidak dapat menghapus subkategori karena kesalahan integritas." -#: home_industry/utils.py:14 +#: api/views.py:500 +msgid "" +"Cannot update transaction because it has a completed, canceled, or failed " +"status." +msgstr "" +"Tidak dapat memperbarui transaksi karena memiliki status selesai, " +"dibatalkan, atau gagal." + +#: api/views.py:562 +msgid "Cannot update donation because it has a completed status." +msgstr "Tidak dapat memperbarui donasi karena statusnya sudah selesai." + +#: home_industry/utils.py:13 home_industry/utils.py:21 +#: home_industry/utils.py:29 msgid "A bad configuration error occurred." msgstr "Terjadi kesalahan konfigurasi." -#: home_industry/utils.py:16 +#: home_industry/utils.py:37 msgid "Server is currently unable to send SMS." msgstr "Server saat ini tidak dapat mengirim SMS." @@ -211,9 +554,6 @@ msgstr "" msgid "Enter a valid phone number (e.g. {example_number})." msgstr "Masukkan nomor telepon yang valid (misalnya {example_number})." -msgid "Phone number" -msgstr "Nomor telepon" - msgid "Enter a valid phone number." msgstr "Masukkan nomor telepon yang valid." diff --git a/sonar-project.properties b/sonar-project.properties index bdbc0ee..92086b7 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -3,3 +3,6 @@ sonar.python.coverage.reportPaths=coverage.xml sonar.scm.provider=git sonar.sourceEncoding=UTF-8 sonar.sources=api +sonar.issue.ignore.multicriteria=p1 +sonar.issue.ignore.multicriteria.p1.ruleKey=python:S1192 +sonar.issue.ignore.multicriteria.p1.resourceKey=**/*.py -- GitLab From a50a98e603c3594ab145afe867659ba0775fcc18 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Sun, 26 Apr 2020 07:06:38 +0700 Subject: [PATCH 68/90] Add Product Stock Validation --- api/serializers.py | 14 ++------------ locale/id/LC_MESSAGES/django.mo | Bin 9263 -> 9397 bytes locale/id/LC_MESSAGES/django.po | 10 +++++++--- 3 files changed, 9 insertions(+), 15 deletions(-) diff --git a/api/serializers.py b/api/serializers.py index 9307f3d..3b861fb 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -72,18 +72,6 @@ class DonationCreateSerializer(serializers.Serializer): # pylint: disable=abstra ) -class DonationCreateSerializer(serializers.Serializer): # pylint: disable=abstract-method - program = serializers.UUIDField() - amount = serializers.DecimalField( - decimal_places=2, - max_digits=12, - min_value=decimal.Decimal('0.01') - ) - proof_of_bank_transfer = serializers.ImageField() - user_bank_account_name = serializers.CharField(max_length=200) - user_bank_account_number = serializers.CharField(max_length=100) - - class UserSerializer(serializers.ModelSerializer): total_transactions = serializers.SerializerMethodField('get_total_transactions') total_program_donations = serializers.SerializerMethodField('get_total_program_donations') @@ -193,6 +181,8 @@ class ProductSerializer(serializers.ModelSerializer): errors = {} if (pre_order) and (stock is not None): errors['pre_order'] = _('Pre-order products cannot have stock data.') + if (stock is None) and (not pre_order): + errors['stock'] = _('Stock cannot be empty if it is not a pre-order.') if errors: raise serializers.ValidationError(errors) return super().validate(attrs) diff --git a/locale/id/LC_MESSAGES/django.mo b/locale/id/LC_MESSAGES/django.mo index 9d92140df9a70dc732b1c1bff56776807acb2952..8eb725b3ffe6f33de05e8c6f7bc676629b864979 100644 GIT binary patch delta 2971 zcmYk;e@vBC9LMpmh-4IMDvAmE5QIMvQSb+fAeovs!2B7a6<*+~*DGAa>klk?P^?zX zIa6wLtMy~9+)zu_Y>oP-rfjygky|yJ>8jN%b1S#}BUkUwy$9zRUtZ@q&-0w~ea<=0 z1E-gL9PlrXOztvDCy`0aN;d1ks-gU%WZY+#gX1s-%P)!yU-v*qd0u_;!NI68sj`QPu-yC76dAs17r57f!%KsE$tI zM7)4{;@?pZP&qQOMC(zBZ$ZsW3y#Cx_#hreUzKhun!@v_CHTpC9W{0TIFm*t5==w3 ziH$*>AMe`pkcYNH=RzbWdji#O5S2&-hhVGbUr)S;26ea(HT5r|p5%x-e%E;#)lo01 zgL9}S{K}obh-?eHit6_UDxq7b>xPa_EZGQDV%ejae+^hngKnrmO?kjwxY4z5ckXrP zJ8>-69Ya>l&Y%+h0@q+aY6&N$vmCe)l}I=8W4+FPpNa{~mPS{iz>2*Li2tP!1{5NW#B6e~Vn^EnT@gdCOl~LjqScOY5N$>v=Dw>kl zQ6Gr+P*c{2Bx{#Y--ny18MuX-vD7TH8JK}eD1b_E1!~FGp_XDBYSXsk6nqH_@FSef z`1T`}shBWP5VaI#&Q++6Hapu;1HOvd16`;YI_cWaV0`MGeXji?Dv`@h{{|Jk zX4?8XF#^?bHmW_}9haikcnNC2<*2t~6Kaq6s3-42UDu6+a4@lgP|@bwPdrb|ASix( zhw8o5^m9+VEOO4p23OyRO6X}~8?l^tlo%+_y2=jhboD3kaY8eiPb3p6T7m+iP4B-p zU=y*5SWVnrHgV8$SHpDb9fW46j?i~Pvynxpga|F$P90F@5Ui|4bU=BESV(Lk))EUC zKR%67+)Aiu!>Md0wiAjDMU4)!nevEBbtduLVIX0 z@f@LVh`)+Tj`#Xap0O+V zipGm}ze-tx_}}qK)GLWFQPXoh_50-Mt7FlIh}RHoZjQ#h`j8iDYKgUZ;Z0sR=7o27 zY7KfVt)YTwYh$Rj=gZ+;Y16nT;>E&^!H8EM-4@#7MWQ>R&6~Zg;YiS{-x&!u-*rb# I)^{oY1BxODMF0Q* delta 2839 zcmZA3ZA_L`9LMoLPl6#2@uZLfq97oFNQej_QILoaQGkG&O1BxBVr5x()@9~e&$b!# z0yS5$R$Hsd$kt5TlAgx!MXP2`wAIw!n2Z-?TdnW!z7DQ>?39pMUiG7V08>1lxsJb&B1Y;f~m1)aX1roOCc6x8J1!fHsb48iDTG| zGp3o%$E|oX_FwmNwFF^_8X)>cmJKs86Dv?1wP6-Mf_l<} zs3)307RADo{0S$bR%8aIV-{v$rLzS+EnN>4E&WT*UeuDk>3knG!676)?4)b|!nKbg z4{JX@S)P#ihXP9qUjX zcA_S{4O=jX+QKu~gfXf9L|Tv^3pj(Q{$BH_Xy)&tIvPUF_%t#X8$mtsMbwNhq3)kR zB~uu^$T-x5(os)Xh^)R({# ziZr4IYQ{QjM|Iqf8t5nN!89^j?ayHj4x=W11y|rz)O(?dqclzDzlMrl3{9vd+lfrl zo<+SE4xv`yC~C=$;R5^|HKB3T1ph>Bna%RIA_bMSxmbWDSdJa2_sRjxWPJOSif%lM z>i8FIz!cV3$+;FQF@Q?GBd7^}j4YO&asGxpoJC}rt-&nRR_sRY{XW!$4>_c^U81?;Is4W;kb#&Z0jGEwg&hw~#E~6%L6*bUbsP^dDetSG>%Q9wre%9vD zpzJM0{X(s~VI^u$TTv^}jT$iM+>aatd(}CBn&=7C{iE1`7qATT$jDmkL_J8q=PK`^ zw&1vP1l7^c&I#1OQFHtpNI0yIi>T`DgtkDXhR~|)A_a6En!uh= zjlXL66!A2%!nLi%OhV^a8LuRYAXJ)tEE~HCZR2A^fKXXP6cG>Vf>KJXCW?tzjlYqK zik?=-MWv3=({ZH#E9^k%SgBzfv7T5(+(B?KLS-qR;jTW3R!upjBb-m{AXq$WBdYcM z%0!h4Vt1(K|2w9t&a(=u8~XQY2}Tl+6MAbl5lYa-#4>_cN2ql1*+JwIC7~9xmDuX) z8donN?+MqSH{Sx{7UE%IJJC*P?^QPV*sa(?C^=P>Op_&#%A@Y$K2+lUztqwYBv0Pd7\n" "Language-Team: LANGUAGE \n" @@ -435,11 +435,15 @@ msgstr "Bukti transfer bank" msgid "Pre-order products cannot have stock data." msgstr "Produk pre-order tidak dapat memiliki data stok." -#: api/serializers.py:319 +#: api/serializers.py:185 +msgid "Stock cannot be empty if it is not a pre-order." +msgstr "Stok tidak boleh kosong jika bukan pre-order." + +#: api/serializers.py:321 msgid "Cannot update transaction status to failed." msgstr "Tidak dapat memperbarui status transaksi ke gagal." -#: api/serializers.py:348 +#: api/serializers.py:350 msgid "End date time should be greater than start date time." msgstr "Waktu tanggal berakhir harus lebih besar dari waktu tanggal mulai." -- GitLab From 2b36fe38d5926c79dab28c19ee410427d7edc349 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Mon, 27 Apr 2020 23:39:53 +0700 Subject: [PATCH 69/90] Add Reupload Proof of Bank Transfer Endpoint --- api/constants.py | 2 + api/migrations/0001_initial.py | 72 +++++--- api/migrations/0002_auto_20200419_0448.py | 51 ------ api/migrations/0003_auto_20200419_1707.py | 19 -- api/migrations/0004_auto_20200420_1446.py | 49 ----- api/migrations/0005_auto_20200426_0108.py | 23 --- api/migrations/0006_auto_20200426_0113.py | 18 -- api/migrations/0007_programdonation.py | 44 ----- api/migrations/0008_auto_20200426_0325.py | 19 -- api/models.py | 7 + api/serializers.py | 17 +- api/tests.py | 51 +++++- api/urls.py | 5 + api/views.py | 50 +++++- locale/id/LC_MESSAGES/django.mo | Bin 9397 -> 9834 bytes locale/id/LC_MESSAGES/django.po | 206 ++++++++++++---------- 16 files changed, 282 insertions(+), 351 deletions(-) delete mode 100644 api/migrations/0002_auto_20200419_0448.py delete mode 100644 api/migrations/0003_auto_20200419_1707.py delete mode 100644 api/migrations/0004_auto_20200420_1446.py delete mode 100644 api/migrations/0005_auto_20200426_0108.py delete mode 100644 api/migrations/0006_auto_20200426_0113.py delete mode 100644 api/migrations/0007_programdonation.py delete mode 100644 api/migrations/0008_auto_20200426_0325.py diff --git a/api/constants.py b/api/constants.py index 30337da..38b00b7 100644 --- a/api/constants.py +++ b/api/constants.py @@ -3,6 +3,8 @@ from django.utils.translation import gettext_lazy as _ DONATION_STATUS_CHOICES = [ ('001', _('Waiting for admin confirmation')), ('002', _('Completed')), + ('003', _('Canceled')), + ('004', _('Waiting for reupload of proof of bank transfer')), ] PAYMENT_METHOD_CHOICES = [ diff --git a/api/migrations/0001_initial.py b/api/migrations/0001_initial.py index 91f6e60..1c08bce 100644 --- a/api/migrations/0001_initial.py +++ b/api/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.0.3 on 2020-04-17 15:47 +# Generated by Django 3.0.3 on 2020-04-27 16:21 import api.utils from decimal import Decimal @@ -45,7 +45,7 @@ class Migration(migrations.Migration): ('hamlet', models.CharField(max_length=3, validators=[django.core.validators.RegexValidator(re.compile('^\\d+(?:\\d+)*\\Z'), code='invalid', message=None), django.core.validators.MinLengthValidator(3)], verbose_name='hamlet')), ('urban_village', models.CharField(max_length=100, verbose_name='urban village')), ('sub_district', models.CharField(max_length=100, verbose_name='sub-district')), - ('profile_picture', models.ImageField(blank=True, null=True, upload_to='uploads/users/', verbose_name='profile picture')), + ('profile_picture', models.ImageField(blank=True, null=True, upload_to=api.utils.get_upload_file_path, verbose_name='profile picture')), ('otp', models.CharField(blank=True, max_length=6, null=True, verbose_name='OTP')), ('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')), @@ -53,7 +53,7 @@ class Migration(migrations.Migration): options={ 'verbose_name': 'user', 'verbose_name_plural': 'users', - 'ordering': ['username'], + 'ordering': ['username', 'id'], }, managers=[ ('objects', django.contrib.auth.models.UserManager()), @@ -66,8 +66,8 @@ class Migration(migrations.Migration): ('send_sms', models.BooleanField(default=False, verbose_name='send SMS')), ], options={ - 'verbose_name': 'application config', - 'verbose_name_plural': 'application configs', + 'verbose_name': 'application configuration', + 'verbose_name_plural': 'application configurations', }, ), migrations.CreateModel( @@ -79,8 +79,8 @@ class Migration(migrations.Migration): ('account_owner', models.CharField(max_length=200, verbose_name='account owner')), ], options={ - 'verbose_name': 'bank account config', - 'verbose_name_plural': 'bank account configs', + 'verbose_name': 'bank account configuration', + 'verbose_name_plural': 'bank account configurations', }, ), migrations.CreateModel( @@ -88,12 +88,12 @@ class Migration(migrations.Migration): fields=[ ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='ID')), ('name', django.contrib.postgres.fields.citext.CICharField(max_length=50, unique=True, verbose_name='name')), - ('image', models.ImageField(blank=True, null=True, upload_to='uploads/categories/', verbose_name='image')), + ('image', models.ImageField(blank=True, null=True, upload_to=api.utils.get_upload_file_path, verbose_name='image')), ], options={ 'verbose_name': 'category', 'verbose_name_plural': 'categories', - 'ordering': ['name'], + 'ordering': ['name', 'id'], }, ), migrations.CreateModel( @@ -104,13 +104,14 @@ class Migration(migrations.Migration): ('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')), + ('stock', models.PositiveIntegerField(blank=True, null=True, verbose_name='stock')), + ('pre_order', models.BooleanField(default=False, verbose_name='pre-order')), + ('image', models.ImageField(blank=True, null=True, upload_to=api.utils.get_upload_file_path, verbose_name='image')), ], options={ 'verbose_name': 'product', 'verbose_name_plural': 'products', - 'ordering': ['subcategory', 'name'], + 'ordering': ['subcategory', 'name', 'code', 'id'], }, ), migrations.CreateModel( @@ -124,12 +125,12 @@ class Migration(migrations.Migration): ('end_date_time', models.DateTimeField(blank=True, null=True, verbose_name='end date and time')), ('location', models.CharField(blank=True, max_length=200, null=True, verbose_name='location')), ('speaker', models.CharField(blank=True, max_length=200, null=True, verbose_name='speaker')), - ('poster_image', models.ImageField(blank=True, null=True, upload_to='uploads/programs/', verbose_name='poster image')), + ('poster_image', models.ImageField(blank=True, null=True, upload_to=api.utils.get_upload_file_path, verbose_name='poster image')), ], options={ 'verbose_name': 'program', 'verbose_name_plural': 'programs', - 'ordering': ['-start_date_time', '-end_date_time', 'name'], + 'ordering': ['-start_date_time', '-end_date_time', 'name', 'code', 'id'], }, ), migrations.CreateModel( @@ -144,8 +145,8 @@ class Migration(migrations.Migration): ('same_sub_district_different_urban_village_costs', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=12, validators=[django.core.validators.MinValueValidator(Decimal('0'))], verbose_name='shipping costs if the sub-district is the same as seller')), ], options={ - 'verbose_name': 'shipment config', - 'verbose_name_plural': 'shipment configs', + 'verbose_name': 'shipment configuration', + 'verbose_name_plural': 'shipment configurations', }, ), migrations.CreateModel( @@ -164,7 +165,7 @@ class Migration(migrations.Migration): ('payment_method', models.CharField(choices=[('TRF', 'Transfer'), ('COD', 'Cash on delivery')], max_length=3, verbose_name='payment method')), ('donation', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=12, validators=[django.core.validators.MinValueValidator(Decimal('0'))], verbose_name='donation')), ('transaction_status', models.CharField(choices=[('001', 'Waiting for proof of payment'), ('002', 'Waiting for seller confirmation'), ('003', 'In process'), ('004', 'Being shipped'), ('005', 'Completed'), ('006', 'Canceled'), ('007', 'Failed')], max_length=3, verbose_name='transaction status')), - ('proof_of_payment', models.ImageField(blank=True, null=True, upload_to='uploads/transactions/%Y/%m/%d/', verbose_name='proof of payment')), + ('proof_of_payment', models.ImageField(blank=True, null=True, upload_to=api.utils.get_upload_file_path, verbose_name='proof of payment')), ('user_bank_account_name', models.CharField(blank=True, max_length=200, null=True, verbose_name='user bank account name')), ('user_bank_account_number', models.CharField(blank=True, max_length=100, null=True, verbose_name='user bank account number')), ('created_at', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='created at')), @@ -174,7 +175,7 @@ class Migration(migrations.Migration): options={ 'verbose_name': 'transaction', 'verbose_name_plural': 'transactions', - 'ordering': ['-updated_at', '-created_at'], + 'ordering': ['-updated_at', '-created_at', 'transaction_number', 'id'], }, ), migrations.CreateModel( @@ -183,6 +184,7 @@ class Migration(migrations.Migration): ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='ID')), ('product_name', models.CharField(max_length=200, verbose_name='product name')), ('product_price', models.DecimalField(decimal_places=2, max_digits=12, validators=[django.core.validators.MinValueValidator(Decimal('0.01'))], verbose_name='product price')), + ('product_pre_order', models.BooleanField(verbose_name='product pre-order')), ('quantity', models.PositiveIntegerField(verbose_name='quantity')), ('product', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transaction_items', to='api.Product', verbose_name='product')), ('transaction', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transaction_items', to='api.Transaction', verbose_name='transaction')), @@ -190,7 +192,7 @@ class Migration(migrations.Migration): options={ 'verbose_name': 'transaction item', 'verbose_name_plural': 'transaction items', - 'ordering': ['transaction'], + 'ordering': ['transaction', 'product', 'id'], }, ), migrations.CreateModel( @@ -198,13 +200,13 @@ class Migration(migrations.Migration): fields=[ ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='ID')), ('name', django.contrib.postgres.fields.citext.CICharField(max_length=50, unique=True, verbose_name='name')), - ('image', models.ImageField(blank=True, null=True, upload_to='uploads/subcategories/', verbose_name='image')), + ('image', models.ImageField(blank=True, null=True, upload_to=api.utils.get_upload_file_path, 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'], + 'ordering': ['category', 'name', 'id'], }, ), migrations.CreateModel( @@ -216,7 +218,31 @@ class Migration(migrations.Migration): options={ 'verbose_name': 'shopping cart', 'verbose_name_plural': 'shopping carts', - 'ordering': ['user'], + 'ordering': ['user', 'id'], + }, + ), + migrations.CreateModel( + name='ProgramDonation', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='ID')), + ('donation_number', models.CharField(default=api.utils.generate_donation_number, max_length=6, unique=True, verbose_name='donation number')), + ('user_full_name', models.CharField(max_length=200, verbose_name='user full name')), + ('user_phone_number', phonenumber_field.modelfields.PhoneNumberField(max_length=128, region=None, verbose_name='user phone number')), + ('program_name', models.CharField(max_length=200, verbose_name='program name')), + ('amount', models.DecimalField(decimal_places=2, max_digits=12, validators=[django.core.validators.MinValueValidator(Decimal('0.01'))], verbose_name='amount')), + ('donation_status', models.CharField(choices=[('001', 'Waiting for admin confirmation'), ('002', 'Completed'), ('003', 'Canceled'), ('004', 'Waiting for reupload of proof of bank transfer')], default='001', max_length=3, verbose_name='donation status')), + ('proof_of_bank_transfer', models.ImageField(upload_to=api.utils.get_upload_file_path, verbose_name='proof of bank transfer')), + ('user_bank_account_name', models.CharField(max_length=200, verbose_name='user bank account name')), + ('user_bank_account_number', models.CharField(max_length=100, verbose_name='user bank account number')), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='created at')), + ('updated_at', models.DateTimeField(auto_now=True, db_index=True, verbose_name='updated at')), + ('program', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='program_donations', to='api.Program', verbose_name='program')), + ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='program_donations', to=settings.AUTH_USER_MODEL, verbose_name='user')), + ], + options={ + 'verbose_name': 'program donation', + 'verbose_name_plural': 'program donations', + 'ordering': ['-updated_at', '-created_at', 'donation_number', 'id'], }, ), migrations.AddField( @@ -235,7 +261,7 @@ class Migration(migrations.Migration): options={ 'verbose_name': 'cart item', 'verbose_name_plural': 'cart items', - 'ordering': ['shopping_cart', 'product'], + 'ordering': ['shopping_cart', 'product', 'id'], 'unique_together': {('shopping_cart', 'product')}, }, ), diff --git a/api/migrations/0002_auto_20200419_0448.py b/api/migrations/0002_auto_20200419_0448.py deleted file mode 100644 index 7bd30cf..0000000 --- a/api/migrations/0002_auto_20200419_0448.py +++ /dev/null @@ -1,51 +0,0 @@ -# Generated by Django 3.0.3 on 2020-04-18 21:48 - -import api.utils -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0001_initial'), - ] - - operations = [ - migrations.AlterModelOptions( - name='appconfig', - options={'verbose_name': 'application configuration', 'verbose_name_plural': 'application configurations'}, - ), - migrations.AlterModelOptions( - name='bankaccountconfig', - options={'verbose_name': 'bank account configuration', 'verbose_name_plural': 'bank account configurations'}, - ), - migrations.AlterModelOptions( - name='shipmentconfig', - options={'verbose_name': 'shipment configuration', 'verbose_name_plural': 'shipment configurations'}, - ), - migrations.AlterField( - model_name='category', - name='image', - field=models.ImageField(blank=True, null=True, upload_to=api.utils.get_upload_file_path, verbose_name='image'), - ), - migrations.AlterField( - model_name='product', - name='image', - field=models.ImageField(blank=True, null=True, upload_to=api.utils.get_upload_file_path, verbose_name='image'), - ), - migrations.AlterField( - model_name='program', - name='poster_image', - field=models.ImageField(blank=True, null=True, upload_to=api.utils.get_upload_file_path, verbose_name='poster image'), - ), - migrations.AlterField( - model_name='subcategory', - name='image', - field=models.ImageField(blank=True, null=True, upload_to=api.utils.get_upload_file_path, verbose_name='image'), - ), - migrations.AlterField( - model_name='user', - name='profile_picture', - field=models.ImageField(blank=True, null=True, upload_to=api.utils.get_upload_file_path, verbose_name='profile picture'), - ), - ] diff --git a/api/migrations/0003_auto_20200419_1707.py b/api/migrations/0003_auto_20200419_1707.py deleted file mode 100644 index d97ef2a..0000000 --- a/api/migrations/0003_auto_20200419_1707.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 3.0.3 on 2020-04-19 10:07 - -import api.utils -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0002_auto_20200419_0448'), - ] - - operations = [ - migrations.AlterField( - model_name='transaction', - name='proof_of_payment', - field=models.ImageField(blank=True, null=True, upload_to=api.utils.get_upload_file_path, verbose_name='proof of payment'), - ), - ] diff --git a/api/migrations/0004_auto_20200420_1446.py b/api/migrations/0004_auto_20200420_1446.py deleted file mode 100644 index d96549f..0000000 --- a/api/migrations/0004_auto_20200420_1446.py +++ /dev/null @@ -1,49 +0,0 @@ -# Generated by Django 3.0.3 on 2020-04-20 07:46 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0003_auto_20200419_1707'), - ] - - operations = [ - migrations.AlterModelOptions( - name='cartitem', - options={'ordering': ['shopping_cart', 'product', 'id'], 'verbose_name': 'cart item', 'verbose_name_plural': 'cart items'}, - ), - migrations.AlterModelOptions( - name='category', - options={'ordering': ['name', 'id'], 'verbose_name': 'category', 'verbose_name_plural': 'categories'}, - ), - migrations.AlterModelOptions( - name='product', - options={'ordering': ['subcategory', 'name', 'code', 'id'], 'verbose_name': 'product', 'verbose_name_plural': 'products'}, - ), - migrations.AlterModelOptions( - name='program', - options={'ordering': ['-start_date_time', '-end_date_time', 'name', 'code', 'id'], 'verbose_name': 'program', 'verbose_name_plural': 'programs'}, - ), - migrations.AlterModelOptions( - name='shoppingcart', - options={'ordering': ['user', 'id'], 'verbose_name': 'shopping cart', 'verbose_name_plural': 'shopping carts'}, - ), - migrations.AlterModelOptions( - name='subcategory', - options={'ordering': ['category', 'name', 'id'], 'verbose_name': 'subcategory', 'verbose_name_plural': 'subcategories'}, - ), - migrations.AlterModelOptions( - name='transaction', - options={'ordering': ['-updated_at', '-created_at', 'transaction_number', 'id'], 'verbose_name': 'transaction', 'verbose_name_plural': 'transactions'}, - ), - migrations.AlterModelOptions( - name='transactionitem', - options={'ordering': ['transaction', 'product', 'id'], 'verbose_name': 'transaction item', 'verbose_name_plural': 'transaction items'}, - ), - migrations.AlterModelOptions( - name='user', - options={'ordering': ['username', 'id'], 'verbose_name': 'user', 'verbose_name_plural': 'users'}, - ), - ] diff --git a/api/migrations/0005_auto_20200426_0108.py b/api/migrations/0005_auto_20200426_0108.py deleted file mode 100644 index d61102d..0000000 --- a/api/migrations/0005_auto_20200426_0108.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 3.0.3 on 2020-04-25 18:08 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0004_auto_20200420_1446'), - ] - - operations = [ - migrations.AddField( - model_name='product', - name='po', - field=models.BooleanField(default=False, verbose_name='pre-order'), - ), - migrations.AlterField( - model_name='product', - name='stock', - field=models.PositiveIntegerField(blank=True, null=True, verbose_name='stock'), - ), - ] diff --git a/api/migrations/0006_auto_20200426_0113.py b/api/migrations/0006_auto_20200426_0113.py deleted file mode 100644 index 0823737..0000000 --- a/api/migrations/0006_auto_20200426_0113.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.0.3 on 2020-04-25 18:13 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0005_auto_20200426_0108'), - ] - - operations = [ - migrations.RenameField( - model_name='product', - old_name='po', - new_name='pre_order', - ), - ] diff --git a/api/migrations/0007_programdonation.py b/api/migrations/0007_programdonation.py deleted file mode 100644 index e2d0f06..0000000 --- a/api/migrations/0007_programdonation.py +++ /dev/null @@ -1,44 +0,0 @@ -# Generated by Django 3.0.3 on 2020-04-25 20:21 - -import api.utils -from decimal import Decimal -from django.conf import settings -import django.core.validators -from django.db import migrations, models -import django.db.models.deletion -import phonenumber_field.modelfields -import uuid - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0006_auto_20200426_0113'), - ] - - operations = [ - migrations.CreateModel( - name='ProgramDonation', - fields=[ - ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='ID')), - ('donation_number', models.CharField(default=api.utils.generate_transaction_number, max_length=8, unique=True, verbose_name='donation number')), - ('user_full_name', models.CharField(max_length=200, verbose_name='user full name')), - ('user_phone_number', phonenumber_field.modelfields.PhoneNumberField(max_length=128, region=None, verbose_name='user phone number')), - ('program_name', models.CharField(max_length=200, verbose_name='program name')), - ('amount', models.DecimalField(decimal_places=2, max_digits=12, validators=[django.core.validators.MinValueValidator(Decimal('0.01'))], verbose_name='amount')), - ('donation_status', models.CharField(choices=[('001', 'Waiting for admin confirmation'), ('002', 'Completed')], default='001', max_length=3, verbose_name='donation status')), - ('proof_of_bank_transfer', models.ImageField(upload_to=api.utils.get_upload_file_path, verbose_name='proof of bank transfer')), - ('user_bank_account_name', models.CharField(max_length=200, verbose_name='user bank account name')), - ('user_bank_account_number', models.CharField(max_length=100, verbose_name='user bank account number')), - ('created_at', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='created at')), - ('updated_at', models.DateTimeField(auto_now=True, db_index=True, verbose_name='updated at')), - ('program', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='program_donations', to='api.Program', verbose_name='program')), - ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='program_donations', to=settings.AUTH_USER_MODEL, verbose_name='user')), - ], - options={ - 'verbose_name': 'program donation', - 'verbose_name_plural': 'program donations', - 'ordering': ['-updated_at', '-created_at', 'donation_number', 'id'], - }, - ), - ] diff --git a/api/migrations/0008_auto_20200426_0325.py b/api/migrations/0008_auto_20200426_0325.py deleted file mode 100644 index 6c7e5e6..0000000 --- a/api/migrations/0008_auto_20200426_0325.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 3.0.3 on 2020-04-25 20:25 - -import api.utils -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0007_programdonation'), - ] - - operations = [ - migrations.AlterField( - model_name='programdonation', - name='donation_number', - field=models.CharField(default=api.utils.generate_donation_number, max_length=6, unique=True, verbose_name='donation number'), - ), - ] diff --git a/api/models.py b/api/models.py index 9275634..c5f5880 100644 --- a/api/models.py +++ b/api/models.py @@ -128,6 +128,11 @@ class Product(db_models.Model): verbose_name = _('product') verbose_name_plural = _('products') + def save(self, *args, **kwargs): # pylint: disable=arguments-differ + if (self.pre_order) and (self.stock is not None): + self.stock = None + super().save(*args, **kwargs) + def __str__(self): return self.code @@ -299,6 +304,7 @@ class TransactionItem(db_models.Model): validators=[validators.MinValueValidator(decimal.Decimal('0.01'))], verbose_name=_('product price') ) + product_pre_order = db_models.BooleanField(verbose_name=_('product pre-order')) quantity = db_models.PositiveIntegerField(verbose_name=_('quantity')) class Meta: @@ -310,6 +316,7 @@ class TransactionItem(db_models.Model): if self._state.adding: self.product_name = self.product.name self.product_price = self.product.price + self.product_pre_order = self.product.pre_order super().save(*args, **kwargs) def __str__(self): diff --git a/api/serializers.py b/api/serializers.py index 3b861fb..2b743a3 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -72,6 +72,19 @@ class DonationCreateSerializer(serializers.Serializer): # pylint: disable=abstra ) +class DonationReuploadProofOfBankTransferSerializer(serializers.Serializer): # pylint: disable=abstract-method + program_donation = serializers.UUIDField(label=_('Program donasi')) + proof_of_bank_transfer = serializers.ImageField(label=_('Proof of bank transfer')) + user_bank_account_name = serializers.CharField( + label=_('User bank account name'), + max_length=200 + ) + user_bank_account_number = serializers.CharField( + label=_('User bank account number'), + max_length=100 + ) + + class UserSerializer(serializers.ModelSerializer): total_transactions = serializers.SerializerMethodField('get_total_transactions') total_program_donations = serializers.SerializerMethodField('get_total_program_donations') @@ -179,8 +192,6 @@ class ProductSerializer(serializers.ModelSerializer): stock = attrs.get('stock', getattr(instance, 'stock', None)) pre_order = attrs.get('pre_order', getattr(instance, 'pre_order', None)) errors = {} - if (pre_order) and (stock is not None): - errors['pre_order'] = _('Pre-order products cannot have stock data.') if (stock is None) and (not pre_order): errors['stock'] = _('Stock cannot be empty if it is not a pre-order.') if errors: @@ -224,6 +235,7 @@ class TransactionItemSerializer(serializers.ModelSerializer): 'product_code', 'product_name', 'product_price', + 'product_pre_order', 'quantity', ] model = models.TransactionItem @@ -232,6 +244,7 @@ class TransactionItemSerializer(serializers.ModelSerializer): 'product', 'product_name', 'product_price', + 'product_pre_order', 'quantity', ] diff --git a/api/tests.py b/api/tests.py index 5e3492d..7b2794d 100644 --- a/api/tests.py +++ b/api/tests.py @@ -629,6 +629,54 @@ class DonationTest(rest_framework_test.APITestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(models.ProgramDonation.objects.count(), 0) + def test_donation_reupload_proof_of_bank_transfer_success(self): + program = models.Program.objects.create(**seeds.PROGRAM_DATA) + program_donation = models.ProgramDonation.objects.create(**dict( + seeds.PROGRAM_DONATION_DATA, + user=self.user, + program=program + )) + with open(self.proof_of_bank_transfer_file.name, 'rb') as proof_of_bank_transfer: + data = { + 'program_donation': program_donation.id, + 'proof_of_bank_transfer': proof_of_bank_transfer, + 'user_bank_account_name': 'Dummy User Bank Account Name', + 'user_bank_account_number': '0123456789', + } + response = request( + 'POST', + 'donation-reupload-proof-of-bank-transfer', + data, + format='multipart', + http_authorization=self.user_http_authorization + ) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + + def test_donation_reupload_proof_of_bank_transfer_fail(self): + program = models.Program.objects.create(**seeds.PROGRAM_DATA) + program_donation = models.ProgramDonation.objects.create(**dict( + seeds.PROGRAM_DONATION_DATA, + user=self.user, + program=program + )) + program_donation.donation_status = '002' + program_donation.save() + with open(self.proof_of_bank_transfer_file.name, 'rb') as proof_of_bank_transfer: + data = { + 'program_donation': program_donation.id, + 'proof_of_bank_transfer': proof_of_bank_transfer, + 'user_bank_account_name': 'Dummy User Bank Account Name', + 'user_bank_account_number': '0123456789', + } + response = request( + 'POST', + 'donation-reupload-proof-of-bank-transfer', + data, + format='multipart', + http_authorization=self.user_http_authorization + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + class UserTest(rest_framework_test.APITestCase): def setUp(self): @@ -939,6 +987,7 @@ class ProductTest(rest_framework_test.APITestCase): def test_create_product_success(self): data = dict(seeds.PRODUCT_DATA, subcategory=self.subcategory.id) + data['pre_order'] = True response = request( 'POST', 'product-list', @@ -951,7 +1000,7 @@ class ProductTest(rest_framework_test.APITestCase): def test_create_product_fail(self): data = dict(seeds.PRODUCT_DATA, subcategory=self.subcategory.id) - data['pre_order'] = True + data['stock'] = None response = request( 'POST', 'product-list', diff --git a/api/urls.py b/api/urls.py index 39eff93..a5ad661 100644 --- a/api/urls.py +++ b/api/urls.py @@ -30,6 +30,11 @@ urlpatterns = [ name='cart-cancel-transaction' ), urls.path('donation/create/', api_views.DonationCreate.as_view(), name='donation-create'), + urls.path( + 'donation/reupload-proof-of-bank-transfer/', + api_views.DonationReuploadProofOfBankTransfer.as_view(), + name='donation-reupload-proof-of-bank-transfer' + ), urls.path('users/', api_views.UserList.as_view(), name='user-list'), urls.path('users//', api_views.UserDetail.as_view(), name='user-detail'), urls.path('categories/', api_views.CategoryList.as_view(), name='category-list'), diff --git a/api/views.py b/api/views.py index 98ce988..be3d8f3 100644 --- a/api/views.py +++ b/api/views.py @@ -211,13 +211,13 @@ class CartUploadPOP(rest_framework_views.APIView): user=user ) if transaction.payment_method != 'TRF': - raise rest_framework_exceptions.PermissionDenied( - _('The payment method for this transaction is not a transfer.') - ) + raise rest_framework_exceptions.PermissionDenied(_( + 'The payment method for this transaction is not a transfer.' + )) if transaction.transaction_status not in ('001', '002'): - raise rest_framework_exceptions.PermissionDenied( - _('Cannot upload proof of payment at this stage.') - ) + raise rest_framework_exceptions.PermissionDenied(_( + 'Cannot upload proof of payment at this stage.' + )) transaction.proof_of_payment = serializer.validated_data['proof_of_payment'] transaction.user_bank_account_name = serializer.validated_data['user_bank_account_name'] transaction.user_bank_account_number = ( @@ -308,6 +308,40 @@ class DonationCreate(rest_framework_views.APIView): ) +class DonationReuploadProofOfBankTransfer(rest_framework_views.APIView): + permission_classes = [rest_framework_permissions.IsAuthenticated] + serializer_class = api_serializers.DonationReuploadProofOfBankTransferSerializer + + def get_serializer(self, *args, **kwargs): + return self.serializer_class(*args, **kwargs) + + def post(self, request, _format=None): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + user = request.user + program_donation = shortcuts.get_object_or_404( + models.ProgramDonation, + id=serializer.validated_data['program_donation'], + user=user + ) + if program_donation.donation_status not in ('001', '004'): + raise rest_framework_exceptions.PermissionDenied(_( + 'Cannot reupload proof of bank transfer at this stage.' + )) + program_donation.proof_of_bank_transfer = ( + serializer.validated_data['proof_of_bank_transfer'] + ) + program_donation.user_bank_account_name = ( + serializer.validated_data['user_bank_account_name'] + ) + program_donation.user_bank_account_number = ( + serializer.validated_data['user_bank_account_number'] + ) + program_donation.donation_status = '001' + program_donation.save() + return response.Response(status=status.HTTP_204_NO_CONTENT) + + class UserList(generics.ListCreateAPIView): filter_backends = [ rest_framework.DjangoFilterBackend, @@ -557,9 +591,9 @@ class ProgramDonationDetail(generics.RetrieveUpdateAPIView): instance = self.get_object() serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) - if instance.donation_status == '002': + if instance.donation_status in ('002', '003'): raise rest_framework_exceptions.PermissionDenied(_( - 'Cannot update donation because it has a completed status.' + 'Cannot update program donation because it has a completed or canceled status.' )) return super().update(request, *args, **kwargs) # pylint: disable=no-member diff --git a/locale/id/LC_MESSAGES/django.mo b/locale/id/LC_MESSAGES/django.mo index 8eb725b3ffe6f33de05e8c6f7bc676629b864979..7a06f6568cd8db3d3ed53b23e00fae611ab93c25 100644 GIT binary patch delta 3322 zcmaLZdrZ}39LMoTK=4A|z!KE>feIRdk|-vIco%Ppc_|b02j_?<;GDxb2ertKmKiSR zTIj0f=H_g9`6G+r)XlOvqnl~X>DJ23Zkwx7{$XXh_5PgmVExg0#`nCQ-}C#O=l**R z9TnFWL@y6Y+HFX!#4uu2k}(~)rxzcjU|(ZKU=t?eZoCWkpdLAdh1iZocoi!!J=K^A zSd9yD7gp*%=3_CH6c`gVYsd_sV5fC2_U8Hss(}-zicVt*{)wen)Xx|f8*x0I!=aei zKi<(ST+MYZCg3*ggWGW=?!tbwZ;q2mqu@hK#IIsEjJbkz)^y>0cnec7kKW3$NSmev z@5b54pQ++Q9rt4%Zosj)7boL6%*8$fnQ_`TMP!&Yvj_`tCCS#WrWb({p)BxsURBO7JOa`vNOl-35KuzhJs3kdV zJ&T&kOV+EXj{bsdA9KT&|AiV@a(ev!AY>BFP-}iV^Ut76DFtd^A*w?wuqRfdD)yi% zZbU6f2sNUpz20ek9aYZ()c4;(jr5qke+t#!S=3B?lg|9ddQO3Qb_4Z=>(r>VB(0qaJ4e+%Bj{Z~=X9YJ;ceO!v?qhz$Uqo{2@R--y{2)SiW zSkEJW=0`r%^V_J3`ck_(oQce~$w7^LBC5kvP|sJOmSTmq7S*9>fQ&}C8QCZ1W$PQL z3XY-H`V6X~^Qfh`jOx&LsLgm2)uA*lR9_CNBgI(SgH4Cptn1lmv2z4@K5F)mQ3Hoj z@AXkE!|PaqMeNAw*n-RPU3?4&vojyS0G8lB)XaTpy@r~Zo2Uj8ctuoCZ&dvYuvG8= zi(~>6blMvw!|6TO>ro?k4Hw{E)IstyYG%4n2gxne%w;n?25E{>=g30TOjM#~t`^5( z6RJb|FopKbK{A@Uw^3_&3bi&DP`mjD9D`k0fEiiw6LAjeVA_P-G6zu4ccL2l9A{&K z6W_EI_#oFykkv9fFsdH!C&O%;lh#XE#Px5;?3oeSY#f}5TB=u2Gq4XelDDi!kr&9E zw0?ow#6MYYqdJ(z^lE^^bC`c+3MtTQHPt#7waF?`yLLJ1i#4bQ8&Dkxqo#H<>fqXG z%MW4#*GEy$AGe-D)!T^)_-RfwzDDOLP(|NbyHE|@wx;kCsEP)nIy3~;@sX&GOtAN- z+xr!$&9@A-v{lvy)blO&eruGB*8C+@h5JzrcUa#;y=EU-ub?*7@2KZ{vjb;iCXPoJ z%W*eqKo_mwqn75nHDy%1p6D=}DMU3~j@lgaFbQjHc^zuxP1a4ed^@TmZPxv$*RaE0 zpF!1o(UxDa*S{i59yK?~sKLbC`0FziwP}h`BcF%b8!q<1f2T=YBoGs9VKQo?b|+TX ztS25OBDPS=q0~&w*ZZ%uhIo=FBf68$1f_@K-1#iFIlpx)t|Dd;v6(~7h>}B0Bc33} z5gM46(9%3g#MZy$5?X5|Z8D{)abo{sy+EEf&5X8X zkE70qxx_QXQlgwtij99UnVCdbB_t1_b73~&5+eznaNTJJnTbRrq4S}YP--G_h&p09 z(Veo$EVLID)0P}2)(u=2&Mbum~Ba9>e~kQ{bOdx+Z=2NxE?1M3Ix_V{MESr zdM6Tc{o%FVkmE+2$U0xx2}j&IZ=spt`u%~3(;W1;5wE^e7jhfh(o#qD3WS_m*I(;x z+m@Q!zB08ev9E49o`ByC``UN)pOKJrr!89f@8;S+9$3|*k2hQ!@&zNlfWK{2+6O7# zfb;FMZiCOMX|9j>{^@V5_d(a=IuUoB8+3erUt3E? zc1m|wzs|@Sz@Xe_$K$KPhI-e}1DWj?GPWl6ni=b@6Kt;>J}qH%(A(Xb>;J#@+FP@x f_2}gd*VF5;udUNLK7i&t&Gn43yYtcPiHUy$TZ??# delta 2981 zcmYk;eN5F=9LMo5AY=$eJSfV8TtR#QL`4Ke5KXNcXlk0G7T)Blq<{#hSoBk|S~chF zN^Nd6tGRMREAJYuQERKIn{938)*m+0Rjcg5t=!@dUA;f|9-L=Ob~-S5rsy8KswGli4H8!dO3&UzE&IX8AY{6R;YSaWU$aRX80t;7oi4{WyqoF(Jup zCDvjQzJ?|EIi{F-_D8g0ckq5LjHY!poQdV=!wOuBRd@sgcoEC6WVBfp22ca+!A3lU zvG@}v;xAZ$eR{R5XR>QA_Zn^H0>&{p*ZPiw-au*(Npy zbv?_SFGL>NN}UUkG1+6Nb_1w^Y{z)qrTNzr@8yIVJb;?|7g0|Va>wsDKR`7!h-%;* z>IuJg*DoX6!mgv*y@?vo5bC~>>Cq)iK@H58&it#xGEV3NwWulgyBjyT^X<-k?s_kd z<-X&{s@Yl8fWO4mcnP(HlQUQj+=LoPKk{RP&PyH@b@(f4O6H_SoQ(|L3Q$j6 zi5hSX>i#;^W@>N-Q3Gm6J>fxQ_3W7QJ=EtvMJ=6oo{Ac}gj#}Y$dCQZFKxQpr~zg0 zxN5KfHIO-26=OCZwJG;wv`DfYe1}AhO-WUDOPQ}Q;oY|-b^WF6+sHG@EHB{}aLk+OOxf#_?3u+*psDAdl z^M`$2^ujStXzfm*cJKSn0n`W1x$EDe*7gc&25zD{)DDx0sJAEGS%ezsBGmn>aVZ9| z4EsGQwN$R5p2WxBT3L!(ifZR7R6|>x-KY*YW$d`OBz*Ty=Uk zspvJ+*4Kp;RKq^h`672*g<9h!s1BE--i|G(J>sFBybpC>KgM7jSwU2^`3{oj$qYjA zB0E&?rKX?HM9Ly(1#WirO{f7qNp_Is~-}g@KK@}Eg~a`ik6^+bnE@s z25cd#$m8VRvW0`4_neqceLv9*H4=SKXf|?)N|0#Tx^zI9Ls(hct^>+OvVd$OYsh^1 zk4$45K0{Qr;Z(Mgb~1(RA`N63i6t{hfRvN<g%FS?-{#-Z_!B6 z?pLXf68SqmnR*>*CTl}2qlOZ?b~Xh%gP~JNlS0MG6T`QYYT`oeNonDYDaEnjfz%Z- zp|td`!oQ`Th!6E-B~EH;YYlWX=WP${3bqFFIywWLT^+66)Vi7i+wwYsJAxg7=1@!4 d_;5+qp}6oDx$|Pf#lDJ|P|L)DaCv@t-2aYB3PJz? diff --git a/locale/id/LC_MESSAGES/django.po b/locale/id/LC_MESSAGES/django.po index a323324..81c2563 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-04-26 06:46+0700\n" +"POT-Creation-Date: 2020-04-27 23:15+0700\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -22,45 +22,49 @@ msgstr "" msgid "Waiting for admin confirmation" msgstr "Menunggu konfirmasi admin" -#: api/constants.py:5 api/constants.py:18 +#: api/constants.py:5 api/constants.py:20 msgid "Completed" msgstr "Selesai" -#: api/constants.py:9 +#: api/constants.py:6 api/constants.py:21 +msgid "Canceled" +msgstr "Dibatalkan" + +#: api/constants.py:7 +msgid "Waiting for reupload of proof of bank transfer" +msgstr "Menunggu pengunggahan kembali bukti transfer bank" + +#: api/constants.py:11 msgid "Transfer" msgstr "Transfer" -#: api/constants.py:10 +#: api/constants.py:12 msgid "Cash on delivery" msgstr "Bayar di tempat" -#: api/constants.py:14 +#: api/constants.py:16 msgid "Waiting for proof of payment" msgstr "Menunggu bukti pembayaran" -#: api/constants.py:15 +#: api/constants.py:17 msgid "Waiting for seller confirmation" msgstr "Menunggu konfirmasi penjual" -#: api/constants.py:16 +#: api/constants.py:18 msgid "In process" msgstr "Dalam proses" -#: api/constants.py:17 +#: api/constants.py:19 msgid "Being shipped" msgstr "Sedang dikirim" -#: api/constants.py:19 -msgid "Canceled" -msgstr "Dibatalkan" - -#: api/constants.py:20 +#: api/constants.py:22 msgid "Failed" msgstr "Gagal" #: api/models.py:16 api/models.py:52 api/models.py:71 api/models.py:96 -#: api/models.py:136 api/models.py:149 api/models.py:175 api/models.py:281 -#: api/models.py:320 api/models.py:364 +#: api/models.py:141 api/models.py:154 api/models.py:180 api/models.py:286 +#: api/models.py:327 api/models.py:371 msgid "ID" msgstr "ID" @@ -80,15 +84,15 @@ msgstr "alamat" msgid "neighborhood" msgstr "RT" -#: api/models.py:30 api/models.py:463 +#: api/models.py:30 api/models.py:470 msgid "hamlet" msgstr "RW" -#: api/models.py:32 api/models.py:467 +#: api/models.py:32 api/models.py:474 msgid "urban village" msgstr "kelurahan" -#: api/models.py:33 api/models.py:471 +#: api/models.py:33 api/models.py:478 msgid "sub-district" msgstr "kecamatan" @@ -100,7 +104,7 @@ msgstr "foto profil" msgid "OTP" msgstr "OTP" -#: api/models.py:44 api/models.py:137 api/models.py:187 api/models.py:376 +#: api/models.py:44 api/models.py:142 api/models.py:192 api/models.py:383 msgid "user" msgstr "pengguna" @@ -108,7 +112,7 @@ msgstr "pengguna" msgid "users" msgstr "pengguna" -#: api/models.py:53 api/models.py:72 api/models.py:103 api/models.py:327 +#: api/models.py:53 api/models.py:72 api/models.py:103 api/models.py:334 msgid "name" msgstr "nama" @@ -132,13 +136,13 @@ msgstr "subkategori" msgid "subcategories" msgstr "subkategori" -#: api/models.py:101 api/models.py:325 +#: api/models.py:101 api/models.py:332 msgid "code" msgstr "kode" -#: api/models.py:110 api/models.py:328 +#: api/models.py:110 api/models.py:335 msgid "description" -msgstr "" +msgstr "deskripsi" #: api/models.py:115 msgid "price" @@ -152,7 +156,7 @@ msgstr "stok" msgid "pre-order" msgstr "pre-order" -#: api/models.py:128 api/models.py:160 api/models.py:293 +#: api/models.py:128 api/models.py:165 api/models.py:298 msgid "product" msgstr "produk" @@ -160,226 +164,230 @@ msgstr "produk" msgid "products" msgstr "produk" -#: api/models.py:141 api/models.py:154 +#: api/models.py:146 api/models.py:159 msgid "shopping cart" msgstr "keranjang belanja" -#: api/models.py:142 +#: api/models.py:147 msgid "shopping carts" msgstr "keranjang belanja" -#: api/models.py:162 api/models.py:302 +#: api/models.py:167 api/models.py:308 msgid "quantity" msgstr "kuantitas" -#: api/models.py:167 +#: api/models.py:172 msgid "cart item" msgstr "barang keranjang" -#: api/models.py:168 +#: api/models.py:173 msgid "cart items" msgstr "barang keranjang" -#: api/models.py:180 +#: api/models.py:185 msgid "transaction number" msgstr "nomor transaksi" -#: api/models.py:189 api/models.py:385 +#: api/models.py:194 api/models.py:392 msgid "user full name" msgstr "nama lengkap pengguna" -#: api/models.py:190 api/models.py:386 +#: api/models.py:195 api/models.py:393 msgid "user phone number" msgstr "nomor telepon pengguna" -#: api/models.py:191 +#: api/models.py:196 msgid "shipping address" msgstr "alamat pengiriman" -#: api/models.py:195 +#: api/models.py:200 msgid "shipping neighborhood" msgstr "RT pengiriman" -#: api/models.py:200 +#: api/models.py:205 msgid "shipping hamlet" msgstr "RW pengiriman" -#: api/models.py:204 +#: api/models.py:209 msgid "shipping urban village" msgstr "kelurahan pengiriman" -#: api/models.py:208 +#: api/models.py:213 msgid "shipping sub-district" msgstr "kecamatan pengiriman" -#: api/models.py:214 +#: api/models.py:219 msgid "shipping costs" msgstr "biaya pengiriman" -#: api/models.py:219 +#: api/models.py:224 msgid "payment method" msgstr "metode pembayaran" -#: api/models.py:226 +#: api/models.py:231 msgid "donation" msgstr "donasi" -#: api/models.py:231 +#: api/models.py:236 msgid "transaction status" msgstr "status transaksi" -#: api/models.py:237 +#: api/models.py:242 msgid "proof of payment" msgstr "bukti pembayaran" -#: api/models.py:243 api/models.py:406 +#: api/models.py:248 api/models.py:413 msgid "user bank account name" msgstr "nama akun bank pengguna" -#: api/models.py:249 api/models.py:410 +#: api/models.py:254 api/models.py:417 msgid "user bank account number" msgstr "nomor akun bank pengguna" -#: api/models.py:254 api/models.py:415 +#: api/models.py:259 api/models.py:422 msgid "created at" msgstr "dibuat pada" -#: api/models.py:256 api/models.py:417 +#: api/models.py:261 api/models.py:424 msgid "updated at" msgstr "diperbarui pada" -#: api/models.py:260 api/models.py:286 +#: api/models.py:265 api/models.py:291 msgid "transaction" msgstr "transaksi" -#: api/models.py:261 +#: api/models.py:266 msgid "transactions" msgstr "transaksi" -#: api/models.py:295 +#: api/models.py:300 msgid "product name" msgstr "nama produk" -#: api/models.py:300 +#: api/models.py:305 msgid "product price" msgstr "harga produk" -#: api/models.py:306 +#: api/models.py:307 +msgid "product pre-order" +msgstr "produk pre-order" + +#: api/models.py:312 msgid "transaction item" msgstr "barang transaksi" -#: api/models.py:307 +#: api/models.py:313 msgid "transaction items" msgstr "barang transaksi" -#: api/models.py:333 +#: api/models.py:340 msgid "start date and time" msgstr "tanggal dan waktu mulai" -#: api/models.py:338 +#: api/models.py:345 msgid "end date and time" msgstr "tanggal dan waktu berakhir" -#: api/models.py:344 +#: api/models.py:351 msgid "location" msgstr "lokasi" -#: api/models.py:346 +#: api/models.py:353 msgid "speaker" msgstr "pembicara" -#: api/models.py:351 +#: api/models.py:358 msgid "poster image" msgstr "gambar poster" -#: api/models.py:356 api/models.py:383 +#: api/models.py:363 api/models.py:390 msgid "program" msgstr "program" -#: api/models.py:357 +#: api/models.py:364 msgid "programs" msgstr "program" -#: api/models.py:369 +#: api/models.py:376 msgid "donation number" msgstr "nomor donasi" -#: api/models.py:387 +#: api/models.py:394 msgid "program name" msgstr "nama program" -#: api/models.py:392 +#: api/models.py:399 msgid "amount" msgstr "jumlah" -#: api/models.py:398 +#: api/models.py:405 msgid "donation status" msgstr "status donasi" -#: api/models.py:402 +#: api/models.py:409 msgid "proof of bank transfer" msgstr "bukti transfer bank" -#: api/models.py:421 +#: api/models.py:428 msgid "program donation" msgstr "donasi program" -#: api/models.py:422 +#: api/models.py:429 msgid "program donations" msgstr "donasi program" -#: api/models.py:436 +#: api/models.py:443 msgid "send SMS" msgstr "kirim SMS" -#: api/models.py:439 +#: api/models.py:446 msgid "application configuration" msgstr "konfigurasi aplikasi" -#: api/models.py:440 +#: api/models.py:447 msgid "application configurations" msgstr "konfigurasi aplikasi" -#: api/models.py:447 +#: api/models.py:454 msgid "bank name" msgstr "nama bank" -#: api/models.py:448 +#: api/models.py:455 msgid "account number" msgstr "nomor akun" -#: api/models.py:449 +#: api/models.py:456 msgid "account owner" msgstr "pemilik akun" -#: api/models.py:452 +#: api/models.py:459 msgid "bank account configuration" msgstr "konfigurasi akun bank" -#: api/models.py:453 +#: api/models.py:460 msgid "bank account configurations" msgstr "konfigurasi akun bank" -#: api/models.py:479 +#: api/models.py:486 msgid "" "shipping costs if the hamlet, urban village, and sub-district are the same " "as seller" msgstr "biaya pengiriman jika RW, kelurahan, dan kecamatan sama dengan penjual" -#: api/models.py:488 +#: api/models.py:495 msgid "" "shipping costs if the urban village and sub-district are the same as seller" msgstr "biaya pengirima jika kelurahan dan kecamatan sama dengan penjual" -#: api/models.py:496 +#: api/models.py:503 msgid "shipping costs if the sub-district is the same as seller" msgstr "biaya pengiriman jika kecamatan sama dengan penjual" -#: api/models.py:500 +#: api/models.py:507 msgid "shipment configuration" msgstr "konfigurasi pengiriman" -#: api/models.py:501 +#: api/models.py:508 msgid "shipment configurations" msgstr "konfigurasi pengiriman" @@ -411,11 +419,11 @@ msgstr "Transaksi" msgid "Proof of payment" msgstr "Bukti pembayaran" -#: api/serializers.py:43 api/serializers.py:66 +#: api/serializers.py:43 api/serializers.py:66 api/serializers.py:79 msgid "User bank account name" msgstr "Nama akun bank pengguna" -#: api/serializers.py:47 api/serializers.py:70 +#: api/serializers.py:47 api/serializers.py:70 api/serializers.py:83 msgid "User bank account number" msgstr "Nomor akun bank pengguna" @@ -427,23 +435,23 @@ msgstr "Program" msgid "Amount" msgstr "Jumlah" -#: api/serializers.py:64 +#: api/serializers.py:64 api/serializers.py:77 msgid "Proof of bank transfer" msgstr "Bukti transfer bank" -#: api/serializers.py:183 -msgid "Pre-order products cannot have stock data." -msgstr "Produk pre-order tidak dapat memiliki data stok." +#: api/serializers.py:76 +msgid "Program donasi" +msgstr "Donasi program" -#: api/serializers.py:185 +#: api/serializers.py:196 msgid "Stock cannot be empty if it is not a pre-order." msgstr "Stok tidak boleh kosong jika bukan pre-order." -#: api/serializers.py:321 +#: api/serializers.py:334 msgid "Cannot update transaction status to failed." msgstr "Tidak dapat memperbarui status transaksi ke gagal." -#: api/serializers.py:350 +#: api/serializers.py:363 msgid "End date time should be greater than start date time." msgstr "Waktu tanggal berakhir harus lebih besar dari waktu tanggal mulai." @@ -503,15 +511,19 @@ msgstr "" msgid "Transaction cannot be canceled at this stage." msgstr "Transaksi tidak dapat dibatalkan pada tahap ini." -#: api/views.py:372 +#: api/views.py:329 +msgid "Cannot reupload proof of bank transfer at this stage." +msgstr "Tidak dapat mengunggah kembali bukti transfer bank pada tahap ini." + +#: api/views.py:406 msgid "Cannot delete category due to integrity error." msgstr "Tidak dapat menghapus kategori karena kesalahan integritas." -#: api/views.py:409 +#: api/views.py:443 msgid "Cannot delete subcategory due to integrity error." msgstr "Tidak dapat menghapus subkategori karena kesalahan integritas." -#: api/views.py:500 +#: api/views.py:534 msgid "" "Cannot update transaction because it has a completed, canceled, or failed " "status." @@ -519,9 +531,12 @@ msgstr "" "Tidak dapat memperbarui transaksi karena memiliki status selesai, " "dibatalkan, atau gagal." -#: api/views.py:562 -msgid "Cannot update donation because it has a completed status." -msgstr "Tidak dapat memperbarui donasi karena statusnya sudah selesai." +#: api/views.py:596 +msgid "" +"Cannot update program donation because it has a completed or canceled status." +msgstr "" +"Tidak dapat memperbarui donasi program karena memiliki status selesai atau " +"dibatalkan." #: home_industry/utils.py:13 home_industry/utils.py:21 #: home_industry/utils.py:29 @@ -532,6 +547,9 @@ msgstr "Terjadi kesalahan konfigurasi." msgid "Server is currently unable to send SMS." msgstr "Server saat ini tidak dapat mengirim SMS." +msgid "Pre-order products cannot have stock data." +msgstr "Produk pre-order tidak dapat memiliki data stok." + msgid "Not a valid string." msgstr "Bukan string yang valid." -- GitLab From e7bd24ad56277bbd66416b49df8f8a8a0d97a01c Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Tue, 28 Apr 2020 21:53:58 +0700 Subject: [PATCH 70/90] Fix null proof_of_bank_transfer --- api/serializers.py | 2 +- api/views.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/api/serializers.py b/api/serializers.py index 2b743a3..ad97c42 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -378,9 +378,9 @@ class ProgramDonationSerializer(serializers.ModelSerializer): 'id', 'donation_number', 'user', + 'user_username', 'program', 'program_code', - 'user_username', 'user_full_name', 'user_phone_number', 'program_name', diff --git a/api/views.py b/api/views.py index be3d8f3..64020ad 100644 --- a/api/views.py +++ b/api/views.py @@ -300,7 +300,8 @@ class DonationCreate(rest_framework_views.APIView): program=program, amount=serializer.validated_data['amount'], user_bank_account_name=serializer.validated_data['user_bank_account_name'], - user_bank_account_number=serializer.validated_data['user_bank_account_number'] + user_bank_account_number=serializer.validated_data['user_bank_account_number'], + proof_of_bank_transfer=serializer.validated_data['proof_of_bank_transfer'] ) return response.Response( {'program_donation': program_donation.id}, -- GitLab From f6f7edb574d60fe250e781887c19d7080eef166a Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Fri, 8 May 2020 00:01:58 +0700 Subject: [PATCH 71/90] Add Program Donation Validation and Donation Total Amount --- api/serializers.py | 15 +++++++++++ api/tests.py | 32 ++++++++++++++++++----- api/urls.py | 2 +- api/views.py | 18 ++++++++----- locale/id/LC_MESSAGES/django.mo | Bin 9834 -> 9984 bytes locale/id/LC_MESSAGES/django.po | 44 +++++++++++++++++--------------- 6 files changed, 77 insertions(+), 34 deletions(-) diff --git a/api/serializers.py b/api/serializers.py index ad97c42..4c5f7ba 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -74,6 +74,12 @@ class DonationCreateSerializer(serializers.Serializer): # pylint: disable=abstra class DonationReuploadProofOfBankTransferSerializer(serializers.Serializer): # pylint: disable=abstract-method program_donation = serializers.UUIDField(label=_('Program donasi')) + amount = serializers.DecimalField( + decimal_places=2, + label=_('Amount'), + max_digits=12, + min_value=decimal.Decimal('0.01') + ) proof_of_bank_transfer = serializers.ImageField(label=_('Proof of bank transfer')) user_bank_account_name = serializers.CharField( label=_('User bank account name'), @@ -338,6 +344,8 @@ class TransactionSerializer(serializers.ModelSerializer): class ProgramSerializer(serializers.ModelSerializer): + donation_total_amount = serializers.SerializerMethodField('get_donation_total_amount') + class Meta: fields = [ 'id', @@ -349,10 +357,17 @@ class ProgramSerializer(serializers.ModelSerializer): 'location', 'speaker', 'poster_image', + 'donation_total_amount', ] model = models.Program read_only_fields = ['id', 'code'] + def get_donation_total_amount(self, obj): # pylint: disable=no-self-use + donation_total_amount = sum( + program_donation.amount for program_donation in obj.program_donations.all() + ) + return str(donation_total_amount) + def validate(self, attrs): instance = self.instance start_date_time = attrs.get('start_date_time', getattr(instance, 'start_date_time', None)) diff --git a/api/tests.py b/api/tests.py index 7b2794d..5adb3a9 100644 --- a/api/tests.py +++ b/api/tests.py @@ -4,6 +4,7 @@ from unittest import mock import jwt from django import conf, test as django_test, urls +from django.utils import timezone from PIL import Image from rest_framework import exceptions, status, test as rest_framework_test @@ -602,6 +603,8 @@ class DonationTest(rest_framework_test.APITestCase): def test_donation_create_success(self): program = models.Program.objects.create(**seeds.PROGRAM_DATA) + program.end_date_time = (timezone.now() + timezone.timedelta(days=1)).isoformat() + program.save() with open(self.proof_of_bank_transfer_file.name, 'rb') as proof_of_bank_transfer: data = { 'program': program.id, @@ -621,13 +624,26 @@ class DonationTest(rest_framework_test.APITestCase): self.assertEqual(models.ProgramDonation.objects.count(), 1) def test_donation_create_fail(self): - response = request( - 'POST', - 'donation-create', - http_authorization=self.user_http_authorization - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(models.ProgramDonation.objects.count(), 0) + program = models.Program.objects.create(**seeds.PROGRAM_DATA) + program.end_date_time = (timezone.now() - timezone.timedelta(days=1)).isoformat() + program.save() + with open(self.proof_of_bank_transfer_file.name, 'rb') as proof_of_bank_transfer: + data = { + 'program': program.id, + 'amount': '1000', + 'proof_of_bank_transfer': proof_of_bank_transfer, + 'user_bank_account_name': 'Dummy User Bank Account Name', + 'user_bank_account_number': '0123456789', + } + response = request( + 'POST', + 'donation-create', + data, + format='multipart', + http_authorization=self.user_http_authorization + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(models.ProgramDonation.objects.count(), 0) def test_donation_reupload_proof_of_bank_transfer_success(self): program = models.Program.objects.create(**seeds.PROGRAM_DATA) @@ -638,6 +654,7 @@ class DonationTest(rest_framework_test.APITestCase): )) with open(self.proof_of_bank_transfer_file.name, 'rb') as proof_of_bank_transfer: data = { + 'amount': '1000', 'program_donation': program_donation.id, 'proof_of_bank_transfer': proof_of_bank_transfer, 'user_bank_account_name': 'Dummy User Bank Account Name', @@ -663,6 +680,7 @@ class DonationTest(rest_framework_test.APITestCase): program_donation.save() with open(self.proof_of_bank_transfer_file.name, 'rb') as proof_of_bank_transfer: data = { + 'amount': '1000', 'program_donation': program_donation.id, 'proof_of_bank_transfer': proof_of_bank_transfer, 'user_bank_account_name': 'Dummy User Bank Account Name', diff --git a/api/urls.py b/api/urls.py index a5ad661..06f64fa 100644 --- a/api/urls.py +++ b/api/urls.py @@ -55,7 +55,7 @@ urlpatterns = [ ), urls.path('transactions/', api_views.TransactionList.as_view(), name='transaction-list'), urls.path( - 'transactions/', + 'transactions//', api_views.TransactionDetail.as_view(), name='transaction-detail' ), diff --git a/api/views.py b/api/views.py index 64020ad..104bad4 100644 --- a/api/views.py +++ b/api/views.py @@ -2,6 +2,7 @@ from django import shortcuts from django.contrib import auth from django.db import transaction as db_transaction, utils as db_utils from django.db.models import deletion +from django.utils import timezone from django.utils.translation import gettext_lazy as _ from django_filters import rest_framework from knox import views as knox_views @@ -245,9 +246,9 @@ class CartCompleteTransaction(rest_framework_views.APIView): user=user ) if transaction.transaction_status != '004': - raise rest_framework_exceptions.PermissionDenied( - _('Transaction cannot be completed unless the status is \"Being shipped\".') - ) + raise rest_framework_exceptions.PermissionDenied(_( + 'Transaction cannot be completed unless the status is \"Being shipped\".' + )) transaction.transaction_status = '005' transaction.save() return response.Response(status=status.HTTP_204_NO_CONTENT) @@ -270,9 +271,9 @@ class CartCancelTransaction(rest_framework_views.APIView): user=user ) if transaction.transaction_status not in ('001', '002'): - raise rest_framework_exceptions.PermissionDenied( - _('Transaction cannot be canceled at this stage.') - ) + raise rest_framework_exceptions.PermissionDenied(_( + 'Transaction cannot be canceled at this stage.' + )) transaction_items = transaction.transaction_items.all() api_utils.return_transaction_items_to_product_stock(transaction_items) transaction.transaction_status = '006' @@ -295,6 +296,10 @@ class DonationCreate(rest_framework_views.APIView): models.Program, id=serializer.validated_data['program'] ) + if (program.end_date_time is not None) and (program.end_date_time < timezone.now()): + raise rest_framework_exceptions.PermissionDenied(_( + 'The donation period for this program has ended.' + )) program_donation = models.ProgramDonation.objects.create( user=user, program=program, @@ -329,6 +334,7 @@ class DonationReuploadProofOfBankTransfer(rest_framework_views.APIView): raise rest_framework_exceptions.PermissionDenied(_( 'Cannot reupload proof of bank transfer at this stage.' )) + program_donation.amount = serializer.validated_data['amount'] program_donation.proof_of_bank_transfer = ( serializer.validated_data['proof_of_bank_transfer'] ) diff --git a/locale/id/LC_MESSAGES/django.mo b/locale/id/LC_MESSAGES/django.mo index 7a06f6568cd8db3d3ed53b23e00fae611ab93c25..bdf5d275fd37dfdcda5726da75b64cb493b59d8a 100644 GIT binary patch delta 3110 zcmYk;eN5F=9LMn^qWF-HsHv6wL_?7fQ4vA|GsP6td?YoYi}wmw;a0fr_I&I zY_e%?J(zASHL_)Y#A=SV)c-QFEp*kUWq(w*bTzl$pZojLI^+9$o!`0Vch32phoAmZ zwrg>`YiRN@M>%i@ot04#2NbRer$9co%bV^drtKz%tCk zH*gVN#ijb4hqJI2vz?2(<75U?&|!9BU+(XqI`{+C&_9@uxdWY>iD3-jyEqL~208Z_ z&O{Bg94oN`lkhX_ho9qkyov+q-~CP|l>&{TCk#W{bR&^Ly9qc6r(+6Mn$@U!Q5=Cg zkw15giw1rgC*V2E!A>(}h;xP9=VJ!_yBHZweJe7H?qkfxFK{0IhH7xeQ0EG;7}eo+ z9Eq>t7(9b&=S!T7KVcfC@^+euX{Z6WAd~6d!noe(JQ+>dCpZe*aWvjA|3ppgpke;f zWSM!WDJ?b^q6WGW*+#Cy%GaY_EMmVmA(QU*4rBh6X`z6ZbZ?{9{vv8X?br)DPz_&2 zHGCblG~c1#=(gSeW%eBIw=)>^{AkpRdZ_0nqWa4l&irdC=2MUuIch)^s0Sj*vbjdo z0|!wJw4&;tMlHoTRJ{%>?=)}O??2doIo4fq{Y{R^n2XgA|m$Y?~Js5iWW>>KyDIe;Ce z20YYSXQLV_KrKZHYCxsPpR3}c0X3uAJAxX>37px3O^4d7U2HU#IPM;j(F4isICYSU zI^Sck2-l!KD6KdLf5cUo!H!&wJ8&v?Vm|ic2x#W!n9EQzQ-#`78&U0S!6KdikI1OQ zdlC;X02VLJC+s5j`ru*)z7^+8#InwfIc2PKG_xx>gyx)Z4H$wkymTtdy<6`X=M zus8j?erf&)hoEL+6lx78qLyYB>QpSkOf1K2+-;u2Jnp|oeW+4Be~ODx&#gmsSdArk z9JP6GV*F_`cgV2ZE`6*&@KHf zF(z?ehN{2BTs1E4H@wa&RH4=?glcG;xgXVGi+K{&(0ix>eSms_Pf-K8V!wZDzu!b{ z!n>#?zGo)I**SV(2&%#u)Y@mD8Z1P0TxzaF9k)ue5jEhWsQRa{1kd9%ypOXn>q-BG zYRx9p(!>v0<|L}256#P{j=w?eja!(E53Kwl>djL!d{a^N(@+D+Gz(G3vDEI@qUzQ9 z<#E?&1qV=T{yM6|R@5=NfZ8-)q2Bx!s@{FE+4lD;GQ1}JSK)(~@aW9b!S z;#?!4eK3{&T^+HH&~yfgRfN{&B|_;bAGgKSPS!Eh(N#(#vWRDOW9d~qMC>Lii6w+m zPhvAsOlVo+eB2Ugg1HFwF;UXAcPAYurRNBp`4@>DM0d(0qh(WC=94%SoNV^SjYMDK zpf=EWqKOzwC^4o)TF7-Lkx%F>&m(#fMTEW?wQY6%H}skmj79%GmfXmu=5=hX!2y-*|=3btJv+&pknpg!Q~nfkD|HPWzk(|`5Ck+9bgstwe5 QRiRj5Q%$&SZdyjlzp}d*%>V!Z delta 2968 zcmYk;drZ}39LMoTt|3Kou@p2E5HbQKK@1Ftl8XX~DU=Ban05gXO|*~>S{W|KTFI)# z#>Q}Y`6G+L)XkzfN39HMO}5sE?6$cYpmu9I+dh2=W$!f%%Pyu?7{Xy2V96G_3R=#SrdZ#XxBbkS!{fWb#}VY5+y((VDI&GaoB48k^0ds40C9wImnK%c!XwGq0jL z`a80H+zl)L2Q{#uDBt}EWD;GBnHC^Y86B1?t%i)E9#0`qnZG^@Uhe!)d7Jb5KiB zh%Be8w(=%(zuiBGcXI!2)N^N09sdM3;;4s=)^-WCt;1?mM@}KP+<9{p`E$SUp`K5p zDhj1`bvPQCZ5M|c`ASrWSEHUUMlD69S&!Te^ zaa4zXL~X_?REHwDPQFBR)4n@NMpO3z zY7K`_Yx50iH~)-FaS~H-eys0AEJPhlZOAQm0`>ebs-ds20R0yEHf=H9&-DgmwcJtk zsKWg)#1{+Wv*n^tdcGSVuY2_!;kLy9y^XJSVRK3IK zhhM~bd}}m9fhzjJoJ2J^Z3gobsEWc-9h!&gcmk>;8FoM4?iZsr-zL=3R+$Z`=UeT5 zhlh;T{B=}?eW->9%#Ts8*=ObiYE%7%dOn05Sb)*E0&6i3yHEqVV*Z3$n(Jop5??)@ z1(r!gHJpdq9BVNE>#TeSYUIsko0T6yb>vO65A_-j*!3k;y;rP!!mj^7mfYi}$f!a8 zMBnQZgW5FdsFANl?TuQTh5t{hxbPz~tS|_*QD+jX>sp9Mi07`6jP^BCc6e;)=Cj}k8t zI@L5{rKgD+qK(jUC>`g=HZJ&Ia_aix diff --git a/locale/id/LC_MESSAGES/django.po b/locale/id/LC_MESSAGES/django.po index 81c2563..74c1334 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-04-27 23:15+0700\n" +"POT-Creation-Date: 2020-05-07 23:26+0700\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -419,11 +419,11 @@ msgstr "Transaksi" msgid "Proof of payment" msgstr "Bukti pembayaran" -#: api/serializers.py:43 api/serializers.py:66 api/serializers.py:79 +#: api/serializers.py:43 api/serializers.py:66 api/serializers.py:85 msgid "User bank account name" msgstr "Nama akun bank pengguna" -#: api/serializers.py:47 api/serializers.py:70 api/serializers.py:83 +#: api/serializers.py:47 api/serializers.py:70 api/serializers.py:89 msgid "User bank account number" msgstr "Nomor akun bank pengguna" @@ -431,11 +431,11 @@ msgstr "Nomor akun bank pengguna" msgid "Program" msgstr "Program" -#: api/serializers.py:60 +#: api/serializers.py:60 api/serializers.py:79 msgid "Amount" msgstr "Jumlah" -#: api/serializers.py:64 api/serializers.py:77 +#: api/serializers.py:64 api/serializers.py:83 msgid "Proof of bank transfer" msgstr "Bukti transfer bank" @@ -443,15 +443,15 @@ msgstr "Bukti transfer bank" msgid "Program donasi" msgstr "Donasi program" -#: api/serializers.py:196 +#: api/serializers.py:202 msgid "Stock cannot be empty if it is not a pre-order." msgstr "Stok tidak boleh kosong jika bukan pre-order." -#: api/serializers.py:334 +#: api/serializers.py:340 msgid "Cannot update transaction status to failed." msgstr "Tidak dapat memperbarui status transaksi ke gagal." -#: api/serializers.py:363 +#: api/serializers.py:378 msgid "End date time should be greater than start date time." msgstr "Waktu tanggal berakhir harus lebih besar dari waktu tanggal mulai." @@ -479,51 +479,55 @@ msgid "" msgstr "" "Gagal checkout karena jumlah barang yang dibeli melebihi stok yang tersedia." -#: api/views.py:160 +#: api/views.py:161 #, python-brace-format msgid "" "Cannot process shipment to other sub-districts other than {sub_district}." msgstr "" "Tidak dapat memproses pengiriman ke kecamatan lain selain {sub_district}." -#: api/views.py:166 +#: api/views.py:167 msgid "Unable to checkout because there are no items purchased." msgstr "Tidak dapat checkout karena tidak ada barang yang dibeli." -#: api/views.py:193 +#: api/views.py:194 msgid "Checkout failed." msgstr "Checkout gagal." -#: api/views.py:215 +#: api/views.py:216 msgid "The payment method for this transaction is not a transfer." msgstr "Metode pembayaran untuk transaksi ini bukan transfer." -#: api/views.py:219 +#: api/views.py:220 msgid "Cannot upload proof of payment at this stage." msgstr "Tidak dapat mengunggah bukti pembayaran pada tahap ini." -#: api/views.py:249 +#: api/views.py:250 msgid "Transaction cannot be completed unless the status is \"Being shipped\"." msgstr "" "Transaksi tidak dapat diselesaikan kecuali statusnya \"Sedang dikirim\"." -#: api/views.py:274 +#: api/views.py:275 msgid "Transaction cannot be canceled at this stage." msgstr "Transaksi tidak dapat dibatalkan pada tahap ini." -#: api/views.py:329 +#: api/views.py:301 +msgid "The donation period for this program has ended." +msgstr "Masa donasi untuk program ini telah berakhir." + +#: api/views.py:335 msgid "Cannot reupload proof of bank transfer at this stage." msgstr "Tidak dapat mengunggah kembali bukti transfer bank pada tahap ini." -#: api/views.py:406 +#: api/views.py:413 msgid "Cannot delete category due to integrity error." msgstr "Tidak dapat menghapus kategori karena kesalahan integritas." -#: api/views.py:443 +#: api/views.py:450 msgid "Cannot delete subcategory due to integrity error." msgstr "Tidak dapat menghapus subkategori karena kesalahan integritas." -#: api/views.py:534 +#: api/views.py:541 msgid "" "Cannot update transaction because it has a completed, canceled, or failed " "status." @@ -531,7 +535,7 @@ msgstr "" "Tidak dapat memperbarui transaksi karena memiliki status selesai, " "dibatalkan, atau gagal." -#: api/views.py:596 +#: api/views.py:603 msgid "" "Cannot update program donation because it has a completed or canceled status." msgstr "" -- GitLab From 6b00fa4f7600b9936e66f79c6bb51df0240855d3 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Fri, 8 May 2020 14:27:20 +0700 Subject: [PATCH 72/90] [GREEN] Update search_fields --- api/views.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api/views.py b/api/views.py index 104bad4..2032353 100644 --- a/api/views.py +++ b/api/views.py @@ -466,7 +466,7 @@ class ProductList(generics.ListCreateAPIView): rest_framework_permissions.IsAuthenticated, ] queryset = models.Product.objects.all() - search_fields = ['name'] + search_fields = ['code', 'name'] serializer_class = api_serializers.ProductSerializer @@ -560,7 +560,7 @@ class ProgramList(generics.ListCreateAPIView): rest_framework_permissions.IsAuthenticated, ] queryset = models.Program.objects.all() - search_fields = ['name'] + search_fields = ['code', 'name'] serializer_class = api_serializers.ProgramSerializer @@ -576,7 +576,7 @@ class ProgramDonationList(generics.ListAPIView): permission_classes = [rest_framework_permissions.IsAuthenticated] queryset = models.ProgramDonation.objects.all() schema = schemas.ProgramDonationListSchema() - search_fields = ['program_donation_number', 'user_full_name'] + search_fields = ['donation_number', 'user_full_name', 'program_name'] serializer_class = api_serializers.ProgramDonationSerializer def filter_queryset(self, queryset): -- GitLab From 518e3ebd36b33f6f1e8b2b704fb05d94db98d9a0 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Tue, 12 May 2020 02:50:16 +0700 Subject: [PATCH 73/90] Complete Help Contact Config API and Update Program Donation Fields --- .../commands/createorupdateapiconfig.py | 1 + api/migrations/0002_auto_20200512_0230.py | 44 +++++ api/models.py | 29 ++- api/seeds.py | 5 +- api/serializers.py | 34 ++-- api/tests.py | 79 ++++++-- api/urls.py | 5 + api/views.py | 22 ++- api_config.yaml | 2 + locale/id/LC_MESSAGES/django.mo | Bin 9984 -> 10022 bytes locale/id/LC_MESSAGES/django.po | 181 +++++++++--------- 11 files changed, 262 insertions(+), 140 deletions(-) create mode 100644 api/migrations/0002_auto_20200512_0230.py diff --git a/api/management/commands/createorupdateapiconfig.py b/api/management/commands/createorupdateapiconfig.py index 3e3c859..7185ed2 100644 --- a/api/management/commands/createorupdateapiconfig.py +++ b/api/management/commands/createorupdateapiconfig.py @@ -13,6 +13,7 @@ class Command(base.BaseCommand): config_key_to_model_class_map = { 'app_config': models.AppConfig, 'bank_account_config': models.BankAccountConfig, + 'help_contact_config': models.HelpContactConfig, 'shipment_config': models.ShipmentConfig, } for key, model_class in config_key_to_model_class_map.items(): diff --git a/api/migrations/0002_auto_20200512_0230.py b/api/migrations/0002_auto_20200512_0230.py new file mode 100644 index 0000000..8e2373a --- /dev/null +++ b/api/migrations/0002_auto_20200512_0230.py @@ -0,0 +1,44 @@ +# Generated by Django 3.0.3 on 2020-05-11 19:30 + +import api.utils +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='HelpContactConfig', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, verbose_name='email')), + ], + options={ + 'verbose_name': 'help contact configuration', + 'verbose_name_plural': 'help contact configurations', + }, + ), + migrations.RemoveField( + model_name='programdonation', + name='user_bank_account_number', + ), + migrations.RemoveField( + model_name='transaction', + name='user_bank_account_number', + ), + migrations.AddField( + model_name='program', + name='open_donation', + field=models.BooleanField(default=True, verbose_name='open for donation'), + ), + migrations.AddField( + model_name='program', + name='program_minutes', + field=models.FileField(blank=True, null=True, upload_to=api.utils.get_upload_file_path, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['pdf'])], verbose_name='program minutes'), + ), + ] diff --git a/api/models.py b/api/models.py index c5f5880..0fdce3a 100644 --- a/api/models.py +++ b/api/models.py @@ -247,12 +247,6 @@ class Transaction(db_models.Model): null=True, verbose_name=_('user bank account name') ) - user_bank_account_number = db_models.CharField( - blank=True, - max_length=100, - null=True, - verbose_name=_('user bank account number') - ) created_at = db_models.DateTimeField( auto_now_add=True, db_index=True, @@ -351,6 +345,14 @@ class Program(db_models.Model): verbose_name=_('location') ) speaker = db_models.CharField(blank=True, max_length=200, null=True, verbose_name=_('speaker')) + open_donation = db_models.BooleanField(default=True, verbose_name=_('open for donation')) + program_minutes = db_models.FileField( + blank=True, + null=True, + upload_to=utils.get_upload_file_path, + validators=[validators.FileExtensionValidator(allowed_extensions=['pdf'])], + verbose_name=_('program minutes') + ) poster_image = db_models.ImageField( blank=True, null=True, @@ -412,10 +414,6 @@ class ProgramDonation(db_models.Model): max_length=200, verbose_name=_('user bank account name') ) - user_bank_account_number = db_models.CharField( - max_length=100, - verbose_name=_('user bank account number') - ) created_at = db_models.DateTimeField( auto_now_add=True, db_index=True, @@ -463,6 +461,17 @@ class BankAccountConfig(solo_models.SingletonModel): return 'Bank Account Configuration' +class HelpContactConfig(solo_models.SingletonModel): + email = db_models.EmailField(verbose_name=_('email')) + + class Meta: + verbose_name = _('help contact configuration') + verbose_name_plural = _('help contact configurations') + + def __str__(self): + return 'Help Contact Configuration' + + class ShipmentConfig(solo_models.SingletonModel): hamlet = db_models.CharField( max_length=3, diff --git a/api/seeds.py b/api/seeds.py index cef5665..c7cfece 100644 --- a/api/seeds.py +++ b/api/seeds.py @@ -52,7 +52,6 @@ PROGRAM_DATA = { PROGRAM_DONATION_DATA = { 'amount': '1000', 'user_bank_account_name': 'Dummy User Bank Account Name', - 'user_bank_account_number': '0123456789', } BANK_ACCOUNT_CONFIG_DATA = { @@ -61,6 +60,10 @@ BANK_ACCOUNT_CONFIG_DATA = { 'account_owner': 'Dummy Account Owner', } +HELP_CONTACT_CONFIG_DATA = { + 'email': 'dummy@example.com', +} + SHIPMENT_CONFIG_DATA = { 'hamlet': '001', 'urban_village': 'Dummy Urban Village', diff --git a/api/serializers.py b/api/serializers.py index 4c5f7ba..f0fdd52 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -43,10 +43,6 @@ class CartUploadPOPSerializer(serializers.Serializer): # pylint: disable=abstrac label=_('User bank account name'), max_length=200 ) - user_bank_account_number = serializers.CharField( - label=_('User bank account number'), - max_length=100 - ) class CartCompleteOrCancelTransactionSerializer(serializers.Serializer): # pylint: disable=abstract-method @@ -66,10 +62,6 @@ class DonationCreateSerializer(serializers.Serializer): # pylint: disable=abstra label=_('User bank account name'), max_length=200 ) - user_bank_account_number = serializers.CharField( - label=_('User bank account number'), - max_length=100 - ) class DonationReuploadProofOfBankTransferSerializer(serializers.Serializer): # pylint: disable=abstract-method @@ -85,10 +77,6 @@ class DonationReuploadProofOfBankTransferSerializer(serializers.Serializer): # p label=_('User bank account name'), max_length=200 ) - user_bank_account_number = serializers.CharField( - label=_('User bank account number'), - max_length=100 - ) class UserSerializer(serializers.ModelSerializer): @@ -288,7 +276,6 @@ class TransactionSerializer(serializers.ModelSerializer): 'readable_transaction_status', 'proof_of_payment', 'user_bank_account_name', - 'user_bank_account_number', 'created_at', 'updated_at', 'subtotal', @@ -310,7 +297,6 @@ class TransactionSerializer(serializers.ModelSerializer): 'donation', 'proof_of_payment', 'user_bank_account_name', - 'user_bank_account_number', 'created_at', 'updated_at', ] @@ -344,7 +330,7 @@ class TransactionSerializer(serializers.ModelSerializer): class ProgramSerializer(serializers.ModelSerializer): - donation_total_amount = serializers.SerializerMethodField('get_donation_total_amount') + total_donation_amount = serializers.SerializerMethodField('get_total_donation_amount') class Meta: fields = [ @@ -356,17 +342,19 @@ class ProgramSerializer(serializers.ModelSerializer): 'end_date_time', 'location', 'speaker', + 'open_donation', + 'program_minutes', 'poster_image', - 'donation_total_amount', + 'total_donation_amount', ] model = models.Program read_only_fields = ['id', 'code'] - def get_donation_total_amount(self, obj): # pylint: disable=no-self-use - donation_total_amount = sum( + def get_total_donation_amount(self, obj): # pylint: disable=no-self-use + total_donation_amount = sum( program_donation.amount for program_donation in obj.program_donations.all() ) - return str(donation_total_amount) + return str(total_donation_amount) def validate(self, attrs): instance = self.instance @@ -404,7 +392,6 @@ class ProgramDonationSerializer(serializers.ModelSerializer): 'readable_donation_status', 'proof_of_bank_transfer', 'user_bank_account_name', - 'user_bank_account_number', 'created_at', 'updated_at', ] @@ -420,7 +407,6 @@ class ProgramDonationSerializer(serializers.ModelSerializer): 'amount', 'proof_of_bank_transfer', 'user_bank_account_name', - 'user_bank_account_number', 'created_at', 'updated_at', ] @@ -441,6 +427,12 @@ class BankAccountConfigSerializer(serializers.ModelSerializer): model = models.BankAccountConfig +class HelpContactConfigSerializer(serializers.ModelSerializer): + class Meta: + fields = ['email'] + model = models.HelpContactConfig + + class ShipmentConfigSerializer(serializers.ModelSerializer): class Meta: fields = [ diff --git a/api/tests.py b/api/tests.py index 5adb3a9..58380ad 100644 --- a/api/tests.py +++ b/api/tests.py @@ -4,7 +4,6 @@ from unittest import mock import jwt from django import conf, test as django_test, urls -from django.utils import timezone from PIL import Image from rest_framework import exceptions, status, test as rest_framework_test @@ -480,7 +479,6 @@ class CartTest(rest_framework_test.APITestCase): 'transaction': response.data['transaction'], 'proof_of_payment': proof_of_payment, 'user_bank_account_name': 'Dummy User Bank Account Name', - 'user_bank_account_number': '0123456789', } response = request( 'POST', @@ -517,7 +515,6 @@ class CartTest(rest_framework_test.APITestCase): 'transaction': response.data['transaction'], 'proof_of_payment': proof_of_payment, 'user_bank_account_name': 'Dummy User Bank Account Name', - 'user_bank_account_number': '0123456789', } response = request( 'POST', @@ -555,7 +552,6 @@ class CartTest(rest_framework_test.APITestCase): 'transaction': response.data['transaction'], 'proof_of_payment': proof_of_payment, 'user_bank_account_name': 'Dummy User Bank Account Name', - 'user_bank_account_number': '0123456789', } response = request( 'POST', @@ -603,15 +599,12 @@ class DonationTest(rest_framework_test.APITestCase): def test_donation_create_success(self): program = models.Program.objects.create(**seeds.PROGRAM_DATA) - program.end_date_time = (timezone.now() + timezone.timedelta(days=1)).isoformat() - program.save() with open(self.proof_of_bank_transfer_file.name, 'rb') as proof_of_bank_transfer: data = { 'program': program.id, 'amount': '1000', 'proof_of_bank_transfer': proof_of_bank_transfer, 'user_bank_account_name': 'Dummy User Bank Account Name', - 'user_bank_account_number': '0123456789', } response = request( 'POST', @@ -625,7 +618,7 @@ class DonationTest(rest_framework_test.APITestCase): def test_donation_create_fail(self): program = models.Program.objects.create(**seeds.PROGRAM_DATA) - program.end_date_time = (timezone.now() - timezone.timedelta(days=1)).isoformat() + program.open_donation = False program.save() with open(self.proof_of_bank_transfer_file.name, 'rb') as proof_of_bank_transfer: data = { @@ -633,7 +626,6 @@ class DonationTest(rest_framework_test.APITestCase): 'amount': '1000', 'proof_of_bank_transfer': proof_of_bank_transfer, 'user_bank_account_name': 'Dummy User Bank Account Name', - 'user_bank_account_number': '0123456789', } response = request( 'POST', @@ -658,7 +650,6 @@ class DonationTest(rest_framework_test.APITestCase): 'program_donation': program_donation.id, 'proof_of_bank_transfer': proof_of_bank_transfer, 'user_bank_account_name': 'Dummy User Bank Account Name', - 'user_bank_account_number': '0123456789', } response = request( 'POST', @@ -684,7 +675,6 @@ class DonationTest(rest_framework_test.APITestCase): 'program_donation': program_donation.id, 'proof_of_bank_transfer': proof_of_bank_transfer, 'user_bank_account_name': 'Dummy User Bank Account Name', - 'user_bank_account_number': '0123456789', } response = request( 'POST', @@ -1350,6 +1340,20 @@ class ProgramTest(rest_framework_test.APITestCase): url_args=[program.id] ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + tmp_file = create_tmp_image() + with open(tmp_file.name, 'rb') as tmp_file: + data = { + 'program_minutes': tmp_file, + } + response = request( + 'PATCH', + 'program-detail', + data, + format='multipart', + http_authorization=self.superuser_http_authorization, + url_args=[program.id] + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) class ProgramDonationTest(rest_framework_test.APITestCase): @@ -1553,7 +1557,7 @@ class BankAccountConfigTest(rest_framework_test.APITestCase): def test_update_bank_account_config_success(self): models.BankAccountConfig.objects.create(**seeds.BANK_ACCOUNT_CONFIG_DATA) data = { - 'bank_name': 'Dummy', + 'bank_name': 'Another Dummy Bank Name', } response = request( 'PATCH', @@ -1578,6 +1582,57 @@ class BankAccountConfigTest(rest_framework_test.APITestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) +class HelpContactConfigTest(rest_framework_test.APITestCase): + def setUp(self): + self.superuser = models.User.objects.create_superuser(**seeds.SUPERUSER_DATA) + self.superuser_http_authorization = get_http_authorization( + seeds.SUPERUSER_DATA['username'], + seeds.SUPERUSER_DATA['password'] + ) + + def test_help_account_config_model_string_representation(self): + help_account_config = models.HelpContactConfig.objects.create( + **seeds.HELP_CONTACT_CONFIG_DATA + ) + self.assertTrue(len(str(help_account_config)) > 0) + + def test_help_account_config_detail_success(self): + models.HelpContactConfig.objects.create(**seeds.HELP_CONTACT_CONFIG_DATA) + response = request( + 'GET', + 'help-contact-config-detail', + http_authorization=self.superuser_http_authorization + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_update_help_account_config_success(self): + models.HelpContactConfig.objects.create(**seeds.HELP_CONTACT_CONFIG_DATA) + data = { + 'email': 'another.dummy@example.com', + } + response = request( + 'PATCH', + 'help-contact-config-detail', + data, + http_authorization=self.superuser_http_authorization + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(models.HelpContactConfig.objects.get().email, data['email']) + + def test_update_help_account_config_fail(self): + models.HelpContactConfig.objects.create(**seeds.HELP_CONTACT_CONFIG_DATA) + data = { + 'email': '', + } + response = request( + 'PATCH', + 'help-contact-config-detail', + data, + http_authorization=self.superuser_http_authorization + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + class ShipmentConfigTest(rest_framework_test.APITestCase): def setUp(self): self.superuser = models.User.objects.create_superuser(**seeds.SUPERUSER_DATA) diff --git a/api/urls.py b/api/urls.py index 06f64fa..ea34218 100644 --- a/api/urls.py +++ b/api/urls.py @@ -92,6 +92,11 @@ urlpatterns = [ api_views.BankAccountConfigDetail.as_view(), name='bank-account-config-detail' ), + urls.path( + 'configs/help-contact/', + api_views.HelpContactConfigDetail.as_view(), + name='help-contact-config-detail' + ), urls.path( 'configs/shipment/', api_views.ShipmentConfigDetail.as_view(), diff --git a/api/views.py b/api/views.py index 2032353..48e7756 100644 --- a/api/views.py +++ b/api/views.py @@ -2,7 +2,6 @@ from django import shortcuts from django.contrib import auth from django.db import transaction as db_transaction, utils as db_utils from django.db.models import deletion -from django.utils import timezone from django.utils.translation import gettext_lazy as _ from django_filters import rest_framework from knox import views as knox_views @@ -221,9 +220,6 @@ class CartUploadPOP(rest_framework_views.APIView): )) transaction.proof_of_payment = serializer.validated_data['proof_of_payment'] transaction.user_bank_account_name = serializer.validated_data['user_bank_account_name'] - transaction.user_bank_account_number = ( - serializer.validated_data['user_bank_account_number'] - ) transaction.transaction_status = '002' transaction.save() return response.Response(status=status.HTTP_204_NO_CONTENT) @@ -296,16 +292,15 @@ class DonationCreate(rest_framework_views.APIView): models.Program, id=serializer.validated_data['program'] ) - if (program.end_date_time is not None) and (program.end_date_time < timezone.now()): + if not program.open_donation: raise rest_framework_exceptions.PermissionDenied(_( - 'The donation period for this program has ended.' + 'This program is currently not accepting donations.' )) program_donation = models.ProgramDonation.objects.create( user=user, program=program, amount=serializer.validated_data['amount'], user_bank_account_name=serializer.validated_data['user_bank_account_name'], - user_bank_account_number=serializer.validated_data['user_bank_account_number'], proof_of_bank_transfer=serializer.validated_data['proof_of_bank_transfer'] ) return response.Response( @@ -341,9 +336,6 @@ class DonationReuploadProofOfBankTransfer(rest_framework_views.APIView): program_donation.user_bank_account_name = ( serializer.validated_data['user_bank_account_name'] ) - program_donation.user_bank_account_number = ( - serializer.validated_data['user_bank_account_number'] - ) program_donation.donation_status = '001' program_donation.save() return response.Response(status=status.HTTP_204_NO_CONTENT) @@ -657,6 +649,16 @@ class BankAccountConfigDetail(generics.RetrieveUpdateAPIView): return obj +class HelpContactConfigDetail(generics.RetrieveUpdateAPIView): + permission_classes = [api_permissions.IsAdminUserOrReadOnly] + queryset = models.HelpContactConfig.objects.all() + serializer_class = api_serializers.HelpContactConfigSerializer + + def get_object(self): + obj = shortcuts.get_object_or_404(models.HelpContactConfig) + return obj + + class ShipmentConfigDetail(generics.RetrieveUpdateAPIView): permission_classes = [api_permissions.IsAdminUserOrReadOnly] queryset = models.ShipmentConfig.objects.all() diff --git a/api_config.yaml b/api_config.yaml index d53a0d9..7c54316 100644 --- a/api_config.yaml +++ b/api_config.yaml @@ -4,6 +4,8 @@ bank_account_config: bank_name: "Dummy Bank Name" account_number: "0123456789" account_owner: "Dummy Account Owner" +help_contact_config: + email: "dummy@example.com" shipment_config: hamlet: "001" urban_village: "Dummy Urban Village" diff --git a/locale/id/LC_MESSAGES/django.mo b/locale/id/LC_MESSAGES/django.mo index bdf5d275fd37dfdcda5726da75b64cb493b59d8a..db0ae278e35e1f2e14f0b2b2e546762295f78547 100644 GIT binary patch delta 3373 zcmZ|RYiv|S7{>8wyIr{|Ev=wJduU4$DCJs;6e<@f)Izz6B2ZY`rCr+Y*1doQWVtC& znh;r)1eFj#V#L&FMNt!lD1t(wL=)wb8i_$ZfPgXT2QdDh-Kp^l6W;yJnRCvZnRn)# zva#}TWwayR^|GPtB>E9ymocB=*)IH0PIosZ7r#O`UdNvJ2kMgK9>x@6cN~f1u^bz) z2tUACcn#<3JU0);Rvczb)EuMIi-s%KzpyLEUG7ahARX1wLs*P+u>^OZ2T$T?9CRPo z;9}H3*WfbTfXUc_-SB73!)tgi&o^m2y*CXGrr;Q)Pcse~w3&|g-~#Isd%gzmr#*!H zGcEimBd_8hJcPsXERM(9n2*J2iA-1FD4uWDQ6ZbA4M~al7^mS?RELG>3bSdnt3|2(nNwtQq3mRM0TMvvUrZ10FJ`jSd7DYMU=5x>l#!>wxL_^|1K)( z=oQoh&SMGo?Qcu~7ogg|z#L3rw3+K@S#6R7XY1ym+F zuqV$qH>qgm|DZ;enw`i%Pt=NskXo3kL@<4{z4VQw^0DW^dl)mZCM4-BGiScA&UTGZO@Mm^||^*z*pj$6M*_4hq$ zz!y;y{1r8jzwPh;@a zJ&Nk*Eb96WEW_vxDx;~49cauXT!ot15$i{&H9Td#gzD&)H6=gs;7nA8veAXbwtWI> zf@Riuw!I1&NYvCNDyA9rp0?QIy{H)-vF%6g@k!L)_y+aBA5oj?CThm+L5cJIF^S{H z399i|a5z3etRu96XX=G4iJj}xC#oE%%pIx;FPh7l^%Xq2acbu&&$tqz&FOSM(q8srv zv61L34^z?NsLUtyf!40-ETy*6(Al67K0~Y~dfUdOSVn9hBE&F4r6xiAH;3QniN*hG zn?*fHyg)3l?S9n0_Yn@!NT`$(E+V>|AC(ovPGTD2CA8O7wh(Oj`2S(`aaEa4j3Klg z2NNori4pNy;^RBT)&tgOtp!#$&e0zr?e!f*F7X^OfKaJRU}oSZqKFtpOeIo@35323 zYuexF{-P`9>Gf8uByIhOI$y|X3I=L}o<@gibtD+{`oj&)jz17~Jk{0Srm)Xn>(m7N zp0F?A55?Y3yV(9_`t2lFqsP|}JCKn#w$9toLz> xr(%`4efosG!4;8uj}zhQdM6ZVSTVwbM1wUEd=m>sE|bDQ~}nK?rb zRjsdzU+tZ?+fcR>j}g8!W6t7-o%x|0>1NDeJc^z0TkMYCp>DZ>g?JlBWB*5tnTC~E zgm2>vynwTGorgzY0~Qz)H+!gLaH7@PhFv(mh3eoBR6~DbF^=wG%yZK9LHihF0YZEpxXHw3-MNp{1w?Lddk4 zX4C^)Q4Q=x-G2x*6~|Hcwc7J-*6a5Auh^ICx>@(-pc2oIQ<+Pp1U0uiu>xCBiS*_v zo;A7F66BJpLM84;H55f9yb+l#vjg?!@1PQXA9epp)Kr|c#?MnxMs27!yoIbA^T67j z6{ZFp)La*!8Y)3eML8;=3gn-u=0^#wLAAF7mB>CEpTeR;E!L|nG^RLi9#YW*X{5~>iT+L3fyoSep+=$~Y7U2?re*@_tC)fLxBv@qgY`HTaeM`}QDr*GA)bhOZV9Tx zS}eyssKt8?<4;q$MTO}$xr36Kmm;%b=2(L`n&Yj=D4Wx`054)&%r@ zIO2)q3pH3*qoyXl%~tlK8v59J4%PAZsI_q&)9}7M{}A=&>3K;rQTGo-C6aF~MSYGH z_INSszJ}!aYSU~_Y(dTWTc{5AqCTUOs73P)>dmjC?z@L6SV;^cbnhrayXh)o9^ogR z@$Pi)8y`zeWfrlSSVD{>wh-lnik7@eO_Ic~midcB2{D0Sk1%zFiZ%^>@G2{bMZ{zs z*zy`ON~T%coMv6yRFHUs(AfD1ZDpE>mkE_8lbBbnTDJP;^qs2kjVJzodNy&C{COR= z5gUkQ#7sh^1JOv7X`9m|u#+XqQ0olT&Y_|)ZZG#>?DcL zWudK4vIeX%`ZpmWN@#hjEGAwc)({b54xv(=B=O5Cy_E<|^Wj(=yO8iC1zMLr2_#H=dCbX}%m(jLTfX~}{uC>Wp zXzhe6v@E(%*-8u{Ruh8=6^)F_bYdM*Oz6X%LUbf1651ddTE6PGD{Yv+ARO`eBTi!^ z>}#rtMV%Tq6bi?jI=9*HL}THaddKI++(K_o#z(__;gA~(ghNiFKN1N0oaNz&6RQhE z^>l5-4f0gf@rQhVpZ6eRqj$X5rJQ+D8n1Rk^^RLp6K)E{oKRD++8=4z()(eGw>>M@ zyOA}>%g#R2axJ?xHS_=OJdoq>G@LHlQ;h|DuCi)$V@}W?3^WAl1BoP+Z+)TnaKPc= zZq#*@Z8YG}XH)&Z83sZDC+2T(>l~7B>+1sE!GW8+na-7tRpDSb(vmaimlUtV;1Sui lZm`;on1*mYjhK)dbj|F!-mbyNT4oJ7n3|@Wy&v\n" "Language-Team: LANGUAGE \n" @@ -63,8 +63,8 @@ msgid "Failed" msgstr "Gagal" #: api/models.py:16 api/models.py:52 api/models.py:71 api/models.py:96 -#: api/models.py:141 api/models.py:154 api/models.py:180 api/models.py:286 -#: api/models.py:327 api/models.py:371 +#: api/models.py:141 api/models.py:154 api/models.py:180 api/models.py:280 +#: api/models.py:321 api/models.py:373 msgid "ID" msgstr "ID" @@ -84,15 +84,15 @@ msgstr "alamat" msgid "neighborhood" msgstr "RT" -#: api/models.py:30 api/models.py:470 +#: api/models.py:30 api/models.py:479 msgid "hamlet" msgstr "RW" -#: api/models.py:32 api/models.py:474 +#: api/models.py:32 api/models.py:483 msgid "urban village" msgstr "kelurahan" -#: api/models.py:33 api/models.py:478 +#: api/models.py:33 api/models.py:487 msgid "sub-district" msgstr "kecamatan" @@ -104,7 +104,7 @@ msgstr "foto profil" msgid "OTP" msgstr "OTP" -#: api/models.py:44 api/models.py:142 api/models.py:192 api/models.py:383 +#: api/models.py:44 api/models.py:142 api/models.py:192 api/models.py:385 msgid "user" msgstr "pengguna" @@ -112,7 +112,7 @@ msgstr "pengguna" msgid "users" msgstr "pengguna" -#: api/models.py:53 api/models.py:72 api/models.py:103 api/models.py:334 +#: api/models.py:53 api/models.py:72 api/models.py:103 api/models.py:328 msgid "name" msgstr "nama" @@ -136,11 +136,11 @@ msgstr "subkategori" msgid "subcategories" msgstr "subkategori" -#: api/models.py:101 api/models.py:332 +#: api/models.py:101 api/models.py:326 msgid "code" msgstr "kode" -#: api/models.py:110 api/models.py:335 +#: api/models.py:110 api/models.py:329 msgid "description" msgstr "deskripsi" @@ -156,7 +156,7 @@ msgstr "stok" msgid "pre-order" msgstr "pre-order" -#: api/models.py:128 api/models.py:165 api/models.py:298 +#: api/models.py:128 api/models.py:165 api/models.py:292 msgid "product" msgstr "produk" @@ -172,7 +172,7 @@ msgstr "keranjang belanja" msgid "shopping carts" msgstr "keranjang belanja" -#: api/models.py:167 api/models.py:308 +#: api/models.py:167 api/models.py:302 msgid "quantity" msgstr "kuantitas" @@ -188,11 +188,11 @@ msgstr "barang keranjang" msgid "transaction number" msgstr "nomor transaksi" -#: api/models.py:194 api/models.py:392 +#: api/models.py:194 api/models.py:394 msgid "user full name" msgstr "nama lengkap pengguna" -#: api/models.py:195 api/models.py:393 +#: api/models.py:195 api/models.py:395 msgid "user phone number" msgstr "nomor telepon pengguna" @@ -236,158 +236,174 @@ msgstr "status transaksi" msgid "proof of payment" msgstr "bukti pembayaran" -#: api/models.py:248 api/models.py:413 +#: api/models.py:248 api/models.py:415 msgid "user bank account name" msgstr "nama akun bank pengguna" -#: api/models.py:254 api/models.py:417 -msgid "user bank account number" -msgstr "nomor akun bank pengguna" - -#: api/models.py:259 api/models.py:422 +#: api/models.py:253 api/models.py:420 msgid "created at" msgstr "dibuat pada" -#: api/models.py:261 api/models.py:424 +#: api/models.py:255 api/models.py:422 msgid "updated at" msgstr "diperbarui pada" -#: api/models.py:265 api/models.py:291 +#: api/models.py:259 api/models.py:285 msgid "transaction" msgstr "transaksi" -#: api/models.py:266 +#: api/models.py:260 msgid "transactions" msgstr "transaksi" -#: api/models.py:300 +#: api/models.py:294 msgid "product name" msgstr "nama produk" -#: api/models.py:305 +#: api/models.py:299 msgid "product price" msgstr "harga produk" -#: api/models.py:307 +#: api/models.py:301 msgid "product pre-order" msgstr "produk pre-order" -#: api/models.py:312 +#: api/models.py:306 msgid "transaction item" msgstr "barang transaksi" -#: api/models.py:313 +#: api/models.py:307 msgid "transaction items" msgstr "barang transaksi" -#: api/models.py:340 +#: api/models.py:334 msgid "start date and time" msgstr "tanggal dan waktu mulai" -#: api/models.py:345 +#: api/models.py:339 msgid "end date and time" msgstr "tanggal dan waktu berakhir" -#: api/models.py:351 +#: api/models.py:345 msgid "location" msgstr "lokasi" -#: api/models.py:353 +#: api/models.py:347 msgid "speaker" msgstr "pembicara" -#: api/models.py:358 +#: api/models.py:348 +msgid "open for donation" +msgstr "terbuka untuk sumbangan" + +#: api/models.py:354 +msgid "program minutes" +msgstr "notulensi program" + +#: api/models.py:360 msgid "poster image" msgstr "gambar poster" -#: api/models.py:363 api/models.py:390 +#: api/models.py:365 api/models.py:392 msgid "program" msgstr "program" -#: api/models.py:364 +#: api/models.py:366 msgid "programs" msgstr "program" -#: api/models.py:376 +#: api/models.py:378 msgid "donation number" msgstr "nomor donasi" -#: api/models.py:394 +#: api/models.py:396 msgid "program name" msgstr "nama program" -#: api/models.py:399 +#: api/models.py:401 msgid "amount" msgstr "jumlah" -#: api/models.py:405 +#: api/models.py:407 msgid "donation status" msgstr "status donasi" -#: api/models.py:409 +#: api/models.py:411 msgid "proof of bank transfer" msgstr "bukti transfer bank" -#: api/models.py:428 +#: api/models.py:426 msgid "program donation" msgstr "donasi program" -#: api/models.py:429 +#: api/models.py:427 msgid "program donations" msgstr "donasi program" -#: api/models.py:443 +#: api/models.py:441 msgid "send SMS" msgstr "kirim SMS" -#: api/models.py:446 +#: api/models.py:444 msgid "application configuration" msgstr "konfigurasi aplikasi" -#: api/models.py:447 +#: api/models.py:445 msgid "application configurations" msgstr "konfigurasi aplikasi" -#: api/models.py:454 +#: api/models.py:452 msgid "bank name" msgstr "nama bank" -#: api/models.py:455 +#: api/models.py:453 msgid "account number" msgstr "nomor akun" -#: api/models.py:456 +#: api/models.py:454 msgid "account owner" msgstr "pemilik akun" -#: api/models.py:459 +#: api/models.py:457 msgid "bank account configuration" msgstr "konfigurasi akun bank" -#: api/models.py:460 +#: api/models.py:458 msgid "bank account configurations" msgstr "konfigurasi akun bank" -#: api/models.py:486 +#: api/models.py:465 +msgid "email" +msgstr "email" + +#: api/models.py:468 +msgid "help contact configuration" +msgstr "konfigurasi kontak bantuan" + +#: api/models.py:469 +msgid "help contact configurations" +msgstr "konfigurasi kontak bantuan" + +#: api/models.py:495 msgid "" "shipping costs if the hamlet, urban village, and sub-district are the same " "as seller" msgstr "biaya pengiriman jika RW, kelurahan, dan kecamatan sama dengan penjual" -#: api/models.py:495 +#: api/models.py:504 msgid "" "shipping costs if the urban village and sub-district are the same as seller" msgstr "biaya pengirima jika kelurahan dan kecamatan sama dengan penjual" -#: api/models.py:503 +#: api/models.py:512 msgid "shipping costs if the sub-district is the same as seller" msgstr "biaya pengiriman jika kecamatan sama dengan penjual" -#: api/models.py:507 +#: api/models.py:516 msgid "shipment configuration" msgstr "konfigurasi pengiriman" -#: api/models.py:508 +#: api/models.py:517 msgid "shipment configurations" msgstr "konfigurasi pengiriman" @@ -411,7 +427,7 @@ msgstr "Metode pembayaran" msgid "Donation" msgstr "Donasi" -#: api/serializers.py:40 api/serializers.py:53 +#: api/serializers.py:40 api/serializers.py:49 msgid "Transaction" msgstr "Transaksi" @@ -419,39 +435,35 @@ msgstr "Transaksi" msgid "Proof of payment" msgstr "Bukti pembayaran" -#: api/serializers.py:43 api/serializers.py:66 api/serializers.py:85 +#: api/serializers.py:43 api/serializers.py:62 api/serializers.py:77 msgid "User bank account name" msgstr "Nama akun bank pengguna" -#: api/serializers.py:47 api/serializers.py:70 api/serializers.py:89 -msgid "User bank account number" -msgstr "Nomor akun bank pengguna" - -#: api/serializers.py:57 +#: api/serializers.py:53 msgid "Program" msgstr "Program" -#: api/serializers.py:60 api/serializers.py:79 +#: api/serializers.py:56 api/serializers.py:71 msgid "Amount" msgstr "Jumlah" -#: api/serializers.py:64 api/serializers.py:83 +#: api/serializers.py:60 api/serializers.py:75 msgid "Proof of bank transfer" msgstr "Bukti transfer bank" -#: api/serializers.py:76 +#: api/serializers.py:68 msgid "Program donasi" msgstr "Donasi program" -#: api/serializers.py:202 +#: api/serializers.py:190 msgid "Stock cannot be empty if it is not a pre-order." msgstr "Stok tidak boleh kosong jika bukan pre-order." -#: api/serializers.py:340 +#: api/serializers.py:326 msgid "Cannot update transaction status to failed." msgstr "Tidak dapat memperbarui status transaksi ke gagal." -#: api/serializers.py:378 +#: api/serializers.py:366 msgid "End date time should be greater than start date time." msgstr "Waktu tanggal berakhir harus lebih besar dari waktu tanggal mulai." @@ -479,55 +491,55 @@ msgid "" msgstr "" "Gagal checkout karena jumlah barang yang dibeli melebihi stok yang tersedia." -#: api/views.py:161 +#: api/views.py:160 #, python-brace-format msgid "" "Cannot process shipment to other sub-districts other than {sub_district}." msgstr "" "Tidak dapat memproses pengiriman ke kecamatan lain selain {sub_district}." -#: api/views.py:167 +#: api/views.py:166 msgid "Unable to checkout because there are no items purchased." msgstr "Tidak dapat checkout karena tidak ada barang yang dibeli." -#: api/views.py:194 +#: api/views.py:193 msgid "Checkout failed." msgstr "Checkout gagal." -#: api/views.py:216 +#: api/views.py:215 msgid "The payment method for this transaction is not a transfer." msgstr "Metode pembayaran untuk transaksi ini bukan transfer." -#: api/views.py:220 +#: api/views.py:219 msgid "Cannot upload proof of payment at this stage." msgstr "Tidak dapat mengunggah bukti pembayaran pada tahap ini." -#: api/views.py:250 +#: api/views.py:246 msgid "Transaction cannot be completed unless the status is \"Being shipped\"." msgstr "" "Transaksi tidak dapat diselesaikan kecuali statusnya \"Sedang dikirim\"." -#: api/views.py:275 +#: api/views.py:271 msgid "Transaction cannot be canceled at this stage." msgstr "Transaksi tidak dapat dibatalkan pada tahap ini." -#: api/views.py:301 -msgid "The donation period for this program has ended." -msgstr "Masa donasi untuk program ini telah berakhir." +#: api/views.py:297 +msgid "This program is currently not accepting donations." +msgstr "Program ini saat ini tidak menerima donasi." -#: api/views.py:335 +#: api/views.py:330 msgid "Cannot reupload proof of bank transfer at this stage." msgstr "Tidak dapat mengunggah kembali bukti transfer bank pada tahap ini." -#: api/views.py:413 +#: api/views.py:405 msgid "Cannot delete category due to integrity error." msgstr "Tidak dapat menghapus kategori karena kesalahan integritas." -#: api/views.py:450 +#: api/views.py:442 msgid "Cannot delete subcategory due to integrity error." msgstr "Tidak dapat menghapus subkategori karena kesalahan integritas." -#: api/views.py:541 +#: api/views.py:533 msgid "" "Cannot update transaction because it has a completed, canceled, or failed " "status." @@ -535,7 +547,7 @@ msgstr "" "Tidak dapat memperbarui transaksi karena memiliki status selesai, " "dibatalkan, atau gagal." -#: api/views.py:603 +#: api/views.py:595 msgid "" "Cannot update program donation because it has a completed or canceled status." msgstr "" @@ -551,9 +563,6 @@ msgstr "Terjadi kesalahan konfigurasi." msgid "Server is currently unable to send SMS." msgstr "Server saat ini tidak dapat mengirim SMS." -msgid "Pre-order products cannot have stock data." -msgstr "Produk pre-order tidak dapat memiliki data stok." - msgid "Not a valid string." msgstr "Bukan string yang valid." -- GitLab From 75c8008289c66e6e47449936a5971ebcb34b3a9b Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Tue, 12 May 2020 03:10:32 +0700 Subject: [PATCH 74/90] Refactor --- api/tests.py | 6 +++--- api/views.py | 20 ++++++++++---------- locale/id/LC_MESSAGES/django.po | 4 ++-- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/api/tests.py b/api/tests.py index 58380ad..095ff2b 100644 --- a/api/tests.py +++ b/api/tests.py @@ -1340,10 +1340,10 @@ class ProgramTest(rest_framework_test.APITestCase): url_args=[program.id] ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - tmp_file = create_tmp_image() - with open(tmp_file.name, 'rb') as tmp_file: + program_minutes_file = create_tmp_image() + with open(program_minutes_file.name, 'rb') as program_minutes_file: data = { - 'program_minutes': tmp_file, + 'program_minutes': program_minutes_file, } response = request( 'PATCH', diff --git a/api/views.py b/api/views.py index 48e7756..31417f5 100644 --- a/api/views.py +++ b/api/views.py @@ -243,7 +243,7 @@ class CartCompleteTransaction(rest_framework_views.APIView): ) if transaction.transaction_status != '004': raise rest_framework_exceptions.PermissionDenied(_( - 'Transaction cannot be completed unless the status is \"Being shipped\".' + 'Transaction cannot be completed unless the status is "Being shipped".' )) transaction.transaction_status = '005' transaction.save() @@ -556,6 +556,15 @@ class ProgramList(generics.ListCreateAPIView): serializer_class = api_serializers.ProgramSerializer +class ProgramDetail(generics.RetrieveUpdateDestroyAPIView): + permission_classes = [ + api_permissions.IsAdminUserOrReadOnly, + rest_framework_permissions.IsAuthenticated, + ] + queryset = models.Program.objects.all() + serializer_class = api_serializers.ProgramSerializer + + class ProgramDonationList(generics.ListAPIView): filter_backends = [ rest_framework.DjangoFilterBackend, @@ -597,15 +606,6 @@ class ProgramDonationDetail(generics.RetrieveUpdateAPIView): return super().update(request, *args, **kwargs) # pylint: disable=no-member -class ProgramDetail(generics.RetrieveUpdateDestroyAPIView): - permission_classes = [ - api_permissions.IsAdminUserOrReadOnly, - rest_framework_permissions.IsAuthenticated, - ] - queryset = models.Program.objects.all() - serializer_class = api_serializers.ProgramSerializer - - class ChoicesAPIView(rest_framework_views.APIView): choices = None permission_classes = [rest_framework_permissions.IsAuthenticated] diff --git a/locale/id/LC_MESSAGES/django.po b/locale/id/LC_MESSAGES/django.po index 29e001b..44cf5ac 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-05-12 02:32+0700\n" +"POT-Creation-Date: 2020-05-12 03:05+0700\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -547,7 +547,7 @@ msgstr "" "Tidak dapat memperbarui transaksi karena memiliki status selesai, " "dibatalkan, atau gagal." -#: api/views.py:595 +#: api/views.py:604 msgid "" "Cannot update program donation because it has a completed or canceled status." msgstr "" -- GitLab From b5b5bd5ab804caa5f1d27a8184075c0489a28903 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Thu, 4 Jun 2020 07:17:37 +0700 Subject: [PATCH 75/90] Complete 5th Sprint --- .coveragerc | 2 +- api/filters.py | 16 + .../commands/createorupdateapiconfig.py | 1 - api/migrations/0003_auto_20200520_0710.py | 23 + api/migrations/0004_auto_20200521_2252.py | 62 +++ api/migrations/0005_auto_20200521_2327.py | 39 ++ .../0006_delete_bankaccountconfig.py | 16 + api/models.py | 121 +++-- api/reports_writer.py | 378 +++++++++++++ api/schemas.py | 8 + api/seeds.py | 14 +- api/serializers.py | 81 ++- api/signals.py | 104 +++- api/tests.py | 265 +++++++-- api/urls.py | 31 +- api/views.py | 98 +++- api_config.yaml | 4 - home_industry/settings/ci.py | 11 + home_industry/settings/local.py | 11 + home_industry/settings/production.py | 20 + home_industry/settings/staging.py | 20 + locale/id/LC_MESSAGES/django.mo | Bin 10022 -> 12651 bytes locale/id/LC_MESSAGES/django.po | 514 +++++++++++++----- requirements.txt | 3 +- sonar-project.properties | 10 +- 25 files changed, 1563 insertions(+), 289 deletions(-) create mode 100644 api/migrations/0003_auto_20200520_0710.py create mode 100644 api/migrations/0004_auto_20200521_2252.py create mode 100644 api/migrations/0005_auto_20200521_2327.py create mode 100644 api/migrations/0006_delete_bankaccountconfig.py create mode 100644 api/reports_writer.py diff --git a/.coveragerc b/.coveragerc index 18aae54..334d92d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,6 +1,6 @@ [run] omit = */schemas.py - */management/* + */signals.py source = api diff --git a/api/filters.py b/api/filters.py index 4527b36..67bfb5d 100644 --- a/api/filters.py +++ b/api/filters.py @@ -3,6 +3,22 @@ import django_filters from api import models +class ReportTransactionFilter(django_filters.FilterSet): + created_at_date_range = django_filters.DateFromToRangeFilter(field_name='created_at') + + class Meta: + fields = ['created_at_date_range'] + model = models.Transaction + + +class ReportProgramDonationFilter(django_filters.FilterSet): + created_at_date_range = django_filters.DateFromToRangeFilter(field_name='created_at') + + class Meta: + fields = ['created_at_date_range'] + model = models.ProgramDonation + + class TransactionFilter(django_filters.FilterSet): updated_at_date_range = django_filters.DateFromToRangeFilter(field_name='updated_at') diff --git a/api/management/commands/createorupdateapiconfig.py b/api/management/commands/createorupdateapiconfig.py index 7185ed2..e608fa6 100644 --- a/api/management/commands/createorupdateapiconfig.py +++ b/api/management/commands/createorupdateapiconfig.py @@ -12,7 +12,6 @@ class Command(base.BaseCommand): api_config = yaml.load(file, Loader=yaml.FullLoader) config_key_to_model_class_map = { 'app_config': models.AppConfig, - 'bank_account_config': models.BankAccountConfig, 'help_contact_config': models.HelpContactConfig, 'shipment_config': models.ShipmentConfig, } diff --git a/api/migrations/0003_auto_20200520_0710.py b/api/migrations/0003_auto_20200520_0710.py new file mode 100644 index 0000000..9801be4 --- /dev/null +++ b/api/migrations/0003_auto_20200520_0710.py @@ -0,0 +1,23 @@ +# Generated by Django 3.0.3 on 2020-05-20 00:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0002_auto_20200512_0230'), + ] + + operations = [ + migrations.AddField( + model_name='programdonation', + name='user_bank_name', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='user bank name'), + ), + migrations.AddField( + model_name='transaction', + name='user_bank_name', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='user bank name'), + ), + ] diff --git a/api/migrations/0004_auto_20200521_2252.py b/api/migrations/0004_auto_20200521_2252.py new file mode 100644 index 0000000..3718dbe --- /dev/null +++ b/api/migrations/0004_auto_20200521_2252.py @@ -0,0 +1,62 @@ +# Generated by Django 3.0.3 on 2020-05-21 15:52 + +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', '0003_auto_20200520_0710'), + ] + + operations = [ + migrations.AddField( + model_name='program', + name='link', + field=models.URLField(blank=True, null=True, verbose_name='link'), + ), + migrations.AddField( + model_name='transaction', + name='transfer_destination_bank_account_name', + field=models.CharField(blank=True, max_length=200, null=True, verbose_name='transfer destination bank account name'), + ), + migrations.AddField( + model_name='transaction', + name='transfer_destination_bank_account_number', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='transfer destination bank account number'), + ), + migrations.AddField( + model_name='transaction', + name='transfer_destination_bank_name', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='transfer destination bank name'), + ), + migrations.AlterField( + model_name='transaction', + name='shipping_costs', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True, validators=[django.core.validators.MinValueValidator(Decimal('0'))], verbose_name='shipping costs'), + ), + migrations.CreateModel( + name='BankAccountTransferDestination', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='ID')), + ('bank_name', models.CharField(max_length=100, verbose_name='bank name')), + ('bank_account_number', models.CharField(max_length=100, verbose_name='bank account number')), + ('bank_account_name', models.CharField(max_length=200, verbose_name='bank account owner')), + ], + options={ + 'verbose_name': 'bank account transfer destination', + 'verbose_name_plural': 'bank account transfer destinations', + 'ordering': ['bank_name', 'id'], + 'unique_together': {('bank_name', 'bank_account_number')}, + }, + ), + migrations.AddField( + model_name='transaction', + name='bank_account_transfer_destination', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transactions', to='api.BankAccountTransferDestination', verbose_name='bank account transfer destination'), + ), + ] diff --git a/api/migrations/0005_auto_20200521_2327.py b/api/migrations/0005_auto_20200521_2327.py new file mode 100644 index 0000000..4dca250 --- /dev/null +++ b/api/migrations/0005_auto_20200521_2327.py @@ -0,0 +1,39 @@ +# Generated by Django 3.0.3 on 2020-05-21 16:27 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0004_auto_20200521_2252'), + ] + + operations = [ + migrations.AddField( + model_name='programdonation', + name='bank_account_transfer_destination', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='program_donations', to='api.BankAccountTransferDestination', verbose_name='bank account transfer destination'), + ), + migrations.AddField( + model_name='programdonation', + name='transfer_destination_bank_account_name', + field=models.CharField(blank=True, max_length=200, null=True, verbose_name='transfer destination bank account name'), + ), + migrations.AddField( + model_name='programdonation', + name='transfer_destination_bank_account_number', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='transfer destination bank account number'), + ), + migrations.AddField( + model_name='programdonation', + name='transfer_destination_bank_name', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='transfer destination bank name'), + ), + migrations.AlterField( + model_name='bankaccounttransferdestination', + name='bank_account_name', + field=models.CharField(max_length=200, verbose_name='bank account name'), + ), + ] diff --git a/api/migrations/0006_delete_bankaccountconfig.py b/api/migrations/0006_delete_bankaccountconfig.py new file mode 100644 index 0000000..0cb3836 --- /dev/null +++ b/api/migrations/0006_delete_bankaccountconfig.py @@ -0,0 +1,16 @@ +# Generated by Django 3.0.3 on 2020-05-22 04:32 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0005_auto_20200521_2327'), + ] + + operations = [ + migrations.DeleteModel( + name='BankAccountConfig', + ), + ] diff --git a/api/models.py b/api/models.py index 0fdce3a..ef58b28 100644 --- a/api/models.py +++ b/api/models.py @@ -48,6 +48,22 @@ class User(auth_models.AbstractUser): return self.username +class BankAccountTransferDestination(db_models.Model): + id = db_models.UUIDField(default=uuid.uuid4, primary_key=True, verbose_name=_('ID')) + bank_name = db_models.CharField(max_length=100, verbose_name=_('bank name')) + bank_account_number = db_models.CharField(max_length=100, verbose_name=_('bank account number')) + bank_account_name = db_models.CharField(max_length=200, verbose_name=_('bank account name')) + + class Meta: + ordering = ['bank_name', 'id'] + unique_together = ['bank_name', 'bank_account_number'] + verbose_name = _('bank account transfer destination') + verbose_name_plural = _('bank account transfer destinations') + + def __str__(self): + return '{} - {}'.format(self.bank_name, self.bank_account_number) + + class Category(db_models.Model): id = db_models.UUIDField(default=uuid.uuid4, primary_key=True, verbose_name=_('ID')) name = fields.CICharField(max_length=50, unique=True, verbose_name=_('name')) @@ -213,8 +229,10 @@ class Transaction(db_models.Model): verbose_name=_('shipping sub-district') ) shipping_costs = db_models.DecimalField( + blank=True, decimal_places=2, max_digits=12, + null=True, validators=[validators.MinValueValidator(decimal.Decimal('0'))], verbose_name=_('shipping costs') ) @@ -241,12 +259,43 @@ class Transaction(db_models.Model): upload_to=utils.get_upload_file_path, verbose_name=_('proof of payment') ) + user_bank_name = db_models.CharField( + blank=True, + max_length=100, + null=True, + verbose_name=_('user bank name') + ) user_bank_account_name = db_models.CharField( blank=True, max_length=200, null=True, verbose_name=_('user bank account name') ) + bank_account_transfer_destination = db_models.ForeignKey( + 'api.BankAccountTransferDestination', + null=True, + on_delete=db_models.SET_NULL, + related_name='transactions', + verbose_name=_('bank account transfer destination') + ) + transfer_destination_bank_name = db_models.CharField( + blank=True, + max_length=100, + null=True, + verbose_name=_('transfer destination bank name') + ) + transfer_destination_bank_account_name = db_models.CharField( + blank=True, + max_length=200, + null=True, + verbose_name=_('transfer destination bank account name') + ) + transfer_destination_bank_account_number = db_models.CharField( + blank=True, + max_length=100, + null=True, + verbose_name=_('transfer destination bank account number') + ) created_at = db_models.DateTimeField( auto_now_add=True, db_index=True, @@ -259,19 +308,6 @@ class Transaction(db_models.Model): verbose_name = _('transaction') verbose_name_plural = _('transactions') - def save(self, *args, **kwargs): # pylint: disable=arguments-differ - if self._state.adding: - self.user_full_name = self.user.full_name - self.user_phone_number = self.user.phone_number - self.shipping_address = self.user.address - self.shipping_neighborhood = self.user.neighborhood - self.shipping_hamlet = self.user.hamlet - self.shipping_urban_village = self.user.urban_village - self.shipping_sub_district = self.user.sub_district - self.transaction_status = '001' if self.payment_method == 'TRF' else '002' - self.shipping_costs = utils.get_shipping_costs(self.user) - super().save(*args, **kwargs) - def __str__(self): return self.transaction_number @@ -306,13 +342,6 @@ class TransactionItem(db_models.Model): verbose_name = _('transaction item') verbose_name_plural = _('transaction items') - def save(self, *args, **kwargs): # pylint: disable=arguments-differ - if self._state.adding: - self.product_name = self.product.name - self.product_price = self.product.price - self.product_pre_order = self.product.pre_order - super().save(*args, **kwargs) - def __str__(self): return self.product_name @@ -359,6 +388,7 @@ class Program(db_models.Model): upload_to=utils.get_upload_file_path, verbose_name=_('poster image') ) + link = db_models.URLField(blank=True, null=True, verbose_name=_('link')) class Meta: ordering = ['-start_date_time', '-end_date_time', 'name', 'code', 'id'] @@ -410,10 +440,41 @@ class ProgramDonation(db_models.Model): upload_to=utils.get_upload_file_path, verbose_name=_('proof of bank transfer') ) + user_bank_name = db_models.CharField( + blank=True, + max_length=100, + null=True, + verbose_name=_('user bank name') + ) user_bank_account_name = db_models.CharField( max_length=200, verbose_name=_('user bank account name') ) + bank_account_transfer_destination = db_models.ForeignKey( + 'api.BankAccountTransferDestination', + null=True, + on_delete=db_models.SET_NULL, + related_name='program_donations', + verbose_name=_('bank account transfer destination') + ) + transfer_destination_bank_name = db_models.CharField( + blank=True, + max_length=100, + null=True, + verbose_name=_('transfer destination bank name') + ) + transfer_destination_bank_account_name = db_models.CharField( + blank=True, + max_length=200, + null=True, + verbose_name=_('transfer destination bank account name') + ) + transfer_destination_bank_account_number = db_models.CharField( + blank=True, + max_length=100, + null=True, + verbose_name=_('transfer destination bank account number') + ) created_at = db_models.DateTimeField( auto_now_add=True, db_index=True, @@ -426,13 +487,6 @@ class ProgramDonation(db_models.Model): verbose_name = _('program donation') verbose_name_plural = _('program donations') - def save(self, *args, **kwargs): # pylint: disable=arguments-differ - if self._state.adding: - self.user_full_name = self.user.full_name - self.user_phone_number = self.user.phone_number - self.program_name = self.program.name - super().save(*args, **kwargs) - def __str__(self): return self.donation_number @@ -448,19 +502,6 @@ class AppConfig(solo_models.SingletonModel): return 'Application Configuration' -class BankAccountConfig(solo_models.SingletonModel): - bank_name = db_models.CharField(max_length=100, verbose_name=_('bank name')) - account_number = db_models.CharField(max_length=100, verbose_name=_('account number')) - account_owner = db_models.CharField(max_length=200, verbose_name=_('account owner')) - - class Meta: - verbose_name = _('bank account configuration') - verbose_name_plural = _('bank account configurations') - - def __str__(self): - return 'Bank Account Configuration' - - class HelpContactConfig(solo_models.SingletonModel): email = db_models.EmailField(verbose_name=_('email')) diff --git a/api/reports_writer.py b/api/reports_writer.py new file mode 100644 index 0000000..b77f2a2 --- /dev/null +++ b/api/reports_writer.py @@ -0,0 +1,378 @@ +import collections +import datetime +import decimal +import numbers +import operator + +import xlsxwriter +from django import conf +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from api import filters, models + + +def donation_donation_number_with_hyperlink(worksheet, row, col, obj, cell_format): + worksheet.write_url( + row, + col, + '{}{}{}'.format( + conf.settings.HOME_INDUSTRY_ADMIN_SITE['URL'], + conf.settings.HOME_INDUSTRY_ADMIN_SITE['PROGRAM_DONATION_PATH'], + obj.id + ), + cell_format=cell_format, + string=str(obj.donation_number) + ) + + +def product_code_with_hyperlink(worksheet, row, col, obj, cell_format): + worksheet.write_url( + row, + col, + '{}{}{}'.format( + conf.settings.HOME_INDUSTRY_ADMIN_SITE['URL'], + conf.settings.HOME_INDUSTRY_ADMIN_SITE['PRODUCT_PATH'], + obj.id + ), + cell_format=cell_format, + string=str(obj.code) + ) + + +def product_sold(worksheet, row, col, obj, cell_format): + worksheet.write_number(row, col, sum( + transaction_item.quantity + for transaction_item in models.TransactionItem.objects.filter( + transaction__transaction_status='005', + product=obj + ) + ), cell_format=cell_format) + + +def program_code_with_hyperlink(worksheet, row, col, obj, cell_format): + worksheet.write_url( + row, + col, + '{}{}{}'.format( + conf.settings.HOME_INDUSTRY_ADMIN_SITE['URL'], + conf.settings.HOME_INDUSTRY_ADMIN_SITE['PROGRAM_PATH'], + obj.id + ), + cell_format=cell_format, + string=str(obj.code) + ) + + +def program_donation_program_code_with_hyperlink(worksheet, row, col, obj, cell_format): + worksheet.write_url( + row, + col, + '{}{}{}'.format( + conf.settings.HOME_INDUSTRY_ADMIN_SITE['URL'], + conf.settings.HOME_INDUSTRY_ADMIN_SITE['PROGRAM_PATH'], + obj.program.id + ), + cell_format=cell_format, + string=str(obj.program.code) + ) + + +def program_total_donation_amount(worksheet, row, col, obj, cell_format): + worksheet.write_number(row, col, sum( + program_donation.amount for program_donation in obj.program_donations.filter( + donation_status='002' + ) + ), cell_format=cell_format) + + +def transaction_item_transaction_transaction_number_with_hyperlink(worksheet, row, col, obj, + cell_format): + worksheet.write_url( + row, + col, + '{}{}{}'.format( + conf.settings.HOME_INDUSTRY_ADMIN_SITE['URL'], + conf.settings.HOME_INDUSTRY_ADMIN_SITE['TRANSACTION_PATH'], + obj.transaction.id + ), + cell_format=cell_format, + string=str(obj.transaction.transaction_number) + ) + + +def transaction_or_donation_user_username_with_hyperlink(worksheet, row, col, obj, cell_format): + if obj.user is None: + return + worksheet.write_url( + row, + col, + '{}{}{}'.format( + conf.settings.HOME_INDUSTRY_ADMIN_SITE['URL'], + conf.settings.HOME_INDUSTRY_ADMIN_SITE['USER_PATH'], + obj.user.id + ), + cell_format=cell_format, + string=str(obj.user.username) + ) + + +def transaction_transaction_item_subtotal(worksheet, row, col, obj, cell_format): + worksheet.write_number(row, col, sum( + transaction_item.product_price * transaction_item.quantity + for transaction_item in obj.transaction_items.all() + ), cell_format=cell_format) + + +def transaction_transaction_number_with_hyperlink(worksheet, row, col, obj, cell_format): + worksheet.write_url( + row, + col, + '{}{}{}'.format( + conf.settings.HOME_INDUSTRY_ADMIN_SITE['URL'], + conf.settings.HOME_INDUSTRY_ADMIN_SITE['TRANSACTION_PATH'], + obj.id + ), + cell_format=cell_format, + string=str(obj.transaction_number) + ) + + +def write_headers_to_worksheet(worksheet, headers, row=0, start_col=0, header_format=None, # pylint: disable=too-many-arguments + col_width=None): + if col_width is not None: + worksheet.set_column(0, len(headers) - 1, col_width) + for col, header in enumerate(headers, start=start_col): + worksheet.write_string(row, col, str(header), cell_format=header_format) + + +def write_queryset_data_to_worksheet(worksheet, queryset, fields, start_col=0, start_row=0, # pylint: disable=too-many-arguments + header_format=None, data_format=None, col_width=None): + fields = collections.OrderedDict(fields) + write_headers_to_worksheet( + worksheet, + fields.values(), + row=start_row, + start_col=start_col, + header_format=header_format, + col_width=col_width + ) + row = start_row + for row, obj in enumerate(queryset, start=row + 1): + for col, field_name in enumerate(fields.keys(), start=start_col): + cell_format = data_format.get(field_name) if data_format is not None else None + if callable(field_name): + field_name(worksheet, row, col, obj, cell_format) + continue + value = operator.attrgetter(field_name)(obj) + if value is None: + continue + value = value() if callable(value) else value + if isinstance(value, bool): + worksheet.write_boolean(row, col, value, cell_format=cell_format) + elif isinstance(value, datetime.datetime): + worksheet.write_datetime(row, col, value, cell_format=cell_format) + elif isinstance(value, (decimal.Decimal, numbers.Real)): + worksheet.write_number(row, col, value, cell_format=cell_format) + else: + worksheet.write_string(row, col, str(value), cell_format=cell_format) + return row + + +def create_program_donation_report(filter_params): + workbook = xlsxwriter.Workbook( + '{} {}–{}.xlsx'.format( + _('Program Donation Report'), + filter_params.get('created_at_date_range_after', '*'), + filter_params.get('created_at_date_range_before', str(timezone.now())[:10]) + ), + { + 'default_date_format': 'yyyy/mm/dd hh:mm', + 'remove_timezone': True, + } + ) + header_format = workbook.add_format( + { + 'align': 'center', + 'bg_color': '#408EBA', + 'bold': 1, + 'font_color': '#FFFFFF', + 'font_size': 14, + } + ) + money_format = workbook.add_format({'num_format': '#,##0'}) + program_donation_worksheet = workbook.add_worksheet(str(_('Program Donations'))) + program_donation_fields = [ + (donation_donation_number_with_hyperlink, _('Donation Number')), + (transaction_or_donation_user_username_with_hyperlink, _('User Username')), + (program_donation_program_code_with_hyperlink, _('Program Code')), + ('user_full_name', _('User Full Name')), + ('user_phone_number', _('User Phone Number')), + ('program_name', _('Program Name')), + ('amount', _('Amount')), + ('get_donation_status_display', _('Donation Status')), + ('user_bank_name', _('User Bank Name')), + ('user_bank_account_name', _('User Bank Account Name')), + ('transfer_destination_bank_name', _('Transfer Destination Bank Name')), + ('transfer_destination_bank_account_name', _('Transfer Destination Bank Account Name')), + ( + 'transfer_destination_bank_account_number', + _('Transfer Destination Bank Account Number'), + ), + ('created_at', _('Created at')), + ('updated_at', _('Updated at')), + ] + write_queryset_data_to_worksheet( + program_donation_worksheet, + filters.ReportProgramDonationFilter(filter_params).qs, + program_donation_fields, + header_format=header_format, + data_format={'amount': money_format}, + col_width=32 + ) + program_summary_worksheet = workbook.add_worksheet(str(_('Program Summary'))) + program_summary_fields = [ + (program_code_with_hyperlink, _('Program Code')), + ('name', _('Program Name')), + ('open_donation', _('Open for Donation')), + (program_total_donation_amount, _('Total Donation Amount')), + ] + last_row = write_queryset_data_to_worksheet( + program_summary_worksheet, + models.Program.objects.all(), + program_summary_fields, + header_format=header_format, + data_format={program_total_donation_amount: money_format}, + col_width=32 + ) + program_summary_worksheet.write_string(last_row + 1, 0, str(_( + 'NB: This program summary only shows programs that have not been deleted. ' + 'It also not affected by date filtering.' + ))) + workbook.close() + return workbook.filename + + +def create_transaction_report(filter_params): + workbook = xlsxwriter.Workbook( + '{} {}–{}.xlsx'.format( + _('Transaction Report'), + filter_params.get('created_at_date_range_after', '*'), + filter_params.get('created_at_date_range_before', str(timezone.now())[:10]) + ), + { + 'default_date_format': 'yyyy/mm/dd hh:mm', + 'remove_timezone': True, + } + ) + header_format = workbook.add_format( + { + 'align': 'center', + 'bg_color': '#408EBA', + 'bold': 1, + 'font_color': '#FFFFFF', + 'font_size': 14, + } + ) + money_format = workbook.add_format({'num_format': '#,##0'}) + transaction_worksheet = workbook.add_worksheet(str(_('Transactions'))) + transaction_fields = [ + (transaction_transaction_number_with_hyperlink, _('Transaction Number')), + (transaction_or_donation_user_username_with_hyperlink, _('User Username')), + ('user_full_name', _('User Full Name')), + ('user_phone_number', _('User Phone Number')), + ('shipping_address', _('Shpping Address')), + ('shipping_neighborhood', _('Shipping Neighborhood')), + ('shipping_hamlet', _('Shipping Hamlet')), + ('shipping_urban_village', _('Shipping Urban Village')), + ('shipping_sub_district', _('Shipping Sub-District')), + (transaction_transaction_item_subtotal, _('Transaction Item Subtotal')), + ('shipping_costs', _('Shipping Costs')), + ('get_payment_method_display', _('Payment Method')), + ('donation', _('Donation')), + ('get_transaction_status_display', _('Transaction Status')), + ('user_bank_name', _('User Bank Name')), + ('user_bank_account_name', _('User Bank Account Name')), + ('transfer_destination_bank_name', _('Transfer Destination Bank Name')), + ('transfer_destination_bank_account_name', _('Transfer Destination Bank Account Name')), + ( + 'transfer_destination_bank_account_number', + _('Transfer Destination Bank Account Number'), + ), + ('created_at', _('Created at')), + ('updated_at', _('Updated at')), + ] + transactions = filters.ReportTransactionFilter(filter_params).qs + write_queryset_data_to_worksheet( + transaction_worksheet, + transactions, + transaction_fields, + header_format=header_format, + data_format={ + transaction_transaction_item_subtotal: money_format, + 'shipping_costs': money_format, + 'donation': money_format, + }, + col_width=32 + ) + transaction_item_worksheet = workbook.add_worksheet(str(_('Transaction Items'))) + transaction_item_fields = [ + (transaction_item_transaction_transaction_number_with_hyperlink, _('Transaction Number')), + ('product_name', _('Product Name')), + ('product_price', _('Product Price')), + ('product_pre_order', _('Product Pre-Order')), + ('quantity', _('Quantity')), + ] + write_queryset_data_to_worksheet( + transaction_item_worksheet, + models.TransactionItem.objects.filter(transaction__in=transactions), + transaction_item_fields, + header_format=header_format, + data_format={'product_price': money_format}, + col_width=32 + ) + product_order_summary_worksheet = workbook.add_worksheet(str(_('Product Order Summary'))) + product_order_summary_fields = [ + (product_code_with_hyperlink, _('Product Code')), + ('name', _('Product Name')), + (product_sold, _('Sold')), + ] + last_row = write_queryset_data_to_worksheet( + product_order_summary_worksheet, + models.Product.objects.all(), + product_order_summary_fields, + header_format=header_format, + col_width=32 + ) + product_order_summary_worksheet.write_string(last_row + 1, 0, str(_( + 'NB: This product order summary only shows products that have not been deleted. ' + 'It also not affected by date filtering.' + ))) + summary_worksheet = workbook.add_worksheet(str(_('Transaction Summary'))) + summary_worksheet.set_column(0, 1, 32) + summary_worksheet.write_string(0, 0, str(_('Revenue (Transaction)')), workbook.add_format( + { + 'bg_color': '#94C380', + 'bold': 1, + 'font_size': 14, + } + )) + transactions = transactions.filter(transaction_status='005') + transaction_revenue = sum( + transaction_item.product_price * transaction_item.quantity + for transaction_item in models.TransactionItem.objects.filter(transaction__in=transactions) + ) + summary_worksheet.write_number(0, 1, transaction_revenue, money_format) + summary_worksheet.write_string(1, 0, str(_('Donation (Transaction)')), workbook.add_format( + { + 'bg_color': '#3AA757', + 'bold': 1, + 'font_size': 14, + } + )) + transaction_donation = sum( + transaction_item.donation for transaction_item in transactions + ) + summary_worksheet.write_number(1, 1, transaction_donation, money_format) + workbook.close() + return workbook.filename diff --git a/api/schemas.py b/api/schemas.py index 5d12865..5a1375c 100644 --- a/api/schemas.py +++ b/api/schemas.py @@ -32,6 +32,14 @@ class AutoSchemaWithDateRange(schemas.AutoSchema): return filter_fields +class ReportTransactionSchema(AutoSchemaWithDateRange): + date_range_fields = ['created_at_date_range'] + + +class ReportProgramDonationSchema(AutoSchemaWithDateRange): + date_range_fields = ['created_at_date_range'] + + class TransactionListSchema(AutoSchemaWithDateRange): date_range_fields = ['updated_at_date_range'] diff --git a/api/seeds.py b/api/seeds.py index c7cfece..fa23a08 100644 --- a/api/seeds.py +++ b/api/seeds.py @@ -16,6 +16,12 @@ USER_DATA = { 'sub_district': 'Dummy Sub-District', } +BANK_ACCOUNT_TRANSFER_DESTINATION = { + 'bank_name': 'Dummy Bank Name', + 'bank_account_number': 'Dummy Bank Account Number', + 'bank_account_name': 'Dummy Bank Account Name', +} + CATEGORY_DATA = { 'name': 'Dummy Category', } @@ -34,6 +40,7 @@ PRODUCT_DATA = { TRANSACTION_DATA = { 'payment_method': 'TRF', 'donation': '1000', + 'transaction_status': '001', } TRANSACTION_ITEM_DATA = { @@ -47,6 +54,7 @@ PROGRAM_DATA = { 'end_date_time': '2020-02-02T00:00:00+07:00', 'location': 'Dummy Location', 'speaker': 'Dummy Speaker', + 'link': 'https://example.com', } PROGRAM_DONATION_DATA = { @@ -54,12 +62,6 @@ PROGRAM_DONATION_DATA = { 'user_bank_account_name': 'Dummy User Bank Account Name', } -BANK_ACCOUNT_CONFIG_DATA = { - 'bank_name': 'Dummy Bank Name', - 'account_number': '0123456789', - 'account_owner': 'Dummy Account Owner', -} - HELP_CONTACT_CONFIG_DATA = { 'email': 'dummy@example.com', } diff --git a/api/serializers.py b/api/serializers.py index f0fdd52..ca2a424 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -39,10 +39,17 @@ class CartCheckoutSerializer(serializers.Serializer): # pylint: disable=abstract class CartUploadPOPSerializer(serializers.Serializer): # pylint: disable=abstract-method transaction = serializers.UUIDField(label=_('Transaction')) proof_of_payment = serializers.ImageField(label=_('Proof of payment')) + user_bank_name = serializers.CharField( + label=_('User bank name'), + max_length=100 + ) user_bank_account_name = serializers.CharField( label=_('User bank account name'), max_length=200 ) + bank_account_transfer_destination = serializers.UUIDField( + label=_('Bank account transfer destination') + ) class CartCompleteOrCancelTransactionSerializer(serializers.Serializer): # pylint: disable=abstract-method @@ -58,10 +65,17 @@ class DonationCreateSerializer(serializers.Serializer): # pylint: disable=abstra min_value=decimal.Decimal('0.01') ) proof_of_bank_transfer = serializers.ImageField(label=_('Proof of bank transfer')) + user_bank_name = serializers.CharField( + label=_('User bank name'), + max_length=100 + ) user_bank_account_name = serializers.CharField( label=_('User bank account name'), max_length=200 ) + bank_account_transfer_destination = serializers.UUIDField( + label=_('Bank account transfer destination') + ) class DonationReuploadProofOfBankTransferSerializer(serializers.Serializer): # pylint: disable=abstract-method @@ -73,10 +87,39 @@ class DonationReuploadProofOfBankTransferSerializer(serializers.Serializer): # p min_value=decimal.Decimal('0.01') ) proof_of_bank_transfer = serializers.ImageField(label=_('Proof of bank transfer')) + user_bank_name = serializers.CharField( + label=_('User bank name'), + max_length=100 + ) user_bank_account_name = serializers.CharField( label=_('User bank account name'), max_length=200 ) + bank_account_transfer_destination = serializers.UUIDField( + label=_('Bank account transfer destination') + ) + + +class ReportTransactionSerializer(serializers.Serializer): # pylint: disable=abstract-method + created_at_date_range_after = serializers.DateField( + label=_('Created after date'), + required=False + ) + created_at_date_range_before = serializers.DateField( + label=_('Created before date'), + required=False + ) + + +class ReportProgramDonationSerializer(serializers.Serializer): # pylint: disable=abstract-method + created_at_date_range_after = serializers.DateField( + label=_('Created after date'), + required=False + ) + created_at_date_range_before = serializers.DateField( + label=_('Created before date'), + required=False + ) class UserSerializer(serializers.ModelSerializer): @@ -142,6 +185,13 @@ class UserSerializer(serializers.ModelSerializer): return super().validate(attrs) +class BankAccountTransferDestinationSerializer(serializers.ModelSerializer): + class Meta: + fields = ['id', 'bank_name', 'bank_account_number', 'bank_account_name'] + model = models.BankAccountTransferDestination + read_only_fields = ['id'] + + class CategorySerializer(serializers.ModelSerializer): class Meta: fields = ['id', 'name', 'image'] @@ -275,7 +325,12 @@ class TransactionSerializer(serializers.ModelSerializer): 'transaction_status', 'readable_transaction_status', 'proof_of_payment', + 'user_bank_name', 'user_bank_account_name', + 'bank_account_transfer_destination', + 'transfer_destination_bank_name', + 'transfer_destination_bank_account_name', + 'transfer_destination_bank_account_number', 'created_at', 'updated_at', 'subtotal', @@ -296,7 +351,12 @@ class TransactionSerializer(serializers.ModelSerializer): 'payment_method', 'donation', 'proof_of_payment', + 'user_bank_name', 'user_bank_account_name', + 'bank_account_transfer_destination', + 'transfer_destination_bank_name', + 'transfer_destination_bank_account_name', + 'transfer_destination_bank_account_number', 'created_at', 'updated_at', ] @@ -345,6 +405,7 @@ class ProgramSerializer(serializers.ModelSerializer): 'open_donation', 'program_minutes', 'poster_image', + 'link', 'total_donation_amount', ] model = models.Program @@ -352,7 +413,9 @@ class ProgramSerializer(serializers.ModelSerializer): def get_total_donation_amount(self, obj): # pylint: disable=no-self-use total_donation_amount = sum( - program_donation.amount for program_donation in obj.program_donations.all() + program_donation.amount for program_donation in obj.program_donations.filter( + donation_status='002' + ) ) return str(total_donation_amount) @@ -391,7 +454,12 @@ class ProgramDonationSerializer(serializers.ModelSerializer): 'donation_status', 'readable_donation_status', 'proof_of_bank_transfer', + 'user_bank_name', 'user_bank_account_name', + 'bank_account_transfer_destination', + 'transfer_destination_bank_name', + 'transfer_destination_bank_account_name', + 'transfer_destination_bank_account_number', 'created_at', 'updated_at', ] @@ -406,7 +474,12 @@ class ProgramDonationSerializer(serializers.ModelSerializer): 'program_name', 'amount', 'proof_of_bank_transfer', + 'user_bank_name', 'user_bank_account_name', + 'bank_account_transfer_destination', + 'transfer_destination_bank_name', + 'transfer_destination_bank_account_name', + 'transfer_destination_bank_account_number', 'created_at', 'updated_at', ] @@ -421,12 +494,6 @@ class AppConfigSerializer(serializers.ModelSerializer): model = models.AppConfig -class BankAccountConfigSerializer(serializers.ModelSerializer): - class Meta: - fields = ['bank_name', 'account_number', 'account_owner'] - model = models.BankAccountConfig - - class HelpContactConfigSerializer(serializers.ModelSerializer): class Meta: fields = ['email'] diff --git a/api/signals.py b/api/signals.py index 6ce525c..144afaf 100644 --- a/api/signals.py +++ b/api/signals.py @@ -1,10 +1,112 @@ from django import dispatch from django.db.models import signals -from api import models +from api import models, utils @dispatch.receiver(signals.post_save, sender=models.User) def create_shopping_cart(sender, created, instance, **_kwargs): # pylint: disable=unused-argument if created: models.ShoppingCart.objects.create(user=instance) + + +@dispatch.receiver(signals.pre_save, sender=models.Transaction) +def fill_dependent_transaction_fields(sender, instance, **_kwargs): + try: + obj = sender.objects.get(id=instance.id) + except sender.DoesNotExist: + obj = None + if (obj is None) or (obj.user != instance.user) or (getattr(instance, 'update_user', False)): + if instance.user is None: + instance.user_full_name = None + instance.user_phone_number = None + instance.shipping_address = None + instance.shipping_neighborhood = None + instance.shipping_hamlet = None + instance.shipping_urban_village = None + instance.shipping_sub_district = None + instance.shipping_costs = None + else: + instance.user_full_name = instance.user.full_name + instance.user_phone_number = instance.user.phone_number + instance.shipping_address = instance.user.address + instance.shipping_neighborhood = instance.user.neighborhood + instance.shipping_hamlet = instance.user.hamlet + instance.shipping_urban_village = instance.user.urban_village + instance.shipping_sub_district = instance.user.sub_district + instance.shipping_costs = utils.get_shipping_costs(instance.user) + if ((obj is None) or + (obj.bank_account_transfer_destination != instance.bank_account_transfer_destination) + or (getattr(instance, 'update_bank_account_transfer_destination', False))): + if instance.bank_account_transfer_destination is None: + instance.transfer_destination_bank_name = None + instance.transfer_destination_bank_account_name = None + instance.transfer_destination_bank_account_number = None + else: + instance.transfer_destination_bank_name = ( + instance.bank_account_transfer_destination.bank_name + ) + instance.transfer_destination_bank_account_name = ( + instance.bank_account_transfer_destination.bank_account_name + ) + instance.transfer_destination_bank_account_number = ( + instance.bank_account_transfer_destination.bank_account_number + ) + + +@dispatch.receiver(signals.pre_save, sender=models.TransactionItem) +def fill_dependent_transaction_item_fields(sender, instance, **_kwargs): + try: + obj = sender.objects.get(id=instance.id) + except sender.DoesNotExist: + obj = None + if ((obj is None) or + (obj.product != instance.product) or + (getattr(instance, 'update_product', False))): + if instance.product is None: + instance.product_name = None + instance.product_price = None + instance.product_pre_order = None + else: + instance.product_name = instance.product.name + instance.product_price = instance.product.price + instance.product_pre_order = instance.product.pre_order + + +@dispatch.receiver(signals.pre_save, sender=models.ProgramDonation) +def fill_dependent_program_donation_fields(sender, instance, **_kwargs): + try: + obj = sender.objects.get(id=instance.id) + except sender.DoesNotExist: + obj = None + if ((obj is None) or + (obj.user != instance.user) or + (getattr(instance, 'update_user', False))): + if instance.user is None: + instance.user_full_name = None + instance.user_phone_number = None + else: + instance.user_full_name = instance.user.full_name + instance.user_phone_number = instance.user.phone_number + if (obj is None) or (obj.program != instance.program): + if instance.program is None: + instance.program_name = None + else: + instance.program_name = instance.program.name + if ((obj is None) or + (obj.bank_account_transfer_destination != instance.bank_account_transfer_destination) + or (getattr(instance, 'update_bank_account_transfer_destination', False))): + if instance.bank_account_transfer_destination is None: + instance.transfer_destination_bank_name = None + instance.transfer_destination_bank_account_name = None + instance.transfer_destination_bank_account_number = None + else: + instance.transfer_destination_bank_name = ( + instance.bank_account_transfer_destination.bank_name + ) + instance.transfer_destination_bank_account_name = ( + instance.bank_account_transfer_destination.bank_account_name + ) + instance.transfer_destination_bank_account_number = ( + instance.bank_account_transfer_destination.bank_account_number + ) diff --git a/api/tests.py b/api/tests.py index 095ff2b..33b10ca 100644 --- a/api/tests.py +++ b/api/tests.py @@ -4,6 +4,7 @@ from unittest import mock import jwt from django import conf, test as django_test, urls +from django.core import management from PIL import Image from rest_framework import exceptions, status, test as rest_framework_test @@ -54,6 +55,14 @@ def request(method, url_name, data=None, format='json', http_authorization=None, return response +class CommandsTest(django_test.TestCase): + def test_createorupdateapiconfig_command(self): + management.call_command('createorupdateapiconfig') + self.assertTrue(models.AppConfig.objects.count(), 1) + self.assertTrue(models.HelpContactConfig.objects.count(), 1) + self.assertTrue(models.ShipmentConfig.objects.count(), 1) + + class UtilsTest(django_test.TestCase): def test_generate_bearer_token(self): user = models.User.objects.create(**seeds.USER_DATA) @@ -248,6 +257,11 @@ class CartTest(rest_framework_test.APITestCase): seeds.USER_DATA['username'], seeds.USER_DATA['password'] ) + self.bank_account_transfer_destination = ( + models.BankAccountTransferDestination.objects.create( + **seeds.BANK_ACCOUNT_TRANSFER_DESTINATION + ) + ) self.category = models.Category.objects.create(**seeds.CATEGORY_DATA) self.subcategory = models.Subcategory.objects.create(**dict( seeds.CATEGORY_DATA, @@ -478,7 +492,9 @@ class CartTest(rest_framework_test.APITestCase): data = { 'transaction': response.data['transaction'], 'proof_of_payment': proof_of_payment, + 'user_bank_name': 'Dummy User Bank Name', 'user_bank_account_name': 'Dummy User Bank Account Name', + 'bank_account_transfer_destination': self.bank_account_transfer_destination.id, } response = request( 'POST', @@ -514,7 +530,9 @@ class CartTest(rest_framework_test.APITestCase): data = { 'transaction': response.data['transaction'], 'proof_of_payment': proof_of_payment, + 'user_bank_name': 'Dummy User Bank Name', 'user_bank_account_name': 'Dummy User Bank Account Name', + 'bank_account_transfer_destination': self.bank_account_transfer_destination.id, } response = request( 'POST', @@ -551,7 +569,9 @@ class CartTest(rest_framework_test.APITestCase): data = { 'transaction': response.data['transaction'], 'proof_of_payment': proof_of_payment, + 'user_bank_name': 'Dummy User Bank Name', 'user_bank_account_name': 'Dummy User Bank Account Name', + 'bank_account_transfer_destination': self.bank_account_transfer_destination.id, } response = request( 'POST', @@ -595,6 +615,11 @@ class DonationTest(rest_framework_test.APITestCase): seeds.USER_DATA['username'], seeds.USER_DATA['password'] ) + self.bank_account_transfer_destination = ( + models.BankAccountTransferDestination.objects.create( + **seeds.BANK_ACCOUNT_TRANSFER_DESTINATION + ) + ) self.proof_of_bank_transfer_file = create_tmp_image() def test_donation_create_success(self): @@ -604,7 +629,9 @@ class DonationTest(rest_framework_test.APITestCase): 'program': program.id, 'amount': '1000', 'proof_of_bank_transfer': proof_of_bank_transfer, + 'user_bank_name': 'Dummy User Bank Name', 'user_bank_account_name': 'Dummy User Bank Account Name', + 'bank_account_transfer_destination': self.bank_account_transfer_destination.id, } response = request( 'POST', @@ -625,7 +652,9 @@ class DonationTest(rest_framework_test.APITestCase): 'program': program.id, 'amount': '1000', 'proof_of_bank_transfer': proof_of_bank_transfer, + 'user_bank_name': 'Dummy User Bank Name', 'user_bank_account_name': 'Dummy User Bank Account Name', + 'bank_account_transfer_destination': self.bank_account_transfer_destination.id, } response = request( 'POST', @@ -649,7 +678,9 @@ class DonationTest(rest_framework_test.APITestCase): 'amount': '1000', 'program_donation': program_donation.id, 'proof_of_bank_transfer': proof_of_bank_transfer, + 'user_bank_name': 'Dummy User Bank Name', 'user_bank_account_name': 'Dummy User Bank Account Name', + 'bank_account_transfer_destination': self.bank_account_transfer_destination.id, } response = request( 'POST', @@ -674,7 +705,9 @@ class DonationTest(rest_framework_test.APITestCase): 'amount': '1000', 'program_donation': program_donation.id, 'proof_of_bank_transfer': proof_of_bank_transfer, + 'user_bank_name': 'Dummy User Bank Name', 'user_bank_account_name': 'Dummy User Bank Account Name', + 'bank_account_transfer_destination': self.bank_account_transfer_destination.id, } response = request( 'POST', @@ -686,6 +719,61 @@ class DonationTest(rest_framework_test.APITestCase): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) +class ReportViewsTest(rest_framework_test.APITestCase): + def setUp(self): + self.superuser = models.User.objects.create_superuser(**seeds.SUPERUSER_DATA) + self.superuser_http_authorization = get_http_authorization( + seeds.SUPERUSER_DATA['username'], + seeds.SUPERUSER_DATA['password'] + ) + models.ShipmentConfig.objects.create(**seeds.SHIPMENT_CONFIG_DATA) + + def test_transaction_report_success(self): + user = create_user(seeds.USER_DATA) + category = models.Category.objects.create(**seeds.CATEGORY_DATA) + subcategory = models.Subcategory.objects.create(**dict( + seeds.CATEGORY_DATA, + category=category + )) + product = models.Product.objects.create(**dict( + seeds.PRODUCT_DATA, subcategory=subcategory + )) + transaction = models.Transaction.objects.create(**dict( + seeds.TRANSACTION_DATA, user=user + )) + models.TransactionItem.objects.create(**dict( + seeds.TRANSACTION_ITEM_DATA, + transaction=transaction, + product=product + )) + transaction.transaction_status = '005' + transaction.save() + user.delete() + response = request( + 'GET', + 'transaction-report', + http_authorization=self.superuser_http_authorization + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_program_donation_report_success(self): + user = create_user(seeds.USER_DATA) + program = models.Program.objects.create(**seeds.PROGRAM_DATA) + program_donation = models.ProgramDonation.objects.create(**dict( + seeds.PROGRAM_DONATION_DATA, + user=user, + program=program + )) + program_donation.donation_status = '002' + program_donation.save() + response = request( + 'GET', + 'program-donation-report', + http_authorization=self.superuser_http_authorization + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + class UserTest(rest_framework_test.APITestCase): def setUp(self): self.superuser = models.User.objects.create_superuser(**seeds.SUPERUSER_DATA) @@ -788,6 +876,126 @@ class UserTest(rest_framework_test.APITestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) +class BankAccountTransferDestinationTest(rest_framework_test.APITestCase): + def setUp(self): + self.superuser = models.User.objects.create_superuser(**seeds.SUPERUSER_DATA) + self.superuser_http_authorization = get_http_authorization( + seeds.SUPERUSER_DATA['username'], + seeds.SUPERUSER_DATA['password'] + ) + + def test_bank_account_transfer_destination_model_string_representation(self): + bank_account_transfer_destination = models.BankAccountTransferDestination.objects.create( + **seeds.BANK_ACCOUNT_TRANSFER_DESTINATION + ) + self.assertEqual( + str(bank_account_transfer_destination), + '{} - {}'.format( + bank_account_transfer_destination.bank_name, + bank_account_transfer_destination.bank_account_number + ) + ) + + def test_bank_account_transfer_destination_list_success(self): + response = request( + 'GET', + 'bank-account-transfer-destination-list', + http_authorization=self.superuser_http_authorization, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_bank_account_transfer_destination_detail_success(self): + bank_account_transfer_destination = models.BankAccountTransferDestination.objects.create( + **seeds.BANK_ACCOUNT_TRANSFER_DESTINATION + ) + response = request( + 'GET', + 'bank-account-transfer-destination-detail', + http_authorization=self.superuser_http_authorization, + url_args=[bank_account_transfer_destination.id] + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_create_bank_account_transfer_destination_success(self): + data = seeds.BANK_ACCOUNT_TRANSFER_DESTINATION + response = request( + 'POST', + 'bank-account-transfer-destination-list', + data, + http_authorization=self.superuser_http_authorization + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(models.BankAccountTransferDestination.objects.count(), 1) + self.assertEqual( + models.BankAccountTransferDestination.objects.get( + id=response.data['id'] + ).bank_account_number, + data['bank_account_number'] + ) + + def test_create_bank_account_transfer_destination_fail(self): + response = request( + 'POST', + 'bank-account-transfer-destination-list', + http_authorization=self.superuser_http_authorization + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(models.BankAccountTransferDestination.objects.count(), 0) + + def test_update_bank_account_transfer_destination_success(self): + bank_account_transfer_destination = models.BankAccountTransferDestination.objects.create( + **seeds.BANK_ACCOUNT_TRANSFER_DESTINATION + ) + data = { + 'bank_account_number': 'Another Dummy Bank Account Number', + } + response = request( + 'PATCH', + 'bank-account-transfer-destination-detail', + data, + http_authorization=self.superuser_http_authorization, + url_args=[bank_account_transfer_destination.id] + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + models.BankAccountTransferDestination.objects.get( + id=bank_account_transfer_destination.id + ).bank_account_number, + data['bank_account_number'] + ) + data = seeds.BANK_ACCOUNT_TRANSFER_DESTINATION + response = request( + 'PUT', + 'bank-account-transfer-destination-detail', + data, + http_authorization=self.superuser_http_authorization, + url_args=[bank_account_transfer_destination.id] + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + models.BankAccountTransferDestination.objects.get( + id=bank_account_transfer_destination.id + ).bank_account_number, + data['bank_account_number'] + ) + + def test_update_bank_account_transfer_destination_fail(self): + bank_account_transfer_destination = models.BankAccountTransferDestination.objects.create( + **seeds.BANK_ACCOUNT_TRANSFER_DESTINATION + ) + data = { + 'bank_account_number': '', + } + response = request( + 'PATCH', + 'bank-account-transfer-destination-detail', + data, + http_authorization=self.superuser_http_authorization, + url_args=[bank_account_transfer_destination.id] + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + class CategoryTest(rest_framework_test.APITestCase): def setUp(self): self.superuser = models.User.objects.create_superuser(**seeds.SUPERUSER_DATA) @@ -1460,7 +1668,7 @@ class ChoicesViewsTest(rest_framework_test.APITestCase): def test_payment_method_choices_success(self): response = request( 'GET', - 'payment-method-choices', + 'payment-method-choice', http_authorization=self.superuser_http_authorization ) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -1468,7 +1676,7 @@ class ChoicesViewsTest(rest_framework_test.APITestCase): def test_transaction_status_choices_success(self): response = request( 'GET', - 'transaction-status-choices', + 'transaction-status-choice', http_authorization=self.superuser_http_authorization ) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -1476,7 +1684,7 @@ class ChoicesViewsTest(rest_framework_test.APITestCase): def test_donation_status_choices_success(self): response = request( 'GET', - 'donation-status-choices', + 'donation-status-choice', http_authorization=self.superuser_http_authorization ) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -1531,57 +1739,6 @@ class AppConfigTest(rest_framework_test.APITestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) -class BankAccountConfigTest(rest_framework_test.APITestCase): - def setUp(self): - self.superuser = models.User.objects.create_superuser(**seeds.SUPERUSER_DATA) - self.superuser_http_authorization = get_http_authorization( - seeds.SUPERUSER_DATA['username'], - seeds.SUPERUSER_DATA['password'] - ) - - def test_bank_account_config_model_string_representation(self): - bank_account_config = models.BankAccountConfig.objects.create( - **seeds.BANK_ACCOUNT_CONFIG_DATA - ) - self.assertTrue(len(str(bank_account_config)) > 0) - - def test_bank_account_config_detail_success(self): - models.BankAccountConfig.objects.create(**seeds.BANK_ACCOUNT_CONFIG_DATA) - response = request( - 'GET', - 'bank-account-config-detail', - http_authorization=self.superuser_http_authorization - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - def test_update_bank_account_config_success(self): - models.BankAccountConfig.objects.create(**seeds.BANK_ACCOUNT_CONFIG_DATA) - data = { - 'bank_name': 'Another Dummy Bank Name', - } - response = request( - 'PATCH', - 'bank-account-config-detail', - data, - http_authorization=self.superuser_http_authorization - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(models.BankAccountConfig.objects.get().bank_name, data['bank_name']) - - def test_update_bank_account_config_fail(self): - models.BankAccountConfig.objects.create(**seeds.BANK_ACCOUNT_CONFIG_DATA) - data = { - 'bank_name': '', - } - response = request( - 'PATCH', - 'bank-account-config-detail', - data, - http_authorization=self.superuser_http_authorization - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - class HelpContactConfigTest(rest_framework_test.APITestCase): def setUp(self): self.superuser = models.User.objects.create_superuser(**seeds.SUPERUSER_DATA) diff --git a/api/urls.py b/api/urls.py index ea34218..bfe3cc1 100644 --- a/api/urls.py +++ b/api/urls.py @@ -35,8 +35,28 @@ urlpatterns = [ api_views.DonationReuploadProofOfBankTransfer.as_view(), name='donation-reupload-proof-of-bank-transfer' ), + urls.path( + 'reports/transaction/', + api_views.ReportTransaction.as_view(), + name='transaction-report' + ), + urls.path( + 'reports/program-donation/', + api_views.ReportProgramDonation.as_view(), + name='program-donation-report' + ), urls.path('users/', api_views.UserList.as_view(), name='user-list'), urls.path('users//', api_views.UserDetail.as_view(), name='user-detail'), + urls.path( + 'bank-account-transfer-destinations/', + api_views.BankAccountTransferDestinationList.as_view(), + name='bank-account-transfer-destination-list' + ), + urls.path( + 'bank-account-transfer-destinations//', + api_views.BankAccountTransferDestinationDetail.as_view(), + name='bank-account-transfer-destination-detail' + ), urls.path('categories/', api_views.CategoryList.as_view(), name='category-list'), urls.path('categories//', api_views.CategoryDetail.as_view(), name='category-detail'), urls.path('subcategories/', api_views.SubcategoryList.as_view(), name='subcategory-list'), @@ -74,24 +94,19 @@ urlpatterns = [ urls.path( 'choices/payment-method/', api_views.PaymentMethodChoices.as_view(), - name='payment-method-choices' + name='payment-method-choice' ), urls.path( 'choices/transaction-status/', api_views.TransactionStatusChoices.as_view(), - name='transaction-status-choices' + name='transaction-status-choice' ), urls.path( 'choices/donation-status/', api_views.DonationStatusChoices.as_view(), - name='donation-status-choices' + name='donation-status-choice' ), urls.path('configs/app/', api_views.AppConfigDetail.as_view(), name='app-config-detail'), - urls.path( - 'configs/bank-account/', - api_views.BankAccountConfigDetail.as_view(), - name='bank-account-config-detail' - ), urls.path( 'configs/help-contact/', api_views.HelpContactConfigDetail.as_view(), diff --git a/api/views.py b/api/views.py index 31417f5..b6ac193 100644 --- a/api/views.py +++ b/api/views.py @@ -1,4 +1,4 @@ -from django import shortcuts +from django import http, shortcuts from django.contrib import auth from django.db import transaction as db_transaction, utils as db_utils from django.db.models import deletion @@ -13,7 +13,8 @@ from rest_framework.authtoken import serializers as authtoken_serializers from api import ( constants, exceptions as api_exceptions, filters as api_filters, models, paginations, - permissions as api_permissions, serializers as api_serializers, schemas, utils as api_utils + permissions as api_permissions, reports_writer, schemas, serializers as api_serializers, + utils as api_utils ) from home_industry import utils as home_industry_utils @@ -166,10 +167,14 @@ class CartCheckout(rest_framework_views.APIView): 'Unable to checkout because there are no items purchased.' )) api_utils.validate_product_stock(cart_items) + transaction_status = ( + '001' if serializer.validated_data['payment_method'] == 'TRF' else '002' + ) transaction = models.Transaction.objects.create( user=user, payment_method=serializer.validated_data['payment_method'], - donation=serializer.validated_data['donation'] + donation=serializer.validated_data['donation'], + transaction_status=transaction_status ) is_success = True for cart_item in cart_items: @@ -210,6 +215,10 @@ class CartUploadPOP(rest_framework_views.APIView): id=serializer.validated_data['transaction'], user=user ) + bank_account_transfer_destination = shortcuts.get_object_or_404( + models.BankAccountTransferDestination, + id=serializer.validated_data['bank_account_transfer_destination'] + ) if transaction.payment_method != 'TRF': raise rest_framework_exceptions.PermissionDenied(_( 'The payment method for this transaction is not a transfer.' @@ -219,7 +228,10 @@ class CartUploadPOP(rest_framework_views.APIView): 'Cannot upload proof of payment at this stage.' )) transaction.proof_of_payment = serializer.validated_data['proof_of_payment'] + transaction.user_bank_name = serializer.validated_data['user_bank_name'] transaction.user_bank_account_name = serializer.validated_data['user_bank_account_name'] + transaction.bank_account_transfer_destination = bank_account_transfer_destination + transaction.update_bank_account_transfer_destination = True transaction.transaction_status = '002' transaction.save() return response.Response(status=status.HTTP_204_NO_CONTENT) @@ -292,6 +304,10 @@ class DonationCreate(rest_framework_views.APIView): models.Program, id=serializer.validated_data['program'] ) + bank_account_transfer_destination = shortcuts.get_object_or_404( + models.BankAccountTransferDestination, + id=serializer.validated_data['bank_account_transfer_destination'] + ) if not program.open_donation: raise rest_framework_exceptions.PermissionDenied(_( 'This program is currently not accepting donations.' @@ -300,8 +316,10 @@ class DonationCreate(rest_framework_views.APIView): user=user, program=program, amount=serializer.validated_data['amount'], + proof_of_bank_transfer=serializer.validated_data['proof_of_bank_transfer'], + user_bank_name=serializer.validated_data['user_bank_name'], user_bank_account_name=serializer.validated_data['user_bank_account_name'], - proof_of_bank_transfer=serializer.validated_data['proof_of_bank_transfer'] + bank_account_transfer_destination=bank_account_transfer_destination ) return response.Response( {'program_donation': program_donation.id}, @@ -325,6 +343,10 @@ class DonationReuploadProofOfBankTransfer(rest_framework_views.APIView): id=serializer.validated_data['program_donation'], user=user ) + bank_account_transfer_destination = shortcuts.get_object_or_404( + models.BankAccountTransferDestination, + id=serializer.validated_data['bank_account_transfer_destination'] + ) if program_donation.donation_status not in ('001', '004'): raise rest_framework_exceptions.PermissionDenied(_( 'Cannot reupload proof of bank transfer at this stage.' @@ -333,14 +355,51 @@ class DonationReuploadProofOfBankTransfer(rest_framework_views.APIView): program_donation.proof_of_bank_transfer = ( serializer.validated_data['proof_of_bank_transfer'] ) + program_donation.user_bank_name = serializer.validated_data['user_bank_name'] program_donation.user_bank_account_name = ( serializer.validated_data['user_bank_account_name'] ) + program_donation.bank_account_transfer_destination = bank_account_transfer_destination + program_donation.update_bank_account_transfer_destination = True program_donation.donation_status = '001' program_donation.save() return response.Response(status=status.HTTP_204_NO_CONTENT) +class ReportAPIView(rest_framework_views.APIView): + permission_classes = [rest_framework_permissions.IsAdminUser] + report_function = None + serializer_class = None + + def get_serializer(self, *args, **kwargs): + return self.serializer_class(*args, **kwargs) # pylint: disable=not-callable + + def get(self, request, _format=None): + assert self.report_function is not None, ( + '{} should include a `report_function` attribute.'.format(self.__class__.__name__) + ) + assert self.report_function is not None, ( + '{} should include a `serializer_class` attribute.'.format(self.__class__.__name__) + ) + serializer = self.get_serializer(data=request.query_params) + serializer.is_valid(raise_exception=True) + filename = self.report_function.__func__(serializer.validated_data) + file = open(filename, 'rb') + return http.FileResponse(file) + + +class ReportTransaction(ReportAPIView): + report_function = reports_writer.create_transaction_report + schema = schemas.ReportTransactionSchema() + serializer_class = api_serializers.ReportTransactionSerializer + + +class ReportProgramDonation(ReportAPIView): + report_function = reports_writer.create_program_donation_report + schema = schemas.ReportProgramDonationSchema() + serializer_class = api_serializers.ReportProgramDonationSerializer + + class UserList(generics.ListCreateAPIView): filter_backends = [ rest_framework.DjangoFilterBackend, @@ -370,6 +429,27 @@ class UserDetail(generics.RetrieveUpdateDestroyAPIView): return super().get_object() +class BankAccountTransferDestinationList(generics.ListCreateAPIView): + filter_backends = [rest_framework_filters.SearchFilter] + pagination_class = paginations.SmallResultsSetPagination + permission_classes = [ + api_permissions.IsAdminUserOrReadOnly, + rest_framework_permissions.IsAuthenticated, + ] + queryset = models.BankAccountTransferDestination.objects.all() + search_fields = ['bank_name', 'bank_account_number', 'bank_account_name'] + serializer_class = api_serializers.BankAccountTransferDestinationSerializer + + +class BankAccountTransferDestinationDetail(generics.RetrieveUpdateDestroyAPIView): + permission_classes = [ + api_permissions.IsAdminUserOrReadOnly, + rest_framework_permissions.IsAuthenticated, + ] + queryset = models.BankAccountTransferDestination.objects.all() + serializer_class = api_serializers.BankAccountTransferDestinationSerializer + + class CategoryList(generics.ListCreateAPIView): filter_backends = [ rest_framework.DjangoFilterBackend, @@ -639,16 +719,6 @@ class AppConfigDetail(generics.RetrieveUpdateAPIView): return obj -class BankAccountConfigDetail(generics.RetrieveUpdateAPIView): - permission_classes = [api_permissions.IsAdminUserOrReadOnly] - queryset = models.BankAccountConfig.objects.all() - serializer_class = api_serializers.BankAccountConfigSerializer - - def get_object(self): - obj = shortcuts.get_object_or_404(models.BankAccountConfig) - return obj - - class HelpContactConfigDetail(generics.RetrieveUpdateAPIView): permission_classes = [api_permissions.IsAdminUserOrReadOnly] queryset = models.HelpContactConfig.objects.all() diff --git a/api_config.yaml b/api_config.yaml index 7c54316..8b9d05c 100644 --- a/api_config.yaml +++ b/api_config.yaml @@ -1,9 +1,5 @@ app_config: send_sms: no -bank_account_config: - bank_name: "Dummy Bank Name" - account_number: "0123456789" - account_owner: "Dummy Account Owner" help_contact_config: email: "dummy@example.com" shipment_config: diff --git a/home_industry/settings/ci.py b/home_industry/settings/ci.py index 7c77e00..2d7f48e 100644 --- a/home_industry/settings/ci.py +++ b/home_industry/settings/ci.py @@ -133,6 +133,17 @@ REST_FRAMEWORK = { 'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema', } +# Home Industry Admin Site + +HOME_INDUSTRY_ADMIN_SITE = { + 'URL': 'https://example.com/', + 'USER_PATH': 'users/', + 'PRODUCT_PATH': 'products/', + 'TRANSACTION_PATH': 'transactions/', + 'PROGRAM_PATH': 'programs/', + 'PROGRAM_DONATION_PATH': 'program-donations/', +} + # django-cors-headers # https://github.com/adamchainz/django-cors-headers diff --git a/home_industry/settings/local.py b/home_industry/settings/local.py index d886c94..ec385da 100644 --- a/home_industry/settings/local.py +++ b/home_industry/settings/local.py @@ -137,6 +137,17 @@ REST_FRAMEWORK = { 'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema', } +# Home Industry Admin Site + +HOME_INDUSTRY_ADMIN_SITE = { + 'URL': 'https://example.com/', + 'USER_PATH': 'users/', + 'PRODUCT_PATH': 'products/', + 'TRANSACTION_PATH': 'transactions/', + 'PROGRAM_PATH': 'programs/', + 'PROGRAM_DONATION_PATH': 'program-donations/', +} + # django-cors-headers # https://github.com/adamchainz/django-cors-headers diff --git a/home_industry/settings/production.py b/home_industry/settings/production.py index e67ddb6..5f367ff 100644 --- a/home_industry/settings/production.py +++ b/home_industry/settings/production.py @@ -1,5 +1,6 @@ import os +from corsheaders import defaults from rest_framework import settings BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) @@ -152,9 +153,28 @@ AWS = { 'AWS_REGION_NAME': 'ap-southeast-1', } +# Home Industry Admin Site + +HOME_INDUSTRY_ADMIN_SITE = { + 'URL': os.environ.get('HOME_INDUSTRY_ADMIN_SITE_URL', ''), + 'USER_PATH': os.environ.get('HOME_INDUSTRY_ADMIN_SITE_USER_PATH', ''), + 'PRODUCT_PATH': os.environ.get('HOME_INDUSTRY_ADMIN_SITE_PRODUCT_PATH', ''), + 'TRANSACTION_PATH': os.environ.get('HOME_INDUSTRY_ADMIN_SITE_TRANSACTION_PATH', ''), + 'PROGRAM_PATH': os.environ.get('HOME_INDUSTRY_ADMIN_SITE_PROGRAM_PATH', ''), + 'PROGRAM_DONATION_PATH': os.environ.get('HOME_INDUSTRY_ADMIN_SITE_PROGRAM_DONATION_PATH', ''), +} + # django-cors-headers # https://github.com/adamchainz/django-cors-headers +CORS_ALLOW_HEADERS = list(defaults.default_headers) + [ + 'Access-Control-Expose-Headers', +] + +CORS_EXPOSE_HEADERS = [ + 'Content-Disposition', +] + CORS_ORIGIN_ALLOW_ALL = True # django-rest-knox diff --git a/home_industry/settings/staging.py b/home_industry/settings/staging.py index b6fd230..480c2c5 100644 --- a/home_industry/settings/staging.py +++ b/home_industry/settings/staging.py @@ -1,6 +1,7 @@ import os import dj_database_url +from corsheaders import defaults from rest_framework import settings BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) @@ -146,9 +147,28 @@ AWS = { 'AWS_REGION_NAME': 'ap-southeast-1', } +# Home Industry Admin Site + +HOME_INDUSTRY_ADMIN_SITE = { + 'URL': os.environ.get('HOME_INDUSTRY_ADMIN_SITE_URL', ''), + 'USER_PATH': os.environ.get('HOME_INDUSTRY_ADMIN_SITE_USER_PATH', ''), + 'PRODUCT_PATH': os.environ.get('HOME_INDUSTRY_ADMIN_SITE_PRODUCT_PATH', ''), + 'TRANSACTION_PATH': os.environ.get('HOME_INDUSTRY_ADMIN_SITE_TRANSACTION_PATH', ''), + 'PROGRAM_PATH': os.environ.get('HOME_INDUSTRY_ADMIN_SITE_PROGRAM_PATH', ''), + 'PROGRAM_DONATION_PATH': os.environ.get('HOME_INDUSTRY_ADMIN_SITE_PROGRAM_DONATION_PATH', ''), +} + # django-cors-headers # https://github.com/adamchainz/django-cors-headers +CORS_ALLOW_HEADERS = list(defaults.default_headers) + [ + 'Access-Control-Expose-Headers', +] + +CORS_EXPOSE_HEADERS = [ + 'Content-Disposition', +] + CORS_ORIGIN_ALLOW_ALL = True # django-rest-knox diff --git a/locale/id/LC_MESSAGES/django.mo b/locale/id/LC_MESSAGES/django.mo index db0ae278e35e1f2e14f0b2b2e546762295f78547..cd88a0b6cd37dd1661012b0564214f240a6a0410 100644 GIT binary patch literal 12651 zcmcJU3y>VedB+=pL6!x`_<;o>4C1MvI}mRqAl&Ir=q%moxLZl!Bsg=sb9bYC%rY}8 zIIwIIaAIRZcp1kDjtQ?2V}ilhxEzOqR7f1KLnQ@OsSvx$g%c`v*=6H6#(7lo`*-*3 z?4EQoNhMqQ?l;}j&#%A!`kU4_k2>VzhTl`j&mk|p#h8DGx4ex%{H{IHm=oawJQRKk zo&Xs%i&9K5Pk!$hbvbZGX^h#=faP`ariX6T<_m*%<1qdH~?>kyfR<%{44lY z(ti(+g5QQK;7{N?;4(7R@4Ml<;qj2J&3T@iq59nkHSSyB`{A8%8T=7cd;bbgf(Oy) z5%3ha2c8St@U!q>c-%XUp=)yjRJpS~hv7=nm&3#0RX#oInZb9Fe*;wgyP@>gV_p!ECa zQ04yCzyD{b_TGT9^P?I3AUp>i535l2aU;~ayb5JE--Y}$uk+^^xRO8W=TxY9T?n-w z#^D;6K-ujbp7%hF`ytQAq2~J)D1ATY^Ph*(>&yQAx1iSLJD&dr*_vj>v9AABQ2nfd z8s|Dl5py==pPAr~%I}8irw!F_$EQCCrT-hD=5rgAKJWASk9$51)z1r1?Y#`8pI7|* zSE1_v07~!6P_Ft}1vUQFQ03P`+3R|!^27dp1!^2MsCrjJt=n}_%@uGf>D!^k^9?9F{+{Q-7=`*<4K@BVq1w3wYTV;c>%0SM z{xQ^e6R7eZfLgbkJ@1AZ&wWty{5;e?c+T_hpz41Y%HH3Ar@`YfLLSW)D0`WMYCnZq zzZ;;&`C+K>-Q)QPR6k#a8sFF8#)GhXsP>M)IAtFLkSUvUpz2RS^%KI4umLZD55qC| z8XSh}G3uQ#hgZTEp!~tf917>dE1>jxo9DeydixyIc)tXtpBH`l6{zvN4yBi$Lg{1W zNp8NYq53@)TKk3DNVnlK_@_|ipMm@{U+0h7c^%#Y-*U1szX9)n2gB7U85e4fgVNJk zo+D8D-3bqcHK=jcJ+Fbd3UedWdfo%o@1s!j`?62}8Pq&qgwpFj`TQS4^|$O)m;X*E zJ30}nz4M{=)fTAwQ*bSe{rfwi#`l2d6HxPb#`9&U_P+xUgZ}{s;2{{(x$rD_I-G~< z_b#aZ9)|K~Pe6_1YtZfssP?|+^ZyH~-YN!lCOiplhC86zxd$$X4?)@4V-S-we+||C z_u++b`7gNkhoScC6x4d{hT4aFq2_-lRR5oWD)$wraxX!(_nPNHOlm#pV<9fbjKf_p zhKIsuJ--U2uNOVP4YfW$g_`g3b#6br$MZb+R`M%Qdb!Foftu$Ho*#vWkiG-H4SoWu z-u+PhKMGgEC!yBwStvVx5z0Tm3|GP*K-u$u`g|>(-X9Cq&Uu~}dyYW$yA`V5B$S?} zq4ZXVhr_*|AA=g#Cq3_nc6?CdeiCY)&p?gmZ~Xgj`uG0`Wmi9hR&Som2VH+hL)BXg zRqi|}d$|Ov-6^R4tDa4$b-u>)R;Y152vzf``MKJnw*7*Lyv` z0M*X(p8o*V@Askf^9GdOR-Nhc-vu@Q<2_IJ`RBt`i@Tqhme~Q{eBhs2$CcEEg;v}C+8>6_s_o%tzE%!UsWUp5KeS&*yy{YOO9pI8ICz(eExKMD9iIN7f*0Z@cd6pniXV$bXC?Pax8j zeh(wRh72pg?@{E_$R6Z35&dM3`rU{`i0uA8M86FVW(T~|Kg&L}mcN8N=JSMANaE9% z!p|aCBiAEeMD&vn(r*a49r+N#(pz6FJ39i=?{wrT`{e9Hk8R{!BtSlg=+{7g%RUh! z!`l$q3+7>($jJy>#Qx6nTnj(opYMdXB3pf0xC6P&r!R&dLv|x0$iawyzw2OQf@%M} z7k<<~e*qqYoP&JA=LtF^KZR)T>Nn$H=HRa&*COxmc@uCJdAm>l4y++3`n2$2|9l)g z9GOD4BPSt$jA*azM8=R)kQcG?%0cL9G)7c@o4e(rKJ8xPAv!ucvs{nR&A{ z+G$davdj*k8EKG7kmU0Wl12tv{A3;?3S}-)YooHxd*>lgVtr zzZp%wJL!Q##Pw)NnZ-)z3rTff0n-4G< z);8V~r3+@$d{nI`oxE(Xn5)^8MyRJ2gtNKqhUYRX?>^2%vq>7c*CSqorg#WWtH|!0 zI@Xri6P@Nvl$Pmft4cH4s@aBuJZ?sqMbc^1SdC!LjwEFbF#%MNVw_qz6&p00t@1pi znrbnpLjTxQ(C(yFR+IT$-3eQ{>=!esMrj_#EtWcJW2II(+ zYJJd*jhL~Pj72ufbbVj^soiS1T{(@ulkB9mz}VBhrQ#8q2MvsA8CMA5EI6ax*WqIQzz z-9klv*3BtKV5{}?ZPPXOKo+a)ejHJm?f0j-+#92riuOb;JZ%4tnT}GnX@JdG=ibUO zkxt8+C5E0wY?$fs=|M9sA1-IUDarDzOl}FAIIc215yf-!Gf6t1ums&o)41Ca?{3T7 z9Vt#U*cmq(I8Cx$2Sc@5stqumG-yFvL$1tOXAm{p?5lWIu9Gn;^12iEcsLd+r0ZzP7Tc(O@=xeO;M! z(qODr>nKbLCONDal3y~NN(59fk(dI+D*jXZ@%kih?;Q-bMo~LhQ1$(ln;zXXHCh=o zq2K7CizdT%yHU2+6MmW{-(+TAefH-#lj+MV=HC~n{>&1-E4xNj#Rsw<(|yW#v{7sv zbsrau)MBbdl#?4m<7yh?Mp#L?@xF)RY${&(oo1pYae|Q}w&!lhgTfLsE6cOvnRh;T zK5DeJf;rKUTgf7TH%q_DOx%?BZN#m*X(VnvjcvBo>&lbjxGjcf`rWK)_nxn94hhW3 zHQja3rcJmV@z(|?1>GEcCbsH*dG~c+rA*m`9(Z{o_fBeClgCzm_jt5=U7TU7+9K?# zPR=?NCG2c{!shPpVCB8bE_PcH<(cA;H2Ti5F>&A5(-PU4&9_Htu@UTdl8%_FJ!AWm zI`rg!-D0l`I`;6~;||Ytfy|=!qsF>9>#y-W0UsG|hnJn&K8i;BYO;^tbj7|={LcsB zEw?+2?&>If{BB`|dWf!MEw}qWguZ-OQ`vd(&9o z;XXEcpPe)4dFf7@DPu(%w>;ODy_~cHf$X>$TRpKPR@Rm~(wg7qsoTFI0Qz z_3N|4v1RmUQL8%1%52w$N|oNozU}h{%CW)iZayIf#%cpQN#g@I1d~IXwhmo38tfdM znjYIevBJeT1LN6TTpJkf%w+?WWJ9oGa{B<$Ypgm0Bly`3!Ih&!Qv>7M2S$d1E!#JY zkDdO$?GvLbwuMFG--+M4U& z_y#Ib*xV3o8=AOmhemSIwr!U-;=v@HE6Xr~@v-sIa@gw!&s?zyN6QH~P+4d*o;q6B38k!#}G5H`SXs*(~DgltE~anrFnnYDoA)Li&{>6Bb|8EFMUwH8&T|Q@J_AcXTk* zspoO28hhTi!&(^RsI$$T6Kd>lqS=mcv1uo^5q}m3#c|>F(TtjL17#KwG2>!&V&ccJ zgO&^JF5RqMr?5`T2X1j)Pir`OH9r^5g^i_K_Az=-2V8ONuL^s>smZCQEoCi)SSlY; z{kj=eJ_Etho7hQtt>fFBcC(jRbuc6BpJkdyyK28tWUJNUIg4hZMyFX8ao@Eg7R6U8 z+F5HRlBWO8=eu{>-sISiE>gR~dfq9T;QAsB>+^BSA(>*qjc6vGCo|K?Lt@x#`bsxD z7}B7*Op0-T%ZnYqBD?LzV*j9$xnRNVABG*XPi*U2{iN>R5b<#P6NmODXM}y)l*wSv zMw?sLEbj6q!8OGh-|)n7+IaS!hsD$#PJfo}bEeq9^)S zB=0Zu{i$ zzAnwUyq+0vb=N;3=P#cSa`BA9Ujf<8HSI2dvJeB+Vmgy34+HsOcLg*Y;{(ud;hW9W zuD(>IH?_6vw0kOSWy7cT`0B|VVx!P^PmWHz4ttWzAFkM~iOyKNtS4E*KHMF%_A&sO zd-v+cuYWmrZyu!=)bw1n;)ooS4M^K|>Wg&1w&H>ld#G8LR2;KGdFz7%s_0mk6F1ZT zW`~Od>n+MR`(!wWRu(9%qs;MMuFNQ{q0H+(^(|U?^z7rrm#W~8E-9457S$Jrn!L-J z{=lJo&!aip26f3}EqAEiX}N0?ABmK&tp`8Adu_X6{g2Axnw6gTqI;p^y|Xh79aQ@| z=s}K|?g4I(okc^Gy>a@5Zs@k}{;X{js1q8Lxpop2=RC^n9YnV+ePopdHJeb%aA8JY zPyJcrT*+!2E}r9*;8d{JJ~%J`28$(fDb2ck)23l}e%pmxGSDe&*r?Isc((3Uq#T*Y_L5_Vb?C_1&Ji%hp? za`|R`OS?5+ypQVLqtSj5>5%Kwt+to@3Rx=K*|@@`qTq}@BM*?_4zH+rPxFeJWr zj+b@P>72zXOQjNU|@qDpZD|LjgV-+N{B6bN} O1J;H5-CSmOYyCHo7^5Hn literal 10022 zcmbuDZHyh)S;tRfC!}r*B#j|Jn@*iJiHYyEohG$e$0log?QGX;Z+6!z1a4G;X9y6)#eO3eku!k%B-96h1&I2q_Q=RUkoM6p08yKeV((2>8$< ze*ZIP?wz|{TP}?D+272WInO!gInVQ)bM9-mZ~j%o^=ag7NWR&aKZCE{%0I3zzsZ=p z;a|a9;J4vh;D11Ua?_iQX~H+b-Ea!d!ajTm{vn))--3_nJ(cf=&%y@$BK&dqFP{Ge z-^lZ=KU(U)9ZJv7z&-FWxDP%9Eqoci2j2T*d;>oK)z1s?Ec__E3BCrmz<+}8g5QEv zHMi0DTVVhdCj=~>-%bp+f@7wSvD9<2&<_iC0kKcgz!sp;l_$r)&--q|W zJ-3y1ejGl)^CgHW%?nWW_!Bq-zX_$Uc{_c9d!X7I!n@$pQ1kV>unGShz61U{)VObF zP#Q-HF{$}D)Hpr^WuIS%KLtMze;R(#^Y5VS_-|12u;q@DcR<i4ry-~T3L3e9s+-@oYJ zzXH|XKS0&{XQ+AlHq`fk7L(Ti&z&W^?kJXKhHq*|cMzXu+JJD~E)8k9Xg0+m;ufwI>NkRh5cLFJXNLD}Io zD0_VusvrLY)vucgUXA}PQ1kURD8JYR<-gN#JDh_JSipzi7vRsruR>Ir%|V%8?}n;( z0`7um;1PHQ${)W1e-3^PGWF(;cb55JhvyNvoAM_hCN-ahr{N1w^K%PE(YSAe8pmCp z?}mh=+2J`2<*%na`%wLQ3Tk}Mdj1wvzh3bCBPf4*3CcfThN|}!sP?`N)&A?y`7zY| z`~c!2W)q8+-rogpg7-s}H$7*e^t3&DQ0=8q-(P^T%ac&^_F1TQp7Z0N_r|BC0Y zK*inXJzs><^D5N$ufZemJMca5z3(>W5qJ)2T+e&{G1UCM;`t3IJ>T=(bWhptRw(POde4Jtmb_~*|-jpun^{-S?=8OksI7OK5}gz~5FLXG#9 zd&~E4hi~BdeF(QM_IARP$R$L+dkj&G%bwLG9lB%_U5m)4kh4e=(RH|lTQ7CH>YoMW zo)gG>ktu{UVtR-!#g}q{t|yTXB99=|^$~8E4zq^H2X-Sv>$5@Xg(fCbSc(# zwMsZ&RebJ2&LN}$cm2Gl@G;~H@=3%Z#}QqdkPO+6Xf8-~?!vV0n&baoe=nQs$`Qqp zu4!Zo@_ytOk?OjS8_kujCFB$$AFHkd{-$3Qzz-wmk+=G?55XhIN09<)AiCNm-2ZL- z|0U!DwX%8cQ{O`UHaeyZx(jtgfTT zgNS@{JEH3;WS6@ubMQfbpLkyM+~Ii(TtMXa&mearmyvfOy1FIIqwwR%L&yWjA>?M{ zVMICN0%E3v726J4NxT|$iqz&&5(i?G-0&@RFtPl71sIgRqQQfpK7YM*Dt%8k|-rb#QzGS`7Y z80XTSf{9jd+{1|)gK$2 zxBUob=q7O(#KmBR!u!Hzrx{!dFIvsz*|PNVc3Iz6-&~0DZh(Hdcv?Dx?K4yTK28#@ zMiuC`gI+1xCoNxNv}q9UxKi?l4#;|9SxF7`fb z9`j>1OJR!B1~`;UWO3eK3yRo{6U${Ge!4Wj)HF*Pv?5G-2}cbFLo61pDx#^ZQmYwl zBq<|qnq`@IVjQwU>ALlJ9@VW9i?3Q6b3h&o$+1;C&d~a76a@pj=6XQCR{CTq|B|_J zS!iN#uBWY5ILwu(s{}=mEGx5>S*Ctp8}lelt;+zln)R=%sK=gtsq zb~x;h8>=nt=9+IZGhy9{4Z(F@`D$)5T1x4fIWxM;xOMx1C>-6c8I9kx+;Y%nu{G^5 zYo!rh%am1KJG$}NcKlrWnF$9ZEu$cItBB=BD~VYx$iJ>XT{~bIHr=p4)KujJKsjaA zM&~-OG7}A0{7pY8=gc^baTs;FD@oc-$X&@Wj9uW5x;&W_OnpHy!z9yEP--0|6EkGb z67s*ye4b_ZtB3l>?lU~JRw+~MRW<~{knIm^sGeQ^^c*FE>cyzQRds2E$SHEBsH);d z>mOX{MwyrQlMz*|Hm1t0Mw>;qv6%Fyjf*q)`B1s5@@Kc%R6z^#)!w^DrD!Fda-Met zMT!dt=c9g~ncKn2%oeG(GjzGdB`PvUz)r1<;&G!BN+)U(t2bP6q8C4WC%n^+20r0( zqtbfs#sipWn&M>WjHzgxe8Q5$(Dq0iwSCwqg4`y+Og1svrYn$nRc+c$ZNaPGmwBM} zvP|g{pZH8x|H#L7ttd0)zS|{=$pNzIPL2L;urRpdaVl_+6Y;2$Oq+*uVJmOUwi~B- zCIL+a3)6?@rjO19r)CzHW=|a7vKS7NG;hpjov7V7Sah<+axxWcSvb*H49`cZ(>P3$ zn+nd%OfNR(Pc#ls2aleZnxDP@{U?skY&pjEwXv)PxNq|$oeGYeJa#NNK0QAZJUlZ$ zJ$vkt>BEN?XO@-@@M+vB@ZH98Xa`flvFYPSPpT&mA3OF)KWZjvXZ#I4n4g`W8Fzbk zbJvzbWI5KE#`4;b{^a4seCM!lqj;a&GG}4F|K#$K#@_LF{>0OmiOI>t*;KH1CCaxf z^f?Ol8%NmNWm7>sbdTBo-TQ*_cK@z@Tg-Bpp0n*J=!Kc>TW#ul)hRNIn&y4QfKoPL zZAKNeqh6Fo19Lc9vAON{Y;2aJw(XI;hio~>5uL6b7Fp2q>jgJy7~7im>Lh8kq1mu5 ze))f?J|K~0VHS|jJL;rH67tuwY$JG(1(hs>^(V^p9Vy~Y$9AbaSm96+tQ5UG8Xqj( z5<9eQ8{`;l$jJdiO{y3SL(*7UL@rBbQQ)(1WsgBPi24|_+HTP=>>sg=+5#(A*1fjc zGPZhX_7=W|#vL_nBvYyH*pBUATeIH))*9f@w^0?myfjm3o}rAj#*vOA$2!{_R5=~N zmk0RC`26YhE|sHqhfj_Or)@7Uu&uQB*~+K3*NsvZh!oH2hbvK+!b}=1d$bD^Uk?f< zxXA$}%Ol(~Si!|`T&4)`tZW|Ua=duWP98|HB)W7q*r>XzM69{yuR58!87gbGab(ok3&f>tiaFShjt;6yS(?ZHS9>>5 zy=jhW_RIg{ld5;Sb1pxSx|#P*IY``>9~v;Xn<}|@(ktI!TaL4lbw|^~3~QjcAZU$p z6bZziMiVkDCc?Q(qgUrC9ZDyQ4i>U>c{Ht=xICrKhq<=sW5?t?TbmLs8||VexQ&*6 z`WQ#5LG}8e_hmJObLA=3X4Nq@vkXJtR`z_H7nPDm2UUKha;a}6>4&75Bug;fIUH80 zMw2~0rd~A&4r_Vgf9A4E0BWtQ=3%?QpBk0x(Yrq$|$7rLfN=XPzs-Ilgm>5h`Q|TTbh4# zNcBEj4pTYmYPZF7tQUdp732>6oT%l5L}$%>7%Sfu<@$)bVtRL29lv1~aG9EWjOs{B znJwKwuDPs?b-7izY=z3_7`d5~Hz(Q};3;Zdom_WTosJn6Lu1jd4yKFKk4`VmPR~sr zr|Xj%^mWse=|6=R2u~-*}4pIQ`;5Ffa07*vAtyf_m2yPUG{imQ=Ys95 zjn9!xt}du{1!jDsBPd=~5Oi8}bO!Bl2Bl{@&HDi%KJUuZ2tdX^rVkP^R3)(>us5+8nd{WxrIW8o54qnS8*n#8n)w*ITL??=)|qPF2OXdIOCvp01B% zak>69Zn|!!YDkk``siW@#a`l^HKTYNl_-Dlvr}r#)v@8WS4Jy375&ay2gy-H*6-bv zyXC;g_H_!pmyZ^Z^}A4i\n" "Language-Team: LANGUAGE \n" @@ -62,9 +62,9 @@ msgstr "Sedang dikirim" msgid "Failed" msgstr "Gagal" -#: api/models.py:16 api/models.py:52 api/models.py:71 api/models.py:96 -#: api/models.py:141 api/models.py:154 api/models.py:180 api/models.py:280 -#: api/models.py:321 api/models.py:373 +#: api/models.py:16 api/models.py:52 api/models.py:68 api/models.py:87 +#: api/models.py:112 api/models.py:157 api/models.py:170 api/models.py:196 +#: api/models.py:316 api/models.py:350 api/models.py:403 msgid "ID" msgstr "ID" @@ -84,15 +84,15 @@ msgstr "alamat" msgid "neighborhood" msgstr "RT" -#: api/models.py:30 api/models.py:479 +#: api/models.py:30 api/models.py:520 msgid "hamlet" msgstr "RW" -#: api/models.py:32 api/models.py:483 +#: api/models.py:32 api/models.py:524 msgid "urban village" msgstr "kelurahan" -#: api/models.py:33 api/models.py:487 +#: api/models.py:33 api/models.py:528 msgid "sub-district" msgstr "kecamatan" @@ -104,7 +104,7 @@ msgstr "foto profil" msgid "OTP" msgstr "OTP" -#: api/models.py:44 api/models.py:142 api/models.py:192 api/models.py:385 +#: api/models.py:44 api/models.py:158 api/models.py:208 api/models.py:415 msgid "user" msgstr "pengguna" @@ -112,301 +112,513 @@ msgstr "pengguna" msgid "users" msgstr "pengguna" -#: api/models.py:53 api/models.py:72 api/models.py:103 api/models.py:328 +#: api/models.py:53 +msgid "bank name" +msgstr "nama bank" + +#: api/models.py:54 +msgid "bank account number" +msgstr "nomor akun bank" + +#: api/models.py:55 +msgid "bank account name" +msgstr "nama akun bank" + +#: api/models.py:60 api/models.py:279 api/models.py:458 +msgid "bank account transfer destination" +msgstr "tujuan transfer rekening bank" + +#: api/models.py:61 +msgid "bank account transfer destinations" +msgstr "tujuan transfer rekening bank" + +#: api/models.py:69 api/models.py:88 api/models.py:119 api/models.py:357 msgid "name" msgstr "nama" -#: api/models.py:58 api/models.py:83 api/models.py:123 +#: api/models.py:74 api/models.py:99 api/models.py:139 msgid "image" msgstr "gambar" -#: api/models.py:63 api/models.py:77 +#: api/models.py:79 api/models.py:93 msgid "category" msgstr "kategori" -#: api/models.py:64 +#: api/models.py:80 msgid "categories" msgstr "kategori" -#: api/models.py:88 api/models.py:108 +#: api/models.py:104 api/models.py:124 msgid "subcategory" msgstr "subkategori" -#: api/models.py:89 +#: api/models.py:105 msgid "subcategories" msgstr "subkategori" -#: api/models.py:101 api/models.py:326 +#: api/models.py:117 api/models.py:355 msgid "code" msgstr "kode" -#: api/models.py:110 api/models.py:329 +#: api/models.py:126 api/models.py:358 msgid "description" msgstr "deskripsi" -#: api/models.py:115 +#: api/models.py:131 msgid "price" msgstr "deskripsi" -#: api/models.py:117 +#: api/models.py:133 msgid "stock" msgstr "stok" -#: api/models.py:118 +#: api/models.py:134 msgid "pre-order" msgstr "pre-order" -#: api/models.py:128 api/models.py:165 api/models.py:292 +#: api/models.py:144 api/models.py:181 api/models.py:328 msgid "product" msgstr "produk" -#: api/models.py:129 +#: api/models.py:145 msgid "products" msgstr "produk" -#: api/models.py:146 api/models.py:159 +#: api/models.py:162 api/models.py:175 msgid "shopping cart" msgstr "keranjang belanja" -#: api/models.py:147 +#: api/models.py:163 msgid "shopping carts" msgstr "keranjang belanja" -#: api/models.py:167 api/models.py:302 +#: api/models.py:183 api/models.py:338 msgid "quantity" msgstr "kuantitas" -#: api/models.py:172 +#: api/models.py:188 msgid "cart item" msgstr "barang keranjang" -#: api/models.py:173 +#: api/models.py:189 msgid "cart items" msgstr "barang keranjang" -#: api/models.py:185 +#: api/models.py:201 msgid "transaction number" msgstr "nomor transaksi" -#: api/models.py:194 api/models.py:394 +#: api/models.py:210 api/models.py:424 msgid "user full name" msgstr "nama lengkap pengguna" -#: api/models.py:195 api/models.py:395 +#: api/models.py:211 api/models.py:425 msgid "user phone number" msgstr "nomor telepon pengguna" -#: api/models.py:196 +#: api/models.py:212 msgid "shipping address" msgstr "alamat pengiriman" -#: api/models.py:200 +#: api/models.py:216 msgid "shipping neighborhood" msgstr "RT pengiriman" -#: api/models.py:205 +#: api/models.py:221 msgid "shipping hamlet" msgstr "RW pengiriman" -#: api/models.py:209 +#: api/models.py:225 msgid "shipping urban village" msgstr "kelurahan pengiriman" -#: api/models.py:213 +#: api/models.py:229 msgid "shipping sub-district" msgstr "kecamatan pengiriman" -#: api/models.py:219 +#: api/models.py:237 msgid "shipping costs" msgstr "biaya pengiriman" -#: api/models.py:224 +#: api/models.py:242 msgid "payment method" msgstr "metode pembayaran" -#: api/models.py:231 +#: api/models.py:249 msgid "donation" msgstr "donasi" -#: api/models.py:236 +#: api/models.py:254 msgid "transaction status" msgstr "status transaksi" -#: api/models.py:242 +#: api/models.py:260 msgid "proof of payment" msgstr "bukti pembayaran" -#: api/models.py:248 api/models.py:415 +#: api/models.py:266 api/models.py:447 +msgid "user bank name" +msgstr "nama bank pengguna" + +#: api/models.py:272 api/models.py:451 msgid "user bank account name" msgstr "nama akun bank pengguna" -#: api/models.py:253 api/models.py:420 +#: api/models.py:285 api/models.py:464 +msgid "transfer destination bank name" +msgstr "nama bank tujuan transfer" + +#: api/models.py:291 api/models.py:470 +msgid "transfer destination bank account name" +msgstr "nama akun bank tujuan transfer" + +#: api/models.py:297 api/models.py:476 +msgid "transfer destination bank account number" +msgstr "nomor akun bank tujuan transfer" + +#: api/models.py:302 api/models.py:481 msgid "created at" msgstr "dibuat pada" -#: api/models.py:255 api/models.py:422 +#: api/models.py:304 api/models.py:483 msgid "updated at" msgstr "diperbarui pada" -#: api/models.py:259 api/models.py:285 +#: api/models.py:308 api/models.py:321 msgid "transaction" msgstr "transaksi" -#: api/models.py:260 +#: api/models.py:309 msgid "transactions" msgstr "transaksi" -#: api/models.py:294 +#: api/models.py:330 msgid "product name" msgstr "nama produk" -#: api/models.py:299 +#: api/models.py:335 msgid "product price" msgstr "harga produk" -#: api/models.py:301 +#: api/models.py:337 msgid "product pre-order" msgstr "produk pre-order" -#: api/models.py:306 +#: api/models.py:342 msgid "transaction item" msgstr "barang transaksi" -#: api/models.py:307 +#: api/models.py:343 msgid "transaction items" msgstr "barang transaksi" -#: api/models.py:334 +#: api/models.py:363 msgid "start date and time" msgstr "tanggal dan waktu mulai" -#: api/models.py:339 +#: api/models.py:368 msgid "end date and time" msgstr "tanggal dan waktu berakhir" -#: api/models.py:345 +#: api/models.py:374 msgid "location" msgstr "lokasi" -#: api/models.py:347 +#: api/models.py:376 msgid "speaker" msgstr "pembicara" -#: api/models.py:348 +#: api/models.py:377 msgid "open for donation" msgstr "terbuka untuk sumbangan" -#: api/models.py:354 +#: api/models.py:383 msgid "program minutes" msgstr "notulensi program" -#: api/models.py:360 +#: api/models.py:389 msgid "poster image" msgstr "gambar poster" -#: api/models.py:365 api/models.py:392 +#: api/models.py:391 +msgid "link" +msgstr "tautan" + +#: api/models.py:395 api/models.py:422 msgid "program" msgstr "program" -#: api/models.py:366 +#: api/models.py:396 msgid "programs" msgstr "program" -#: api/models.py:378 +#: api/models.py:408 msgid "donation number" msgstr "nomor donasi" -#: api/models.py:396 +#: api/models.py:426 msgid "program name" msgstr "nama program" -#: api/models.py:401 +#: api/models.py:431 msgid "amount" msgstr "jumlah" -#: api/models.py:407 +#: api/models.py:437 msgid "donation status" msgstr "status donasi" -#: api/models.py:411 +#: api/models.py:441 msgid "proof of bank transfer" msgstr "bukti transfer bank" -#: api/models.py:426 +#: api/models.py:487 msgid "program donation" msgstr "donasi program" -#: api/models.py:427 +#: api/models.py:488 msgid "program donations" msgstr "donasi program" -#: api/models.py:441 +#: api/models.py:495 msgid "send SMS" msgstr "kirim SMS" -#: api/models.py:444 +#: api/models.py:498 msgid "application configuration" msgstr "konfigurasi aplikasi" -#: api/models.py:445 +#: api/models.py:499 msgid "application configurations" msgstr "konfigurasi aplikasi" -#: api/models.py:452 -msgid "bank name" -msgstr "nama bank" - -#: api/models.py:453 -msgid "account number" -msgstr "nomor akun" - -#: api/models.py:454 -msgid "account owner" -msgstr "pemilik akun" - -#: api/models.py:457 -msgid "bank account configuration" -msgstr "konfigurasi akun bank" - -#: api/models.py:458 -msgid "bank account configurations" -msgstr "konfigurasi akun bank" - -#: api/models.py:465 +#: api/models.py:506 msgid "email" msgstr "email" -#: api/models.py:468 +#: api/models.py:509 msgid "help contact configuration" msgstr "konfigurasi kontak bantuan" -#: api/models.py:469 +#: api/models.py:510 msgid "help contact configurations" msgstr "konfigurasi kontak bantuan" -#: api/models.py:495 +#: api/models.py:536 msgid "" "shipping costs if the hamlet, urban village, and sub-district are the same " "as seller" msgstr "biaya pengiriman jika RW, kelurahan, dan kecamatan sama dengan penjual" -#: api/models.py:504 +#: api/models.py:545 msgid "" "shipping costs if the urban village and sub-district are the same as seller" msgstr "biaya pengirima jika kelurahan dan kecamatan sama dengan penjual" -#: api/models.py:512 +#: api/models.py:553 msgid "shipping costs if the sub-district is the same as seller" msgstr "biaya pengiriman jika kecamatan sama dengan penjual" -#: api/models.py:516 +#: api/models.py:557 msgid "shipment configuration" msgstr "konfigurasi pengiriman" -#: api/models.py:517 +#: api/models.py:558 msgid "shipment configurations" msgstr "konfigurasi pengiriman" +#: api/reports_writer.py:188 +msgid "Program Donation Report" +msgstr "Laporan Donasi Program" + +#: api/reports_writer.py:207 +msgid "Program Donations" +msgstr "Donasi Program" + +#: api/reports_writer.py:209 +msgid "Donation Number" +msgstr "Nomor Donasi" + +#: api/reports_writer.py:210 api/reports_writer.py:284 +msgid "User Username" +msgstr "Username Pengguna" + +#: api/reports_writer.py:211 api/reports_writer.py:238 +msgid "Program Code" +msgstr "Kode Program" + +#: api/reports_writer.py:212 api/reports_writer.py:285 +msgid "User Full Name" +msgstr "Nama Lengkap Pengguna" + +#: api/reports_writer.py:213 api/reports_writer.py:286 +msgid "User Phone Number" +msgstr "Nomor Telepon Pengguna" + +#: api/reports_writer.py:214 api/reports_writer.py:239 +msgid "Program Name" +msgstr "Nama Program" + +#: api/reports_writer.py:215 api/serializers.py:63 api/serializers.py:85 +msgid "Amount" +msgstr "Jumlah" + +#: api/reports_writer.py:216 +msgid "Donation Status" +msgstr "Status Donasi" + +#: api/reports_writer.py:217 api/reports_writer.py:297 +msgid "User Bank Name" +msgstr "Nama Bank Pengguna" + +#: api/reports_writer.py:218 api/reports_writer.py:298 +msgid "User Bank Account Name" +msgstr "Nama Akun Bank Pengguna" + +#: api/reports_writer.py:219 api/reports_writer.py:299 +msgid "Transfer Destination Bank Name" +msgstr "Nama Bank Tujuan Transfer" + +#: api/reports_writer.py:220 api/reports_writer.py:300 +msgid "Transfer Destination Bank Account Name" +msgstr "Nama Akun Bank Tujuan Transfer" + +#: api/reports_writer.py:223 api/reports_writer.py:303 +msgid "Transfer Destination Bank Account Number" +msgstr "Nomor Akun Bank Tujuan Transfer" + +#: api/reports_writer.py:225 api/reports_writer.py:305 +msgid "Created at" +msgstr "Dibuat pada" + +#: api/reports_writer.py:226 api/reports_writer.py:306 +msgid "Updated at" +msgstr "Diperbarui pada" + +#: api/reports_writer.py:236 +msgid "Program Summary" +msgstr "Ringkasan Program" + +#: api/reports_writer.py:240 +msgid "Open for Donation" +msgstr "Terbuka untuk Donasi" + +#: api/reports_writer.py:241 +msgid "Total Donation Amount" +msgstr "Jumlah Total Donasi" + +#: api/reports_writer.py:252 +msgid "" +"NB: This program summary only shows programs that have not been deleted. It " +"also not affected by date filtering." +msgstr "" +"NB: Ringkasan program ini hanya menampilkan program yang belum dihapus. " +"Ringkasan ini juga tidak terpengaruh oleh penyaringan tanggal." + +#: api/reports_writer.py:262 +msgid "Transaction Report" +msgstr "Laporan Transaksi" + +#: api/reports_writer.py:281 +msgid "Transactions" +msgstr "Transaksi" + +#: api/reports_writer.py:283 api/reports_writer.py:323 +msgid "Transaction Number" +msgstr "Nomor Transaksi" + +#: api/reports_writer.py:287 +msgid "Shpping Address" +msgstr "Alamat Pengiriman" + +#: api/reports_writer.py:288 +msgid "Shipping Neighborhood" +msgstr "RT Pengiriman" + +#: api/reports_writer.py:289 +msgid "Shipping Hamlet" +msgstr "RW Pengiriman" + +#: api/reports_writer.py:290 +msgid "Shipping Urban Village" +msgstr "Kelurahan Pengiriman" + +#: api/reports_writer.py:291 +msgid "Shipping Sub-District" +msgstr "Kecamatan Pengiriman" + +#: api/reports_writer.py:292 +msgid "Transaction Item Subtotal" +msgstr "Subtotal Barang Transaksi" + +#: api/reports_writer.py:293 +msgid "Shipping Costs" +msgstr "Biaya Pengiriman" + +#: api/reports_writer.py:294 +msgid "Payment Method" +msgstr "Metode Pembayaran" + +#: api/reports_writer.py:295 api/serializers.py:33 +msgid "Donation" +msgstr "Donasi" + +#: api/reports_writer.py:296 +msgid "Transaction Status" +msgstr "Status Transaksi" + +#: api/reports_writer.py:321 +msgid "Transaction Items" +msgstr "Barang Transaksi" + +#: api/reports_writer.py:324 api/reports_writer.py:340 +msgid "Product Name" +msgstr "Nama Produk" + +#: api/reports_writer.py:325 +msgid "Product Price" +msgstr "Harga Produk" + +#: api/reports_writer.py:326 +msgid "Product Pre-Order" +msgstr "Produk Pre-Order" + +#: api/reports_writer.py:327 api/serializers.py:22 +msgid "Quantity" +msgstr "Kuantitas" + +#: api/reports_writer.py:337 +msgid "Product Order Summary" +msgstr "Ringkasan Pesanan Produk" + +#: api/reports_writer.py:339 +msgid "Product Code" +msgstr "Kode Produk" + +#: api/reports_writer.py:341 +msgid "Sold" +msgstr "Terjual" + +#: api/reports_writer.py:351 +msgid "" +"NB: This product order summary only shows products that have not been " +"deleted. It also not affected by date filtering." +msgstr "" +"NB: Ringkasan pesanan produk ini hanya menampilkan produk yang belum " +"dihapus. Ringkasan ini juga tidak terpengaruh oleh penyaringan tanggal." + +#: api/reports_writer.py:354 +msgid "Transaction Summary" +msgstr "Ringkasan Transaksi" + +#: api/reports_writer.py:356 +msgid "Revenue (Transaction)" +msgstr "Pendapatan (Transaksi)" + +#: api/reports_writer.py:369 +msgid "Donation (Transaction)" +msgstr "Donasi (Transaksi)" + #: api/serializers.py:13 msgid "Phone number" msgstr "Nomor telepon" @@ -415,19 +627,11 @@ msgstr "Nomor telepon" msgid "Product" msgstr "Produk" -#: api/serializers.py:22 -msgid "Quantity" -msgstr "Kuantitas" - #: api/serializers.py:28 msgid "Payment method" msgstr "Metode pembayaran" -#: api/serializers.py:33 -msgid "Donation" -msgstr "Donasi" - -#: api/serializers.py:40 api/serializers.py:49 +#: api/serializers.py:40 api/serializers.py:56 msgid "Transaction" msgstr "Transaksi" @@ -435,35 +639,47 @@ msgstr "Transaksi" msgid "Proof of payment" msgstr "Bukti pembayaran" -#: api/serializers.py:43 api/serializers.py:62 api/serializers.py:77 +#: api/serializers.py:43 api/serializers.py:69 api/serializers.py:91 +msgid "User bank name" +msgstr "Nama bank pengguna" + +#: api/serializers.py:47 api/serializers.py:73 api/serializers.py:95 msgid "User bank account name" msgstr "Nama akun bank pengguna" -#: api/serializers.py:53 +#: api/serializers.py:51 api/serializers.py:77 api/serializers.py:99 +msgid "Bank account transfer destination" +msgstr "Tujuan transfer rekening bank" + +#: api/serializers.py:60 msgid "Program" msgstr "Program" -#: api/serializers.py:56 api/serializers.py:71 -msgid "Amount" -msgstr "Jumlah" - -#: api/serializers.py:60 api/serializers.py:75 +#: api/serializers.py:67 api/serializers.py:89 msgid "Proof of bank transfer" msgstr "Bukti transfer bank" -#: api/serializers.py:68 +#: api/serializers.py:82 msgid "Program donasi" msgstr "Donasi program" -#: api/serializers.py:190 +#: api/serializers.py:105 api/serializers.py:116 +msgid "Created after date" +msgstr "Dibuat setelah tanggal" + +#: api/serializers.py:109 api/serializers.py:120 +msgid "Created before date" +msgstr "Dibuat sebelum tanggal" + +#: api/serializers.py:240 msgid "Stock cannot be empty if it is not a pre-order." msgstr "Stok tidak boleh kosong jika bukan pre-order." -#: api/serializers.py:326 +#: api/serializers.py:386 msgid "Cannot update transaction status to failed." msgstr "Tidak dapat memperbarui status transaksi ke gagal." -#: api/serializers.py:366 +#: api/serializers.py:429 msgid "End date time should be greater than start date time." msgstr "Waktu tanggal berakhir harus lebih besar dari waktu tanggal mulai." @@ -491,55 +707,55 @@ msgid "" msgstr "" "Gagal checkout karena jumlah barang yang dibeli melebihi stok yang tersedia." -#: api/views.py:160 +#: api/views.py:161 #, python-brace-format msgid "" "Cannot process shipment to other sub-districts other than {sub_district}." msgstr "" "Tidak dapat memproses pengiriman ke kecamatan lain selain {sub_district}." -#: api/views.py:166 +#: api/views.py:167 msgid "Unable to checkout because there are no items purchased." msgstr "Tidak dapat checkout karena tidak ada barang yang dibeli." -#: api/views.py:193 +#: api/views.py:198 msgid "Checkout failed." msgstr "Checkout gagal." -#: api/views.py:215 +#: api/views.py:224 msgid "The payment method for this transaction is not a transfer." msgstr "Metode pembayaran untuk transaksi ini bukan transfer." -#: api/views.py:219 +#: api/views.py:228 msgid "Cannot upload proof of payment at this stage." msgstr "Tidak dapat mengunggah bukti pembayaran pada tahap ini." -#: api/views.py:246 +#: api/views.py:258 msgid "Transaction cannot be completed unless the status is \"Being shipped\"." msgstr "" "Transaksi tidak dapat diselesaikan kecuali statusnya \"Sedang dikirim\"." -#: api/views.py:271 +#: api/views.py:283 msgid "Transaction cannot be canceled at this stage." msgstr "Transaksi tidak dapat dibatalkan pada tahap ini." -#: api/views.py:297 +#: api/views.py:313 msgid "This program is currently not accepting donations." msgstr "Program ini saat ini tidak menerima donasi." -#: api/views.py:330 +#: api/views.py:352 msgid "Cannot reupload proof of bank transfer at this stage." msgstr "Tidak dapat mengunggah kembali bukti transfer bank pada tahap ini." -#: api/views.py:405 +#: api/views.py:485 msgid "Cannot delete category due to integrity error." msgstr "Tidak dapat menghapus kategori karena kesalahan integritas." -#: api/views.py:442 +#: api/views.py:522 msgid "Cannot delete subcategory due to integrity error." msgstr "Tidak dapat menghapus subkategori karena kesalahan integritas." -#: api/views.py:533 +#: api/views.py:613 msgid "" "Cannot update transaction because it has a completed, canceled, or failed " "status." @@ -547,7 +763,7 @@ msgstr "" "Tidak dapat memperbarui transaksi karena memiliki status selesai, " "dibatalkan, atau gagal." -#: api/views.py:604 +#: api/views.py:684 msgid "" "Cannot update program donation because it has a completed or canceled status." msgstr "" @@ -563,34 +779,34 @@ msgstr "Terjadi kesalahan konfigurasi." msgid "Server is currently unable to send SMS." msgstr "Server saat ini tidak dapat mengirim SMS." -msgid "Not a valid string." -msgstr "Bukan string yang valid." +#~ msgid "Not a valid string." +#~ msgstr "Bukan string yang valid." -msgid "This field may not be blank." -msgstr "Bidang ini tidak boleh kosong." +#~ msgid "This field may not be blank." +#~ msgstr "Bidang ini tidak boleh kosong." #, python-brace-format -msgid "Ensure this field has no more than {max_length} characters." -msgstr "Pastikan bidang ini tidak lebih dari {max_length} karakter." +#~ msgid "Ensure this field has no more than {max_length} characters." +#~ msgstr "Pastikan bidang ini tidak lebih dari {max_length} karakter." #, python-brace-format -msgid "Ensure this field has at least {min_length} characters." -msgstr "Pastikan bidang ini memiliki setidaknya {min_length} karakter." +#~ msgid "Ensure this field has at least {min_length} characters." +#~ msgstr "Pastikan bidang ini memiliki setidaknya {min_length} karakter." #, python-brace-format -msgid "" -"Enter a valid phone number (e.g. {example_number}) or a number with an " -"international call prefix." -msgstr "" -"Masukkan nomor telepon yang valid (misalnya {example_number}) atau nomor " -"dengan awalan panggilan internasional." +#~ msgid "" +#~ "Enter a valid phone number (e.g. {example_number}) or a number with an " +#~ "international call prefix." +#~ msgstr "" +#~ "Masukkan nomor telepon yang valid (misalnya {example_number}) atau nomor " +#~ "dengan awalan panggilan internasional." #, python-brace-format -msgid "Enter a valid phone number (e.g. {example_number})." -msgstr "Masukkan nomor telepon yang valid (misalnya {example_number})." +#~ msgid "Enter a valid phone number (e.g. {example_number})." +#~ msgstr "Masukkan nomor telepon yang valid (misalnya {example_number})." -msgid "Enter a valid phone number." -msgstr "Masukkan nomor telepon yang valid." +#~ msgid "Enter a valid phone number." +#~ msgstr "Masukkan nomor telepon yang valid." -msgid "The phone number entered is not valid." -msgstr "Nomor telepon yang dimasukkan tidak valid." +#~ msgid "The phone number entered is not valid." +#~ msgstr "Nomor telepon yang dimasukkan tidak valid." diff --git a/requirements.txt b/requirements.txt index 2ddef89..16785a6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,7 +11,7 @@ coreschema==0.0.4 coverage==5.0.3 cryptography==2.8 dj-database-url==0.5.0 -Django==3.0.3 +Django==3.0.7 django-cleanup==4.0.0 django-cors-headers==3.2.1 django-filter==2.2.0 @@ -52,3 +52,4 @@ typed-ast==1.4.1 uritemplate==3.0.1 urllib3==1.25.8 wrapt==1.11.2 +XlsxWriter==1.2.8 diff --git a/sonar-project.properties b/sonar-project.properties index 92086b7..43bcb24 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,8 +1,12 @@ -sonar.exclusions=**/schemas.py,**/seeds.py,**/tests.py,**/management/**,**/migrations/** +sonar.exclusions=**/schemas.py,**/seeds.py,**signals.py,**/tests.py,**/migrations/** sonar.python.coverage.reportPaths=coverage.xml sonar.scm.provider=git sonar.sourceEncoding=UTF-8 sonar.sources=api -sonar.issue.ignore.multicriteria=p1 -sonar.issue.ignore.multicriteria.p1.ruleKey=python:S1192 +sonar.issue.ignore.multicriteria=p1,p2,p3 +sonar.issue.ignore.multicriteria.p1.ruleKey=python:S107 sonar.issue.ignore.multicriteria.p1.resourceKey=**/*.py +sonar.issue.ignore.multicriteria.p2.ruleKey=python:S1192 +sonar.issue.ignore.multicriteria.p2.resourceKey=**/*.py +sonar.issue.ignore.multicriteria.p3.ruleKey=python:S3776 +sonar.issue.ignore.multicriteria.p3.resourceKey=**/*.py -- GitLab From 6c4c5a335a6803ebc345c373160e25f4e36d397c Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Thu, 4 Jun 2020 07:20:06 +0700 Subject: [PATCH 76/90] [CHORES] Update django.po --- locale/id/LC_MESSAGES/django.mo | Bin 12651 -> 13617 bytes locale/id/LC_MESSAGES/django.po | 134 ++++++++++++++++---------------- 2 files changed, 67 insertions(+), 67 deletions(-) diff --git a/locale/id/LC_MESSAGES/django.mo b/locale/id/LC_MESSAGES/django.mo index cd88a0b6cd37dd1661012b0564214f240a6a0410..b057ef14e33c810251c90a622433d5dc2dcbba16 100644 GIT binary patch delta 4987 zcma*q3vd+m0mt!wc!x(q03m?2E5wi#xdaf)TN?x<$Rjo)Sb^>(m)vrfy^y;=c)3XB zQM5>Th(N7YDOeO*2m)4xIo zd$A`T!dyIVJ&kJkL;N-t@X#oH7+riChtj_JghF2~gnDE~SdMc!ufSHg57qDiEWo3f zgCF1myoB{Q@1{)0PGM`#KSIW4E}`ykOD|;))a3TZ_Ox$G?S%m2!l(-7qrQLC zx&>9y9{c@U*oN~HSb~4Uk(ixtOd;Nb+EXzcf@@Iqy@s9ePx;J$Zwl{oK_mDWSuT^! zMR~Ke1X(3hhT1DLa6CSSnu&K%Bl;_<;WJo-?fV#W3zp(=oQ7J;HK>7X?8E$P#Jjm5 zUqyA~_oylVyS?$e{r+=Qg*j{(jcgExupGaMi%@%KD{84Oqu!bA2GrU;XMGvfp~I+={sHw?oU(q18gUjIN4q`;i*Oh+1~Uh>XO^Mr zUxQl0r%)Z;j)8i(pMrc7)zG`B9{vMIWf^lGRbc^t2((AWA**G|QO`wD4K2h`xB|!G z5uAjdVHr*=;MK*oI1A4fu>SgC7hW>Z44t#*7f~JR!0Ics zAHXLtimUM7$ZwcgTAcYCuo?ODV78)WcCYm}s1CjtP-sWt3~FTmv|d70^f_t|REL^y5H81IxEHC)e1O_JxwohP-kT7rBM%`R2#ilbKaEYOo^3$wffrB> zG~4UHLp^vBOYvVg0sGRbMiRyj7)LeOh`ga@6RQ3LI0BEO>hBQJYtQ=UQ_xzLqTc6m zs2Q1s>Oei}_hALOpq6&EbscI3H(PgOj^6*H6ms!6 z>a{v;y@DE9ABL-$DXNyY9fh2au#n=T`qc-_w4D?&S-QI8z zwcCG-y8gEHEUKa_*47MH=N(ZE=b@hKiJGb2n1_Q=4UV%`pgK0!>J4W8(>>&ZdcGLD z;wn^+x1he?VZVO~wb>3~4j#20$2`v8M?Lou>b@(eJ=11Lrrvy12a2sjhcN$I>oHuA zm8gnhsE#bd3Ahf2;$QG?%;rQRE4R)>t!<5U396p;*5^?TA3)8}5!B3`4D1)DP&07O zdeL704AqhLci0i2_DZomAAzd49JT8!QM*2g+N2v%_wPa7_XcY397A;^c$Y#q3g=O~ z|5MaVg#vmOMwECz@&<`t8@Tl{Awn zdjF?Vm`^m?a-yS%Xcn$Lwp@Ec`8C2TpWYAps+b&7fnx;ONM@2JiBDc9_mJ^q7SVB& z-v4^?19F61d#tf16(we+LQe*?s+o3n0<>*9Vi9K0|djGXg zf>-$9cP9P+e{(THenhmptBC#xwH)_R_&M1@w3(hJP2_g+AeljilaAy?vYXsObo?-b z`2`Lpzb0X=f6K9-LXADC#KpF}65k;D9r&@mrZ?hYGM$u?<>Xi7XGF(ctMW}Y;1@)si>E9YP?9i+KIYJ$4xo$ zh?`6~OX@s-Zam`GrlQN7s;JxGR;3~h$&k76bAH09OQfrFeV%oj{?D`2;keEMHy(J^ zPJJ}tM;yPgF3i=UNT@dCEQvI^b@lPc+|1Qw#ZE#G*n1Xwsi;E(o=-i#oAMIA8+WSQ zc-*OPh}3vZ>fZmF_{tUvuQu^j4>hmtwlOPbY9f{CHB}~4&AoceZgqF$`tC>6Zlu~_ zHhu19gkE*XR9>CkI(Jd}n{dPl$6Y_xT;KDPF2Qu>+KVxyuvhK+wT#Sj>LPVs+>3cm zGLqtI%wObQzZ@}EBSv!}bEC)8&#;PNFX~jg4c_(3arN0LZn80msck>O+BvC6JW`+V zoki+Ly8lj5otJdu>Gr?0E^f+gv`<#EO{m6Q=rS;;p66>lmFzMl)5{bxYVZr~P5Jr_ z-thVBA8(Ep?91v^)~GQhQw<*DxH_2TUWFB{?w$JOp;mi!Egj2Don5=Kt1FVZ9!|s~ RQ74v2Cj8pwnxeMZ{{^#TsL22T delta 4028 zcmYk<2~bs49LMp)0}&Mz7eq88S0oW}0bG(4mqbMq6}1I($=$$xNqrPrE@X;Z7N)sn zE~z|R#>~+s%Uoh=WTwe<#?f-p(OhzCnfm_T``37e|NESK?s;ds=Uial`0FFQ7i)y9 zcBF&E6U4a+&i#pnRrnyys^(lHoQ(bIqOex*ResC|H8rbBD}tuoBllVhy~4mGJ@AL=T1PxHi_tSfp>4V)j6FoQ)cI zAr8Zp=)t>Knf~2icU@E4Ljh(!V=EMhzUrXgrOoc-hMDp=Rcx6?I$#HRA@T6>EoDi45e&W$_V* zV^Q^&qrSfZ`Eh&96X;b#=gFwT9SlJag)OlncED6@jZ=^+ZYyege1lkDaT>|P+cgF7cS~TmgnU_+a863x^cnX{0ZF~~z)?t>|9<>LiAbq%bs2Q#@ zH=_npf?DD)QT4vJ`@f>vdx+ZX(J`!lBAK>KBNj*EFf2el%gd&g<9Hjt8fYRXs(%mAScvSt``_U zB&wr^sDUORRa_h7$Mxky^~a<7$wPHK-LAd!$>twX+A7V3%What#JyDxx461`E zsArgu8t6jQ0E^9?sE!V!26zTL2C@55?Nw!mYLB!)=Iq*{R}Ey5QAfkEBTmFL+>RM| z71Oa5TRR)4;ZQt{dM`BL7ep!!K`rfab0ccy_M!%S2(?0Gc747P>#q^rp+HOX2(_f) zjRP}|L3JF5{#}fHxXwclevYbt4Eb?q`A|D|un;RWaqcZF!eESHnR%JI`lyv^-P9Y% zbfZ8^oQ4U#Zqy79+w~WynUUi};HIRkzIL_{`L=A9@S%R9$F|!=i!8NRie_;y@VM8TjYiy2VQ5~;B_2=DA zMsKzf)IiRl|9GGpykX`4puQN%sFJWT_P{}?hKex^x1l!M9%SFSZ&B^v#P%5WbfEoo z=cvGI21D!%#eKoij<@ui$~QMcO+`emqHzZuLfOm;)#1ca3 z6=D(LoyLdKY+|NA7kFi+TK+@yZ!XLvc3OE?>_f~adRm#!6giLOUn1X&c)`k6qaN2Y zI{*A`aU<=p_bPs(er$f*kffPlE}5|&bW)1OuSAUAe8hnQtCpiAn3~f z=H$@$Pg_;=7xMn`|NEtDZ4#wCBAIxK*h?r)B;N7o_|qAe6WTLu5jTlwLU2(0DaDM( zxt3pv%ZT1~Exbj1JZA-+a49jK=tcw+O792o|BW!f^7C-9<@aMfm?}ELg`sT?}l;2 zn?y13UmB}p5KRmuJ|J2WX}*Za=wM&3S}THlzeNr9_?FbUAK^P2w0gE%Z7cu) diff --git a/locale/id/LC_MESSAGES/django.po b/locale/id/LC_MESSAGES/django.po index 8692da2..6374b59 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-06-04 06:47+0700\n" +"POT-Creation-Date: 2020-06-04 07:19+0700\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -427,87 +427,87 @@ msgstr "konfigurasi pengiriman" msgid "shipment configurations" msgstr "konfigurasi pengiriman" -#: api/reports_writer.py:188 +#: api/reports_writer.py:185 msgid "Program Donation Report" msgstr "Laporan Donasi Program" -#: api/reports_writer.py:207 +#: api/reports_writer.py:204 msgid "Program Donations" msgstr "Donasi Program" -#: api/reports_writer.py:209 +#: api/reports_writer.py:206 msgid "Donation Number" msgstr "Nomor Donasi" -#: api/reports_writer.py:210 api/reports_writer.py:284 +#: api/reports_writer.py:207 api/reports_writer.py:281 msgid "User Username" msgstr "Username Pengguna" -#: api/reports_writer.py:211 api/reports_writer.py:238 +#: api/reports_writer.py:208 api/reports_writer.py:235 msgid "Program Code" msgstr "Kode Program" -#: api/reports_writer.py:212 api/reports_writer.py:285 +#: api/reports_writer.py:209 api/reports_writer.py:282 msgid "User Full Name" msgstr "Nama Lengkap Pengguna" -#: api/reports_writer.py:213 api/reports_writer.py:286 +#: api/reports_writer.py:210 api/reports_writer.py:283 msgid "User Phone Number" msgstr "Nomor Telepon Pengguna" -#: api/reports_writer.py:214 api/reports_writer.py:239 +#: api/reports_writer.py:211 api/reports_writer.py:236 msgid "Program Name" msgstr "Nama Program" -#: api/reports_writer.py:215 api/serializers.py:63 api/serializers.py:85 +#: api/reports_writer.py:212 api/serializers.py:63 api/serializers.py:85 msgid "Amount" msgstr "Jumlah" -#: api/reports_writer.py:216 +#: api/reports_writer.py:213 msgid "Donation Status" msgstr "Status Donasi" -#: api/reports_writer.py:217 api/reports_writer.py:297 +#: api/reports_writer.py:214 api/reports_writer.py:294 msgid "User Bank Name" msgstr "Nama Bank Pengguna" -#: api/reports_writer.py:218 api/reports_writer.py:298 +#: api/reports_writer.py:215 api/reports_writer.py:295 msgid "User Bank Account Name" msgstr "Nama Akun Bank Pengguna" -#: api/reports_writer.py:219 api/reports_writer.py:299 +#: api/reports_writer.py:216 api/reports_writer.py:296 msgid "Transfer Destination Bank Name" msgstr "Nama Bank Tujuan Transfer" -#: api/reports_writer.py:220 api/reports_writer.py:300 +#: api/reports_writer.py:217 api/reports_writer.py:297 msgid "Transfer Destination Bank Account Name" msgstr "Nama Akun Bank Tujuan Transfer" -#: api/reports_writer.py:223 api/reports_writer.py:303 +#: api/reports_writer.py:220 api/reports_writer.py:300 msgid "Transfer Destination Bank Account Number" msgstr "Nomor Akun Bank Tujuan Transfer" -#: api/reports_writer.py:225 api/reports_writer.py:305 +#: api/reports_writer.py:222 api/reports_writer.py:302 msgid "Created at" msgstr "Dibuat pada" -#: api/reports_writer.py:226 api/reports_writer.py:306 +#: api/reports_writer.py:223 api/reports_writer.py:303 msgid "Updated at" msgstr "Diperbarui pada" -#: api/reports_writer.py:236 +#: api/reports_writer.py:233 msgid "Program Summary" msgstr "Ringkasan Program" -#: api/reports_writer.py:240 +#: api/reports_writer.py:237 msgid "Open for Donation" msgstr "Terbuka untuk Donasi" -#: api/reports_writer.py:241 +#: api/reports_writer.py:238 msgid "Total Donation Amount" msgstr "Jumlah Total Donasi" -#: api/reports_writer.py:252 +#: api/reports_writer.py:249 msgid "" "NB: This program summary only shows programs that have not been deleted. It " "also not affected by date filtering." @@ -515,91 +515,91 @@ msgstr "" "NB: Ringkasan program ini hanya menampilkan program yang belum dihapus. " "Ringkasan ini juga tidak terpengaruh oleh penyaringan tanggal." -#: api/reports_writer.py:262 +#: api/reports_writer.py:259 msgid "Transaction Report" msgstr "Laporan Transaksi" -#: api/reports_writer.py:281 +#: api/reports_writer.py:278 msgid "Transactions" msgstr "Transaksi" -#: api/reports_writer.py:283 api/reports_writer.py:323 +#: api/reports_writer.py:280 api/reports_writer.py:320 msgid "Transaction Number" msgstr "Nomor Transaksi" -#: api/reports_writer.py:287 +#: api/reports_writer.py:284 msgid "Shpping Address" msgstr "Alamat Pengiriman" -#: api/reports_writer.py:288 +#: api/reports_writer.py:285 msgid "Shipping Neighborhood" msgstr "RT Pengiriman" -#: api/reports_writer.py:289 +#: api/reports_writer.py:286 msgid "Shipping Hamlet" msgstr "RW Pengiriman" -#: api/reports_writer.py:290 +#: api/reports_writer.py:287 msgid "Shipping Urban Village" msgstr "Kelurahan Pengiriman" -#: api/reports_writer.py:291 +#: api/reports_writer.py:288 msgid "Shipping Sub-District" msgstr "Kecamatan Pengiriman" -#: api/reports_writer.py:292 +#: api/reports_writer.py:289 msgid "Transaction Item Subtotal" msgstr "Subtotal Barang Transaksi" -#: api/reports_writer.py:293 +#: api/reports_writer.py:290 msgid "Shipping Costs" msgstr "Biaya Pengiriman" -#: api/reports_writer.py:294 +#: api/reports_writer.py:291 msgid "Payment Method" msgstr "Metode Pembayaran" -#: api/reports_writer.py:295 api/serializers.py:33 +#: api/reports_writer.py:292 api/serializers.py:33 msgid "Donation" msgstr "Donasi" -#: api/reports_writer.py:296 +#: api/reports_writer.py:293 msgid "Transaction Status" msgstr "Status Transaksi" -#: api/reports_writer.py:321 +#: api/reports_writer.py:318 msgid "Transaction Items" msgstr "Barang Transaksi" -#: api/reports_writer.py:324 api/reports_writer.py:340 +#: api/reports_writer.py:321 api/reports_writer.py:337 msgid "Product Name" msgstr "Nama Produk" -#: api/reports_writer.py:325 +#: api/reports_writer.py:322 msgid "Product Price" msgstr "Harga Produk" -#: api/reports_writer.py:326 +#: api/reports_writer.py:323 msgid "Product Pre-Order" msgstr "Produk Pre-Order" -#: api/reports_writer.py:327 api/serializers.py:22 +#: api/reports_writer.py:324 api/serializers.py:22 msgid "Quantity" msgstr "Kuantitas" -#: api/reports_writer.py:337 +#: api/reports_writer.py:334 msgid "Product Order Summary" msgstr "Ringkasan Pesanan Produk" -#: api/reports_writer.py:339 +#: api/reports_writer.py:336 msgid "Product Code" msgstr "Kode Produk" -#: api/reports_writer.py:341 +#: api/reports_writer.py:338 msgid "Sold" msgstr "Terjual" -#: api/reports_writer.py:351 +#: api/reports_writer.py:348 msgid "" "NB: This product order summary only shows products that have not been " "deleted. It also not affected by date filtering." @@ -607,15 +607,15 @@ msgstr "" "NB: Ringkasan pesanan produk ini hanya menampilkan produk yang belum " "dihapus. Ringkasan ini juga tidak terpengaruh oleh penyaringan tanggal." -#: api/reports_writer.py:354 +#: api/reports_writer.py:351 msgid "Transaction Summary" msgstr "Ringkasan Transaksi" -#: api/reports_writer.py:356 +#: api/reports_writer.py:353 msgid "Revenue (Transaction)" msgstr "Pendapatan (Transaksi)" -#: api/reports_writer.py:369 +#: api/reports_writer.py:366 msgid "Donation (Transaction)" msgstr "Donasi (Transaksi)" @@ -779,34 +779,34 @@ msgstr "Terjadi kesalahan konfigurasi." msgid "Server is currently unable to send SMS." msgstr "Server saat ini tidak dapat mengirim SMS." -#~ msgid "Not a valid string." -#~ msgstr "Bukan string yang valid." +msgid "Not a valid string." +msgstr "Bukan string yang valid." -#~ msgid "This field may not be blank." -#~ msgstr "Bidang ini tidak boleh kosong." +msgid "This field may not be blank." +msgstr "Bidang ini tidak boleh kosong." #, python-brace-format -#~ msgid "Ensure this field has no more than {max_length} characters." -#~ msgstr "Pastikan bidang ini tidak lebih dari {max_length} karakter." +msgid "Ensure this field has no more than {max_length} characters." +msgstr "Pastikan bidang ini tidak lebih dari {max_length} karakter." #, python-brace-format -#~ msgid "Ensure this field has at least {min_length} characters." -#~ msgstr "Pastikan bidang ini memiliki setidaknya {min_length} karakter." +msgid "Ensure this field has at least {min_length} characters." +msgstr "Pastikan bidang ini memiliki setidaknya {min_length} karakter." #, python-brace-format -#~ msgid "" -#~ "Enter a valid phone number (e.g. {example_number}) or a number with an " -#~ "international call prefix." -#~ msgstr "" -#~ "Masukkan nomor telepon yang valid (misalnya {example_number}) atau nomor " -#~ "dengan awalan panggilan internasional." +msgid "" +"Enter a valid phone number (e.g. {example_number}) or a number with an " +"international call prefix." +msgstr "" +"Masukkan nomor telepon yang valid (misalnya {example_number}) atau nomor " +"dengan awalan panggilan internasional." #, python-brace-format -#~ msgid "Enter a valid phone number (e.g. {example_number})." -#~ msgstr "Masukkan nomor telepon yang valid (misalnya {example_number})." +msgid "Enter a valid phone number (e.g. {example_number})." +msgstr "Masukkan nomor telepon yang valid (misalnya {example_number})." -#~ msgid "Enter a valid phone number." -#~ msgstr "Masukkan nomor telepon yang valid." +msgid "Enter a valid phone number." +msgstr "Masukkan nomor telepon yang valid." -#~ msgid "The phone number entered is not valid." -#~ msgstr "Nomor telepon yang dimasukkan tidak valid." +msgid "The phone number entered is not valid." +msgstr "Nomor telepon yang dimasukkan tidak valid." -- GitLab From bb8fca4cda01c64a0b6b1378b51f692007b98cd2 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Thu, 4 Jun 2020 13:28:28 +0700 Subject: [PATCH 77/90] [GREEN] Fix error on writing donation report --- api/reports_writer.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/api/reports_writer.py b/api/reports_writer.py index b77f2a2..63b21ff 100644 --- a/api/reports_writer.py +++ b/api/reports_writer.py @@ -65,6 +65,8 @@ def program_code_with_hyperlink(worksheet, row, col, obj, cell_format): def program_donation_program_code_with_hyperlink(worksheet, row, col, obj, cell_format): + if obj.program is None: + return worksheet.write_url( row, col, -- GitLab From 355087fd434e5e356268e2ce3213b8316d2660b9 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Thu, 4 Jun 2020 13:50:03 +0700 Subject: [PATCH 78/90] [GREEN] Update unit test for donation report --- api/tests.py | 1 + locale/id/LC_MESSAGES/django.mo | Bin 13617 -> 12651 bytes locale/id/LC_MESSAGES/django.po | 134 ++++++++++++++++---------------- 3 files changed, 68 insertions(+), 67 deletions(-) diff --git a/api/tests.py b/api/tests.py index 33b10ca..5993b93 100644 --- a/api/tests.py +++ b/api/tests.py @@ -766,6 +766,7 @@ class ReportViewsTest(rest_framework_test.APITestCase): )) program_donation.donation_status = '002' program_donation.save() + program.delete() response = request( 'GET', 'program-donation-report', diff --git a/locale/id/LC_MESSAGES/django.mo b/locale/id/LC_MESSAGES/django.mo index b057ef14e33c810251c90a622433d5dc2dcbba16..cd88a0b6cd37dd1661012b0564214f240a6a0410 100644 GIT binary patch delta 4028 zcmYk<2~bs49LMp)0}&Mz7eq88S0oW}0bG(4mqbMq6}1I($=$$xNqrPrE@X;Z7N)sn zE~z|R#>~+s%Uoh=WTwe<#?f-p(OhzCnfm_T``37e|NESK?s;ds=Uial`0FFQ7i)y9 zcBF&E6U4a+&i#pnRrnyys^(lHoQ(bIqOex*ResC|H8rbBD}tuoBllVhy~4mGJ@AL=T1PxHi_tSfp>4V)j6FoQ)cI zAr8Zp=)t>Knf~2icU@E4Ljh(!V=EMhzUrXgrOoc-hMDp=Rcx6?I$#HRA@T6>EoDi45e&W$_V* zV^Q^&qrSfZ`Eh&96X;b#=gFwT9SlJag)OlncED6@jZ=^+ZYyege1lkDaT>|P+cgF7cS~TmgnU_+a863x^cnX{0ZF~~z)?t>|9<>LiAbq%bs2Q#@ zH=_npf?DD)QT4vJ`@f>vdx+ZX(J`!lBAK>KBNj*EFf2el%gd&g<9Hjt8fYRXs(%mAScvSt``_U zB&wr^sDUORRa_h7$Mxky^~a<7$wPHK-LAd!$>twX+A7V3%What#JyDxx461`E zsArgu8t6jQ0E^9?sE!V!26zTL2C@55?Nw!mYLB!)=Iq*{R}Ey5QAfkEBTmFL+>RM| z71Oa5TRR)4;ZQt{dM`BL7ep!!K`rfab0ccy_M!%S2(?0Gc747P>#q^rp+HOX2(_f) zjRP}|L3JF5{#}fHxXwclevYbt4Eb?q`A|D|un;RWaqcZF!eESHnR%JI`lyv^-P9Y% zbfZ8^oQ4U#Zqy79+w~WynUUi};HIRkzIL_{`L=A9@S%R9$F|!=i!8NRie_;y@VM8TjYiy2VQ5~;B_2=DA zMsKzf)IiRl|9GGpykX`4puQN%sFJWT_P{}?hKex^x1l!M9%SFSZ&B^v#P%5WbfEoo z=cvGI21D!%#eKoij<@ui$~QMcO+`emqHzZuLfOm;)#1ca3 z6=D(LoyLdKY+|NA7kFi+TK+@yZ!XLvc3OE?>_f~adRm#!6giLOUn1X&c)`k6qaN2Y zI{*A`aU<=p_bPs(er$f*kffPlE}5|&bW)1OuSAUAe8hnQtCpiAn3~f z=H$@$Pg_;=7xMn`|NEtDZ4#wCBAIxK*h?r)B;N7o_|qAe6WTLu5jTlwLU2(0DaDM( zxt3pv%ZT1~Exbj1JZA-+a49jK=tcw+O792o|BW!f^7C-9<@aMfm?}ELg`sT?}l;2 zn?y13UmB}p5KRmuJ|J2WX}*Za=wM&3S}THlzeNr9_?FbUAK^P2w0gE%Z7cu) delta 4987 zcma*q3vd+m0mt!wc!x(q03m?2E5wi#xdaf)TN?x<$Rjo)Sb^>(m)vrfy^y;=c)3XB zQM5>Th(N7YDOeO*2m)4xIo zd$A`T!dyIVJ&kJkL;N-t@X#oH7+riChtj_JghF2~gnDE~SdMc!ufSHg57qDiEWo3f zgCF1myoB{Q@1{)0PGM`#KSIW4E}`ykOD|;))a3TZ_Ox$G?S%m2!l(-7qrQLC zx&>9y9{c@U*oN~HSb~4Uk(ixtOd;Nb+EXzcf@@Iqy@s9ePx;J$Zwl{oK_mDWSuT^! zMR~Ke1X(3hhT1DLa6CSSnu&K%Bl;_<;WJo-?fV#W3zp(=oQ7J;HK>7X?8E$P#Jjm5 zUqyA~_oylVyS?$e{r+=Qg*j{(jcgExupGaMi%@%KD{84Oqu!bA2GrU;XMGvfp~I+={sHw?oU(q18gUjIN4q`;i*Oh+1~Uh>XO^Mr zUxQl0r%)Z;j)8i(pMrc7)zG`B9{vMIWf^lGRbc^t2((AWA**G|QO`wD4K2h`xB|!G z5uAjdVHr*=;MK*oI1A4fu>SgC7hW>Z44t#*7f~JR!0Ics zAHXLtimUM7$ZwcgTAcYCuo?ODV78)WcCYm}s1CjtP-sWt3~FTmv|d70^f_t|REL^y5H81IxEHC)e1O_JxwohP-kT7rBM%`R2#ilbKaEYOo^3$wffrB> zG~4UHLp^vBOYvVg0sGRbMiRyj7)LeOh`ga@6RQ3LI0BEO>hBQJYtQ=UQ_xzLqTc6m zs2Q1s>Oei}_hALOpq6&EbscI3H(PgOj^6*H6ms!6 z>a{v;y@DE9ABL-$DXNyY9fh2au#n=T`qc-_w4D?&S-QI8z zwcCG-y8gEHEUKa_*47MH=N(ZE=b@hKiJGb2n1_Q=4UV%`pgK0!>J4W8(>>&ZdcGLD z;wn^+x1he?VZVO~wb>3~4j#20$2`v8M?Lou>b@(eJ=11Lrrvy12a2sjhcN$I>oHuA zm8gnhsE#bd3Ahf2;$QG?%;rQRE4R)>t!<5U396p;*5^?TA3)8}5!B3`4D1)DP&07O zdeL704AqhLci0i2_DZomAAzd49JT8!QM*2g+N2v%_wPa7_XcY397A;^c$Y#q3g=O~ z|5MaVg#vmOMwECz@&<`t8@Tl{Awn zdjF?Vm`^m?a-yS%Xcn$Lwp@Ec`8C2TpWYAps+b&7fnx;ONM@2JiBDc9_mJ^q7SVB& z-v4^?19F61d#tf16(we+LQe*?s+o3n0<>*9Vi9K0|djGXg zf>-$9cP9P+e{(THenhmptBC#xwH)_R_&M1@w3(hJP2_g+AeljilaAy?vYXsObo?-b z`2`Lpzb0X=f6K9-LXADC#KpF}65k;D9r&@mrZ?hYGM$u?<>Xi7XGF(ctMW}Y;1@)si>E9YP?9i+KIYJ$4xo$ zh?`6~OX@s-Zam`GrlQN7s;JxGR;3~h$&k76bAH09OQfrFeV%oj{?D`2;keEMHy(J^ zPJJ}tM;yPgF3i=UNT@dCEQvI^b@lPc+|1Qw#ZE#G*n1Xwsi;E(o=-i#oAMIA8+WSQ zc-*OPh}3vZ>fZmF_{tUvuQu^j4>hmtwlOPbY9f{CHB}~4&AoceZgqF$`tC>6Zlu~_ zHhu19gkE*XR9>CkI(Jd}n{dPl$6Y_xT;KDPF2Qu>+KVxyuvhK+wT#Sj>LPVs+>3cm zGLqtI%wObQzZ@}EBSv!}bEC)8&#;PNFX~jg4c_(3arN0LZn80msck>O+BvC6JW`+V zoki+Ly8lj5otJdu>Gr?0E^f+gv`<#EO{m6Q=rS;;p66>lmFzMl)5{bxYVZr~P5Jr_ z-thVBA8(Ep?91v^)~GQhQw<*DxH_2TUWFB{?w$JOp;mi!Egj2Don5=Kt1FVZ9!|s~ RQ74v2Cj8pwnxeMZ{{^#TsL22T diff --git a/locale/id/LC_MESSAGES/django.po b/locale/id/LC_MESSAGES/django.po index 6374b59..d4c880c 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-06-04 07:19+0700\n" +"POT-Creation-Date: 2020-06-04 13:49+0700\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -427,87 +427,87 @@ msgstr "konfigurasi pengiriman" msgid "shipment configurations" msgstr "konfigurasi pengiriman" -#: api/reports_writer.py:185 +#: api/reports_writer.py:187 msgid "Program Donation Report" msgstr "Laporan Donasi Program" -#: api/reports_writer.py:204 +#: api/reports_writer.py:206 msgid "Program Donations" msgstr "Donasi Program" -#: api/reports_writer.py:206 +#: api/reports_writer.py:208 msgid "Donation Number" msgstr "Nomor Donasi" -#: api/reports_writer.py:207 api/reports_writer.py:281 +#: api/reports_writer.py:209 api/reports_writer.py:283 msgid "User Username" msgstr "Username Pengguna" -#: api/reports_writer.py:208 api/reports_writer.py:235 +#: api/reports_writer.py:210 api/reports_writer.py:237 msgid "Program Code" msgstr "Kode Program" -#: api/reports_writer.py:209 api/reports_writer.py:282 +#: api/reports_writer.py:211 api/reports_writer.py:284 msgid "User Full Name" msgstr "Nama Lengkap Pengguna" -#: api/reports_writer.py:210 api/reports_writer.py:283 +#: api/reports_writer.py:212 api/reports_writer.py:285 msgid "User Phone Number" msgstr "Nomor Telepon Pengguna" -#: api/reports_writer.py:211 api/reports_writer.py:236 +#: api/reports_writer.py:213 api/reports_writer.py:238 msgid "Program Name" msgstr "Nama Program" -#: api/reports_writer.py:212 api/serializers.py:63 api/serializers.py:85 +#: api/reports_writer.py:214 api/serializers.py:63 api/serializers.py:85 msgid "Amount" msgstr "Jumlah" -#: api/reports_writer.py:213 +#: api/reports_writer.py:215 msgid "Donation Status" msgstr "Status Donasi" -#: api/reports_writer.py:214 api/reports_writer.py:294 +#: api/reports_writer.py:216 api/reports_writer.py:296 msgid "User Bank Name" msgstr "Nama Bank Pengguna" -#: api/reports_writer.py:215 api/reports_writer.py:295 +#: api/reports_writer.py:217 api/reports_writer.py:297 msgid "User Bank Account Name" msgstr "Nama Akun Bank Pengguna" -#: api/reports_writer.py:216 api/reports_writer.py:296 +#: api/reports_writer.py:218 api/reports_writer.py:298 msgid "Transfer Destination Bank Name" msgstr "Nama Bank Tujuan Transfer" -#: api/reports_writer.py:217 api/reports_writer.py:297 +#: api/reports_writer.py:219 api/reports_writer.py:299 msgid "Transfer Destination Bank Account Name" msgstr "Nama Akun Bank Tujuan Transfer" -#: api/reports_writer.py:220 api/reports_writer.py:300 +#: api/reports_writer.py:222 api/reports_writer.py:302 msgid "Transfer Destination Bank Account Number" msgstr "Nomor Akun Bank Tujuan Transfer" -#: api/reports_writer.py:222 api/reports_writer.py:302 +#: api/reports_writer.py:224 api/reports_writer.py:304 msgid "Created at" msgstr "Dibuat pada" -#: api/reports_writer.py:223 api/reports_writer.py:303 +#: api/reports_writer.py:225 api/reports_writer.py:305 msgid "Updated at" msgstr "Diperbarui pada" -#: api/reports_writer.py:233 +#: api/reports_writer.py:235 msgid "Program Summary" msgstr "Ringkasan Program" -#: api/reports_writer.py:237 +#: api/reports_writer.py:239 msgid "Open for Donation" msgstr "Terbuka untuk Donasi" -#: api/reports_writer.py:238 +#: api/reports_writer.py:240 msgid "Total Donation Amount" msgstr "Jumlah Total Donasi" -#: api/reports_writer.py:249 +#: api/reports_writer.py:251 msgid "" "NB: This program summary only shows programs that have not been deleted. It " "also not affected by date filtering." @@ -515,91 +515,91 @@ msgstr "" "NB: Ringkasan program ini hanya menampilkan program yang belum dihapus. " "Ringkasan ini juga tidak terpengaruh oleh penyaringan tanggal." -#: api/reports_writer.py:259 +#: api/reports_writer.py:261 msgid "Transaction Report" msgstr "Laporan Transaksi" -#: api/reports_writer.py:278 +#: api/reports_writer.py:280 msgid "Transactions" msgstr "Transaksi" -#: api/reports_writer.py:280 api/reports_writer.py:320 +#: api/reports_writer.py:282 api/reports_writer.py:322 msgid "Transaction Number" msgstr "Nomor Transaksi" -#: api/reports_writer.py:284 +#: api/reports_writer.py:286 msgid "Shpping Address" msgstr "Alamat Pengiriman" -#: api/reports_writer.py:285 +#: api/reports_writer.py:287 msgid "Shipping Neighborhood" msgstr "RT Pengiriman" -#: api/reports_writer.py:286 +#: api/reports_writer.py:288 msgid "Shipping Hamlet" msgstr "RW Pengiriman" -#: api/reports_writer.py:287 +#: api/reports_writer.py:289 msgid "Shipping Urban Village" msgstr "Kelurahan Pengiriman" -#: api/reports_writer.py:288 +#: api/reports_writer.py:290 msgid "Shipping Sub-District" msgstr "Kecamatan Pengiriman" -#: api/reports_writer.py:289 +#: api/reports_writer.py:291 msgid "Transaction Item Subtotal" msgstr "Subtotal Barang Transaksi" -#: api/reports_writer.py:290 +#: api/reports_writer.py:292 msgid "Shipping Costs" msgstr "Biaya Pengiriman" -#: api/reports_writer.py:291 +#: api/reports_writer.py:293 msgid "Payment Method" msgstr "Metode Pembayaran" -#: api/reports_writer.py:292 api/serializers.py:33 +#: api/reports_writer.py:294 api/serializers.py:33 msgid "Donation" msgstr "Donasi" -#: api/reports_writer.py:293 +#: api/reports_writer.py:295 msgid "Transaction Status" msgstr "Status Transaksi" -#: api/reports_writer.py:318 +#: api/reports_writer.py:320 msgid "Transaction Items" msgstr "Barang Transaksi" -#: api/reports_writer.py:321 api/reports_writer.py:337 +#: api/reports_writer.py:323 api/reports_writer.py:339 msgid "Product Name" msgstr "Nama Produk" -#: api/reports_writer.py:322 +#: api/reports_writer.py:324 msgid "Product Price" msgstr "Harga Produk" -#: api/reports_writer.py:323 +#: api/reports_writer.py:325 msgid "Product Pre-Order" msgstr "Produk Pre-Order" -#: api/reports_writer.py:324 api/serializers.py:22 +#: api/reports_writer.py:326 api/serializers.py:22 msgid "Quantity" msgstr "Kuantitas" -#: api/reports_writer.py:334 +#: api/reports_writer.py:336 msgid "Product Order Summary" msgstr "Ringkasan Pesanan Produk" -#: api/reports_writer.py:336 +#: api/reports_writer.py:338 msgid "Product Code" msgstr "Kode Produk" -#: api/reports_writer.py:338 +#: api/reports_writer.py:340 msgid "Sold" msgstr "Terjual" -#: api/reports_writer.py:348 +#: api/reports_writer.py:350 msgid "" "NB: This product order summary only shows products that have not been " "deleted. It also not affected by date filtering." @@ -607,15 +607,15 @@ msgstr "" "NB: Ringkasan pesanan produk ini hanya menampilkan produk yang belum " "dihapus. Ringkasan ini juga tidak terpengaruh oleh penyaringan tanggal." -#: api/reports_writer.py:351 +#: api/reports_writer.py:353 msgid "Transaction Summary" msgstr "Ringkasan Transaksi" -#: api/reports_writer.py:353 +#: api/reports_writer.py:355 msgid "Revenue (Transaction)" msgstr "Pendapatan (Transaksi)" -#: api/reports_writer.py:366 +#: api/reports_writer.py:368 msgid "Donation (Transaction)" msgstr "Donasi (Transaksi)" @@ -779,34 +779,34 @@ msgstr "Terjadi kesalahan konfigurasi." msgid "Server is currently unable to send SMS." msgstr "Server saat ini tidak dapat mengirim SMS." -msgid "Not a valid string." -msgstr "Bukan string yang valid." +#~ msgid "Not a valid string." +#~ msgstr "Bukan string yang valid." -msgid "This field may not be blank." -msgstr "Bidang ini tidak boleh kosong." +#~ msgid "This field may not be blank." +#~ msgstr "Bidang ini tidak boleh kosong." #, python-brace-format -msgid "Ensure this field has no more than {max_length} characters." -msgstr "Pastikan bidang ini tidak lebih dari {max_length} karakter." +#~ msgid "Ensure this field has no more than {max_length} characters." +#~ msgstr "Pastikan bidang ini tidak lebih dari {max_length} karakter." #, python-brace-format -msgid "Ensure this field has at least {min_length} characters." -msgstr "Pastikan bidang ini memiliki setidaknya {min_length} karakter." +#~ msgid "Ensure this field has at least {min_length} characters." +#~ msgstr "Pastikan bidang ini memiliki setidaknya {min_length} karakter." #, python-brace-format -msgid "" -"Enter a valid phone number (e.g. {example_number}) or a number with an " -"international call prefix." -msgstr "" -"Masukkan nomor telepon yang valid (misalnya {example_number}) atau nomor " -"dengan awalan panggilan internasional." +#~ msgid "" +#~ "Enter a valid phone number (e.g. {example_number}) or a number with an " +#~ "international call prefix." +#~ msgstr "" +#~ "Masukkan nomor telepon yang valid (misalnya {example_number}) atau nomor " +#~ "dengan awalan panggilan internasional." #, python-brace-format -msgid "Enter a valid phone number (e.g. {example_number})." -msgstr "Masukkan nomor telepon yang valid (misalnya {example_number})." +#~ msgid "Enter a valid phone number (e.g. {example_number})." +#~ msgstr "Masukkan nomor telepon yang valid (misalnya {example_number})." -msgid "Enter a valid phone number." -msgstr "Masukkan nomor telepon yang valid." +#~ msgid "Enter a valid phone number." +#~ msgstr "Masukkan nomor telepon yang valid." -msgid "The phone number entered is not valid." -msgstr "Nomor telepon yang dimasukkan tidak valid." +#~ msgid "The phone number entered is not valid." +#~ msgstr "Nomor telepon yang dimasukkan tidak valid." -- GitLab From 514b2119091dcc382702bc7c21175522b85741af Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Thu, 4 Jun 2020 14:17:49 +0700 Subject: [PATCH 79/90] [CHORES] Rename reports file name --- api/reports_writer.py | 8 +- locale/id/LC_MESSAGES/django.mo | Bin 12651 -> 13669 bytes locale/id/LC_MESSAGES/django.po | 140 +++++++++++++++++--------------- 3 files changed, 80 insertions(+), 68 deletions(-) diff --git a/api/reports_writer.py b/api/reports_writer.py index 63b21ff..54dc45e 100644 --- a/api/reports_writer.py +++ b/api/reports_writer.py @@ -183,9 +183,11 @@ def write_queryset_data_to_worksheet(worksheet, queryset, fields, start_col=0, s def create_program_donation_report(filter_params): workbook = xlsxwriter.Workbook( - '{} {}–{}.xlsx'.format( + '{} {} {} {} {}.xlsx'.format( _('Program Donation Report'), + _('from'), filter_params.get('created_at_date_range_after', '*'), + _('to'), filter_params.get('created_at_date_range_before', str(timezone.now())[:10]) ), { @@ -257,9 +259,11 @@ def create_program_donation_report(filter_params): def create_transaction_report(filter_params): workbook = xlsxwriter.Workbook( - '{} {}–{}.xlsx'.format( + '{} {} {} {} {}.xlsx'.format( _('Transaction Report'), + _('from'), filter_params.get('created_at_date_range_after', '*'), + _('to'), filter_params.get('created_at_date_range_before', str(timezone.now())[:10]) ), { diff --git a/locale/id/LC_MESSAGES/django.mo b/locale/id/LC_MESSAGES/django.mo index cd88a0b6cd37dd1661012b0564214f240a6a0410..ae3f70a2b82e656831cc0c6b28057a94e048b8a3 100644 GIT binary patch delta 5057 zcma*q33L?o0mt!wNC@{8kZ=iu1V{iw!WA`2({L1oAhZZ*2{>Vg?66nNZh%x_1q;PU z6+|d_6to~I+JbJCLqlt|&%SEylPC5-X??X~E0xk(TIJQ(?{D{yfKp%Q`OD`&vorsD zcJck1KP^cf&&%0tIG!i{NI|YKSy{%6=%}m4lyo&_5SAhznHzB+&c{x;65HZ>EXK`P ziZ9|+JcDDgqMI>QxD;pUa~y6=()7$T=1MM9BKMj)>nd!=`F+TL<{|#H$GzAc51|@< z6?@{Zu?v1+{RGu;XKpUTiCB)eql*V{H0_%{J&ftgg&C+3F2RL(E4ISdPz}G11^9dH zj5(Jv8tjgB*o4$+&SExpp;rvjbVuDk*g6I^xf8Gr?VFkQ!aS>o)M4sT6|6%|^#j-j zn^6_)!+dN(-FMVpKY^Nw4>8?QZlY+qqh@#*YUau@sfH#}7>YNe9$1a4U?b}DN38o% z75&nF{to7F{vnRUkMJ5S>SfFj450SZ-8c%HQS}|e4tTm3^WU4of4HC#cjkN#J}Kz{4J^@ z@1dsrV|!y;Hi$kSfU2+AyTEOM72|$q@a<- zP_NaUcm?i4?f#!zkDz+~ru77B#D7OE!C8CVT%MWQ4ye!bQA_DqORzQP6{rp*CsR-b z^H2>|qk3G6>S+`ijJXT-;4aktkE0scXU`9!mf|p~;+Ih~_LjYV()tmqom{4o`jVzA z1x-~?R0aJ}6%0o$!F8yHrlCeM8+CsGwVT7J`&Zb{??u(K9rfH3sI`6?b^j~&`devP z|99;Tr;rV7a#={-*dH~5kvJR2qc+hdoQAKUI@HU_?CL`6cvOS4Q3D8}>S;uEd>v}Z zH(=`|g`E`C<2|Sc_M={ppJDn7$ezEB>ewGpBR-9KTe8_WvL~vZBGg+j4hyjw8Jk&) z+C#fg?d-#(*77g~_4ox;Pk(3qGpeC~qt?DXJF`5CPM|8B&JTn3N(fn2Q;&Ln1FE6z zSdM#eJid!lac}`|0LBVf|2Y(%;=)bXZgA#{ViA^cz6CYKuUe0zrtkw)htHs9suRQ2 zd2dvQN>DRWhMJMVE`ZiR9kJ;-7P@6FM5(QOw4BO#JOiwM+7t?8Y=JPUC z2d7x)phgm~HliB12i2h+cqKODSbPJi(sUe=**j&(CDxyUda@eTfi0-7${nbV9YXDa zmr)HIv)BKMdhQb}!8}@B}>6&|&o!cxxLjm&&=mg8K`=c1N&uk{)1q4&Q<1w4YC z@dS3kGpN@pm*L3*)W|BaD^9moqedFFE2Moha<3pk3aA z`p$m=Rl#x8jekV#`oG!F|6%RSFjZlZwZvM6dVW0Wxe2Hln~dFX0jk})Qs!TUm0VB{ z*IT!sD&Bz_!EWq|DO87Ew4c9bKmQGC)4hY5!IRdr*p2h{qcYF+L)}+|+B>DAn159~ zi3{q$9P2HpHD6*~g{o*P>i#`A8K1?`*or?DI1V+SW!5#QrQKw0M%B||{Z*2J8h#r! zMen1gE_+PodRx>CqHqN9*#7A`!tUU)*eh4597-WU3)h#XXb<7#q0 znWyn@AW?FdOeB-YO+?3Kq>g-({DfS1++|ND;1gsJ*-g&P>6a<{q#xF|v!aBiqPwGJ?z{ zbI4VsJ^4O)TS)Sf_FyS_j?~y|;zwk$J)e!=u;sP*W0Fhmv)A-S%qKHR30Xz< zkw=M+FOeI`cQpPOg|84D2QsAp-eJq@QEx~AsU&^L0-_@?L;CL({QWM``@NUAL7eN!L_ux6!4=M;pj+=QN_h40Vsq)|qA@2DOIPPc zdDdP2Kc1xy$90yv;iMmM>VmPT=R_MKHC!$9iffCV+q~s&q%Q0&%v@bjzT^Qu8&1h1N$z` z&Wfcr_dnV?Ig`2fLkzLT54h1cF^C*g&?x>(d{R6o+=aS9`T+zqGO|J*vc3Ae#M z8DQH`jl0ZcpiUjn*ZL~iWs9emt=Oo+&$L(d|2KHa=P!Od6)bo%E5EWqV~Qv0ea3Nq zFsb|@vsz7=@%fTM@K@q#+rA2G25?i%VYO7ir zyjVstbgEUMrmf6$s?3;BQ--lr(Pi52@4bIJ&gB0-=bn4s+3qugQiOVYSL7G|BxyCpPLvaH(#I0BvzrrxQfGzP7CSrJma~YV1$+!qJ@i_L= z{c6rNch2i_$h4qf33AUJHow6NT>pgC@hVothgbtW6sqIeSR3mjeY+I1JF4Ss)WDbF zP+Wx`ypI*>-~CP|hJqkkt%6N)Iws>J+>OB)UDG-Gb`4SW+L-AW&UHU5k2!Wd%AA6= zD9=ZIe=Ta|c3^q>cL&L+f#Vp7C8&yJR{j7rGY_q(6@^R}EbxqYC#h1U(eC!t&T2Q?WJXAywQq)b{usqws6gfUh8n>F%4M z%tqHy$RfIU)T8c%-SG8D)?YI(ra&_|iB0hgHp9F4G}f)dEU_JG59A?zxVfkqt~R%z z2C^Tu#9yN7eQ)=FMYZ<`wb>)1SpSw}l9)z)9Dze|KI&PPp*GJ=Xo4;@EZ=m;OM~K8H~NSUWppeC9I|Mf6JetB0EDJMxkbqfNCfWHS$cM@a!s6j8w)TPqgZBMrY;(_QmvbM1DUQA zXo<5i6i1>)I?kMdyffT<)H5wab-V{P!y|V61!|_}Q7ik4l|Mjr=!p%K*F^25#$GaN zFco#YdZHT0!Z;jj_gA3?xYgW`n#l>X6xG2EERTO-3k+dHC1Y!Bj$=?AuSWIf-9big zw*9DqoJIffKs9*F%Kt%qF@jMgU<`K0fvAQGF$}k(Hd_(0@7%Yj_HSc540|TfemZh& zy)KK4o>?yHm&;t#3amm6U?=K)A4S!>fNJo%8N{>_xvq!2dt4?C!m${NC(YBS6+3TU z#VR`gkI87pVex@uQO8Wd3X~5(txS$N5jE3%b1{Z+U4WHv9qM~qPy^V5;aH4%geOs( z`8?L8e^*K-9PglZ_20UIdVsPXs-YCKquCYJaWB;OUO=r>f7Hs2!^$|B^63(Nx4v)yDKK(%wm{2A5pZPW@qLakgx z0_(3EwGsj=P~U8hDo@1->}+OXRjx3_RQ>CydJj>1Cx{)Z zfmHO8(XMWU+WpC>mFR`4I2?meNlU9Vn`ldL#Q#r&E!Pk8h)?ZW8%+avkJwHuA(UPv z77^a5d??K#X83b~S7xf^KSKZJ!c1b9m3P73#5|&>mHAAO^F;n-^3M}5TG<-Z<9b%- zpWiKRgxy$;!-!497NQZsG50_FcvSkB(2JxSv7gXtDeWNMB+_+3+Cyw4rW5ZFO4^i4 z^NH7pP@VtHDj~HG;0EFl%WIS9dA>vxS(%6+Cfao;+)Yd--XIPVN_rV7btYC4bmf0@ za%lXgtqS@JdH?wT{nE8IiP9t@nP@=lC6vY!@A`B6>5MA~?HRU+n?N)nI4J&)SV2czO5_q=r`sDLPuC> zcmOvVUm<2{{xz(y4~`b7)!iG z6cYcXF**j3!~o($q7{+mYY`C{?8~jWGRRl8_8^aMXPu%DUrzMYU|(s2AIkY2#1;kl Yj>Yx$__E^{h4{)6dIb9tlahk|1(ptV3jhEB diff --git a/locale/id/LC_MESSAGES/django.po b/locale/id/LC_MESSAGES/django.po index d4c880c..e76425c 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-06-04 13:49+0700\n" +"POT-Creation-Date: 2020-06-04 14:09+0700\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -431,83 +431,91 @@ msgstr "konfigurasi pengiriman" msgid "Program Donation Report" msgstr "Laporan Donasi Program" -#: api/reports_writer.py:206 +#: api/reports_writer.py:188 api/reports_writer.py:264 +msgid "from" +msgstr "dari" + +#: api/reports_writer.py:190 api/reports_writer.py:266 +msgid "to" +msgstr "hingga" + +#: api/reports_writer.py:208 msgid "Program Donations" msgstr "Donasi Program" -#: api/reports_writer.py:208 +#: api/reports_writer.py:210 msgid "Donation Number" msgstr "Nomor Donasi" -#: api/reports_writer.py:209 api/reports_writer.py:283 +#: api/reports_writer.py:211 api/reports_writer.py:287 msgid "User Username" msgstr "Username Pengguna" -#: api/reports_writer.py:210 api/reports_writer.py:237 +#: api/reports_writer.py:212 api/reports_writer.py:239 msgid "Program Code" msgstr "Kode Program" -#: api/reports_writer.py:211 api/reports_writer.py:284 +#: api/reports_writer.py:213 api/reports_writer.py:288 msgid "User Full Name" msgstr "Nama Lengkap Pengguna" -#: api/reports_writer.py:212 api/reports_writer.py:285 +#: api/reports_writer.py:214 api/reports_writer.py:289 msgid "User Phone Number" msgstr "Nomor Telepon Pengguna" -#: api/reports_writer.py:213 api/reports_writer.py:238 +#: api/reports_writer.py:215 api/reports_writer.py:240 msgid "Program Name" msgstr "Nama Program" -#: api/reports_writer.py:214 api/serializers.py:63 api/serializers.py:85 +#: api/reports_writer.py:216 api/serializers.py:63 api/serializers.py:85 msgid "Amount" msgstr "Jumlah" -#: api/reports_writer.py:215 +#: api/reports_writer.py:217 msgid "Donation Status" msgstr "Status Donasi" -#: api/reports_writer.py:216 api/reports_writer.py:296 +#: api/reports_writer.py:218 api/reports_writer.py:300 msgid "User Bank Name" msgstr "Nama Bank Pengguna" -#: api/reports_writer.py:217 api/reports_writer.py:297 +#: api/reports_writer.py:219 api/reports_writer.py:301 msgid "User Bank Account Name" msgstr "Nama Akun Bank Pengguna" -#: api/reports_writer.py:218 api/reports_writer.py:298 +#: api/reports_writer.py:220 api/reports_writer.py:302 msgid "Transfer Destination Bank Name" msgstr "Nama Bank Tujuan Transfer" -#: api/reports_writer.py:219 api/reports_writer.py:299 +#: api/reports_writer.py:221 api/reports_writer.py:303 msgid "Transfer Destination Bank Account Name" msgstr "Nama Akun Bank Tujuan Transfer" -#: api/reports_writer.py:222 api/reports_writer.py:302 +#: api/reports_writer.py:224 api/reports_writer.py:306 msgid "Transfer Destination Bank Account Number" msgstr "Nomor Akun Bank Tujuan Transfer" -#: api/reports_writer.py:224 api/reports_writer.py:304 +#: api/reports_writer.py:226 api/reports_writer.py:308 msgid "Created at" msgstr "Dibuat pada" -#: api/reports_writer.py:225 api/reports_writer.py:305 +#: api/reports_writer.py:227 api/reports_writer.py:309 msgid "Updated at" msgstr "Diperbarui pada" -#: api/reports_writer.py:235 +#: api/reports_writer.py:237 msgid "Program Summary" msgstr "Ringkasan Program" -#: api/reports_writer.py:239 +#: api/reports_writer.py:241 msgid "Open for Donation" msgstr "Terbuka untuk Donasi" -#: api/reports_writer.py:240 +#: api/reports_writer.py:242 msgid "Total Donation Amount" msgstr "Jumlah Total Donasi" -#: api/reports_writer.py:251 +#: api/reports_writer.py:253 msgid "" "NB: This program summary only shows programs that have not been deleted. It " "also not affected by date filtering." @@ -515,91 +523,91 @@ msgstr "" "NB: Ringkasan program ini hanya menampilkan program yang belum dihapus. " "Ringkasan ini juga tidak terpengaruh oleh penyaringan tanggal." -#: api/reports_writer.py:261 +#: api/reports_writer.py:263 msgid "Transaction Report" msgstr "Laporan Transaksi" -#: api/reports_writer.py:280 +#: api/reports_writer.py:284 msgid "Transactions" msgstr "Transaksi" -#: api/reports_writer.py:282 api/reports_writer.py:322 +#: api/reports_writer.py:286 api/reports_writer.py:326 msgid "Transaction Number" msgstr "Nomor Transaksi" -#: api/reports_writer.py:286 +#: api/reports_writer.py:290 msgid "Shpping Address" msgstr "Alamat Pengiriman" -#: api/reports_writer.py:287 +#: api/reports_writer.py:291 msgid "Shipping Neighborhood" msgstr "RT Pengiriman" -#: api/reports_writer.py:288 +#: api/reports_writer.py:292 msgid "Shipping Hamlet" msgstr "RW Pengiriman" -#: api/reports_writer.py:289 +#: api/reports_writer.py:293 msgid "Shipping Urban Village" msgstr "Kelurahan Pengiriman" -#: api/reports_writer.py:290 +#: api/reports_writer.py:294 msgid "Shipping Sub-District" msgstr "Kecamatan Pengiriman" -#: api/reports_writer.py:291 +#: api/reports_writer.py:295 msgid "Transaction Item Subtotal" msgstr "Subtotal Barang Transaksi" -#: api/reports_writer.py:292 +#: api/reports_writer.py:296 msgid "Shipping Costs" msgstr "Biaya Pengiriman" -#: api/reports_writer.py:293 +#: api/reports_writer.py:297 msgid "Payment Method" msgstr "Metode Pembayaran" -#: api/reports_writer.py:294 api/serializers.py:33 +#: api/reports_writer.py:298 api/serializers.py:33 msgid "Donation" msgstr "Donasi" -#: api/reports_writer.py:295 +#: api/reports_writer.py:299 msgid "Transaction Status" msgstr "Status Transaksi" -#: api/reports_writer.py:320 +#: api/reports_writer.py:324 msgid "Transaction Items" msgstr "Barang Transaksi" -#: api/reports_writer.py:323 api/reports_writer.py:339 +#: api/reports_writer.py:327 api/reports_writer.py:343 msgid "Product Name" msgstr "Nama Produk" -#: api/reports_writer.py:324 +#: api/reports_writer.py:328 msgid "Product Price" msgstr "Harga Produk" -#: api/reports_writer.py:325 +#: api/reports_writer.py:329 msgid "Product Pre-Order" msgstr "Produk Pre-Order" -#: api/reports_writer.py:326 api/serializers.py:22 +#: api/reports_writer.py:330 api/serializers.py:22 msgid "Quantity" msgstr "Kuantitas" -#: api/reports_writer.py:336 +#: api/reports_writer.py:340 msgid "Product Order Summary" msgstr "Ringkasan Pesanan Produk" -#: api/reports_writer.py:338 +#: api/reports_writer.py:342 msgid "Product Code" msgstr "Kode Produk" -#: api/reports_writer.py:340 +#: api/reports_writer.py:344 msgid "Sold" msgstr "Terjual" -#: api/reports_writer.py:350 +#: api/reports_writer.py:354 msgid "" "NB: This product order summary only shows products that have not been " "deleted. It also not affected by date filtering." @@ -607,15 +615,15 @@ msgstr "" "NB: Ringkasan pesanan produk ini hanya menampilkan produk yang belum " "dihapus. Ringkasan ini juga tidak terpengaruh oleh penyaringan tanggal." -#: api/reports_writer.py:353 +#: api/reports_writer.py:357 msgid "Transaction Summary" msgstr "Ringkasan Transaksi" -#: api/reports_writer.py:355 +#: api/reports_writer.py:359 msgid "Revenue (Transaction)" msgstr "Pendapatan (Transaksi)" -#: api/reports_writer.py:368 +#: api/reports_writer.py:372 msgid "Donation (Transaction)" msgstr "Donasi (Transaksi)" @@ -779,34 +787,34 @@ msgstr "Terjadi kesalahan konfigurasi." msgid "Server is currently unable to send SMS." msgstr "Server saat ini tidak dapat mengirim SMS." -#~ msgid "Not a valid string." -#~ msgstr "Bukan string yang valid." +msgid "Not a valid string." +msgstr "Bukan string yang valid." -#~ msgid "This field may not be blank." -#~ msgstr "Bidang ini tidak boleh kosong." +msgid "This field may not be blank." +msgstr "Bidang ini tidak boleh kosong." #, python-brace-format -#~ msgid "Ensure this field has no more than {max_length} characters." -#~ msgstr "Pastikan bidang ini tidak lebih dari {max_length} karakter." +msgid "Ensure this field has no more than {max_length} characters." +msgstr "Pastikan bidang ini tidak lebih dari {max_length} karakter." #, python-brace-format -#~ msgid "Ensure this field has at least {min_length} characters." -#~ msgstr "Pastikan bidang ini memiliki setidaknya {min_length} karakter." +msgid "Ensure this field has at least {min_length} characters." +msgstr "Pastikan bidang ini memiliki setidaknya {min_length} karakter." #, python-brace-format -#~ msgid "" -#~ "Enter a valid phone number (e.g. {example_number}) or a number with an " -#~ "international call prefix." -#~ msgstr "" -#~ "Masukkan nomor telepon yang valid (misalnya {example_number}) atau nomor " -#~ "dengan awalan panggilan internasional." +msgid "" +"Enter a valid phone number (e.g. {example_number}) or a number with an " +"international call prefix." +msgstr "" +"Masukkan nomor telepon yang valid (misalnya {example_number}) atau nomor " +"dengan awalan panggilan internasional." #, python-brace-format -#~ msgid "Enter a valid phone number (e.g. {example_number})." -#~ msgstr "Masukkan nomor telepon yang valid (misalnya {example_number})." +msgid "Enter a valid phone number (e.g. {example_number})." +msgstr "Masukkan nomor telepon yang valid (misalnya {example_number})." -#~ msgid "Enter a valid phone number." -#~ msgstr "Masukkan nomor telepon yang valid." +msgid "Enter a valid phone number." +msgstr "Masukkan nomor telepon yang valid." -#~ msgid "The phone number entered is not valid." -#~ msgstr "Nomor telepon yang dimasukkan tidak valid." +msgid "The phone number entered is not valid." +msgstr "Nomor telepon yang dimasukkan tidak valid." -- GitLab From 7d5dd2f728efdefb463bc8a81bba8242bc93f61a Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Sat, 6 Jun 2020 02:42:19 +0700 Subject: [PATCH 80/90] [CHORES] Refactor --- api/migrations/0007_auto_20200606_0021.py | 17 ++ api/models.py | 14 +- api/reports_writer.py | 162 +++++++------ api/schemas.py | 3 - api/seeds.py | 1 + api/serializers.py | 28 +-- api/signals.py | 12 +- api/utils.py | 4 +- api/views.py | 56 +++-- locale/id/LC_MESSAGES/django.mo | Bin 13669 -> 13745 bytes locale/id/LC_MESSAGES/django.po | 264 +++++++++++----------- 11 files changed, 292 insertions(+), 269 deletions(-) create mode 100644 api/migrations/0007_auto_20200606_0021.py diff --git a/api/migrations/0007_auto_20200606_0021.py b/api/migrations/0007_auto_20200606_0021.py new file mode 100644 index 0000000..8505d43 --- /dev/null +++ b/api/migrations/0007_auto_20200606_0021.py @@ -0,0 +1,17 @@ +# Generated by Django 3.0.7 on 2020-06-05 17:21 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0006_delete_bankaccountconfig'), + ] + + operations = [ + migrations.AlterModelOptions( + name='programdonation', + options={'ordering': ['-updated_at', '-created_at', 'program', 'donation_number', 'id'], 'verbose_name': 'program donation', 'verbose_name_plural': 'program donations'}, + ), + ] diff --git a/api/models.py b/api/models.py index ef58b28..c493722 100644 --- a/api/models.py +++ b/api/models.py @@ -407,19 +407,19 @@ class ProgramDonation(db_models.Model): unique=True, verbose_name=_('donation number') ) - user = db_models.ForeignKey( - 'api.User', + program = db_models.ForeignKey( + 'api.Program', null=True, on_delete=db_models.SET_NULL, related_name='program_donations', - verbose_name=_('user') + verbose_name=_('program') ) - program = db_models.ForeignKey( - 'api.Program', + user = db_models.ForeignKey( + 'api.User', null=True, on_delete=db_models.SET_NULL, related_name='program_donations', - verbose_name=_('program') + verbose_name=_('user') ) user_full_name = db_models.CharField(max_length=200, verbose_name=_('user full name')) user_phone_number = modelfields.PhoneNumberField(verbose_name=_('user phone number')) @@ -483,7 +483,7 @@ class ProgramDonation(db_models.Model): updated_at = db_models.DateTimeField(auto_now=True, db_index=True, verbose_name=_('updated at')) class Meta: - ordering = ['-updated_at', '-created_at', 'donation_number', 'id'] + ordering = ['-updated_at', '-created_at', 'program', 'donation_number', 'id'] verbose_name = _('program donation') verbose_name_plural = _('program donations') diff --git a/api/reports_writer.py b/api/reports_writer.py index 54dc45e..6bd8816 100644 --- a/api/reports_writer.py +++ b/api/reports_writer.py @@ -1,12 +1,12 @@ import collections import datetime import decimal +import io import numbers import operator import xlsxwriter from django import conf -from django.utils import timezone from django.utils.translation import gettext_lazy as _ from api import filters, models @@ -181,91 +181,10 @@ def write_queryset_data_to_worksheet(worksheet, queryset, fields, start_col=0, s return row -def create_program_donation_report(filter_params): - workbook = xlsxwriter.Workbook( - '{} {} {} {} {}.xlsx'.format( - _('Program Donation Report'), - _('from'), - filter_params.get('created_at_date_range_after', '*'), - _('to'), - filter_params.get('created_at_date_range_before', str(timezone.now())[:10]) - ), - { - 'default_date_format': 'yyyy/mm/dd hh:mm', - 'remove_timezone': True, - } - ) - header_format = workbook.add_format( - { - 'align': 'center', - 'bg_color': '#408EBA', - 'bold': 1, - 'font_color': '#FFFFFF', - 'font_size': 14, - } - ) - money_format = workbook.add_format({'num_format': '#,##0'}) - program_donation_worksheet = workbook.add_worksheet(str(_('Program Donations'))) - program_donation_fields = [ - (donation_donation_number_with_hyperlink, _('Donation Number')), - (transaction_or_donation_user_username_with_hyperlink, _('User Username')), - (program_donation_program_code_with_hyperlink, _('Program Code')), - ('user_full_name', _('User Full Name')), - ('user_phone_number', _('User Phone Number')), - ('program_name', _('Program Name')), - ('amount', _('Amount')), - ('get_donation_status_display', _('Donation Status')), - ('user_bank_name', _('User Bank Name')), - ('user_bank_account_name', _('User Bank Account Name')), - ('transfer_destination_bank_name', _('Transfer Destination Bank Name')), - ('transfer_destination_bank_account_name', _('Transfer Destination Bank Account Name')), - ( - 'transfer_destination_bank_account_number', - _('Transfer Destination Bank Account Number'), - ), - ('created_at', _('Created at')), - ('updated_at', _('Updated at')), - ] - write_queryset_data_to_worksheet( - program_donation_worksheet, - filters.ReportProgramDonationFilter(filter_params).qs, - program_donation_fields, - header_format=header_format, - data_format={'amount': money_format}, - col_width=32 - ) - program_summary_worksheet = workbook.add_worksheet(str(_('Program Summary'))) - program_summary_fields = [ - (program_code_with_hyperlink, _('Program Code')), - ('name', _('Program Name')), - ('open_donation', _('Open for Donation')), - (program_total_donation_amount, _('Total Donation Amount')), - ] - last_row = write_queryset_data_to_worksheet( - program_summary_worksheet, - models.Program.objects.all(), - program_summary_fields, - header_format=header_format, - data_format={program_total_donation_amount: money_format}, - col_width=32 - ) - program_summary_worksheet.write_string(last_row + 1, 0, str(_( - 'NB: This program summary only shows programs that have not been deleted. ' - 'It also not affected by date filtering.' - ))) - workbook.close() - return workbook.filename - - -def create_transaction_report(filter_params): +def create_transaction_report(filter_params): # pylint: disable=too-many-locals + buffer = io.BytesIO() workbook = xlsxwriter.Workbook( - '{} {} {} {} {}.xlsx'.format( - _('Transaction Report'), - _('from'), - filter_params.get('created_at_date_range_after', '*'), - _('to'), - filter_params.get('created_at_date_range_before', str(timezone.now())[:10]) - ), + buffer, { 'default_date_format': 'yyyy/mm/dd hh:mm', 'remove_timezone': True, @@ -381,4 +300,75 @@ def create_transaction_report(filter_params): ) summary_worksheet.write_number(1, 1, transaction_donation, money_format) workbook.close() - return workbook.filename + return buffer + + +def create_program_donation_report(filter_params): + buffer = io.BytesIO() + workbook = xlsxwriter.Workbook( + buffer, + { + 'default_date_format': 'yyyy/mm/dd hh:mm', + 'remove_timezone': True, + } + ) + header_format = workbook.add_format( + { + 'align': 'center', + 'bg_color': '#408EBA', + 'bold': 1, + 'font_color': '#FFFFFF', + 'font_size': 14, + } + ) + money_format = workbook.add_format({'num_format': '#,##0'}) + program_donation_worksheet = workbook.add_worksheet(str(_('Program Donations'))) + program_donation_fields = [ + (donation_donation_number_with_hyperlink, _('Donation Number')), + (transaction_or_donation_user_username_with_hyperlink, _('User Username')), + (program_donation_program_code_with_hyperlink, _('Program Code')), + ('user_full_name', _('User Full Name')), + ('user_phone_number', _('User Phone Number')), + ('program_name', _('Program Name')), + ('amount', _('Amount')), + ('get_donation_status_display', _('Donation Status')), + ('user_bank_name', _('User Bank Name')), + ('user_bank_account_name', _('User Bank Account Name')), + ('transfer_destination_bank_name', _('Transfer Destination Bank Name')), + ('transfer_destination_bank_account_name', _('Transfer Destination Bank Account Name')), + ( + 'transfer_destination_bank_account_number', + _('Transfer Destination Bank Account Number'), + ), + ('created_at', _('Created at')), + ('updated_at', _('Updated at')), + ] + write_queryset_data_to_worksheet( + program_donation_worksheet, + filters.ReportProgramDonationFilter(filter_params).qs, + program_donation_fields, + header_format=header_format, + data_format={'amount': money_format}, + col_width=32 + ) + program_summary_worksheet = workbook.add_worksheet(str(_('Program Summary'))) + program_summary_fields = [ + (program_code_with_hyperlink, _('Program Code')), + ('name', _('Program Name')), + ('open_donation', _('Open for Donation')), + (program_total_donation_amount, _('Total Donation Amount')), + ] + last_row = write_queryset_data_to_worksheet( + program_summary_worksheet, + models.Program.objects.all(), + program_summary_fields, + header_format=header_format, + data_format={program_total_donation_amount: money_format}, + col_width=32 + ) + program_summary_worksheet.write_string(last_row + 1, 0, str(_( + 'NB: This program summary only shows programs that have not been deleted. ' + 'It also not affected by date filtering.' + ))) + workbook.close() + return buffer diff --git a/api/schemas.py b/api/schemas.py index 5a1375c..1fb99dc 100644 --- a/api/schemas.py +++ b/api/schemas.py @@ -7,9 +7,6 @@ class AutoSchemaWithDateRange(schemas.AutoSchema): date_range_fields = [] def get_filter_fields(self, path, method): - assert len(self.date_range_fields) != 0, ( - '{} should include a `date_range_fields` attribute.'.format(self.__class__.__name__) - ) filter_fields = super().get_filter_fields(path, method) for filter_field in filter_fields: if filter_field.name in self.date_range_fields: diff --git a/api/seeds.py b/api/seeds.py index fa23a08..aff4d00 100644 --- a/api/seeds.py +++ b/api/seeds.py @@ -59,6 +59,7 @@ PROGRAM_DATA = { PROGRAM_DONATION_DATA = { 'amount': '1000', + 'user_bank_name': 'Dummy User Bank Name', 'user_bank_account_name': 'Dummy User Bank Account Name', } diff --git a/api/serializers.py b/api/serializers.py index ca2a424..5a50569 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -295,12 +295,12 @@ class TransactionItemSerializer(serializers.ModelSerializer): class TransactionSerializer(serializers.ModelSerializer): user_username = serializers.ReadOnlyField(source='user.username') - transaction_items = TransactionItemSerializer(many=True, read_only=True) - transaction_item_subtotal = serializers.SerializerMethodField('get_transaction_item_subtotal') readable_payment_method = serializers.SerializerMethodField('get_readable_payment_method') readable_transaction_status = serializers.SerializerMethodField( 'get_readable_transaction_status' ) + transaction_items = TransactionItemSerializer(many=True, read_only=True) + transaction_item_subtotal = serializers.SerializerMethodField('get_transaction_item_subtotal') subtotal = serializers.SerializerMethodField('get_subtotal') class Meta: @@ -316,8 +316,6 @@ class TransactionSerializer(serializers.ModelSerializer): 'shipping_hamlet', 'shipping_urban_village', 'shipping_sub_district', - 'transaction_items', - 'transaction_item_subtotal', 'shipping_costs', 'payment_method', 'readable_payment_method', @@ -333,6 +331,8 @@ class TransactionSerializer(serializers.ModelSerializer): 'transfer_destination_bank_account_number', 'created_at', 'updated_at', + 'transaction_items', + 'transaction_item_subtotal', 'subtotal', ] model = models.Transaction @@ -361,6 +361,12 @@ class TransactionSerializer(serializers.ModelSerializer): 'updated_at', ] + def get_readable_payment_method(self, obj): # pylint: disable=no-self-use + return obj.get_payment_method_display() + + def get_readable_transaction_status(self, obj): # pylint: disable=no-self-use + return obj.get_transaction_status_display() + def get_transaction_item_subtotal(self, obj): # pylint: disable=no-self-use transaction_item_subtotal = sum( transaction_item.product_price * transaction_item.quantity @@ -368,12 +374,6 @@ class TransactionSerializer(serializers.ModelSerializer): ) return str(transaction_item_subtotal) - def get_readable_payment_method(self, obj): # pylint: disable=no-self-use - return obj.get_payment_method_display() - - def get_readable_transaction_status(self, obj): # pylint: disable=no-self-use - return obj.get_transaction_status_display() - def get_subtotal(self, obj): subtotal = (decimal.Decimal(self.get_transaction_item_subtotal(obj)) + obj.shipping_costs + obj.donation) @@ -433,8 +433,8 @@ class ProgramSerializer(serializers.ModelSerializer): class ProgramDonationSerializer(serializers.ModelSerializer): - user_username = serializers.ReadOnlyField(source='user.username') program_code = serializers.ReadOnlyField(source='program.code') + user_username = serializers.ReadOnlyField(source='user.username') readable_donation_status = serializers.SerializerMethodField( 'get_readable_donation_status' ) @@ -443,10 +443,10 @@ class ProgramDonationSerializer(serializers.ModelSerializer): fields = [ 'id', 'donation_number', - 'user', - 'user_username', 'program', 'program_code', + 'user', + 'user_username', 'user_full_name', 'user_phone_number', 'program_name', @@ -467,8 +467,8 @@ class ProgramDonationSerializer(serializers.ModelSerializer): read_only_fields = [ 'id', 'donation_number', - 'user', 'program', + 'user', 'user_full_name', 'user_phone_number', 'program_name', diff --git a/api/signals.py b/api/signals.py index 144afaf..fe0a2e2 100644 --- a/api/signals.py +++ b/api/signals.py @@ -79,6 +79,13 @@ def fill_dependent_program_donation_fields(sender, instance, **_kwargs): obj = sender.objects.get(id=instance.id) except sender.DoesNotExist: obj = None + if ((obj is None) or + (obj.program != instance.program) or + (getattr(instance, 'update_program', False))): + if instance.program is None: + instance.program_name = None + else: + instance.program_name = instance.program.name if ((obj is None) or (obj.user != instance.user) or (getattr(instance, 'update_user', False))): @@ -88,11 +95,6 @@ def fill_dependent_program_donation_fields(sender, instance, **_kwargs): else: instance.user_full_name = instance.user.full_name instance.user_phone_number = instance.user.phone_number - if (obj is None) or (obj.program != instance.program): - if instance.program is None: - instance.program_name = None - else: - instance.program_name = instance.program.name if ((obj is None) or (obj.bank_account_transfer_destination != instance.bank_account_transfer_destination) or (getattr(instance, 'update_bank_account_transfer_destination', False))): diff --git a/api/utils.py b/api/utils.py index fbe6033..20d8b16 100644 --- a/api/utils.py +++ b/api/utils.py @@ -112,6 +112,6 @@ def validate_product_stock(cart_items): product = cart_item.product if (product.stock is not None) and (cart_item.quantity > product.stock): raise rest_framework_exceptions.ParseError(_( - 'Failed to checkout because the purchased quantity of certain items exceeds ' - 'the available stock.' + 'Failed to checkout because the purchased quantity of certain items exceeds the ' + 'available stock.' )) diff --git a/api/views.py b/api/views.py index b6ac193..a0742df 100644 --- a/api/views.py +++ b/api/views.py @@ -2,6 +2,7 @@ from django import http, shortcuts from django.contrib import auth from django.db import transaction as db_transaction, utils as db_utils from django.db.models import deletion +from django.utils import timezone from django.utils.translation import gettext_lazy as _ from django_filters import rest_framework from knox import views as knox_views @@ -210,15 +211,15 @@ class CartUploadPOP(rest_framework_views.APIView): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) user = request.user + bank_account_transfer_destination = shortcuts.get_object_or_404( + models.BankAccountTransferDestination, + id=serializer.validated_data['bank_account_transfer_destination'] + ) transaction = shortcuts.get_object_or_404( models.Transaction, id=serializer.validated_data['transaction'], user=user ) - bank_account_transfer_destination = shortcuts.get_object_or_404( - models.BankAccountTransferDestination, - id=serializer.validated_data['bank_account_transfer_destination'] - ) if transaction.payment_method != 'TRF': raise rest_framework_exceptions.PermissionDenied(_( 'The payment method for this transaction is not a transfer.' @@ -300,14 +301,14 @@ class DonationCreate(rest_framework_views.APIView): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) user = request.user - program = shortcuts.get_object_or_404( - models.Program, - id=serializer.validated_data['program'] - ) bank_account_transfer_destination = shortcuts.get_object_or_404( models.BankAccountTransferDestination, id=serializer.validated_data['bank_account_transfer_destination'] ) + program = shortcuts.get_object_or_404( + models.Program, + id=serializer.validated_data['program'] + ) if not program.open_donation: raise rest_framework_exceptions.PermissionDenied(_( 'This program is currently not accepting donations.' @@ -338,15 +339,15 @@ class DonationReuploadProofOfBankTransfer(rest_framework_views.APIView): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) user = request.user + bank_account_transfer_destination = shortcuts.get_object_or_404( + models.BankAccountTransferDestination, + id=serializer.validated_data['bank_account_transfer_destination'] + ) program_donation = shortcuts.get_object_or_404( models.ProgramDonation, id=serializer.validated_data['program_donation'], user=user ) - bank_account_transfer_destination = shortcuts.get_object_or_404( - models.BankAccountTransferDestination, - id=serializer.validated_data['bank_account_transfer_destination'] - ) if program_donation.donation_status not in ('001', '004'): raise rest_framework_exceptions.PermissionDenied(_( 'Cannot reupload proof of bank transfer at this stage.' @@ -371,6 +372,9 @@ class ReportAPIView(rest_framework_views.APIView): report_function = None serializer_class = None + def get_filename(self, query_params): + raise NotImplementedError + def get_serializer(self, *args, **kwargs): return self.serializer_class(*args, **kwargs) # pylint: disable=not-callable @@ -383,9 +387,9 @@ class ReportAPIView(rest_framework_views.APIView): ) serializer = self.get_serializer(data=request.query_params) serializer.is_valid(raise_exception=True) - filename = self.report_function.__func__(serializer.validated_data) - file = open(filename, 'rb') - return http.FileResponse(file) + buffer = self.report_function.__func__(serializer.validated_data) + buffer.seek(0) + return http.FileResponse(buffer, filename=self.get_filename(serializer.validated_data)) class ReportTransaction(ReportAPIView): @@ -393,12 +397,30 @@ class ReportTransaction(ReportAPIView): schema = schemas.ReportTransactionSchema() serializer_class = api_serializers.ReportTransactionSerializer + def get_filename(self, query_params): + filename = '{}.xlsx'.format(_( # pylint: disable=no-member + 'Transaction Report from {date_from} to {date_to}' + ).format( + date_from=query_params.get('created_at_date_range_after', '*'), + date_to=query_params.get('created_at_date_range_before', str(timezone.now())[:10]) + )) + return filename + class ReportProgramDonation(ReportAPIView): report_function = reports_writer.create_program_donation_report schema = schemas.ReportProgramDonationSchema() serializer_class = api_serializers.ReportProgramDonationSerializer + def get_filename(self, query_params): + filename = '{}.xlsx'.format(_( # pylint: disable=no-member + 'Program Donation Report from {date_from} to {date_to}' + ).format( + date_from=query_params.get('created_at_date_range_after', '*'), + date_to=query_params.get('created_at_date_range_before', str(timezone.now())[:10]) + )) + return filename + class UserList(generics.ListCreateAPIView): filter_backends = [ @@ -424,7 +446,7 @@ class UserDetail(generics.RetrieveUpdateDestroyAPIView): serializer_class = api_serializers.UserSerializer def get_object(self): - if (self.kwargs.get('pk') == 'self') and (self.request.user): + if self.kwargs.get('pk') == 'self': self.kwargs['pk'] = self.request.user.id return super().get_object() @@ -569,7 +591,7 @@ class ShoppingCartDetail(generics.RetrieveAPIView): serializer_class = api_serializers.ShoppingCartSerializer def get_object(self): - if (self.kwargs.get('pk') == 'self') and (self.request.user): + if self.kwargs.get('pk') == 'self': self.kwargs['pk'] = models.ShoppingCart.objects.get(user=self.request.user).id return super().get_object() diff --git a/locale/id/LC_MESSAGES/django.mo b/locale/id/LC_MESSAGES/django.mo index ae3f70a2b82e656831cc0c6b28057a94e048b8a3..57616885c3acf478df384b2170ddf94c67ee91c3 100644 GIT binary patch delta 3952 zcma*pd2o$a7{~FG)pm1}D~TXNM7Xv{LShS2`&fsLD5c?AY6(&%+Ob}>$5P8hBdWAb zZEY>RVyV51&ZwbUl&R{}Hq{!8rR`8N{r+-~ss7cz&iKj<;hK+>4QT#(EXi@m*|z z$vl*WgVBRU*o^+o6AJO1Xc7~cVRsxty)OpiE>y?6F&U4b6K~*~_z>T~v9W=H{eU6V z?;&$D4^j7rF-jSON^Tu2NB<_xo@i<9j67prMl~=RmFlTj4i};tSdN*v9(CV$_WVs$ zCLW+V42}zA$c4&qeN^UJp;sMspy0*S?n(-}8$j7LGgt8%&@+znslTp{RPz`oR z&1?)d!TDGVOHq600&1x;xk+zLTV$-J6ViprMfEeqOF=VRjC!p$U=kif?fx6qyQq=> zX$?sT%-D%qf;iOm8mQE|?e%7;rEFvEiDA?SqXys|MnN4Fphh|qHNttwB+N$C0|!wZ zeTmvUU)lOMs3o|7YVaB=Q;+QV5YnaVQK)_rQ0=*q40%n8y&)6TKnGOn`=L4-hMGYh zDy7pPj0qGr$q`(X}h z^K8Q&cpWvM)WpDU&bH>DI?O{&U^c3q<*0#gKrQuVR0a;9!%N{91wC*EwRTsmzn}*6 z2sP6%Hi+JiIBP@HjN4*8?2g*?6OqZ8V$`14k81w}Y6-tZ4fK2TYJ_(w$Y-dI9PB`i zuqI{&vHMXC4&{eHd&G+@ubGc}t_0PQAG7ck`yVimf|P9h^W7=pv@zRm?<3V`J#cbU^K$ z@yI2!1T~O-SV8aqX$tx@UPN_#AGHU9XssE#Q0F_N9_){4I2OC$I;0_U4a?&lRELj| zH`T;63ACSqt*G}#wZ9PK=-(7k&|2<9{W={-W#lqyhWAn5hv3w}ebrF|PO)~ybm|_| z7t@EY;0_GOa*S3wQ5lT2Hbkf1|7;48*c$<*SYjXefJEG2av3gJqO}EamE<$y@0`=S)RHoLV z3wNP9JZ?RQ8rVDQabD_d=SKdRwVs9k>^wd)_E zHfc2PqV7*Y-Pabice5PEyz!R8A>!Kb`z@z9g~R>#M`QH zyzk3#)C%h3o9swUd5`)EB9RzG%qDc?1t|M3c?{)!#HYSsU+p zeL)eau6N7o|NJ@NYabDvu-Bf@%k3iu5X*=O#6jQ0hTf_MXdIfM!Li1Q(Er(nWxrKnoGEe1%!?vK37zH zP_aKHYIAta#MdX~k58O7!c#DEh>9~33-WEXAb*Dce3hxe|8-CAYL!C$xd{a!{tJn_ tDuNFs-nJ{XU=Y>`a8x9ZtKlj~f{2;`CJ*{hqKRqKO=x_YjvP=K~ delta 3893 zcmYk<2~bs49LMnk4-u3H2#O+tnkeE1xRFst?k1MbxR8m8VkV@RmX4J?mo&HBu8?ak zxe!_|FL%*Q8=Ev69ks=!oK&n#)67yPr|<8*GgEi`zt6eno_qH59_}c(mhZV76;y1v zju7>T#9(6r0*q-9rd(rEBaLZbinBF)M z`|3G1H^yUPqKs)tMkl1K8E2h_q1-P){+U&LRKsl;jr&mzpTZdY1|#s6^%1J!a0;hm z7tFx7Fc(m_E>fqdL>ETT zD+XzzQRN$3+oC47GghU2)5m6HTSp>wnDM9zW}~Kh1y;pUR0Z3yHkPBxowNBBsF}Ej zzK&9eq=`n&a5L1*WuQk5bs>?2gHSI_M^&&G^?aRm52~WC?DMM_#Qi-?!3X#RCf6~h z3Fe{p)I4mBrKtKYV0FAxhxv~s@edgqL2YhWHj`|9()v7VYKNot%0%pn8&EUx3u;7v zp&B-IjcE!=n1FroaV$hFWhrVP-nz`cM*Jli@;g*VZlI?8p)DA~2GR5Ss0v%5M%Ei! zVLsN!<*2>054BXW6w;n*i1f`gMXEHNQ0?S+NN8k*sADw)AH$8P-T#I4465fBtre&d z-$gA!rOh|-{;92wdR`l~lntz@ScUscR0llWNT`BrRD(IF9*;uxbSyF$GY9qJMpXGN zs0Oy%`+caTIE1SB1Zu`E+5DT<2dH*}nMUgKm`DRE$&Z!2o8ccIFkwE35Ovi{d?f!oLiHo+{U3MQaNkb?cNBWe>l z*b7ghI#j2De^)oPc0@JU4>f=SR6UbX9iNR_@G#&3Q4Rf# zTKj73%!~j!fvPZz9|rA}0%Tduc+~rgPz|lY3@pQrcpZCU<3vsX7ACU(14!&7;{^2U?|>1-_#;~G4AI6=jo^p_OK2>jU>-H8P&i7REO4LODx59cpjxG7Z&%WvK6oGE}+GP!*oD-o`ZULsR_U zoEbQf`+=yX-Dcg5HFf^WmB2F?jujYz_ff|xnBmDp)W|wvBxYH2P$L~{orF5pQ?WYE zM7_5d)q#~5hU?MeCb5l#c6mAKJO3!Eg3G9aKcRO0@Amm0)^LWY3X`p=)^ybS9Z~Of zM$K3^tbs#N?T$-h{*@>qLp_{tEkRYh7Bzy+7>Qm~hmP6jr|t7^QJd~6Y6fpwE3pRm z)mr=CtA{F=jM_VCt(kvS+?5PFOU?3lv*d8^YN!FRD zrFE>OsCvq+UwcTX;VY;q`V}>GuD1UC5Y!ArSrbq*)670kLv<*_nuXdsIre@Gs{SI> zDVU8q1)EWu)^nVMUbuoPcn7taDp4H@;_yacJ=AGPLCs88RJp;(Pl6dnv?q2G#l%O% z>qH(gozS(0m`F?}qKRHiO!qK%z&xjaSW3&)a&W^C? z_;sW{AT(jR^i8g71+mMy5|&b9neYC;k8-D>J34-!&FFwjiELsKF_t*w^mWI1-X`@a z@&9X%z3GfwiAKa`LPuEFGsI|O5aA*=5U&vqp-ricr)wvnC4EyjTnh=EJ}t{0f>Yn# z_usLh+>9g^5ZY8D2>k}EC3IyGn~05smTtMzFgz~pLsDA^t+uYYeoPs19LzytxXlxv zIAg+V=guN^n9$x@Lg>=I8cg&dQi*BAc49rD>uI7t@g7k~bSHG}_2c`TYSZ(bYvIk4 z-{(%-e;bi&pYVsP?}y@JoMQ7zoemLkwTkQ=N5Z^Iup`X@XLLk;_YV6+lsLr^Nmbq< zwb(hrTdPRTB$9~Pgzs|hM8pNG^tvNA2Yb)NybtMMwoB{bs`~Qiv7<)i8fQVt=A4Hl5J diff --git a/locale/id/LC_MESSAGES/django.po b/locale/id/LC_MESSAGES/django.po index e76425c..180eb13 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-06-04 14:09+0700\n" +"POT-Creation-Date: 2020-06-06 02:41+0700\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -104,7 +104,7 @@ msgstr "foto profil" msgid "OTP" msgstr "OTP" -#: api/models.py:44 api/models.py:158 api/models.py:208 api/models.py:415 +#: api/models.py:44 api/models.py:158 api/models.py:208 api/models.py:422 msgid "user" msgstr "pengguna" @@ -344,7 +344,7 @@ msgstr "gambar poster" msgid "link" msgstr "tautan" -#: api/models.py:395 api/models.py:422 +#: api/models.py:395 api/models.py:415 msgid "program" msgstr "program" @@ -427,187 +427,127 @@ msgstr "konfigurasi pengiriman" msgid "shipment configurations" msgstr "konfigurasi pengiriman" -#: api/reports_writer.py:187 -msgid "Program Donation Report" -msgstr "Laporan Donasi Program" - -#: api/reports_writer.py:188 api/reports_writer.py:264 -msgid "from" -msgstr "dari" - -#: api/reports_writer.py:190 api/reports_writer.py:266 -msgid "to" -msgstr "hingga" - -#: api/reports_writer.py:208 -msgid "Program Donations" -msgstr "Donasi Program" +#: api/reports_writer.py:203 +msgid "Transactions" +msgstr "Transaksi" -#: api/reports_writer.py:210 -msgid "Donation Number" -msgstr "Nomor Donasi" +#: api/reports_writer.py:205 api/reports_writer.py:245 +msgid "Transaction Number" +msgstr "Nomor Transaksi" -#: api/reports_writer.py:211 api/reports_writer.py:287 +#: api/reports_writer.py:206 api/reports_writer.py:328 msgid "User Username" msgstr "Username Pengguna" -#: api/reports_writer.py:212 api/reports_writer.py:239 -msgid "Program Code" -msgstr "Kode Program" - -#: api/reports_writer.py:213 api/reports_writer.py:288 +#: api/reports_writer.py:207 api/reports_writer.py:330 msgid "User Full Name" msgstr "Nama Lengkap Pengguna" -#: api/reports_writer.py:214 api/reports_writer.py:289 +#: api/reports_writer.py:208 api/reports_writer.py:331 msgid "User Phone Number" msgstr "Nomor Telepon Pengguna" -#: api/reports_writer.py:215 api/reports_writer.py:240 -msgid "Program Name" -msgstr "Nama Program" - -#: api/reports_writer.py:216 api/serializers.py:63 api/serializers.py:85 -msgid "Amount" -msgstr "Jumlah" - -#: api/reports_writer.py:217 -msgid "Donation Status" -msgstr "Status Donasi" - -#: api/reports_writer.py:218 api/reports_writer.py:300 -msgid "User Bank Name" -msgstr "Nama Bank Pengguna" - -#: api/reports_writer.py:219 api/reports_writer.py:301 -msgid "User Bank Account Name" -msgstr "Nama Akun Bank Pengguna" - -#: api/reports_writer.py:220 api/reports_writer.py:302 -msgid "Transfer Destination Bank Name" -msgstr "Nama Bank Tujuan Transfer" - -#: api/reports_writer.py:221 api/reports_writer.py:303 -msgid "Transfer Destination Bank Account Name" -msgstr "Nama Akun Bank Tujuan Transfer" - -#: api/reports_writer.py:224 api/reports_writer.py:306 -msgid "Transfer Destination Bank Account Number" -msgstr "Nomor Akun Bank Tujuan Transfer" - -#: api/reports_writer.py:226 api/reports_writer.py:308 -msgid "Created at" -msgstr "Dibuat pada" - -#: api/reports_writer.py:227 api/reports_writer.py:309 -msgid "Updated at" -msgstr "Diperbarui pada" - -#: api/reports_writer.py:237 -msgid "Program Summary" -msgstr "Ringkasan Program" - -#: api/reports_writer.py:241 -msgid "Open for Donation" -msgstr "Terbuka untuk Donasi" - -#: api/reports_writer.py:242 -msgid "Total Donation Amount" -msgstr "Jumlah Total Donasi" - -#: api/reports_writer.py:253 -msgid "" -"NB: This program summary only shows programs that have not been deleted. It " -"also not affected by date filtering." -msgstr "" -"NB: Ringkasan program ini hanya menampilkan program yang belum dihapus. " -"Ringkasan ini juga tidak terpengaruh oleh penyaringan tanggal." - -#: api/reports_writer.py:263 -msgid "Transaction Report" -msgstr "Laporan Transaksi" - -#: api/reports_writer.py:284 -msgid "Transactions" -msgstr "Transaksi" - -#: api/reports_writer.py:286 api/reports_writer.py:326 -msgid "Transaction Number" -msgstr "Nomor Transaksi" - -#: api/reports_writer.py:290 +#: api/reports_writer.py:209 msgid "Shpping Address" msgstr "Alamat Pengiriman" -#: api/reports_writer.py:291 +#: api/reports_writer.py:210 msgid "Shipping Neighborhood" msgstr "RT Pengiriman" -#: api/reports_writer.py:292 +#: api/reports_writer.py:211 msgid "Shipping Hamlet" msgstr "RW Pengiriman" -#: api/reports_writer.py:293 +#: api/reports_writer.py:212 msgid "Shipping Urban Village" msgstr "Kelurahan Pengiriman" -#: api/reports_writer.py:294 +#: api/reports_writer.py:213 msgid "Shipping Sub-District" msgstr "Kecamatan Pengiriman" -#: api/reports_writer.py:295 +#: api/reports_writer.py:214 msgid "Transaction Item Subtotal" msgstr "Subtotal Barang Transaksi" -#: api/reports_writer.py:296 +#: api/reports_writer.py:215 msgid "Shipping Costs" msgstr "Biaya Pengiriman" -#: api/reports_writer.py:297 +#: api/reports_writer.py:216 msgid "Payment Method" msgstr "Metode Pembayaran" -#: api/reports_writer.py:298 api/serializers.py:33 +#: api/reports_writer.py:217 api/serializers.py:33 msgid "Donation" msgstr "Donasi" -#: api/reports_writer.py:299 +#: api/reports_writer.py:218 msgid "Transaction Status" msgstr "Status Transaksi" -#: api/reports_writer.py:324 +#: api/reports_writer.py:219 api/reports_writer.py:335 +msgid "User Bank Name" +msgstr "Nama Bank Pengguna" + +#: api/reports_writer.py:220 api/reports_writer.py:336 +msgid "User Bank Account Name" +msgstr "Nama Akun Bank Pengguna" + +#: api/reports_writer.py:221 api/reports_writer.py:337 +msgid "Transfer Destination Bank Name" +msgstr "Nama Bank Tujuan Transfer" + +#: api/reports_writer.py:222 api/reports_writer.py:338 +msgid "Transfer Destination Bank Account Name" +msgstr "Nama Akun Bank Tujuan Transfer" + +#: api/reports_writer.py:225 api/reports_writer.py:341 +msgid "Transfer Destination Bank Account Number" +msgstr "Nomor Akun Bank Tujuan Transfer" + +#: api/reports_writer.py:227 api/reports_writer.py:343 +msgid "Created at" +msgstr "Dibuat pada" + +#: api/reports_writer.py:228 api/reports_writer.py:344 +msgid "Updated at" +msgstr "Diperbarui pada" + +#: api/reports_writer.py:243 msgid "Transaction Items" msgstr "Barang Transaksi" -#: api/reports_writer.py:327 api/reports_writer.py:343 +#: api/reports_writer.py:246 api/reports_writer.py:262 msgid "Product Name" msgstr "Nama Produk" -#: api/reports_writer.py:328 +#: api/reports_writer.py:247 msgid "Product Price" msgstr "Harga Produk" -#: api/reports_writer.py:329 +#: api/reports_writer.py:248 msgid "Product Pre-Order" msgstr "Produk Pre-Order" -#: api/reports_writer.py:330 api/serializers.py:22 +#: api/reports_writer.py:249 api/serializers.py:22 msgid "Quantity" msgstr "Kuantitas" -#: api/reports_writer.py:340 +#: api/reports_writer.py:259 msgid "Product Order Summary" msgstr "Ringkasan Pesanan Produk" -#: api/reports_writer.py:342 +#: api/reports_writer.py:261 msgid "Product Code" msgstr "Kode Produk" -#: api/reports_writer.py:344 +#: api/reports_writer.py:263 msgid "Sold" msgstr "Terjual" -#: api/reports_writer.py:354 +#: api/reports_writer.py:273 msgid "" "NB: This product order summary only shows products that have not been " "deleted. It also not affected by date filtering." @@ -615,18 +555,62 @@ msgstr "" "NB: Ringkasan pesanan produk ini hanya menampilkan produk yang belum " "dihapus. Ringkasan ini juga tidak terpengaruh oleh penyaringan tanggal." -#: api/reports_writer.py:357 +#: api/reports_writer.py:276 msgid "Transaction Summary" msgstr "Ringkasan Transaksi" -#: api/reports_writer.py:359 +#: api/reports_writer.py:278 msgid "Revenue (Transaction)" msgstr "Pendapatan (Transaksi)" -#: api/reports_writer.py:372 +#: api/reports_writer.py:291 msgid "Donation (Transaction)" msgstr "Donasi (Transaksi)" +#: api/reports_writer.py:325 +msgid "Program Donations" +msgstr "Donasi Program" + +#: api/reports_writer.py:327 +msgid "Donation Number" +msgstr "Nomor Donasi" + +#: api/reports_writer.py:329 api/reports_writer.py:356 +msgid "Program Code" +msgstr "Kode Program" + +#: api/reports_writer.py:332 api/reports_writer.py:357 +msgid "Program Name" +msgstr "Nama Program" + +#: api/reports_writer.py:333 api/serializers.py:63 api/serializers.py:85 +msgid "Amount" +msgstr "Jumlah" + +#: api/reports_writer.py:334 +msgid "Donation Status" +msgstr "Status Donasi" + +#: api/reports_writer.py:354 +msgid "Program Summary" +msgstr "Ringkasan Program" + +#: api/reports_writer.py:358 +msgid "Open for Donation" +msgstr "Terbuka untuk Donasi" + +#: api/reports_writer.py:359 +msgid "Total Donation Amount" +msgstr "Jumlah Total Donasi" + +#: api/reports_writer.py:370 +msgid "" +"NB: This program summary only shows programs that have not been deleted. It " +"also not affected by date filtering." +msgstr "" +"NB: Ringkasan program ini hanya menampilkan program yang belum dihapus. " +"Ringkasan ini juga tidak terpengaruh oleh penyaringan tanggal." + #: api/serializers.py:13 msgid "Phone number" msgstr "Nomor telepon" @@ -715,55 +699,65 @@ msgid "" msgstr "" "Gagal checkout karena jumlah barang yang dibeli melebihi stok yang tersedia." -#: api/views.py:161 +#: api/views.py:162 #, python-brace-format msgid "" "Cannot process shipment to other sub-districts other than {sub_district}." msgstr "" "Tidak dapat memproses pengiriman ke kecamatan lain selain {sub_district}." -#: api/views.py:167 +#: api/views.py:168 msgid "Unable to checkout because there are no items purchased." msgstr "Tidak dapat checkout karena tidak ada barang yang dibeli." -#: api/views.py:198 +#: api/views.py:199 msgid "Checkout failed." msgstr "Checkout gagal." -#: api/views.py:224 +#: api/views.py:225 msgid "The payment method for this transaction is not a transfer." msgstr "Metode pembayaran untuk transaksi ini bukan transfer." -#: api/views.py:228 +#: api/views.py:229 msgid "Cannot upload proof of payment at this stage." msgstr "Tidak dapat mengunggah bukti pembayaran pada tahap ini." -#: api/views.py:258 +#: api/views.py:259 msgid "Transaction cannot be completed unless the status is \"Being shipped\"." msgstr "" "Transaksi tidak dapat diselesaikan kecuali statusnya \"Sedang dikirim\"." -#: api/views.py:283 +#: api/views.py:284 msgid "Transaction cannot be canceled at this stage." msgstr "Transaksi tidak dapat dibatalkan pada tahap ini." -#: api/views.py:313 +#: api/views.py:314 msgid "This program is currently not accepting donations." msgstr "Program ini saat ini tidak menerima donasi." -#: api/views.py:352 +#: api/views.py:353 msgid "Cannot reupload proof of bank transfer at this stage." msgstr "Tidak dapat mengunggah kembali bukti transfer bank pada tahap ini." -#: api/views.py:485 +#: api/views.py:402 +#, python-brace-format +msgid "Transaction Report from {date_from} to {date_to}" +msgstr "Laporan Transaksi dari {date_from} hingga {date_to}" + +#: api/views.py:417 +#, python-brace-format +msgid "Program Donation Report from {date_from} to {date_to}" +msgstr "Laporan Donasi Program dari {date_from} hingga {date_to}" + +#: api/views.py:507 msgid "Cannot delete category due to integrity error." msgstr "Tidak dapat menghapus kategori karena kesalahan integritas." -#: api/views.py:522 +#: api/views.py:544 msgid "Cannot delete subcategory due to integrity error." msgstr "Tidak dapat menghapus subkategori karena kesalahan integritas." -#: api/views.py:613 +#: api/views.py:635 msgid "" "Cannot update transaction because it has a completed, canceled, or failed " "status." @@ -771,7 +765,7 @@ msgstr "" "Tidak dapat memperbarui transaksi karena memiliki status selesai, " "dibatalkan, atau gagal." -#: api/views.py:684 +#: api/views.py:706 msgid "" "Cannot update program donation because it has a completed or canceled status." msgstr "" -- GitLab From 2db1dc49f3c8f7b17c6265b96036847a1f0736a7 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Sat, 6 Jun 2020 03:39:04 +0700 Subject: [PATCH 81/90] [CHORES] Update settings and README.md --- README.md | 88 ++++++++++++++++++++++++---- home_industry/settings/ci.py | 2 +- home_industry/settings/local.py | 2 +- home_industry/settings/production.py | 2 +- home_industry/settings/staging.py | 2 +- 5 files changed, 79 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 6614ebe..07f7b35 100644 --- a/README.md +++ b/README.md @@ -11,28 +11,22 @@ ## Local Configuration -- Install Python 3.7 and PostgreSQL +- Install Python 3.6 or higher and PostgreSQL - Create Python virtual environment -``` +```bash $ cd /path/to/project/directory $ python3 -m venv env $ source env/bin/activate $ pip3 install -r requirements.txt ``` -- Create PostgreSQL postgres user with password postgres - - Create PostgreSQL database -``` -$ createdb home_industry -``` +- Set up environment variables (change as needed) -- Set up environment variables - -``` +```bash $ export DATABASE_HOST="127.0.0.1" $ export DATABASE_NAME="home_industry" $ export DATABASE_PASSWORD="postgres" @@ -44,24 +38,92 @@ $ export SECRET_KEY="7&s33ax$lxxzti1)0y=8#tu!$7bdy)p$1@kn06tp&8x8i9#h2u" - Migrate the database -``` +```bash $ python3 manage.py migrate ``` +- Create Superuser + +```bash +$ python3 manage.py createsuperuser +``` + - Set up API configuration by modifying `api_config.yaml` - Create or update the API configuration in database -``` +```bash $ python3 manage.py createorupdateapiconfig ``` - Run server -``` +```bash $ python3 manage.py runserver ``` +## Environment Variables + +### Local + +Key | Required | Example +--- | --- | --- +`DATABASE_HOST` | yes | `127.0.0.1` +`DATABASE_NAME` | yes | `home_industry` +`DATABASE_PASSWORD` | yes | `postgres` +`DATABASE_PORT` | yes | `5432` +`DATABASE_USER` | yes | `postgres` +`DEBUG` | no | `True` +`DJANGO_SETTINGS_MODULE` | yes | `home_industry.settings.local` +`SECRET_KEY` | yes | `7&s33ax$lxxzti1)0y=8#tu!$7bdy)p$1@kn06tp&8x8i9#h2u` + +### CI + +Key | Required | Example +--- | --- | --- +`SECRET_KEY` | yes | `7&s33ax$lxxzti1)0y=8#tu!$7bdy)p$1@kn06tp&8x8i9#h2u` + +### Staging + +Key | Required | Example +--- | --- | --- +`AWS_ACCESS_KEY_ID` | yes | `AKIAIOSFODNN7EXAMPLE` +`AWS_REGION_NAME` | yes | `ap-southeast-1` +`AWS_SECRET_ACCESS_KEY` | yes | `wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY` +`AWS_STORAGE_BUCKET_NAME` | yes | `homeindustry-api` +`DATABASE_URL` | yes | `postgres://postgres:postgres@127.0.0.1:5432/home_industry` +`DEBUG` | no | `True` +`DJANGO_SETTINGS_MODULE` | yes | `home_industry.settings.staging` +`HOME_INDUSTRY_ADMIN_SITE_URL` | no | `https://homeindustry.com/admin/` +`HOME_INDUSTRY_ADMIN_SITE_USER_PATH` | no | `users/` +`HOME_INDUSTRY_ADMIN_SITE_PRODUCT_PATH` | no | `products/` +`HOME_INDUSTRY_ADMIN_SITE_TRANSACTION_PATH` | no | `transactions/` +`HOME_INDUSTRY_ADMIN_SITE_PROGRAM_PATH` | no | `programs/` +`HOME_INDUSTRY_ADMIN_SITE_PROGRAM_DONATION_PATH` | no | `program-donations/` +`SECRET_KEY` | yes | `7&s33ax$lxxzti1)0y=8#tu!$7bdy)p$1@kn06tp&8x8i9#h2u` + +### Production + +Key | Required | Example +--- | --- | --- +`AWS_ACCESS_KEY_ID` | yes | `AKIAIOSFODNN7EXAMPLE` +`AWS_REGION_NAME` | yes | `ap-southeast-1` +`AWS_SECRET_ACCESS_KEY` | yes | `wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY` +`AWS_STORAGE_BUCKET_NAME` | yes | `homeindustry-api` +`DATABASE_HOST` | yes | `127.0.0.1` +`DATABASE_NAME` | yes | `home_industry` +`DATABASE_PASSWORD` | yes | `postgres` +`DATABASE_PORT` | yes | `5432` +`DATABASE_USER` | yes | `postgres` +`DJANGO_SETTINGS_MODULE` | yes | `home_industry.settings.production` +`HOME_INDUSTRY_ADMIN_SITE_URL` | no | `https://homeindustry.com/admin/` +`HOME_INDUSTRY_ADMIN_SITE_USER_PATH` | no | `users/` +`HOME_INDUSTRY_ADMIN_SITE_PRODUCT_PATH` | no | `products/` +`HOME_INDUSTRY_ADMIN_SITE_TRANSACTION_PATH` | no | `transactions/` +`HOME_INDUSTRY_ADMIN_SITE_PROGRAM_PATH` | no | `programs/` +`HOME_INDUSTRY_ADMIN_SITE_PROGRAM_DONATION_PATH` | no | `program-donations/` +`SECRET_KEY` | yes | `7&s33ax$lxxzti1)0y=8#tu!$7bdy)p$1@kn06tp&8x8i9#h2u` + ## Deployed API URLs - Staging: https://industripilar-staging.herokuapp.com diff --git a/home_industry/settings/ci.py b/home_industry/settings/ci.py index 2d7f48e..57034ee 100644 --- a/home_industry/settings/ci.py +++ b/home_industry/settings/ci.py @@ -114,7 +114,7 @@ MEDIA_ROOT = os.path.join(BASE_DIR, 'media') MEDIA_URL = '/media/' -STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles') +STATIC_ROOT = os.path.join(BASE_DIR, 'static') STATIC_URL = '/static/' diff --git a/home_industry/settings/local.py b/home_industry/settings/local.py index ec385da..f09e11d 100644 --- a/home_industry/settings/local.py +++ b/home_industry/settings/local.py @@ -118,7 +118,7 @@ MEDIA_ROOT = os.path.join(BASE_DIR, 'media') MEDIA_URL = '/media/' -STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles') +STATIC_ROOT = os.path.join(BASE_DIR, 'static') STATIC_URL = '/static/' diff --git a/home_industry/settings/production.py b/home_industry/settings/production.py index 5f367ff..7f3711f 100644 --- a/home_industry/settings/production.py +++ b/home_industry/settings/production.py @@ -150,7 +150,7 @@ REST_FRAMEWORK = { AWS = { 'AWS_ACCESS_KEY_ID': os.environ['AWS_ACCESS_KEY_ID'], 'AWS_SECRET_ACCESS_KEY': os.environ['AWS_SECRET_ACCESS_KEY'], - 'AWS_REGION_NAME': 'ap-southeast-1', + 'AWS_REGION_NAME': os.environ['AWS_REGION_NAME'], } # Home Industry Admin Site diff --git a/home_industry/settings/staging.py b/home_industry/settings/staging.py index 480c2c5..b9d0651 100644 --- a/home_industry/settings/staging.py +++ b/home_industry/settings/staging.py @@ -144,7 +144,7 @@ REST_FRAMEWORK = { AWS = { 'AWS_ACCESS_KEY_ID': os.environ['AWS_ACCESS_KEY_ID'], 'AWS_SECRET_ACCESS_KEY': os.environ['AWS_SECRET_ACCESS_KEY'], - 'AWS_REGION_NAME': 'ap-southeast-1', + 'AWS_REGION_NAME': os.environ['AWS_REGION_NAME'], } # Home Industry Admin Site -- GitLab From b1632099e3eee0728cfb06e2a6c2b44b57a33dc3 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Sat, 6 Jun 2020 03:56:13 +0700 Subject: [PATCH 82/90] [CHORES] Change double quotes to single quotes to avoid string interpolation --- .gitlab-ci.yml | 4 +- README.md | 111 ++++++++++++++++++++++++++----------------------- 2 files changed, 60 insertions(+), 55 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 17a591a..33caa0b 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -50,7 +50,7 @@ staging: HEROKU_APP: $STAGING_HEROKU_APP script: - gem install dpl - - dpl --provider=heroku --api-key=$HEROKU_API_KEY --app=$HEROKU_APP --run="python3 manage.py migrate" + - dpl --provider=heroku --api-key=$HEROKU_API_KEY --app=$HEROKU_APP --run='python3 manage.py migrate' only: - staging @@ -69,6 +69,6 @@ production: - chmod 700 ~/.ssh - ssh-keyscan $VPS_PUBLIC_IP_ADDRESS >> ~/.ssh/known_hosts - chmod 644 ~/.ssh/known_hosts - - ssh $VPS_USERNAME@$VPS_PUBLIC_IP_ADDRESS "~/build-api" + - ssh $VPS_USERNAME@$VPS_PUBLIC_IP_ADDRESS '~/build-api' only: - master diff --git a/README.md b/README.md index 07f7b35..9a6c1f6 100644 --- a/README.md +++ b/README.md @@ -5,63 +5,15 @@ ## Table of Contents +- [Environment Variables](#environment-variables) + - [Local](#local) + - [CI](#ci) + - [Staging](#staging) + - [Production](#production) - [Local Configuration](#local-configuration) - [Deployed API URLs](#deployed-api-urls) - [API Documentation](#api-documentation) -## Local Configuration - -- Install Python 3.6 or higher and PostgreSQL - -- Create Python virtual environment - -```bash -$ cd /path/to/project/directory -$ python3 -m venv env -$ source env/bin/activate -$ pip3 install -r requirements.txt -``` - -- Create PostgreSQL database - -- Set up environment variables (change as needed) - -```bash -$ export DATABASE_HOST="127.0.0.1" -$ export DATABASE_NAME="home_industry" -$ export DATABASE_PASSWORD="postgres" -$ export DATABASE_PORT="5432" -$ export DATABASE_USER="postgres" -$ export DJANGO_SETTINGS_MODULE="home_industry.settings.local" -$ export SECRET_KEY="7&s33ax$lxxzti1)0y=8#tu!$7bdy)p$1@kn06tp&8x8i9#h2u" -``` - -- Migrate the database - -```bash -$ python3 manage.py migrate -``` - -- Create Superuser - -```bash -$ python3 manage.py createsuperuser -``` - -- Set up API configuration by modifying `api_config.yaml` - -- Create or update the API configuration in database - -```bash -$ python3 manage.py createorupdateapiconfig -``` - -- Run server - -```bash -$ python3 manage.py runserver -``` - ## Environment Variables ### Local @@ -124,6 +76,59 @@ Key | Required | Example `HOME_INDUSTRY_ADMIN_SITE_PROGRAM_DONATION_PATH` | no | `program-donations/` `SECRET_KEY` | yes | `7&s33ax$lxxzti1)0y=8#tu!$7bdy)p$1@kn06tp&8x8i9#h2u` +## Local Configuration + +- Install Python 3.6 or higher and PostgreSQL + +- Create Python virtual environment + +```bash +$ cd /path/to/project/directory +$ python3 -m venv env +$ source env/bin/activate +$ pip3 install -r requirements.txt +``` + +- Create PostgreSQL database + +- Set up environment variables (change as needed) + +```bash +$ export DATABASE_HOST='127.0.0.1' +$ export DATABASE_NAME='home_industry' +$ export DATABASE_PASSWORD='postgres' +$ export DATABASE_PORT='5432' +$ export DATABASE_USER='postgres' +$ export DJANGO_SETTINGS_MODULE='home_industry.settings.local' +$ export SECRET_KEY='7&s33ax$lxxzti1)0y=8#tu!$7bdy)p$1@kn06tp&8x8i9#h2u' +``` + +- Migrate the database + +```bash +$ python3 manage.py migrate +``` + +- Create Superuser + +```bash +$ python3 manage.py createsuperuser +``` + +- Set up API configuration by modifying `api_config.yaml` + +- Create or update the API configuration in database + +```bash +$ python3 manage.py createorupdateapiconfig +``` + +- Run server + +```bash +$ python3 manage.py runserver +``` + ## Deployed API URLs - Staging: https://industripilar-staging.herokuapp.com -- GitLab From a797ba6cfd2153c26a3f096a320cbc675782695e Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Sat, 6 Jun 2020 04:15:42 +0700 Subject: [PATCH 83/90] [CHORES] Update README.md --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 9a6c1f6..24e69ad 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ Key | Required | Example Key | Required | Example --- | --- | --- +`DJANGO_SETTINGS_MODULE` | yes | `home_industry.settings.ci` `SECRET_KEY` | yes | `7&s33ax$lxxzti1)0y=8#tu!$7bdy)p$1@kn06tp&8x8i9#h2u` ### Staging -- GitLab From 7bf73bdf932d292a85b1eb5f14e8a42352094cb2 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Sat, 6 Jun 2020 04:20:05 +0700 Subject: [PATCH 84/90] [CHORES] Update .gitlab-ci.yml --- .gitlab-ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 33caa0b..de93625 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -11,7 +11,7 @@ lint: DJANGO_SETTINGS_MODULE: $CI_TEST_DJANGO_SETTINGS_MODULE SECRET_KEY: $CI_TEST_SECRET_KEY script: - - pip install -r requirements.txt + - pip3 install -r requirements.txt - python3 manage.py migrate - pylint --exit-zero api @@ -26,7 +26,7 @@ test: DJANGO_SETTINGS_MODULE: $CI_TEST_DJANGO_SETTINGS_MODULE SECRET_KEY: $CI_TEST_SECRET_KEY script: - - pip install -r requirements.txt + - pip3 install -r requirements.txt - python3 manage.py migrate - coverage run manage.py test - coverage xml @@ -62,7 +62,7 @@ production: VPS_PUBLIC_IP_ADDRESS: $PRODUCTION_VPS_PUBLIC_IP_ADDRESS VPS_USERNAME: $PRODUCTION_VPS_USERNAME script: - - which ssh-agent || ( apt update -y && apt install openssh-client git -y ) + - which ssh-agent || ( apt -y update && apt -y install openssh-client ) - eval $(ssh-agent -s) - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add - - mkdir -p ~/.ssh -- GitLab From eac40d7241f7e19652db7c7875c2048f671f1fd4 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Sun, 7 Jun 2020 01:05:38 +0700 Subject: [PATCH 85/90] [GREEN] Update unit tests --- api/tests.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/api/tests.py b/api/tests.py index 5993b93..7b3cd49 100644 --- a/api/tests.py +++ b/api/tests.py @@ -8,7 +8,7 @@ from django.core import management from PIL import Image from rest_framework import exceptions, status, test as rest_framework_test -from api import models, seeds, utils +from api import models, seeds, utils, views def create_tmp_image(): @@ -728,6 +728,11 @@ class ReportViewsTest(rest_framework_test.APITestCase): ) models.ShipmentConfig.objects.create(**seeds.SHIPMENT_CONFIG_DATA) + def test_get_filename_not_implemented_error(self): + with self.assertRaises(NotImplementedError): + view = views.ReportAPIView() + view.get_filename({}) + def test_transaction_report_success(self): user = create_user(seeds.USER_DATA) category = models.Category.objects.create(**seeds.CATEGORY_DATA) @@ -748,6 +753,12 @@ class ReportViewsTest(rest_framework_test.APITestCase): )) transaction.transaction_status = '005' transaction.save() + response = request( + 'GET', + 'transaction-report', + http_authorization=self.superuser_http_authorization + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) user.delete() response = request( 'GET', @@ -766,6 +777,12 @@ class ReportViewsTest(rest_framework_test.APITestCase): )) program_donation.donation_status = '002' program_donation.save() + response = request( + 'GET', + 'program-donation-report', + http_authorization=self.superuser_http_authorization + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) program.delete() response = request( 'GET', -- GitLab From 7a416ec74bbad33dc065d2fc30a8bcce0566ccd3 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Sun, 7 Jun 2020 02:08:54 +0700 Subject: [PATCH 86/90] [CHORES] Squash migrations --- api/migrations/0001_initial.py | 46 ++++++++++---- api/migrations/0002_auto_20200512_0230.py | 44 ------------- api/migrations/0003_auto_20200520_0710.py | 23 ------- api/migrations/0004_auto_20200521_2252.py | 62 ------------------- api/migrations/0005_auto_20200521_2327.py | 39 ------------ .../0006_delete_bankaccountconfig.py | 16 ----- api/migrations/0007_auto_20200606_0021.py | 17 ----- 7 files changed, 35 insertions(+), 212 deletions(-) delete mode 100644 api/migrations/0002_auto_20200512_0230.py delete mode 100644 api/migrations/0003_auto_20200520_0710.py delete mode 100644 api/migrations/0004_auto_20200521_2252.py delete mode 100644 api/migrations/0005_auto_20200521_2327.py delete mode 100644 api/migrations/0006_delete_bankaccountconfig.py delete mode 100644 api/migrations/0007_auto_20200606_0021.py diff --git a/api/migrations/0001_initial.py b/api/migrations/0001_initial.py index 1c08bce..573f92f 100644 --- a/api/migrations/0001_initial.py +++ b/api/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.0.3 on 2020-04-27 16:21 +# Generated by Django 3.0.7 on 2020-06-06 19:07 import api.utils from decimal import Decimal @@ -71,16 +71,18 @@ class Migration(migrations.Migration): }, ), migrations.CreateModel( - name='BankAccountConfig', + name='BankAccountTransferDestination', fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='ID')), ('bank_name', models.CharField(max_length=100, verbose_name='bank name')), - ('account_number', models.CharField(max_length=100, verbose_name='account number')), - ('account_owner', models.CharField(max_length=200, verbose_name='account owner')), + ('bank_account_number', models.CharField(max_length=100, verbose_name='bank account number')), + ('bank_account_name', models.CharField(max_length=200, verbose_name='bank account name')), ], options={ - 'verbose_name': 'bank account configuration', - 'verbose_name_plural': 'bank account configurations', + 'verbose_name': 'bank account transfer destination', + 'verbose_name_plural': 'bank account transfer destinations', + 'ordering': ['bank_name', 'id'], + 'unique_together': {('bank_name', 'bank_account_number')}, }, ), migrations.CreateModel( @@ -96,6 +98,17 @@ class Migration(migrations.Migration): 'ordering': ['name', 'id'], }, ), + migrations.CreateModel( + name='HelpContactConfig', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, verbose_name='email')), + ], + options={ + 'verbose_name': 'help contact configuration', + 'verbose_name_plural': 'help contact configurations', + }, + ), migrations.CreateModel( name='Product', fields=[ @@ -125,7 +138,10 @@ class Migration(migrations.Migration): ('end_date_time', models.DateTimeField(blank=True, null=True, verbose_name='end date and time')), ('location', models.CharField(blank=True, max_length=200, null=True, verbose_name='location')), ('speaker', models.CharField(blank=True, max_length=200, null=True, verbose_name='speaker')), + ('open_donation', models.BooleanField(default=True, verbose_name='open for donation')), + ('program_minutes', models.FileField(blank=True, null=True, upload_to=api.utils.get_upload_file_path, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['pdf'])], verbose_name='program minutes')), ('poster_image', models.ImageField(blank=True, null=True, upload_to=api.utils.get_upload_file_path, verbose_name='poster image')), + ('link', models.URLField(blank=True, null=True, verbose_name='link')), ], options={ 'verbose_name': 'program', @@ -161,15 +177,19 @@ class Migration(migrations.Migration): ('shipping_hamlet', models.CharField(max_length=3, validators=[django.core.validators.RegexValidator(re.compile('^\\d+(?:\\d+)*\\Z'), code='invalid', message=None), django.core.validators.MinLengthValidator(3)], verbose_name='shipping hamlet')), ('shipping_urban_village', models.CharField(max_length=100, verbose_name='shipping urban village')), ('shipping_sub_district', models.CharField(max_length=100, verbose_name='shipping sub-district')), - ('shipping_costs', models.DecimalField(decimal_places=2, max_digits=12, validators=[django.core.validators.MinValueValidator(Decimal('0'))], verbose_name='shipping costs')), + ('shipping_costs', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True, validators=[django.core.validators.MinValueValidator(Decimal('0'))], verbose_name='shipping costs')), ('payment_method', models.CharField(choices=[('TRF', 'Transfer'), ('COD', 'Cash on delivery')], max_length=3, verbose_name='payment method')), ('donation', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=12, validators=[django.core.validators.MinValueValidator(Decimal('0'))], verbose_name='donation')), ('transaction_status', models.CharField(choices=[('001', 'Waiting for proof of payment'), ('002', 'Waiting for seller confirmation'), ('003', 'In process'), ('004', 'Being shipped'), ('005', 'Completed'), ('006', 'Canceled'), ('007', 'Failed')], max_length=3, verbose_name='transaction status')), ('proof_of_payment', models.ImageField(blank=True, null=True, upload_to=api.utils.get_upload_file_path, verbose_name='proof of payment')), + ('user_bank_name', models.CharField(blank=True, max_length=100, null=True, verbose_name='user bank name')), ('user_bank_account_name', models.CharField(blank=True, max_length=200, null=True, verbose_name='user bank account name')), - ('user_bank_account_number', models.CharField(blank=True, max_length=100, null=True, verbose_name='user bank account number')), + ('transfer_destination_bank_name', models.CharField(blank=True, max_length=100, null=True, verbose_name='transfer destination bank name')), + ('transfer_destination_bank_account_name', models.CharField(blank=True, max_length=200, null=True, verbose_name='transfer destination bank account name')), + ('transfer_destination_bank_account_number', models.CharField(blank=True, max_length=100, null=True, verbose_name='transfer destination bank account number')), ('created_at', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='created at')), ('updated_at', models.DateTimeField(auto_now=True, db_index=True, verbose_name='updated at')), + ('bank_account_transfer_destination', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transactions', to='api.BankAccountTransferDestination', verbose_name='bank account transfer destination')), ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transactions', to=settings.AUTH_USER_MODEL, verbose_name='user')), ], options={ @@ -232,17 +252,21 @@ class Migration(migrations.Migration): ('amount', models.DecimalField(decimal_places=2, max_digits=12, validators=[django.core.validators.MinValueValidator(Decimal('0.01'))], verbose_name='amount')), ('donation_status', models.CharField(choices=[('001', 'Waiting for admin confirmation'), ('002', 'Completed'), ('003', 'Canceled'), ('004', 'Waiting for reupload of proof of bank transfer')], default='001', max_length=3, verbose_name='donation status')), ('proof_of_bank_transfer', models.ImageField(upload_to=api.utils.get_upload_file_path, verbose_name='proof of bank transfer')), + ('user_bank_name', models.CharField(blank=True, max_length=100, null=True, verbose_name='user bank name')), ('user_bank_account_name', models.CharField(max_length=200, verbose_name='user bank account name')), - ('user_bank_account_number', models.CharField(max_length=100, verbose_name='user bank account number')), + ('transfer_destination_bank_name', models.CharField(blank=True, max_length=100, null=True, verbose_name='transfer destination bank name')), + ('transfer_destination_bank_account_name', models.CharField(blank=True, max_length=200, null=True, verbose_name='transfer destination bank account name')), + ('transfer_destination_bank_account_number', models.CharField(blank=True, max_length=100, null=True, verbose_name='transfer destination bank account number')), ('created_at', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='created at')), ('updated_at', models.DateTimeField(auto_now=True, db_index=True, verbose_name='updated at')), + ('bank_account_transfer_destination', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='program_donations', to='api.BankAccountTransferDestination', verbose_name='bank account transfer destination')), ('program', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='program_donations', to='api.Program', verbose_name='program')), ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='program_donations', to=settings.AUTH_USER_MODEL, verbose_name='user')), ], options={ 'verbose_name': 'program donation', 'verbose_name_plural': 'program donations', - 'ordering': ['-updated_at', '-created_at', 'donation_number', 'id'], + 'ordering': ['-updated_at', '-created_at', 'program', 'donation_number', 'id'], }, ), migrations.AddField( diff --git a/api/migrations/0002_auto_20200512_0230.py b/api/migrations/0002_auto_20200512_0230.py deleted file mode 100644 index 8e2373a..0000000 --- a/api/migrations/0002_auto_20200512_0230.py +++ /dev/null @@ -1,44 +0,0 @@ -# Generated by Django 3.0.3 on 2020-05-11 19:30 - -import api.utils -import django.core.validators -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0001_initial'), - ] - - operations = [ - migrations.CreateModel( - name='HelpContactConfig', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('email', models.EmailField(max_length=254, verbose_name='email')), - ], - options={ - 'verbose_name': 'help contact configuration', - 'verbose_name_plural': 'help contact configurations', - }, - ), - migrations.RemoveField( - model_name='programdonation', - name='user_bank_account_number', - ), - migrations.RemoveField( - model_name='transaction', - name='user_bank_account_number', - ), - migrations.AddField( - model_name='program', - name='open_donation', - field=models.BooleanField(default=True, verbose_name='open for donation'), - ), - migrations.AddField( - model_name='program', - name='program_minutes', - field=models.FileField(blank=True, null=True, upload_to=api.utils.get_upload_file_path, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['pdf'])], verbose_name='program minutes'), - ), - ] diff --git a/api/migrations/0003_auto_20200520_0710.py b/api/migrations/0003_auto_20200520_0710.py deleted file mode 100644 index 9801be4..0000000 --- a/api/migrations/0003_auto_20200520_0710.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 3.0.3 on 2020-05-20 00:10 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0002_auto_20200512_0230'), - ] - - operations = [ - migrations.AddField( - model_name='programdonation', - name='user_bank_name', - field=models.CharField(blank=True, max_length=100, null=True, verbose_name='user bank name'), - ), - migrations.AddField( - model_name='transaction', - name='user_bank_name', - field=models.CharField(blank=True, max_length=100, null=True, verbose_name='user bank name'), - ), - ] diff --git a/api/migrations/0004_auto_20200521_2252.py b/api/migrations/0004_auto_20200521_2252.py deleted file mode 100644 index 3718dbe..0000000 --- a/api/migrations/0004_auto_20200521_2252.py +++ /dev/null @@ -1,62 +0,0 @@ -# Generated by Django 3.0.3 on 2020-05-21 15:52 - -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', '0003_auto_20200520_0710'), - ] - - operations = [ - migrations.AddField( - model_name='program', - name='link', - field=models.URLField(blank=True, null=True, verbose_name='link'), - ), - migrations.AddField( - model_name='transaction', - name='transfer_destination_bank_account_name', - field=models.CharField(blank=True, max_length=200, null=True, verbose_name='transfer destination bank account name'), - ), - migrations.AddField( - model_name='transaction', - name='transfer_destination_bank_account_number', - field=models.CharField(blank=True, max_length=100, null=True, verbose_name='transfer destination bank account number'), - ), - migrations.AddField( - model_name='transaction', - name='transfer_destination_bank_name', - field=models.CharField(blank=True, max_length=100, null=True, verbose_name='transfer destination bank name'), - ), - migrations.AlterField( - model_name='transaction', - name='shipping_costs', - field=models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True, validators=[django.core.validators.MinValueValidator(Decimal('0'))], verbose_name='shipping costs'), - ), - migrations.CreateModel( - name='BankAccountTransferDestination', - fields=[ - ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='ID')), - ('bank_name', models.CharField(max_length=100, verbose_name='bank name')), - ('bank_account_number', models.CharField(max_length=100, verbose_name='bank account number')), - ('bank_account_name', models.CharField(max_length=200, verbose_name='bank account owner')), - ], - options={ - 'verbose_name': 'bank account transfer destination', - 'verbose_name_plural': 'bank account transfer destinations', - 'ordering': ['bank_name', 'id'], - 'unique_together': {('bank_name', 'bank_account_number')}, - }, - ), - migrations.AddField( - model_name='transaction', - name='bank_account_transfer_destination', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transactions', to='api.BankAccountTransferDestination', verbose_name='bank account transfer destination'), - ), - ] diff --git a/api/migrations/0005_auto_20200521_2327.py b/api/migrations/0005_auto_20200521_2327.py deleted file mode 100644 index 4dca250..0000000 --- a/api/migrations/0005_auto_20200521_2327.py +++ /dev/null @@ -1,39 +0,0 @@ -# Generated by Django 3.0.3 on 2020-05-21 16:27 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0004_auto_20200521_2252'), - ] - - operations = [ - migrations.AddField( - model_name='programdonation', - name='bank_account_transfer_destination', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='program_donations', to='api.BankAccountTransferDestination', verbose_name='bank account transfer destination'), - ), - migrations.AddField( - model_name='programdonation', - name='transfer_destination_bank_account_name', - field=models.CharField(blank=True, max_length=200, null=True, verbose_name='transfer destination bank account name'), - ), - migrations.AddField( - model_name='programdonation', - name='transfer_destination_bank_account_number', - field=models.CharField(blank=True, max_length=100, null=True, verbose_name='transfer destination bank account number'), - ), - migrations.AddField( - model_name='programdonation', - name='transfer_destination_bank_name', - field=models.CharField(blank=True, max_length=100, null=True, verbose_name='transfer destination bank name'), - ), - migrations.AlterField( - model_name='bankaccounttransferdestination', - name='bank_account_name', - field=models.CharField(max_length=200, verbose_name='bank account name'), - ), - ] diff --git a/api/migrations/0006_delete_bankaccountconfig.py b/api/migrations/0006_delete_bankaccountconfig.py deleted file mode 100644 index 0cb3836..0000000 --- a/api/migrations/0006_delete_bankaccountconfig.py +++ /dev/null @@ -1,16 +0,0 @@ -# Generated by Django 3.0.3 on 2020-05-22 04:32 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0005_auto_20200521_2327'), - ] - - operations = [ - migrations.DeleteModel( - name='BankAccountConfig', - ), - ] diff --git a/api/migrations/0007_auto_20200606_0021.py b/api/migrations/0007_auto_20200606_0021.py deleted file mode 100644 index 8505d43..0000000 --- a/api/migrations/0007_auto_20200606_0021.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 3.0.7 on 2020-06-05 17:21 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0006_delete_bankaccountconfig'), - ] - - operations = [ - migrations.AlterModelOptions( - name='programdonation', - options={'ordering': ['-updated_at', '-created_at', 'program', 'donation_number', 'id'], 'verbose_name': 'program donation', 'verbose_name_plural': 'program donations'}, - ), - ] -- GitLab From 8315bd0943f53614c173f2f059ed409f2d35dffd Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Sun, 7 Jun 2020 02:27:06 +0700 Subject: [PATCH 87/90] [CHORES] Update README.md --- README.md | 76 +++++++++++++++++++++++-------------------------------- 1 file changed, 31 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 24e69ad..a06f3b5 100644 --- a/README.md +++ b/README.md @@ -80,61 +80,47 @@ Key | Required | Example ## Local Configuration - Install Python 3.6 or higher and PostgreSQL - - Create Python virtual environment - -```bash -$ cd /path/to/project/directory -$ python3 -m venv env -$ source env/bin/activate -$ pip3 install -r requirements.txt -``` - + ```bash + $ cd /path/to/project/directory + $ python3 -m venv env + $ source env/bin/activate + $ pip3 install -r requirements.txt + ``` - Create PostgreSQL database - - Set up environment variables (change as needed) - -```bash -$ export DATABASE_HOST='127.0.0.1' -$ export DATABASE_NAME='home_industry' -$ export DATABASE_PASSWORD='postgres' -$ export DATABASE_PORT='5432' -$ export DATABASE_USER='postgres' -$ export DJANGO_SETTINGS_MODULE='home_industry.settings.local' -$ export SECRET_KEY='7&s33ax$lxxzti1)0y=8#tu!$7bdy)p$1@kn06tp&8x8i9#h2u' -``` - + ```bash + $ export DATABASE_HOST='127.0.0.1' + $ export DATABASE_NAME='home_industry' + $ export DATABASE_PASSWORD='postgres' + $ export DATABASE_PORT='5432' + $ export DATABASE_USER='postgres' + $ export DJANGO_SETTINGS_MODULE='home_industry.settings.local' + $ export SECRET_KEY='7&s33ax$lxxzti1)0y=8#tu!$7bdy)p$1@kn06tp&8x8i9#h2u' + ``` - Migrate the database - -```bash -$ python3 manage.py migrate -``` - -- Create Superuser - -```bash -$ python3 manage.py createsuperuser -``` - + ```bash + $ python3 manage.py migrate + ``` - Set up API configuration by modifying `api_config.yaml` - - Create or update the API configuration in database - -```bash -$ python3 manage.py createorupdateapiconfig -``` - + ```bash + $ python3 manage.py createorupdateapiconfig + ``` - Run server - -```bash -$ python3 manage.py runserver -``` + ```bash + $ python3 manage.py runserver + ``` +- Create Superuser + ```bash + $ python3 manage.py createsuperuser + ``` ## Deployed API URLs -- Staging: https://industripilar-staging.herokuapp.com -- Production: https://api.industripilar.com +- Staging: [https://industripilar-staging.herokuapp.com](https://industripilar-staging.herokuapp.com) +- Production: [https://api.industripilar.com](https://api.industripilar.com) ## API Documentation -https://industripilar-staging.herokuapp.com/docs/ +[https://industripilar-staging.herokuapp.com/docs/](https://industripilar-staging.herokuapp.com/docs/) -- GitLab From 02974c6ee7cfd74f516c3c36b691db5b13dd730d Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Sun, 7 Jun 2020 03:14:33 +0700 Subject: [PATCH 88/90] [CHORES] Update linter and README.md --- .pylintrc | 1 - README.md | 2 +- api/tests.py | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.pylintrc b/.pylintrc index b748bb1..572db8b 100644 --- a/.pylintrc +++ b/.pylintrc @@ -10,5 +10,4 @@ load-plugins= pylint_django [DESIGN] -max-attributes=10 max-parents=8 diff --git a/README.md b/README.md index a06f3b5..f9fcdc8 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ Key | Required | Example `AWS_REGION_NAME` | yes | `ap-southeast-1` `AWS_SECRET_ACCESS_KEY` | yes | `wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY` `AWS_STORAGE_BUCKET_NAME` | yes | `homeindustry-api` -`DATABASE_URL` | yes | `postgres://postgres:postgres@127.0.0.1:5432/home_industry` +`DATABASE_URL` | yes | `postgres://postgres:postgres@ec2-117-21-174-214.compute-1.amazonaws.com:5432/home_industry` `DEBUG` | no | `True` `DJANGO_SETTINGS_MODULE` | yes | `home_industry.settings.staging` `HOME_INDUSTRY_ADMIN_SITE_URL` | no | `https://homeindustry.com/admin/` diff --git a/api/tests.py b/api/tests.py index 7b3cd49..a0e3dc0 100644 --- a/api/tests.py +++ b/api/tests.py @@ -249,7 +249,7 @@ class AuthTest(rest_framework_test.APITestCase): self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) -class CartTest(rest_framework_test.APITestCase): +class CartTest(rest_framework_test.APITestCase): # pylint: disable=too-many-instance-attributes def setUp(self): models.ShipmentConfig.objects.create(**seeds.SHIPMENT_CONFIG_DATA) self.user = create_user(seeds.USER_DATA) -- GitLab From c478fa0cc2b5e8c0cfd691a3caaa4d9b756e1546 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Sun, 7 Jun 2020 04:21:37 +0700 Subject: [PATCH 89/90] [GREEN] Minor changes --- README.md | 8 ++++---- api/serializers.py | 8 ++++---- api/views.py | 4 ++-- locale/id/LC_MESSAGES/django.mo | Bin 13745 -> 13736 bytes locale/id/LC_MESSAGES/django.po | 10 +++++----- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index f9fcdc8..25dd94b 100644 --- a/README.md +++ b/README.md @@ -107,14 +107,14 @@ Key | Required | Example ```bash $ python3 manage.py createorupdateapiconfig ``` -- Run server - ```bash - $ python3 manage.py runserver - ``` - Create Superuser ```bash $ python3 manage.py createsuperuser ``` +- Run server + ```bash + $ python3 manage.py runserver + ``` ## Deployed API URLs diff --git a/api/serializers.py b/api/serializers.py index 5a50569..157f7aa 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -102,22 +102,22 @@ class DonationReuploadProofOfBankTransferSerializer(serializers.Serializer): # p class ReportTransactionSerializer(serializers.Serializer): # pylint: disable=abstract-method created_at_date_range_after = serializers.DateField( - label=_('Created after date'), + label=_('Created from date'), required=False ) created_at_date_range_before = serializers.DateField( - label=_('Created before date'), + label=_('Created to date'), required=False ) class ReportProgramDonationSerializer(serializers.Serializer): # pylint: disable=abstract-method created_at_date_range_after = serializers.DateField( - label=_('Created after date'), + label=_('Created from date'), required=False ) created_at_date_range_before = serializers.DateField( - label=_('Created before date'), + label=_('Created to date'), required=False ) diff --git a/api/views.py b/api/views.py index a0742df..cfcfecd 100644 --- a/api/views.py +++ b/api/views.py @@ -401,7 +401,7 @@ class ReportTransaction(ReportAPIView): filename = '{}.xlsx'.format(_( # pylint: disable=no-member 'Transaction Report from {date_from} to {date_to}' ).format( - date_from=query_params.get('created_at_date_range_after', '*'), + date_from=query_params.get('created_at_date_range_after', '_'), date_to=query_params.get('created_at_date_range_before', str(timezone.now())[:10]) )) return filename @@ -416,7 +416,7 @@ class ReportProgramDonation(ReportAPIView): filename = '{}.xlsx'.format(_( # pylint: disable=no-member 'Program Donation Report from {date_from} to {date_to}' ).format( - date_from=query_params.get('created_at_date_range_after', '*'), + date_from=query_params.get('created_at_date_range_after', '_'), date_to=query_params.get('created_at_date_range_before', str(timezone.now())[:10]) )) return filename diff --git a/locale/id/LC_MESSAGES/django.mo b/locale/id/LC_MESSAGES/django.mo index 57616885c3acf478df384b2170ddf94c67ee91c3..031439bf46aba97becd45c20b3f7c1218020aa0c 100644 GIT binary patch delta 3079 zcmX}teQeEF9LMoDEKbrd3qwR7dNfoqE_VS&+#*2a!vahOJhYq-_0ZEC`db znkH3MQ!*T`R792olC<$sLU0k zHX4QfaVl#58r1un?D=-j8i!B|ov{hc*n$3C9E^{!6mv73%fos20=|z!a5rkh%b0{M zn1z3%PMSuC+j6;BgeBJZDrqRSwKxd3U<8k&QW@yspC|>jad*tea_ohRaRjbMUF9j% zL4HJ?_@?;)70AD+jHgo=%=26}4NV+|S}=k-Sp^niJ!az}RPkIwT~!g2R8%97Slwu3 zZ8saWQ4Dpk_1GJ?V=kV+PI$dd?*9%AMgG`qMcrYi7yT;;qn>w1r8XD!ybyJl!_5g8 zq(2iCz&zAO)u=#gQ30++&fs=nkp10J8oKL~sD;0?{(00DTtY3_jLOt~8*ep}NSijw zL?}s5|}vHUFB8 z-#4Gx^8oio-MUQFydu;A#^N-blFj|Ac=j+b5wD>(9GK%*bE!E6wP7Xd1n;31+KdW( zJL;}KMP=Y?RKO=t^UtBK?sxMRDxmv0o_|tRh(3?7*$=hA2vpTaFdvs8hjWdnnmB^m z;1uc#FQ5Yb1r^{O^BF3m9jQPCn1y8l3frTh1?Tkfi)1x&t*#!mz)sXgUt$@4gXQ=X zU&ms8HpgKd&cM?+8#DX)zY8@uocywKvVjr~rcl{R3p64%oxYL#;Ce)7allq@e)jqEb?2{dZ8s zS%+Lhxx~g}*!KO`wKX^as#tbe{}5{7 z)2OOHkE;3>)MwS1Z&CC6qUIH&YNs3($OH`IeC&!fsElkx&D)8AcyefPa2maEd~9fR zpf+9~>K|x~?+?uh?2QML8v+^e#^hUpF7euw4FMMqq{aiTsWJ6IyIwK(YR#f(Y*9r{ zWli;4ITbYjABj~r?F(0B#Pjn)Ju0F#RXMTf+lv=Rm$}!f7OafMa+Xx7+fR;OjD7H9?2n&e0MA;lVln+rEWsRJD#a=c;Z7{VE*yyYsm8?N zbexY*V+KHufo#$jYl^R1oGfxYP8!2_%=?(N9}A0=Ho^jjE7Oh(~i2TVkW7mCLp<* zDM$=64;82$wXv-@1b5+3Jc03ey+`i9lZG7f+(@_zY_P zH9LOK+HIf5ac|VE$wbX7Ms1)1D{=N84Hd8;%dO@~*4e0lHK-lDj#{V@ zmG~~yUGG7iz~`uhPoUu;!p?qxadPQ6r!K96*3E^2`ZI1Hzws(vN1In#`) ziKD0hZKx~!9+l{is02H$-KZ1wQGrTu5SDuQ(Q{~M!56dLB5{!GH5*Y2w4egDVkv%! z6R``Q#Bn*?D{jC!cnY7#%tzecg_p5}{(jWSc3AJAPR@y8d&;;k>WGHeejdirpMpA> z8K@JPhuU!sDsVk2p<}27AK-E<3A*EZkUg3k=*4)BaV7>ZM&ExG4J|w$N#4|=0yg0` zY{88<>QQ6(F*8Rn7SAL99!xvxXn(iuw+ewXtk#0cxF6OeDUkq@e`Ds3WPf z{k5p#+=RM{<97TUD&Q46-ia!{Zd6evjc`B5p{SD`iR5I;QO}p55?n8dZ?@6UPMWO; zPyyOd30=fIyo&m#T|7=Hn^ljW#+4XB!V1GVr@R08{~t*E>H!g>L<&L61x-B_XT-%ly3 z8fWuoGHyZb?6ma~>TYjXAD|XW9OF6^6?hElgeIa+ZjK$Fi`rn=y3&rnf<52=rk(~n zKo!eA+dqn0_%y2OFQBUaE~-dV_!c!k4>fNBs&=NK5_t-faWSUhI@F0Yq2}#HPc*?- z;H{^Z5`Drq$z!6g`GTI*=sw?E+TMiip54(M3D-Q8(R%+TPjb`~h(1Or^?N#CGaP8`4t)qvn3H%G4kV`cH diff --git a/locale/id/LC_MESSAGES/django.po b/locale/id/LC_MESSAGES/django.po index 180eb13..2e21e74 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-06-06 02:41+0700\n" +"POT-Creation-Date: 2020-06-07 04:18+0700\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -656,12 +656,12 @@ msgid "Program donasi" msgstr "Donasi program" #: api/serializers.py:105 api/serializers.py:116 -msgid "Created after date" -msgstr "Dibuat setelah tanggal" +msgid "Created from date" +msgstr "Dibuat dari tanggal" #: api/serializers.py:109 api/serializers.py:120 -msgid "Created before date" -msgstr "Dibuat sebelum tanggal" +msgid "Created to date" +msgstr "Dibuat hingga tanggal" #: api/serializers.py:240 msgid "Stock cannot be empty if it is not a pre-order." -- GitLab From 31397fd5ef55be731ffc50efebe3fb7f1bc76a87 Mon Sep 17 00:00:00 2001 From: WILLIAM GATES Date: Fri, 11 Sep 2020 20:16:52 +0700 Subject: [PATCH 90/90] [CHORES] Update .gitignore and settings --- .gitignore | 132 +----------------------------- home_industry/settings/local.py | 2 +- home_industry/settings/staging.py | 2 +- 3 files changed, 5 insertions(+), 131 deletions(-) diff --git a/.gitignore b/.gitignore index 9ba3213..b839e25 100644 --- a/.gitignore +++ b/.gitignore @@ -1,133 +1,7 @@ -# 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 +.dpl +__pycache__/ coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ - -# Translations -*.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/ - -# Elastic Beanstalk Files -.elasticbeanstalk/* -!.elasticbeanstalk/*.cfg.yml -!.elasticbeanstalk/*.global.yml +media/ diff --git a/home_industry/settings/local.py b/home_industry/settings/local.py index f09e11d..81f7d36 100644 --- a/home_industry/settings/local.py +++ b/home_industry/settings/local.py @@ -9,7 +9,7 @@ BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__fil SECRET_KEY = os.environ['SECRET_KEY'] -DEBUG = os.environ.get('DEBUG', True) != "False" +DEBUG = os.environ.get('DEBUG', True) != 'False' ALLOWED_HOSTS = ['127.0.0.1', 'localhost'] diff --git a/home_industry/settings/staging.py b/home_industry/settings/staging.py index b9d0651..ad4eab1 100644 --- a/home_industry/settings/staging.py +++ b/home_industry/settings/staging.py @@ -11,7 +11,7 @@ BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__fil SECRET_KEY = os.environ['SECRET_KEY'] -DEBUG = os.environ.get('DEBUG', True) != "False" +DEBUG = os.environ.get('DEBUG', True) != 'False' ALLOWED_HOSTS = ['.herokuapp.com'] -- GitLab