From 150d0a884d6193a10e936f4fd5a5bb69de5c1715 Mon Sep 17 00:00:00 2001 From: androiddrew Date: Sun, 9 Dec 2018 22:43:31 -0500 Subject: [PATCH] Added a Base model manager class, Custom EntityNotFound error, and example Todo resource This includes an implementation of a Todo Resource manager and corrects the manage.py click functions associated with the initialization and drop of a SQL Database. --- {{cookiecutter.project_slug}}/manage.py | 31 ++++--- .../tests/conftest.py | 42 ++++++++++ .../tests/test_app_routes.py | 43 ++++++++-- .../api/todo/__init__.py | 2 + .../api/todo/manager.py | 80 +++++++++++++++++++ .../api/todo/model.py | 21 +++++ .../api/todo/view.py | 49 ++++++++++++ .../{{cookiecutter.project_slug}}/db.py | 32 ++++++++ .../{{cookiecutter.project_slug}}/error.py | 5 ++ .../{{cookiecutter.project_slug}}/index.py | 21 ++++- .../{{cookiecutter.project_slug}}/manager.py | 32 ++++++++ 11 files changed, 331 insertions(+), 27 deletions(-) create mode 100644 {{cookiecutter.project_slug}}/tests/conftest.py create mode 100644 {{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/api/todo/__init__.py create mode 100644 {{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/api/todo/manager.py create mode 100644 {{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/api/todo/model.py create mode 100644 {{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/api/todo/view.py create mode 100644 {{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/db.py create mode 100644 {{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/error.py create mode 100644 {{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/manager.py diff --git a/{{cookiecutter.project_slug}}/manage.py b/{{cookiecutter.project_slug}}/manage.py index 329aebf..fff14da 100644 --- a/{{cookiecutter.project_slug}}/manage.py +++ b/{{cookiecutter.project_slug}}/manage.py @@ -1,4 +1,8 @@ import click +from molten.contrib.sqlalchemy import EngineData +from {{cookiecutter.project_slug}}.index import create_app + +app = create_app() @click.group() @@ -15,11 +19,8 @@ def runserver(host, port): """ Runs a Werkzueg development server. Do no use for production. """ - from {{cookiecutter.project_slug}}.index import create_app from werkzeug.serving import run_simple - app = create_app() - run_simple( hostname=host, port=port, application=app, use_debugger=True, use_reloader=True ) @@ -30,23 +31,15 @@ def shell(): """ Enters an interactive shell with an app instance and dependency resolver. """ + import rlcompleter import readline from code import InteractiveConsole - from {{cookiecutter.project_slug}}.index import create_app - - app = create_app() - helpers = {"app": app, "resolver": app.injector.get_resolver()} - readline.parse_and_bind("Tab: complete") + readline.parse_and_bind("tab: complete") interpreter = InteractiveConsole(helpers) - interpreter.interact( - f"""\ - Instances in scope: {", ".join(helpers)}. - """, - "", - ) + interpreter.interact(f"Instances in scope: {', '.join(helpers)}.", "") @cli.command() @@ -56,8 +49,11 @@ def initdb(): """ from {{cookiecutter.project_slug}}.db import Base + def _init(engine_data: EngineData): + Base.metadata.create_all(bind=engine_data.engine) + click.echo("Creating database") - Base.metadata.create_all(bind=engine) + app.injector.get_resolver().resolve(_init)() click.echo("Database created") @@ -69,10 +65,13 @@ def dropdb(): from {{cookiecutter.project_slug}}.db import Base + def _drop(engine_data: EngineData): + Base.metadata.drop_all(bind=engine_data.engine) + click.echo("Are you sure you would like to drop the database?: [Y/N]") response = input() if response.lower() == "y": - Base.metadata.drop_all(bind=engine) + app.injector.get_resolver().resolve(_drop)() click.echo("Database dropped") else: click.echo("Database drop aborted") diff --git a/{{cookiecutter.project_slug}}/tests/conftest.py b/{{cookiecutter.project_slug}}/tests/conftest.py new file mode 100644 index 0000000..495394e --- /dev/null +++ b/{{cookiecutter.project_slug}}/tests/conftest.py @@ -0,0 +1,42 @@ +import pytest + +from molten import testing +from molten.contrib.sqlalchemy import EngineData + +from {{cookiecutter.project_slug}}.index import create_app +from {{cookiecutter.project_slug}}.db import Base + + + +# requires function scope so that database is removed on every tests +@pytest.fixture(scope="function") +def app(): + app = create_app() + yield app + + +@pytest.fixture(autouse=True) +def create_db(app): + """Creates a test database with session scope""" + def _retrieve_engine(engine_data: EngineData): + return engine_data.engine + + engine = app.injector.get_resolver().resolve(_retrieve_engine)() + + Base.metadata.create_all(bind=engine) + + yield + + Base.metadata.drop_all(bind=engine) + + +@pytest.fixture(scope="function") +def client(app): + """Creates a testing client""" + return testing.TestClient(app) + + + +@pytest.fixture(scope="function") +def session(): + pass \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/tests/test_app_routes.py b/{{cookiecutter.project_slug}}/tests/test_app_routes.py index c523276..2b74e5f 100644 --- a/{{cookiecutter.project_slug}}/tests/test_app_routes.py +++ b/{{cookiecutter.project_slug}}/tests/test_app_routes.py @@ -1,12 +1,39 @@ -from molten.testing import TestClient -from {{cookiecutter.project_slug}}.index import create_app +def test_welcome_route(client): + message = "welcome to {{cookiecutter.project_slug}}" + response = client.get("/") + content = response.json() + assert message == content.get("message") -app = create_app() -test_client = TestClient(app) +def test_empty_get_todos(client): + response = client.get("/todos") + assert response.status_code == 200 + assert response.json() == [] -def test_welcome_route(): - message = "welcome to {{cookiecutter.project_slug}}" - response = test_client.get("/") + +def test_insert_todo(client): + payload = {"todo": "walk the dog"} + response = client.post("/todos", data=payload) content = response.json() - assert message == content.get("message") + assert response.status_code == 201 + assert type(content['id']) == int + assert content['todo'] == payload['todo'] + + +def test_update_todo(client): + payload = {"todo": "sample app"} + response = client.post("/todos", json=payload) + todo = response.json() + print(todo) + update_response = client.patch("{}".format(todo.get("href")), json={"complete": True, "todo": "sample app"}) + updated_todo = update_response.json() + print(updated_todo) + assert updated_todo["complete"] == True + + +def test_todo_not_found(client): + response = client.get("/todo/1111111") + content = response.json() + assert response.status_code == 404 + assert content["status"] == 404 + assert content["message"] diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/api/todo/__init__.py b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/api/todo/__init__.py new file mode 100644 index 0000000..caaebba --- /dev/null +++ b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/api/todo/__init__.py @@ -0,0 +1,2 @@ +from .manager import TodoManager, TodoManagerComponent +from .view import todo_routes \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/api/todo/manager.py b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/api/todo/manager.py new file mode 100644 index 0000000..0c06874 --- /dev/null +++ b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/api/todo/manager.py @@ -0,0 +1,80 @@ +from inspect import Parameter +from typing import List +from molten import BaseApp, HTTPError, HTTP_409, HTTP_404 +from sqlalchemy.orm import Session + +from {{cookiecutter.project_slug}}.manager import BaseManager +from {{cookiecutter.project_slug}}.error import EntityNotFound +from .model import Todo, TodoModel + + +class TodoManager(BaseManager): + """A `TodoManager` is accountable for the CRUD operations associated with a `Todo` instance""" + + def schema_from_model(self, result: TodoModel) -> Todo: + _todo = Todo( + id=result.id, + href=self.app.reverse_uri("get_todo_by_id", todo_id=result.id), + createdDate=result.created_date, + modifiedDate=result.modified_date, + todo=result.todo, + complete=result.complete + ) + return _todo + + def model_from_schema(self, todo: Todo) -> TodoModel: + _todo_model = TodoModel( + todo=todo.todo, + complete=todo.complete + ) + return _todo_model + + def get_todos(self) -> List[Todo]: + """Retrieves a list of `Todo` representations""" + results = self.session.query(TodoModel).order_by(TodoModel.id).all() + todos = [self.schema_from_model(result) for result in results] + return todos + + def get_todo_by_id(self, id) -> Todo: + """Retrieves a `Todo` representation by id""" + result = self.session.query(TodoModel).filter_by(id=id).one_or_none() + if result is None: + raise EntityNotFound(f"Todo: {id} does not exist") + return self.schema_from_model(result) + + def create_todo(self, todo: Todo) -> Todo: + """Creates a new `Todo` resource and returns its representation""" + todo_model = self.model_from_schema(todo) + self.session.add(todo_model) + self.session.flush() + return self.schema_from_model(todo_model) + + def update_todo(self, todo_id: int, todo: Todo) -> Todo: + """Updates an existing `Todo` resource and returns its new representation""" + result = self.session.query(TodoModel).filter_by(id=todo_id).one_or_none() + if result is None: + raise EntityNotFound(f"Todo: {todo_id} does not exist") + updates = self.model_from_schema(todo) + updates.id = todo_id + self.session.merge(updates) + self.session.flush() + todo = self.schema_from_model(result) + return todo + + def delete_todo(self, id): + """Deletes a `Todo` """ + result = self.session.query(TodoModel).filter_by(id=id).one_or_none() + if result is not None: + self.session.delete(result) + return + + +class TodoManagerComponent: + is_cacheable = True + is_singleton = False + + def can_handle_parameter(self, parameter: Parameter) -> bool: + return parameter.annotation is TodoManager + + def resolve(self, session: Session, app: BaseApp) -> TodoManager: # type: ignore + return TodoManager(session, app) diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/api/todo/model.py b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/api/todo/model.py new file mode 100644 index 0000000..4b8e39f --- /dev/null +++ b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/api/todo/model.py @@ -0,0 +1,21 @@ +from typing import Optional +from molten import schema, field +from sqlalchemy import Column, Text, Boolean +from {{cookiecutter.project_slug}}.db import Base, DBMixin +from {{cookiecutter.project_slug}}.schema import Link + + +@schema +class Todo: + id: int = field(response_only=True) + createdDate: str = field(response_only=True) + modifiedDate: str = field(response_only=True) + todo: str + complete: Optional[bool] + href: Link = field(response_only=True) + + +class TodoModel(Base, DBMixin): + __tablename__ = "todo" + todo = Column(Text) + complete = Column(Boolean, default=False) diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/api/todo/view.py b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/api/todo/view.py new file mode 100644 index 0000000..04b5166 --- /dev/null +++ b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/api/todo/view.py @@ -0,0 +1,49 @@ +from typing import List +from molten import Route, Include, HTTP_201, HTTP_202, HTTPError, HTTP_404 + +from {{cookiecutter.project_slug}}.schema import APIResponse +from {{cookiecutter.project_slug}}.error import EntityNotFound +from .model import Todo +from .manager import TodoManager + + +def list_todos(todo_manager: TodoManager) -> List[Todo]: + return todo_manager.get_todos() + + +def create_todo(todo: Todo, todo_manager: TodoManager) -> Todo: + _todo = todo_manager.create_todo(todo) + headers = {"Location": _todo.href} + return HTTP_201, _todo, headers + + +def delete_todo(todo_id: int, todo_manager: TodoManager): + todo_manager.delete_todo(todo_id) + return ( + HTTP_202, + APIResponse(status=202, message=f"Delete request for store: {todo_id} accepted"), + ) + + +def get_todo_by_id(todo_id: int, todo_manager: TodoManager) -> Todo: + try: + _todo = todo_manager.get_todo_by_id(todo_id) + except EntityNotFound as err: + raise HTTPError(HTTP_404, + APIResponse(status=404, + message=err.message) + ) + return _todo + + +def update_todo(todo_id: int, todo: Todo, todo_manager: TodoManager) -> Todo: + return todo_manager.update_todo(todo_id, todo) + + +todo_routes = Include("/todos", [ + Route("", list_todos, method="GET"), + Route("", create_todo, method="POST"), + Route("/{todo_id}", delete_todo, method="DELETE"), + Route("/{todo_id}", get_todo_by_id, method="GET"), + Route("/{todo_id}", update_todo, method="PATCH") +]) diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/db.py b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/db.py new file mode 100644 index 0000000..7cff90f --- /dev/null +++ b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/db.py @@ -0,0 +1,32 @@ +from sqlalchemy import Column, BigInteger, DateTime +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.schema import CreateColumn +from sqlalchemy.sql import expression +from sqlalchemy.types import DateTime as DatetimeType + +Base = declarative_base() + + +class utcnow(expression.FunctionElement): + type = DatetimeType() + + +@compiles(utcnow, "postgresql") +def pg_utcnow(element, compiler, **kw): + return "TIMEZONE('utc', CURRENT_TIMESTAMP)" + + +@compiles(CreateColumn, 'postgresql') +def use_identity(element, compiler, **kw): + text = compiler.visit_create_column(element, **kw) + text = text.replace("SERIAL", "INT GENERATED BY DEFAULT AS IDENTITY") + return text + + +class DBMixin: + id = Column(BigInteger, primary_key=True) + created_date = Column(DateTime(timezone=True), server_default=utcnow()) + modified_date = Column( + DateTime(timezone=True), server_default=utcnow(), onupdate=utcnow() + ) diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/error.py b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/error.py new file mode 100644 index 0000000..2c29f4a --- /dev/null +++ b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/error.py @@ -0,0 +1,5 @@ +from molten.errors import MoltenError + + +class EntityNotFound(MoltenError): + """Raised when an entity is not found using an `exists` check in sqlalchemy.""" diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/index.py b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/index.py index 00c1963..305e8ae 100644 --- a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/index.py +++ b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/index.py @@ -1,24 +1,39 @@ +import os from typing import Tuple from molten import App, Route, ResponseRendererMiddleware from molten.http import HTTP_404, Request from molten.settings import Settings, SettingsComponent -from molten.contrib.sqlalchemy import SQLAlchemyMiddleware +from molten.contrib.sqlalchemy import SQLAlchemyMiddleware, SQLAlchemyEngineComponent, SQLAlchemySessionComponent from .api.welcome import welcome +from .api.todo import TodoManagerComponent, todo_routes from .common import ExtJSONRenderer from .schema import APIResponse -settings = Settings({}) +settings = Settings( + { + "database_engine_dsn": os.getenv( + "DATABASE_DSN", "postgresql://molten:local@localhost/cookiecutter" + ), + "database_engine_params": { + "echo": True, + "connect_args": {"options": "-c timezone=utc"}, + }, + } +) components = [ SettingsComponent(settings), + SQLAlchemyEngineComponent(), + SQLAlchemySessionComponent(), + TodoManagerComponent(), ] middleware = [ResponseRendererMiddleware(), SQLAlchemyMiddleware()] renderers = [ExtJSONRenderer()] -routes = [Route("/", welcome, "GET")] +routes = [Route("/", welcome, "GET")] + [todo_routes] class ExtApp(App): diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/manager.py b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/manager.py new file mode 100644 index 0000000..96ac713 --- /dev/null +++ b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/manager.py @@ -0,0 +1,32 @@ +from abc import ABCMeta, abstractmethod +from molten import BaseApp, HTTPError, HTTP_409, HTTP_404 +from sqlalchemy.orm import Session + + +class BaseManager(metaclass=ABCMeta): + """Base instance for Model managers""" + + def __init__(self, session: Session, app: BaseApp): + self.session = session + self.app = app + + @abstractmethod + def model_from_schema(self, schema): + """Converts a Schema instance into a SQLAlchemy ORM model instance""" + pass + + @abstractmethod + def schema_from_model(self, result): + """Converts a SQLAlchemy results proxy into a Schema instance""" + pass + + def raise_409(self, id:int): + """Raises a 409 HTTP error response in the event of Conflict""" + raise HTTPError( + HTTP_409, + { + "status": 409, + "message": f"Entity {self.__class__.__name__} with id: {id} already exists" + } + + )