Permalink
Browse files

Merge pull request #24 from klrmn/env_api

API for environments, including PATCH for combinatorics

LGTM
  • Loading branch information...
2 parents 74c621f + 7d87544 commit 51d37f394a7758d173092e9cf1232b6453ccf75a @camd camd committed Mar 22, 2013
@@ -1,6 +1,12 @@
Environment API
===============
+Environments do not behave in quite the same way in the API as they do in
+the Web UI. In the API, create Categories and their child Elements first,
+then create a Profile for which you can create Environments whose elements
+must each belong to a separate profile.
+
+
Profile
-------
@@ -87,3 +93,64 @@ Filtering
.. sourcecode:: http
GET /api/v1/environment/?format=json&elements=5
+
+.. http:get:: /api/v1/environment/<id>
+.. http:post:: /api/v1/environment
+
+Required Fields
+^^^^^^^^^^^^^^^
+
+ :profile: A resource uri to the parent Profile.
+ :elements: A list of element resource uri's.
+
+.. note:: Each element must be from a separate category.
+
+.. http:delete:: /api/v1/environment/<id>
+.. http:put:: /api/v1/environment/<id>
+
+.. http:patch:: /api/v1/environment
+
+ The `PATCH` command is being overloaded to provide combinatorics
+ services to create `environments` out of `elements` contained by
+ `categories`.
+
+ To create environments for all of the combinations of elements in
+ the listed categories:
+
+ .. sourcecode:: python
+
+ data={
+ u'profile': u'/api/v1/profile/1',
+ u'categories': [u'/api/v1/category/1', ...]
+ }
+
+ You may also do combinatorics with partial sets of elements from
+ the categories by using dictionaries with 'include' and 'exclude' keys.
+
+ .. sourcecode:: python
+
+ data={
+ u'profile': u'/api/v1/profile/1',
+ u'categories': [
+ {
+ u'category': u'/api/v1/category/1',
+ u'exclude': [u'/api/v1/element/1']
+ },
+ {
+ u'category': u'/api/v1/category/2',
+ u'include': [
+ u'/api/v1/element/4',
+ u'/api/v1/element/5'
+ ]
+ },
+ {
+ u'category': u'/api/v1/category/3'
+ }
+ ]
+ }
+
+ .. note::
+
+ The included or excluded elements must be members of the category
+ they accompany. If both include and exclude are sent with the same
+ category, exclude will be performed.
@@ -16,33 +16,46 @@ The general format for all rest endpoints is:
Return a list of objects
+ **limit** (optional) Defaults to 20 items, but can be set higher or lower.
+ 0 will return all records, but may run afoul of
**Example request**:
.. sourcecode:: http
GET /api/v1/product/?format=json&limit=50
-.. http:get:: /api/v1/<object_type/<id>/
+.. http:get:: /api/v1/<object_type>/<id>/
Return a single object
-.. http:post:: /api/v1/<object_type>/
+.. https:post:: /api/v1/<object_type>/
- Create one or more items. **requires** :ref:`API key<api-key>`
+ Create one or more items.
-.. http:put:: /api/v1/<object_type>/<id>
+ **requires** :ref:`API key<api-key>`
+ **requires** :ref:`username`
- Update one item. **requires** :ref:`API key<api-key>`
+ If sending the fields as data, the data must be sent as json, with
+ Content-Type application/json in the headers.
-.. http:delete:: /api/v1/<object_type>/<id>
+.. https:put:: /api/v1/<object_type>/<id>
- Delete one item. **requires** :ref:`API key<api-key>`
+ Update one item.
+ **requires** :ref:`API key<api-key>`
+ **requires** :ref:`username`
+
+.. https:delete:: /api/v1/<object_type>/<id>
+
+ Delete one item.
+ **requires** :ref:`API key<api-key>`
+ **requires** :ref:`username`
.. note::
* POST does not replace the whole list of items, it only creates new ones
* DELETE on a list is not supported
* PUT to a list is not supported
+ * commands that make changes may need to be sent to https, not http.
Query Parameters
@@ -59,8 +72,6 @@ Some fields are universal to all requests and :ref:`Object Types<object-types>`:
* **format** (required) The API **always** requires a value of ``json`` for
this field.
-* **limit** (optional) Defaults to 20 items, but can be set higher or lower.
- 0 will return all records.
.. note::
@@ -1,9 +1,13 @@
from tastypie import fields
+from tastypie import http
from tastypie.resources import ModelResource, ALL, ALL_WITH_RELATIONS
+from tastypie.exceptions import ImmediateHttpResponse
from ..mtapi import MTResource, MTAuthorization
from .models import Profile, Environment, Element, Category
+import logging
+logger = logging.getLogger(__name__)
class EnvironmentAuthorization(MTAuthorization):
@@ -54,6 +58,7 @@ class Meta(MTResource.Meta):
"name": ALL,
}
+
@property
def model(self):
"""Model class related to this resource."""
@@ -82,20 +87,132 @@ def model(self):
"""Model class related to this resource."""
return Element
+
@property
def read_create_fields(self):
- """List of fields that are required for create but read-only for update."""
+ """List of fields that are required for create
+ but read-only for update."""
return ["category"]
-class EnvironmentResource(ModelResource):
- """Return a list of environments"""
+class EnvironmentResource(MTResource):
+ """Create, Read and Delete capabilities for environments"""
- elements = fields.ToManyField(ElementResource, "elements", full=True)
+ elements = fields.ToManyField(ElementResource, "elements")
+ profile = fields.ForeignKey(ProfileResource, "profile")
- class Meta:
+ class Meta(MTResource.Meta):
queryset = Environment.objects.all()
- list_allowed_methods = ['get']
- fields = ["id"]
- filtering = {"elements": ALL}
+ list_allowed_methods = ['get', 'post', 'patch']
+ detail_allowed_methods = ['get', 'put', 'delete']
+ fields = ["id", "profile", "elements"]
+ filtering = {
+ "elements": ALL,
+ "profile": ALL_WITH_RELATIONS,
+ }
+ ordering = ["id", "profile"]
+
+
+ @property
+ def model(self):
+ """Model class related to this resource."""
+ return Environment
+
+
+ def hydrate_m2m(self, bundle):
+ """Validate the elements,
+ which should each belong to separate categories."""
+
+ bundle = super(EnvironmentResource, self).hydrate_m2m(bundle)
+ elem_categories = [elem.data['category'] for elem in
+ bundle.data['elements']]
+ if len(set(elem_categories)) != len(bundle.data['elements']):
+ error_msg = "Elements must each belong to a different Category."
+ logger.error(error_msg)
+ raise ImmediateHttpResponse(
+ response=http.HttpBadRequest(error_msg))
+ return bundle
+
+
+ def patch_list(self, request, **kwargs):
+ """
+ Since there is no RESTful way to do what we want to do, and since
+ ``PATCH`` is poorly defined with regards to RESTfulness, we are
+ overloading ``PATCH`` to take a single request that performs
+ combinatorics and creates multiple objects.
+ """
+ import itertools
+ from django.db import transaction
+ from tastypie.utils import dict_strip_unicode_keys
+
+ deserialized = self.deserialize(
+ request,
+ request.raw_post_data,
+ format=request.META.get('CONTENT_TYPE', 'application/json'))
+
+ # verify input
+ categories = deserialized.pop('categories', [])
+ if not categories or not isinstance(categories, list):
+ error_msg = "PATCH request must contain categories list."
+ logger.error(error_msg)
+ raise ImmediateHttpResponse(
+ response=http.HttpBadRequest(error_msg))
+
+
+ # do the combinatorics
+ elem_lists = []
+ for cat in categories:
+ # do some type validation / variation
+ if isinstance(cat, basestring):
+ # simple case of create all the combinations
+ cat = Category.objects.filter(id=self._id_from_uri(cat))
+ elem_list = Element.objects.filter(category=cat)
+ elif isinstance(cat, dict):
+ # we must be working with at least one partial category
+ category = Category.objects.filter(
+ id=self._id_from_uri(cat['category']))
+ elem_list = Element.objects.filter(category=category)
+ if 'exclude' in cat:
+ # exclude some element(s) from the combinations
+ exclude_uris = cat['exclude']
+ exclude_ids = [int(
+ self._id_from_uri(x)) for x in exclude_uris]
+ elem_list = [elem for elem in elem_list
+ if elem.id not in exclude_ids]
+ elif 'include' in cat:
+ # include only a few elements in the combinations
+ include_uris = cat['include']
+ include_ids = [int(
+ self._id_from_uri(x)) for x in include_uris]
+ elem_list = [elem for elem in elem_list
+ if elem.id in include_ids]
+ else:
+ # don't worry about this,
+ # it'll act like a list of categories
+ pass # pragma: no cover
+ else:
+ error_msg = "categories list must contain resource uris or hashes."
+ logger.error(error_msg)
+ raise ImmediateHttpResponse(
+ response=http.HttpBadRequest(error_msg))
+
+ # save off the elements from this category that will be used
+ elem_lists.append(elem_list)
+
+ # create all the combinations of elements from categories
+ combinatorics = itertools.product(*elem_lists)
+
+ # do the creation
+ with transaction.commit_on_success():
+ for combo in combinatorics:
+ deserialized['elements'] = combo
+ bundle = self.build_bundle(
+ data=dict_strip_unicode_keys(deserialized))
+ bundle.request.META['REQUEST_METHOD'] = 'PATCH'
+ self.is_valid(bundle, request)
+ self.obj_create(bundle, request=request)
+
+ # don't try to reply with data, the request doesn't
+ # really match the results.
+ return http.HttpAccepted()
View
@@ -352,7 +352,7 @@ def _test_filter_list_by(self, key, value, expected_number_of_results):
self._test_filter_list_by(u'product', u'', 3)
"""
- res = self.get_list(params={key: value})
+ res = self.get_list(params={key: value, u'limit': 100})
objects = res.json["objects"]
# validate
@@ -362,7 +362,7 @@ def _test_filter_list_by(self, key, value, expected_number_of_results):
# can't use assertEqual here because for foriegn keys,
# the filter must be an id
# but the value in the response is an uri
- self.assertTrue(value in obj[key])
+ self.assertTrue(str(value) in obj[key])
else:
# case for value being None
self.assertEqual(value, obj[key])
@@ -52,17 +52,17 @@ def test_productversionenvironments_list(self):
exp_objects.append({
u"codename": unicode(pv.codename),
u'environments': [{
- u'elements': [{
- u'category': unicode(
- self.get_detail_url("category", category.id)),
- u'id': unicode(element.id),
- u'name': u'OS X',
- u'resource_uri': unicode(self.get_detail_url(
+ u'elements': [
+ unicode(self.get_detail_url(
"element",
element.id,
)),
- }],
+ ],
u'id': unicode(envs[0].id),
+ u'profile': unicode(self.get_detail_url(
+ "profile",
+ envs[0].profile.id
+ )),
u'resource_uri': unicode(self.get_detail_url(
"environment",
envs[0].id,
@@ -1,5 +1,5 @@
"""
-Tests for EnvironmentResource api.
+Tests for ElementResource api.
"""
Oops, something went wrong.

0 comments on commit 51d37f3

Please sign in to comment.