commit
06e1b7178d
47 changed files with 2667 additions and 0 deletions
-
57.gitignore
-
7LICENSE
-
33Pipfile
-
467Pipfile.lock
-
1Procfile
-
64Readme.md
-
103Vagrantfile
-
10autoapp.py
-
1conduit/__init__.py
-
80conduit/app.py
-
3conduit/articles/__init__.py
-
112conduit/articles/models.py
-
88conduit/articles/serializers.py
-
168conduit/articles/views.py
-
123conduit/commands.py
-
18conduit/compat.py
-
44conduit/database.py
-
47conduit/exceptions.py
-
50conduit/extensions.py
-
1conduit/profile/__init__.py
-
65conduit/profile/models.py
-
27conduit/profile/serializers.py
-
45conduit/profile/views.py
-
60conduit/settings.py
-
3conduit/user/__init__.py
-
39conduit/user/models.py
-
38conduit/user/serializers.py
-
66conduit/user/views.py
-
11conduit/utils.py
-
BINimage.png
-
1migrations/README
-
45migrations/alembic.ini
-
96migrations/env.py
-
24migrations/script.py.mako
-
101migrations/versions/2267f00a4594_.py
-
3requirements.txt
-
9requirements/dev.txt
-
18requirements/prod.txt
-
2setup.cfg
-
1tests/__init__.py
-
61tests/conftest.py
-
30tests/factories.py
-
136tests/test_articles.py
-
65tests/test_authentication.py
-
18tests/test_config.py
-
181tests/test_models.py
-
45tests/test_profile.py
@ -0,0 +1,57 @@ |
|||||
|
*.py[cod] |
||||
|
|
||||
|
# C extensions |
||||
|
*.so |
||||
|
|
||||
|
# Packages |
||||
|
*.egg |
||||
|
*.egg-info |
||||
|
build |
||||
|
eggs |
||||
|
parts |
||||
|
bin |
||||
|
var |
||||
|
sdist |
||||
|
develop-eggs |
||||
|
.installed.cfg |
||||
|
lib |
||||
|
lib64 |
||||
|
|
||||
|
# Installer logs |
||||
|
pip-log.txt |
||||
|
|
||||
|
# Unit test / coverage reports |
||||
|
.coverage |
||||
|
.tox |
||||
|
nosetests.xml |
||||
|
|
||||
|
# Translations |
||||
|
*.mo |
||||
|
|
||||
|
# Mr Developer |
||||
|
.mr.developer.cfg |
||||
|
.project |
||||
|
.pydevproject |
||||
|
|
||||
|
# Complexity |
||||
|
output/*.html |
||||
|
output/*/index.html |
||||
|
|
||||
|
# Sphinx |
||||
|
docs/_build |
||||
|
|
||||
|
.webassets-cache |
||||
|
|
||||
|
# Virtualenvs |
||||
|
env/ |
||||
|
dev.db |
||||
|
.cache/v/cache |
||||
|
|
||||
|
# Intellij IDEA based IDEs folder |
||||
|
.idea/** |
||||
|
|
||||
|
# Pytest cache |
||||
|
.pytest_cache/** |
||||
|
|
||||
|
# Vagrant machines |
||||
|
.vagrant/** |
||||
@ -0,0 +1,7 @@ |
|||||
|
Copyright 2017 Mohamed Aziz Knani |
||||
|
|
||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: |
||||
|
|
||||
|
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. |
||||
|
|
||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. |
||||
@ -0,0 +1,33 @@ |
|||||
|
[[source]] |
||||
|
url = "https://pypi.org/simple" |
||||
|
verify_ssl = true |
||||
|
name = "pypi" |
||||
|
|
||||
|
[packages] |
||||
|
werkzeug = "*" |
||||
|
sqlalchemy = "*" |
||||
|
click = "*" |
||||
|
marshmallow = "*" |
||||
|
flask-apispec = "*" |
||||
|
unicode-slugify = "*" |
||||
|
"psycopg2" = "*" |
||||
|
gunicorn = "*" |
||||
|
Flask-Caching = "*" |
||||
|
Flask-SQLAlchemy = "*" |
||||
|
Flask-Bcrypt = "*" |
||||
|
Flask = "*" |
||||
|
PyJWT = "*" |
||||
|
Flask-JWT-Extended = "*" |
||||
|
Flask-Migrate = "*" |
||||
|
Flask-Cors = "*" |
||||
|
|
||||
|
[dev-packages] |
||||
|
# Testing |
||||
|
pytest = "*" |
||||
|
WebTest = "*" |
||||
|
factory-boy = "*" |
||||
|
# For python 3 |
||||
|
Faker = "*" |
||||
|
|
||||
|
[requires] |
||||
|
python_version = "3.7" |
||||
@ -0,0 +1,467 @@ |
|||||
|
{ |
||||
|
"_meta": { |
||||
|
"hash": { |
||||
|
"sha256": "d718acf10c644a164aa41698100acbfd104741e6c6988e45eb5b40037c4c1798" |
||||
|
}, |
||||
|
"pipfile-spec": 6, |
||||
|
"requires": { |
||||
|
"python_version": "3.7" |
||||
|
}, |
||||
|
"sources": [ |
||||
|
{ |
||||
|
"name": "pypi", |
||||
|
"url": "https://pypi.org/simple", |
||||
|
"verify_ssl": true |
||||
|
} |
||||
|
] |
||||
|
}, |
||||
|
"default": { |
||||
|
"alembic": { |
||||
|
"hashes": [ |
||||
|
"sha256:52d73b1d750f1414fa90c25a08da47b87de1e4ad883935718a8f36396e19e78e", |
||||
|
"sha256:eb7db9b4510562ec37c91d00b00d95fde076c1030d3f661aea882eec532b3565" |
||||
|
], |
||||
|
"version": "==1.0.0" |
||||
|
}, |
||||
|
"apispec": { |
||||
|
"hashes": [ |
||||
|
"sha256:1661bc574b1579ef72883aafd87c0178d1c129659cd4f1c76a68fcc852e1b4ed", |
||||
|
"sha256:3ad66b1aa0a330db71ab424ca17b946d80ef5923d28da5ccdad692a7937efb9e" |
||||
|
], |
||||
|
"version": "==0.39.0" |
||||
|
}, |
||||
|
"bcrypt": { |
||||
|
"hashes": [ |
||||
|
"sha256:01477981abf74e306e8ee31629a940a5e9138de000c6b0898f7f850461c4a0a5", |
||||
|
"sha256:054d6e0acaea429e6da3613fcd12d05ee29a531794d96f6ab959f29a39f33391", |
||||
|
"sha256:0872eeecdf9a429c1420158500eedb323a132bc5bf3339475151c52414729e70", |
||||
|
"sha256:09a3b8c258b815eadb611bad04ca15ec77d86aa9ce56070e1af0d5932f17642a", |
||||
|
"sha256:0f317e4ffbdd15c3c0f8ab5fbd86aa9aabc7bea18b5cc5951b456fe39e9f738c", |
||||
|
"sha256:2788c32673a2ad0062bea850ab73cffc0dba874db10d7a3682b6f2f280553f20", |
||||
|
"sha256:321d4d48be25b8d77594d8324c0585c80ae91ac214f62db9098734e5e7fb280f", |
||||
|
"sha256:346d6f84ff0b493dbc90c6b77136df83e81f903f0b95525ee80e5e6d5e4eef84", |
||||
|
"sha256:34dd60b90b0f6de94a89e71fcd19913a30e83091c8468d0923a93a0cccbfbbff", |
||||
|
"sha256:3b4c23300c4eded8895442c003ae9b14328ae69309ac5867e7530de8bdd7875d", |
||||
|
"sha256:43d1960e7db14042319c46925892d5fa99b08ff21d57482e6f5328a1aca03588", |
||||
|
"sha256:49e96267cd9be55a349fd74f9852eb9ae2c427cd7f6455d0f1765d7332292832", |
||||
|
"sha256:63e06ffdaf4054a89757a3a1ab07f1b922daf911743114a54f7c561b9e1baa58", |
||||
|
"sha256:67ed1a374c9155ec0840214ce804616de49c3df9c5bc66740687c1c9b1cd9e8d", |
||||
|
"sha256:6b662a5669186439f4f583636c8d6ea77cf92f7cfe6aae8d22edf16c36840574", |
||||
|
"sha256:6efd9ca20aefbaf2e7e6817a2c6ed4a50ff6900fafdea1bcb1d0e9471743b144", |
||||
|
"sha256:8569844a5d8e1fdde4d7712a05ab2e6061343ac34af6e7e3d7935b2bd1907bfd", |
||||
|
"sha256:8629ea6a8a59f865add1d6a87464c3c676e60101b8d16ef404d0a031424a8491", |
||||
|
"sha256:988cac675e25133d01a78f2286189c1f01974470817a33eaf4cfee573cfb72a5", |
||||
|
"sha256:9a6fedda73aba1568962f7543a1f586051c54febbc74e87769bad6a4b8587c39", |
||||
|
"sha256:9eced8962ce3b7124fe20fd358cf8c7470706437fa064b9874f849ad4c5866fc", |
||||
|
"sha256:a005ed6163490988711ff732386b08effcbf8df62ae93dd1e5bda0714fad8afb", |
||||
|
"sha256:ae35dbcb6b011af6c840893b32399252d81ff57d52c13e12422e16b5fea1d0fb", |
||||
|
"sha256:b1e8491c6740f21b37cca77bc64677696a3fb9f32360794d57fa8477b7329eda", |
||||
|
"sha256:c906bdb482162e9ef48eea9f8c0d967acceb5c84f2d25574c7d2a58d04861df1", |
||||
|
"sha256:cb18ffdc861dbb244f14be32c47ab69604d0aca415bee53485fcea4f8e93d5ef", |
||||
|
"sha256:cc2f24dc1c6c88c56248e93f28d439ee4018338567b0bbb490ea26a381a29b1e", |
||||
|
"sha256:d860c7fff18d49e20339fc6dffc2d485635e36d4b2cccf58f45db815b64100b4", |
||||
|
"sha256:d86da365dda59010ba0d1ac45aa78390f56bf7f992e65f70b3b081d5e5257b09", |
||||
|
"sha256:e22f0997622e1ceec834fd25947dc2ee2962c2133ea693d61805bc867abaf7ea", |
||||
|
"sha256:f2fe545d27a619a552396533cddf70d83cecd880a611cdfdbb87ca6aec52f66b", |
||||
|
"sha256:f425e925485b3be48051f913dbe17e08e8c48588fdf44a26b8b14067041c0da6", |
||||
|
"sha256:f7fd3ed3745fe6e81e28dc3b3d76cce31525a91f32a387e1febd6b982caf8cdb", |
||||
|
"sha256:f9210820ee4818d84658ed7df16a7f30c9fba7d8b139959950acef91745cc0f7" |
||||
|
], |
||||
|
"version": "==3.1.4" |
||||
|
}, |
||||
|
"cffi": { |
||||
|
"hashes": [ |
||||
|
"sha256:151b7eefd035c56b2b2e1eb9963c90c6302dc15fbd8c1c0a83a163ff2c7d7743", |
||||
|
"sha256:1553d1e99f035ace1c0544050622b7bc963374a00c467edafac50ad7bd276aef", |
||||
|
"sha256:1b0493c091a1898f1136e3f4f991a784437fac3673780ff9de3bcf46c80b6b50", |
||||
|
"sha256:2ba8a45822b7aee805ab49abfe7eec16b90587f7f26df20c71dd89e45a97076f", |
||||
|
"sha256:3bb6bd7266598f318063e584378b8e27c67de998a43362e8fce664c54ee52d30", |
||||
|
"sha256:3c85641778460581c42924384f5e68076d724ceac0f267d66c757f7535069c93", |
||||
|
"sha256:3eb6434197633b7748cea30bf0ba9f66727cdce45117a712b29a443943733257", |
||||
|
"sha256:495c5c2d43bf6cebe0178eb3e88f9c4aa48d8934aa6e3cddb865c058da76756b", |
||||
|
"sha256:4c91af6e967c2015729d3e69c2e51d92f9898c330d6a851bf8f121236f3defd3", |
||||
|
"sha256:57b2533356cb2d8fac1555815929f7f5f14d68ac77b085d2326b571310f34f6e", |
||||
|
"sha256:770f3782b31f50b68627e22f91cb182c48c47c02eb405fd689472aa7b7aa16dc", |
||||
|
"sha256:79f9b6f7c46ae1f8ded75f68cf8ad50e5729ed4d590c74840471fc2823457d04", |
||||
|
"sha256:7a33145e04d44ce95bcd71e522b478d282ad0eafaf34fe1ec5bbd73e662f22b6", |
||||
|
"sha256:857959354ae3a6fa3da6651b966d13b0a8bed6bbc87a0de7b38a549db1d2a359", |
||||
|
"sha256:87f37fe5130574ff76c17cab61e7d2538a16f843bb7bca8ebbc4b12de3078596", |
||||
|
"sha256:95d5251e4b5ca00061f9d9f3d6fe537247e145a8524ae9fd30a2f8fbce993b5b", |
||||
|
"sha256:9d1d3e63a4afdc29bd76ce6aa9d58c771cd1599fbba8cf5057e7860b203710dd", |
||||
|
"sha256:a36c5c154f9d42ec176e6e620cb0dd275744aa1d804786a71ac37dc3661a5e95", |
||||
|
"sha256:a6a5cb8809091ec9ac03edde9304b3ad82ad4466333432b16d78ef40e0cce0d5", |
||||
|
"sha256:ae5e35a2c189d397b91034642cb0eab0e346f776ec2eb44a49a459e6615d6e2e", |
||||
|
"sha256:b0f7d4a3df8f06cf49f9f121bead236e328074de6449866515cea4907bbc63d6", |
||||
|
"sha256:b75110fb114fa366b29a027d0c9be3709579602ae111ff61674d28c93606acca", |
||||
|
"sha256:ba5e697569f84b13640c9e193170e89c13c6244c24400fc57e88724ef610cd31", |
||||
|
"sha256:be2a9b390f77fd7676d80bc3cdc4f8edb940d8c198ed2d8c0be1319018c778e1", |
||||
|
"sha256:ca1bd81f40adc59011f58159e4aa6445fc585a32bb8ac9badf7a2c1aa23822f2", |
||||
|
"sha256:d5d8555d9bfc3f02385c1c37e9f998e2011f0db4f90e250e5bc0c0a85a813085", |
||||
|
"sha256:e55e22ac0a30023426564b1059b035973ec82186ddddbac867078435801c7801", |
||||
|
"sha256:e90f17980e6ab0f3c2f3730e56d1fe9bcba1891eeea58966e89d352492cc74f4", |
||||
|
"sha256:ecbb7b01409e9b782df5ded849c178a0aa7c906cf8c5a67368047daab282b184", |
||||
|
"sha256:ed01918d545a38998bfa5902c7c00e0fee90e957ce036a4000a88e3fe2264917", |
||||
|
"sha256:edabd457cd23a02965166026fd9bfd196f4324fe6032e866d0f3bd0301cd486f", |
||||
|
"sha256:fdf1c1dc5bafc32bc5d08b054f94d659422b05aba244d6be4ddc1c72d9aa70fb" |
||||
|
], |
||||
|
"version": "==1.11.5" |
||||
|
}, |
||||
|
"click": { |
||||
|
"hashes": [ |
||||
|
"sha256:29f99fc6125fbc931b758dc053b3114e55c77a6e4c6c3a2674a2dc986016381d", |
||||
|
"sha256:f15516df478d5a56180fbf80e68f206010e6d160fc39fa508b65e035fd75130b" |
||||
|
], |
||||
|
"index": "pypi", |
||||
|
"version": "==6.7" |
||||
|
}, |
||||
|
"flask": { |
||||
|
"hashes": [ |
||||
|
"sha256:2271c0070dbcb5275fad4a82e29f23ab92682dc45f9dfbc22c02ba9b9322ce48", |
||||
|
"sha256:a080b744b7e345ccfcbc77954861cb05b3c63786e93f2b3875e0913d44b43f05" |
||||
|
], |
||||
|
"index": "pypi", |
||||
|
"version": "==1.0.2" |
||||
|
}, |
||||
|
"flask-apispec": { |
||||
|
"hashes": [ |
||||
|
"sha256:1a3f83788b67b6e05c1bb7ffff9cd455fb5bd3800caac8b7bc4ce462b7c5a94b", |
||||
|
"sha256:aae3656220e4cbf447d61345cb61a495746e4dede7f6d83faab1f8665638a801" |
||||
|
], |
||||
|
"index": "pypi", |
||||
|
"version": "==0.7.0" |
||||
|
}, |
||||
|
"flask-bcrypt": { |
||||
|
"hashes": [ |
||||
|
"sha256:d71c8585b2ee1c62024392ebdbc447438564e2c8c02b4e57b56a4cafd8d13c5f" |
||||
|
], |
||||
|
"index": "pypi", |
||||
|
"version": "==0.7.1" |
||||
|
}, |
||||
|
"flask-caching": { |
||||
|
"hashes": [ |
||||
|
"sha256:44fe827c6cc519d48fb0945fa05ae3d128af9a98f2a6e71d4702fd512534f227", |
||||
|
"sha256:e34f24631ba240e09fe6241e1bf652863e0cff06a1a94598e23be526bc2e4985" |
||||
|
], |
||||
|
"index": "pypi", |
||||
|
"version": "==1.4.0" |
||||
|
}, |
||||
|
"flask-cors": { |
||||
|
"hashes": [ |
||||
|
"sha256:e4c8fc15d3e4b4cce6d3b325f2bab91e0e09811a61f50d7a53493bc44242a4f1", |
||||
|
"sha256:ecc016c5b32fa5da813ec8d272941cfddf5f6bba9060c405a70285415cbf24c9" |
||||
|
], |
||||
|
"index": "pypi", |
||||
|
"version": "==3.0.6" |
||||
|
}, |
||||
|
"flask-jwt-extended": { |
||||
|
"hashes": [ |
||||
|
"sha256:3f28e6fe9bba450ccf48f640b7a7c7b61c9f520dfb9a80854867b5203bd3cb49" |
||||
|
], |
||||
|
"index": "pypi", |
||||
|
"version": "==3.10.0" |
||||
|
}, |
||||
|
"flask-migrate": { |
||||
|
"hashes": [ |
||||
|
"sha256:83ebc105f87357ddd3968f83510d2b1092f006660b1c6ba07a4efce036ca567d", |
||||
|
"sha256:cd1b4e6cb829eeb41c02ad9202d83bef5f4b7a036dd9fad72ce96ad1e22efb07" |
||||
|
], |
||||
|
"index": "pypi", |
||||
|
"version": "==2.2.1" |
||||
|
}, |
||||
|
"flask-sqlalchemy": { |
||||
|
"hashes": [ |
||||
|
"sha256:3bc0fac969dd8c0ace01b32060f0c729565293302f0c4269beed154b46bec50b", |
||||
|
"sha256:5971b9852b5888655f11db634e87725a9031e170f37c0ce7851cf83497f56e53" |
||||
|
], |
||||
|
"index": "pypi", |
||||
|
"version": "==2.3.2" |
||||
|
}, |
||||
|
"gunicorn": { |
||||
|
"hashes": [ |
||||
|
"sha256:aa8e0b40b4157b36a5df5e599f45c9c76d6af43845ba3b3b0efe2c70473c2471", |
||||
|
"sha256:fa2662097c66f920f53f70621c6c58ca4a3c4d3434205e608e121b5b3b71f4f3" |
||||
|
], |
||||
|
"index": "pypi", |
||||
|
"version": "==19.9.0" |
||||
|
}, |
||||
|
"itsdangerous": { |
||||
|
"hashes": [ |
||||
|
"sha256:cbb3fcf8d3e33df861709ecaf89d9e6629cff0a217bc2848f1b41cd30d360519" |
||||
|
], |
||||
|
"version": "==0.24" |
||||
|
}, |
||||
|
"jinja2": { |
||||
|
"hashes": [ |
||||
|
"sha256:74c935a1b8bb9a3947c50a54766a969d4846290e1e788ea44c1392163723c3bd", |
||||
|
"sha256:f84be1bb0040caca4cea721fcbbbbd61f9be9464ca236387158b0feea01914a4" |
||||
|
], |
||||
|
"version": "==2.10" |
||||
|
}, |
||||
|
"jsonify": { |
||||
|
"hashes": [ |
||||
|
"sha256:f340032753577575e9777835809b283fdc9b251867d5d5600389131647f8bfe1" |
||||
|
], |
||||
|
"index": "pypi", |
||||
|
"version": "==0.5" |
||||
|
}, |
||||
|
"mako": { |
||||
|
"hashes": [ |
||||
|
"sha256:4e02fde57bd4abb5ec400181e4c314f56ac3e49ba4fb8b0d50bba18cb27d25ae" |
||||
|
], |
||||
|
"version": "==1.0.7" |
||||
|
}, |
||||
|
"markupsafe": { |
||||
|
"hashes": [ |
||||
|
"sha256:a6be69091dac236ea9c6bc7d012beab42010fa914c459791d627dad4910eb665" |
||||
|
], |
||||
|
"version": "==1.0" |
||||
|
}, |
||||
|
"marshmallow": { |
||||
|
"hashes": [ |
||||
|
"sha256:171f409d48b44786b7df2793cbd7f1a9062f0fe2c14d547da536b5010f671ade", |
||||
|
"sha256:c231784b5a5d2b26e50c90f3038004a3552ec27658cde6e0a5a7279d0c5a8e26" |
||||
|
], |
||||
|
"index": "pypi", |
||||
|
"version": "==2.15.3" |
||||
|
}, |
||||
|
"psycopg2": { |
||||
|
"hashes": [ |
||||
|
"sha256:0b9e48a1c1505699a64ac58815ca99104aacace8321e455072cee4f7fe7b2698", |
||||
|
"sha256:0f4c784e1b5a320efb434c66a50b8dd7e30a7dc047e8f45c0a8d2694bfe72781", |
||||
|
"sha256:0fdbaa32c9eb09ef09d425dc154628fca6fa69d2f7c1a33f889abb7e0efb3909", |
||||
|
"sha256:11fbf688d5c953c0a5ba625cc42dea9aeb2321942c7c5ed9341a68f865dc8cb1", |
||||
|
"sha256:19eaac4eb25ab078bd0f28304a0cb08702d120caadfe76bb1e6846ed1f68635e", |
||||
|
"sha256:3232ec1a3bf4dba97fbf9b03ce12e4b6c1d01ea3c85773903a67ced725728232", |
||||
|
"sha256:36f8f9c216fcca048006f6dd60e4d3e6f406afde26cfb99e063f137070139eaf", |
||||
|
"sha256:59c1a0e4f9abe970062ed35d0720935197800a7ef7a62b3a9e3a70588d9ca40b", |
||||
|
"sha256:6506c5ff88750948c28d41852c09c5d2a49f51f28c6d90cbf1b6808e18c64e88", |
||||
|
"sha256:6bc3e68ee16f571681b8c0b6d5c0a77bef3c589012352b3f0cf5520e674e9d01", |
||||
|
"sha256:6dbbd7aabbc861eec6b910522534894d9dbb507d5819bc982032c3ea2e974f51", |
||||
|
"sha256:6e737915de826650d1a5f7ff4ac6cf888a26f021a647390ca7bafdba0e85462b", |
||||
|
"sha256:6ed9b2cfe85abc720e8943c1808eeffd41daa73e18b7c1e1a228b0b91f768ccc", |
||||
|
"sha256:711ec617ba453fdfc66616db2520db3a6d9a891e3bf62ef9aba4c95bb4e61230", |
||||
|
"sha256:844dacdf7530c5c612718cf12bc001f59b2d9329d35b495f1ff25045161aa6af", |
||||
|
"sha256:86b52e146da13c896e50c5a3341a9448151f1092b1a4153e425d1e8b62fec508", |
||||
|
"sha256:985c06c2a0f227131733ae58d6a541a5bc8b665e7305494782bebdb74202b793", |
||||
|
"sha256:a86dfe45f4f9c55b1a2312ff20a59b30da8d39c0e8821d00018372a2a177098f", |
||||
|
"sha256:aa3cd07f7f7e3183b63d48300666f920828a9dbd7d7ec53d450df2c4953687a9", |
||||
|
"sha256:b1964ed645ef8317806d615d9ff006c0dadc09dfc54b99ae67f9ba7a1ec9d5d2", |
||||
|
"sha256:b2abbff9e4141484bb89b96eb8eae186d77bc6d5ffbec6b01783ee5c3c467351", |
||||
|
"sha256:cc33c3a90492e21713260095f02b12bee02b8d1f2c03a221d763ce04fa90e2e9", |
||||
|
"sha256:d7de3bf0986d777807611c36e809b77a13bf1888f5c8db0ebf24b47a52d10726", |
||||
|
"sha256:db5e3c52576cc5b93a959a03ccc3b02cb8f0af1fbbdc80645f7a215f0b864f3a", |
||||
|
"sha256:e168aa795ffbb11379c942cf95bf813c7db9aa55538eb61de8c6815e092416f5", |
||||
|
"sha256:e9ca911f8e2d3117e5241d5fa9aaa991cb22fb0792627eeada47425d706b5ec8", |
||||
|
"sha256:eccf962d41ca46e6326b97c8fe0a6687b58dfc1a5f6540ed071ff1474cea749e", |
||||
|
"sha256:efa19deae6b9e504a74347fe5e25c2cb9343766c489c2ae921b05f37338b18d1", |
||||
|
"sha256:f4b0460a21f784abe17b496f66e74157a6c36116fa86da8bf6aa028b9e8ad5fe", |
||||
|
"sha256:f93d508ca64d924d478fb11e272e09524698f0c581d9032e68958cfbdd41faef" |
||||
|
], |
||||
|
"index": "pypi", |
||||
|
"version": "==2.7.5" |
||||
|
}, |
||||
|
"pycparser": { |
||||
|
"hashes": [ |
||||
|
"sha256:99a8ca03e29851d96616ad0404b4aad7d9ee16f25c9f9708a11faf2810f7b226" |
||||
|
], |
||||
|
"version": "==2.18" |
||||
|
}, |
||||
|
"pyjwt": { |
||||
|
"hashes": [ |
||||
|
"sha256:30b1380ff43b55441283cc2b2676b755cca45693ae3097325dea01f3d110628c", |
||||
|
"sha256:4ee413b357d53fd3fb44704577afac88e72e878716116270d722723d65b42176" |
||||
|
], |
||||
|
"index": "pypi", |
||||
|
"version": "==1.6.4" |
||||
|
}, |
||||
|
"python-dateutil": { |
||||
|
"hashes": [ |
||||
|
"sha256:1adb80e7a782c12e52ef9a8182bebeb73f1d7e24e374397af06fb4956c8dc5c0", |
||||
|
"sha256:e27001de32f627c22380a688bcc43ce83504a7bc5da472209b4c70f02829f0b8" |
||||
|
], |
||||
|
"version": "==2.7.3" |
||||
|
}, |
||||
|
"python-editor": { |
||||
|
"hashes": [ |
||||
|
"sha256:a3c066acee22a1c94f63938341d4fb374e3fdd69366ed6603d7b24bed1efc565" |
||||
|
], |
||||
|
"version": "==1.0.3" |
||||
|
}, |
||||
|
"pyyaml": { |
||||
|
"hashes": [ |
||||
|
"sha256:3d7da3009c0f3e783b2c873687652d83b1bbfd5c88e9813fb7e5b03c0dd3108b", |
||||
|
"sha256:3ef3092145e9b70e3ddd2c7ad59bdd0252a94dfe3949721633e41344de00a6bf", |
||||
|
"sha256:40c71b8e076d0550b2e6380bada1f1cd1017b882f7e16f09a65be98e017f211a", |
||||
|
"sha256:558dd60b890ba8fd982e05941927a3911dc409a63dcb8b634feaa0cda69330d3", |
||||
|
"sha256:a7c28b45d9f99102fa092bb213aa12e0aaf9a6a1f5e395d36166639c1f96c3a1", |
||||
|
"sha256:aa7dd4a6a427aed7df6fb7f08a580d68d9b118d90310374716ae90b710280af1", |
||||
|
"sha256:bc558586e6045763782014934bfaf39d48b8ae85a2713117d16c39864085c613", |
||||
|
"sha256:d46d7982b62e0729ad0175a9bc7e10a566fc07b224d2c79fafb5e032727eaa04", |
||||
|
"sha256:d5eef459e30b09f5a098b9cea68bebfeb268697f78d647bd255a085371ac7f3f", |
||||
|
"sha256:e01d3203230e1786cd91ccfdc8f8454c8069c91bee3962ad93b87a4b2860f537", |
||||
|
"sha256:e170a9e6fcfd19021dd29845af83bb79236068bf5fd4df3327c1be18182b2531" |
||||
|
], |
||||
|
"version": "==3.13" |
||||
|
}, |
||||
|
"six": { |
||||
|
"hashes": [ |
||||
|
"sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9", |
||||
|
"sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb" |
||||
|
], |
||||
|
"version": "==1.11.0" |
||||
|
}, |
||||
|
"sqlalchemy": { |
||||
|
"hashes": [ |
||||
|
"sha256:e21e5561a85dcdf16b8520ae4daec7401c5c24558e0ce004f9b60be75c4b6957" |
||||
|
], |
||||
|
"index": "pypi", |
||||
|
"version": "==1.2.9" |
||||
|
}, |
||||
|
"unicode-slugify": { |
||||
|
"hashes": [ |
||||
|
"sha256:34cf3afefa6480efe705a4fc0eaeeaf7f49754aec322ba3e8b2f27dc1cbcf650" |
||||
|
], |
||||
|
"index": "pypi", |
||||
|
"version": "==0.1.3" |
||||
|
}, |
||||
|
"unidecode": { |
||||
|
"hashes": [ |
||||
|
"sha256:72f49d3729f3d8f5799f710b97c1451c5163102e76d64d20e170aedbbd923582", |
||||
|
"sha256:8c33dd588e0c9bc22a76eaa0c715a5434851f726131bd44a6c26471746efabf5" |
||||
|
], |
||||
|
"version": "==1.0.22" |
||||
|
}, |
||||
|
"webargs": { |
||||
|
"hashes": [ |
||||
|
"sha256:7f76cea1989391480198840ef9cabb8041db7129e0a58f15e6962b92d4938a17", |
||||
|
"sha256:a4701fd0af6cc398005584865cd43a914e319d7a29942f757cd9dbc53e2a39ec" |
||||
|
], |
||||
|
"version": "==3.0.2" |
||||
|
}, |
||||
|
"werkzeug": { |
||||
|
"hashes": [ |
||||
|
"sha256:c3fd7a7d41976d9f44db327260e263132466836cef6f91512889ed60ad26557c", |
||||
|
"sha256:d5da73735293558eb1651ee2fddc4d0dedcfa06538b8813a2e20011583c9e49b" |
||||
|
], |
||||
|
"index": "pypi", |
||||
|
"version": "==0.14.1" |
||||
|
} |
||||
|
}, |
||||
|
"develop": { |
||||
|
"atomicwrites": { |
||||
|
"hashes": [ |
||||
|
"sha256:240831ea22da9ab882b551b31d4225591e5e447a68c5e188db5b89ca1d487585", |
||||
|
"sha256:a24da68318b08ac9c9c45029f4a10371ab5b20e4226738e150e6e7c571630ae6" |
||||
|
], |
||||
|
"version": "==1.1.5" |
||||
|
}, |
||||
|
"attrs": { |
||||
|
"hashes": [ |
||||
|
"sha256:4b90b09eeeb9b88c35bc642cbac057e45a5fd85367b985bd2809c62b7b939265", |
||||
|
"sha256:e0d0eb91441a3b53dab4d9b743eafc1ac44476296a2053b6ca3af0b139faf87b" |
||||
|
], |
||||
|
"version": "==18.1.0" |
||||
|
}, |
||||
|
"beautifulsoup4": { |
||||
|
"hashes": [ |
||||
|
"sha256:11a9a27b7d3bddc6d86f59fb76afb70e921a25ac2d6cc55b40d072bd68435a76", |
||||
|
"sha256:7015e76bf32f1f574636c4288399a6de66ce08fb7b2457f628a8d70c0fbabb11", |
||||
|
"sha256:808b6ac932dccb0a4126558f7dfdcf41710dd44a4ef497a0bb59a77f9f078e89" |
||||
|
], |
||||
|
"version": "==4.6.0" |
||||
|
}, |
||||
|
"factory-boy": { |
||||
|
"hashes": [ |
||||
|
"sha256:6f25cc4761ac109efd503f096e2ad99421b1159f01a29dbb917359dcd68e08ca", |
||||
|
"sha256:d552cb872b310ae78bd7429bf318e42e1e903b1a109e899a523293dfa762ea4f" |
||||
|
], |
||||
|
"index": "pypi", |
||||
|
"version": "==2.11.1" |
||||
|
}, |
||||
|
"faker": { |
||||
|
"hashes": [ |
||||
|
"sha256:0e9a1227a3a0f3297a485715e72ee6eb77081b17b629367042b586e38c03c867", |
||||
|
"sha256:b4840807a94a3bad0217d6ed3f9b65a1cc6e1db1c99e1184673056ae2c0a4c4d" |
||||
|
], |
||||
|
"index": "pypi", |
||||
|
"version": "==0.8.17" |
||||
|
}, |
||||
|
"more-itertools": { |
||||
|
"hashes": [ |
||||
|
"sha256:2b6b9893337bfd9166bee6a62c2b0c9fe7735dcf85948b387ec8cba30e85d8e8", |
||||
|
"sha256:6703844a52d3588f951883005efcf555e49566a48afd4db4e965d69b883980d3", |
||||
|
"sha256:a18d870ef2ffca2b8463c0070ad17b5978056f403fb64e3f15fe62a52db21cc0" |
||||
|
], |
||||
|
"version": "==4.2.0" |
||||
|
}, |
||||
|
"pluggy": { |
||||
|
"hashes": [ |
||||
|
"sha256:7f8ae7f5bdf75671a718d2daf0a64b7885f74510bcd98b1a0bb420eb9a9d0cff", |
||||
|
"sha256:d345c8fe681115900d6da8d048ba67c25df42973bda370783cd58826442dcd7c", |
||||
|
"sha256:e160a7fcf25762bb60efc7e171d4497ff1d8d2d75a3d0df7a21b76821ecbf5c5" |
||||
|
], |
||||
|
"markers": "python_version != '3.3.*' and python_version != '3.1.*' and python_version != '3.0.*' and python_version != '3.2.*' and python_version >= '2.7'", |
||||
|
"version": "==0.6.0" |
||||
|
}, |
||||
|
"py": { |
||||
|
"hashes": [ |
||||
|
"sha256:3fd59af7435864e1a243790d322d763925431213b6b8529c6ca71081ace3bbf7", |
||||
|
"sha256:e31fb2767eb657cbde86c454f02e99cb846d3cd9d61b318525140214fdc0e98e" |
||||
|
], |
||||
|
"markers": "python_version != '3.3.*' and python_version != '3.1.*' and python_version != '3.2.*' and python_version >= '2.7' and python_version != '3.0.*'", |
||||
|
"version": "==1.5.4" |
||||
|
}, |
||||
|
"pytest": { |
||||
|
"hashes": [ |
||||
|
"sha256:0453c8676c2bee6feb0434748b068d5510273a916295fd61d306c4f22fbfd752", |
||||
|
"sha256:4b208614ae6d98195430ad6bde03641c78553acee7c83cec2e85d613c0cd383d" |
||||
|
], |
||||
|
"index": "pypi", |
||||
|
"version": "==3.6.3" |
||||
|
}, |
||||
|
"python-dateutil": { |
||||
|
"hashes": [ |
||||
|
"sha256:1adb80e7a782c12e52ef9a8182bebeb73f1d7e24e374397af06fb4956c8dc5c0", |
||||
|
"sha256:e27001de32f627c22380a688bcc43ce83504a7bc5da472209b4c70f02829f0b8" |
||||
|
], |
||||
|
"version": "==2.7.3" |
||||
|
}, |
||||
|
"six": { |
||||
|
"hashes": [ |
||||
|
"sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9", |
||||
|
"sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb" |
||||
|
], |
||||
|
"version": "==1.11.0" |
||||
|
}, |
||||
|
"text-unidecode": { |
||||
|
"hashes": [ |
||||
|
"sha256:5a1375bb2ba7968740508ae38d92e1f889a0832913cb1c447d5e2046061a396d", |
||||
|
"sha256:801e38bd550b943563660a91de8d4b6fa5df60a542be9093f7abf819f86050cc" |
||||
|
], |
||||
|
"version": "==1.2" |
||||
|
}, |
||||
|
"waitress": { |
||||
|
"hashes": [ |
||||
|
"sha256:40b0f297a7f3af61fbfbdc67e59090c70dc150a1601c39ecc9f5f1d283fb931b", |
||||
|
"sha256:d33cd3d62426c0f1b3cd84ee3d65779c7003aae3fc060dee60524d10a57f05a9" |
||||
|
], |
||||
|
"version": "==1.1.0" |
||||
|
}, |
||||
|
"webob": { |
||||
|
"hashes": [ |
||||
|
"sha256:1fe722f2ab857685fc96edec567dc40b1875b21219b3b348e58cd8c4d5ea7df3", |
||||
|
"sha256:263690003a3e092ca1ec4df787f93feb0004e39d7bac9cba2c19a552c765894b" |
||||
|
], |
||||
|
"markers": "python_version != '3.1.*' and python_version >= '2.7' and python_version != '3.0.*' and python_version != '3.2.*'", |
||||
|
"version": "==1.8.2" |
||||
|
}, |
||||
|
"webtest": { |
||||
|
"hashes": [ |
||||
|
"sha256:0c08a44bb03dcb2f5ca61d40bd5b4638e74a564d4ec7848098f419a5fa078dfe", |
||||
|
"sha256:5c69f73cc58bef355e919ff96054b68cbaecc7d970b60b602568d3d92ca967d5" |
||||
|
], |
||||
|
"index": "pypi", |
||||
|
"version": "==2.0.30" |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1 @@ |
|||||
|
web: gunicorn autoapp:app -b 0.0.0.0:$PORT -w 3 |
||||
@ -0,0 +1,64 @@ |
|||||
|
|
||||
|
## Install |
||||
|
|
||||
|
Before running shell commands, set the `CONDUIT_SECRET`, `FLASK_APP` and `FLASK_DEBUG` environment variables : |
||||
|
```bash |
||||
|
export CONDUIT_SECRET='something-really-secret' |
||||
|
export FLASK_APP=/path/to/autoapp.py |
||||
|
export FLASK_DEBUG=1 |
||||
|
``` |
||||
|
|
||||
|
- be sure to have python 3+ |
||||
|
- have postgresql installed on your machine |
||||
|
- `pip install -r requirements/dev.txt --user` |
||||
|
|
||||
|
|
||||
|
## Applying db schema : |
||||
|
```bash |
||||
|
flask db init |
||||
|
flask db migrate |
||||
|
flask db upgrade |
||||
|
``` |
||||
|
|
||||
|
|
||||
|
Whenever a database migration needs to be made. Run the following commands |
||||
|
|
||||
|
`flask db migrate` |
||||
|
|
||||
|
This will generate a new migration script. Then run `flask db` upgrade |
||||
|
|
||||
|
To apply the migration. |
||||
|
|
||||
|
For a full migration command reference, run `flask db --help`. |
||||
|
|
||||
|
## Running tests with docker : |
||||
|
|
||||
|
```bash |
||||
|
docker container run --name flask_db_test -e POSTGRES_PASSWORD=somePwd -e POSTGRES_USER=myUsr -p 5432:5432 -d postgres |
||||
|
sleep 1 |
||||
|
export DATABASE_URL=postgresql://myUsr:somePwd@localhost:5432/myUsr |
||||
|
flask db upgrade |
||||
|
flask test |
||||
|
docker container stop flask_db_test |
||||
|
docker container rm flask_db_test |
||||
|
unset DATABASE_URL |
||||
|
``` |
||||
|
|
||||
|
## Usage in prod : |
||||
|
|
||||
|
``` |
||||
|
gunicorn autoapp:app -b 0.0.0.0:$PORT -w 3 |
||||
|
``` |
||||
|
|
||||
|
## Shell |
||||
|
|
||||
|
To open the interactive shell, run `flask shell`. |
||||
|
|
||||
|
By default, you will have access to the flask ``app`` and models. |
||||
|
|
||||
|
## Dev |
||||
|
|
||||
|
In your production environment, make sure the `FLASK_DEBUG` environment |
||||
|
variable is unset or is set to ``0``, so that `ProdConfig` is used, and |
||||
|
set `DATABASE_URL` which is your postgresql URI like for example |
||||
|
`postgresql://localhost/example` |
||||
@ -0,0 +1,103 @@ |
|||||
|
# -*- mode: ruby -*- |
||||
|
# vi: set ft=ruby : |
||||
|
# Postgres config based on Davis Ford's config: https://gist.github.com/davisford/8000332 |
||||
|
|
||||
|
$script = <<SCRIPT |
||||
|
#!/usr/bin/env bash |
||||
|
|
||||
|
# Update package list |
||||
|
|
||||
|
echo "Updating packages list" |
||||
|
sudo add-apt-repository -y ppa:deadsnakes/ppa |
||||
|
sudo apt-get update |
||||
|
|
||||
|
# Install necessary packages |
||||
|
echo "Installing python and postgres" |
||||
|
sudo apt-get install -y python3.7 python3-pip postgresql-10 |
||||
|
|
||||
|
# Setting postgres config |
||||
|
echo "Fixing listen_addresses on postgresql.conf" |
||||
|
sudo sed -i "s/#listen_address.*/listen_addresses '*'/" /etc/postgresql/9.5/main/postgresql.conf |
||||
|
echo "Fixing postgres pg_hba.conf file" |
||||
|
|
||||
|
# Replace the ipv4 host line with the above line |
||||
|
sudo cat >> /etc/postgresql/9.5/main/pg_hba.conf << EOF |
||||
|
# Accept all IPv4 connections - FOR DEVELOPMENT ONLY!!! |
||||
|
host all all 0.0.0.0/0 md5 |
||||
|
EOF |
||||
|
echo "Creating postgres vagrant role with password vagrant" |
||||
|
# Create Role and login |
||||
|
sudo -i -u postgres psql -c "create role vagrant with superuser login password 'vagrant';" |
||||
|
|
||||
|
#Install requeriments, assuming you have your Pipfile in the shared folder. If not, pipenv will create a new |
||||
|
# virtual environment and a new Pipfile |
||||
|
echo "Installing project dependencies" |
||||
|
python3.7 -m pip install pipenv |
||||
|
cd /vagrant |
||||
|
python3.7 -m pipenv install --dev |
||||
|
SCRIPT |
||||
|
|
||||
|
# All Vagrant configuration is done below. The "2" in Vagrant.configure |
||||
|
# configures the configuration version (we support older styles for |
||||
|
# backwards compatibility). Please don't change it unless you know what |
||||
|
# you're doing. |
||||
|
Vagrant.configure("2") do |config| |
||||
|
# The most common configuration options are documented and commented below. |
||||
|
# For a complete reference, please see the online documentation at |
||||
|
# https://docs.vagrantup.com. |
||||
|
|
||||
|
# Every Vagrant development environment requires a box. You can search for |
||||
|
# boxes at https://vagrantcloud.com/search. |
||||
|
config.vm.box = "bento/ubuntu-18.04" |
||||
|
|
||||
|
# Disable automatic box update checking. If you disable this, then |
||||
|
# boxes will only be checked for updates when the user runs |
||||
|
# `vagrant box outdated`. This is not recommended. |
||||
|
# config.vm.box_check_update = false |
||||
|
|
||||
|
# Create a forwarded port mapping which allows access to a specific port |
||||
|
# within the machine from a port on the host machine. In the example below, |
||||
|
# accessing "localhost:8080" will access port 80 on the guest machine. |
||||
|
# NOTE: This will enable public access to the opened port |
||||
|
# config.vm.network "forwarded_port", guest: 80, host: 8080 |
||||
|
|
||||
|
# Create a forwarded port mapping which allows access to a specific port |
||||
|
# within the machine from a port on the host machine and only allow access |
||||
|
# via 127.0.0.1 to disable public access |
||||
|
config.vm.network "forwarded_port", guest: 5432, host: 55432, host_ip: "127.0.0.1" |
||||
|
|
||||
|
# Create a private network, which allows host-only access to the machine |
||||
|
# using a specific IP. |
||||
|
# config.vm.network "private_network", ip: "192.168.33.10" |
||||
|
|
||||
|
# Create a public network, which generally matched to bridged network. |
||||
|
# Bridged networks make the machine appear as another physical device on |
||||
|
# your network. |
||||
|
# config.vm.network "public_network" |
||||
|
|
||||
|
# Share an additional folder to the guest VM. The first argument is |
||||
|
# the path on the host to the actual folder. The second argument is |
||||
|
# the path on the guest to mount the folder. And the optional third |
||||
|
# argument is a set of non-required options. |
||||
|
# config.vm.synced_folder "../data", "/vagrant_data" |
||||
|
|
||||
|
# Provider-specific configuration so you can fine-tune various |
||||
|
# backing providers for Vagrant. These expose provider-specific options. |
||||
|
# Example for VirtualBox: |
||||
|
# |
||||
|
# config.vm.provider "virtualbox" do |vb| |
||||
|
# # Display the VirtualBox GUI when booting the machine |
||||
|
# vb.gui = true |
||||
|
# |
||||
|
# # Customize the amount of memory on the VM: |
||||
|
# vb.memory = "1024" |
||||
|
# end |
||||
|
# |
||||
|
# View the documentation for the provider you are using for more |
||||
|
# information on available options. |
||||
|
|
||||
|
# Enable provisioning with a shell script. Additional provisioners such as |
||||
|
# Puppet, Chef, Ansible, Salt, and Docker are also available. Please see the |
||||
|
# documentation for more information about their specific syntax and use. |
||||
|
config.vm.provision :shell, inline: $script |
||||
|
end |
||||
@ -0,0 +1,10 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
"""Create an application instance.""" |
||||
|
from flask.helpers import get_debug_flag |
||||
|
|
||||
|
from conduit.app import create_app |
||||
|
from conduit.settings import DevConfig, ProdConfig |
||||
|
|
||||
|
CONFIG = DevConfig if get_debug_flag() else ProdConfig |
||||
|
|
||||
|
app = create_app(CONFIG) |
||||
@ -0,0 +1 @@ |
|||||
|
"""Main application package.""" |
||||
@ -0,0 +1,80 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
"""The app module, containing the app factory function.""" |
||||
|
from flask import Flask |
||||
|
from conduit.extensions import bcrypt, cache, db, migrate, jwt, cors |
||||
|
|
||||
|
from conduit import commands, user, profile, articles |
||||
|
from conduit.settings import ProdConfig |
||||
|
from conduit.exceptions import InvalidUsage |
||||
|
|
||||
|
|
||||
|
def create_app(config_object=ProdConfig): |
||||
|
"""An application factory, as explained here: |
||||
|
http://flask.pocoo.org/docs/patterns/appfactories/. |
||||
|
|
||||
|
:param config_object: The configuration object to use. |
||||
|
""" |
||||
|
app = Flask(__name__.split('.')[0]) |
||||
|
app.url_map.strict_slashes = False |
||||
|
app.config.from_object(config_object) |
||||
|
register_extensions(app) |
||||
|
register_blueprints(app) |
||||
|
register_errorhandlers(app) |
||||
|
register_shellcontext(app) |
||||
|
register_commands(app) |
||||
|
return app |
||||
|
|
||||
|
|
||||
|
def register_extensions(app): |
||||
|
"""Register Flask extensions.""" |
||||
|
bcrypt.init_app(app) |
||||
|
cache.init_app(app) |
||||
|
db.init_app(app) |
||||
|
migrate.init_app(app, db) |
||||
|
jwt.init_app(app) |
||||
|
|
||||
|
|
||||
|
def register_blueprints(app): |
||||
|
"""Register Flask blueprints.""" |
||||
|
origins = app.config.get('CORS_ORIGIN_WHITELIST', '*') |
||||
|
cors.init_app(user.views.blueprint, origins=origins) |
||||
|
cors.init_app(profile.views.blueprint, origins=origins) |
||||
|
cors.init_app(articles.views.blueprint, origins=origins) |
||||
|
|
||||
|
app.register_blueprint(user.views.blueprint) |
||||
|
app.register_blueprint(profile.views.blueprint) |
||||
|
app.register_blueprint(articles.views.blueprint) |
||||
|
|
||||
|
|
||||
|
def register_errorhandlers(app): |
||||
|
|
||||
|
def errorhandler(error): |
||||
|
response = error.to_json() |
||||
|
response.status_code = error.status_code |
||||
|
return response |
||||
|
|
||||
|
app.errorhandler(InvalidUsage)(errorhandler) |
||||
|
|
||||
|
|
||||
|
def register_shellcontext(app): |
||||
|
"""Register shell context objects.""" |
||||
|
def shell_context(): |
||||
|
"""Shell context objects.""" |
||||
|
return { |
||||
|
'db': db, |
||||
|
'User': user.models.User, |
||||
|
'UserProfile': profile.models.UserProfile, |
||||
|
'Article': articles.models.Article, |
||||
|
'Tag': articles.models.Tags, |
||||
|
'Comment': articles.models.Comment, |
||||
|
} |
||||
|
|
||||
|
app.shell_context_processor(shell_context) |
||||
|
|
||||
|
|
||||
|
def register_commands(app): |
||||
|
"""Register Click commands.""" |
||||
|
app.cli.add_command(commands.test) |
||||
|
app.cli.add_command(commands.lint) |
||||
|
app.cli.add_command(commands.clean) |
||||
|
app.cli.add_command(commands.urls) |
||||
@ -0,0 +1,3 @@ |
|||||
|
# coding: utf-8 |
||||
|
|
||||
|
from . import views # noqa |
||||
@ -0,0 +1,112 @@ |
|||||
|
# coding: utf-8 |
||||
|
|
||||
|
import datetime as dt |
||||
|
|
||||
|
from flask_jwt_extended import current_user |
||||
|
from slugify import slugify |
||||
|
|
||||
|
from conduit.database import (Model, SurrogatePK, db, Column, |
||||
|
reference_col, relationship) |
||||
|
from conduit.profile.models import UserProfile |
||||
|
|
||||
|
favoriter_assoc = db.Table("favoritor_assoc", |
||||
|
db.Column("favoriter", db.Integer, db.ForeignKey("userprofile.id")), |
||||
|
db.Column("favorited_article", db.Integer, db.ForeignKey("article.id"))) |
||||
|
|
||||
|
tag_assoc = db.Table("tag_assoc", |
||||
|
db.Column("tag", db.Integer, db.ForeignKey("tags.id")), |
||||
|
db.Column("article", db.Integer, db.ForeignKey("article.id"))) |
||||
|
|
||||
|
|
||||
|
class Tags(Model): |
||||
|
__tablename__ = 'tags' |
||||
|
|
||||
|
id = db.Column(db.Integer, primary_key=True) |
||||
|
tagname = db.Column(db.String(100)) |
||||
|
|
||||
|
def __init__(self, tagname): |
||||
|
db.Model.__init__(self, tagname=tagname) |
||||
|
|
||||
|
def __repr__(self): |
||||
|
return self.tagname |
||||
|
|
||||
|
|
||||
|
class Comment(Model, SurrogatePK): |
||||
|
__tablename__ = 'comment' |
||||
|
|
||||
|
id = db.Column(db.Integer, primary_key=True) |
||||
|
body = Column(db.Text) |
||||
|
createdAt = Column(db.DateTime, nullable=False, default=dt.datetime.utcnow) |
||||
|
updatedAt = Column(db.DateTime, nullable=False, default=dt.datetime.utcnow) |
||||
|
author_id = reference_col('userprofile', nullable=False) |
||||
|
author = relationship('UserProfile', backref=db.backref('comments')) |
||||
|
article_id = reference_col('article', nullable=False) |
||||
|
|
||||
|
def __init__(self, article, author, body, **kwargs): |
||||
|
db.Model.__init__(self, author=author, body=body, article=article, **kwargs) |
||||
|
|
||||
|
|
||||
|
class Article(SurrogatePK, Model): |
||||
|
__tablename__ = 'article' |
||||
|
|
||||
|
id = db.Column(db.Integer, primary_key=True) |
||||
|
slug = Column(db.Text, unique=True) |
||||
|
title = Column(db.String(100), nullable=False) |
||||
|
description = Column(db.Text, nullable=False) |
||||
|
body = Column(db.Text) |
||||
|
createdAt = Column(db.DateTime, nullable=False, default=dt.datetime.utcnow) |
||||
|
updatedAt = Column(db.DateTime, nullable=False, default=dt.datetime.utcnow) |
||||
|
author_id = reference_col('userprofile', nullable=False) |
||||
|
author = relationship('UserProfile', backref=db.backref('articles')) |
||||
|
favoriters = relationship( |
||||
|
'UserProfile', |
||||
|
secondary=favoriter_assoc, |
||||
|
backref='favorites', |
||||
|
lazy='dynamic') |
||||
|
|
||||
|
tagList = relationship( |
||||
|
'Tags', secondary=tag_assoc, backref='articles') |
||||
|
|
||||
|
comments = relationship('Comment', backref=db.backref('article'), lazy='dynamic') |
||||
|
|
||||
|
def __init__(self, author, title, body, description, slug=None, **kwargs): |
||||
|
db.Model.__init__(self, author=author, title=title, description=description, body=body, |
||||
|
slug=slug or slugify(title), **kwargs) |
||||
|
|
||||
|
def favourite(self, profile): |
||||
|
if not self.is_favourite(profile): |
||||
|
self.favoriters.append(profile) |
||||
|
return True |
||||
|
return False |
||||
|
|
||||
|
def unfavourite(self, profile): |
||||
|
if self.is_favourite(profile): |
||||
|
self.favoriters.remove(profile) |
||||
|
return True |
||||
|
return False |
||||
|
|
||||
|
def is_favourite(self, profile): |
||||
|
return bool(self.query.filter(favoriter_assoc.c.favoriter == profile.id).count()) |
||||
|
|
||||
|
def add_tag(self, tag): |
||||
|
if tag not in self.tagList: |
||||
|
self.tagList.append(tag) |
||||
|
return True |
||||
|
return False |
||||
|
|
||||
|
def remove_tag(self, tag): |
||||
|
if tag in self.tagList: |
||||
|
self.tagList.remove(tag) |
||||
|
return True |
||||
|
return False |
||||
|
|
||||
|
@property |
||||
|
def favoritesCount(self): |
||||
|
return len(self.favoriters.all()) |
||||
|
|
||||
|
@property |
||||
|
def favorited(self): |
||||
|
if current_user: |
||||
|
profile = current_user.profile |
||||
|
return self.query.join(Article.favoriters).filter(UserProfile.id == profile.id).count() == 1 |
||||
|
return False |
||||
@ -0,0 +1,88 @@ |
|||||
|
# coding: utf-8 |
||||
|
|
||||
|
from marshmallow import Schema, fields, pre_load, post_dump |
||||
|
|
||||
|
from conduit.profile.serializers import ProfileSchema |
||||
|
|
||||
|
|
||||
|
class TagSchema(Schema): |
||||
|
tagname = fields.Str() |
||||
|
|
||||
|
|
||||
|
class ArticleSchema(Schema): |
||||
|
slug = fields.Str() |
||||
|
title = fields.Str() |
||||
|
description = fields.Str() |
||||
|
createdAt = fields.DateTime() |
||||
|
body = fields.Str() |
||||
|
updatedAt = fields.DateTime(dump_only=True) |
||||
|
author = fields.Nested(ProfileSchema) |
||||
|
article = fields.Nested('self', exclude=('article',), default=True, load_only=True) |
||||
|
tagList = fields.List(fields.Str()) |
||||
|
favoritesCount = fields.Int(dump_only=True) |
||||
|
favorited = fields.Bool(dump_only=True) |
||||
|
|
||||
|
@pre_load |
||||
|
def make_article(self, data, **kwargs): |
||||
|
return data['article'] |
||||
|
|
||||
|
@post_dump |
||||
|
def dump_article(self, data, **kwargs): |
||||
|
data['author'] = data['author']['profile'] |
||||
|
return {'article': data} |
||||
|
|
||||
|
class Meta: |
||||
|
strict = True |
||||
|
|
||||
|
|
||||
|
class ArticleSchemas(ArticleSchema): |
||||
|
|
||||
|
@post_dump |
||||
|
def dump_article(self, data, **kwargs): |
||||
|
data['author'] = data['author']['profile'] |
||||
|
return data |
||||
|
|
||||
|
@post_dump(pass_many=True) |
||||
|
def dump_articles(self, data, many, **kwargs): |
||||
|
return {'articles': data, 'articlesCount': len(data)} |
||||
|
|
||||
|
|
||||
|
class CommentSchema(Schema): |
||||
|
createdAt = fields.DateTime() |
||||
|
body = fields.Str() |
||||
|
updatedAt = fields.DateTime(dump_only=True) |
||||
|
author = fields.Nested(ProfileSchema) |
||||
|
id = fields.Int() |
||||
|
|
||||
|
# for the envelope |
||||
|
comment = fields.Nested('self', exclude=('comment',), default=True, load_only=True) |
||||
|
|
||||
|
@pre_load |
||||
|
def make_comment(self, data, **kwargs): |
||||
|
return data['comment'] |
||||
|
|
||||
|
@post_dump |
||||
|
def dump_comment(self, data, **kwargs): |
||||
|
data['author'] = data['author']['profile'] |
||||
|
return {'comment': data} |
||||
|
|
||||
|
class Meta: |
||||
|
strict = True |
||||
|
|
||||
|
|
||||
|
class CommentsSchema(CommentSchema): |
||||
|
|
||||
|
@post_dump |
||||
|
def dump_comment(self, data, **kwargs): |
||||
|
data['author'] = data['author']['profile'] |
||||
|
return data |
||||
|
|
||||
|
@post_dump(pass_many=True) |
||||
|
def make_comment(self, data, many, **kwargs): |
||||
|
return {'comments': data} |
||||
|
|
||||
|
|
||||
|
article_schema = ArticleSchema() |
||||
|
articles_schema = ArticleSchemas(many=True) |
||||
|
comment_schema = CommentSchema() |
||||
|
comments_schema = CommentsSchema(many=True) |
||||
@ -0,0 +1,168 @@ |
|||||
|
# coding: utf-8 |
||||
|
|
||||
|
import datetime as dt |
||||
|
|
||||
|
from flask import Blueprint, jsonify |
||||
|
from flask_apispec import marshal_with, use_kwargs |
||||
|
from flask_jwt_extended import current_user, jwt_required, jwt_optional |
||||
|
from marshmallow import fields |
||||
|
|
||||
|
from conduit.exceptions import InvalidUsage |
||||
|
from conduit.user.models import User |
||||
|
from .models import Article, Tags, Comment |
||||
|
from .serializers import (article_schema, articles_schema, comment_schema, |
||||
|
comments_schema) |
||||
|
|
||||
|
blueprint = Blueprint('articles', __name__) |
||||
|
|
||||
|
|
||||
|
########## |
||||
|
# Articles |
||||
|
########## |
||||
|
|
||||
|
@blueprint.route('/api/articles', methods=('GET',)) |
||||
|
@jwt_optional |
||||
|
@use_kwargs({'tag': fields.Str(), 'author': fields.Str(), |
||||
|
'favorited': fields.Str(), 'limit': fields.Int(), 'offset': fields.Int()}) |
||||
|
@marshal_with(articles_schema) |
||||
|
def get_articles(tag=None, author=None, favorited=None, limit=20, offset=0): |
||||
|
res = Article.query |
||||
|
if tag: |
||||
|
res = res.filter(Article.tagList.any(Tags.tagname == tag)) |
||||
|
if author: |
||||
|
res = res.join(Article.author).join(User).filter(User.username == author) |
||||
|
if favorited: |
||||
|
res = res.join(Article.favoriters).filter(User.username == favorited) |
||||
|
return res.offset(offset).limit(limit).all() |
||||
|
|
||||
|
|
||||
|
@blueprint.route('/api/articles', methods=('POST',)) |
||||
|
@jwt_required |
||||
|
@use_kwargs(article_schema) |
||||
|
@marshal_with(article_schema) |
||||
|
def make_article(body, title, description, tagList=None): |
||||
|
article = Article(title=title, description=description, body=body, |
||||
|
author=current_user.profile) |
||||
|
if tagList is not None: |
||||
|
for tag in tagList: |
||||
|
mtag = Tags.query.filter_by(tagname=tag).first() |
||||
|
if not mtag: |
||||
|
mtag = Tags(tag) |
||||
|
mtag.save() |
||||
|
article.add_tag(mtag) |
||||
|
article.save() |
||||
|
return article |
||||
|
|
||||
|
|
||||
|
@blueprint.route('/api/articles/<slug>', methods=('PUT',)) |
||||
|
@jwt_required |
||||
|
@use_kwargs(article_schema) |
||||
|
@marshal_with(article_schema) |
||||
|
def update_article(slug, **kwargs): |
||||
|
article = Article.query.filter_by(slug=slug, author_id=current_user.profile.id).first() |
||||
|
if not article: |
||||
|
raise InvalidUsage.article_not_found() |
||||
|
article.update(updatedAt=dt.datetime.utcnow(), **kwargs) |
||||
|
article.save() |
||||
|
return article |
||||
|
|
||||
|
|
||||
|
@blueprint.route('/api/articles/<slug>', methods=('DELETE',)) |
||||
|
@jwt_required |
||||
|
def delete_article(slug): |
||||
|
article = Article.query.filter_by(slug=slug, author_id=current_user.profile.id).first() |
||||
|
article.delete() |
||||
|
return '', 200 |
||||
|
|
||||
|
|
||||
|
@blueprint.route('/api/articles/<slug>', methods=('GET',)) |
||||
|
@jwt_optional |
||||
|
@marshal_with(article_schema) |
||||
|
def get_article(slug): |
||||
|
article = Article.query.filter_by(slug=slug).first() |
||||
|
if not article: |
||||
|
raise InvalidUsage.article_not_found() |
||||
|
return article |
||||
|
|
||||
|
|
||||
|
@blueprint.route('/api/articles/<slug>/favorite', methods=('POST',)) |
||||
|
@jwt_required |
||||
|
@marshal_with(article_schema) |
||||
|
def favorite_an_article(slug): |
||||
|
profile = current_user.profile |
||||
|
article = Article.query.filter_by(slug=slug).first() |
||||
|
if not article: |
||||
|
raise InvalidUsage.article_not_found() |
||||
|
article.favourite(profile) |
||||
|
article.save() |
||||
|
return article |
||||
|
|
||||
|
|
||||
|
@blueprint.route('/api/articles/<slug>/favorite', methods=('DELETE',)) |
||||
|
@jwt_required |
||||
|
@marshal_with(article_schema) |
||||
|
def unfavorite_an_article(slug): |
||||
|
profile = current_user.profile |
||||
|
article = Article.query.filter_by(slug=slug).first() |
||||
|
if not article: |
||||
|
raise InvalidUsage.article_not_found() |
||||
|
article.unfavourite(profile) |
||||
|
article.save() |
||||
|
return article |
||||
|
|
||||
|
|
||||
|
@blueprint.route('/api/articles/feed', methods=('GET',)) |
||||
|
@jwt_required |
||||
|
@use_kwargs({'limit': fields.Int(), 'offset': fields.Int()}) |
||||
|
@marshal_with(articles_schema) |
||||
|
def articles_feed(limit=20, offset=0): |
||||
|
return Article.query.join(current_user.profile.follows). \ |
||||
|
order_by(Article.createdAt.desc()).offset(offset).limit(limit).all() |
||||
|
|
||||
|
|
||||
|
###### |
||||
|
# Tags |
||||
|
###### |
||||
|
|
||||
|
@blueprint.route('/api/tags', methods=('GET',)) |
||||
|
def get_tags(): |
||||
|
return jsonify({'tags': [tag.tagname for tag in Tags.query.all()]}) |
||||
|
|
||||
|
|
||||
|
########## |
||||
|
# Comments |
||||
|
########## |
||||
|
|
||||
|
|
||||
|
@blueprint.route('/api/articles/<slug>/comments', methods=('GET',)) |
||||
|
@marshal_with(comments_schema) |
||||
|
def get_comments(slug): |
||||
|
article = Article.query.filter_by(slug=slug).first() |
||||
|
if not article: |
||||
|
raise InvalidUsage.article_not_found() |
||||
|
return article.comments |
||||
|
|
||||
|
|
||||
|
@blueprint.route('/api/articles/<slug>/comments', methods=('POST',)) |
||||
|
@jwt_required |
||||
|
@use_kwargs(comment_schema) |
||||
|
@marshal_with(comment_schema) |
||||
|
def make_comment_on_article(slug, body, **kwargs): |
||||
|
article = Article.query.filter_by(slug=slug).first() |
||||
|
if not article: |
||||
|
raise InvalidUsage.article_not_found() |
||||
|
comment = Comment(article, current_user.profile, body, **kwargs) |
||||
|
comment.save() |
||||
|
return comment |
||||
|
|
||||
|
|
||||
|
@blueprint.route('/api/articles/<slug>/comments/<cid>', methods=('DELETE',)) |
||||
|
@jwt_required |
||||
|
def delete_comment_on_article(slug, cid): |
||||
|
article = Article.query.filter_by(slug=slug).first() |
||||
|
if not article: |
||||
|
raise InvalidUsage.article_not_found() |
||||
|
|
||||
|
comment = article.comments.filter_by(id=cid, author=current_user.profile).first() |
||||
|
comment.delete() |
||||
|
return '', 200 |
||||
@ -0,0 +1,123 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
"""Click commands.""" |
||||
|
import os |
||||
|
from glob import glob |
||||
|
from subprocess import call |
||||
|
|
||||
|
import click |
||||
|
from flask import current_app |
||||
|
from flask.cli import with_appcontext |
||||
|
from werkzeug.exceptions import MethodNotAllowed, NotFound |
||||
|
|
||||
|
HERE = os.path.abspath(os.path.dirname(__file__)) |
||||
|
PROJECT_ROOT = os.path.join(HERE, os.pardir) |
||||
|
TEST_PATH = os.path.join(PROJECT_ROOT, 'tests') |
||||
|
|
||||
|
|
||||
|
@click.command() |
||||
|
def test(): |
||||
|
"""Run the tests.""" |
||||
|
import pytest |
||||
|
rv = pytest.main([TEST_PATH, '--verbose']) |
||||
|
exit(rv) |
||||
|
|
||||
|
|
||||
|
@click.command() |
||||
|
@click.option('-f', '--fix-imports', default=False, is_flag=True, |
||||
|
help='Fix imports using isort, before linting') |
||||
|
def lint(fix_imports): |
||||
|
"""Lint and check code style with flake8 and isort.""" |
||||
|
skip = ['requirements'] |
||||
|
root_files = glob('*.py') |
||||
|
root_directories = [ |
||||
|
name for name in next(os.walk('.'))[1] if not name.startswith('.')] |
||||
|
files_and_directories = [ |
||||
|
arg for arg in root_files + root_directories if arg not in skip] |
||||
|
|
||||
|
def execute_tool(description, *args): |
||||
|
"""Execute a checking tool with its arguments.""" |
||||
|
command_line = list(args) + files_and_directories |
||||
|
click.echo('{}: {}'.format(description, ' '.join(command_line))) |
||||
|
rv = call(command_line) |
||||
|
if rv != 0: |
||||
|
exit(rv) |
||||
|
|
||||
|
if fix_imports: |
||||
|
execute_tool('Fixing import order', 'isort', '-rc') |
||||
|
execute_tool('Checking code style', 'flake8') |
||||
|
|
||||
|
|
||||
|
@click.command() |
||||
|
def clean(): |
||||
|
"""Remove *.pyc and *.pyo files recursively starting at current directory. |
||||
|
|
||||
|
Borrowed from Flask-Script, converted to use Click. |
||||
|
""" |
||||
|
for dirpath, _, filenames in os.walk('.'): |
||||
|
for filename in filenames: |
||||
|
if filename.endswith('.pyc') or filename.endswith('.pyo'): |
||||
|
full_pathname = os.path.join(dirpath, filename) |
||||
|
click.echo('Removing {}'.format(full_pathname)) |
||||
|
os.remove(full_pathname) |
||||
|
|
||||
|
|
||||
|
@click.command() |
||||
|
@click.option('--url', default=None, |
||||
|
help='Url to test (ex. /static/image.png)') |
||||
|
@click.option('--order', default='rule', |
||||
|
help='Property on Rule to order by (default: rule)') |
||||
|
@with_appcontext |
||||
|
def urls(url, order): |
||||
|
"""Display all of the url matching routes for the project. |
||||
|
|
||||
|
Borrowed from Flask-Script, converted to use Click. |
||||
|
""" |
||||
|
rows = [] |
||||
|
column_headers = ('Rule', 'Endpoint', 'Arguments') |
||||
|
|
||||
|
if url: |
||||
|
try: |
||||
|
rule, arguments = ( |
||||
|
current_app.url_map.bind('localhost') |
||||
|
.match(url, return_rule=True)) |
||||
|
rows.append((rule.rule, rule.endpoint, arguments)) |
||||
|
column_length = 3 |
||||
|
except (NotFound, MethodNotAllowed) as e: |
||||
|
rows.append(('<{}>'.format(e), None, None)) |
||||
|
column_length = 1 |
||||
|
else: |
||||
|
rules = sorted( |
||||
|
current_app.url_map.iter_rules(), |
||||
|
key=lambda rule: getattr(rule, order)) |
||||
|
for rule in rules: |
||||
|
rows.append((rule.rule, rule.endpoint, None)) |
||||
|
column_length = 2 |
||||
|
|
||||
|
str_template = '' |
||||
|
table_width = 0 |
||||
|
|
||||
|
if column_length >= 1: |
||||
|
max_rule_length = max(len(r[0]) for r in rows) |
||||
|
max_rule_length = max_rule_length if max_rule_length > 4 else 4 |
||||
|
str_template += '{:' + str(max_rule_length) + '}' |
||||
|
table_width += max_rule_length |
||||
|
|
||||
|
if column_length >= 2: |
||||
|
max_endpoint_length = max(len(str(r[1])) for r in rows) |
||||
|
max_endpoint_length = ( |
||||
|
max_endpoint_length if max_endpoint_length > 8 else 8) |
||||
|
str_template += ' {:' + str(max_endpoint_length) + '}' |
||||
|
table_width += 2 + max_endpoint_length |
||||
|
|
||||
|
if column_length >= 3: |
||||
|
max_arguments_length = max(len(str(r[2])) for r in rows) |
||||
|
max_arguments_length = ( |
||||
|
max_arguments_length if max_arguments_length > 9 else 9) |
||||
|
str_template += ' {:' + str(max_arguments_length) + '}' |
||||
|
table_width += 2 + max_arguments_length |
||||
|
|
||||
|
click.echo(str_template.format(*column_headers[:column_length])) |
||||
|
click.echo('-' * table_width) |
||||
|
|
||||
|
for row in rows: |
||||
|
click.echo(str_template.format(*row[:column_length])) |
||||
@ -0,0 +1,18 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
"""Python 2/3 compatibility module.""" |
||||
|
import sys |
||||
|
|
||||
|
PY2 = int(sys.version[0]) == 2 |
||||
|
|
||||
|
if PY2: |
||||
|
text_type = unicode # noqa |
||||
|
binary_type = str |
||||
|
string_types = (str, unicode) # noqa |
||||
|
unicode = unicode # noqa |
||||
|
basestring = basestring # noqa |
||||
|
else: |
||||
|
text_type = str |
||||
|
binary_type = bytes |
||||
|
string_types = (str,) |
||||
|
unicode = str |
||||
|
basestring = (str, bytes) |
||||
@ -0,0 +1,44 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
"""Database module, including the SQLAlchemy database object and DB-related utilities.""" |
||||
|
from sqlalchemy.orm import relationship |
||||
|
|
||||
|
from .compat import basestring |
||||
|
from .extensions import db |
||||
|
|
||||
|
# Alias common SQLAlchemy names |
||||
|
Column = db.Column |
||||
|
relationship = relationship |
||||
|
Model = db.Model |
||||
|
|
||||
|
# From Mike Bayer's "Building the app" talk |
||||
|
# https://speakerdeck.com/zzzeek/building-the-app |
||||
|
class SurrogatePK(object): |
||||
|
"""A mixin that adds a surrogate integer 'primary key' column named ``id`` \ |
||||
|
to any declarative-mapped class. |
||||
|
""" |
||||
|
|
||||
|
__table_args__ = {'extend_existing': True} |
||||
|
|
||||
|
id = db.Column(db.Integer, primary_key=True) |
||||
|
|
||||
|
@classmethod |
||||
|
def get_by_id(cls, record_id): |
||||
|
"""Get record by ID.""" |
||||
|
if any( |
||||
|
(isinstance(record_id, basestring) and record_id.isdigit(), |
||||
|
isinstance(record_id, (int, float))), |
||||
|
): |
||||
|
return cls.query.get(int(record_id)) |
||||
|
|
||||
|
|
||||
|
def reference_col(tablename, nullable=False, pk_name='id', **kwargs): |
||||
|
"""Column that adds primary key foreign key reference. |
||||
|
|
||||
|
Usage: :: |
||||
|
|
||||
|
category_id = reference_col('category') |
||||
|
category = relationship('Category', backref='categories') |
||||
|
""" |
||||
|
return db.Column( |
||||
|
db.ForeignKey('{0}.{1}'.format(tablename, pk_name)), |
||||
|
nullable=nullable, **kwargs) |
||||
@ -0,0 +1,47 @@ |
|||||
|
from flask import jsonify |
||||
|
|
||||
|
|
||||
|
def template(data, code=500): |
||||
|
return {'message': {'errors': {'body': data}}, 'status_code': code} |
||||
|
|
||||
|
|
||||
|
USER_NOT_FOUND = template(['User not found'], code=404) |
||||
|
USER_ALREADY_REGISTERED = template(['User already registered'], code=422) |
||||
|
UNKNOWN_ERROR = template([], code=500) |
||||
|
ARTICLE_NOT_FOUND = template(['Article not found'], code=404) |
||||
|
COMMENT_NOT_OWNED = template(['Not your article'], code=422) |
||||
|
|
||||
|
|
||||
|
class InvalidUsage(Exception): |
||||
|
status_code = 500 |
||||
|
|
||||
|
def __init__(self, message, status_code=None, payload=None): |
||||
|
Exception.__init__(self) |
||||
|
self.message = message |
||||
|
if status_code is not None: |
||||
|
self.status_code = status_code |
||||
|
self.payload = payload |
||||
|
|
||||
|
def to_json(self): |
||||
|
rv = self.message |
||||
|
return jsonify(rv) |
||||
|
|
||||
|
@classmethod |
||||
|
def user_not_found(cls): |
||||
|
return cls(**USER_NOT_FOUND) |
||||
|
|
||||
|
@classmethod |
||||
|
def user_already_registered(cls): |
||||
|
return cls(**USER_ALREADY_REGISTERED) |
||||
|
|
||||
|
@classmethod |
||||
|
def unknown_error(cls): |
||||
|
return cls(**UNKNOWN_ERROR) |
||||
|
|
||||
|
@classmethod |
||||
|
def article_not_found(cls): |
||||
|
return cls(**ARTICLE_NOT_FOUND) |
||||
|
|
||||
|
@classmethod |
||||
|
def comment_not_owned(cls): |
||||
|
return cls(**COMMENT_NOT_OWNED) |
||||
@ -0,0 +1,50 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
"""Extensions module. Each extension is initialized in the app factory located in app.py.""" |
||||
|
|
||||
|
from flask_bcrypt import Bcrypt |
||||
|
from flask_caching import Cache |
||||
|
from flask_cors import CORS |
||||
|
from flask_jwt_extended import JWTManager |
||||
|
from flask_migrate import Migrate |
||||
|
from flask_sqlalchemy import SQLAlchemy, Model |
||||
|
|
||||
|
|
||||
|
class CRUDMixin(Model): |
||||
|
"""Mixin that adds convenience methods for CRUD (create, read, update, delete) operations.""" |
||||
|
|
||||
|
@classmethod |
||||
|
def create(cls, **kwargs): |
||||
|
"""Create a new record and save it the database.""" |
||||
|
instance = cls(**kwargs) |
||||
|
return instance.save() |
||||
|
|
||||
|
def update(self, commit=True, **kwargs): |
||||
|
"""Update specific fields of a record.""" |
||||
|
for attr, value in kwargs.items(): |
||||
|
setattr(self, attr, value) |
||||
|
return commit and self.save() or self |
||||
|
|
||||
|
def save(self, commit=True): |
||||
|
"""Save the record.""" |
||||
|
db.session.add(self) |
||||
|
if commit: |
||||
|
db.session.commit() |
||||
|
return self |
||||
|
|
||||
|
def delete(self, commit=True): |
||||
|
"""Remove the record from the database.""" |
||||
|
db.session.delete(self) |
||||
|
return commit and db.session.commit() |
||||
|
|
||||
|
|
||||
|
bcrypt = Bcrypt() |
||||
|
db = SQLAlchemy(model_class=CRUDMixin) |
||||
|
migrate = Migrate() |
||||
|
cache = Cache() |
||||
|
cors = CORS() |
||||
|
|
||||
|
from conduit.utils import jwt_identity, identity_loader # noqa |
||||
|
|
||||
|
jwt = JWTManager() |
||||
|
jwt.user_loader_callback_loader(jwt_identity) |
||||
|
jwt.user_identity_loader(identity_loader) |
||||
@ -0,0 +1 @@ |
|||||
|
from . import views # noqa |
||||
@ -0,0 +1,65 @@ |
|||||
|
from flask_jwt_extended import current_user |
||||
|
|
||||
|
from conduit.database import (Model, SurrogatePK, db, |
||||
|
reference_col, relationship) |
||||
|
|
||||
|
|
||||
|
followers_assoc = db.Table("followers_assoc", |
||||
|
db.Column("follower", db.Integer, db.ForeignKey("userprofile.user_id")), |
||||
|
db.Column("followed_by", db.Integer, db.ForeignKey("userprofile.user_id"))) |
||||
|
|
||||
|
|
||||
|
class UserProfile(Model, SurrogatePK): |
||||
|
__tablename__ = 'userprofile' |
||||
|
|
||||
|
# id is needed for primary join, it does work with SurrogatePK class |
||||
|
id = db.Column(db.Integer, primary_key=True) |
||||
|
|
||||
|
user_id = reference_col('users', nullable=False, unique=True) |
||||
|
user = relationship('User', backref=db.backref('profile', uselist=False)) |
||||
|
follows = relationship('UserProfile', |
||||
|
secondary=followers_assoc, |
||||
|
primaryjoin=id == followers_assoc.c.follower, |
||||
|
secondaryjoin=id == followers_assoc.c.followed_by, |
||||
|
backref='followed_by', |
||||
|
lazy='dynamic') |
||||
|
|
||||
|
def __init__(self, user, **kwargs): |
||||
|
db.Model.__init__(self, user=user, **kwargs) |
||||
|
|
||||
|
def is_following(self, profile): |
||||
|
return bool(self.follows.filter(followers_assoc.c.followed_by == profile.id).count()) |
||||
|
|
||||
|
def follow(self, profile): |
||||
|
if self is not profile and not self.is_following(profile): |
||||
|
self.follows.append(profile) |
||||
|
return True |
||||
|
return False |
||||
|
|
||||
|
def unfollow(self, profile): |
||||
|
if self is not profile and self.is_following(profile): |
||||
|
self.follows.remove(profile) |
||||
|
return True |
||||
|
return False |
||||
|
|
||||
|
@property |
||||
|
def following(self): |
||||
|
if current_user: |
||||
|
return current_user.profile.is_following(self) |
||||
|
return False |
||||
|
|
||||
|
@property |
||||
|
def username(self): |
||||
|
return self.user.username |
||||
|
|
||||
|
@property |
||||
|
def bio(self): |
||||
|
return self.user.bio |
||||
|
|
||||
|
@property |
||||
|
def image(self): |
||||
|
return self.user.image |
||||
|
|
||||
|
@property |
||||
|
def email(self): |
||||
|
return self.user.email |
||||
@ -0,0 +1,27 @@ |
|||||
|
from marshmallow import Schema, fields, pre_load, post_dump |
||||
|
|
||||
|
|
||||
|
class ProfileSchema(Schema): |
||||
|
username = fields.Str() |
||||
|
email = fields.Email() |
||||
|
password = fields.Str(load_only=True) |
||||
|
bio = fields.Str() |
||||
|
image = fields.Url() |
||||
|
following = fields.Boolean() |
||||
|
# ugly hack. |
||||
|
profile = fields.Nested('self', exclude=('profile',), default=True, load_only=True) |
||||
|
|
||||
|
@pre_load |
||||
|
def make_user(self, data, **kwargs): |
||||
|
return data['profile'] |
||||
|
|
||||
|
@post_dump |
||||
|
def dump_user(self, data, **kwargs): |
||||
|
return {'profile': data} |
||||
|
|
||||
|
class Meta: |
||||
|
strict = True |
||||
|
|
||||
|
|
||||
|
profile_schema = ProfileSchema() |
||||
|
profile_schemas = ProfileSchema(many=True) |
||||
@ -0,0 +1,45 @@ |
|||||
|
# coding: utf-8 |
||||
|
|
||||
|
from flask import Blueprint |
||||
|
from flask_apispec import marshal_with |
||||
|
from flask_jwt_extended import current_user, jwt_required, jwt_optional |
||||
|
|
||||
|
from conduit.exceptions import InvalidUsage |
||||
|
from conduit.user.models import User |
||||
|
from .serializers import profile_schema |
||||
|
|
||||
|
blueprint = Blueprint('profiles', __name__) |
||||
|
|
||||
|
|
||||
|
@blueprint.route('/api/profiles/<username>', methods=('GET',)) |
||||
|
@jwt_optional |
||||
|
@marshal_with(profile_schema) |
||||
|
def get_profile(username): |
||||
|
user = User.query.filter_by(username=username).first() |
||||
|
if not user: |
||||
|
raise InvalidUsage.user_not_found() |
||||
|
return user.profile |
||||
|
|
||||
|
|
||||
|
@blueprint.route('/api/profiles/<username>/follow', methods=('POST',)) |
||||
|
@jwt_required |
||||
|
@marshal_with(profile_schema) |
||||
|
def follow_user(username): |
||||
|
user = User.query.filter_by(username=username).first() |
||||
|
if not user: |
||||
|
raise InvalidUsage.user_not_found() |
||||
|
current_user.profile.follow(user.profile) |
||||
|
current_user.profile.save() |
||||
|
return user.profile |
||||
|
|
||||
|
|
||||
|
@blueprint.route('/api/profiles/<username>/follow', methods=('DELETE',)) |
||||
|
@jwt_required |
||||
|
@marshal_with(profile_schema) |
||||
|
def unfollow_user(username): |
||||
|
user = User.query.filter_by(username=username).first() |
||||
|
if not user: |
||||
|
raise InvalidUsage.user_not_found() |
||||
|
current_user.profile.unfollow(user.profile) |
||||
|
current_user.profile.save() |
||||
|
return user.profile |
||||
@ -0,0 +1,60 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
"""Application configuration.""" |
||||
|
import os |
||||
|
from datetime import timedelta |
||||
|
|
||||
|
|
||||
|
class Config(object): |
||||
|
"""Base configuration.""" |
||||
|
|
||||
|
SECRET_KEY = os.environ.get('CONDUIT_SECRET', 'secret-key') # TODO: Change me |
||||
|
APP_DIR = os.path.abspath(os.path.dirname(__file__)) # This directory |
||||
|
PROJECT_ROOT = os.path.abspath(os.path.join(APP_DIR, os.pardir)) |
||||
|
BCRYPT_LOG_ROUNDS = 13 |
||||
|
DEBUG_TB_INTERCEPT_REDIRECTS = False |
||||
|
CACHE_TYPE = 'simple' # Can be "memcached", "redis", etc. |
||||
|
SQLALCHEMY_TRACK_MODIFICATIONS = False |
||||
|
JWT_AUTH_USERNAME_KEY = 'email' |
||||
|
JWT_AUTH_HEADER_PREFIX = 'Token' |
||||
|
CORS_ORIGIN_WHITELIST = [ |
||||
|
'http://0.0.0.0:4100', |
||||
|
'http://localhost:4100', |
||||
|
'http://0.0.0.0:8000', |
||||
|
'http://localhost:8000', |
||||
|
'http://0.0.0.0:4200', |
||||
|
'http://localhost:4200', |
||||
|
'http://0.0.0.0:4000', |
||||
|
'http://localhost:4000', |
||||
|
] |
||||
|
JWT_HEADER_TYPE = 'Token' |
||||
|
|
||||
|
|
||||
|
class ProdConfig(Config): |
||||
|
"""Production configuration.""" |
||||
|
|
||||
|
ENV = 'prod' |
||||
|
DEBUG = False |
||||
|
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL', |
||||
|
'postgresql://localhost/example') |
||||
|
|
||||
|
|
||||
|
class DevConfig(Config): |
||||
|
"""Development configuration.""" |
||||
|
|
||||
|
ENV = 'dev' |
||||
|
DEBUG = True |
||||
|
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL', |
||||
|
'postgresql://localhost/example') |
||||
|
CACHE_TYPE = 'simple' # Can be "memcached", "redis", etc. |
||||
|
JWT_ACCESS_TOKEN_EXPIRES = timedelta(10 ** 6) |
||||
|
|
||||
|
|
||||
|
class TestConfig(Config): |
||||
|
"""Test configuration.""" |
||||
|
|
||||
|
TESTING = True |
||||
|
DEBUG = True |
||||
|
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL', |
||||
|
'postgresql://localhost/example') |
||||
|
# For faster tests; needs at least 4 to avoid "ValueError: Invalid rounds" |
||||
|
BCRYPT_LOG_ROUNDS = 4 |
||||
@ -0,0 +1,3 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
"""The user module.""" |
||||
|
from . import views # noqa |
||||
@ -0,0 +1,39 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
"""User models.""" |
||||
|
import datetime as dt |
||||
|
|
||||
|
from conduit.database import Column, Model, SurrogatePK, db |
||||
|
from conduit.extensions import bcrypt |
||||
|
|
||||
|
|
||||
|
class User(SurrogatePK, Model): |
||||
|
|
||||
|
__tablename__ = 'users' |
||||
|
username = Column(db.String(80), unique=True, nullable=False) |
||||
|
email = Column(db.String(100), unique=True, nullable=False) |
||||
|
password = Column(db.Binary(128), nullable=True) |
||||
|
created_at = Column(db.DateTime, nullable=False, default=dt.datetime.utcnow) |
||||
|
updated_at = Column(db.DateTime, nullable=False, default=dt.datetime.utcnow) |
||||
|
bio = Column(db.String(300), nullable=True) |
||||
|
image = Column(db.String(120), nullable=True) |
||||
|
token: str = '' |
||||
|
|
||||
|
def __init__(self, username, email, password=None, **kwargs): |
||||
|
"""Create instance.""" |
||||
|
db.Model.__init__(self, username=username, email=email, **kwargs) |
||||
|
if password: |
||||
|
self.set_password(password) |
||||
|
else: |
||||
|
self.password = None |
||||
|
|
||||
|
def set_password(self, password): |
||||
|
"""Set password.""" |
||||
|
self.password = bcrypt.generate_password_hash(password) |
||||
|
|
||||
|
def check_password(self, value): |
||||
|
"""Check password.""" |
||||
|
return bcrypt.check_password_hash(self.password, value) |
||||
|
|
||||
|
def __repr__(self): |
||||
|
"""Represent instance as a unique string.""" |
||||
|
return '<User({username!r})>'.format(username=self.username) |
||||
@ -0,0 +1,38 @@ |
|||||
|
# coding: utf-8 |
||||
|
|
||||
|
from marshmallow import Schema, fields, pre_load, post_dump |
||||
|
|
||||
|
|
||||
|
class UserSchema(Schema): |
||||
|
username = fields.Str() |
||||
|
email = fields.Email() |
||||
|
password = fields.Str(load_only=True) |
||||
|
bio = fields.Str() |
||||
|
image = fields.Url() |
||||
|
token = fields.Str(dump_only=True) |
||||
|
createdAt = fields.DateTime(attribute='created_at', dump_only=True) |
||||
|
updatedAt = fields.DateTime(attribute='updated_at') |
||||
|
# ugly hack. |
||||
|
user = fields.Nested('self', exclude=('user',), default=True, load_only=True) |
||||
|
|
||||
|
@pre_load |
||||
|
def make_user(self, data, **kwargs): |
||||
|
data = data['user'] |
||||
|
# some of the frontends send this like an empty string and some send |
||||
|
# null |
||||
|
if not data.get('email', True): |
||||
|
del data['email'] |
||||
|
if not data.get('image', True): |
||||
|
del data['image'] |
||||
|
return data |
||||
|
|
||||
|
@post_dump |
||||
|
def dump_user(self, data, **kwargs): |
||||
|
return {'user': data} |
||||
|
|
||||
|
class Meta: |
||||
|
strict = True |
||||
|
|
||||
|
|
||||
|
user_schema = UserSchema() |
||||
|
user_schemas = UserSchema(many=True) |
||||
@ -0,0 +1,66 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
"""User views.""" |
||||
|
from flask import Blueprint, request |
||||
|
from flask_apispec import use_kwargs, marshal_with |
||||
|
from flask_jwt_extended import jwt_required, jwt_optional, create_access_token, current_user |
||||
|
from sqlalchemy.exc import IntegrityError |
||||
|
|
||||
|
from conduit.database import db |
||||
|
from conduit.exceptions import InvalidUsage |
||||
|
from conduit.profile.models import UserProfile |
||||
|
from .models import User |
||||
|
from .serializers import user_schema |
||||
|
|
||||
|
blueprint = Blueprint('user', __name__) |
||||
|
|
||||
|
|
||||
|
@blueprint.route('/api/users', methods=('POST',)) |
||||
|
@use_kwargs(user_schema) |
||||
|
@marshal_with(user_schema) |
||||
|
def register_user(username, password, email, **kwargs): |
||||
|
try: |
||||
|
userprofile = UserProfile(User(username, email, password=password, **kwargs).save()).save() |
||||
|
userprofile.user.token = create_access_token(identity=userprofile.user) |
||||
|
except IntegrityError: |
||||
|
db.session.rollback() |
||||
|
raise InvalidUsage.user_already_registered() |
||||
|
return userprofile.user |
||||
|
|
||||
|
|
||||
|
@blueprint.route('/api/users/login', methods=('POST',)) |
||||
|
@jwt_optional |
||||
|
@use_kwargs(user_schema) |
||||
|
@marshal_with(user_schema) |
||||
|
def login_user(email, password, **kwargs): |
||||
|
user = User.query.filter_by(email=email).first() |
||||
|
if user is not None and user.check_password(password): |
||||
|
user.token = create_access_token(identity=user, fresh=True) |
||||
|
return user |
||||
|
else: |
||||
|
raise InvalidUsage.user_not_found() |
||||
|
|
||||
|
|
||||
|
@blueprint.route('/api/user', methods=('GET',)) |
||||
|
@jwt_required |
||||
|
@marshal_with(user_schema) |
||||
|
def get_user(): |
||||
|
user = current_user |
||||
|
# Not sure about this |
||||
|
user.token = request.headers.environ['HTTP_AUTHORIZATION'].split('Token ')[1] |
||||
|
return current_user |
||||
|
|
||||
|
|
||||
|
@blueprint.route('/api/user', methods=('PUT',)) |
||||
|
@jwt_required |
||||
|
@use_kwargs(user_schema) |
||||
|
@marshal_with(user_schema) |
||||
|
def update_user(**kwargs): |
||||
|
user = current_user |
||||
|
# take in consideration the password |
||||
|
password = kwargs.pop('password', None) |
||||
|
if password: |
||||
|
user.set_password(password) |
||||
|
if 'updated_at' in kwargs: |
||||
|
kwargs['updated_at'] = user.created_at.replace(tzinfo=None) |
||||
|
user.update(**kwargs) |
||||
|
return user |
||||
@ -0,0 +1,11 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
"""Helper utilities and decorators.""" |
||||
|
from conduit.user.models import User # noqa |
||||
|
|
||||
|
|
||||
|
def jwt_identity(payload): |
||||
|
return User.get_by_id(payload) |
||||
|
|
||||
|
|
||||
|
def identity_loader(user): |
||||
|
return user.id |
||||
|
After Width: 1669 | Height: 257 | Size: 48 KiB |
@ -0,0 +1 @@ |
|||||
|
Generic single-database configuration. |
||||
@ -0,0 +1,45 @@ |
|||||
|
# A generic, single database configuration. |
||||
|
|
||||
|
[alembic] |
||||
|
# template used to generate migration files |
||||
|
# file_template = %%(rev)s_%%(slug)s |
||||
|
|
||||
|
# set to 'true' to run the environment during |
||||
|
# the 'revision' command, regardless of autogenerate |
||||
|
# revision_environment = false |
||||
|
|
||||
|
|
||||
|
# Logging configuration |
||||
|
[loggers] |
||||
|
keys = root,sqlalchemy,alembic |
||||
|
|
||||
|
[handlers] |
||||
|
keys = console |
||||
|
|
||||
|
[formatters] |
||||
|
keys = generic |
||||
|
|
||||
|
[logger_root] |
||||
|
level = WARN |
||||
|
handlers = console |
||||
|
qualname = |
||||
|
|
||||
|
[logger_sqlalchemy] |
||||
|
level = WARN |
||||
|
handlers = |
||||
|
qualname = sqlalchemy.engine |
||||
|
|
||||
|
[logger_alembic] |
||||
|
level = INFO |
||||
|
handlers = |
||||
|
qualname = alembic |
||||
|
|
||||
|
[handler_console] |
||||
|
class = StreamHandler |
||||
|
args = (sys.stderr,) |
||||
|
level = NOTSET |
||||
|
formatter = generic |
||||
|
|
||||
|
[formatter_generic] |
||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s |
||||
|
datefmt = %H:%M:%S |
||||
@ -0,0 +1,96 @@ |
|||||
|
from __future__ import with_statement |
||||
|
|
||||
|
import logging |
||||
|
from logging.config import fileConfig |
||||
|
|
||||
|
from sqlalchemy import engine_from_config |
||||
|
from sqlalchemy import pool |
||||
|
|
||||
|
from alembic import context |
||||
|
|
||||
|
# this is the Alembic Config object, which provides |
||||
|
# access to the values within the .ini file in use. |
||||
|
config = context.config |
||||
|
|
||||
|
# Interpret the config file for Python logging. |
||||
|
# This line sets up loggers basically. |
||||
|
fileConfig(config.config_file_name) |
||||
|
logger = logging.getLogger('alembic.env') |
||||
|
|
||||
|
# add your model's MetaData object here |
||||
|
# for 'autogenerate' support |
||||
|
# from myapp import mymodel |
||||
|
# target_metadata = mymodel.Base.metadata |
||||
|
from flask import current_app |
||||
|
config.set_main_option( |
||||
|
'sqlalchemy.url', current_app.config.get( |
||||
|
'SQLALCHEMY_DATABASE_URI').replace('%', '%%')) |
||||
|
target_metadata = current_app.extensions['migrate'].db.metadata |
||||
|
|
||||
|
# other values from the config, defined by the needs of env.py, |
||||
|
# can be acquired: |
||||
|
# my_important_option = config.get_main_option("my_important_option") |
||||
|
# ... etc. |
||||
|
|
||||
|
|
||||
|
def run_migrations_offline(): |
||||
|
"""Run migrations in 'offline' mode. |
||||
|
|
||||
|
This configures the context with just a URL |
||||
|
and not an Engine, though an Engine is acceptable |
||||
|
here as well. By skipping the Engine creation |
||||
|
we don't even need a DBAPI to be available. |
||||
|
|
||||
|
Calls to context.execute() here emit the given string to the |
||||
|
script output. |
||||
|
|
||||
|
""" |
||||
|
url = config.get_main_option("sqlalchemy.url") |
||||
|
context.configure( |
||||
|
url=url, target_metadata=target_metadata, literal_binds=True |
||||
|
) |
||||
|
|
||||
|
with context.begin_transaction(): |
||||
|
context.run_migrations() |
||||
|
|
||||
|
|
||||
|
def run_migrations_online(): |
||||
|
"""Run migrations in 'online' mode. |
||||
|
|
||||
|
In this scenario we need to create an Engine |
||||
|
and associate a connection with the context. |
||||
|
|
||||
|
""" |
||||
|
|
||||
|
# this callback is used to prevent an auto-migration from being generated |
||||
|
# when there are no changes to the schema |
||||
|
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html |
||||
|
def process_revision_directives(context, revision, directives): |
||||
|
if getattr(config.cmd_opts, 'autogenerate', False): |
||||
|
script = directives[0] |
||||
|
if script.upgrade_ops.is_empty(): |
||||
|
directives[:] = [] |
||||
|
logger.info('No changes in schema detected.') |
||||
|
|
||||
|
connectable = engine_from_config( |
||||
|
config.get_section(config.config_ini_section), |
||||
|
prefix='sqlalchemy.', |
||||
|
poolclass=pool.NullPool, |
||||
|
) |
||||
|
|
||||
|
with connectable.connect() as connection: |
||||
|
context.configure( |
||||
|
connection=connection, |
||||
|
target_metadata=target_metadata, |
||||
|
process_revision_directives=process_revision_directives, |
||||
|
**current_app.extensions['migrate'].configure_args |
||||
|
) |
||||
|
|
||||
|
with context.begin_transaction(): |
||||
|
context.run_migrations() |
||||
|
|
||||
|
|
||||
|
if context.is_offline_mode(): |
||||
|
run_migrations_offline() |
||||
|
else: |
||||
|
run_migrations_online() |
||||
@ -0,0 +1,24 @@ |
|||||
|
"""${message} |
||||
|
|
||||
|
Revision ID: ${up_revision} |
||||
|
Revises: ${down_revision | comma,n} |
||||
|
Create Date: ${create_date} |
||||
|
|
||||
|
""" |
||||
|
from alembic import op |
||||
|
import sqlalchemy as sa |
||||
|
${imports if imports else ""} |
||||
|
|
||||
|
# revision identifiers, used by Alembic. |
||||
|
revision = ${repr(up_revision)} |
||||
|
down_revision = ${repr(down_revision)} |
||||
|
branch_labels = ${repr(branch_labels)} |
||||
|
depends_on = ${repr(depends_on)} |
||||
|
|
||||
|
|
||||
|
def upgrade(): |
||||
|
${upgrades if upgrades else "pass"} |
||||
|
|
||||
|
|
||||
|
def downgrade(): |
||||
|
${downgrades if downgrades else "pass"} |
||||
@ -0,0 +1,101 @@ |
|||||
|
"""empty message |
||||
|
|
||||
|
Revision ID: 2267f00a4594 |
||||
|
Revises: |
||||
|
Create Date: 2020-01-04 15:20:33.461410 |
||||
|
|
||||
|
""" |
||||
|
from alembic import op |
||||
|
import sqlalchemy as sa |
||||
|
|
||||
|
|
||||
|
# revision identifiers, used by Alembic. |
||||
|
revision = '2267f00a4594' |
||||
|
down_revision = None |
||||
|
branch_labels = None |
||||
|
depends_on = None |
||||
|
|
||||
|
|
||||
|
def upgrade(): |
||||
|
# ### commands auto generated by Alembic - please adjust! ### |
||||
|
op.create_table('tags', |
||||
|
sa.Column('id', sa.Integer(), nullable=False), |
||||
|
sa.Column('tagname', sa.String(length=100), nullable=True), |
||||
|
sa.PrimaryKeyConstraint('id') |
||||
|
) |
||||
|
op.create_table('users', |
||||
|
sa.Column('id', sa.Integer(), nullable=False), |
||||
|
sa.Column('username', sa.String(length=80), nullable=False), |
||||
|
sa.Column('email', sa.String(length=100), nullable=False), |
||||
|
sa.Column('password', sa.Binary(), nullable=True), |
||||
|
sa.Column('created_at', sa.DateTime(), nullable=False), |
||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False), |
||||
|
sa.Column('bio', sa.String(length=300), nullable=True), |
||||
|
sa.Column('image', sa.String(length=120), nullable=True), |
||||
|
sa.PrimaryKeyConstraint('id'), |
||||
|
sa.UniqueConstraint('email'), |
||||
|
sa.UniqueConstraint('username') |
||||
|
) |
||||
|
op.create_table('userprofile', |
||||
|
sa.Column('id', sa.Integer(), nullable=False), |
||||
|
sa.Column('user_id', sa.Integer(), nullable=False), |
||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), |
||||
|
sa.PrimaryKeyConstraint('id'), |
||||
|
sa.UniqueConstraint('user_id') |
||||
|
) |
||||
|
op.create_table('article', |
||||
|
sa.Column('id', sa.Integer(), nullable=False), |
||||
|
sa.Column('slug', sa.Text(), nullable=True), |
||||
|
sa.Column('title', sa.String(length=100), nullable=False), |
||||
|
sa.Column('description', sa.Text(), nullable=False), |
||||
|
sa.Column('body', sa.Text(), nullable=True), |
||||
|
sa.Column('createdAt', sa.DateTime(), nullable=False), |
||||
|
sa.Column('updatedAt', sa.DateTime(), nullable=False), |
||||
|
sa.Column('author_id', sa.Integer(), nullable=False), |
||||
|
sa.ForeignKeyConstraint(['author_id'], ['userprofile.id'], ), |
||||
|
sa.PrimaryKeyConstraint('id'), |
||||
|
sa.UniqueConstraint('slug') |
||||
|
) |
||||
|
op.create_table('followers_assoc', |
||||
|
sa.Column('follower', sa.Integer(), nullable=True), |
||||
|
sa.Column('followed_by', sa.Integer(), nullable=True), |
||||
|
sa.ForeignKeyConstraint(['followed_by'], ['userprofile.user_id'], ), |
||||
|
sa.ForeignKeyConstraint(['follower'], ['userprofile.user_id'], ) |
||||
|
) |
||||
|
op.create_table('comment', |
||||
|
sa.Column('id', sa.Integer(), nullable=False), |
||||
|
sa.Column('body', sa.Text(), nullable=True), |
||||
|
sa.Column('createdAt', sa.DateTime(), nullable=False), |
||||
|
sa.Column('updatedAt', sa.DateTime(), nullable=False), |
||||
|
sa.Column('author_id', sa.Integer(), nullable=False), |
||||
|
sa.Column('article_id', sa.Integer(), nullable=False), |
||||
|
sa.ForeignKeyConstraint(['article_id'], ['article.id'], ), |
||||
|
sa.ForeignKeyConstraint(['author_id'], ['userprofile.id'], ), |
||||
|
sa.PrimaryKeyConstraint('id') |
||||
|
) |
||||
|
op.create_table('favoritor_assoc', |
||||
|
sa.Column('favoriter', sa.Integer(), nullable=True), |
||||
|
sa.Column('favorited_article', sa.Integer(), nullable=True), |
||||
|
sa.ForeignKeyConstraint(['favorited_article'], ['article.id'], ), |
||||
|
sa.ForeignKeyConstraint(['favoriter'], ['userprofile.id'], ) |
||||
|
) |
||||
|
op.create_table('tag_assoc', |
||||
|
sa.Column('tag', sa.Integer(), nullable=True), |
||||
|
sa.Column('article', sa.Integer(), nullable=True), |
||||
|
sa.ForeignKeyConstraint(['article'], ['article.id'], ), |
||||
|
sa.ForeignKeyConstraint(['tag'], ['tags.id'], ) |
||||
|
) |
||||
|
# ### end Alembic commands ### |
||||
|
|
||||
|
|
||||
|
def downgrade(): |
||||
|
# ### commands auto generated by Alembic - please adjust! ### |
||||
|
op.drop_table('tag_assoc') |
||||
|
op.drop_table('favoritor_assoc') |
||||
|
op.drop_table('comment') |
||||
|
op.drop_table('followers_assoc') |
||||
|
op.drop_table('article') |
||||
|
op.drop_table('userprofile') |
||||
|
op.drop_table('users') |
||||
|
op.drop_table('tags') |
||||
|
# ### end Alembic commands ### |
||||
@ -0,0 +1,3 @@ |
|||||
|
# Included because many Paas's require a requirements.txt file in the project root |
||||
|
# Just installs the production requirements. |
||||
|
-r requirements/prod.txt |
||||
@ -0,0 +1,9 @@ |
|||||
|
# Everything the developer needs in addition to the production requirements |
||||
|
-r prod.txt |
||||
|
|
||||
|
# Testing |
||||
|
pytest |
||||
|
WebTest |
||||
|
factory-boy |
||||
|
# For python 3 |
||||
|
Faker |
||||
@ -0,0 +1,18 @@ |
|||||
|
# Everything needed in production |
||||
|
|
||||
|
Werkzeug |
||||
|
SQLAlchemy==1.1.9 |
||||
|
Flask_Caching |
||||
|
Flask_SQLAlchemy==2.2 |
||||
|
click |
||||
|
marshmallow |
||||
|
Flask_Bcrypt |
||||
|
flask_apispec |
||||
|
Flask |
||||
|
PyJWT |
||||
|
Flask-JWT-Extended |
||||
|
unicode_slugify |
||||
|
psycopg2 |
||||
|
Flask-Migrate |
||||
|
gunicorn |
||||
|
Flask-Cors |
||||
@ -0,0 +1,2 @@ |
|||||
|
[flake8] |
||||
|
max-line-length=120 |
||||
@ -0,0 +1 @@ |
|||||
|
"""Tests for the app.""" |
||||
@ -0,0 +1,61 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
"""Defines fixtures available to all tests.""" |
||||
|
|
||||
|
import pytest |
||||
|
from webtest import TestApp |
||||
|
|
||||
|
from conduit.app import create_app |
||||
|
from conduit.database import db as _db |
||||
|
from conduit.settings import TestConfig |
||||
|
from conduit.profile.models import UserProfile |
||||
|
|
||||
|
|
||||
|
from .factories import UserFactory |
||||
|
|
||||
|
|
||||
|
@pytest.yield_fixture(scope='function') |
||||
|
def app(): |
||||
|
"""An application for the tests.""" |
||||
|
_app = create_app(TestConfig) |
||||
|
|
||||
|
with _app.app_context(): |
||||
|
_db.create_all() |
||||
|
|
||||
|
ctx = _app.test_request_context() |
||||
|
ctx.push() |
||||
|
|
||||
|
yield _app |
||||
|
|
||||
|
ctx.pop() |
||||
|
|
||||
|
|
||||
|
@pytest.fixture(scope='function') |
||||
|
def testapp(app): |
||||
|
"""A Webtest app.""" |
||||
|
return TestApp(app) |
||||
|
|
||||
|
|
||||
|
@pytest.yield_fixture(scope='function') |
||||
|
def db(app): |
||||
|
"""A database for the tests.""" |
||||
|
_db.app = app |
||||
|
with app.app_context(): |
||||
|
_db.create_all() |
||||
|
|
||||
|
yield _db |
||||
|
|
||||
|
# Explicitly close DB connection |
||||
|
_db.session.close() |
||||
|
_db.drop_all() |
||||
|
|
||||
|
|
||||
|
@pytest.fixture |
||||
|
def user(db): |
||||
|
"""A user for the tests.""" |
||||
|
class User(): |
||||
|
def get(self): |
||||
|
muser = UserFactory(password='myprecious') |
||||
|
UserProfile(muser).save() |
||||
|
db.session.commit() |
||||
|
return muser |
||||
|
return User() |
||||
@ -0,0 +1,30 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
"""Factories to help in tests.""" |
||||
|
from factory import PostGenerationMethodCall, Sequence |
||||
|
from factory.alchemy import SQLAlchemyModelFactory |
||||
|
|
||||
|
from conduit.database import db |
||||
|
from conduit.user.models import User |
||||
|
|
||||
|
|
||||
|
class BaseFactory(SQLAlchemyModelFactory): |
||||
|
"""Base factory.""" |
||||
|
|
||||
|
class Meta: |
||||
|
"""Factory configuration.""" |
||||
|
|
||||
|
abstract = True |
||||
|
sqlalchemy_session = db.session |
||||
|
|
||||
|
|
||||
|
class UserFactory(BaseFactory): |
||||
|
"""User factory.""" |
||||
|
|
||||
|
username = Sequence(lambda n: 'user{0}'.format(n)) |
||||
|
email = Sequence(lambda n: 'user{0}@example.com'.format(n)) |
||||
|
password = PostGenerationMethodCall('set_password', 'example') |
||||
|
|
||||
|
class Meta: |
||||
|
"""Factory configuration.""" |
||||
|
|
||||
|
model = User |
||||
@ -0,0 +1,136 @@ |
|||||
|
# coding: utf-8 |
||||
|
|
||||
|
from flask import url_for |
||||
|
from datetime import datetime |
||||
|
|
||||
|
class TestArticleViews: |
||||
|
|
||||
|
def test_get_articles_by_author(self, testapp, user): |
||||
|
user = user.get() |
||||
|
resp = testapp.post_json(url_for('user.login_user'), {'user': { |
||||
|
'email': user.email, |
||||
|
'password': 'myprecious' |
||||
|
}}) |
||||
|
|
||||
|
token = str(resp.json['user']['token']) |
||||
|
for _ in range(2): |
||||
|
testapp.post_json(url_for('articles.make_article'), { |
||||
|
"article": { |
||||
|
"title": "How to train your dragon {}".format(_), |
||||
|
"description": "Ever wonder how?", |
||||
|
"body": "You have to believe", |
||||
|
"tagList": ["reactjs", "angularjs", "dragons"] |
||||
|
} |
||||
|
}, headers={ |
||||
|
'Authorization': 'Token {}'.format(token) |
||||
|
}) |
||||
|
|
||||
|
resp = testapp.get(url_for('articles.get_articles', author=user.username)) |
||||
|
assert len(resp.json['articles']) == 2 |
||||
|
|
||||
|
def test_favorite_an_article(self, testapp, user): |
||||
|
user = user.get() |
||||
|
resp = testapp.post_json(url_for('user.login_user'), {'user': { |
||||
|
'email': user.email, |
||||
|
'password': 'myprecious' |
||||
|
}}) |
||||
|
|
||||
|
token = str(resp.json['user']['token']) |
||||
|
resp1 = testapp.post_json(url_for('articles.make_article'), { |
||||
|
"article": { |
||||
|
"title": "How to train your dragon", |
||||
|
"description": "Ever wonder how?", |
||||
|
"body": "You have to believe", |
||||
|
"tagList": ["reactjs", "angularjs", "dragons"] |
||||
|
} |
||||
|
}, headers={ |
||||
|
'Authorization': 'Token {}'.format(token) |
||||
|
}) |
||||
|
|
||||
|
resp = testapp.post(url_for('articles.favorite_an_article', |
||||
|
slug=resp1.json['article']['slug']), |
||||
|
headers={ |
||||
|
'Authorization': 'Token {}'.format(token) |
||||
|
} |
||||
|
) |
||||
|
assert resp.json['article']['favorited'] |
||||
|
|
||||
|
def test_get_articles_by_favoriter(self, testapp, user): |
||||
|
user = user.get() |
||||
|
resp = testapp.post_json(url_for('user.login_user'), {'user': { |
||||
|
'email': user.email, |
||||
|
'password': 'myprecious' |
||||
|
}}) |
||||
|
|
||||
|
token = str(resp.json['user']['token']) |
||||
|
for _ in range(2): |
||||
|
testapp.post_json(url_for('articles.make_article'), { |
||||
|
"article": { |
||||
|
"title": "How to train your dragon {}".format(_), |
||||
|
"description": "Ever wonder how?", |
||||
|
"body": "You have to believe", |
||||
|
"tagList": ["reactjs", "angularjs", "dragons"] |
||||
|
} |
||||
|
}, headers={ |
||||
|
'Authorization': 'Token {}'.format(token) |
||||
|
}) |
||||
|
|
||||
|
resp = testapp.get(url_for('articles.get_articles', author=user.username)) |
||||
|
assert len(resp.json['articles']) == 2 |
||||
|
|
||||
|
def test_make_article(self, testapp, user): |
||||
|
user = user.get() |
||||
|
resp = testapp.post_json(url_for('user.login_user'), {'user': { |
||||
|
'email': user.email, |
||||
|
'password': 'myprecious' |
||||
|
}}) |
||||
|
|
||||
|
token = str(resp.json['user']['token']) |
||||
|
resp = testapp.post_json(url_for('articles.make_article'), { |
||||
|
"article": { |
||||
|
"title": "How to train your dragon", |
||||
|
"description": "Ever wonder how?", |
||||
|
"body": "You have to believe", |
||||
|
"tagList": ["reactjs", "angularjs", "dragons"] |
||||
|
} |
||||
|
}, headers={ |
||||
|
'Authorization': 'Token {}'.format(token) |
||||
|
}) |
||||
|
assert resp.json['article']['author']['email'] == user.email |
||||
|
assert resp.json['article']['body'] == 'You have to believe' |
||||
|
|
||||
|
def test_make_comment_correct_schema(self, testapp, user): |
||||
|
from conduit.profile.serializers import profile_schema |
||||
|
user = user.get() |
||||
|
resp = testapp.post_json(url_for('user.login_user'), {'user': { |
||||
|
'email': user.email, |
||||
|
'password': 'myprecious' |
||||
|
}}) |
||||
|
|
||||
|
token = str(resp.json['user']['token']) |
||||
|
resp = testapp.post_json(url_for('articles.make_article'), { |
||||
|
"article": { |
||||
|
"title": "How to train your dragon", |
||||
|
"description": "Ever wonder how?", |
||||
|
"body": "You have to believe", |
||||
|
"tagList": ["reactjs", "angularjs", "dragons"] |
||||
|
} |
||||
|
}, headers={ |
||||
|
'Authorization': 'Token {}'.format(token) |
||||
|
}) |
||||
|
slug = resp.json['article']['slug'] |
||||
|
# make a comment |
||||
|
resp = testapp.post_json(url_for('articles.make_comment_on_article', slug=slug), { |
||||
|
"comment": { |
||||
|
"createdAt": datetime.now().isoformat(), |
||||
|
"body": "You have to believe", |
||||
|
} |
||||
|
}, headers={ |
||||
|
'Authorization': 'Token {}'.format(token) |
||||
|
}) |
||||
|
|
||||
|
# check |
||||
|
authorp = resp.json['comment']['author'] |
||||
|
del authorp['following'] |
||||
|
# assert profile_schema.dump(user).data['profile'] == authorp |
||||
|
assert profile_schema.dump(user)['profile'] == authorp |
||||
@ -0,0 +1,65 @@ |
|||||
|
# coding: utf-8 |
||||
|
|
||||
|
from flask import url_for |
||||
|
from conduit.exceptions import USER_ALREADY_REGISTERED |
||||
|
|
||||
|
|
||||
|
def _register_user(testapp, name, **kwargs): |
||||
|
return testapp.post_json(url_for("user.register_user"), { |
||||
|
"user": { |
||||
|
"username": "mo" + name, |
||||
|
"email": "mo" + name + "@mo.mo", |
||||
|
"password": "momo" |
||||
|
} |
||||
|
}, **kwargs) |
||||
|
|
||||
|
|
||||
|
class TestAuthenticate: |
||||
|
|
||||
|
def test_register_user(self, testapp): |
||||
|
resp = _register_user(testapp, 'register_user') |
||||
|
assert resp.json['user']['email'] == 'moregister_user@mo.mo' |
||||
|
assert resp.json['user']['token'] != 'None' |
||||
|
assert resp.json['user']['token'] != '' |
||||
|
|
||||
|
def test_user_login(self, testapp): |
||||
|
_register_user(testapp, 'login') |
||||
|
|
||||
|
resp = testapp.post_json(url_for('user.login_user'), {'user': { |
||||
|
'email': 'mologin@mo.mo', |
||||
|
'password': 'momo' |
||||
|
}}) |
||||
|
|
||||
|
assert resp.json['user']['email'] == 'mologin@mo.mo' |
||||
|
assert resp.json['user']['token'] != 'None' |
||||
|
assert resp.json['user']['token'] != '' |
||||
|
|
||||
|
def test_get_user(self, testapp): |
||||
|
resp = _register_user(testapp, 'getUser') |
||||
|
token = str(resp.json['user']['token']) |
||||
|
resp = testapp.get(url_for('user.get_user'), headers={ |
||||
|
'Authorization': 'Token {}'.format(token) |
||||
|
}) |
||||
|
assert resp.json['user']['email'] == 'mogetUser@mo.mo' |
||||
|
assert resp.json['user']['token'] == token |
||||
|
|
||||
|
def test_register_already_registered_user(self, testapp): |
||||
|
_register_user(testapp, 'already_register') |
||||
|
resp = _register_user(testapp, 'already_register', expect_errors=True) |
||||
|
assert resp.status_int == 422 |
||||
|
assert resp.json == USER_ALREADY_REGISTERED['message'] |
||||
|
|
||||
|
def test_update_user(self, testapp): |
||||
|
resp = _register_user(testapp, 'update') |
||||
|
token = str(resp.json['user']['token']) |
||||
|
resp = testapp.put_json(url_for('user.update_user'), { |
||||
|
'user': { |
||||
|
'email': 'meh@mo.mo', |
||||
|
'bio': 'I\'m a simple man', |
||||
|
'password': 'hmm' |
||||
|
} |
||||
|
}, headers={ |
||||
|
'Authorization': 'Token {}'.format(token) |
||||
|
}) |
||||
|
assert resp.json['user']['bio'] == 'I\'m a simple man' |
||||
|
assert resp.json['user']['email'] == 'meh@mo.mo' |
||||
@ -0,0 +1,18 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
"""Test configs.""" |
||||
|
from conduit.app import create_app |
||||
|
from conduit.settings import DevConfig, ProdConfig |
||||
|
|
||||
|
|
||||
|
def test_production_config(): |
||||
|
"""Production config.""" |
||||
|
app = create_app(ProdConfig) |
||||
|
assert app.config['ENV'] == 'prod' |
||||
|
assert not app.config['DEBUG'] |
||||
|
|
||||
|
|
||||
|
def test_dev_config(): |
||||
|
"""Development config.""" |
||||
|
app = create_app(DevConfig) |
||||
|
assert app.config['ENV'] == 'dev' |
||||
|
assert app.config['DEBUG'] |
||||
@ -0,0 +1,181 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
"""Model unit tests.""" |
||||
|
import datetime as dt |
||||
|
|
||||
|
import pytest |
||||
|
|
||||
|
from conduit.user.models import User |
||||
|
from conduit.profile.models import UserProfile |
||||
|
from conduit.articles.models import Article, Tags, Comment |
||||
|
|
||||
|
|
||||
|
from .factories import UserFactory |
||||
|
|
||||
|
|
||||
|
@pytest.mark.usefixtures('db') |
||||
|
class TestUser: |
||||
|
"""User tests.""" |
||||
|
|
||||
|
def test_get_by_id(self): |
||||
|
"""Get user by ID.""" |
||||
|
user = User('foo', 'foo@bar.com') |
||||
|
user.save() |
||||
|
|
||||
|
retrieved = User.get_by_id(user.id) |
||||
|
assert retrieved == user |
||||
|
|
||||
|
def test_created_at_defaults_to_datetime(self): |
||||
|
"""Test creation date.""" |
||||
|
user = User(username='foo', email='foo@bar.com') |
||||
|
user.save() |
||||
|
assert bool(user.created_at) |
||||
|
assert isinstance(user.created_at, dt.datetime) |
||||
|
|
||||
|
def test_password_is_nullable(self): |
||||
|
"""Test null password.""" |
||||
|
user = User(username='foo', email='foo@bar.com') |
||||
|
user.save() |
||||
|
assert user.password is None |
||||
|
|
||||
|
def test_factory(self, db): |
||||
|
"""Test user factory.""" |
||||
|
user = UserFactory(password='myprecious') |
||||
|
db.session.commit() |
||||
|
assert bool(user.username) |
||||
|
assert bool(user.email) |
||||
|
assert bool(user.created_at) |
||||
|
assert user.check_password('myprecious') |
||||
|
|
||||
|
def test_check_password(self): |
||||
|
"""Check password.""" |
||||
|
user = User.create(username='foo', email='foo@bar.com', |
||||
|
password='foobarbaz123') |
||||
|
assert user.check_password('foobarbaz123') |
||||
|
assert not user.check_password('barfoobaz') |
||||
|
|
||||
|
|
||||
|
@pytest.mark.usefixtures('db') |
||||
|
class TestProfile: |
||||
|
|
||||
|
def test_follow_user(self): |
||||
|
u1 = User('foo', 'foo@bar.com') |
||||
|
u1.save() |
||||
|
u2 = User('foo1', 'foo1@bar.com') |
||||
|
u2.save() |
||||
|
p1 = UserProfile(u1) |
||||
|
p2 = UserProfile(u2) |
||||
|
p1.save() |
||||
|
p2.save() |
||||
|
p1.follow(p2) |
||||
|
assert p1.is_following(p2) |
||||
|
|
||||
|
def test_unfollow_user(self): |
||||
|
u1 = User('foo', 'foo@bar.com') |
||||
|
u1.save() |
||||
|
u2 = User('foo1', 'foo1@bar.com') |
||||
|
u2.save() |
||||
|
p1 = UserProfile(u1) |
||||
|
p2 = UserProfile(u2) |
||||
|
p1.save() |
||||
|
p2.save() |
||||
|
p1.follow(p2) |
||||
|
assert p1.is_following(p2) |
||||
|
p1.unfollow(p2) |
||||
|
assert not p1.is_following(p2) |
||||
|
|
||||
|
def test_follow_self(self): |
||||
|
u1 = User('foo', 'foo@bar.com') |
||||
|
u1.save() |
||||
|
p1 = UserProfile(u1) |
||||
|
p1.save() |
||||
|
assert not p1.follow(p1) |
||||
|
|
||||
|
def test_unfollow_self(self): |
||||
|
u1 = User('foo', 'foo@bar.com') |
||||
|
u1.save() |
||||
|
p1 = UserProfile(u1) |
||||
|
assert not p1.unfollow(p1) |
||||
|
|
||||
|
|
||||
|
@pytest.mark.usefixtures('db') |
||||
|
class TestArticles: |
||||
|
def test_create_article(self, user): |
||||
|
u1 = user.get() |
||||
|
article = Article(u1.profile, 'title', 'some body', description='some') |
||||
|
article.save() |
||||
|
assert article.author.user == u1 |
||||
|
|
||||
|
def test_favorite_an_article(self): |
||||
|
u1 = User('foo', 'foo@bar.com') |
||||
|
u1.save() |
||||
|
p1 = UserProfile(u1) |
||||
|
p1.save() |
||||
|
article = Article(p1, 'title', 'some body', description='some') |
||||
|
article.save() |
||||
|
assert article.favourite(u1.profile) |
||||
|
assert article.is_favourite(u1.profile) |
||||
|
|
||||
|
def test_unfavorite_an_article(self): |
||||
|
u1 = User('foo', 'foo@bar.com') |
||||
|
u1.save() |
||||
|
p1 = UserProfile(u1) |
||||
|
p1.save() |
||||
|
|
||||
|
u2 = User('foo1', 'fo1o@bar.com') |
||||
|
u2.save() |
||||
|
p2 = UserProfile(u2) |
||||
|
p2.save() |
||||
|
|
||||
|
article = Article(p1, 'title', 'some body', description='some') |
||||
|
article.save() |
||||
|
assert article.favourite(p1) |
||||
|
assert article.unfavourite(p1) |
||||
|
assert not article.is_favourite(p1) |
||||
|
|
||||
|
def test_add_tag(self, user): |
||||
|
user = user.get() |
||||
|
article = Article(user.profile, 'title', 'some body', description='some') |
||||
|
article.save() |
||||
|
t = Tags(tagname='python') |
||||
|
t1 = Tags(tagname='flask') |
||||
|
assert article.add_tag(t) |
||||
|
assert article.add_tag(t1) |
||||
|
assert len(article.tagList) == 2 |
||||
|
|
||||
|
def test_remove_tag(self, user): |
||||
|
user = user.get() |
||||
|
article = Article(user.profile, 'title', 'some body', description='some') |
||||
|
article.save() |
||||
|
t1 = Tags(tagname='flask') |
||||
|
assert article.add_tag(t1) |
||||
|
assert article.remove_tag(t1) |
||||
|
assert len(article.tagList) == 0 |
||||
|
|
||||
|
|
||||
|
@pytest.mark.usefixtures('db') |
||||
|
class TestComment: |
||||
|
|
||||
|
def test_make_comment(self, user): |
||||
|
user = user.get() |
||||
|
article = Article(user.profile, 'title', 'some body', description='some') |
||||
|
article.save() |
||||
|
comment = Comment(article, user.profile, 'some body') |
||||
|
comment.save() |
||||
|
|
||||
|
assert comment.article == article |
||||
|
assert comment.author == user.profile |
||||
|
|
||||
|
def test_make_comments(self, user): |
||||
|
user = user.get() |
||||
|
article = Article(user.profile, 'title', 'some body', description='some') |
||||
|
article.save() |
||||
|
comment = Comment(article, user.profile, 'some body') |
||||
|
comment1 = Comment(article, user.profile, 'some body2') |
||||
|
comment.save() |
||||
|
comment1.save() |
||||
|
|
||||
|
assert comment.article == article |
||||
|
assert comment.author == user.profile |
||||
|
assert comment1.article == article |
||||
|
assert comment1.author == user.profile |
||||
|
assert len(article.comments.all()) == 2 |
||||
@ -0,0 +1,45 @@ |
|||||
|
# coding: utf-8 |
||||
|
|
||||
|
from flask import url_for |
||||
|
from conduit.exceptions import USER_NOT_FOUND |
||||
|
|
||||
|
|
||||
|
def _register_user(testapp, name, **kwargs): |
||||
|
return testapp.post_json(url_for('user.register_user'), { |
||||
|
"user": { |
||||
|
"email": 'foo' + name + '@bar.com', |
||||
|
"username": name + 'foobar', |
||||
|
"password": 'myprecious' |
||||
|
}}, **kwargs) |
||||
|
|
||||
|
|
||||
|
class TestProfile: |
||||
|
|
||||
|
def test_get_profile_not_loggedin(self, testapp): |
||||
|
_register_user(testapp, 'not_loggedin') |
||||
|
resp = testapp.get(url_for('profiles.get_profile', username='not_loggedinfoobar')) |
||||
|
assert resp.json['profile']['email'] == 'foonot_loggedin@bar.com' |
||||
|
assert not resp.json['profile']['following'] |
||||
|
|
||||
|
def test_get_profile_not_existing(self, testapp): |
||||
|
resp = testapp.get(url_for('profiles.get_profile', username='unknownfoobar'), expect_errors=True) |
||||
|
assert resp.status_int == 404 |
||||
|
assert resp.json == USER_NOT_FOUND['message'] |
||||
|
|
||||
|
def test_follow_user(self, testapp, user): |
||||
|
user = user.get() |
||||
|
resp = _register_user(testapp, 'folow_user') |
||||
|
token = str(resp.json['user']['token']) |
||||
|
resp = testapp.post(url_for('profiles.follow_user', username=user.username), headers={ |
||||
|
'Authorization': 'Token {}'.format(token) |
||||
|
}) |
||||
|
assert resp.json['profile']['following'] |
||||
|
|
||||
|
def test_unfollow_user(self, testapp, user): |
||||
|
user = user.get() |
||||
|
resp = _register_user(testapp, 'unfolow') |
||||
|
token = str(resp.json['user']['token']) |
||||
|
resp = testapp.delete(url_for('profiles.unfollow_user', username=user.username), headers={ |
||||
|
'Authorization': 'Token {}'.format(token) |
||||
|
}) |
||||
|
assert not resp.json['profile']['following'] |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue