# test_fetching.py - unit tests for fetching resources
#
# Copyright 2011 Lincoln de Sousa <lincoln@comum.org>.
# Copyright 2012, 2013, 2014, 2015, 2016 Jeffrey Finkelstein
#           <jeffrey.finkelstein@gmail.com> and contributors.
#
# This file is part of Flask-Restless.
#
# Flask-Restless is distributed under both the GNU Affero General Public
# License version 3 and under the 3-clause BSD license. For more
# information, see LICENSE.AGPL and LICENSE.BSD.
"""Unit tests for fetching resources from endpoints generated by
Flask-Restless.

This module includes tests for additional functionality that is not
already tested by :mod:`test_jsonapi`, the package that guarantees
Flask-Restless meets the minimum requirements of the JSON API
specification.

"""
import unittest

from operator import itemgetter

from sqlalchemy import Column
from sqlalchemy import ForeignKey
from sqlalchemy import Integer
from sqlalchemy import Unicode
from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.orm import backref
from sqlalchemy.orm import relationship

from flask_restless import APIManager
from flask_restless import ProcessingException
from flask_restless import simple_serialize

from .helpers import check_sole_error
from .helpers import dumps
from .helpers import FlaskSQLAlchemyTestBase
from .helpers import loads
from .helpers import MSIE8_UA
from .helpers import MSIE9_UA
from .helpers import ManagerTestBase



class TestFetchCollection(ManagerTestBase):

    def setUp(self):
        super(TestFetchCollection, self).setUp()

        class Person(self.Base):
            __tablename__ = 'person'
            id = Column(Integer, primary_key=True)
            name = Column(Unicode)

        class Article(self.Base):
            __tablename__ = 'article'
            id = Column(Integer, primary_key=True)
            author_id = Column(Integer, ForeignKey('person.id'))
            author = relationship('Person', backref=backref('articles'))

        class Comment(self.Base):
            __tablename__ = 'comment'
            id = Column(Integer, primary_key=True)

            @classmethod
            def query(cls):
                return self.session.query(cls).filter(cls.id < 2)

        self.Article = Article
        self.Comment = Comment
        self.Person = Person
        self.Base.metadata.create_all()
        self.manager.create_api(Article)
        self.manager.create_api(Person)
        self.manager.create_api(Comment)

    def test_wrong_accept_header(self):

        """Tests that if a client specifies only :http:header:`Accept`
        headers with non-JSON API media types, then the server responds
        with a :http:status:`406`.

        """
        headers = {'Accept': 'application/json'}
        response = self.app.get('/api/person', headers=headers)
        assert response.status_code == 406

    def test_jsonp(self):
        """Test for a JSON-P callback on a collection of resources."""
        person1 = self.Person(id=1)
        person2 = self.Person(id=2)
        self.session.add_all([person1, person2])
        self.session.commit()
        response = self.app.get('/api/person?callback=foo')
        assert response.data.startswith(b'foo(')
        assert response.data.endswith(b')')
        document = loads(response.data[4:-1])
        people = document['data']
        assert ['1', '2'] == sorted(person['id'] for person in people)

    def test_msie8(self):
        """Tests for compatibility with Microsoft Internet Explorer 8.

        According to issue #267, making requests using JavaScript from MSIE8
        does not allow changing the content type of the request (it is always
        ``text/html``). Therefore Flask-Restless should ignore the content type
        when a request is coming from this client.

        """
        headers = {'User-Agent': MSIE8_UA}
        content_type = 'text/html'
        response = self.app.get('/api/person', headers=headers,
                                content_type=content_type)
        assert response.status_code == 200

    def test_msie9(self):
        """Tests for compatibility with Microsoft Internet Explorer 9.

        According to issue #267, making requests using JavaScript from MSIE9
        does not allow changing the content type of the request (it is always
        ``text/html``). Therefore Flask-Restless should ignore the content type
        when a request is coming from this client.

        """
        headers = {'User-Agent': MSIE9_UA}
        content_type = 'text/html'
        response = self.app.get('/api/person', headers=headers,
                                content_type=content_type)
        assert response.status_code == 200

    def test_callable_query(self):
        """Tests for making a query with a custom callable ``query`` attribute.

        For more information, see pull request #133.

        """
        comment1 = self.Comment(id=1)
        comment2 = self.Comment(id=2)
        self.session.add_all([comment1, comment2])
        self.session.commit()
        response = self.app.get('/api/comment')
        document = loads(response.data)
        comments = document['data']
        assert ['1'] == sorted(comment['id'] for comment in comments)

    def test_collection_name_multiple(self):
        """Tests for fetching multiple resources with an alternate collection
        name.

        """
        person = self.Person(id=1)
        self.session.add(person)
        self.session.commit()
        self.manager.create_api(self.Person, collection_name='people')
        response = self.app.get('/api/people')
        assert response.status_code == 200
        document = loads(response.data)
        people = document['data']
        assert len(people) == 1
        person = people[0]
        assert person['id'] == '1'
        assert person['type'] == 'people'

    def test_group_by(self):
        """Tests for grouping results."""
        person1 = self.Person(id=1, name=u'foo')
        person2 = self.Person(id=2, name=u'foo')
        person3 = self.Person(id=3, name=u'bar')
        self.session.add_all([person1, person2, person3])
        self.session.commit()
        query_string = {'group': 'name'}
        response = self.app.get('/api/person', query_string=query_string)
        document = loads(response.data)
        people = document['data']
        assert ['bar', 'foo'] == sorted(person['attributes']['name']
                                        for person in people)

    def test_group_by_related(self):
        """Tests for grouping results by a field on a related model."""
        person1 = self.Person(id=1, name=u'foo')
        person2 = self.Person(id=2, name=u'bar')
        article1 = self.Article(id=1)
        article2 = self.Article(id=2)
        article3 = self.Article(id=3)
        article1.author = person1
        article2.author = person1
        article3.author = person2
        self.session.add_all([person1, person2, article1, article2, article3])
        self.session.commit()
        response = self.app.get('/api/article?group=author.name')
        document = loads(response.data)
        articles = document['data']
        author_ids = sorted(article['relationships']['author']['data']['id']
                            for article in articles)
        assert ['1', '2'] == author_ids

    def test_pagination_links_empty_collection(self):
        """Tests that pagination links work correctly for an empty
        collection.

        """
        base_url = '/api/person'
        response = self.app.get(base_url)
        assert response.status_code == 200
        document = loads(response.data)
        pagination = document['links']
        base_url = '{0}?'.format(base_url)
        assert base_url in pagination['first']
        assert 'page[number]=1' in pagination['first']
        assert base_url in pagination['last']
        assert 'page[number]=1' in pagination['last']
        assert pagination['prev'] is None
        assert pagination['next'] is None

    def test_link_headers_empty_collection(self):
        """Tests that :http:header:`Link` headers work correctly for an
        empty collection.

        """
        base_url = '/api/person'
        response = self.app.get(base_url)
        assert response.status_code == 200
        base_url = '{0}?'.format(base_url)
        # There should be exactly two, one for the first page and one
        # for the last page; there are no previous or next pages, so
        # there cannot be any valid Link headers for them.
        links = response.headers['Link'].split(',')
        assert len(links) == 2
        # Decide which link is for the first page and which is for the last.
        if 'first' in links[0]:
            first, last = links
        else:
            last, first = links
        assert base_url in first
        assert 'rel="first"' in first
        assert 'page[number]=1' in first
        assert base_url in last
        assert 'rel="last"' in last
        assert 'page[number]=1' in last

    def test_pagination_with_query_parameter(self):
        """Tests that the URLs produced for pagination links include
        non-pagination query parameters from the original request URL.

        """
        query_string = {'foo': 'bar'}
        base_url = '/api/person'
        response = self.app.get(base_url, query_string=query_string)
        assert response.status_code == 200
        document = loads(response.data)
        pagination = document['links']
        base_url = '{0}?'.format(base_url)
        # There are no previous and next links in this case, so we only
        # check the first and last links.
        assert base_url in pagination['first']
        assert 'foo=bar' in pagination['first']
        assert base_url in pagination['last']
        assert 'foo=bar' in pagination['last']

    def test_sorting_null_field(self):
        """Tests that sorting by a nullable field causes resources with
        a null attribute value to appear first.

        """
        person1 = self.Person(id=1)
        person2 = self.Person(id=2, name=u'foo')
        person3 = self.Person(id=3, name=u'bar')
        person4 = self.Person(id=4)
        self.session.add_all([person1, person2, person3, person4])
        self.session.commit()
        query_string = {'sort': 'name'}
        response = self.app.get('/api/person', query_string=query_string)
        assert response.status_code == 200
        document = loads(response.data)
        people = document['data']
        assert len(people) == 4
        person_ids = list(map(itemgetter('id'), people))
        assert ['3', '2'] == person_ids[-2:]
        # TODO In Python 2.7 or later, this should be a set literal.
        assert set(['1', '4']) == set(person_ids[:2])


class TestFetchResource(ManagerTestBase):

    def setUp(self):
        super(TestFetchResource, self).setUp()

        # class Article(self.Base):
        #     __tablename__ = 'article'
        #     id = Column(Integer, primary_key=True)
        #     title = Column(Unicode, primary_key=True)

        class Person(self.Base):
            __tablename__ = 'person'
            id = Column(Integer, primary_key=True)

        # class Tag(self.Base):
        #     __tablename__ = 'tag'
        #     name = Column(Unicode, primary_key=True)

        # self.Article = Article
        self.Person = Person
        # self.Tag = Tag
        self.Base.metadata.create_all()
        # self.manager.create_api(Article)
        self.manager.create_api(Person)
        # self.manager.create_api(Tag)

    def test_jsonp(self):
        """Test for a JSON-P callback on a single resource request."""
        person = self.Person(id=1)
        self.session.add(person)
        self.session.commit()
        response = self.app.get('/api/person/1?callback=foo')
        assert response.data.startswith(b'foo(')
        assert response.data.endswith(b')')
        document = loads(response.data[4:-1])
        person = document['data']
        assert person['id'] == '1'

    @unittest.skip('Currently not supported')
    def test_alternate_primary_key(self):
        """Tests that models with primary keys that are not named ``id`` are
        are still accessible via their primary keys.

        """
        tag = self.Tag(name=u'foo')
        self.session.add(tag)
        self.session.commit()
        response = self.app.get('/api/tag/foo')
        document = loads(response.data)
        tag = document['data']
        assert tag['id'] == 'foo'

    @unittest.skip('Currently not supported')
    def test_primary_key_int_string(self):
        """Tests for getting a resource that has a string primary key,
        including the possibility of a string representation of a number.

        """
        tag = self.Tag(name=u'1')
        self.session.add(tag)
        self.session.commit()
        response = self.app.get('/api/tag/1')
        document = loads(response.data)
        tag = document['data']
        assert tag['attributes']['name'] == '1'
        assert tag['id'] == '1'

    @unittest.skip('Currently not supported')
    def test_specified_primary_key(self):
        """Tests that models with more than one primary key are accessible via
        a primary key specified by the server.

        """
        article = self.Article(id=1, title=u'foo')
        self.session.add(article)
        self.session.commit()
        self.manager.create_api(self.Article, url_prefix='/api2',
                                primary_key='title')
        response = self.app.get('/api2/article/1')
        assert response.status_code == 404
        response = self.app.get('/api2/article/foo')
        assert response.status_code == 200
        document = loads(response.data)
        resource = document['data']
        # Resource objects must have string IDs.
        assert resource['id'] == str(article.id)
        assert resource['title'] == article.title

    def test_collection_name(self):
        """Tests for fetching a single resource with an alternate collection
        name.

        """
        person = self.Person(id=1)
        self.session.add(person)
        self.session.commit()
        self.manager.create_api(self.Person, collection_name='people',
                                url_prefix='/api2')
        response = self.app.get('/api2/people/1')
        assert response.status_code == 200
        document = loads(response.data)
        person = document['data']
        assert person['id'] == '1'
        assert person['type'] == 'people'

    def test_attributes_in_url(self):
        """Tests that a user attempting to access an attribute in the
        URL instead of a relation yields a meaningful error response.

        For more information, see issue #213.

        """
        person = self.Person(id=1)
        self.session.add(person)
        self.session.commit()
        self.manager.create_api(self.Person)
        response = self.app.get('/api/person/1/id')
        check_sole_error(response, 404, ['No such relation', 'id'])


class TestFetchRelation(ManagerTestBase):

    def setUp(self):
        super(TestFetchRelation, self).setUp()

        class Article(self.Base):
            __tablename__ = 'article'
            id = Column(Integer, primary_key=True)
            title = Column(Unicode)
            author_id = Column(Integer, ForeignKey('person.id'))
            author = relationship('Person', backref=backref('articles'))

        class Person(self.Base):
            __tablename__ = 'person'
            id = Column(Integer, primary_key=True)

        self.Article = Article
        self.Person = Person
        self.Base.metadata.create_all()
        self.manager.create_api(Article)
        self.manager.create_api(Person)

    def test_nonexistent_resource(self):
        """Tests that a request for a relation on a nonexistent resource yields
        an error.

        """
        response = self.app.get('/api/person/bogus/articles')
        assert response.status_code == 404
        # TODO Check error message here.

    def test_nonexistent_relation(self):
        """Tests that a request for a nonexistent relation yields an error."""
        person = self.Person(id=1)
        self.session.add(person)
        self.session.commit()
        response = self.app.get('/api/person/1/bogus')
        assert response.status_code == 404
        # TODO Check error message here.

    def test_to_many_pagination(self):
        """Tests that fetching a to-many relation obeys pagination.

        For more information, see the `Pagination`_ section of the JSON
        API specification.

        .. _Pagination: http://jsonapi.org/format/#fetching-pagination

        """
        person = self.Person(id=1)
        articles = [self.Article(id=i) for i in range(10)]
        person.articles = articles
        self.session.add(person)
        self.session.add_all(articles)
        self.session.commit()
        params = {'page[number]': 3, 'page[size]': 2}
        base_url = '/api/person/1/articles'
        response = self.app.get(base_url, query_string=params)
        document = loads(response.data)
        articles = document['data']
        assert all(article['type'] == 'article' for article in articles)
        assert ['4', '5'] == sorted(article['id'] for article in articles)
        pagination = document['links']
        base_url = '{0}?'.format(base_url)
        assert base_url in pagination['first']
        assert 'page[number]=1' in pagination['first']
        assert base_url in pagination['last']
        assert 'page[number]=5' in pagination['last']
        assert base_url in pagination['prev']
        assert 'page[number]=2' in pagination['prev']
        assert base_url in pagination['next']
        assert 'page[number]=4' in pagination['next']

    def test_to_many_sorting(self):
        """Tests for sorting a to-many relation."""
        person = self.Person(id=1)
        article1 = self.Article(id=1, title=u'b')
        article2 = self.Article(id=2, title=u'c')
        article3 = self.Article(id=3, title=u'a')
        articles = [article1, article2, article3]
        person.articles = articles
        self.session.add(person)
        self.session.add_all(articles)
        self.session.commit()
        params = {'sort': '-title'}
        response = self.app.get('/api/person/1/articles', query_string=params)
        document = loads(response.data)
        articles = document['data']
        assert ['c', 'b', 'a'] == [article['attributes']['title']
                                   for article in articles]
        assert ['2', '1', '3'] == [article['id'] for article in articles]

    def test_to_many_grouping(self):
        """Tests for grouping a to-many relation."""
        person = self.Person(id=1)
        article1 = self.Article(id=1, title=u'b')
        article2 = self.Article(id=2, title=u'a')
        article3 = self.Article(id=3, title=u'b')
        articles = [article1, article2, article3]
        person.articles = articles
        self.session.add(person)
        self.session.add_all(articles)
        self.session.commit()
        params = {'group': 'title'}
        response = self.app.get('/api/person/1/articles', query_string=params)
        document = loads(response.data)
        articles = document['data']
        assert ['a', 'b'] == sorted(article['attributes']['title']
                                    for article in articles)


class TestFetchRelatedResource(ManagerTestBase):

    def setUp(self):
        super(TestFetchRelatedResource, self).setUp()

        class Article(self.Base):
            __tablename__ = 'article'
            id = Column(Integer, primary_key=True)
            author_id = Column(Integer, ForeignKey('person.id'))
            author = relationship('Person', backref=backref('articles'))

        class Person(self.Base):
            __tablename__ = 'person'
            id = Column(Integer, primary_key=True)

        self.Article = Article
        self.Person = Person
        self.Base.metadata.create_all()
        self.manager.create_api(Article)
        self.manager.create_api(Person)

    def test_nonexistent_related_resource(self):
        """Tests that a request for a nonexistent related resource yields an
        error.

        """
        person = self.Person(id=1)
        self.session.add(person)
        self.session.commit()
        response = self.app.get('/api/person/1/articles/1')
        assert response.status_code == 404
        # TODO Check error message here.

    def test_nonexistent_related_model(self):
        """Tests that a request for a nonexistent related model yields
        an error.

        """
        person = self.Person(id=1)
        self.session.add(person)
        self.session.commit()
        response = self.app.get('/api/person/1/bogus/1')
        check_sole_error(response, 404, ['No such relation', 'bogus'])

    def test_to_one_with_id(self):
        """Tests that a request to fetch a resource by its ID from a to-one
        relation yields an error.

        """
        article = self.Article(id=1)
        person = self.Person(id=1)
        article.author = person
        self.session.add_all([article, person])
        self.session.commit()
        response = self.app.get('/api/article/1/author/1')
        check_sole_error(response, 404, ['Cannot access', 'to-one',
                                         'related resource'])

    def test_related_resource(self):
        """Tests for fetching a single resource from a to-many relation.

        This is not required by the JSON API specification.

        """
        article = self.Article(id=1)
        person = self.Person(id=1)
        article.author = person
        self.session.add_all([article, person])
        self.session.commit()
        response = self.app.get('/api/person/1/articles/1')
        assert response.status_code == 200
        document = loads(response.data)
        article = document['data']
        assert article['id'] == '1'
        assert article['type'] == 'article'
        author = article['relationships']['author']['data']
        assert author['id'] == '1'
        assert author['type'] == 'person'

    def test_nonexistent_resource(self):
        """Tests that a request for a relation on a nonexistent resource yields
        an error.

        """
        article = self.Article(id=1)
        self.session.add(article)
        self.session.commit()
        response = self.app.get('/api/person/foo/articles/1')
        assert response.status_code == 404
        # TODO Check error message here.

    def test_nonexistent_relation(self):
        """Tests that a request for a nonexistent relation on a resource yields
        an error.

        """
        person = self.Person(id=1)
        self.session.add(person)
        self.session.commit()
        response = self.app.get('/api/person/1/bogus/1')
        assert response.status_code == 404
        # TODO Check error message here.


class TestFetchRelationship(ManagerTestBase):
    """Tests for fetching from a relationship URL."""

    def setUp(self):
        super(TestFetchRelationship, self).setUp()

        class Article(self.Base):
            __tablename__ = 'article'
            id = Column(Integer, primary_key=True)
            author_id = Column(Integer, ForeignKey('person.id'))
            author = relationship('Person', backref=backref('articles'))

        class Person(self.Base):
            __tablename__ = 'person'
            id = Column(Integer, primary_key=True)

        self.Article = Article
        self.Person = Person
        self.Base.metadata.create_all()
        self.manager.create_api(Article)
        self.manager.create_api(Person)

    def test_relationship_url_nonexistent_instance(self):
        """Tests that an attempt to fetch from a relationship URL for a
        resource that doesn't exist yields an error.

        """
        response = self.app.get('/api/person/bogus/relationships/articles')
        assert response.status_code == 404
        # TODO check error message here

    def test_relationship_url_nonexistent_relation(self):
        """Tests that an attempt to fetch from a relationship URL without
        specifying a relationship yields an error.

        """
        person = self.Person(id=1)
        self.session.add(person)
        self.session.commit()
        response = self.app.get('/api/person/1/relationships')
        assert response.status_code == 404
        # TODO check error message here


class TestServerSparseFieldsets(ManagerTestBase):
    """Tests for specifying default sparse fieldsets on the server."""

    def setUp(self):
        super(TestServerSparseFieldsets, self).setUp()

        class Person(self.Base):
            __tablename__ = 'person'
            id = Column(Integer, primary_key=True)
            name = Column(Unicode)
            age = Column(Integer)

        class Article(self.Base):
            __tablename__ = 'article'
            id = Column(Integer, primary_key=True)
            title = Column(Unicode)
            author_id = Column(Integer, ForeignKey('person.id'))
            author = relationship(Person, backref=backref('articles'))
            comments = relationship('Comment')

            def first_comment(self):
                return min(self.comments, key=lambda c: c.id)

        class Comment(self.Base):
            __tablename__ = 'comment'
            id = Column(Integer, primary_key=True)
            article_id = Column(Integer, ForeignKey('article.id'))
            article = relationship(Article)

        class Photo(self.Base):
            __tablename__ = 'photo'
            id = Column(Integer, primary_key=True)
            title = Column(Unicode)

            def website(self):
                return 'example.com'

            @property
            def year(self):
                return 2015

        self.Article = Article
        self.Comment = Comment
        self.Person = Person
        self.Photo = Photo
        self.Base.metadata.create_all()

    def test_only_column(self):
        """Tests for specifying that responses should only include certain
        column fields.

        """
        person = self.Person(id=1, name=u'foo')
        self.session.add(person)
        self.session.commit()
        self.manager.create_api(self.Person, only=['name'])
        response = self.app.get('/api/person/1')
        document = loads(response.data)
        person = document['data']
        assert ['attributes', 'id', 'type'] == sorted(person)
        assert ['name'] == sorted(person['attributes'])

    def test_only_relationship(self):
        """Tests for specifying that response should only include certain
        relationships.

        """
        person = self.Person(id=1)
        self.session.add(person)
        self.session.commit()
        self.manager.create_api(self.Person, only=['articles'])
        response = self.app.get('/api/person/1')
        document = loads(response.data)
        person = document['data']
        assert ['id', 'relationships', 'type'] == sorted(person)
        assert ['articles'] == sorted(person['relationships'])

    # # TODO This doesn't exactly make sense anymore; each type of included
    # # resource should really determine its own sparse fieldsets.
    # def test_only_on_included(self):
    #     """Tests for specifying that response should only include certain
    #     attributes of related models.

    #     """
    #     person = self.Person(id=1)
    #     article = self.Article(title='foo')
    #     article.author = person
    #     self.session.add_all([person, article])
    #     self.session.commit()
    #     only = ['articles', 'articles.title']
    #     self.manager.create_api(self.Person, only=only)
    #     response = self.app.get('/api/person/1?include=articles')
    #     document = loads(response.data)
    #     person = document['data']
    #     assert person['id'] == '1'
    #     assert person['type'] == 'person'
    #     assert 'name' not in person
    #     articles = person['relationships']['articles']['data']
    #     included = document['included']
    #     expected_ids = sorted(article['id'] for article in articles)
    #     actual_ids = sorted(article['id'] for article in included)
    #     assert expected_ids == actual_ids
    #     assert all('title' not in article for article in included)
    #     assert all('comments' in article['relationships'] for article in included)

    def test_only_as_objects(self):
        """Test for specifying included columns as SQLAlchemy column objects
        instead of strings.

        """
        person = self.Person(id=1, name=u'foo')
        self.session.add(person)
        self.session.commit()
        self.manager.create_api(self.Person, only=[self.Person.name])
        response = self.app.get('/api/person/1')
        document = loads(response.data)
        person = document['data']
        assert ['attributes', 'id', 'type'] == sorted(person)
        assert ['name'] == sorted(person['attributes'])

    def test_only_none(self):
        """Tests that providing an empty list as the list of fields to include
        in responses causes responses to have only the ``id`` and ``type``
        elements.

        """
        person = self.Person(id=1)
        self.session.add(person)
        self.session.commit()
        self.manager.create_api(self.Person, only=[])
        response = self.app.get('/api/person/1')
        document = loads(response.data)
        person = document['data']
        assert ['id', 'type'] == sorted(person)

    def test_additional_attributes(self):
        """Tests that additional attributes other than SQLAlchemy columns can
        be included in responses by default.

        """
        self.Person.foo = 'bar'
        person = self.Person(id=1)
        self.session.add(person)
        self.session.commit()
        self.manager.create_api(self.Person, additional_attributes=['foo'])
        response = self.app.get('/api/person/1')
        document = loads(response.data)
        person = document['data']
        assert person['attributes']['foo'] == 'bar'

    def test_additional_attributes_not_related(self):
        """Tests that we do not try to include additional attributes when
        requesting a related resource.

        For more information, see pull request #257.

        """
        self.Article.foo = 'bar'
        person = self.Person(id=1)
        article = self.Article(id=1)
        article.author = person
        self.session.add_all([person, article])
        self.session.commit()
        self.manager.create_api(self.Article, additional_attributes=['foo'])
        self.manager.create_api(self.Person)
        response = self.app.get('/api/article/1/author')
        document = loads(response.data)
        person = document['data']
        assert 'foo' not in person['attributes']

    def test_additional_attributes_callable(self):
        """Tests that callable attributes can be included using the
        ``additional_attributes`` keyword argument.

        """
        photo = self.Photo(id=1)
        self.session.add(photo)
        self.session.commit()
        self.manager.create_api(self.Photo, additional_attributes=['website'])
        response = self.app.get('/api/photo/1')
        document = loads(response.data)
        photo = document['data']
        assert photo['attributes']['website'] == 'example.com'

    def test_additional_attributes_property(self):
        """Tests that class properties can be included using the
        ``additional_attributes`` keyword argument.

        """
        photo = self.Photo(id=1)
        self.session.add(photo)
        self.session.commit()
        self.manager.create_api(self.Photo, additional_attributes=['year'])
        response = self.app.get('/api/photo/1')
        document = loads(response.data)
        photo = document['data']
        assert photo['attributes']['year'] == 2015

    def test_additional_attributes_object(self):
        """Tests that an additional attribute is serialized if it is an
        instance of a SQLAlchemy model.

        Technically, a resource's attribute MAY contain any valid JSON object,
        so this is allowed by the `JSON API specification`_.

        .. _JSON API specification: http://jsonapi.org/format/#document-structure-resource-object-attributes

        """
        article = self.Article(id=1)
        comment1 = self.Comment(id=1)
        comment2 = self.Comment(id=2)
        article.comments = [comment1, comment2]
        self.session.add_all([article, comment1, comment2])
        self.session.commit()

        def add_foo(instance, *args, **kw):
            result = simple_serialize(instance)
            if 'attributes' not in result:
                result['attributes'] = {}
            result['attributes']['foo'] = 'foo'
            return result

        self.manager.create_api(self.Article,
                                additional_attributes=['first_comment'])
        # Ensure that the comment object has a custom serialization
        # function, so we can test that it is serialized using this
        # function in particular.
        self.manager.create_api(self.Comment, serializer=add_foo)
        # HACK Need to create an API for this model because otherwise
        # we're not able to create the link URLs to them.
        self.manager.create_api(self.Person)

        response = self.app.get('/api/article/1')
        document = loads(response.data)
        article = document['data']
        first_comment = article['attributes']['first_comment']
        assert first_comment['id'] == '1'
        assert first_comment['type'] == 'comment'
        assert first_comment['attributes']['foo'] == 'foo'

    def test_exclude(self):
        """Test for excluding columns from a resource's representation."""
        person = self.Person(id=1)
        self.session.add(person)
        self.session.commit()
        self.manager.create_api(self.Person, exclude=['name'])
        response = self.app.get('/api/person/1')
        document = loads(response.data)
        person = document['data']
        assert 'name' not in person['attributes']

    # # TODO This doesn't exactly make sense anymore; each type of included
    # # resource should really determine its own sparse fieldsets.
    # def test_exclude_on_included(self):
    #     """Tests for specifying that response should exclude certain
    #     attributes of related models.
    #
    #     """
    #     person = self.Person(id=1)
    #     article = self.Article(title='foo')
    #     article.author = person
    #     self.session.add_all([person, article])
    #     self.session.commit()
    #     self.manager.create_api(self.Person, exclude=['articles.title'])
    #     response = self.app.get('/api/person/1?include=articles')
    #     document = loads(response.data)
    #     person = document['data']
    #     articles = person['relationships']['articles']['data']
    #     included = document['included']
    #     expected_ids = sorted(article['id'] for article in articles)
    #     actual_ids = sorted(article['id'] for article in included)
    #     assert expected_ids == actual_ids
    #     assert all('title' not in article for article in included)

    def test_exclude_as_objects(self):
        """Test for specifying excluded columns as SQLAlchemy column
        attributes.

        """
        person = self.Person(id=1)
        self.session.add(person)
        self.session.commit()
        self.manager.create_api(self.Person, exclude=[self.Person.name])
        response = self.app.get('/api/person/1')
        document = loads(response.data)
        person = document['data']
        assert 'name' not in person['attributes']

    def test_exclude_relations(self):
        """Tests for excluding relationships of a resource."""
        article = self.Article(id=1)
        comment = self.Comment()
        person = self.Person()
        article.author = person
        comment.article = article
        self.session.add_all([article, comment, person])
        self.session.commit()
        self.manager.create_api(self.Article, exclude=['comments'])
        # Create the APIs for the other models, just so they have URLs.
        self.manager.create_api(self.Person)
        self.manager.create_api(self.Comment)
        response = self.app.get('/api/article/1')
        document = loads(response.data)
        article = document['data']
        assert 'comments' not in article['relationships']
        author = article['relationships']['author']['data']
        assert '1' == author['id']
        assert 'person' == author['type']


class TestProcessors(ManagerTestBase):
    """Tests for pre- and postprocessors."""

    def setUp(self):
        """Creates the database, the :class:`~flask.Flask` object, the
        :class:`~flask_restless.manager.APIManager` for that application, and
        creates the ReSTful API endpoints for the :class:`TestSupport.Person`
        and :class:`TestSupport.Article` models.

        """
        super(TestProcessors, self).setUp()

        class Person(self.Base):
            __tablename__ = 'person'
            id = Column(Integer, primary_key=True)
            name = Column(Unicode)
            articles = relationship('Article')

        class Article(self.Base):
            __tablename__ = 'article'
            id = Column(Integer, primary_key=True)
            author_id = Column(Integer, ForeignKey('person.id'))
            author = relationship(Person)

        self.Article = Article
        self.Person = Person
        self.Base.metadata.create_all()

    def test_single_resource_processing_exception(self):
        """Tests for a preprocessor that raises a :exc:`ProcessingException`
        when fetching a single resource.

        """
        person = self.Person(id=1)
        self.session.add(person)
        self.session.commit()

        def forbidden(**kw):
            raise ProcessingException(status=403, detail='forbidden')

        preprocessors = dict(GET_RESOURCE=[forbidden])
        self.manager.create_api(self.Person, preprocessors=preprocessors)
        response = self.app.get('/api/person/1')
        assert response.status_code == 403
        document = loads(response.data)
        errors = document['errors']
        assert len(errors) == 1
        error = errors[0]
        assert 'forbidden' == error['detail']

    def test_collection_processing_exception(self):
        """Tests for a preprocessor that raises a :exc:`ProcessingException`
        when fetching a collection of resources.

        """

        def forbidden(**kw):
            raise ProcessingException(status=403, detail='forbidden')

        preprocessors = dict(GET_COLLECTION=[forbidden])
        self.manager.create_api(self.Person, preprocessors=preprocessors)
        response = self.app.get('/api/person')
        assert response.status_code == 403
        document = loads(response.data)
        errors = document['errors']
        assert len(errors) == 1
        error = errors[0]
        assert 'forbidden' == error['detail']

    def test_resource(self):
        """Tests for running a preprocessor on a request to fetch a
        single resource.

        """
        person = self.Person(id=1)
        self.session.add(person)
        self.session.commit()

        data = {'triggered': False}

        def update_data(*args, **kw):
            data['triggered'] = True

        preprocessors = {'GET_RESOURCE': [update_data]}
        self.manager.create_api(self.Person, preprocessors=preprocessors)
        self.app.get('/api/person/1')
        assert data['triggered']

    def test_change_id(self):
        """Tests that a return value from a preprocessor overrides the ID of
        the resource to fetch as given in the request URL.

        """
        person = self.Person(id=1, name=u'foo')
        self.session.add(person)
        self.session.commit()

        def increment_id(resource_id=None, **kw):
            if resource_id is None:
                raise ProcessingException
            return int(resource_id) + 1

        preprocessors = dict(GET_RESOURCE=[increment_id])
        self.manager.create_api(self.Person, preprocessors=preprocessors)
        response = self.app.get('/api/person/0')
        assert response.status_code == 200
        document = loads(response.data)
        person = document['data']
        assert person['id'] == '1'
        assert person['attributes']['name'] == 'foo'

    def test_change_relation(self):
        """Tests for changing the resource ID when fetching a relation.

        """
        person = self.Person(id=1)
        article = self.Article(id=1)
        article.author = person
        self.session.add_all([article, person])
        self.session.commit()

        def change_id(*args, **kw):
            # We will change the primary resource ID.
            return 1

        preprocessors = {'GET_RELATION': [change_id]}
        self.manager.create_api(self.Person, preprocessors=preprocessors)
        # Need to create an API for Article resources so that each
        # Article has a URL.
        self.manager.create_api(self.Article)
        response = self.app.get('/api/person/bogus/articles')
        document = loads(response.data)
        articles = document['data']
        assert len(articles) == 1
        article = articles[0]
        assert 'article' == article['type']
        assert '1' == article['id']

    def test_relationship(self):
        """Tests for running a preprocessor on a request to fetch a
        relationship.

        """
        person = self.Person(id=1)
        article = self.Article(id=1)
        article.author = person
        self.session.add_all([article, person])
        self.session.commit()

        data = {'triggered': False}

        def update_data(*args, **kw):
            data['triggered'] = True

        preprocessors = {'GET_RELATIONSHIP': [update_data]}
        self.manager.create_api(self.Person, preprocessors=preprocessors)
        # Need to create an API for Article resources so that each
        # Article has a URL.
        self.manager.create_api(self.Article)
        self.app.get('/api/person/1/relationships/articles')
        assert data['triggered']

    def test_change_id_relationship(self):
        """Tests for changing the resource ID when fetching a
        relationship.

        """
        person = self.Person(id=1)
        article = self.Article(id=1)
        article.author = person
        self.session.add_all([article, person])
        self.session.commit()

        def change_id(*args, **kw):
            # We will change the primary resource ID.
            return 1

        preprocessors = {'GET_RELATIONSHIP': [change_id]}
        self.manager.create_api(self.Person, preprocessors=preprocessors)
        # Need to create an API for Article resources so that each
        # Article has a URL.
        self.manager.create_api(self.Article)
        response = self.app.get('/api/person/bogus/relationships/articles')
        document = loads(response.data)
        articles = document['data']
        assert len(articles) == 1
        article = articles[0]
        assert 'article' == article['type']
        assert '1' == article['id']

    def test_relation(self):
        """Tests that a preprocessor is executed when fetching a
        relation.

        """
        person = self.Person(id=1)
        article = self.Article(id=1)
        article.author = person
        self.session.add_all([article, person])
        self.session.commit()

        data = {'triggered': False}

        def update_data(*args, **kw):
            data['triggered'] = True

        preprocessors = {'GET_RELATION': [update_data]}
        self.manager.create_api(self.Person, preprocessors=preprocessors)
        # Need to create an API for Article resources so that each
        # Article has a URL.
        self.manager.create_api(self.Article)
        self.app.get('/api/person/1/articles')
        assert data['triggered']

    def test_change_relation_2(self):
        """Tests for changing the primary resource ID and the relation
        name in a preprocessor for fetching a relation.

        """
        person = self.Person(id=1)
        article = self.Article(id=1)
        article.author = person
        self.session.add_all([article, person])
        self.session.commit()

        def change_two(*args, **kw):
            # We will change the primary resource ID and the relation name.
            return 1, 'articles'

        preprocessors = {'GET_RELATION': [change_two]}
        self.manager.create_api(self.Person, preprocessors=preprocessors)
        # Need to create an API for Article resources so that each
        # Article has a URL.
        self.manager.create_api(self.Article)
        response = self.app.get('/api/person/foo/bar')
        document = loads(response.data)
        articles = document['data']
        assert len(articles) == 1
        article = articles[0]
        assert 'article' == article['type']
        assert '1' == article['id']

    def test_related_resource(self):
        """Tests that a preprocessor is executed when fetching a
        related resource from a to-many relation.

        """
        person = self.Person(id=1)
        article = self.Article(id=1)
        article.author = person
        self.session.add_all([article, person])
        self.session.commit()

        data = {'triggered': False}

        def update_data(*args, **kw):
            data['triggered'] = True

        preprocessors = {'GET_RELATED_RESOURCE': [update_data]}
        self.manager.create_api(self.Person, preprocessors=preprocessors)
        # Need to create an API for Article resources so that each
        # Article has a URL.
        self.manager.create_api(self.Article)
        self.app.get('/api/person/1/articles/1')
        assert data['triggered']

    def test_change_related_resource_1(self):
        """Tests for changing the primary resource ID in a preprocessor
        for fetching a related resource.

        """
        person = self.Person(id=1)
        article = self.Article(id=1)
        article.author = person
        self.session.add_all([article, person])
        self.session.commit()

        def change_one(*args, **kw):
            # We will change the primary resource ID only.
            return 1

        preprocessors = {'GET_RELATED_RESOURCE': [change_one]}
        self.manager.create_api(self.Person, preprocessors=preprocessors)
        # Need to create an API for Article resources so that each
        # Article has a URL.
        self.manager.create_api(self.Article)
        response = self.app.get('/api/person/foo/articles/1')
        document = loads(response.data)
        resource = document['data']
        assert 'article' == resource['type']
        assert '1' == resource['id']

    def test_change_related_resource_2(self):
        """Tests for changing the primary resource ID and the relation
        name in a preprocessor for fetching a related resource.

        """
        person = self.Person(id=1)
        article = self.Article(id=1)
        article.author = person
        self.session.add_all([article, person])
        self.session.commit()

        def change_two(*args, **kw):
            # We will change the primary resource ID and the relation name.
            return 1, 'articles'

        preprocessors = {'GET_RELATED_RESOURCE': [change_two]}
        self.manager.create_api(self.Person, preprocessors=preprocessors)
        # Need to create an API for Article resources so that each
        # Article has a URL.
        self.manager.create_api(self.Article)
        response = self.app.get('/api/person/foo/bar/1')
        document = loads(response.data)
        resource = document['data']
        assert 'article' == resource['type']
        assert '1' == resource['id']

    def test_change_related_resource_3(self):
        """Tests for changing the primary resource ID, the relation
        name, and the related resource ID in a preprocessor for fetching
        a related resource.

        """
        person = self.Person(id=1)
        article = self.Article(id=1)
        article.author = person
        self.session.add_all([article, person])
        self.session.commit()

        def change_three(*args, **kw):
            # We will change the primary resource ID, the relation name,
            # and the related resource ID.
            return 1, 'articles', 1

        preprocessors = {'GET_RELATED_RESOURCE': [change_three]}
        self.manager.create_api(self.Person, preprocessors=preprocessors)
        # Need to create an API for Article resources so that each
        # Article has a URL.
        self.manager.create_api(self.Article)
        response = self.app.get('/api/person/foo/bar/baz')
        document = loads(response.data)
        resource = document['data']
        assert 'article' == resource['type']
        assert '1' == resource['id']

    def test_last_preprocessor_changes_id(self):
        """Tests that a return value from the last preprocessor in the list
        overrides the ID of the resource to fetch as given in the request URL.

        """
        person = self.Person(id=2, name=u'foo')
        self.session.add(person)
        self.session.commit()

        def increment_id(resource_id=None, **kw):
            if resource_id is None:
                raise ProcessingException
            return int(resource_id) + 1

        preprocessors = dict(GET_RESOURCE=[increment_id, increment_id])
        self.manager.create_api(self.Person, preprocessors=preprocessors)
        response = self.app.get('/api/person/0')
        assert response.status_code == 200
        document = loads(response.data)
        person = document['data']
        assert person['id'] == '2'
        assert person['attributes']['name'] == 'foo'

    def test_no_client_filters(self):
        """Tests that a preprocessor can modify the filter objects in a
        request, even if the client did not specify any ``filter[objects]``
        query parameter.

        """
        person1 = self.Person(id=1)
        person2 = self.Person(id=2)
        self.session.add_all([person1, person2])
        self.session.commit()

        def restrict_ids(filters=None, **kw):
            """Adds an additional filter to any existing filters that restricts
            which resources appear in the response.

            """
            if filters is None:
                raise ProcessingException
            filt = dict(name='id', op='lt', val=2)
            filters.append(filt)

        preprocessors = dict(GET_COLLECTION=[restrict_ids])
        self.manager.create_api(self.Person, preprocessors=preprocessors)
        response = self.app.get('/api/person')
        assert response.status_code == 200
        document = loads(response.data)
        people = document['data']
        assert ['1'] == sorted(person['id'] for person in people)

    def test_add_filters(self):
        """Tests that a preprocessor can modify the filter objects provided by
        the client in the ``filter[objects]`` query parameter.

        """
        person1 = self.Person(id=1)
        person2 = self.Person(id=2)
        person3 = self.Person(id=3)
        self.session.add_all([person1, person2, person3])
        self.session.commit()

        def restrict_ids(filters=None, **kw):
            """Adds an additional filter to any existing filters that restricts
            which resources appear in the response.

            """
            if filters is None:
                raise ProcessingException
            filt = dict(name='id', op='lt', val=2)
            filters.append(filt)

        preprocessors = dict(GET_COLLECTION=[restrict_ids])
        self.manager.create_api(self.Person, preprocessors=preprocessors)
        filters = [dict(name='id', op='in', val=[1, 3])]
        query = {'filter[objects]': dumps(filters)}
        response = self.app.get('/api/person', query_string=query)
        assert response.status_code == 200
        document = loads(response.data)
        people = document['data']
        assert ['1'] == sorted(person['id'] for person in people)

    def test_collection_postprocessor(self):
        """Tests that a postprocessor for a collection endpoint has access to
        the filters specified by the client.

        """
        client_filters = [dict(name='id', op='eq', val=1)]

        def check_filters(filters=None, **kw):
            """Assert that the filters that Flask-Restless understood from the
            request are the same filter objects provided by the client.

            """
            assert filters == client_filters

        postprocessors = dict(GET_COLLECTION=[check_filters])
        self.manager.create_api(self.Person, postprocessors=postprocessors)
        query_string = {'filter[objects]': dumps(client_filters)}
        response = self.app.get('/api/person', query_string=query_string)
        assert response.status_code == 200

    def test_resource_postprocessor(self):
        """Tests for a postprocessor for a single resource."""
        person = self.Person(id=1)
        self.session.add(person)
        self.session.commit()

        def modify_result(result=None, **kw):
            result['foo'] = 'bar'

        postprocessors = dict(GET_RESOURCE=[modify_result])
        self.manager.create_api(self.Person, postprocessors=postprocessors)
        response = self.app.get('/api/person/1')
        assert response.status_code == 200
        document = loads(response.data)
        assert document['foo'] == 'bar'


class TestDynamicRelationships(ManagerTestBase):
    """Tests for fetching resources from dynamic to-many relationships."""

    def setUp(self):
        """Creates the database, the :class:`~flask.Flask` object, the
        :class:`~flask_restless.manager.APIManager` for that application, and
        creates the ReSTful API endpoints for the :class:`TestSupport.Person`
        and :class:`TestSupport.Article` models.

        """
        super(TestDynamicRelationships, self).setUp()

        class Article(self.Base):
            __tablename__ = 'article'
            id = Column(Integer, primary_key=True)
            author_id = Column(Integer, ForeignKey('person.id'))
            author = relationship('Person')

        class Person(self.Base):
            __tablename__ = 'person'
            id = Column(Integer, primary_key=True)
            articles = relationship(Article, lazy='dynamic')

        self.Article = Article
        self.Person = Person
        self.Base.metadata.create_all()
        self.manager.create_api(Article)
        self.manager.create_api(Person)

    def test_to_many(self):
        """Tests for fetching a resource with a dynamic link to a to-many
        relation.

        """
        person = self.Person(id=1)
        article1 = self.Article(id=1)
        article2 = self.Article(id=2)
        article1.author = person
        article2.author = person
        self.session.add_all([person, article1, article2])
        self.session.commit()
        response = self.app.get('/api/person/1')
        document = loads(response.data)
        person = document['data']
        articles = person['relationships']['articles']['data']
        assert ['1', '2'] == sorted(article['id'] for article in articles)

    def test_related_resource_url(self):
        """Tests for fetching a resource with a dynamic link to a to-many
        relation from the related resource URL.

        """
        person = self.Person(id=1)
        article1 = self.Article(id=1)
        article2 = self.Article(id=2)
        person.articles = [article1, article2]
        self.session.add_all([person, article1, article2])
        self.session.commit()
        response = self.app.get('/api/person/1/articles')
        assert response.status_code == 200
        document = loads(response.data)
        articles = document['data']
        assert ['1', '2'] == sorted(article['id'] for article in articles)
        assert all(article['type'] == 'article' for article in articles)

    def test_relationship_url(self):
        """Tests for fetching a resource with a dynamic link to a to-many
        relation from the relationship URL.

        """
        person = self.Person(id=1)
        article1 = self.Article(id=1)
        article2 = self.Article(id=2)
        person.articles = [article1, article2]
        self.session.add_all([person, article1, article2])
        self.session.commit()
        response = self.app.get('/api/person/1/relationships/articles')
        assert response.status_code == 200
        document = loads(response.data)
        articles = document['data']
        assert ['1', '2'] == sorted(article['id'] for article in articles)
        assert all(article['type'] == 'article' for article in articles)


class TestAssociationProxy(ManagerTestBase):
    """Tests for getting an object with a relationship using an association
    proxy.

    """

    def setUp(self):
        """Creates the database, the :class:`~flask.Flask` object, the
        :class:`~flask_restless.manager.APIManager` for that application,
        and creates the ReSTful API endpoints for the models used in the test
        methods.

        """
        super(TestAssociationProxy, self).setUp()

        class Article(self.Base):
            __tablename__ = 'article'
            id = Column(Integer, primary_key=True)
            tags = association_proxy('articletags', 'tag',
                                     creator=lambda tag: ArticleTag(tag=tag))
            # tag_names = association_proxy('tags', 'name',
            #                               creator=lambda name: Tag(name=name))

        class ArticleTag(self.Base):
            __tablename__ = 'articletag'
            article_id = Column(Integer, ForeignKey('article.id'),
                                primary_key=True)
            article = relationship(Article, backref=backref('articletags'))
            tag_id = Column(Integer, ForeignKey('tag.id'), primary_key=True)
            tag = relationship('Tag')
            # TODO this dummy column is required to create an API for this
            # object.
            id = Column(Integer)

        class Tag(self.Base):
            __tablename__ = 'tag'
            id = Column(Integer, primary_key=True)
            name = Column(Unicode)

        self.Article = Article
        self.Tag = Tag
        self.Base.metadata.create_all()
        self.manager.create_api(Article)
        # HACK Need to create APIs for these other models because otherwise
        # we're not able to create the link URLs to them.
        #
        # TODO Fix this by simply not creating links to related models for
        # which no API has been made.
        self.manager.create_api(Tag)
        self.manager.create_api(ArticleTag)

    def test_fetch(self):
        """Test for fetching a resource that has a many-to-many relation that
        uses an association proxy.

        """
        article = self.Article(id=1)
        tag = self.Tag(id=1)
        article.tags.append(tag)
        self.session.add_all([article, tag])
        self.session.commit()
        response = self.app.get('/api/article/1')
        document = loads(response.data)
        article = document['data']
        tags = article['relationships']['tags']['data']
        assert ['1'] == sorted(tag['id'] for tag in tags)

    @unittest.skip('Not sure how to implement this.')
    def test_scalar(self):
        """Tests for fetching an association proxy to scalars as a list
        attribute instead of a link object.

        """
        article = self.Article(id=1)
        tag1 = self.Tag(name=u'foo')
        tag2 = self.Tag(name=u'bar')
        article.tags = [tag1, tag2]
        self.session.add_all([article, tag1, tag2])
        self.session.commit()
        response = self.app.get('/api/article/1')
        document = loads(response.data)
        article = document['data']
        assert ['bar', 'foo'] == sorted(article['attributes']['tag_names'])


class TestFlaskSQLAlchemy(FlaskSQLAlchemyTestBase):
    """Tests for fetching resources defined as Flask-SQLAlchemy models
    instead of pure SQLAlchemy models.

    """

    def setUp(self):
        """Creates the Flask-SQLAlchemy database and models."""
        super(TestFlaskSQLAlchemy, self).setUp()

        class Person(self.db.Model):
            id = self.db.Column(self.db.Integer, primary_key=True)

        self.Person = Person
        self.db.create_all()
        self.manager = APIManager(self.flaskapp, flask_sqlalchemy_db=self.db)
        self.manager.create_api(self.Person)

    def test_fetch_resource(self):
        """Test for fetching a resource."""
        person = self.Person(id=1)
        self.session.add(person)
        self.session.commit()
        response = self.app.get('/api/person/1')
        document = loads(response.data)
        person = document['data']
        assert person['id'] == '1'
        assert person['type'] == 'person'

    def test_fetch_collection(self):
        """Test for fetching a collection of resource."""
        person1 = self.Person(id=1)
        person2 = self.Person(id=2)
        self.session.add_all([person1, person2])
        self.session.commit()
        response = self.app.get('/api/person')
        document = loads(response.data)
        people = document['data']
        assert ['1', '2'] == sorted(person['id'] for person in people)
