267 lines
8.1 KiB
Python
267 lines
8.1 KiB
Python
import flask
|
|
from flask import Flask, Blueprint
|
|
from artifice import interface, database
|
|
from artifice.models import UsageEntry, SalesOrder, Tenant, billing
|
|
import sqlalchemy
|
|
from sqlalchemy import create_engine, func
|
|
from sqlalchemy.orm import scoped_session, create_session
|
|
from sqlalchemy.pool import NullPool
|
|
from decimal import Decimal
|
|
from datetime import datetime
|
|
import importlib
|
|
import collections
|
|
import pytz
|
|
import json
|
|
|
|
from .helpers import returns_json, json_must
|
|
|
|
|
|
engine = None
|
|
# Session.configure(bind=create_engine(conn_string))
|
|
|
|
Session = None
|
|
# db = Session()
|
|
|
|
app = Blueprint("main", __name__)
|
|
|
|
config = None
|
|
|
|
invoicer = None
|
|
|
|
DEFAULT_TIMEZONE = "Pacific/Auckland"
|
|
|
|
current_region = "Wellington" # FIXME
|
|
|
|
|
|
def get_app(conf):
|
|
actual_app = Flask(__name__)
|
|
actual_app.register_blueprint(app, url_prefix="/")
|
|
|
|
global engine
|
|
engine = create_engine(conf["main"]["database_uri"], poolclass=NullPool)
|
|
|
|
global config
|
|
config = conf
|
|
|
|
global Session
|
|
Session = scoped_session(lambda: create_session(bind=engine))
|
|
|
|
global invoicer
|
|
module, kls = config["main"]["export_provider"].split(":")
|
|
invoicer = getattr(importlib.import_module(module), kls)
|
|
|
|
if config["main"].get("timezone"):
|
|
global DEFAULT_TIMEZONE
|
|
DEFAULT_TIMEZONE = config["main"]["timezone"]
|
|
|
|
return actual_app
|
|
|
|
|
|
# Some useful constants
|
|
iso_time = "%Y-%m-%dT%H:%M:%S"
|
|
iso_date = "%Y-%m-%d"
|
|
dawn_of_time = "2012-01-01"
|
|
|
|
|
|
class validators(object):
|
|
|
|
@classmethod
|
|
def iterable(cls, val):
|
|
return isinstance(val, collections.Iterable)
|
|
|
|
|
|
class DecimalEncoder(json.JSONEncoder):
|
|
"""Simple encoder which handles Decimal objects, rendering them to strings.
|
|
*REQUIRES* use of a decimal-aware decoder.
|
|
"""
|
|
def default(self, obj):
|
|
if isinstance(obj, Decimal):
|
|
return str(obj)
|
|
return json.JSONEncoder.default(self, obj)
|
|
|
|
|
|
def fetch_endpoint(region):
|
|
return config.get("keystone_endpoint")
|
|
# return "http://0.0.0.0:35357/v2.0" # t\/his ought to be in config. #FIXME
|
|
|
|
|
|
def keystone(func):
|
|
"""Will eventually provide a keystone wrapper for validating a query.
|
|
Currently does not.
|
|
"""
|
|
return func # disabled for now
|
|
# admin_token = config.get("admin_token")
|
|
# def _perform_keystone(*args, **kwargs):
|
|
# headers = flask.request.headers
|
|
# if not 'user_id' in headers:
|
|
# flask.abort(401) # authentication required
|
|
#
|
|
# endpoint = fetch_endpoint( current_region )
|
|
# keystone = keystoneclient.v2_0.client.Client(token=admin_token,
|
|
# endpoint=endpoint)
|
|
|
|
# return _perform_keystone
|
|
|
|
|
|
@app.route("collect_usage", methods=["POST"])
|
|
@keystone
|
|
def run_usage_collection():
|
|
"""
|
|
Adds usage for a given tenant T and resource R.
|
|
Expects to receive a Resource ID, a time range, and a volume.
|
|
|
|
The volume will be parsed from JSON as a Decimal object.
|
|
"""
|
|
|
|
session = Session()
|
|
|
|
artifice = interface.Artifice(config)
|
|
d = database.Database(session)
|
|
|
|
tenants = artifice.tenants
|
|
|
|
resp = {"tenants": [],
|
|
"errors": 0}
|
|
|
|
for tenant in tenants:
|
|
d.insert_tenant(tenant.conn['id'], tenant.conn['name'],
|
|
tenant.conn['description'])
|
|
session.begin(subtransactions=True)
|
|
start = session.query(func.max(UsageEntry.end).label('end')).\
|
|
filter(UsageEntry.tenant_id == tenant.conn['id']).first().end
|
|
if not start:
|
|
start = datetime.strptime(dawn_of_time, iso_date)
|
|
|
|
end = datetime.now(pytz.timezone(DEFAULT_TIMEZONE)).\
|
|
replace(minute=0, second=0, microsecond=0)
|
|
|
|
usage = tenant.usage(start, end)
|
|
|
|
# enter all resources into the db
|
|
d.enter(usage.values(), start, end)
|
|
|
|
try:
|
|
session.commit()
|
|
resp["tenants"].append(
|
|
{"id": tenant.conn['id'],
|
|
"updated": True,
|
|
"start": start.strftime(iso_time),
|
|
"end": end.strftime(iso_time)
|
|
}
|
|
)
|
|
except sqlalchemy.exc.IntegrityError:
|
|
# this is fine.
|
|
resp["tenants"].append(
|
|
{"id": tenant.conn['id'],
|
|
"updated": False,
|
|
"error": "Integrity error",
|
|
"start": start.strftime(iso_time),
|
|
"end": end.strftime(iso_time)
|
|
}
|
|
)
|
|
resp["errors"] += 1
|
|
session.close()
|
|
return json.dumps(resp)
|
|
|
|
|
|
def generate_sales_order(tenant, session):
|
|
db = database.Database(session)
|
|
|
|
session.begin()
|
|
# Get the last sales order for this tenant, to establish
|
|
# the proper ranging
|
|
start = session.query(func.max(SalesOrder.end).label('end')).\
|
|
filter(SalesOrder.tenant == tenant).first().end
|
|
if not start:
|
|
start = datetime.strptime(dawn_of_time, iso_date)
|
|
# Today, the beginning of.
|
|
end = datetime.now().\
|
|
replace(hour=0, minute=0, second=0, microsecond=0)
|
|
# Invoicer is pulled from the configfile and set up above.
|
|
usage = db.usage(start, end, tenant.id)
|
|
order = SalesOrder(tenant_id=tenant.id, start=start, end=end)
|
|
session.add(order)
|
|
|
|
try:
|
|
# Commit the record before we generate the bill, to mark this as a
|
|
# billed region of data. Avoids race conditions by marking a tenant
|
|
# BEFORE we start to generate the data for it.
|
|
session.commit()
|
|
|
|
# Transform the query result into a billable dict.
|
|
# This is non-portable and very much tied to the CSV exporter
|
|
# and will probably result in the CSV exporter being changed.
|
|
billable = billing.build_billable(usage, session)
|
|
session.close()
|
|
exporter = invoicer(start, end, config["export_config"])
|
|
exporter.bill(billable)
|
|
exporter.close()
|
|
return {"id": tenant.id,
|
|
"generated": True,
|
|
"start": str(start),
|
|
"end": str(end)}
|
|
|
|
except sqlalchemy.exc.IntegrityError:
|
|
session.rollback()
|
|
session.close()
|
|
return {"id": tenant.id,
|
|
"generated": False,
|
|
"start": str(start),
|
|
"end": str(end)}
|
|
|
|
|
|
@app.route("sales_order", methods=["POST"])
|
|
@keystone
|
|
@json_must()
|
|
@returns_json
|
|
def run_sales_order_generation():
|
|
session = Session()
|
|
|
|
# TODO: ensure cases work as follows:
|
|
# if no body or content type: generate orders for all
|
|
# if no body and json content type: throw 400? Or should this order all?
|
|
# if body, and json, and parsed, but no tenants, throw 400? Or order all.
|
|
|
|
# If request has body, content type must be json.
|
|
# else: throw 400
|
|
# if request has body and content type json, body must parse to json
|
|
# else: throw 400
|
|
|
|
# if 'tenants' is not None, and not a list, throw a 400 response.
|
|
|
|
# if the list is empty, throw 400 and invalid parameter 'list is empty'.
|
|
# Or allow return 200 and resp['tenants'] will just be empty?
|
|
|
|
# if list isn't empty, but query produces no results, throw 400?
|
|
# Or allow return 200 and resp['tenants'] will just be empty?
|
|
|
|
# any missing cases? Any cases not worth covering?
|
|
|
|
# should these checks be here, or in decorators.
|
|
# if in decorators can we make these as parameters for the decorators,
|
|
# to keep them fairly generic, or are these cases too specific?
|
|
|
|
tenants = flask.request.json.get("tenants", None)
|
|
tenant_query = session.query(Tenant)
|
|
|
|
if isinstance(tenants, list):
|
|
tenant_query = tenant_query.filter(Tenant.id.in_(tenants))
|
|
if tenant_query.count() == 0:
|
|
# if an explicit list of tenants is passed, and none of them
|
|
# exist in the db, then we consider that an error.
|
|
return 400, {"errors": ["No tenants found from given list."]}
|
|
elif tenants is not None:
|
|
return 400, {"missing parameters": {"tenants": "A list of tenant ids."}}
|
|
|
|
# Handled like this for a later move to Celery distributed workers
|
|
resp = {"tenants": []}
|
|
|
|
for tenant in tenant_query:
|
|
resp['tenants'].append(generate_sales_order(tenant, session))
|
|
|
|
return 200, resp
|
|
|
|
|
|
if __name__ == '__main__':
|
|
pass
|