Browse Source

Initial commit

circleci-project-setup
Etiam Ayedze 6 years ago
commit
ba06e99f17
  1. 57
      .gitignore
  2. 7
      LICENSE
  3. 33
      Pipfile
  4. 467
      Pipfile.lock
  5. 1
      Procfile
  6. 64
      Readme.md
  7. 103
      Vagrantfile
  8. 10
      autoapp.py
  9. 1
      conduit/__init__.py
  10. 80
      conduit/app.py
  11. 3
      conduit/articles/__init__.py
  12. 112
      conduit/articles/models.py
  13. 88
      conduit/articles/serializers.py
  14. 168
      conduit/articles/views.py
  15. 123
      conduit/commands.py
  16. 18
      conduit/compat.py
  17. 44
      conduit/database.py
  18. 47
      conduit/exceptions.py
  19. 50
      conduit/extensions.py
  20. 1
      conduit/profile/__init__.py
  21. 65
      conduit/profile/models.py
  22. 27
      conduit/profile/serializers.py
  23. 45
      conduit/profile/views.py
  24. 60
      conduit/settings.py
  25. 3
      conduit/user/__init__.py
  26. 39
      conduit/user/models.py
  27. 38
      conduit/user/serializers.py
  28. 66
      conduit/user/views.py
  29. 11
      conduit/utils.py
  30. BIN
      image.png
  31. 1
      migrations/README
  32. 45
      migrations/alembic.ini
  33. 96
      migrations/env.py
  34. 24
      migrations/script.py.mako
  35. 101
      migrations/versions/2267f00a4594_.py
  36. 3
      requirements.txt
  37. 9
      requirements/dev.txt
  38. 18
      requirements/prod.txt
  39. 2
      setup.cfg
  40. 1
      tests/__init__.py
  41. 61
      tests/conftest.py
  42. 30
      tests/factories.py
  43. 136
      tests/test_articles.py
  44. 65
      tests/test_authentication.py
  45. 18
      tests/test_config.py
  46. 181
      tests/test_models.py
  47. 45
      tests/test_profile.py

57
.gitignore

@ -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/**

7
LICENSE

@ -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.

33
Pipfile

@ -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"

467
Pipfile.lock

@ -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"
}
}
}

1
Procfile

@ -0,0 +1 @@
web: gunicorn autoapp:app -b 0.0.0.0:$PORT -w 3

64
Readme.md

@ -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`

103
Vagrantfile

@ -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

10
autoapp.py

@ -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)

1
conduit/__init__.py

@ -0,0 +1 @@
"""Main application package."""

80
conduit/app.py

@ -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)

3
conduit/articles/__init__.py

@ -0,0 +1,3 @@
# coding: utf-8
from . import views # noqa

112
conduit/articles/models.py

@ -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

88
conduit/articles/serializers.py

@ -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)

168
conduit/articles/views.py

@ -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

123
conduit/commands.py

@ -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]))

18
conduit/compat.py

@ -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)

44
conduit/database.py

@ -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)

47
conduit/exceptions.py

@ -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)

50
conduit/extensions.py

@ -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)

1
conduit/profile/__init__.py

@ -0,0 +1 @@
from . import views # noqa

65
conduit/profile/models.py

@ -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

27
conduit/profile/serializers.py

@ -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)

45
conduit/profile/views.py

@ -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

60
conduit/settings.py

@ -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

3
conduit/user/__init__.py

@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
"""The user module."""
from . import views # noqa

39
conduit/user/models.py

@ -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)

38
conduit/user/serializers.py

@ -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)

66
conduit/user/views.py

@ -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

11
conduit/utils.py

@ -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

BIN
image.png

After

Width: 1669  |  Height: 257  |  Size: 48 KiB

1
migrations/README

@ -0,0 +1 @@
Generic single-database configuration.

45
migrations/alembic.ini

@ -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

96
migrations/env.py

@ -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()

24
migrations/script.py.mako

@ -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"}

101
migrations/versions/2267f00a4594_.py

@ -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 ###

3
requirements.txt

@ -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

9
requirements/dev.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

18
requirements/prod.txt

@ -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

2
setup.cfg

@ -0,0 +1,2 @@
[flake8]
max-line-length=120

1
tests/__init__.py

@ -0,0 +1 @@
"""Tests for the app."""

61
tests/conftest.py

@ -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()

30
tests/factories.py

@ -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

136
tests/test_articles.py

@ -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

65
tests/test_authentication.py

@ -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'

18
tests/test_config.py

@ -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']

181
tests/test_models.py

@ -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

45
tests/test_profile.py

@ -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']
Loading…
Cancel
Save