Browse Source

Merge branch 'development' into 'master'

Development

See merge request warricksothr/Atheneum!1
merge-requests/8/head
Drew Short 6 years ago
parent
commit
bb27b946c2
  1. 14
      .gitignore
  2. 3
      server/.dockerignore
  3. 3
      server/Pipfile
  4. 168
      server/Pipfile.lock
  5. 6
      server/README.md
  6. 4
      server/atheneum/__init__.py
  7. 6
      server/atheneum/api/authentication_api.py
  8. 25
      server/atheneum/api/model.py
  9. 36
      server/atheneum/api/user_api.py
  10. 11
      server/atheneum/errors.py
  11. 26
      server/atheneum/service/authentication_service.py
  12. 10
      server/atheneum/service/patch_service.py
  13. 11
      server/atheneum/service/user_service.py
  14. 4
      server/atheneum/utility/json_utility.py
  15. 20
      server/documentation/Makefile
  16. 98
      server/documentation/api/authentication.rst
  17. 9
      server/documentation/api/index.rst
  18. 158
      server/documentation/api/user.rst
  19. 156
      server/documentation/conf.py
  20. 20
      server/documentation/index.rst
  21. 4
      server/documentation/introduction.rst
  22. 36
      server/documentation/make.bat
  23. 2
      server/manage.py
  24. 1
      server/run_tests.sh
  25. 2
      server/tests/api/test_authentication_api.py
  26. 44
      server/tests/api/test_user_api.py
  27. 2
      server/tests/conftest.py
  28. 9
      server/tests/service/test_patch_service.py

14
.gitignore

@ -1,8 +1,10 @@
instance/
.idea
*.iml
.admin_credentials
*__pycache__/
.pytest_cache/
.coverage
.mypy_cache/
# Atheneum Specific Ignores
/server/.admin_credentials
/server/.coverage
/server/.mypy_cache/
/server/.pytest_cache/
/server/documentation/_build/
/server/instance/

3
server/.dockerignore

@ -7,5 +7,6 @@ setup.py
test_settings.py
.mypy_cache/
.pytest_cache/
documentation/
instance/
tests/
tests/

3
server/Pipfile

@ -21,6 +21,9 @@ mypy = ">=0.620,<1.0"
mock = ">=2.0,<2.1"
pylint = ">=2.0,<2.1"
pydocstyle = ">=2.1,<2.2"
sphinx = ">=1.7,<1.8"
sphinx-rtd-theme = ">=0.4,<0.5"
sphinxcontrib-httpdomain = ">=1.7,<1.8"
[requires]
python_version = "3.6"

168
server/Pipfile.lock

@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "3ce19ff3ec099304cd32e4cb6868cbc317ec05da27c6020ebc5f35262f2d9613"
"sha256": "2d44286a15539f26e69e12737247e8f490fa6c5e1f35a28f5c322cbd525d5eda"
},
"pipfile-spec": 6,
"requires": {
@ -212,6 +212,13 @@
}
},
"develop": {
"alabaster": {
"hashes": [
"sha256:674bb3bab080f598371f4443c5008cbfeb1a5e622dd312395d2d82af2c54c456",
"sha256:b63b1f4dc77c074d386752ec4a8a7517600f6c0db8cd42980cae17ab7b3275d7"
],
"version": "==0.7.11"
},
"astroid": {
"hashes": [
"sha256:0a0c484279a5f08c9bcedd6fa9b42e378866a7dcc695206b92d59dc9f2d9760d",
@ -233,12 +240,36 @@
],
"version": "==18.1.0"
},
"babel": {
"hashes": [
"sha256:6778d85147d5d85345c14a26aada5e478ab04e39b078b0745ee6870c2b5cf669",
"sha256:8cba50f48c529ca3fa18cf81fa9403be176d374ac4d60738b839122dfaaa3d23"
],
"version": "==2.6.0"
},
"certifi": {
"hashes": [
"sha256:13e698f54293db9f89122b0581843a782ad0934a4fe0172d2a980ba77fc61bb7",
"sha256:9fa520c1bacfb634fa7af20a76bcbd3d5fb390481724c597da32c719a7dca4b0"
],
"version": "==2018.4.16"
},
"chardet": {
"hashes": [
"sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
"sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
],
"version": "==3.0.4"
},
"coverage": {
"hashes": [
"sha256:03481e81d558d30d230bc12999e3edffe392d244349a90f4ef9b88425fac74ba",
"sha256:0b136648de27201056c1869a6c0d4e23f464750fd9a9ba9750b8336a244429ed",
"sha256:104ab3934abaf5be871a583541e8829d6c19ce7bde2923b2751e0d3ca44db60a",
"sha256:10a46017fef60e16694a30627319f38a2b9b52e90182dddb6e37dcdab0f4bf95",
"sha256:15b111b6a0f46ee1a485414a52a7ad1d703bdf984e9ed3c288a4414d3871dcbd",
"sha256:198626739a79b09fa0a2f06e083ffd12eb55449b5f8bfdbeed1df4910b2ca640",
"sha256:1c383d2ef13ade2acc636556fd544dba6e14fa30755f26812f54300e401f98f2",
"sha256:23d341cdd4a0371820eb2b0bd6b88f5003a7438bbedb33688cd33b8eae59affd",
"sha256:28b2191e7283f4f3568962e373b47ef7f0392993bb6660d079c62bd50fe9d162",
"sha256:2a5b73210bad5279ddb558d9a2bfedc7f4bf6ad7f3c988641d83c40293deaec1",
@ -261,15 +292,42 @@
"sha256:7e1fe19bd6dce69d9fd159d8e4a80a8f52101380d5d3a4d374b6d3eae0e5de9c",
"sha256:8c3cb8c35ec4d9506979b4cf90ee9918bc2e49f84189d9bf5c36c0c1119c6558",
"sha256:9d6dd10d49e01571bf6e147d3b505141ffc093a06756c60b053a859cb2128b1f",
"sha256:9e112fcbe0148a6fa4f0a02e8d58e94470fc6cb82a5481618fea901699bf34c4",
"sha256:ac4fef68da01116a5c117eba4dd46f2e06847a497de5ed1d64bb99a5fda1ef91",
"sha256:b8815995e050764c8610dbc82641807d196927c3dbed207f0a079833ffcf588d",
"sha256:be6cfcd8053d13f5f5eeb284aa8a814220c3da1b0078fa859011c7fffd86dab9",
"sha256:c1bb572fab8208c400adaf06a8133ac0712179a334c09224fb11393e920abcdd",
"sha256:de4418dadaa1c01d497e539210cb6baa015965526ff5afc078c57ca69160108d",
"sha256:e05cb4d9aad6233d67e0541caa7e511fa4047ed7750ec2510d466e806e0255d6",
"sha256:f3f501f345f24383c0000395b26b726e46758b71393267aeae0bd36f8b3ade80"
"sha256:e4d96c07229f58cb686120f168276e434660e4358cc9cf3b0464210b04913e77",
"sha256:f3f501f345f24383c0000395b26b726e46758b71393267aeae0bd36f8b3ade80",
"sha256:f8a923a85cb099422ad5a2e345fe877bbc89a8a8b23235824a93488150e45f6e"
],
"index": "pypi",
"version": "==4.5.1"
},
"docutils": {
"hashes": [
"sha256:02aec4bd92ab067f6ff27a38a38a41173bf01bed8f89157768c1573f53e474a6",
"sha256:51e64ef2ebfb29cae1faa133b3710143496eca21c530f3f71424d77687764274",
"sha256:7a4bd47eaf6596e1295ecb11361139febe29b084a87bf005bf899f9a42edc3c6"
],
"version": "==0.14"
},
"idna": {
"hashes": [
"sha256:156a6814fb5ac1fc6850fb002e0852d56c0c8d2531923a51032d1b70760e186e",
"sha256:684a38a6f903c1d71d6d5fac066b58d7768af4de2b832e426ec79c30daa94a16"
],
"version": "==2.7"
},
"imagesize": {
"hashes": [
"sha256:3620cc0cadba3f7475f9940d22431fc4d407269f1be59ec9b8edcca26440cf18",
"sha256:5b326e4678b6925158ccc66a9fa3122b6106d7c876ee32d7de6ce59385b96315"
],
"version": "==1.0.0"
},
"isort": {
"hashes": [
"sha256:1153601da39a25b14ddc54955dbbacbb6b2d19135386699e2ad58517953b34af",
@ -278,6 +336,13 @@
],
"version": "==4.3.4"
},
"jinja2": {
"hashes": [
"sha256:74c935a1b8bb9a3947c50a54766a969d4846290e1e788ea44c1392163723c3bd",
"sha256:f84be1bb0040caca4cea721fcbbbbd61f9be9464ca236387158b0feea01914a4"
],
"version": "==2.10"
},
"lazy-object-proxy": {
"hashes": [
"sha256:0ce34342b419bd8f018e6666bfef729aec3edf62345a53b537a4dcc115746a33",
@ -312,6 +377,12 @@
],
"version": "==1.3.1"
},
"markupsafe": {
"hashes": [
"sha256:a6be69091dac236ea9c6bc7d012beab42010fa914c459791d627dad4910eb665"
],
"version": "==1.0"
},
"mccabe": {
"hashes": [
"sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42",
@ -343,6 +414,13 @@
"index": "pypi",
"version": "==0.620"
},
"packaging": {
"hashes": [
"sha256:e9215d2d2535d3ae866c3d6efc77d5b24a0192cce0ff20e42896cc0664f889c0",
"sha256:f019b770dd64e585a99714f1fd5e01c7a8f11b45635aa953fd41c689a657375b"
],
"version": "==17.1"
},
"pbr": {
"hashes": [
"sha256:1b8be50d938c9bb75d0eaf7eda111eec1bf6dc88a62a6412e33bf077457e0f45",
@ -352,11 +430,10 @@
},
"pluggy": {
"hashes": [
"sha256:7f8ae7f5bdf75671a718d2daf0a64b7885f74510bcd98b1a0bb420eb9a9d0cff",
"sha256:d345c8fe681115900d6da8d048ba67c25df42973bda370783cd58826442dcd7c",
"sha256:e160a7fcf25762bb60efc7e171d4497ff1d8d2d75a3d0df7a21b76821ecbf5c5"
"sha256:6e3836e39f4d36ae72840833db137f7b7d35105079aee6ec4a62d9f80d594dd1",
"sha256:95eb8364a4708392bae89035f45341871286a333f749c3141c20573d2b3876e1"
],
"version": "==0.6.0"
"version": "==0.7.1"
},
"py": {
"hashes": [
@ -367,6 +444,7 @@
},
"pycodestyle": {
"hashes": [
"sha256:74abc4e221d393ea5ce1f129ea6903209940c1ecd29e002e8c6933c2b21026e0",
"sha256:cbc619d09254895b0d12c2c691e237b2e91e9b2ecf5e84c26b35400f93dcfb83",
"sha256:cbfca99bd594a10f674d0cd97a3d802a1fdef635d4361e1a2658de47ed261e3a"
],
@ -382,6 +460,13 @@
"index": "pypi",
"version": "==2.1.1"
},
"pygments": {
"hashes": [
"sha256:78f3f434bcc5d6ee09020f92ba487f95ba50f1e3ef83ae96b9d5ffa1bab25c5d",
"sha256:dbae1046def0efb574852fab9e90209b23f556367b5a320c0bcb871c77c3e8cc"
],
"version": "==2.2.0"
},
"pylint": {
"hashes": [
"sha256:2c90a24bee8fae22ac98061c896e61f45c5b73c2e0511a4bf53f99ba56e90434",
@ -390,13 +475,25 @@
"index": "pypi",
"version": "==2.0.1"
},
"pyparsing": {
"hashes": [
"sha256:0832bcf47acd283788593e7a0f542407bd9550a55a8a8435214a1960e04bcb04",
"sha256:281683241b25fe9b80ec9d66017485f6deff1af5cde372469134b56ca8447a07",
"sha256:8f1e18d3fd36c6795bb7e02a39fd05c611ffc2596c1e0d995d34d67630426c18",
"sha256:9e8143a3e15c13713506886badd96ca4b579a87fbdf49e550dbfc057d6cb218e",
"sha256:b8b3117ed9bdf45e14dcc89345ce638ec7e0e29b2b579fa1ecf32ce45ebac8a5",
"sha256:e4d45427c6e20a59bf4f88c639dcc03ce30d193112047f94012102f235853a58",
"sha256:fee43f17a9c4087e7ed1605bd6df994c6173c1e977d7ade7b651292fab2bd010"
],
"version": "==2.2.0"
},
"pytest": {
"hashes": [
"sha256:0453c8676c2bee6feb0434748b068d5510273a916295fd61d306c4f22fbfd752",
"sha256:4b208614ae6d98195430ad6bde03641c78553acee7c83cec2e85d613c0cd383d"
"sha256:341ec10361b64a24accaec3c7ba5f7d5ee1ca4cebea30f76fad3dd12db9f0541",
"sha256:952c0389db115437f966c4c2079ae9d54714b9455190e56acebe14e8c38a7efa"
],
"index": "pypi",
"version": "==3.6.3"
"version": "==3.6.4"
},
"python-dotenv": {
"hashes": [
@ -406,6 +503,20 @@
"index": "pypi",
"version": "==0.8.2"
},
"pytz": {
"hashes": [
"sha256:a061aa0a9e06881eb8b3b2b43f05b9439d6583c206d0a6c340ff72a7b6669053",
"sha256:ffb9ef1de172603304d9d2819af6f5ece76f2e85ec10692a524dd876e72bf277"
],
"version": "==2018.5"
},
"requests": {
"hashes": [
"sha256:63b52e3c866428a224f97cab011de738c36aec0185aa91cfacd418b5d58911d1",
"sha256:ec22d826a36ed72a7358ff3fe56cbd4ba69dd7a6718ffd450ff0e9df7a47ce6a"
],
"version": "==2.19.1"
},
"six": {
"hashes": [
"sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9",
@ -420,6 +531,37 @@
],
"version": "==1.2.1"
},
"sphinx": {
"hashes": [
"sha256:217ad9ece2156ed9f8af12b5d2c82a499ddf2c70a33c5f81864a08d8c67b9efc",
"sha256:a765c6db1e5b62aae857697cd4402a5c1a315a7b0854bbcd0fc8cdc524da5896"
],
"index": "pypi",
"version": "==1.7.6"
},
"sphinx-rtd-theme": {
"hashes": [
"sha256:3b49758a64f8a1ebd8a33cb6cc9093c3935a908b716edfaa5772fd86aac27ef6",
"sha256:80e01ec0eb711abacb1fa507f3eae8b805ae8fa3e8b057abfdf497e3f644c82c"
],
"index": "pypi",
"version": "==0.4.1"
},
"sphinxcontrib-httpdomain": {
"hashes": [
"sha256:1fb5375007d70bf180cdd1c79e741082be7aa2d37ba99efe561e1c2e3f38191e",
"sha256:ac40b4fba58c76b073b03931c7b8ead611066a6aebccafb34dc19694f4eb6335"
],
"index": "pypi",
"version": "==1.7.0"
},
"sphinxcontrib-websupport": {
"hashes": [
"sha256:68ca7ff70785cbe1e7bccc71a48b5b6d965d79ca50629606c7861a21b206d9dd",
"sha256:9de47f375baf1ea07cdb3436ff39d7a9c76042c10a769c52353ec46e4e8fc3b9"
],
"version": "==1.1.0"
},
"typed-ast": {
"hashes": [
"sha256:0948004fa228ae071054f5208840a1e88747a357ec1101c17217bfe99b299d58",
@ -446,9 +588,15 @@
"sha256:edb04bdd45bfd76c8292c4d9654568efaedf76fe78eb246dde69bdb13b2dad87",
"sha256:f19f2a4f547505fe9072e15f6f4ae714af51b5a681a97f187971f50c283193b6"
],
"markers": "python_version < '3.7' and implementation_name == 'cpython'",
"version": "==1.1.0"
},
"urllib3": {
"hashes": [
"sha256:a68ac5e15e76e7e5dd2b8f94007233e01effe3e50e8daddf69acfd81cb686baf",
"sha256:b5725a0bd4ba422ab0e66e89e030c806576753ea3ee08554382c14e685d117b5"
],
"version": "==1.23"
},
"wrapt": {
"hashes": [
"sha256:d4d560d479f2c21e1b5443bbd15fe7ec4b37fe7e53d335d3b9b0a7b1226fe3c6"

6
server/README.md

@ -8,8 +8,8 @@
## Installation
```bash
git clone <repository>
cd <cloned repository>
git clone https://gitlab.com/WarrickSothr/Atheneum.git
cd Atheneum/server
pipenv install
pipenv shell
```
@ -28,6 +28,8 @@ docker run -d atheneum:local-test
### Local Development Version
```bash
FLASK_APP=atheneum:atheneum flask db upgrade
python manage.py user register-admin
FLASK_APP=atheneum:atheneum flask run
```

4
server/atheneum/__init__.py

@ -6,7 +6,8 @@ from flask import Flask
from flask_migrate import Migrate
from atheneum.db import db
from atheneum.errors import BaseError, handle_atheneum_base_error
from atheneum.errors import BaseError, handle_atheneum_base_error, \
handle_atheneum_404_error
from atheneum.utility import json_utility, session_utility
dictConfig({
@ -92,6 +93,7 @@ def register_error_handlers(app: Flask) -> None:
:param app:
:return:
"""
app.register_error_handler(404, handle_atheneum_404_error)
app.register_error_handler(BaseError, handle_atheneum_base_error)

6
server/atheneum/api/authentication_api.py

@ -2,7 +2,7 @@
from flask import Blueprint, g
from atheneum.api.decorators import return_json
from atheneum.api.model import APIResponse
from atheneum.api.model import APIMessage, APIResponse
from atheneum.middleware import authentication_middleware
from atheneum.service import (
user_token_service,
@ -37,7 +37,7 @@ def login_bump() -> APIResponse:
:return: A time stamp for the bumped login
"""
user_service.update_last_login_time(g.user)
return APIResponse({'lastLoginTime': g.user.last_login_time}, 200)
return APIResponse(g.user, 200, ['lastLoginTime'])
@AUTH_BLUEPRINT.route('/logout', methods=['POST'])
@ -50,4 +50,4 @@ def logout() -> APIResponse:
:return:
"""
authentication_service.logout(g.user_token)
return APIResponse(None, 200)
return APIResponse(APIMessage(True, None), 200)

25
server/atheneum/api/model.py

@ -1,5 +1,5 @@
"""Model definitions for the api module."""
from typing import Any, List, Optional
from typing import Any, List, Optional, Dict
class APIResponse: # pylint: disable=too-few-public-methods
@ -13,3 +13,26 @@ class APIResponse: # pylint: disable=too-few-public-methods
self.payload = payload
self.status = status
self.options = options
class APIMessage: # pylint: disable=too-few-public-methods
"""Simple class to encapsulate response messages."""
success: bool
message: Optional[str]
def __init__(self,
success: bool,
message: Optional[str]) -> None:
"""Construct an APIMessage."""
self.success = success
self.message = message
def to_dict(self) -> dict:
"""Serialize an APIMessage to a dict."""
obj: Dict[str, Any] = {
'success': self.success
}
if self.message is not None:
obj['message'] = self.message
return obj

36
server/atheneum/api/user_api.py

@ -2,7 +2,7 @@
from flask import Blueprint, abort, request, g
from atheneum.api.decorators import return_json
from atheneum.api.model import APIResponse
from atheneum.api.model import APIResponse, APIMessage
from atheneum.middleware import authentication_middleware
from atheneum.model import User
from atheneum.service import (
@ -47,12 +47,9 @@ def patch_user(name: str) -> APIResponse:
if user is not None:
user_patch: User = transformation_service.deserialize_model(
User, request.json)
try:
patched_user = patch_service.patch(
g.user, user, user_patch, get_patch_fields(request.json))
return APIResponse(patched_user, 200)
except ValueError:
return abort(400)
patched_user = patch_service.patch(
g.user, user, user_patch, get_patch_fields(request.json))
return APIResponse(patched_user, 200)
return abort(404)
@ -68,9 +65,32 @@ def register_user() -> APIResponse:
"""
new_user: User = transformation_service.deserialize_model(
User, request.json)
requested_password = None
if 'password' in request.json:
requested_password = request.json['password'].strip()
registered_user = user_service.register(
name=new_user.name,
password=None,
password=requested_password,
role=new_user.role
)
return APIResponse(payload=registered_user, status=200)
@USER_BLUEPRINT.route('/<name>', methods=['DELETE'])
@return_json
@authentication_middleware.require_token_auth
@authentication_middleware.require_role(required_role=Role.ADMIN)
def delete_user(name: str) -> APIResponse:
"""
Delete a user with the service.
:return: The newly registered User
"""
user = user_service.find_by_name(name)
if user is not None:
user_service.delete(user)
return APIResponse(
APIMessage(True, 'Successfully Deleted'), status=200)
return abort(404)

11
server/atheneum/errors.py

@ -1,8 +1,10 @@
"""Error definitions for Atheneum."""
from typing import Dict
from werkzeug.exceptions import HTTPException
from atheneum.api.decorators import return_json
from atheneum.api.model import APIResponse
from atheneum.api.model import APIResponse, APIMessage
class BaseError(Exception):
@ -44,6 +46,13 @@ class ValidationError(ClientError):
pass
@return_json
def handle_atheneum_404_error(exception: HTTPException) -> APIResponse:
"""Error handler for 404 Atheneum errors."""
return APIResponse(
payload=APIMessage(False, 'Not Found'), status=exception.code)
@return_json
def handle_atheneum_base_error(error: BaseError) -> APIResponse:
"""Error handler for basic Atheneum raised errors."""

26
server/atheneum/service/authentication_service.py

@ -1,14 +1,40 @@
"""Service to handle authentication."""
import re
from datetime import datetime
from typing import Optional
from nacl import pwhash
from nacl.exceptions import InvalidkeyError
from atheneum import errors
from atheneum.model import User, UserToken
from atheneum.service import user_token_service
def validate_password_strength(proposed_password: str) -> str:
"""Validate that a password meets minimum strength requirements."""
# calculating the length
length_error = len(proposed_password) < 8
# searching for digits
digit_error = re.search(r"\d", proposed_password) is None
# searching for uppercase
uppercase_error = re.search(r"[A-Z]", proposed_password) is None
# searching for lowercase
lowercase_error = re.search(r"[a-z]", proposed_password) is None
if length_error or digit_error or uppercase_error or lowercase_error:
raise errors.ValidationError(
' '.join(['The password must be at least 8 characters long.',
'Contain one or more digits,',
'one or more uppercase characters,',
'and one or more lowercase characters']))
return proposed_password
def is_valid_password(user: User, password: str) -> bool:
"""
User password must pass pwhash verify.

10
server/atheneum/service/patch_service.py

@ -2,6 +2,7 @@
from typing import Type, Set, Optional, Any, Dict
from atheneum import db
from atheneum import errors
from atheneum.model import User
from atheneum.service import transformation_service
from atheneum.service import validation_service
@ -39,7 +40,7 @@ def perform_patch(request_user: User,
setattr(original_model, attribute, value)
db.session.commit()
else:
raise ValueError(
raise errors.ValidationError(
'Restricted attributes modified. Invalid Patch Set.')
return original_model
@ -73,7 +74,8 @@ def versioning_aware_patch(request_user: User,
patch_model,
model_attributes,
patched_fields)
raise ValueError('Versions do not match. Concurrent edit in progress.')
raise errors.ValidationError(
'Versions do not match. Concurrent edit in progress.')
def patch(
@ -94,7 +96,7 @@ def patch(
model_attributes = validation_service.get_changable_attribute_names(
original_model)
if patch_model.id is not None and original_model.id != patch_model.id:
raise ValueError('Cannot change ids through patching')
raise errors.ValidationError('Cannot change ids through patching')
if 'version' in model_attributes:
return versioning_aware_patch(
request_user,
@ -108,7 +110,7 @@ def patch(
patch_model,
model_attributes,
patched_fields)
raise ValueError(
raise errors.ValidationError(
'Model types "{}" and "{}" do not match'.format(
original_model.__class__.__name__,
patch_model.__class__.__name__

11
server/atheneum/service/user_service.py

@ -11,6 +11,7 @@ from atheneum import errors
from atheneum.db import db
from atheneum.model import User
from atheneum.service import role_service
from atheneum.service.authentication_service import validate_password_strength
from atheneum.service.transformation_service import (
BaseTransformer,
register_transformer
@ -157,17 +158,25 @@ def find_by_name(name: str) -> Optional[User]:
return User.query.filter_by(name=name).first()
def register(name: str, password: Optional[str], role: Optional[str]) -> User:
def register(
name: str,
password: Optional[str],
role: Optional[str],
validate_password: bool = True) -> User:
"""
Register a new user.
:param name: Desired user name. Must be unique and not already registered
:param password: Password to be hashed and stored for the user
:param role: Role to assign the user [ROLE_USER, ROLE_ADMIN]
:param validate_password: Perform password validation
:return:
"""
if validate_password and password is not None:
validate_password_strength(password)
password = password if password is not None else ''.join(
random.choices(string.ascii_letters + string.digits, k=32))
role = role if role is not None else User.ROLE_USER
if find_by_name(name=name) is not None:

4
server/atheneum/utility/json_utility.py

@ -5,7 +5,7 @@ from typing import Any
import rfc3339
from flask.json import JSONEncoder
from atheneum.api.model import APIResponse
from atheneum.api.model import APIResponse, APIMessage
from atheneum.db import db
from atheneum.errors import BaseError
from atheneum.service.transformation_service import serialize_model
@ -25,6 +25,8 @@ class CustomJSONEncoder(JSONEncoder):
return serialize_model(o.payload, o.options)
if isinstance(payload, BaseError):
return payload.to_dict()
if isinstance(payload, APIMessage):
return payload.to_dict()
return payload
if isinstance(o, db.Model):
return serialize_model(o)

20
server/documentation/Makefile

@ -0,0 +1,20 @@
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line.
SPHINXOPTS =
SPHINXBUILD = sphinx-build
SPHINXPROJ = Atheneum
SOURCEDIR = .
BUILDDIR = _build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

98
server/documentation/api/authentication.rst

@ -0,0 +1,98 @@
Authentication API
==================
.. http:post:: /auth/login
Authenticate with the server and receive a userToken for requests.
**Example request**:
.. sourcecode:: http
POST /auth/login HTTP/1.1
Host: example.tld
Accept: application/json
Authorization: Basic <Base64 Encoded Basic Auth>
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"creationTime": "2018-07-29T11:59:29-05:00",
"enabled": true,
"token": "b94cf5c7-cddc-4610-9d4c-6b8e04088ae8",
"version": 0
}
:reqheader Accept: the response content type depends on :mailheader:`Accept` header
:reqheader Authorization: The encoded basic authorization
:resheader Content-Type: this depends on :mailheader:`Accept` header of request
:statuscode 200: user successfully logged in
:statuscode 401: authorization failed
.. http:post:: /auth/bump
Bump user login information.
**Example request**:
.. sourcecode:: http
POST /auth/bump HTTP/1.1
Host: example.tld
Accept: application/json
Authorization: Token <Base64(user:userToken)>
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"lastLoginTime": "2018-07-29T12:15:51-05:00"
}
:reqheader Accept: the response content type depends on :mailheader:`Accept` header
:reqheader Authorization: The encoded basic authorization
:resheader Content-Type: this depends on :mailheader:`Accept` header of request
:statuscode 200: user last_login_time successfully bumped
:statuscode 401: authorization failed
.. http:post:: /auth/logout
Logout a user and remove the provided userToken from valid tokens.
**Example request**:
.. sourcecode:: http
POST /auth/logout HTTP/1.1
Host: example.tld
Accept: application/json
Authorization: Token <Base64(user:userToken)>
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"success": true
}
:reqheader Accept: the response content type depends on :mailheader:`Accept` header
:reqheader Authorization: The encoded basic authorization
:resheader Content-Type: this depends on :mailheader:`Accept` header of request
:statuscode 200: user successfully logged out
:statuscode 401: authorization failed

9
server/documentation/api/index.rst

@ -0,0 +1,9 @@
Atheneum API documentation
==========================
.. toctree::
:maxdepth: 2
:caption: Contents:
authentication
user

158
server/documentation/api/user.rst

@ -0,0 +1,158 @@
User API
========
.. http:get:: /user/(str:user_name)
Find a user by name.
**Example request**:
.. sourcecode:: http
GET /user/atheneum_administrator HTTP/1.1
Host: example.tld
Accept: application/json
Authorization: Token <Base64(user:userToken)>
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"creationTime": "2018-07-29T11:58:17-05:00",
"lastLoginTime": "2018-07-29T12:43:27-05:00",
"name": "atheneum_administrator",
"role": "ADMIN",
"version": 0
}
:reqheader Accept: the response content type depends on :mailheader:`Accept` header
:reqheader Authorization: The encoded basic authorization
:resheader Content-Type: this depends on :mailheader:`Accept` header of request
:statuscode 200: user successfully logged in
:statuscode 401: authorization failed
:statuscode 404: user doesn't exist
.. http:patch:: /user/(str:user_name)
Patch a user.
**Example request**:
.. sourcecode:: http
PATCH /user/atheneum_administrator HTTP/1.1
Host: example.tld
Accept: application/json
Authorization: Token <Base64(user:userToken)>
Content-Type: application/json
{
"lastLoginTime": "2019-07-29T12:43:27-05:00",
"version": 0
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"creationTime": "2018-07-29T11:58:17-05:00",
"lastLoginTime": "2019-07-29T12:43:27-05:00",
"name": "atheneum_administrator",
"role": "ADMIN",
"version": 1
}
:reqheader Accept: the response content type depends on :mailheader:`Accept` header
:reqheader Authorization: The encoded basic authorization
:reqheader Content-Type: application/json
:resheader Content-Type: this depends on :mailheader:`Accept` header of request
:statuscode 200: user successfully logged in
:statuscode 400: an issue in the payload was discovered
:statuscode 401: authorization failed
:statuscode 404: user doesn't exist
.. http:post:: /user/
Register a new user with the service.
**Example request**:
.. sourcecode:: http
POST /user/ HTTP/1.1
Host: example.tld
Accept: application/json
Authorization: Token <Base64(user:userToken)>
Content-Type: application/json
{
"name": "test_user",
"password": "JvZ9bm79",
"role": "USER"
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"creationTime": "2018-07-29T14:16:48-05:00",
"name": "test_user",
"role": "USER",
"version": 0
}
:reqheader Accept: the response content type depends on :mailheader:`Accept` header
:reqheader Authorization: The encoded basic authorization
:reqheader Content-Type: application/json
:resheader Content-Type: this depends on :mailheader:`Accept` header of request
:statuscode 200: user successfully logged in
:statuscode 400: an issue in the payload was discovered
:statuscode 401: authorization failed
.. http:delete:: /user/(str:user_name)
Register a new user with the service.
**Example request**:
.. sourcecode:: http
DELETE /user/test_user HTTP/1.1
Host: example.tld
Accept: application/json
Authorization: Token <Base64(user:userToken)>
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"message": "Successfully Deleted",
"success": true
}
:reqheader Accept: the response content type depends on :mailheader:`Accept` header
:reqheader Authorization: The encoded basic authorization
:resheader Content-Type: this depends on :mailheader:`Accept` header of request
:statuscode 200: user successfully logged in
:statuscode 401: authorization failed
:statuscode 404: user doesn't exist

156
server/documentation/conf.py

@ -0,0 +1,156 @@
# -*- coding: utf-8 -*-
#
# Configuration file for the Sphinx documentation builder.
#
# This file does only contain a selection of the most common options. For a
# full list see the documentation:
# http://www.sphinx-doc.org/en/master/config
# -- Path setup --------------------------------------------------------------
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#
# import os
# import sys
# sys.path.insert(0, os.path.abspath('.'))
# -- Project information -----------------------------------------------------
project = 'Atheneum'
copyright = '2018, Drew Short'
author = 'Drew Short'
# The short X.Y version
version = '2018.8'
# The full version, including alpha/beta/rc tags
release = '2018.8.1'
# -- General configuration ---------------------------------------------------
# If your documentation needs a minimal Sphinx version, state it here.
#
# needs_sphinx = '1.0'
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
'sphinxcontrib.httpdomain'
]
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
# The suffix(es) of source filenames.
# You can specify multiple suffix as a list of string:
#
# source_suffix = ['.rst', '.md']
source_suffix = '.rst'
# The master toctree document.
master_doc = 'index'
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
#
# This is also used if you do content translation via gettext catalogs.
# Usually you set "language" from the command line for these cases.
language = None
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This pattern also affects html_static_path and html_extra_path .
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
# The name of the Pygments (syntax highlighting) style to use.
pygments_style = 'sphinx'
# -- Options for HTML output -------------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
#
html_theme = 'sphinx_rtd_theme'
# Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the
# documentation.
#
# html_theme_options = {}
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static']
# Custom sidebar templates, must be a dictionary that maps document names
# to template names.
#
# The default sidebars (for documents that don't match any pattern) are
# defined by theme itself. Builtin themes are using these templates by
# default: ``['localtoc.html', 'relations.html', 'sourcelink.html',
# 'searchbox.html']``.
#
# html_sidebars = {}
# -- Options for HTMLHelp output ---------------------------------------------
# Output file base name for HTML help builder.
htmlhelp_basename = 'Atheneumdoc'
# -- Options for LaTeX output ------------------------------------------------
latex_elements = {
# The paper size ('letterpaper' or 'a4paper').
#
# 'papersize': 'letterpaper',
# The font size ('10pt', '11pt' or '12pt').
#
# 'pointsize': '10pt',
# Additional stuff for the LaTeX preamble.
#
# 'preamble': '',
# Latex figure (float) alignment
#
# 'figure_align': 'htbp',
}
# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title,
# author, documentclass [howto, manual, or own class]).
latex_documents = [
(master_doc, 'Atheneum.tex', 'Atheneum Documentation',
'Drew Short', 'manual'),
]
# -- Options for manual page output ------------------------------------------
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
(master_doc, 'atheneum', 'Atheneum Documentation',
[author], 1)
]
# -- Options for Texinfo output ----------------------------------------------
# Grouping the document tree into Texinfo files. List of tuples
# (source start file, target name, title, author,
# dir menu entry, description, category)
texinfo_documents = [
(master_doc, 'Atheneum', 'Atheneum Documentation',
author, 'Atheneum', 'One line description of project.',
'Miscellaneous'),
]

20
server/documentation/index.rst

@ -0,0 +1,20 @@
.. Atheneum documentation master file, created by
sphinx-quickstart on Sun Jul 29 11:09:44 2018.
You can adapt this file completely to your liking, but it should at least
contain the root `toctree` directive.
Welcome to Atheneum's documentation!
====================================
.. toctree::
:maxdepth: 2
:caption: Contents:
introduction
api/index
Indices and tables
==================
* :ref:`genindex`
* :ref:`search`

4
server/documentation/introduction.rst

@ -0,0 +1,4 @@
Introduction To Atheneum
========================
TODO

36
server/documentation/make.bat

@ -0,0 +1,36 @@
@ECHO OFF
pushd %~dp0
REM Command file for Sphinx documentation
if "%SPHINXBUILD%" == "" (
set SPHINXBUILD=sphinx-build
)
set SOURCEDIR=.
set BUILDDIR=_build
set SPHINXPROJ=Atheneum
if "%1" == "" goto help
%SPHINXBUILD% >NUL 2>NUL
if errorlevel 9009 (
echo.
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
echo.installed, then set the SPHINXBUILD environment variable to point
echo.to the full path of the 'sphinx-build' executable. Alternatively you
echo.may add the Sphinx directory to PATH.
echo.
echo.If you don't have Sphinx installed, grab it from
echo.http://sphinx-doc.org/
exit /b 1
)
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
goto end
:help
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
:end
popd

2
server/manage.py

@ -56,7 +56,7 @@ def register_user(
if existing_user is None:
user_password = password if password else ''.join(
random.choices(string.ascii_letters + string.digits, k=24))
new_user = user_service.register(name, user_password, role)
new_user = user_service.register(name, user_password, role, False)
logging.warning(
'Created new user: \'%s\' with password \'%s\' and role %s',
new_user.name,

1
server/run_tests.sh

@ -6,7 +6,6 @@ set -x
python3 --version
python3 -m pip --version
python3 -m pipenv --version
pylint --version
mypy --version

2
server/tests/api/test_authentication_api.py

@ -19,4 +19,4 @@ def test_logout_happy_path(auth: AuthActions):
auth.login()
result = auth.logout()
assert result.status_code == 200
assert result.json is None
assert result.json['success']

44
server/tests/api/test_user_api.py

@ -64,6 +64,25 @@ def test_register_user_happy_path(auth: AuthActions, client: FlaskClient):
assert result.json['name'] == 'test_registered_user'
def test_register_user_invalid_password(
auth: AuthActions, client: FlaskClient):
auth.login()
auth_header = auth.get_authorization_header_token()
result = client.post(
'/user/',
data=json.dumps({
'name': 'test_registered_user',
'password': ''
}),
headers={
auth_header[0]: auth_header[1],
'Content-Type': 'application/json'
})
assert 400 == result.status_code
assert result.json is not None
assert 'message' in result.json
def test_register_user_twice_failure(auth: AuthActions, client: FlaskClient):
auth.login()
auth_header = auth.get_authorization_header_token()
@ -91,3 +110,28 @@ def test_register_user_twice_failure(auth: AuthActions, client: FlaskClient):
assert 400 == result2.status_code
assert result2.json is not None
assert result2.json['message'] == 'User name is already taken.'
def test_delete_user_happy_path(auth: AuthActions, client: FlaskClient):
auth.login()
auth_header = auth.get_authorization_header_token()
result1 = client.post(
'/user/',
data=json.dumps({
'name': 'test_registered_user'
}),
headers={
auth_header[0]: auth_header[1],
'Content-Type': 'application/json'
})
result2 = client.delete(
'/user/'+result1.json['name'],
headers={
auth_header[0]: auth_header[1]
})
assert 200 == result1.status_code
assert result1.json is not None
assert result1.json['name'] == 'test_registered_user'
assert 200 == result2.status_code
assert result2.json is not None
assert 'message' in result2.json

2
server/tests/conftest.py

@ -21,7 +21,7 @@ def add_test_user() -> Tuple[str, str]:
random.choices(string.ascii_letters + string.digits, k=17)).strip()
test_password = ''.join(
random.choices(string.ascii_letters + string.digits, k=32)).strip()
user_service.register(test_username, test_password, User.ROLE_ADMIN)
user_service.register(test_username, test_password, User.ROLE_ADMIN, False)
return test_username, test_password

9
server/tests/service/test_patch_service.py

@ -3,6 +3,7 @@ from datetime import datetime, timedelta
import pytest
from mock import MagicMock, patch
from atheneum import errors
from atheneum.model import UserToken, User
from atheneum.service import patch_service, role_service
@ -38,7 +39,7 @@ def test_patch_of_different_types():
user = User()
user_token = UserToken()
with pytest.raises(ValueError) as error_info:
with pytest.raises(errors.ValidationError) as error_info:
patch_service.patch(request_user, user, user_token)
@ -52,7 +53,7 @@ def test_patch_different_ids():
user2 = User()
user2.id = 2
with pytest.raises(ValueError) as error_info:
with pytest.raises(errors.ValidationError) as error_info:
patch_service.patch(request_user, user1, user2)
@ -66,7 +67,7 @@ def test_patch_different_versions():
user2 = User()
user2.version = 2
with pytest.raises(ValueError) as error_info:
with pytest.raises(errors.ValidationError) as error_info:
patch_service.patch(request_user, user1, user2)
@ -82,5 +83,5 @@ def test_patch_restricted_attributes():
user2.version = 1
user2.name = 'Chris'
with pytest.raises(ValueError) as error_info:
with pytest.raises(errors.ValidationError) as error_info:
patch_service.patch(request_user, user1, user2)
Loading…
Cancel
Save