Repos / pytaku / 6b80e2c238
commit 6b80e2c2383ff22763be11f1cf8b0802f57e4874
Author: Bùi Thành Nhân <hi@imnhan.com>
Date:   Mon May 4 17:21:52 2020 +0700

    init django project

diff --git a/.flake8 b/.flake8
new file mode 100644
index 0000000..9f1ac39
--- /dev/null
+++ b/.flake8
@@ -0,0 +1,6 @@
+[flake8]
+ignore =
+    E501 # long line - taken care of by black already
+    E266 # too many leading '#' for block comment - wtf?
+    C901 # mccabe - I'll judge how complex my code is thank you very much
+    W503 # line break before binary operator - Incompatible with black/pep8
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..6085df7
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+__pycache__
+*.pyc
+*.egg-info
+/dist/
+pytaku.conf.json
+/test_media/
+/pgdata
diff --git a/.isort.cfg b/.isort.cfg
new file mode 100644
index 0000000..cd3bfb3
--- /dev/null
+++ b/.isort.cfg
@@ -0,0 +1,3 @@
+[settings]
+multi_line_output=3
+include_trailing_comma=True
diff --git a/.lvimrc b/.lvimrc
new file mode 100644
index 0000000..ec1cb36
--- /dev/null
+++ b/.lvimrc
@@ -0,0 +1,3 @@
+augroup LOCAL_SETUP
+    autocmd BufNewFile,BufRead *.html set filetype=htmldjango
+augroup end
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..706c2eb
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,29 @@
+dev:
+	pytaku-manage runserver
+
+test: lint djangotest
+
+djangotest:
+	pytaku-manage test --settings=pytaku.test_settings
+
+shell:
+	pytaku-manage shell
+
+lint:
+	flake8
+	black --check .
+	isort --check-only --recursive .
+
+localconfig:
+	pytaku-generate-config > pytaku.conf.json
+
+clean:
+	rm -rf dist
+	rm -rf src/pytaku.egg-info
+
+startdb:
+	docker-compose up -d
+
+destroydb:
+	docker-compose down
+	sudo rm -rf pgdata
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..6c76f86
--- /dev/null
+++ b/README.md
@@ -0,0 +1,51 @@
+# Pytaku
+
+Goals:
+
+- Simplest backend setup that works, but no simpler.
+  A webmaster's installation runlist should be as simple as:
+  + pip install pytaku
+  + pytaku-generate-config > pytaku.conf.json
+  + [edits pytaku.conf.json: db credentials etc.]
+  + pytaku-run
+
+
+# Dev
+
+On Arch Linux:
+
+```sh
+sudo pacman -S postgresql-libs python-poetry
+
+# spin up postgres container, because manually creating databases for dev
+# purposes is fiddly and annoying.
+docker-compose up -d
+
+# assuming pyenv and pyenv-virtualenv are already installed:
+pyenv virtualenv 3.7.7 pytaku
+pyenv activate pytaku
+poetry install
+
+# need to activate again so pyenv can make shims for installed entrypoints:
+# (see [tool.poetry.scripts] in pyproject.toml)
+pyenv deactivate && pyenv activate
+
+# generate initial config for local dev - should work out of the box with the
+# db provided docker-compose above.
+make localconfig
+
+make dev
+```
+
+# Env-specific configs
+
+This project uses [goodconf](https://github.com/lincolnloop/goodconf).
+See what options are available at **src/pytaku/conf.py**.
+
+# Installation for actual use
+
+```sh
+pip install pytaku
+```
+
+To be expanded.
diff --git a/docker-compose.yml b/docker-compose.yml
index 02d3e6d..facf833 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,28 +1,12 @@
 version: '3.7'
 services:
-  pytaku:
-    # or use `:latest-builder` to get currently deployed image with tooling
-    #image: registry.gitlab.com/pytaku/ptk-web:master-builder
-    build:
-      context: ./ptk-web
-      target: builder
-      args:
-        NO_DEV: 0
-    env_file:
-      - ./ptk-web/dockerstuff/envs/local.env
-    volumes:
-      - ./ptk-web:/code
-    # Uncomment this to avoid launching web service automatically:
-    #command: sleep 365d
-    init: true
-    ports:
-      - "8000:8000"
-    depends_on:
-      - postgres
-
   postgres:
-    image: "postgres:11-alpine"
+    image: "postgres:12-alpine"
     environment:
       - 'POSTGRES_USER=ptklocal'
       - 'POSTGRES_PASSWORD=ptklocal'
       - 'POSTGRES_DB=ptklocal'
+    ports:
+      - "127.0.0.1:5432:5432"
+    volumes:
+      - ./pgdata:/var/lib/postgresql/data
diff --git a/poetry.lock b/poetry.lock
index d78f837..2a61293 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,3 +1,11 @@
+[[package]]
+category = "dev"
+description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
+name = "appdirs"
+optional = false
+python-versions = "*"
+version = "1.4.3"
+
 [[package]]
 category = "dev"
 description = "Disable App Nap on OS X 10.9"
@@ -18,6 +26,20 @@ version = "3.2.7"
 [package.extras]
 tests = ["pytest (>=4.3.0,<4.4.0)", "pytest-asyncio (>=0.10.0,<0.11.0)"]
 
+[[package]]
+category = "dev"
+description = "Classes Without Boilerplate"
+name = "attrs"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+version = "19.3.0"
+
+[package.extras]
+azure-pipelines = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "pytest-azurepipelines"]
+dev = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "sphinx", "pre-commit"]
+docs = ["sphinx", "zope.interface"]
+tests = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"]
+
 [[package]]
 category = "dev"
 description = "Specifications for callback functions passed in to an API"
@@ -27,6 +49,34 @@ optional = false
 python-versions = "*"
 version = "0.1.0"
 
+[[package]]
+category = "dev"
+description = "The uncompromising code formatter."
+name = "black"
+optional = false
+python-versions = ">=3.6"
+version = "19.10b0"
+
+[package.dependencies]
+appdirs = "*"
+attrs = ">=18.1.0"
+click = ">=6.5"
+pathspec = ">=0.6,<1"
+regex = "*"
+toml = ">=0.9.4"
+typed-ast = ">=1.4.0"
+
+[package.extras]
+d = ["aiohttp (>=3.3.2)", "aiohttp-cors"]
+
+[[package]]
+category = "dev"
+description = "Composable command line interface toolkit"
+name = "click"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+version = "7.1.2"
+
 [[package]]
 category = "dev"
 description = "Cross-platform colored terminal text."
@@ -84,6 +134,19 @@ mccabe = ">=0.6.0,<0.7.0"
 pycodestyle = ">=2.5.0,<2.6.0"
 pyflakes = ">=2.1.0,<2.2.0"
 
+[[package]]
+category = "main"
+description = "Load configuration variables from a file or environment"
+name = "goodconf"
+optional = false
+python-versions = "*"
+version = "1.0.0"
+
+[package.extras]
+maintainer = ["zest.releaser"]
+tests = ["django (<2.1)", "ruamel.yaml", "pytest (3.5.0)", "pytest-cov (2.5.1)", "pytest-mock (1.7.1)"]
+yaml = ["ruamel.yaml"]
+
 [[package]]
 category = "dev"
 description = "Read metadata from Python packages"
@@ -157,6 +220,20 @@ optional = false
 python-versions = "*"
 version = "0.2.0"
 
+[[package]]
+category = "dev"
+description = "A Python utility / library to sort Python imports."
+name = "isort"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+version = "4.3.21"
+
+[package.extras]
+pipfile = ["pipreqs", "requirementslib"]
+pyproject = ["toml"]
+requirements = ["pipreqs", "pip-api"]
+xdg_home = ["appdirs (>=1.4.0)"]
+
 [[package]]
 category = "dev"
 description = "An autocompletion tool for Python that can be used for text editors."
@@ -190,6 +267,14 @@ version = "0.7.0"
 [package.extras]
 testing = ["docopt", "pytest (>=3.0.7)"]
 
+[[package]]
+category = "dev"
+description = "Utility library for gitignore style pattern matching of file paths."
+name = "pathspec"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+version = "0.8.0"
+
 [[package]]
 category = "dev"
 description = "Pexpect allows easy control of interactive console applications."
@@ -330,6 +415,14 @@ optional = false
 python-versions = "*"
 version = "2020.1"
 
+[[package]]
+category = "dev"
+description = "Alternative regular expression module, to replace re."
+name = "regex"
+optional = false
+python-versions = "*"
+version = "2020.4.4"
+
 [[package]]
 category = "dev"
 description = "Python 2 and 3 compatibility utilities"
@@ -347,6 +440,14 @@ optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
 version = "0.3.1"
 
+[[package]]
+category = "dev"
+description = "Python Library for Tom's Obvious, Minimal Language"
+name = "toml"
+optional = false
+python-versions = "*"
+version = "0.10.0"
+
 [[package]]
 category = "dev"
 description = "Traitlets Python config system"
@@ -364,6 +465,14 @@ six = "*"
 [package.extras]
 test = ["pytest", "mock"]
 
+[[package]]
+category = "dev"
+description = "a fork of Python 2 and 3 ast modules with type comment support"
+name = "typed-ast"
+optional = false
+python-versions = "*"
+version = "1.4.1"
+
 [[package]]
 category = "dev"
 description = "Ultra fast JSON encoder and decoder for Python"
@@ -396,10 +505,14 @@ docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"]
 testing = ["jaraco.itertools", "func-timeout"]
 
 [metadata]
-content-hash = "e1b232f7b6aad51a35b9de578600bb1b77f222cc99dafc8621a334ea749e91f2"
+content-hash = "6bda94aa80b72143c515264c16b725f01d1bcac858b642f637dd99deca5cf6c7"
 python-versions = "^3.7"
 
 [metadata.files]
+appdirs = [
+    {file = "appdirs-1.4.3-py2.py3-none-any.whl", hash = "sha256:d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e"},
+    {file = "appdirs-1.4.3.tar.gz", hash = "sha256:9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92"},
+]
 appnope = [
     {file = "appnope-0.1.0-py2.py3-none-any.whl", hash = "sha256:5b26757dc6f79a3b7dc9fab95359328d5747fcb2409d331ea66d0272b90ab2a0"},
     {file = "appnope-0.1.0.tar.gz", hash = "sha256:8b995ffe925347a2138d7ac0fe77155e4311a0ea6d6da4f5128fe4b3cbe5ed71"},
@@ -408,10 +521,22 @@ asgiref = [
     {file = "asgiref-3.2.7-py2.py3-none-any.whl", hash = "sha256:9ca8b952a0a9afa61d30aa6d3d9b570bb3fd6bafcf7ec9e6bed43b936133db1c"},
     {file = "asgiref-3.2.7.tar.gz", hash = "sha256:8036f90603c54e93521e5777b2b9a39ba1bad05773fcf2d208f0299d1df58ce5"},
 ]
+attrs = [
+    {file = "attrs-19.3.0-py2.py3-none-any.whl", hash = "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c"},
+    {file = "attrs-19.3.0.tar.gz", hash = "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"},
+]
 backcall = [
     {file = "backcall-0.1.0.tar.gz", hash = "sha256:38ecd85be2c1e78f77fd91700c76e14667dc21e2713b63876c0eb901196e01e4"},
     {file = "backcall-0.1.0.zip", hash = "sha256:bbbf4b1e5cd2bdb08f915895b51081c041bac22394fdfcfdfbe9f14b77c08bf2"},
 ]
+black = [
+    {file = "black-19.10b0-py36-none-any.whl", hash = "sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b"},
+    {file = "black-19.10b0.tar.gz", hash = "sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539"},
+]
+click = [
+    {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"},
+    {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"},
+]
 colorama = [
     {file = "colorama-0.4.3-py2.py3-none-any.whl", hash = "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff"},
     {file = "colorama-0.4.3.tar.gz", hash = "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1"},
@@ -432,6 +557,10 @@ flake8 = [
     {file = "flake8-3.7.9-py2.py3-none-any.whl", hash = "sha256:49356e766643ad15072a789a20915d3c91dc89fd313ccd71802303fd67e4deca"},
     {file = "flake8-3.7.9.tar.gz", hash = "sha256:45681a117ecc81e870cbf1262835ae4af5e7a8b08e40b944a8a6e6b895914cfb"},
 ]
+goodconf = [
+    {file = "goodconf-1.0.0-py2.py3-none-any.whl", hash = "sha256:beb2f9ed734015e1becd4338d8b1e363cf51fb52e2f794f4e85e8c59d097442e"},
+    {file = "goodconf-1.0.0.tar.gz", hash = "sha256:2c33460b4d9859ffacff32355b7effb1a922a16c1d54e8edd6452503bd8e809b"},
+]
 importlib-metadata = [
     {file = "importlib_metadata-1.6.0-py2.py3-none-any.whl", hash = "sha256:2a688cbaa90e0cc587f1df48bdc97a6eadccdcd9c35fb3f976a09e3b5016d90f"},
     {file = "importlib_metadata-1.6.0.tar.gz", hash = "sha256:34513a8a0c4962bc66d35b359558fd8a5e10cd472d37aec5f66858addef32c1e"},
@@ -447,6 +576,10 @@ ipython-genutils = [
     {file = "ipython_genutils-0.2.0-py2.py3-none-any.whl", hash = "sha256:72dd37233799e619666c9f639a9da83c34013a73e8bbc79a7a6348d93c61fab8"},
     {file = "ipython_genutils-0.2.0.tar.gz", hash = "sha256:eb2e116e75ecef9d4d228fdc66af54269afa26ab4463042e33785b887c628ba8"},
 ]
+isort = [
+    {file = "isort-4.3.21-py2.py3-none-any.whl", hash = "sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd"},
+    {file = "isort-4.3.21.tar.gz", hash = "sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1"},
+]
 jedi = [
     {file = "jedi-0.15.2-py2.py3-none-any.whl", hash = "sha256:1349c1e8c107095a55386628bb3b2a79422f3a2cab8381e34ce19909e0cf5064"},
     {file = "jedi-0.15.2.tar.gz", hash = "sha256:e909527104a903606dd63bea6e8e888833f0ef087057829b89a18364a856f807"},
@@ -459,6 +592,10 @@ parso = [
     {file = "parso-0.7.0-py2.py3-none-any.whl", hash = "sha256:158c140fc04112dc45bca311633ae5033c2c2a7b732fa33d0955bad8152a8dd0"},
     {file = "parso-0.7.0.tar.gz", hash = "sha256:908e9fae2144a076d72ae4e25539143d40b8e3eafbaeae03c1bfe226f4cdf12c"},
 ]
+pathspec = [
+    {file = "pathspec-0.8.0-py2.py3-none-any.whl", hash = "sha256:7d91249d21749788d07a2d0f94147accd8f845507400749ea19c1ec9054a12b0"},
+    {file = "pathspec-0.8.0.tar.gz", hash = "sha256:da45173eb3a6f2a5a487efba21f050af2b41948be6ab52b6a1e3ff22bb8b7061"},
+]
 pexpect = [
     {file = "pexpect-4.8.0-py2.py3-none-any.whl", hash = "sha256:0b48a55dcb3c05f3329815901ea4fc1537514d6ba867a152b581d69ae3710937"},
     {file = "pexpect-4.8.0.tar.gz", hash = "sha256:fc65a43959d153d0114afe13997d439c22823a27cefceb5ff35c2178c6784c0c"},
@@ -518,6 +655,29 @@ pytz = [
     {file = "pytz-2020.1-py2.py3-none-any.whl", hash = "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed"},
     {file = "pytz-2020.1.tar.gz", hash = "sha256:c35965d010ce31b23eeb663ed3cc8c906275d6be1a34393a1d73a41febf4a048"},
 ]
+regex = [
+    {file = "regex-2020.4.4-cp27-cp27m-win32.whl", hash = "sha256:90742c6ff121a9c5b261b9b215cb476eea97df98ea82037ec8ac95d1be7a034f"},
+    {file = "regex-2020.4.4-cp27-cp27m-win_amd64.whl", hash = "sha256:24f4f4062eb16c5bbfff6a22312e8eab92c2c99c51a02e39b4eae54ce8255cd1"},
+    {file = "regex-2020.4.4-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:08119f707f0ebf2da60d2f24c2f39ca616277bb67ef6c92b72cbf90cbe3a556b"},
+    {file = "regex-2020.4.4-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:c9423a150d3a4fc0f3f2aae897a59919acd293f4cb397429b120a5fcd96ea3db"},
+    {file = "regex-2020.4.4-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:c087bff162158536387c53647411db09b6ee3f9603c334c90943e97b1052a156"},
+    {file = "regex-2020.4.4-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:1cbe0fa0b7f673400eb29e9ef41d4f53638f65f9a2143854de6b1ce2899185c3"},
+    {file = "regex-2020.4.4-cp36-cp36m-win32.whl", hash = "sha256:0ce9537396d8f556bcfc317c65b6a0705320701e5ce511f05fc04421ba05b8a8"},
+    {file = "regex-2020.4.4-cp36-cp36m-win_amd64.whl", hash = "sha256:7e1037073b1b7053ee74c3c6c0ada80f3501ec29d5f46e42669378eae6d4405a"},
+    {file = "regex-2020.4.4-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:4385f12aa289d79419fede43f979e372f527892ac44a541b5446617e4406c468"},
+    {file = "regex-2020.4.4-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:a58dd45cb865be0ce1d5ecc4cfc85cd8c6867bea66733623e54bd95131f473b6"},
+    {file = "regex-2020.4.4-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:ccccdd84912875e34c5ad2d06e1989d890d43af6c2242c6fcfa51556997af6cd"},
+    {file = "regex-2020.4.4-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:ea4adf02d23b437684cd388d557bf76e3afa72f7fed5bbc013482cc00c816948"},
+    {file = "regex-2020.4.4-cp37-cp37m-win32.whl", hash = "sha256:2294f8b70e058a2553cd009df003a20802ef75b3c629506be20687df0908177e"},
+    {file = "regex-2020.4.4-cp37-cp37m-win_amd64.whl", hash = "sha256:e91ba11da11cf770f389e47c3f5c30473e6d85e06d7fd9dcba0017d2867aab4a"},
+    {file = "regex-2020.4.4-cp38-cp38-manylinux1_i686.whl", hash = "sha256:5635cd1ed0a12b4c42cce18a8d2fb53ff13ff537f09de5fd791e97de27b6400e"},
+    {file = "regex-2020.4.4-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:23069d9c07e115537f37270d1d5faea3e0bdded8279081c4d4d607a2ad393683"},
+    {file = "regex-2020.4.4-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:c162a21e0da33eb3d31a3ac17a51db5e634fc347f650d271f0305d96601dc15b"},
+    {file = "regex-2020.4.4-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:fb95debbd1a824b2c4376932f2216cc186912e389bdb0e27147778cf6acb3f89"},
+    {file = "regex-2020.4.4-cp38-cp38-win32.whl", hash = "sha256:2a3bf8b48f8e37c3a40bb3f854bf0121c194e69a650b209628d951190b862de3"},
+    {file = "regex-2020.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:5bfed051dbff32fd8945eccca70f5e22b55e4148d2a8a45141a3b053d6455ae3"},
+    {file = "regex-2020.4.4.tar.gz", hash = "sha256:295badf61a51add2d428a46b8580309c520d8b26e769868b922750cf3ce67142"},
+]
 six = [
     {file = "six-1.14.0-py2.py3-none-any.whl", hash = "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c"},
     {file = "six-1.14.0.tar.gz", hash = "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a"},
@@ -526,10 +686,38 @@ sqlparse = [
     {file = "sqlparse-0.3.1-py2.py3-none-any.whl", hash = "sha256:022fb9c87b524d1f7862b3037e541f68597a730a8843245c349fc93e1643dc4e"},
     {file = "sqlparse-0.3.1.tar.gz", hash = "sha256:e162203737712307dfe78860cc56c8da8a852ab2ee33750e33aeadf38d12c548"},
 ]
+toml = [
+    {file = "toml-0.10.0-py2.7.egg", hash = "sha256:f1db651f9657708513243e61e6cc67d101a39bad662eaa9b5546f789338e07a3"},
+    {file = "toml-0.10.0-py2.py3-none-any.whl", hash = "sha256:235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e"},
+    {file = "toml-0.10.0.tar.gz", hash = "sha256:229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c"},
+]
 traitlets = [
     {file = "traitlets-4.3.3-py2.py3-none-any.whl", hash = "sha256:70b4c6a1d9019d7b4f6846832288f86998aa3b9207c6821f3578a6a6a467fe44"},
     {file = "traitlets-4.3.3.tar.gz", hash = "sha256:d023ee369ddd2763310e4c3eae1ff649689440d4ae59d7485eb4cfbbe3e359f7"},
 ]
+typed-ast = [
+    {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3"},
+    {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb"},
+    {file = "typed_ast-1.4.1-cp35-cp35m-win32.whl", hash = "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919"},
+    {file = "typed_ast-1.4.1-cp35-cp35m-win_amd64.whl", hash = "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01"},
+    {file = "typed_ast-1.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75"},
+    {file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652"},
+    {file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"},
+    {file = "typed_ast-1.4.1-cp36-cp36m-win32.whl", hash = "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1"},
+    {file = "typed_ast-1.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa"},
+    {file = "typed_ast-1.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614"},
+    {file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41"},
+    {file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b"},
+    {file = "typed_ast-1.4.1-cp37-cp37m-win32.whl", hash = "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe"},
+    {file = "typed_ast-1.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355"},
+    {file = "typed_ast-1.4.1-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6"},
+    {file = "typed_ast-1.4.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907"},
+    {file = "typed_ast-1.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d"},
+    {file = "typed_ast-1.4.1-cp38-cp38-win32.whl", hash = "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c"},
+    {file = "typed_ast-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4"},
+    {file = "typed_ast-1.4.1-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34"},
+    {file = "typed_ast-1.4.1.tar.gz", hash = "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b"},
+]
 ujson = [
     {file = "ujson-1.35.tar.gz", hash = "sha256:f66073e5506e91d204ab0c614a148d5aa938bdbf104751be66f8ad7a222f5f86"},
 ]
diff --git a/pyproject.toml b/pyproject.toml
index 59ba16e..0411818 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,19 +1,31 @@
 [tool.poetry]
-name = "reader"
+name = "pytaku"
 version = "0.1.0"
 description = ""
-authors = ["nhanb"]
 license = "MIT"
+authors = ["Bùi Thành Nhân <hi@imnhan.com>"]
+packages = [
+    { include = "pytaku", from = "src" },
+    { include = "pytaku_web", from = "src" },
+]
+
+[tool.poetry.scripts]
+pytaku-manage = "pytaku:manage"
+pytaku-generate-config = "pytaku:generate_config"
+pytaku-generate-psql-envars = "pytaku:generate_psql_envars"
 
 [tool.poetry.dependencies]
 python = "^3.7"
 django = "^3.0.5"
 psycopg2 = "^2.8.5"
+goodconf = "^1.0.0"
 
 [tool.poetry.dev-dependencies]
 python-language-server = "^0.31.10"
 flake8 = "^3.7.9"
 ipdb = "^0.13.2"
+black = "^19.10b0"
+isort = "^4.3.21"
 
 [build-system]
 requires = ["poetry>=0.12"]
diff --git a/src/__init__.py b/src/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/pytaku/__init__.py b/src/pytaku/__init__.py
new file mode 100644
index 0000000..81de581
--- /dev/null
+++ b/src/pytaku/__init__.py
@@ -0,0 +1,32 @@
+import os
+
+from pytaku.conf import config
+
+
+def manage():
+    """Django's command-line utility for administrative tasks."""
+    os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pytaku.settings")
+    config.django_manage()  # use goodconf to run a monkey-patched "manage.py"
+
+
+def generate_config():
+    print(config.generate_json(DEBUG=True))
+
+
+def generate_psql_envars():
+    """
+    Outputs shell statements to set Postgres connection envars.
+    Usage:
+        pytaku-generate-psql-envars | source
+        psql  # or even better, pgcli
+        # should drop you into the pytaku db shell
+    """
+    print(
+        f"""\
+export PGHOST='{config.DB_HOST}'
+export PGPORT='{config.DB_PORT}'
+export PGDATABASE='{config.DB_NAME}'
+export PGUSER='{config.DB_USER}'
+export PGPASSWORD='{config.DB_PASSWORD}'
+"""
+    )
diff --git a/src/pytaku/asgi.py b/src/pytaku/asgi.py
new file mode 100644
index 0000000..2061535
--- /dev/null
+++ b/src/pytaku/asgi.py
@@ -0,0 +1,16 @@
+"""
+ASGI config for pytaku 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.0/howto/deployment/asgi/
+"""
+
+import os
+
+from django.core.asgi import get_asgi_application
+
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pytaku.settings")
+
+application = get_asgi_application()
diff --git a/src/pytaku/conf.py b/src/pytaku/conf.py
new file mode 100644
index 0000000..6db70cc
--- /dev/null
+++ b/src/pytaku/conf.py
@@ -0,0 +1,23 @@
+import secrets
+
+from goodconf import GoodConf, Value
+
+
+class Config(GoodConf):
+    DEBUG = Value(default=False)
+    SECRET_KEY = Value(initial=lambda: secrets.token_urlsafe(60))
+    DB_HOST = Value(default="127.0.0.1")
+    DB_PORT = Value(default="5432")
+    DB_NAME = Value(default="ptklocal")
+    DB_USER = Value(default="ptklocal")
+    DB_PASSWORD = Value(default="ptklocal")
+
+    # AWS creds used for the S3 storage backend for uploaded pages
+    AWS_ACCESS_KEY_ID = Value()
+    AWS_SECRET_ACCESS_KEY = Value()
+    AWS_STORAGE_BUCKET_NAME = Value(default="pytaku")
+    AWS_DEFAULT_ACL = Value(default="public-read")
+    AWS_S3_ENDPOINT_URL = Value(default="")
+
+
+config = Config(default_files=["pytaku.conf.json"])
diff --git a/src/pytaku/settings.py b/src/pytaku/settings.py
new file mode 100644
index 0000000..dac1ae6
--- /dev/null
+++ b/src/pytaku/settings.py
@@ -0,0 +1,131 @@
+"""
+Django settings for pytaku project.
+
+Generated by 'django-admin startproject' using Django 3.0.5.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/3.0/topics/settings/
+
+For the full list of settings and their values, see
+https://docs.djangoproject.com/en/3.0/ref/settings/
+"""
+
+import os
+
+from .conf import config
+
+config.load()
+
+# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
+BASE_DIR = 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/
+
+# SECURITY WARNING: keep the secret key used in production secret!
+SECRET_KEY = config.SECRET_KEY
+
+# SECURITY WARNING: don't run with debug turned on in production!
+DEBUG = config.DEBUG
+
+ALLOWED_HOSTS = []
+
+
+# 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 = "pytaku.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 = "pytaku.wsgi.application"
+
+
+# Database
+# https://docs.djangoproject.com/en/3.0/ref/settings/#databases
+
+DATABASES = {
+    "default": {
+        "ENGINE": "django.db.backends.postgresql",
+        "NAME": config.DB_NAME,
+        "USER": config.DB_USER,
+        "PASSWORD": config.DB_PASSWORD,
+        "HOST": config.DB_HOST,
+        "PORT": config.DB_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 = "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.0/howto/static-files/
+
+STATIC_URL = "/static/"
+
+# TODO: I really should support simple plain filesystem storage backend too.
+# Forcing people to set up S3(-compatible) storage as well ain't nice.
+DEFAULT_FILE_STORAGE = "storages.backends.s3boto3.S3Boto3Storage"
+AWS_ACCESS_KEY_ID = config.AWS_ACCESS_KEY_ID
+AWS_SECRET_ACCESS_KEY = config.AWS_SECRET_ACCESS_KEY
+AWS_STORAGE_BUCKET_NAME = config.AWS_STORAGE_BUCKET_NAME
+AWS_S3_ENDPOINT_URL = config.AWS_S3_ENDPOINT_URL or None
+AWS_DEFAULT_ACL = None  # use bucket's default policy
diff --git a/src/pytaku/urls.py b/src/pytaku/urls.py
new file mode 100644
index 0000000..f121eed
--- /dev/null
+++ b/src/pytaku/urls.py
@@ -0,0 +1,21 @@
+"""pytaku URL Configuration
+
+The `urlpatterns` list routes URLs to views. For more information please see:
+    https://docs.djangoproject.com/en/3.0/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/src/pytaku/wsgi.py b/src/pytaku/wsgi.py
new file mode 100644
index 0000000..4c689ae
--- /dev/null
+++ b/src/pytaku/wsgi.py
@@ -0,0 +1,16 @@
+"""
+WSGI config for pytaku 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.0/howto/deployment/wsgi/
+"""
+
+import os
+
+from django.core.wsgi import get_wsgi_application
+
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pytaku.settings")
+
+application = get_wsgi_application()
diff --git a/src/pytaku_web/__init__.py b/src/pytaku_web/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/pytaku_web/admin.py b/src/pytaku_web/admin.py
new file mode 100644
index 0000000..6a4dbf2
--- /dev/null
+++ b/src/pytaku_web/admin.py
@@ -0,0 +1,6 @@
+from django.contrib import admin
+
+from .models import Chapter, Title
+
+admin.site.register(Title)
+admin.site.register(Chapter)
diff --git a/src/pytaku_web/apps.py b/src/pytaku_web/apps.py
new file mode 100644
index 0000000..a38ace0
--- /dev/null
+++ b/src/pytaku_web/apps.py
@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class PytakuWebConfig(AppConfig):
+    name = 'pytaku_web'
diff --git a/src/pytaku_web/migrations/__init__.py b/src/pytaku_web/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/pytaku_web/models.py b/src/pytaku_web/models.py
new file mode 100644
index 0000000..d465aae
--- /dev/null
+++ b/src/pytaku_web/models.py
@@ -0,0 +1,58 @@
+from django.contrib.postgres.fields import ArrayField
+from django.db import models
+
+
+class Title(models.Model):
+    class Meta:
+        db_table = "title"
+
+    site = models.CharField(max_length=50)
+    original_id = models.CharField(max_length=255)
+    name = models.CharField(max_length=255)
+    alt_names = ArrayField(models.CharField(max_length=255), default=list)
+    cover = models.CharField(max_length=255)
+    descriptions = ArrayField(models.TextField(), default=list)  # paragraphs
+    publication_status = models.CharField(max_length=100)
+    authors = ArrayField(models.CharField(max_length=100), default=list)
+    tags = ArrayField(models.CharField(max_length=50), default=list)
+
+    def __str__(self):
+        return f"{self.name} ({self.id})"
+
+
+class Chapter(models.Model):
+    class Meta:
+        db_table = "chapter"
+        constraints = [
+            models.UniqueConstraint(
+                fields=["title", "ordering"], name="unique_order_within_title"
+            )
+        ]
+
+    original_id = models.CharField(max_length=255)
+    name = models.CharField(max_length=255)
+    title = models.ForeignKey(Title, on_delete=models.CASCADE, related_name="chapters")
+    num_major = models.PositiveIntegerField()
+    num_minor = models.PositiveIntegerField(null=True, blank=True)
+    pages = ArrayField(models.TextField(), default=list)
+
+    def __str__(self):
+        return f"{self.name} ({self.id})"
+
+    @property
+    def next_chapter(self):
+        try:
+            return Title.chapters.get(ordering=self.ordering + 1)
+        except Chapter.DoesNotExist:
+            return None
+
+    @property
+    def prev_chapter(self):
+        # premature optimzation: avoid unnecessary db query
+        if self.ordering == 0:
+            return None
+
+        try:
+            return Title.chapters.get(ordering=self.ordering - 1)
+        except Chapter.DoesNotExist:
+            return None
diff --git a/src/pytaku_web/tests.py b/src/pytaku_web/tests.py
new file mode 100644
index 0000000..7ce503c
--- /dev/null
+++ b/src/pytaku_web/tests.py
@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.
diff --git a/src/pytaku_web/views.py b/src/pytaku_web/views.py
new file mode 100644
index 0000000..91ea44a
--- /dev/null
+++ b/src/pytaku_web/views.py
@@ -0,0 +1,3 @@
+from django.shortcuts import render
+
+# Create your views here.