From 2828de6674933a637fff8e6a894c38718f5c966f Mon Sep 17 00:00:00 2001 From: Ivan Balingit Date: Sat, 16 Jul 2022 00:41:37 +0800 Subject: [PATCH 01/10] Initialize Django app and Docker config --- .env.dev | 10 ++ .gitignore | 239 +++++++++++++++++++++++++++++++++++++--- Dockerfile | 22 ++++ Dockerfile.prod | 63 +++++++++++ README.md | 33 +----- docker-compose.prod.yml | 23 ++++ docker-compose.yml | 25 +++++ entrypoint.prod.sh | 14 +++ entrypoint.sh | 16 +++ manage.py | 22 ++++ requirements.txt | 7 ++ togo/__init__.py | 0 togo/asgi.py | 16 +++ togo/settings.py | 125 +++++++++++++++++++++ togo/urls.py | 21 ++++ togo/wsgi.py | 16 +++ 16 files changed, 607 insertions(+), 45 deletions(-) create mode 100644 .env.dev create mode 100644 Dockerfile create mode 100644 Dockerfile.prod create mode 100644 docker-compose.prod.yml create mode 100644 docker-compose.yml create mode 100755 entrypoint.prod.sh create mode 100755 entrypoint.sh create mode 100755 manage.py create mode 100644 requirements.txt create mode 100644 togo/__init__.py create mode 100644 togo/asgi.py create mode 100644 togo/settings.py create mode 100644 togo/urls.py create mode 100644 togo/wsgi.py diff --git a/.env.dev b/.env.dev new file mode 100644 index 000000000..2e025dda4 --- /dev/null +++ b/.env.dev @@ -0,0 +1,10 @@ +DEBUG=1 +SECRET_KEY=ov9-1@q(gxfpqd=nzc0jq5ug&f7ugv*sy3(+2yus(8h9l$4(%5 +DJANGO_ALLOWED_HOSTS=localhost 127.0.0.1 [::1] +SQL_ENGINE=django.db.backends.postgresql +SQL_USER=togo +SQL_PASSWORD=togo_password +SQL_DATABASE=togo_dev +SQL_HOST=db +SQL_PORT=5432 +DATABASE=postgres \ No newline at end of file diff --git a/.gitignore b/.gitignore index a512c8b38..2724df270 100644 --- a/.gitignore +++ b/.gitignore @@ -1,19 +1,232 @@ -# Binaries for programs and plugins -*.exe -*.exe~ -*.dll +# Created by https://www.toptal.com/developers/gitignore/api/python,django +# Edit at https://www.toptal.com/developers/gitignore?templates=python,django + +### Django ### +*.log +*.pot +*.pyc +env +__pycache__/ +local_settings.py +db.sqlite3 +uploads +db.sqlite3-journal +media +.idea +migrations/ +db.json +dump.json +dump2.json +dump3.json +newfile.json +# If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/ +# in your Git repository. Update and uncomment the following line accordingly. +# /staticfiles/ + +### Django.Python Stack ### +# Byte-compiled / optimized / DLL files +*.py[cod] +*$py.class + +# C extensions *.so -*.dylib +try/ +# Distribution / packaging +.Python +try +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +try/ +var/ +wheels/ +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/ +cover/ + +# Translations +*.mo + +# Django stuff: + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .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 +.env.prod +.env.prod.db +.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/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +### Python ### +# Byte-compiled / optimized / DLL files + +# C extensions + +# Distribution / packaging + +# 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. + +# Installer logs + +# Unit test / coverage reports + +# Translations + +# Django stuff: + +# Flask stuff: + +# Scrapy stuff: + +# Sphinx documentation + +# PyBuilder + +# Jupyter Notebook + +# IPython + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .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. + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow + +# Celery stuff + +# SageMath parsed files + +# Environments + +# Spyder project settings + +# Rope project settings + +# mkdocs documentation + +# mypy -# Test binary, built with `go test -c` -*.test +# Pyre type checker -# Output of the go coverage tool, specifically when used with LiteIDE -*.out +# pytype static type analyzer -# Dependency directories (remove the comment below to include it) -# vendor/ +# Cython debug symbols -out/ +# End of https://www.toptal.com/developers/gitignore/api/python,django -.idea \ No newline at end of file +# Logs +logs +logs/* \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..363837c52 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +# pull official base image +FROM python:3.8.5-alpine + +# set work directory +WORKDIR /usr/src/app + +# set environment variables +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 + +# install psycopg2 dependencies +RUN apk update && apk add postgresql-dev gcc python3-dev musl-dev + +# install dependencies +RUN pip install --upgrade pip +COPY ./requirements.txt . +RUN pip install -r requirements.txt + +# copy project +COPY . . +# run entrypoint.sh +ENTRYPOINT ["/usr/src/app/entrypoint.sh"] \ No newline at end of file diff --git a/Dockerfile.prod b/Dockerfile.prod new file mode 100644 index 000000000..6ac0a54ea --- /dev/null +++ b/Dockerfile.prod @@ -0,0 +1,63 @@ +########### +# BUILDER # +########### + +# pull official base image +FROM python:3.8.5-alpine as builder + +# set work directory +WORKDIR /usr/src/app + +# set environment variables +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 + +# install psycopg2 dependencies +RUN apk update && apk add postgresql-dev gcc python3-dev musl-dev + +# install dependencies +COPY ./requirements.txt . +RUN pip wheel --no-cache-dir --no-deps --wheel-dir /usr/src/app/wheels -r requirements.txt + + +######### +# FINAL # +######### + +# pull official base image +FROM python:3.8.5-alpine + +# create directory for the app user +RUN mkdir -p /home/app + +# create the app user +RUN addgroup -S app && adduser -S app -G app + +# create the appropriate directories +ENV HOME=/home/app +ENV APP_HOME=/home/app/web +RUN mkdir $APP_HOME +WORKDIR $APP_HOME + +# install dependencies +RUN apk update && apk add libpq +COPY --from=builder /usr/src/app/wheels /wheels +COPY --from=builder /usr/src/app/requirements.txt . +RUN pip install --no-cache /wheels/* + +# copy entrypoint.prod.sh +COPY ./entrypoint.prod.sh . +RUN sed -i 's/\r$//g' $APP_HOME/entrypoint.prod.sh +RUN chmod +x $APP_HOME/entrypoint.prod.sh + +# copy project +COPY . $APP_HOME + +# chown all the files to the app user +RUN chown -R app:app $APP_HOME + +# change to the app user +USER app + +# run entrypoint.prod.sh +ENTRYPOINT ["/home/app/web/entrypoint.prod.sh"] \ No newline at end of file diff --git a/README.md b/README.md index 6398b5c09..c84c9c5c3 100644 --- a/README.md +++ b/README.md @@ -1,32 +1 @@ -### Requirements - -- Implement one single API which accepts a todo task and records it - - There is a maximum **limit of N tasks per user** that can be added **per day**. - - Different users can have **different** maximum daily limit. -- Write integration (functional) tests -- Write unit tests -- Choose a suitable architecture to make your code simple, organizable, and maintainable -- Using Docker to run locally - - Using Docker for database (if used) is mandatory. -- Write a concise README - - How to run your code locally? - - A sample “curl” command to call your API - - How to run your unit tests locally? - - What do you love about your solution? - - What else do you want us to know about however you do not have enough time to complete? - -### Notes - -- We're using Golang at Manabie. **However**, we encourage you to use the programming language that you are most comfortable with because we want you to **shine** with all your skills and knowledge. - -### How to submit your solution? - -- Fork this repo and show us your development progress via a PR - -### Interesting facts about Manabie - -- Monthly there are about 2 million lines of code changes (inserted/updated/deleted) committed into our GitHub repositories. To avoid **regression bugs**, we write different kinds of **automated tests** (unit/integration (functionality)/end2end) as parts of the definition of done of our assigned tasks. -- We nurture the cultural values: **knowledge sharing** and **good communication**, therefore good written documents and readable, organizable, and maintainable code are in our blood when we build any features to grow our products. -- We have **collaborative** culture at Manabie. Feel free to ask trieu@manabie.com any questions. We are very happy to answer all of them. - -Thank you for spending time to read and attempt our take-home assessment. We are looking forward to your submission. +### Togo \ No newline at end of file diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 000000000..0936e951b --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,23 @@ +version: '3.8' + +services: + web: + build: + context: ../togo + dockerfile: Dockerfile.prod + command: gunicorn togo.wsgi:application --bind 0.0.0.0:8000 + ports: + - 8000:8000 + env_file: + - ./.env.prod + depends_on: + - db + db: + image: postgres:13.0-alpine + volumes: + - postgres_data:/var/lib/postgresql/data/ + env_file: + - ./.env.prod.db + +volumes: + postgres_data: \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..c85ed59d8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,25 @@ +version: '3.8' + +services: + web: + build: ../togo + command: python manage.py runserver 0.0.0.0:8000 + volumes: + - ../togo/:/usr/src/app/ + ports: + - 8000:8000 + env_file: + - ./.env.dev + depends_on: + - db + db: + image: postgres:13.0-alpine + volumes: + - postgres_data:/var/lib/postgresql/data/ + environment: + - POSTGRES_USER=togo + - POSTGRES_PASSWORD=togo_password + - POSTGRES_DB=togo_dev + +volumes: + postgres_data: \ No newline at end of file diff --git a/entrypoint.prod.sh b/entrypoint.prod.sh new file mode 100755 index 000000000..1b31120cb --- /dev/null +++ b/entrypoint.prod.sh @@ -0,0 +1,14 @@ +#!/bin/sh + +if [ "$DATABASE" = "postgres" ] +then + echo "Waiting for postgres..." + + while ! nc -z $SQL_HOST $SQL_PORT; do + sleep 0.1 + done + + echo "PostgreSQL started" +fi + +exec "$@" \ No newline at end of file diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 000000000..ace55cef7 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,16 @@ +#!/bin/sh + +if [ "$DATABASE" = "postgres" ] +then + echo "Waiting for postgres..." + + while ! nc -z $SQL_HOST $SQL_PORT; do + sleep 0.1 + done + + echo "PostgreSQL started" +fi + +python manage.py migrate + +exec "$@" \ No newline at end of file diff --git a/manage.py b/manage.py new file mode 100755 index 000000000..fbb2fae91 --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'togo.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 000000000..9053cd482 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +asgiref==3.5.2 +Django==3.2.7 +djangorestframework==3.12.4 +gunicorn==20.1.0 +psycopg2==2.9.3 +pytz==2022.1 +sqlparse==0.4.2 diff --git a/togo/__init__.py b/togo/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/togo/asgi.py b/togo/asgi.py new file mode 100644 index 000000000..5bf44ed32 --- /dev/null +++ b/togo/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for togo project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/3.1/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'togo.settings') + +application = get_asgi_application() diff --git a/togo/settings.py b/togo/settings.py new file mode 100644 index 000000000..ceb04743e --- /dev/null +++ b/togo/settings.py @@ -0,0 +1,125 @@ +""" +Django settings for togo project. + +Generated by 'django-admin startproject' using Django 3.1.1. + +For more information on this file, see +https://docs.djangoproject.com/en/3.1/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/3.1/ref/settings/ +""" + +from pathlib import Path +import os + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = os.environ.get("SECRET_KEY") + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = int(os.environ.get("DEBUG", default=0)) + +ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS").split(" ") + + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', +] + +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 = 'togo.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 = 'togo.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/3.1/ref/settings/#databases + +DATABASES = { + "default": { + "ENGINE": os.environ.get("SQL_ENGINE", "django.db.backends.sqlite3"), + "NAME": os.environ.get("SQL_DATABASE", BASE_DIR / "db.sqlite3"), + "USER": os.environ.get("SQL_USER", "user"), + "PASSWORD": os.environ.get("SQL_PASSWORD", "password"), + "HOST": os.environ.get("SQL_HOST", "localhost"), + "PORT": os.environ.get("SQL_PORT", "5432"), + } +} + + +# Password validation +# https://docs.djangoproject.com/en/3.1/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.1/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/3.1/howto/static-files/ + +STATIC_URL = '/static/' diff --git a/togo/urls.py b/togo/urls.py new file mode 100644 index 000000000..6e438d903 --- /dev/null +++ b/togo/urls.py @@ -0,0 +1,21 @@ +"""togo URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/3.1/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path + +urlpatterns = [ + path('admin/', admin.site.urls), +] diff --git a/togo/wsgi.py b/togo/wsgi.py new file mode 100644 index 000000000..5c3489133 --- /dev/null +++ b/togo/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for togo project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/3.1/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'togo.settings') + +application = get_wsgi_application() From c0f6072bed5c4cdf616c6f51556f8ffc4d397d6d Mon Sep 17 00:00:00 2001 From: Ivan Balingit Date: Sat, 16 Jul 2022 00:52:50 +0800 Subject: [PATCH 02/10] Create workflow for checking tests --- .github/workflows/django.yml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .github/workflows/django.yml diff --git a/.github/workflows/django.yml b/.github/workflows/django.yml new file mode 100644 index 000000000..58ef0abd3 --- /dev/null +++ b/.github/workflows/django.yml @@ -0,0 +1,30 @@ +name: Django CI + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + max-parallel: 4 + matrix: + python-version: [3.7, 3.8, 3.9] + + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + - name: Install Dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Run Tests + run: | + python manage.py test From df20a540d0d311e6c113f3f6103db0f264d1763d Mon Sep 17 00:00:00 2001 From: Ivan Balingit Date: Sat, 16 Jul 2022 00:54:18 +0800 Subject: [PATCH 03/10] Use Python 3.8.5 --- .github/workflows/django.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/django.yml b/.github/workflows/django.yml index 58ef0abd3..612951d65 100644 --- a/.github/workflows/django.yml +++ b/.github/workflows/django.yml @@ -13,7 +13,7 @@ jobs: strategy: max-parallel: 4 matrix: - python-version: [3.7, 3.8, 3.9] + python-version: [3.8.5] steps: - uses: actions/checkout@v3 From 5822261bea40a9d5b1251b639a3fff47fc29f4f8 Mon Sep 17 00:00:00 2001 From: Ivan Balingit Date: Sat, 16 Jul 2022 01:07:48 +0800 Subject: [PATCH 04/10] Modify ALLOWED_HOSTS --- togo/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/togo/settings.py b/togo/settings.py index ceb04743e..05bd0a798 100644 --- a/togo/settings.py +++ b/togo/settings.py @@ -26,7 +26,7 @@ # SECURITY WARNING: don't run with debug turned on in production! DEBUG = int(os.environ.get("DEBUG", default=0)) -ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS").split(" ") +ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", default="localhost 127.0.0.1 [::1]").split(" ") # Application definition From d5f893115e5fd09e9f55884f543fa683d66b0eb7 Mon Sep 17 00:00:00 2001 From: Ivan Balingit Date: Sat, 16 Jul 2022 01:10:57 +0800 Subject: [PATCH 05/10] Set default values for GitHub workflow --- togo/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/togo/settings.py b/togo/settings.py index 05bd0a798..172d8784d 100644 --- a/togo/settings.py +++ b/togo/settings.py @@ -21,7 +21,7 @@ # See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = os.environ.get("SECRET_KEY") +SECRET_KEY = os.environ.get("SECRET_KEY", default="ov9-1@q(gxfpqd=nzc0jq5ug&f7ugv*sy3(+2yus(8h9l$4(%5") # SECURITY WARNING: don't run with debug turned on in production! DEBUG = int(os.environ.get("DEBUG", default=0)) From 17eab4c9e62fb81f4019307b2dbe7a51c809373f Mon Sep 17 00:00:00 2001 From: Ivan Balingit Date: Sat, 16 Jul 2022 01:14:06 +0800 Subject: [PATCH 06/10] Run workflow only when pushing to master (e.g. merge PR) --- .github/workflows/django.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/django.yml b/.github/workflows/django.yml index 612951d65..23df94bba 100644 --- a/.github/workflows/django.yml +++ b/.github/workflows/django.yml @@ -3,8 +3,6 @@ name: Django CI on: push: branches: [ "master" ] - pull_request: - branches: [ "master" ] jobs: build: From 3cc0d93091ecd86b2f4a016fa54c3c889d98194b Mon Sep 17 00:00:00 2001 From: Ivan Balingit Date: Sat, 16 Jul 2022 02:40:12 +0800 Subject: [PATCH 07/10] Implement User and UserProfile --- Dockerfile.prod | 2 +- entrypoint.prod.sh | 14 -------------- entrypoint.sh | 1 + togo/admin.py | 15 +++++++++++++++ togo/models.py | 12 ++++++++++++ togo/serializers.py | 16 ++++++++++++++++ togo/settings.py | 4 ++++ togo/urls.py | 2 ++ togo/views.py | 21 +++++++++++++++++++++ 9 files changed, 72 insertions(+), 15 deletions(-) delete mode 100755 entrypoint.prod.sh create mode 100644 togo/admin.py create mode 100644 togo/models.py create mode 100644 togo/serializers.py create mode 100644 togo/views.py diff --git a/Dockerfile.prod b/Dockerfile.prod index 6ac0a54ea..a25b9555a 100644 --- a/Dockerfile.prod +++ b/Dockerfile.prod @@ -60,4 +60,4 @@ RUN chown -R app:app $APP_HOME USER app # run entrypoint.prod.sh -ENTRYPOINT ["/home/app/web/entrypoint.prod.sh"] \ No newline at end of file +ENTRYPOINT ["/home/app/web/entrypoint.sh"] \ No newline at end of file diff --git a/entrypoint.prod.sh b/entrypoint.prod.sh deleted file mode 100755 index 1b31120cb..000000000 --- a/entrypoint.prod.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/bin/sh - -if [ "$DATABASE" = "postgres" ] -then - echo "Waiting for postgres..." - - while ! nc -z $SQL_HOST $SQL_PORT; do - sleep 0.1 - done - - echo "PostgreSQL started" -fi - -exec "$@" \ No newline at end of file diff --git a/entrypoint.sh b/entrypoint.sh index ace55cef7..69d5bd315 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -11,6 +11,7 @@ then echo "PostgreSQL started" fi +python manage.py makemigrations togo python manage.py migrate exec "$@" \ No newline at end of file diff --git a/togo/admin.py b/togo/admin.py new file mode 100644 index 000000000..e2fb8c2ad --- /dev/null +++ b/togo/admin.py @@ -0,0 +1,15 @@ +from django.contrib import admin +from togo.models import * + +@admin.register(UserProfile) +class UserProfileAdmin(admin.ModelAdmin): + list_display = ("username", "email", "task_limit", "is_superuser") + + def username(self, obj): + return obj.user.username + + def email(self, obj): + return obj.user.email + + def is_superuser(self, obj): + return obj.user.is_superuser \ No newline at end of file diff --git a/togo/models.py b/togo/models.py new file mode 100644 index 000000000..84ab314d7 --- /dev/null +++ b/togo/models.py @@ -0,0 +1,12 @@ +from django.contrib.auth.models import User +from django.db import models +from django.dispatch import receiver + +class UserProfile(models.Model): + user = models.OneToOneField(User, on_delete=models.CASCADE) + task_limit = models.IntegerField(default=5) + +# Automatically create a UserProfile when a User is created +@receiver(models.signals.post_save, sender=User) +def create_user_profile_for_user(sender, instance, created, **kwargs): + if created: UserProfile.objects.create(user=instance) \ No newline at end of file diff --git a/togo/serializers.py b/togo/serializers.py new file mode 100644 index 000000000..a383b6374 --- /dev/null +++ b/togo/serializers.py @@ -0,0 +1,16 @@ +from django.contrib.auth.models import User +from rest_framework import serializers +from togo.models import * + + +class UserSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ["id", "username", "email", "is_superuser"] + +class UserProfileSerializer(serializers.ModelSerializer): + user = UserSerializer() + + class Meta: + model = UserProfile + fields = ["user", "task_limit"] \ No newline at end of file diff --git a/togo/settings.py b/togo/settings.py index 172d8784d..24ab37d65 100644 --- a/togo/settings.py +++ b/togo/settings.py @@ -38,6 +38,8 @@ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', + 'rest_framework', + 'togo' ] MIDDLEWARE = [ @@ -123,3 +125,5 @@ # https://docs.djangoproject.com/en/3.1/howto/static-files/ STATIC_URL = '/static/' + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' \ No newline at end of file diff --git a/togo/urls.py b/togo/urls.py index 6e438d903..6581ab1e0 100644 --- a/togo/urls.py +++ b/togo/urls.py @@ -15,7 +15,9 @@ """ from django.contrib import admin from django.urls import path +from togo.views import * urlpatterns = [ path('admin/', admin.site.urls), + path('api/users/', UserView.as_view(), name="users"), ] diff --git a/togo/views.py b/togo/views.py new file mode 100644 index 000000000..e614eb9f0 --- /dev/null +++ b/togo/views.py @@ -0,0 +1,21 @@ +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import status +from django.contrib.auth.models import User +from togo.models import UserProfile +from togo.serializers import UserProfileSerializer + + +class UserView(APIView): + """ + API endpoint that allows users to be viewed or edited. + """ + def get(self, request): + response_dict = { "message": "Success.", "users": UserProfileSerializer(UserProfile.objects.all(), many=True).data } + return Response(response_dict, status=status.HTTP_200_OK) + + def post(self, request): + user = User.objects.create(**request.data) + user_profile = UserProfile.objects.get(user=user) + response_dict = { "message": "User successfully created.", "user": UserProfileSerializer(user_profile).data } + return Response(response_dict, status=status.HTTP_200_OK) \ No newline at end of file From ac70cf7d62c5ffe8c12c62b5092dcc5fbe928a05 Mon Sep 17 00:00:00 2001 From: Ivan Balingit Date: Sun, 17 Jul 2022 22:16:58 +0800 Subject: [PATCH 08/10] Implement CRUD for Task model --- requirements.txt | 16 ++++++++------ togo/models.py | 4 ++++ togo/serializers.py | 7 +++++- togo/settings.py | 13 ++++++++++- togo/urls.py | 11 ++++++++++ togo/views.py | 53 ++++++++++++++++++++++++++++++++++++++++++--- 6 files changed, 92 insertions(+), 12 deletions(-) diff --git a/requirements.txt b/requirements.txt index 9053cd482..bf83bb413 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,9 @@ -asgiref==3.5.2 -Django==3.2.7 -djangorestframework==3.12.4 -gunicorn==20.1.0 -psycopg2==2.9.3 -pytz==2022.1 -sqlparse==0.4.2 +asgiref==3.5.2 +Django==3.2.7 +djangorestframework==3.12.4 +djangorestframework-simplejwt==5.2.0 +gunicorn==20.1.0 +psycopg2==2.9.3 +PyJWT==2.4.0 +pytz==2022.1 +sqlparse==0.4.2 diff --git a/togo/models.py b/togo/models.py index 84ab314d7..696e2025c 100644 --- a/togo/models.py +++ b/togo/models.py @@ -6,6 +6,10 @@ class UserProfile(models.Model): user = models.OneToOneField(User, on_delete=models.CASCADE) task_limit = models.IntegerField(default=5) +class Task(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE) + name = models.CharField(max_length=255, blank=True) + # Automatically create a UserProfile when a User is created @receiver(models.signals.post_save, sender=User) def create_user_profile_for_user(sender, instance, created, **kwargs): diff --git a/togo/serializers.py b/togo/serializers.py index a383b6374..6242b6dfc 100644 --- a/togo/serializers.py +++ b/togo/serializers.py @@ -13,4 +13,9 @@ class UserProfileSerializer(serializers.ModelSerializer): class Meta: model = UserProfile - fields = ["user", "task_limit"] \ No newline at end of file + fields = ["user", "task_limit"] + +class TaskSerializer(serializers.ModelSerializer): + class Meta: + model = Task + fields = ["id", "name"] \ No newline at end of file diff --git a/togo/settings.py b/togo/settings.py index 24ab37d65..6d4e201d9 100644 --- a/togo/settings.py +++ b/togo/settings.py @@ -11,6 +11,7 @@ """ from pathlib import Path +from datetime import timedelta import os # Build paths inside the project like this: BASE_DIR / 'subdir'. @@ -52,6 +53,12 @@ 'django.middleware.clickjacking.XFrameOptionsMiddleware', ] +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework_simplejwt.authentication.JWTAuthentication', + ) +} + ROOT_URLCONF = 'togo.urls' TEMPLATES = [ @@ -126,4 +133,8 @@ STATIC_URL = '/static/' -DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' \ No newline at end of file +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(days=1), +} \ No newline at end of file diff --git a/togo/urls.py b/togo/urls.py index 6581ab1e0..ddb37137a 100644 --- a/togo/urls.py +++ b/togo/urls.py @@ -15,9 +15,20 @@ """ from django.contrib import admin from django.urls import path +from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView from togo.views import * urlpatterns = [ path('admin/', admin.site.urls), + + # Authentication + path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'), + path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), + + # Users path('api/users/', UserView.as_view(), name="users"), + + # Tasks + path('api/tasks/', TaskView.as_view(), name="tasks"), + path('api/tasks//', TaskView.as_view(), name="tasks"), ] diff --git a/togo/views.py b/togo/views.py index e614eb9f0..674669293 100644 --- a/togo/views.py +++ b/togo/views.py @@ -1,21 +1,68 @@ from rest_framework.views import APIView from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated from rest_framework import status from django.contrib.auth.models import User -from togo.models import UserProfile -from togo.serializers import UserProfileSerializer +from togo.models import UserProfile, Task +from togo.serializers import UserProfileSerializer, TaskSerializer class UserView(APIView): """ API endpoint that allows users to be viewed or edited. """ + # GET /api/users/ + # Retrieve list of users and their user profiles def get(self, request): - response_dict = { "message": "Success.", "users": UserProfileSerializer(UserProfile.objects.all(), many=True).data } + response_dict = { "message": "User profiles successfully retrieved.", "users": UserProfileSerializer(UserProfile.objects.all(), many=True).data } return Response(response_dict, status=status.HTTP_200_OK) + # POST /api/users/ + # Create new user (registration) def post(self, request): user = User.objects.create(**request.data) user_profile = UserProfile.objects.get(user=user) response_dict = { "message": "User successfully created.", "user": UserProfileSerializer(user_profile).data } + return Response(response_dict, status=status.HTTP_200_OK) + +class TaskView(APIView): + """ + API endpoint that allows CRUD actions for tasks. + """ + permission_classes = (IsAuthenticated,) + + # GET /api/tasks/ + # Retrieve tasks list for currently logged-in user + def get(self, request): + tasks = Task.objects.filter(user=request.user) + response_dict = { "message": "User successfully retrieved.", "tasks": TaskSerializer(tasks, many=True).data } + return Response(response_dict, status=status.HTTP_200_OK) + + # POST /api/tasks/ + # Create new task for currently logged-in user + def post(self, request): + # Check if user exceeded task limit + if Task.objects.filter(user=request.user).count() >= UserProfile.objects.get(user=request.user).task_limit: + return Response({ "message": "Task limit exceeded." }, status=status.HTTP_403_FORBIDDEN) + # Create task if not + else: + task = Task.objects.create(user=request.user, **request.data) + response_dict = { "message": "Task successfully created.", "task": TaskSerializer(task).data } + return Response(response_dict, status=status.HTTP_201_CREATED) + + # PUT /api/tasks// + # Update task with given ID + def put(self, request, task_id=None): + task = Task.objects.get(id=task_id) + task.name = request.data["name"] + task.save() + response_dict = { "message": "Task successfully updated.", "task": TaskSerializer(task).data } + return Response(response_dict, status=status.HTTP_200_OK) + + # DELETE /api/tasks// + # Delete task with given ID + def delete(self, request, task_id=None): + task = Task.objects.get(id=task_id) + task.delete() + response_dict = { "message": "Task successfully deleted." } return Response(response_dict, status=status.HTTP_200_OK) \ No newline at end of file From e27d26a57fc3bb3f57e1a813dee63c0a2d9ab5dd Mon Sep 17 00:00:00 2001 From: Ivan Balingit Date: Mon, 18 Jul 2022 00:09:42 +0800 Subject: [PATCH 09/10] Create tests --- togo/tests.py | 101 ++++++++++++++++++++++++++++++++++++++++++++++++++ togo/views.py | 6 +-- 2 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 togo/tests.py diff --git a/togo/tests.py b/togo/tests.py new file mode 100644 index 000000000..c61d3c53e --- /dev/null +++ b/togo/tests.py @@ -0,0 +1,101 @@ +from rest_framework.test import APITestCase +from rest_framework import status +from django.test import TestCase +from django.contrib.auth.models import User +from togo.models import * + +class UserTestCase(TestCase): + # Test if UserProfile for user is automatically created upon registration + def test_create_user_profile(self): + user = User.objects.create(username="test_user", password="test_password") + self.assertIsNotNone(UserProfile.objects.filter(user=user).first()) + +class UsersAPITestCase(APITestCase): + # Test if user can register properly + def test_user_registration(self): + request_data = { + "username": "test_user", + "password": "test_password" + } + response = self.client.post("/api/users/", request_data, format="json") + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(User.objects.count(), 1) + self.assertEqual(UserProfile.objects.count(), 1) + self.assertEqual(User.objects.get().username, "test_user") + + # Test if user can get token using authentication endpoint + def test_user_authentication(self): + User.objects.create_user(username="test_user", password="test_password") + request_data = { + "username": "test_user", + "password": "test_password" + } + response = self.client.post("/api/token/", request_data, format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn("access", response.data) + self.assertIn("refresh", response.data) + +class TasksAPITestCase(APITestCase): + # Create test user and authenticate the client + def setUp(self): + self.user = User.objects.create_user(username="test_user", password="test_password") + self.client.force_authenticate(user=self.user) + + # Test if task can be created properly + def test_task_create(self): + request_data = { + "name": "Test task name" + } + response = self.client.post("/api/tasks/", request_data, format="json") + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data["task"]["name"], "Test task name") + self.assertEqual(Task.objects.filter(user=self.user).count(), 1) + + # Test if creating tasks beyond task limit will return an error + def test_task_create_limit(self): + request_data = { + "name": "Test task name" + } + + # Maximize task limit + task_limit = UserProfile.objects.get(user=self.user).task_limit + for i in range(task_limit): + self.client.post("/api/tasks/", request_data, format="json") + + # Next task creation should fail + response = self.client.post("/api/tasks/", request_data, format="json") + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Task.objects.filter(user=self.user).count(), task_limit) + + # Test task list retrieval for user + def test_task_read(self): + task1 = Task.objects.create(user=self.user, name="Test task 1 name") + task2 = Task.objects.create(user=self.user, name="Test task 2 name") + response = self.client.get("/api/tasks/", format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data["tasks"]), 2) + self.assertIn({"id": task1.id, "name": "Test task 1 name"}, response.data["tasks"]) + self.assertIn({"id": task2.id, "name": "Test task 2 name"}, response.data["tasks"]) + + # Test if task updates properly + def test_task_update(self): + task = Task.objects.create(user=self.user, name="Test task before update") + request_data = { + "name": "Test task after update" + } + + # Update of task must be successful + response = self.client.put("/api/tasks/{}/".format(task.id), request_data, format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # Retrieving task list should return updated name + response = self.client.get("/api/tasks/", format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn({"id": task.id, "name": "Test task after update"}, response.data["tasks"]) + + # Test if task deletes properly + def test_task_delete(self): + task = Task.objects.create(user=self.user, name="Test task name") + response = self.client.delete("/api/tasks/{}/".format(task.id), format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(Task.objects.count(), 0) diff --git a/togo/views.py b/togo/views.py index 674669293..b08ccd291 100644 --- a/togo/views.py +++ b/togo/views.py @@ -20,10 +20,10 @@ def get(self, request): # POST /api/users/ # Create new user (registration) def post(self, request): - user = User.objects.create(**request.data) + user = User.objects.create_user(**request.data) user_profile = UserProfile.objects.get(user=user) response_dict = { "message": "User successfully created.", "user": UserProfileSerializer(user_profile).data } - return Response(response_dict, status=status.HTTP_200_OK) + return Response(response_dict, status=status.HTTP_201_CREATED) class TaskView(APIView): """ @@ -35,7 +35,7 @@ class TaskView(APIView): # Retrieve tasks list for currently logged-in user def get(self, request): tasks = Task.objects.filter(user=request.user) - response_dict = { "message": "User successfully retrieved.", "tasks": TaskSerializer(tasks, many=True).data } + response_dict = { "message": "Task list successfully retrieved.", "tasks": TaskSerializer(tasks, many=True).data } return Response(response_dict, status=status.HTTP_200_OK) # POST /api/tasks/ From e8c4153937157fbf7e20e7c40ca36e28aa4490bb Mon Sep 17 00:00:00 2001 From: Ivan Balingit Date: Mon, 18 Jul 2022 01:15:57 +0800 Subject: [PATCH 10/10] Update README.md --- README.md | 100 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 99 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c84c9c5c3..215517490 100644 --- a/README.md +++ b/README.md @@ -1 +1,99 @@ -### Togo \ No newline at end of file +# Togo +Test API for the Akaru Backend Engineer Coding Challenge that allows a user to create tasks. + +## About the project +The Togo API is built using the following: +- Python 3.8.5 +- Django 3.2.7 +- Django REST Framework 3.12.4 +- PostgreSQL 13.0 + +## Setting up +You may build a Docker container which contains the Django server as well as a Postgres database: +``` +docker-compose -f docker-compose.yml up --build +``` +Once this is successful, you may access the API at `localhost:8000/api/`. + +Alternatively, you may run the project outside of Docker, using `virtualenv`. +``` +virtualenv env +source env/bin/activate +pip3 install -r requirements.txt +python3 manage.py runserver +``` +Note that this will use SQLite as the default database instead of Postgres. + +## Creating an admin user +You may optionally create a superuser so that you can change the task limits for each user. While the Docker container is running, execute the following command in another terminal: +``` +docker exec -it python manage.py createsuperuser +``` +An interactive console will guide you through setting up the credentials of the admin user. + +You may obtain the container ID by executing `docker ps` and looking for the ID of the `web-1` container. + +## Running the tests +``` +docker exec -it python manage.py test +``` + +## Using the API endpoints + +You may test the API through the following Postman collection: + +[![Run in Postman](https://run.pstmn.io/button.svg)](https://app.getpostman.com/run-collection/3110431-083cc236-df41-4cb7-88b0-deaaf6939eff?action=collection%2Ffork&collection-url=entityId%3D3110431-083cc236-df41-4cb7-88b0-deaaf6939eff%26entityType%3Dcollection%26workspaceId%3Dd75aa22d-c1db-40db-a193-1fee1cdf4e3c#?env%5BLocal%5D=W3sia2V5IjoiSE9TVCIsInZhbHVlIjoiaHR0cDovL2xvY2FsaG9zdDo4MDAwIiwiZW5hYmxlZCI6dHJ1ZSwidHlwZSI6ImRlZmF1bHQiLCJzZXNzaW9uVmFsdWUiOiJodHRwOi8vbG9jYWxob3N0OjgwMDAiLCJzZXNzaW9uSW5kZXgiOjB9LHsia2V5IjoiUFJPRCIsInZhbHVlIjoiaHR0cDovL3NncDEuaXZhbmJhbGluZ2l0Lm1lOjgwMDAiLCJlbmFibGVkIjp0cnVlLCJ0eXBlIjoiZGVmYXVsdCIsInNlc3Npb25WYWx1ZSI6Imh0dHA6Ly9zZ3AxLml2YW5iYWxpbmdpdC5tZTo4MDAwIiwic2Vzc2lvbkluZGV4IjoxfSx7ImtleSI6IlRPS0VOIiwidmFsdWUiOiJleUowZVhBaU9pSktWMVFpTENKaGJHY2lPaUpJVXpJMU5pSjkuZXlKMGIydGxibDkwZVhCbElqb2lZV05qWlhOeklpd2laWGh3SWpveE5qVTNPVEV5TkRrMUxDSnBZWFFpT2pFMk5UYzVNVEl4T1RVc0ltcDBhU0k2SWpCaU9XTTVOamRoTm1ZelpUUTJPV0k1WldOa1pHTm1aR1E0WTJNNU4yTmtJaXdpZFhObGNsOXBaQ0k2TVgwLllnSW00Vi1rVE5ocG9NQlQ3ak1oakFOUk83b3BGMEl6XzNvdkhwVEtTZ1UiLCJlbmFibGVkIjp0cnVlLCJzZXNzaW9uVmFsdWUiOiJleUowZVhBaU9pSktWMVFpTENKaGJHY2lPaUpJVXpJMU5pSjkuZXlKMGIydGxibDkwZVhCbElqb2lZV05qWlhOeklpd2laWGh3SWpveE5qVTRNVFl4Tmpnd0xDSnBZWFFpT2pFMk5UZ3dOelV5T0RBc0ltcDBhU0k2SW1KbE5EQTFOelE1T1ROaS4uLiIsInNlc3Npb25JbmRleCI6Mn0seyJrZXkiOiJUQVNLX0lEIiwidmFsdWUiOiIxIiwiZW5hYmxlZCI6dHJ1ZSwic2Vzc2lvblZhbHVlIjoiMSIsInNlc3Npb25JbmRleCI6M31d) + +The API is also deployed live on my personal server at `http://sgp1.ivanbalingit.me:8000/api/`. + +### User Signup ```POST /api/users/``` +``` +curl -X POST http://localhost:8000/api/users/ \ + -H "Content-Type: application/json" \ + -d '{"username": "your_username", "email": "email@email.com", "password": "password"}' +``` + +### User Login ```POST /api/token/``` +``` +curl -X POST http://localhost:8000/api/token/ \ + -H "Content-Type: application/json" \ + -d '{"username": "your_username", "password": "password"}' +``` +This returns something like ```{"refresh": "REFRESH_TOKEN_HERE", "access": "ACCESS_TOKEN_HERE"}```. You may use the access token for endpoints requiring authentication. +The token lasts for 1 day. You can get a new access token through supplying the refresh token to ```/api/token/refresh/```. + +### Get Tasks for User ```GET /api/tasks/``` +``` +curl -L -X GET http://localhost:8000/api/tasks/ \ + -H "Authorization: Bearer ACCESS_TOKEN_HERE" +``` + +### Create Task for User ```POST /api/tasks/``` +``` +curl -X POST http://localhost:8000/api/tasks/ \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ACCESS_TOKEN_HERE" \ + -d '{"name": "Create tasks API"}' +``` + +### Update Task for User ```PUT /api/tasks//``` +``` +curl -X PUT http://localhost:8000/api/tasks// \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ACCESS_TOKEN_HERE" \ + -d '{"name": "Create tasks API and push to repo"}' +``` + +### Delete Task for User ```DELETE /api/tasks//``` +``` +curl -L -X DELETE http://localhost:8000/api/tasks// \ + -H "Authorization: Bearer ACCESS_TOKEN_HERE" +``` + +## Notes +- I have chosen to use Python/Django REST Framework since it is the platform I am most comfortable with. I have created the structure of the project so that it is simple to understand from the point of view of another person. Since the API has simple requirements, a complex file structure is not needed right now. +- Given more time, the following could have been implemented: + - Modularize the code for the viewsets, serializers, models, and tests so that each component of the project (e.g. User, Task) has its own file for better maintainability. + - Write further tests that will catch possible edge cases when using the API (e.g. deleting a task that does not belong to you). + - Deploy the Docker container in AWS Lightsail instead of my own server. + - Utilize GitHub Actions more for continuous integration.