Initial commit
commit
f9c9146190
@ -0,0 +1,73 @@
|
||||
# virtualenv
|
||||
env
|
||||
|
||||
# Pycharm
|
||||
.idea
|
||||
|
||||
# ---> Python
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
env/
|
||||
pypyenv/
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*,cover
|
||||
.pytest_cache/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# Redis
|
||||
/dump.rdb
|
||||
|
||||
# Project
|
||||
config.py
|
||||
|
||||
# Mac
|
||||
.DS_Store
|
@ -0,0 +1,16 @@
|
||||
# Market Look
|
||||
|
||||
A application of tracking in store promotion compliance at a store.
|
||||
|
||||
## API
|
||||
|
||||
This api returns resource representations with `Content Type` of `application/json`. No other mime types are supported by this api at this time.
|
||||
|
||||
### Date times
|
||||
When a date time is provided with a representation it will follow the ISO 8601 extend format for easy human readability.
|
||||
|
||||
### Features
|
||||
|
||||
- [ ] Resource expansion.
|
||||
- [ ] JWT Authentication
|
||||
|
@ -0,0 +1,5 @@
|
||||
black
|
||||
pip-tools
|
||||
pytest
|
||||
pytest-cov
|
||||
werkzeug
|
@ -0,0 +1,25 @@
|
||||
#
|
||||
# This file is autogenerated by pip-compile
|
||||
# To update, run:
|
||||
#
|
||||
# pip-compile --output-file dev_requirements.txt dev_requirements.in
|
||||
#
|
||||
--trusted-host pypi.python.org
|
||||
--trusted-host git.aigalatic.com
|
||||
|
||||
appdirs==1.4.3 # via black
|
||||
atomicwrites==1.1.5 # via pytest
|
||||
attrs==18.1.0 # via black, pytest
|
||||
black==18.6b4
|
||||
click==6.7 # via black, pip-tools
|
||||
coverage==4.5.1 # via pytest-cov
|
||||
first==2.0.1 # via pip-tools
|
||||
more-itertools==4.3.0 # via pytest
|
||||
pip-tools==2.0.2
|
||||
pluggy==0.7.1 # via pytest
|
||||
py==1.5.4 # via pytest
|
||||
pytest-cov==2.5.1
|
||||
pytest==3.7.1
|
||||
six==1.11.0 # via more-itertools, pip-tools, pytest
|
||||
toml==0.9.4 # via black
|
||||
werkzeug==0.14.1
|
@ -0,0 +1,230 @@
|
||||
import os
|
||||
from typing import List, Tuple, Any, Dict, Optional
|
||||
from molten import (
|
||||
annotate,
|
||||
App,
|
||||
Request,
|
||||
QueryParam,
|
||||
Route,
|
||||
Settings,
|
||||
SettingsComponent,
|
||||
ResponseRendererMiddleware,
|
||||
HTTP_201,
|
||||
HTTP_202,
|
||||
HTTP_404,
|
||||
HTTPError,
|
||||
)
|
||||
from molten.openapi import Metadata, OpenAPIHandler, OpenAPIUIHandler
|
||||
from molten.contrib.sqlalchemy import (
|
||||
SQLAlchemyEngineComponent,
|
||||
SQLAlchemySessionComponent,
|
||||
SQLAlchemyMiddleware,
|
||||
)
|
||||
|
||||
from .errors import EntityNotFound
|
||||
|
||||
from .manager import (
|
||||
AlertManager,
|
||||
StoreManager,
|
||||
GeographyManager,
|
||||
AlertManagerComponent,
|
||||
StoreManagerComponent,
|
||||
GeographyManagerComponent,
|
||||
)
|
||||
from .schema import Alert, Store, Geography, APIResponse
|
||||
from .util import ExtJSONRender
|
||||
|
||||
|
||||
class MarketLook(App):
|
||||
def handle_404(self, request: Request) -> Tuple[str, APIResponse]:
|
||||
return (
|
||||
HTTP_404,
|
||||
APIResponse(
|
||||
status=404,
|
||||
message=f"The resource you are looking for {request.scheme}://{request.host}{request.path} doesn't exist",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# We are passing explicitly the engine param for
|
||||
# establishing the utc as the timezone for our connection.
|
||||
settings = Settings(
|
||||
{
|
||||
"database_engine_dsn": os.getenv(
|
||||
"DATABASE_DSN", "postgres://molten:local@localhost/market_look"
|
||||
),
|
||||
"database_engine_params": {
|
||||
"echo": True,
|
||||
"connect_args": {"options": "-c timezone=utc"},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
get_schema = OpenAPIHandler(
|
||||
metadata=Metadata(
|
||||
title="Market Look API",
|
||||
description="An API for managing promotion compliance at store.",
|
||||
version="0.1.0",
|
||||
)
|
||||
)
|
||||
|
||||
get_docs = OpenAPIUIHandler()
|
||||
setattr(get_docs, "openapi_tags", ["API Management"])
|
||||
|
||||
|
||||
def name(name: Optional[QueryParam]) -> APIResponse:
|
||||
_name = name or "Molten"
|
||||
return APIResponse(
|
||||
status=200, message=f"Hello, {_name}! Glad you are programming with us"
|
||||
)
|
||||
|
||||
|
||||
def ping() -> APIResponse:
|
||||
return APIResponse(200, "Pong")
|
||||
|
||||
|
||||
@annotate(openapi_tags=["Stores"])
|
||||
def get_stores(store_manager: StoreManager) -> List[Store]:
|
||||
"""Returns a collection of Stores"""
|
||||
stores = store_manager.get_stores()
|
||||
return stores
|
||||
|
||||
|
||||
@annotate(openapi_tags=["Stores"])
|
||||
def create_store(
|
||||
store: Store, store_manager: StoreManager
|
||||
) -> Tuple[Any, Store, Dict[str, str]]:
|
||||
"""Creates a new store resource and returns its respresentation"""
|
||||
try:
|
||||
_store = store_manager.create_store(store)
|
||||
except HTTPError as err:
|
||||
raise err
|
||||
headers = {"Location": _store.href}
|
||||
return HTTP_201, _store, headers
|
||||
|
||||
|
||||
@annotate(openapi_tags=["Stores"])
|
||||
def get_store_by_id(id: int, store_manager: StoreManager) -> Store:
|
||||
_store = store_manager.get_store_by_id(id)
|
||||
|
||||
if _store is None:
|
||||
raise HTTPError(
|
||||
HTTP_404,
|
||||
{
|
||||
"status": 404,
|
||||
"message": f"The resource you are looking for /stores/{id} does not exist",
|
||||
},
|
||||
)
|
||||
|
||||
return _store
|
||||
|
||||
|
||||
@annotate(openapi_tags=["Stores"])
|
||||
def update_store(store: Store, store_manager: StoreManager) -> Store:
|
||||
_updated_store = store_manager.update_store(store)
|
||||
return _updated_store
|
||||
|
||||
|
||||
@annotate(openapi_tags=["Stores"])
|
||||
def delete_store_by_id(id: int, store_manager: StoreManager) -> Tuple[Any, APIResponse]:
|
||||
store_manager.delete_store_by_id(id)
|
||||
return (
|
||||
HTTP_202,
|
||||
APIResponse(status=202, message=f"Delete request for store: {id} accepted"),
|
||||
)
|
||||
|
||||
|
||||
@annotate(openapi_tags=["Geographies"])
|
||||
def get_geographies(geo_manager: GeographyManager) -> List[Geography]:
|
||||
_geographies = geo_manager.get_geographies()
|
||||
return _geographies
|
||||
|
||||
|
||||
@annotate(openapi_tags=["Geographies"])
|
||||
def get_geo_by_id(id: int, geo_manager: GeographyManager) -> Geography:
|
||||
try:
|
||||
_geo = geo_manager.get_geo_by_id(id=id)
|
||||
except HTTPError as err:
|
||||
raise err
|
||||
|
||||
return _geo
|
||||
|
||||
|
||||
@annotate(openapi_tags=["Geographies"])
|
||||
def get_geo_stores(id: int, store_manager: StoreManager) -> List[Store]:
|
||||
stores = store_manager.get_stores_by_geo(geo_id=id)
|
||||
return stores
|
||||
|
||||
|
||||
@annotate(openapi_tags=["Alerts"])
|
||||
def get_alerts(alert_manager: AlertManager) -> List[Alert]:
|
||||
alerts = alert_manager.get_alerts()
|
||||
return alerts
|
||||
|
||||
|
||||
@annotate(openapi_tags=["Alerts"])
|
||||
def get_alert_by_id(id: int, alert_manager: AlertManager) -> Alert:
|
||||
try:
|
||||
alert = alert_manager.get_alert_by_id(alert_id=id)
|
||||
except EntityNotFound as err:
|
||||
raise HTTPError(HTTP_404, APIResponse(status=404, message=err.message))
|
||||
return alert
|
||||
|
||||
|
||||
@annotate(openapi_tags=["Alerts"])
|
||||
def get_store_alerts(id: int, alert_manager: AlertManager) -> List[Alert]:
|
||||
try:
|
||||
alerts = alert_manager.get_alerts_at_store(store_id=id)
|
||||
except EntityNotFound as err:
|
||||
raise HTTPError(HTTP_404, APIResponse(status=404, message=err.message))
|
||||
return alerts
|
||||
|
||||
|
||||
@annotate(openapi_tags=["Alerts"])
|
||||
def get_geo_alerts(id: int, alert_manager: AlertManager) -> List[Alert]:
|
||||
try:
|
||||
alerts = alert_manager.get_alerts_at_geo(geo_id=id)
|
||||
except EntityNotFound as err:
|
||||
raise HTTPError(HTTP_404, APIResponse(status=404, message=err.message))
|
||||
return alerts
|
||||
|
||||
|
||||
routes = [
|
||||
Route("/", method="GET", handler=name),
|
||||
Route("/ping", method="GET", handler=ping),
|
||||
# Stores
|
||||
Route("/stores", method="GET", handler=get_stores),
|
||||
Route("/stores", method="POST", handler=create_store),
|
||||
Route("/stores/{id}", method="GET", handler=get_store_by_id),
|
||||
Route("/stores/{id}", method="PUT", handler=update_store),
|
||||
Route("/stores/{id}", method="DELETE", handler=delete_store_by_id),
|
||||
Route("/geographies/{id}/stores", method="GET", handler=get_geo_stores),
|
||||
# Geographies
|
||||
Route("/geographies", method="GET", handler=get_geographies),
|
||||
Route("/geographies/{id}", method="GET", handler=get_geo_by_id),
|
||||
# Alerts
|
||||
Route("/alerts", method="GET", handler=get_alerts),
|
||||
Route("/alerts/{id}", method="GET", handler=get_alert_by_id),
|
||||
Route("/stores/{id}/alerts", method="GET", handler=get_store_alerts),
|
||||
Route("/geographies/{id}/alerts", method="GET", handler=get_geo_alerts),
|
||||
# OpenAPI
|
||||
Route("/_schema", get_schema),
|
||||
Route("/docs", get_docs),
|
||||
]
|
||||
|
||||
components = [
|
||||
SettingsComponent(settings),
|
||||
SQLAlchemyEngineComponent(),
|
||||
SQLAlchemySessionComponent(),
|
||||
StoreManagerComponent(),
|
||||
GeographyManagerComponent(),
|
||||
AlertManagerComponent()
|
||||
]
|
||||
|
||||
middleware = [ResponseRendererMiddleware(), SQLAlchemyMiddleware()]
|
||||
|
||||
renderers = [ExtJSONRender()]
|
||||
|
||||
app = MarketLook(
|
||||
routes=routes, components=components, middleware=middleware, renderers=renderers
|
||||
)
|
@ -0,0 +1,87 @@
|
||||
from decimal import Decimal
|
||||
|
||||
import click
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from werkzeug.serving import run_simple
|
||||
from .app import app, settings
|
||||
from .model import Base, UserModel, GeographyModel, StoreModel, AlertModel
|
||||
|
||||
engine = create_engine(settings.get("database_engine_dsn"))
|
||||
|
||||
Session = sessionmaker(bind=engine)
|
||||
|
||||
|
||||
@click.group()
|
||||
def cli():
|
||||
pass
|
||||
|
||||
|
||||
@click.command()
|
||||
def serve(host="0.0.0.0", port=8080, debug=True, use_reloader=True):
|
||||
run_simple(host, port, app, use_debugger=debug, use_reloader=use_reloader)
|
||||
|
||||
|
||||
@click.command()
|
||||
def initdb():
|
||||
click.echo("Creating database")
|
||||
Base.metadata.create_all(bind=engine)
|
||||
click.echo("Database created")
|
||||
|
||||
|
||||
@click.command()
|
||||
def dropdb():
|
||||
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)
|
||||
click.echo("Database dropped")
|
||||
else:
|
||||
click.echo("Database drop aborted")
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("email", type=str)
|
||||
@click.argument("passwd", type=str)
|
||||
def adduser(email, passwd):
|
||||
user = UserModel(email=email, password=passwd, admin=False)
|
||||
session = Session()
|
||||
session.add(user)
|
||||
session.commit()
|
||||
click.echo(f"New user {user.email} has been created")
|
||||
session.close()
|
||||
|
||||
|
||||
@click.command()
|
||||
def testdata():
|
||||
session = Session()
|
||||
user = UserModel(email="test@kellogg.com", password="Welcome1")
|
||||
geography = GeographyModel(id=500_000, name="TEST", user=user)
|
||||
store = StoreModel(
|
||||
id=9_000_000_000,
|
||||
name="SUPER TEST STORE 4000",
|
||||
number="4000",
|
||||
address="123 FAKE STREET",
|
||||
city="PORTAGE",
|
||||
zip="49024",
|
||||
state="MI",
|
||||
lat=Decimal("42.2607409"),
|
||||
long=Decimal("-85.6121787"),
|
||||
tdlinx="0123456",
|
||||
geography=geography,
|
||||
)
|
||||
alert = AlertModel(store=store, promo_name="BATMAN CHEEZIT")
|
||||
session.add_all([user, geography, store, alert])
|
||||
session.commit()
|
||||
click.echo("Test Data created")
|
||||
session.close()
|
||||
|
||||
|
||||
cli.add_command(serve)
|
||||
cli.add_command(initdb)
|
||||
cli.add_command(dropdb)
|
||||
cli.add_command(adduser)
|
||||
cli.add_command(testdata)
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
@ -0,0 +1,9 @@
|
||||
from molten.errors import MoltenError
|
||||
|
||||
|
||||
class MarketLookError(MoltenError):
|
||||
"""Base class for MarketLook errors"""
|
||||
|
||||
|
||||
class EntityNotFound(MarketLookError):
|
||||
"""Raised when an entity is not found using an `exists` check in sqlalchemy."""
|
@ -0,0 +1,261 @@
|
||||
from abc import ABCMeta, abstractmethod
|
||||
from inspect import Parameter
|
||||
import typing
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from molten import BaseApp, HTTPError, HTTP_409, HTTP_404
|
||||
from .errors import EntityNotFound
|
||||
from .model import StoreModel, GeographyModel, AlertModel
|
||||
from .schema import Alert, Geography, Link, Store, APIResponse
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
class StoreManager(BaseManager):
|
||||
"""A `StoreManager` is accountable for the CRUD operations associated with a Store"""
|
||||
|
||||
def _schema_from_model(self, result: StoreModel) -> Store:
|
||||
_store = Store(
|
||||
id=result.id,
|
||||
href=self.app.reverse_uri("get_store_by_id", id=result.id),
|
||||
createdDate=result.created_date,
|
||||
modifiedDate=result.modified_date,
|
||||
name=result.name,
|
||||
number=result.number,
|
||||
address=result.address,
|
||||
city=result.city,
|
||||
state=result.state,
|
||||
zip=result.zip,
|
||||
lat=result.lat,
|
||||
long=result.long,
|
||||
tdlinx=result.tdlinx,
|
||||
geography=Link(
|
||||
href=self.app.reverse_uri("get_geo_by_id", id=result.geography_id)
|
||||
)
|
||||
if result.geography_id
|
||||
else None,
|
||||
alerts=Link(
|
||||
href=self.app.reverse_uri("get_store_alerts", id=result.id)
|
||||
)
|
||||
)
|
||||
return _store
|
||||
|
||||
def _model_from_schema(self, store: Store) -> StoreModel:
|
||||
_store_model = StoreModel(
|
||||
id=store.id,
|
||||
name=store.name,
|
||||
number=store.number,
|
||||
address=store.address,
|
||||
city=store.city,
|
||||
state=store.state,
|
||||
zip=store.zip,
|
||||
lat=store.lat,
|
||||
long=store.long,
|
||||
tdlinx=store.tdlinx,
|
||||
)
|
||||
return _store_model
|
||||
|
||||
def get_stores(self) -> typing.List[Store]:
|
||||
"""Retrieves a list of Store representations"""
|
||||
results = self.session.query(StoreModel).order_by(StoreModel.id).all()
|
||||
_stores = [self._schema_from_model(result) for result in results]
|
||||
return _stores
|
||||
|
||||
def get_store_by_id(self, id: int) -> Store:
|
||||
"""Retrieves a store representation by id"""
|
||||
result = self.session.query(StoreModel).filter_by(id=id).one_or_none()
|
||||
if result is None:
|
||||
return result
|
||||
|
||||
_store = self._schema_from_model(result)
|
||||
|
||||
return _store
|
||||
|
||||
def create_store(self, store: Store) -> Store:
|
||||
"""Creates a new store resource and returns its representation"""
|
||||
result = self.session.query(StoreModel).filter_by(id=store.id).one_or_none()
|
||||
if result is not None:
|
||||
raise HTTPError(
|
||||
HTTP_409,
|
||||
{
|
||||
"status": 409,
|
||||
"message": f"A store with id: {store.id} already exists",
|
||||
},
|
||||
)
|
||||
|
||||
store_model = self._model_from_schema(store)
|
||||
|
||||
self.session.add(store_model)
|
||||
self.session.flush()
|
||||
|
||||
_store = self._schema_from_model(store_model)
|
||||
|
||||
return _store
|
||||
|
||||
def update_store(self, store: Store) -> Store:
|
||||
result = self.session.query(StoreModel).filter_by(id=store.id).one_or_none()
|
||||
_updates = self._model_from_schema(store)
|
||||
self.session.merge(_updates)
|
||||
self.session.flush()
|
||||
_store = self._schema_from_model(result)
|
||||
return _store
|
||||
|
||||
def delete_store_by_id(self, id):
|
||||
_store = self.session.query(StoreModel).filter_by(id=id).one_or_none()
|
||||
if _store is not None:
|
||||
self.session.delete(_store)
|
||||
return
|
||||
|
||||
def get_stores_by_geo(self, geo_id) -> typing.List[Store]:
|
||||
results = self.session.query(StoreModel).filter_by(geography_id=geo_id).all()
|
||||
_stores = [self._schema_from_model(store) for store in results]
|
||||
return _stores
|
||||
|
||||
|
||||
class GeographyManager(BaseManager):
|
||||
|
||||
def _model_from_schema(self, schema: Geography) -> GeographyModel:
|
||||
pass
|
||||
|
||||
def _schema_from_model(self, result: GeographyModel) -> Geography:
|
||||
_geography = Geography(
|
||||
id=result.id,
|
||||
href=self.app.reverse_uri("get_geo_by_id", id=result.id),
|
||||
createdDate=result.created_date,
|
||||
modifiedDate=result.modified_date,
|
||||
name=result.name,
|
||||
stores=Link(href=self.app.reverse_uri('get_geo_stores', id=result.id)),
|
||||
alerts=Link(href=self.app.reverse_uri('get_geo_alerts', id=result.id)),
|
||||
)
|
||||
return _geography
|
||||
|
||||
def get_geographies(self) -> typing.List[Geography]:
|
||||
results = self.session.query(GeographyModel).all()
|
||||
return [self._schema_from_model(geo) for geo in results]
|
||||
|
||||
def get_geo_by_id(self, id: int) -> Geography:
|
||||
result = self.session.query(GeographyModel).one_or_none()
|
||||
if result is None:
|
||||
raise HTTPError(
|
||||
HTTP_404,
|
||||
APIResponse(
|
||||
status=404,
|
||||
message=f"The resource you are looking for /geographies/{id} does not exist",
|
||||
),
|
||||
)
|
||||
|
||||
return self._schema_from_model(result)
|
||||
|
||||
|
||||
class AlertManager(BaseManager):
|
||||
|
||||
def _model_from_schema(self, schema: Alert) -> AlertModel:
|
||||
_alert_model = AlertModel(
|
||||
promo_name=schema.promoName,
|
||||
response=schema.response,
|
||||
valid=schema.valid
|
||||
)
|
||||
|
||||
def _schema_from_model(self, result: AlertModel) -> Alert:
|
||||
_alert = Alert(
|
||||
id=result.id,
|
||||
href=self.app.reverse_uri('get_alert_by_id', id=result.id),
|
||||
createdDate=result.created_date,
|
||||
modifiedDate=result.modified_date,
|
||||
promoName=result.promo_name,
|
||||
response=result.response,
|
||||
valid=result.valid,
|
||||
store=Link(href=self.app.reverse_uri('get_store_by_id', id=result.store.id))
|
||||
)
|
||||
return _alert
|
||||
|
||||
def get_alerts(self) -> typing.List[Alert]:
|
||||
results = self.session.query(AlertModel).all()
|
||||
alerts = [self._schema_from_model(alert) for alert in results]
|
||||
return alerts
|
||||
|
||||
def get_alert_by_id(self, alert_id) -> Alert:
|
||||
result = self.session.query(AlertModel).filter_by(id=alert_id).one_or_none()
|
||||
if result is None:
|
||||
raise EntityNotFound(f"Alert: {alert_id} does not exist")
|
||||
alert = self._schema_from_model(result)
|
||||
return alert
|
||||
|
||||
def get_alerts_at_store(self, store_id) -> typing.List[Alert]:
|
||||
store_check = self.session.query(StoreModel.id).filter_by(id=store_id).exists()
|
||||
|
||||
if not self.session.query(store_check).scalar():
|
||||
raise EntityNotFound(f'Store {store_id} does not exist')
|
||||
|
||||
results = self.session.query(AlertModel).filter_by(store_id=store_id, active=True).all()
|
||||
alerts = [self._schema_from_model(alert) for alert in results]
|
||||
return alerts
|
||||
|
||||
def get_alerts_at_geo(self, geo_id) -> typing.List[Alert]:
|
||||
geo_check = self.session.query(GeographyModel.id).filter_by(id=geo_id).exists()
|
||||
|
||||
if not self.session.query(geo_check).scalar():
|
||||
raise EntityNotFound(f'Geography {geo_id} does not exist')
|
||||
|
||||
_subquery = self.session.query(StoreModel.id).filter(StoreModel.geography_id==geo_id)
|
||||
results = self.session.query(AlertModel).filter(AlertModel.store_id.in_(_subquery)).all()
|
||||
alerts = [self._schema_from_model(alert) for alert in results]
|
||||
return alerts
|
||||
|
||||
def create_alert_at_store(self, store_id, alert: Alert) -> Alert:
|
||||
store = self.session.query(StoreModel).filter_by(id=store_id).one()
|
||||
alert_model = self._model_from_schema(alert)
|
||||
alert_model.store = store
|
||||
self.session.add(alert_model)
|
||||
self.session.flush()
|
||||
_alert = self._schema_from_model(alert_model)
|
||||
return _alert
|
||||
|
||||
|
||||
class StoreManagerComponent:
|
||||
is_cacheable = True
|
||||
is_singleton = False
|
||||
|
||||
def can_handle_parameter(self, parameter: Parameter) -> bool:
|
||||
return parameter.annotation is StoreManager
|
||||
|
||||
def resolve(self, session: Session, app: BaseApp) -> StoreManager: # type: ignore
|
||||
return StoreManager(session, app)
|
||||
|
||||
|
||||
class GeographyManagerComponent:
|
||||
is_cacheable = True
|
||||
is_singleton = False
|
||||
|
||||
def can_handle_parameter(self, parameter: Parameter) -> bool:
|
||||
return parameter.annotation is GeographyManager
|
||||
|
||||
def resolve(self, session: Session, app: BaseApp) -> StoreManager: # type: ignore
|
||||
return GeographyManager(session, app)
|
||||
|
||||
|
||||
class AlertManagerComponent:
|
||||
is_cacheable = True
|
||||
is_singleton = False
|
||||
|
||||
def can_handle_parameter(self, parameter: Parameter) -> bool:
|
||||
return parameter.annotation is AlertManager
|
||||
|
||||
def resolve(self, session: Session, app: BaseApp) -> AlertManager: # type: ignore
|
||||
return AlertManager(session, app)
|
@ -0,0 +1,103 @@
|
||||
import bcrypt
|
||||
from sqlalchemy import (
|
||||
Column,
|
||||
Integer,
|
||||
BigInteger,
|
||||
Numeric,
|
||||
String,
|
||||
ForeignKey,
|
||||
DateTime,
|
||||
Boolean,
|
||||
Enum,
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import expression
|
||||
from sqlalchemy.ext.compiler import compiles
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.types import DateTime as DatetimeType
|
||||
|
||||
BCRYPT_LOG_ROUNDS = 11
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
class utcnow(expression.FunctionElement):
|
||||
type = DatetimeType()
|
||||
|
||||
|
||||
@compiles(utcnow, "postgresql")
|
||||
def pg_utcnow(element, compiler, **kw):
|
||||
return "TIMEZONE('utc', CURRENT_TIMESTAMP)"
|
||||
|
||||
|
||||
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()
|
||||
)
|
||||
|
||||
|
||||
class UserModel(DBMixin, Base):
|
||||
__tablename__ = "user"
|
||||
|
||||
email = Column(String(255), nullable=False, unique=True)
|
||||
password = Column(String(255))
|
||||
geography = relationship("GeographyModel", back_populates="user")
|
||||
admin = Column(Boolean, nullable=False, default=False)
|
||||
confirmed = Column(Boolean, nullable=False, default=False)
|
||||
active = Column(Boolean, nullable=False, default=True)
|
||||
|
||||
def __init__(self, email, password, admin=False):
|
||||
self.email = email
|
||||
self.password = bcrypt.hashpw(
|
||||
password.encode("utf-8"), bcrypt.gensalt(BCRYPT_LOG_ROUNDS)
|
||||
).decode()
|
||||
self.admin = admin
|
||||
|
||||
def check_password(self, password):
|
||||
return bcrypt.checkpw(password.encode("utf-8"), self.password.encode("utf-8"))
|
||||
|
||||
|
||||
class GeographyModel(Base, DBMixin):
|
||||
__tablename__ = "geography"
|
||||
|
||||
name = Column(String(255))
|
||||
user_id = Column(BigInteger, ForeignKey("user.id"), nullable=True)
|
||||
active = Column(Boolean, nullable=False, default=True)
|
||||
user = relationship("UserModel", uselist=False, back_populates="geography")
|
||||
stores = relationship(
|
||||
"StoreModel", order_by="StoreModel.id", back_populates="geography"
|
||||
)
|
||||
|
||||
|
||||
class StoreModel(Base, DBMixin):
|
||||
__tablename__ = "store"
|
||||
|
||||
geography_id = Column(BigInteger, ForeignKey("geography.id"), nullable=True)
|
||||
name = Column(String(255))
|
||||
number = Column(String(4), nullable=True)
|
||||
address = Column(String(255))
|
||||
city = Column(String(255))
|
||||
state = Column(String(2))
|
||||
zip = Column(String(12))
|
||||
lat = Column(Numeric(10, 7), nullable=True)
|
||||
long = Column(Numeric(10, 7), nullable=True)
|
||||
tdlinx = Column(String(8), nullable=True)
|
||||
active = Column(Boolean, nullable=False, default=True)
|
||||
geography = relationship("GeographyModel", back_populates="stores")
|
||||
alerts = relationship("AlertModel", back_populates="store")
|
||||
|
||||
|
||||
class AlertModel(Base, DBMixin):
|
||||
__tablename__ = "alert"
|
||||
|
||||
store_id = Column(BigInteger, ForeignKey("store.id"))
|
||||
store = relationship("StoreModel", back_populates="alerts")
|
||||
promo_name = Column(String(255))
|
||||
response = Column(
|
||||
Enum("", "manager_refused", "valid", "invalid", name="response_type")
|
||||
)
|
||||
responded = Column(Boolean, default=False)
|
||||
valid = Column(Boolean, nullable=True)
|
||||
active = Column(Boolean, nullable=False, default=True)
|
@ -0,0 +1,79 @@
|
||||
from typing import Optional, List, Union
|
||||
from molten import schema, field
|
||||
|
||||
|
||||
@schema
|
||||
class Link:
|
||||
href: str
|
||||
|
||||
|
||||
@schema
|
||||
class Geography:
|
||||
id: int
|
||||
href: str = field(response_only=True)
|
||||
createdDate: str = field(response_only=True)
|
||||
modifiedDate: str = field(response_only=True)
|
||||
name: str
|
||||
stores: Link = field(response_only=True)
|
||||
alerts: Link = field(response_only=True)
|
||||
|
||||
|
||||
@schema
|
||||
class Store:
|
||||
id: int
|
||||
href: str = field(response_only=True)
|
||||
createdDate: str = field(response_only=True)
|
||||
modifiedDate: str = field(response_only=True)
|
||||
name: str
|
||||
number: Optional[str]
|
||||
address: str
|
||||
city: str
|
||||
state: str
|
||||
zip: str
|
||||
lat: Optional[float]
|
||||
long: Optional[float]
|
||||
tdlinx: Optional[str]
|
||||
geography: Link = field(response_only=True)
|
||||
alerts: Link = field(response_only=True)
|
||||
|
||||
|
||||
@schema
|
||||
class User:
|
||||
id: int = field(response_only=True)
|
||||
href: str = field(response_only=True)
|
||||
email: str
|
||||
password: str = field(request_only=True)
|
||||
geography: Link = field(response_only=True)
|
||||
|
||||
|
||||
@schema
|
||||
class Alert:
|
||||
id: int
|
||||
href: str = field(response_only=True)
|
||||
createdDate: str = field(response_only=True)
|
||||
modifiedDate: str = field(response_only=True)
|
||||
promoName: str
|
||||
response: Optional[str]
|
||||
valid: Optional[bool]
|
||||
store: Link = field(response_only=True)
|
||||
|
||||
|
||||
@schema
|
||||
class StorePatch:
|
||||
name: Optional[str]
|
||||
number: Optional[str]
|
||||
address: Optional[str]
|
||||
city: Optional[str]
|
||||
state: Optional[str]
|
||||
zip: Optional[str]
|
||||
lat: Optional[float]
|
||||
long: Optional[float]
|
||||
tdlinx: Optional[str]
|
||||
|
||||
|
||||
@schema
|
||||
class APIResponse:
|
||||
status: int = field(description="An HTTP status code")
|
||||
message: str = field(
|
||||
description="A user presentable message in response to the request provided to the API"
|
||||
)
|
@ -0,0 +1,21 @@
|
||||
import datetime as dt
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
from molten import JSONRenderer, is_schema, dump_schema
|
||||
|
||||
|
||||
class ExtJSONRender(JSONRenderer):
|
||||
"""JSON Render with support for ISO 8601 datetime format strings"""
|
||||
|
||||
def default(self, ob: Any) -> Any:
|
||||
"""You may override this when subclassing the JSON renderer in
|
||||
order to encode non-standard object types.
|
||||
"""
|
||||
if is_schema(type(ob)):
|
||||
return dump_schema(ob)
|
||||
if isinstance(ob, dt.datetime):
|
||||
return ob.isoformat()
|
||||
if isinstance(ob, Decimal):
|
||||
return float(ob)
|
||||
|
||||
raise TypeError(f"cannot encode values of type {type(ob)}") # pragma: no cover
|
@ -0,0 +1,5 @@
|
||||
bcrypt
|
||||
molten
|
||||
psycopg2
|
||||
sqlalchemy
|
||||
|
@ -0,0 +1,19 @@
|
||||
#
|
||||
# This file is autogenerated by pip-compile
|
||||
# To update, run:
|
||||
#
|
||||
# pip-compile --output-file requirements.txt requirements.in
|
||||
#
|
||||
--trusted-host pypi.python.org
|
||||
--trusted-host git.aigalatic.com
|
||||
|
||||
bcrypt==3.1.4
|
||||
cffi==1.11.5 # via bcrypt
|
||||
molten==0.5.0
|
||||
mypy-extensions==0.4.0 # via typing-inspect
|
||||
psycopg2==2.7.5
|
||||
pycparser==2.18 # via cffi
|
||||
six==1.11.0 # via bcrypt
|
||||
sqlalchemy==1.2.10
|
||||
typing-extensions==3.6.5 # via molten
|
||||
typing-inspect==0.3.1 # via molten
|
@ -0,0 +1,15 @@
|
||||
from setuptools import setup, find_packages
|
||||
|
||||
setup(
|
||||
name="Market Look",
|
||||
description="An API application used for tracking promotion compliance at store",
|
||||
zip_safe=False,
|
||||
author="Drew Bednar",
|
||||
author_email="andrew.bednar@kellogg.com",
|
||||
packages=find_packages(exclude=['tests'], include=['scripts', 'market_look']),
|
||||
entry_points={
|
||||
"console_scripts": [
|
||||
"manage=market_look.cli:cli"
|
||||
]
|
||||
}
|
||||
)
|
Loading…
Reference in New Issue