Repository: linode/linode_api4-python

Issue:
How to access a list of Invoice Items?
I do not see a way to retrieve a list of invoice line items.

Discussion:

Patch:
diff --git a/linode_api4/objects/__init__.py b/linode_api4/objects/__init__.py
index 6b408ad..63f3c9d 100644
--- a/linode_api4/objects/__init__.py
+++ b/linode_api4/objects/__init__.py
@@ -1,4 +1,4 @@
-from .base import Base, Property, MappedObject
+from .base import Base, Property, MappedObject, DATE_FORMAT
 from .dbase import DerivedBase
 from .filtering import and_, or_
 from .region import Region
diff --git a/linode_api4/objects/account.py b/linode_api4/objects/account.py
index cb5d820..eb8c910 100644
--- a/linode_api4/objects/account.py
+++ b/linode_api4/objects/account.py
@@ -1,10 +1,11 @@
 from __future__ import absolute_import
 
+from datetime import datetime
 import requests
 
 from linode_api4.errors import ApiError, UnexpectedResponseError
 from linode_api4.objects import (Base, DerivedBase, Domain, Image, Instance,
-                                 Property, StackScript, Volume)
+                                 Property, StackScript, Volume, DATE_FORMAT)
 from linode_api4.objects.longview import LongviewClient, LongviewSubscription
 from linode_api4.objects.nodebalancer import NodeBalancer
 from linode_api4.objects.support import SupportTicket
@@ -104,21 +105,11 @@ class Event(Base):
         self._client.post('{}/read'.format(Event.api_endpoint), model=self)
 
 
-class Invoice(Base):
-    api_endpoint = "/account/invoices/{id}"
-
-    properties = {
-        "id": Property(identifier=True),
-        "label": Property(),
-        "date": Property(is_datetime=True),
-        "total": Property(),
-    }
-
-
 class InvoiceItem(DerivedBase):
     api_endpoint = '/account/invoices/{invoice_id}/items'
     derived_url_path = 'items'
     parent_id_name='invoice_id'
+    id_attribute = 'label' # this has to be something
 
     # TODO - this object doesn't have its own ID .. this might need
     # special handling
@@ -128,11 +119,34 @@ class InvoiceItem(DerivedBase):
         'label': Property(),
         'amount': Property(),
         'quantity': Property(),
-        'from': Property(is_datetime=True),
+        #'from_date': Property(is_datetime=True), this is populated below from the "from" attribute
         'to': Property(is_datetime=True),
+        #'to_date': Property(is_datetime=True), this is populated below from the "to" attribute
         'type': Property(),
     }
 
+    def _populate(self, json):
+        """
+        Allows population of "from_date" from the returned "from" attribute which
+        is a reserved word in python.  Also populates "to_date" to be complete.
+        """
+        super(InvoiceItem, self)._populate(json)
+
+        self.from_date = datetime.strptime(json['from'], DATE_FORMAT)
+        self.to_date = datetime.strptime(json['to'], DATE_FORMAT)
+
+
+class Invoice(Base):
+    api_endpoint = "/account/invoices/{id}"
+
+    properties = {
+        "id": Property(identifier=True),
+        "label": Property(),
+        "date": Property(is_datetime=True),
+        "total": Property(),
+        "items": Property(derived_class=InvoiceItem),
+    }
+
 
 class OAuthClient(Base):
     api_endpoint = "/account/oauth-clients/{id}"
diff --git a/linode_api4/objects/base.py b/linode_api4/objects/base.py
index 9cc8a5f..e04e15b 100644
--- a/linode_api4/objects/base.py
+++ b/linode_api4/objects/base.py
@@ -5,6 +5,10 @@ from future.utils import with_metaclass
 
 from .filtering import FilterableMetaclass
 
+
+DATE_FORMAT = "%Y-%m-%dT%H:%M:%S"
+
+
 # The interval to reload volatile properties
 volatile_refresh_timeout = timedelta(seconds=15)
 
@@ -249,7 +253,7 @@ class Base(object, with_metaclass(FilterableMetaclass)):
                     self._set(key, mapping._list) # pylint: disable=no-member
                 elif type(self).properties[key].is_datetime:
                     try:
-                        t = time.strptime(json[key], "%Y-%m-%dT%H:%M:%S")
+                        t = time.strptime(json[key], DATE_FORMAT)
                         self._set(key, datetime.fromtimestamp(time.mktime(t)))
                     except:
                         #TODO - handle this better (or log it?)
diff --git a/test/objects/image_tests.py b/test/objects/image_test.py
similarity index 100%
rename from test/objects/image_tests.py
rename to test/objects/image_test.py
diff --git a/tox.ini b/tox.ini
index a71b956..05a9f19 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,5 +1,5 @@
 [tox]
-envlist = py27,py34,py35,py36
+envlist = py27,py34,py35,py36,py37
 skip_missing_interpreters = true
 
 [testenv]


Test patch:
diff --git a/test/fixtures/account_invoices.json b/test/fixtures/account_invoices.json
new file mode 100644
index 0000000..980ecde
--- /dev/null
+++ b/test/fixtures/account_invoices.json
@@ -0,0 +1,13 @@
+{
+  "data": [
+    {
+      "id": 123456,
+      "date": "2015-01-01T05:01:02",
+      "label": "Invoice #123456",
+      "total": 9.51
+    }
+  ],
+  "page": 1,
+  "pages": 1,
+  "results": 1
+}
diff --git a/test/fixtures/account_invoices_123456_items.json b/test/fixtures/account_invoices_123456_items.json
new file mode 100644
index 0000000..289b131
--- /dev/null
+++ b/test/fixtures/account_invoices_123456_items.json
@@ -0,0 +1,16 @@
+{
+  "data": [
+    {
+      "from": "2014-12-19T00:27:02",
+      "label": "Linode 2048 - Example",
+      "type": "hourly",
+      "amount": 9.51,
+      "to": "2015-01-01T04:59:59",
+      "quantity": 317,
+      "unit_price": "0.03"
+    }
+  ],
+  "page": 1,
+  "pages": 1,
+  "results": 1
+}
diff --git a/test/linode_client_test.py b/test/linode_client_test.py
index 739c304..bf01c82 100644
--- a/test/linode_client_test.py
+++ b/test/linode_client_test.py
@@ -205,6 +205,20 @@ class AccountGroupTest(ClientBaseCase):
         self.assertEqual(type(s.longview_subscription), LongviewSubscription)
         self.assertEqual(s.longview_subscription.id, 'longview-100')
 
+    def test_get_invoices(self):
+        """
+        Tests that invoices can be retrieved
+        """
+        i = self.client.account.invoices()
+
+        self.assertEqual(len(i), 1)
+        invoice = i[0]
+
+        self.assertEqual(invoice.id, 123456)
+        self.assertEqual(invoice.date, datetime(2015, 1, 1, 5, 1, 2))
+        self.assertEqual(invoice.label, 'Invoice #123456')
+        self.assertEqual(invoice.total, 9.51)
+
 
 class LinodeGroupTest(ClientBaseCase):
     """
diff --git a/test/objects/account_test.py b/test/objects/account_test.py
new file mode 100644
index 0000000..f869eb7
--- /dev/null
+++ b/test/objects/account_test.py
@@ -0,0 +1,38 @@
+from datetime import datetime
+
+from test.base import ClientBaseCase
+
+from linode_api4.objects import Invoice
+
+
+class InvoiceTest(ClientBaseCase):
+    """
+    Tests methods of the Invoice
+    """
+    def test_get_invoice(self):
+        invoice = Invoice(self.client, 123456)
+        self.assertEqual(invoice._populated, False)
+
+        self.assertEqual(invoice.label, 'Invoice #123456')
+        self.assertEqual(invoice._populated, True)
+
+        self.assertEqual(invoice.date, datetime(2015, 1, 1, 5, 1, 2))
+        self.assertEqual(invoice.total, 9.51)
+
+    def test_get_invoice_items(self):
+        """
+        Tests that you can get items for an invoice
+        """
+        invoice = Invoice(self.client, 123456)
+        items = invoice.items
+
+        self.assertEqual(len(items), 1)
+        item = items[0]
+
+        self.assertEqual(item.label, "Linode 2048 - Example")
+        self.assertEqual(item.type, "hourly")
+        self.assertEqual(item.amount, 9.51)
+        self.assertEqual(item.quantity, 317)
+        self.assertEqual(item.unit_price, "0.03")
+        self.assertEqual(item.from_date, datetime(year=2014, month=12, day=19, hour=0, minute=27, second=2))
+        self.assertEqual(item.to_date, datetime(year=2015, month=1, day=1, hour=4, minute=59, second=59))