Skip to content
This repository has been archived by the owner on Jul 21, 2019. It is now read-only.

Commit

Permalink
Add Tracker API object models and integration tests
Browse files Browse the repository at this point in the history
  • Loading branch information
textbook committed Mar 11, 2016
1 parent b1fc7b1 commit 6eb5d0c
Show file tree
Hide file tree
Showing 9 changed files with 388 additions and 11 deletions.
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ can be selected using the environment variable `FLASK_CONFIG`:
* `dev`: For local development, which starts the app in debug mode;
* `test`: For testing and continuous integration environments (e.g.
Travis CI), which starts the app in testing mode and also requires:
* `ACCESSIBLE_PROJECT`: A project accessible using the supplied API
token; and
* `VALID_API_TOKEN`: A valid token for integration testing; and
* `prod` [**default**]: For production environments (e.g. Cloud
Foundry), which starts the app in its default mode and also requires:
Expand All @@ -31,7 +33,17 @@ In addition, for automatic deployment from Travis to Cloud Foundry, the
following environment variables are required: `CF_ORG`; `CF_SPACE`;
`CF_USERNAME`; and `CF_PASSWORD`.

The easiest way to set up a database locally, assuming that you're on OS
X with [Homebrew], is:

brew install postgresql
psql -c 'create database flask_test;' -U postgres

This creates the required test DB using the default PostgreSQL user.

[1]: https://travis-ci.org/textbook/flask-forecaster.svg?branch=master
[2]: https://travis-ci.org/textbook/flask-forecaster
[3]: https://coveralls.io/repos/github/textbook/flask-forecaster/badge.svg?branch=master
[4]: https://coveralls.io/github/textbook/flask-forecaster?branch=master

[Homebrew]: http://brew.sh/
1 change: 1 addition & 0 deletions flask_forecaster/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ def for_current_env(cls, env_var='FLASK_CONFIG', default='prod'):
)
elif env == 'test':
config_vars = dict(
PROJECT_ID=_require('ACCESSIBLE_PROJECT'),
VALID_TOKEN=_require('VALID_API_TOKEN'),
TESTING=True,
)
Expand Down
8 changes: 4 additions & 4 deletions flask_forecaster/flask_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@
from flask import Flask, redirect, render_template, session, url_for
from flask.ext.sqlalchemy import SQLAlchemy # pylint: disable=no-name-in-module,import-error

from .config import Config
from .forms import TrackerApiForm
from .tracker import Tracker
from flask_forecaster.config import Config
from flask_forecaster.forms import TrackerApiForm
from flask_forecaster.tracker import Tracker

logger = logging.getLogger(__name__)

__version__ = '0.0.4'
__version__ = '0.0.5'

app = Flask(__name__)
app.config.from_object(Config.for_current_env())
Expand Down
29 changes: 27 additions & 2 deletions flask_forecaster/tracker/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
import logging
import requests

from ..constants import HttpStatus
from flask_forecaster.constants import HttpStatus
from flask_forecaster.tracker.models import ProjectSnapshot

logger = logging.getLogger(__name__)

Expand All @@ -29,13 +30,37 @@ def get_project(self, project_id):
"""
response = requests.get(
self.BASE_URL + 'projects/' + str(project_id),
self.BASE_URL + 'projects/{}'.format(project_id),
headers=self.headers,
)
result = self._handle_response(response)
if result is not None and 'error' not in result:
return result

def get_project_history(self, project_id, convert=False):
"""Get the history for a specified project.
Arguments:
project_id (:py:class:`int` or :py:class:`str`): The ID of
the project.
convert (:py:class:`bool`, optional): Whether to convert the
JSON data into model objects (defaults to ``False``).
Returns:
:py:class:`list`: The history data.
"""
response = requests.get(
self.BASE_URL + 'projects/{}/history/snapshots'.format(project_id),
headers=self.headers,

)
result = self._handle_response(response)
if result is not None and 'error' not in result:
if not convert:
return result
return [ProjectSnapshot.from_response(data) for data in result]

@staticmethod
def _handle_response(response):
"""Handle the standard response pattern."""
Expand Down
145 changes: 145 additions & 0 deletions flask_forecaster/tracker/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
from abc import ABCMeta, abstractmethod
import datetime
from enum import Enum, unique


class _AutoNumber(Enum):
"""Auto-numbered enumerator from the `Python Docs`_.
.. _Python Docs:
https://docs.python.org/3/library/enum.html#autonumber
"""

def __new__(cls):
value = len(cls.__members__) + 1 # pylint: disable=no-member
obj = object.__new__(cls)
obj._value_ = value # pylint: disable=protected-access
return obj


@unique
class StoryState(_AutoNumber):
"""Valid states for a story."""
accepted = ()
delivered = ()
finished = ()
started = ()
rejected = ()
planned = ()
unstarted = ()
unscheduled = ()


@unique
class StoryType(_AutoNumber):
"""Valid types for a story."""
feature = ()
bug = ()
chore = ()
release = ()


class _BaseModel(metaclass=ABCMeta):
"""Abstract base class for the models."""

@classmethod
@abstractmethod
def from_response(cls, response):
"""Create a new instance from an API response."""
raise NotImplementedError


class ProjectSnapshot(_BaseModel):
"""Represents the `Project Snapshot resource`_.
Arguments:
date (:py:class:`str`): The date of the snapshot, in the
format YYYY-MM-DD.
.. _Project Snapshot resource:
https://www.pivotaltracker.com/help/api/rest/v5#project_snapshot_resource
"""

def __init__(self, date):
self.backlog = []
self.current = []
self.date = datetime.datetime.strptime(date, '%Y-%m-%d').date()
self.icebox = []

@classmethod
def from_response(cls, response):
"""Create a new instance from an API response.
Arguments:
response (:py:class:`dict`): The parsed JSON from the API.
"""
if response.get('kind') != 'project_snapshot':
raise TypeError('ProjectSnapshot needs a project_snapshot resource')
snapshot = cls(response['date'])
for story_list in ('backlog', 'current', 'icebox'):
for story in response.get(story_list, []):
getattr(snapshot, story_list).append(
StorySnapshot.from_response(story)
)
return snapshot


class StorySnapshot(_BaseModel):
"""Represents the `Story Snapshot resource`_.
.. _StorySnapshot resource:
https://www.pivotaltracker.com/help/api/rest/v5#story_snapshot_resource
"""

def __init__(self,
state=StoryState.unscheduled,
story_type=StoryType.feature):
if state not in StoryState:
raise TypeError('state must be a StoryState')
if story_type not in StoryType:
raise TypeError('story_type must be a StoryType')
self.accepted_at = None
self.estimate = 0
self.story_id = None
self.state = state
self.story_type = story_type

@property
def accepted(self):
"""Whether the story has been accepted yet."""
return self.accepted_at is not None

@classmethod
def from_response(cls, response):
"""Create a new instance from an API response.
Arguments:
response (:py:class:`dict`): The parsed JSON from the API.
"""
if response.get('kind') != 'story_snapshot':
raise TypeError('StorySnapshot needs a story_snapshot resource')
# It appears that pylint doesn't play nice with Enum...
# pylint: disable=unsubscriptable-object
snapshot = cls(
state=StoryState[response['state']],
story_type=StoryType[response['story_type']],
)
# pylint: enable=unsubscriptable-object
if 'accepted_at' in response:
try:
snapshot.accepted_at = datetime.datetime.strptime(
response['accepted_at'],
'%Y-%m-%dT%H:%M:%SZ',
)
except TypeError:
snapshot.accepted_at = datetime.datetime.utcfromtimestamp(
response['accepted_at'] // 1000
)
snapshot.estimate = response.get('estimate', 0)
snapshot.story_id = response['story_id']
return snapshot
2 changes: 1 addition & 1 deletion tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ def test_default_env(self, getenv):

@pytest.mark.parametrize('env,debug,testing,requires', [
('dev', True, False, []),
('test', False, True, ['VALID_API_TOKEN']),
('test', False, True, ['VALID_API_TOKEN', 'ACCESSIBLE_PROJECT']),
('prod', False, False, ['FLASK_SECRET_KEY', 'PORT']),
])
@mock.patch('flask_forecaster.config._require')
Expand Down
78 changes: 74 additions & 4 deletions tests/tracker/test_tracker_api.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import json
from datetime import date
from textwrap import dedent

import pytest
import responses

from flask_forecaster.tracker import Tracker
from flask_forecaster.tracker import Tracker, models


@pytest.fixture
Expand All @@ -20,7 +24,9 @@ def test_token_valid(self):
json={'projects': projects},
status=200,
)

result = Tracker.validate_token('hello')

assert len(responses.calls) == 1
assert responses.calls[0].request.headers.get('X-TrackerToken') == 'hello'
assert result == projects
Expand All @@ -30,10 +36,12 @@ def test_token_invalid(self):
responses.add(
responses.GET,
'https://www.pivotaltracker.com/services/v5/me',
json={'error': 'something went horribly wrong'},
body='{"error": "something went horribly wrong"}',
status=200,
)

result = Tracker.validate_token('hello')

calls = responses.calls
assert len(calls) == 1
assert calls[0].request.headers.get('X-TrackerToken') == 'hello'
Expand All @@ -46,25 +54,87 @@ def test_token_failed(self):
'https://www.pivotaltracker.com/services/v5/me',
status=404,
)

result = Tracker.validate_token('hello')

calls = responses.calls
assert len(calls) == 1
assert calls[0].request.headers.get('X-TrackerToken') == 'hello'
assert result is None

class TestProjectFetch:
class TestGetProject:

@responses.activate
def test_get_project_data(self, api):
project = dict(name='demo', description='some stupid project')
responses.add(
responses.GET,
'https://www.pivotaltracker.com/services/v5/projects/123',
json=project,
body=json.dumps(project),
status=200,
)

result = api.get_project(123)

calls = responses.calls
assert len(calls) == 1
assert calls[0].request.headers.get('X-TrackerToken') == 'hello'
assert result == project

class TestGetProjectSnapshot:

@responses.activate
def test_get_project_history(self, api):
data = self._project_data()
responses.add(
responses.GET,
'https://www.pivotaltracker.com/services/v5/projects/123/history/snapshots',
body=data,
status=200,
)

result = api.get_project_history(123)

calls = responses.calls
assert len(calls) == 1
assert calls[0].request.headers.get('X-TrackerToken') == 'hello'
assert result == json.loads(data)

@responses.activate
def test_get_converted_project_history(self, api):
data = self._project_data()
responses.add(
responses.GET,
'https://www.pivotaltracker.com/services/v5/projects/123/history/snapshots',
body=data,
status=200,
)

result = api.get_project_history(123, True)

calls = responses.calls
assert len(calls) == 1
assert calls[0].request.headers.get('X-TrackerToken') == 'hello'
assert result[0].date == date(2016, 2, 28)
assert result[0].current[0].story_type == models.StoryType.feature

@staticmethod
def _project_data():
return dedent("""
[
{
"kind": "project_snapshot",
"date": "2016-02-28",
"current":
[
{
"kind": "story_snapshot",
"story_id": 555,
"state": "unstarted",
"estimate": 1,
"story_type": "feature"
}
]
}
]
""")
12 changes: 12 additions & 0 deletions tests/tracker/test_tracker_api_integration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from flask_forecaster.tracker import Tracker
from flask_forecaster.tracker.models import ProjectSnapshot

from tests.helpers import slow


@slow
def test_project_api(config):
api = Tracker.from_untrusted_token(config['VALID_TOKEN'])
history = api.get_project_history(config['PROJECT_ID'], True)
if history:
assert isinstance(history[0], ProjectSnapshot)
Loading

0 comments on commit 6eb5d0c

Please sign in to comment.