diff --git a/INVOICES.md b/INVOICES.md new file mode 100644 index 0000000..51dd2fa --- /dev/null +++ b/INVOICES.md @@ -0,0 +1,83 @@ +# Invoice API + +This document details how to add a new Invoice adapter to Artifice, enabling connection with arbitrary ERP systems. + +## What Does Artifice Do + +Artifice manages the connection from OpenStack Ceilometer to the ERP/billing system, managing data from Ceilometer in terms of a block (usually a month) of "billed time." + +Artifice maintains its own storage to handle time that has been billed. + +Artifice does not hold opinions on which ERP system should be used; OpenERP acts only as a reference implementation, detailing how an invoice could be billed. + +Artifice makes the following assumptions: + +* An invoice must be creatable +* An invoice must allow Sections to be declared +* An invoice must allow line items to be added +* An invoice must respond to cost(datacenter, name), returning a cost value +* An invoice must be commitable, saving it to the underlying ERP storage + + +## Implementation + +Implementing an Artifice invoice object is intended to be simple: + + from artifice.invoice import Invoice + class MyInvoice(Invoice): + def __init__(self, tenant, config): + pass + + def add_line(self, item): + """item is a triple of datacenter, name, value""" + def add_section(self, name): + + """Expected to create a subsection in the Invoice. Used for datacenter locations. + """ + def commit(self): + """Closes this invoice. Expects to save to the underlying ERP storage system. + """ + + def cost(self, datacenter, name): + """ + Taking a datacenter and a meter name, expected to return a fixed value for the given month. + :param datacenter + :param name + :returns float + """ + +## Configuration + +Configuration of the Invoice object is passed from the main Artifice configuration during Invoice initiation. + +Configuration will be passed as-is from the main configuration to the Invoice system, allowing for arbitrary items to be passed through. + +For example: + + [invoice:object] + database_name="somedb" + +or + + [invoice:object] + url = "https://localhost:4567" + +or + + [invoice:object] + csv_directory = "/path/to/my/file" + +## Usage and Declaration + +Usage of the custom Invoice is controlled via the Artifice configuration file. + +Under + [main] + +add the line: + + [main] + invoice:object="path.to.my.object:MyInvoice" + +similar to *paste*-style configuration items. + diff --git a/artifice/billing/openerp.py b/artifice/billing/openerp.py index fce6c8e..86bb704 100644 --- a/artifice/billing/openerp.py +++ b/artifice/billing/openerp.py @@ -1,15 +1,68 @@ import oerplib +from collections import defaultdict from artifice import invoice class connection(object): - pass + def __init__(self, config): + self.config = config + self.oerp = oerplib() + self.sections = [] + self._data = defaultdict(list) + self.this_section = None -class openerp(invoice.Invoice): + def create(self): + pass - def save(self): + def section(self, title): + if title not in self.sections: + self.sections.append(title) + self.this_section = title + + def add_line(self, text, value): + self._data[self.this_section].append((text, value)) + + def cost(self, datacenter, meter): + """Returns the cost of a given resource in a given datacenter.""" + return costs[meter][datacenter] + + +class OpenERP(invoice.Invoice): + + def __init__(self, tenant, config): + + super(OpenERP, self).__init__(tenant) + # Conn is expected to be a dict with: + # + self.conn = connection(self.config) + + self.id = self.conn.create() + + def add_section(self, name): + self.conn.section(name) + + def add_line(self, item): """ - Performs the save action against the OpenERP API - Doesn't do much more than create a thing and + """ + + datacenter, name, value = item + self.conn.section( datacenter ) + + cost = self.conn.cost(datacenter, name) + + self.conn.add_line( name, cost * value ) + + def commit(self): + self.conn.bill(self.id) + return self.id + + + def close(self): + """ + Makes this invoice no longer writable - it's closed and registered as + a closed invoice in OpenERP; sent out for payment, etc. """ pass + + def cost(self, datacenter, name): + return self.conn.cost(datacenter, name) \ No newline at end of file diff --git a/artifice/interface.py b/artifice/interface.py index c7d1bf7..83217c2 100644 --- a/artifice/interface.py +++ b/artifice/interface.py @@ -411,14 +411,16 @@ class Artifact(object): self.start, self.end, ) - self.db.add(usage) + session.add(usage) + session.commit() # Persist to our backend + def volume(self): """ Default billable number for this volume """ - return self.usage[-1]["counter_volume"] + return sum([x["counter_volume"] for x in self.usage]) class Cumulative(Artifact): @@ -428,12 +430,11 @@ class Cumulative(Artifact): total_usage = measurements[-1]["counter_volume"] - measurements[0]["counter_volume"] return total_usage -class Gauge(Artifact): - # def volume(self): - # pass +# Gauge and Delta have very little to do: They are expected only to +# exist as "not a cumulative" sort of artifact. +class Gauge(Artifact): pass class Delta(Artifact): - pass diff --git a/artifice/invoice.py b/artifice/invoice.py index 42d2720..a308b32 100644 --- a/artifice/invoice.py +++ b/artifice/invoice.py @@ -10,50 +10,77 @@ an Invoice interface consists of: """ +class IntegrityViolation(BaseException): pass + +class BillingOverlap(BaseException): pass + +class NoSuchType(KeyError): pass + +class NoSuchLocation(KeyError): pass + costs = { - "cpu_util" : { "local": "1"} + "cpu_util" : { "nova": "1" } } +class Costs(object): + + def cost(self, location, name): + """""" + try: + locations = costs[name] + except KeyError: + raise NoSuchType(name) + try: + return locations[location] + except KeyError: + raise NoSuchLocation(location) + +required = ["add_line", "close"] + +def requirements(name, parents, attrs): + for attr_name in required: + try: + assert attr_name in attrs + except AssertionError: + raise RuntimeError("%s not in %s" % (attr_name, name)) + + try: + assert callable(attr_name) + except AssertionError: + raise RuntimeError("%s is not callable" % (attr_name)) + return type(name, parents, attrs) + class Invoice(object): - def __init__(self, tenant): + # __metaclass__ = requirements + + def __init__(self, tenant, config): self.tenant = tenant + self.config = config + + def close(self): + raise NotImplementedError("Not implemented in base class") def bill(self, usage): - """ - Expects a list of dicts of datacenters - Each DC is expected to have a list of Types: VM, Network, Storage - Each Type is expected have to a list of Meters - Each Meter is expected to have a Usage method that takes our start - and end values. - Each Meter will be entered as a line on the Invoice. - """ for dc in usage: # DC is the name of the DC/region. Or the internal code. W/E. # print datacenter self.subheading(dc["name"]) for section in dc["sections"]: # will be vm, network, storage - self.subheading( section ) + self.add_section( section ) meters = dc["sections"][section] for usage in meters: cost = self.cost( dc["name"], meter["name"] ) - self.line( "%s per unit " % cost, usage.volume, cost * usage.volume ) + self.add_line( "%s per unit " % cost, usage.volume, cost * usage.volume ) self.commit() # Writes to OpenERP? Closes the invoice? Something. - def commit(self): - pass - def close(self): - """ - Makes this invoice no longer writable - it's closed and registered as - a closed invoice in OpenERP; sent out for payment, etc. - """ - pass + def add_line(self, item): + raise NotImplementedError("Not implemented in base class") - def cost(self, datacenter, meter): - """Returns the cost of a given resource in a given datacenter.""" - return costs[meter][datacenter] + def add_section(self, title): + raise NotImplementedError("Not implemented in base class")