Skip to content

Commit 2f039e7

Browse files
committed
Fixed #383 -- Added summaries to deis releases:list
1 parent 2980404 commit 2f039e7

9 files changed

Lines changed: 383 additions & 20 deletions

File tree

api/migrations/0006_auto__add_field_release_summary.py

Lines changed: 208 additions & 0 deletions
Large diffs are not rendered by default.

api/models.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323

2424
from api import fields, tasks
2525
from provider import import_provider_module
26+
from utils import dict_diff
27+
2628

2729
# import user-defined configuration management module
2830
CM = importlib.import_module(settings.CM_MODULE)
@@ -789,6 +791,7 @@ class Release(UuidAuditedModel):
789791
owner = models.ForeignKey(settings.AUTH_USER_MODEL)
790792
app = models.ForeignKey('App')
791793
version = models.PositiveIntegerField()
794+
summary = models.TextField(blank=True, null=True)
792795

793796
config = models.ForeignKey('Config')
794797
build = models.ForeignKey('Build', blank=True, null=True)
@@ -801,6 +804,57 @@ class Meta:
801804
def __str__(self):
802805
return "{0}-v{1}".format(self.app.id, self.version)
803806

807+
def previous(self):
808+
"""
809+
Return the previous Release to this one.
810+
811+
:return: the previous :class:`Release`, or None
812+
"""
813+
releases = self.app.release_set
814+
if self.pk:
815+
releases = releases.exclude(pk=self.pk)
816+
try:
817+
# Get the Release previous to this one
818+
prev_release = releases.latest()
819+
except Release.DoesNotExist:
820+
prev_release = None
821+
return prev_release
822+
823+
def save(self, *args, **kwargs):
824+
if not self.summary:
825+
self.summary = ''
826+
prev_release = self.previous()
827+
# compare this build to the previous build
828+
old_build = prev_release.build if prev_release else None
829+
# if the build changed, log it and who pushed it
830+
if self.build != old_build and self.build.sha:
831+
self.summary += "{} deployed {}".format(self.build.owner, self.build.sha[:7])
832+
# compare this config to the previous config
833+
old_config = prev_release.config if prev_release else None
834+
# if the config data changed, log the dict diff
835+
if self.config != old_config:
836+
dict1 = self.config.values
837+
dict2 = old_config.values if old_config else {}
838+
diff = dict_diff(dict1, dict2)
839+
# try to be as succinct as possible
840+
added = ', '.join(k for k in diff.get('added', {}))
841+
added = 'added ' + added if added else ''
842+
changed = ', '.join(k for k in diff.get('changed', {}))
843+
changed = 'changed ' + changed if changed else ''
844+
deleted = ', '.join(k for k in diff.get('deleted', {}))
845+
deleted = 'deleted ' + deleted if deleted else ''
846+
changes = ', '.join(i for i in (added, changed, deleted) if i)
847+
if changes:
848+
if self.summary:
849+
self.summary += ' and '
850+
self.summary += "{} {}".format(self.config.owner, changes)
851+
if not self.summary:
852+
if self.version == 1:
853+
self.summary = "{} created the initial release".format(self.owner)
854+
else:
855+
self.summary = "{} changed nothing".format(self.owner)
856+
super(Release, self).save(*args, **kwargs)
857+
804858

805859
@receiver(release_signal)
806860
def new_release(sender, **kwargs):

api/tests/test_release.py

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ def test_release(self):
6363
response = self.client.get(url)
6464
self.assertEqual(response.status_code, 200)
6565
self.assertEqual(response.data['count'], 1)
66-
url = '/api/apps/{app_id}/releases/1'.format(**locals())
66+
url = '/api/apps/{app_id}/releases/v1'.format(**locals())
6767
response = self.client.get(url)
6868
self.assertEqual(response.status_code, 200)
6969
release1 = response.data
@@ -78,7 +78,7 @@ def test_release(self):
7878
self.assertEqual(response.status_code, 201)
7979
self.assertIn('NEW_URL1', json.loads(response.data['values']))
8080
# check to see that a new release was created
81-
url = '/api/apps/{app_id}/releases/2'.format(**locals())
81+
url = '/api/apps/{app_id}/releases/v2'.format(**locals())
8282
response = self.client.get(url)
8383
self.assertEqual(response.status_code, 200)
8484
release2 = response.data
@@ -102,7 +102,7 @@ def test_release(self):
102102
self.assertEqual(response.status_code, 201)
103103
self.assertEqual(response.data['url'], body['url'])
104104
# check to see that a new release was created
105-
url = '/api/apps/{app_id}/releases/3'.format(**locals())
105+
url = '/api/apps/{app_id}/releases/v3'.format(**locals())
106106
response = self.client.get(url)
107107
self.assertEqual(response.status_code, 200)
108108
release3 = response.data
@@ -120,7 +120,7 @@ def test_release(self):
120120
self.assertEqual(
121121
config3_values['PATH'], 'bin:/usr/local/bin:/usr/bin:/bin')
122122
# check that we can fetch a previous release
123-
url = '/api/apps/{app_id}/releases/2'.format(**locals())
123+
url = '/api/apps/{app_id}/releases/v2'.format(**locals())
124124
response = self.client.get(url)
125125
self.assertEqual(response.status_code, 200)
126126
release2 = response.data
@@ -174,12 +174,12 @@ def test_release_rollback(self):
174174
response = self.client.get(url, content_type='application/json')
175175
self.assertEqual(response.status_code, 200)
176176
self.assertEqual(response.data['count'], 4)
177-
url = '/api/apps/{app_id}/releases/2'.format(**locals())
177+
url = '/api/apps/{app_id}/releases/v2'.format(**locals())
178178
response = self.client.get(url, content_type='application/json')
179179
self.assertEqual(response.status_code, 200)
180180
release2 = response.data
181181
self.assertEquals(release2['version'], 2)
182-
url = '/api/apps/{app_id}/releases/4'.format(**locals())
182+
url = '/api/apps/{app_id}/releases/v4'.format(**locals())
183183
response = self.client.get(url, content_type='application/json')
184184
self.assertEqual(response.status_code, 200)
185185
release4 = response.data
@@ -198,11 +198,11 @@ def test_release_rollback(self):
198198
response = self.client.get(url, content_type='application/json')
199199
self.assertEqual(response.status_code, 200)
200200
self.assertEqual(response.data['count'], 5)
201-
url = '/api/apps/{app_id}/releases/1'.format(**locals())
201+
url = '/api/apps/{app_id}/releases/v1'.format(**locals())
202202
response = self.client.get(url)
203203
self.assertEqual(response.status_code, 200)
204204
release1 = response.data
205-
url = '/api/apps/{app_id}/releases/5'.format(**locals())
205+
url = '/api/apps/{app_id}/releases/v5'.format(**locals())
206206
response = self.client.get(url)
207207
self.assertEqual(response.status_code, 200)
208208
release5 = response.data
@@ -216,3 +216,11 @@ def test_release_str(self):
216216
release3 = self.test_release()
217217
release = Release.objects.get(uuid=release3['uuid'])
218218
self.assertEqual(str(release), "{}-v3".format(release3['app']))
219+
220+
def test_release_summary(self):
221+
"""Test the text summary of a release."""
222+
release3 = self.test_release()
223+
release = Release.objects.get(uuid=release3['uuid'])
224+
# check that the release has push and env change messages
225+
self.assertIn('autotest deployed ', release.summary)
226+
self.assertIn('autotest added PATH', release.summary)

api/urls.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -417,7 +417,7 @@
417417
views.AppBuildViewSet.as_view({'get': 'retrieve'})),
418418
url(r'^apps/(?P<id>[-_\w]+)/builds/?',
419419
views.AppBuildViewSet.as_view({'get': 'list', 'post': 'create'})),
420-
url(r'^apps/(?P<id>[-_\w]+)/releases/(?P<version>[0-9]+)/?',
420+
url(r'^apps/(?P<id>[-_\w]+)/releases/v(?P<version>[0-9]+)/?',
421421
views.AppReleaseViewSet.as_view({'get': 'retrieve'})),
422422
url(r'^apps/(?P<id>[-_\w]+)/releases/rollback/?',
423423
views.AppReleaseViewSet.as_view({'post': 'rollback'})),

api/utils.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,41 @@ def generate_app_name():
6363
]
6464
return "{}-{}".format(
6565
random.choice(adjectives), random.choice(nouns))
66+
67+
68+
def dict_diff(dict1, dict2):
69+
"""
70+
Returns the added, changed, and deleted items in dict1 compared with dict2.
71+
72+
:param dict1: a python dict
73+
:param dict2: an earlier version of the same python dict
74+
:return: a new dict, with 'added', 'changed', and 'removed' items if
75+
any were found.
76+
77+
>>> d1 = {1: 'a'}
78+
>>> dict_diff(d1, d1)
79+
{}
80+
>>> d2 = {1: 'a', 2: 'b'}
81+
>>> dict_diff(d2, d1)
82+
{'added': {2: 'b'}}
83+
>>> d3 = {2: 'B', 3: 'c'}
84+
>>> expected = {'added': {3: 'c'}, 'changed': {2: 'B'}, 'deleted': {1: 'a'}}
85+
>>> dict_diff(d3, d2) == expected
86+
True
87+
"""
88+
diff = {}
89+
set1, set2 = set(dict1), set(dict2)
90+
# Find items that were added to dict2
91+
diff['added'] = {k: dict1[k] for k in (set1 - set2)}
92+
# Find common items whose values differ between dict1 and dict2
93+
diff['changed'] = {
94+
k: dict1[k] for k in (set1 & set2) if dict1[k] != dict2[k]
95+
}
96+
# Find items that were deleted from dict2
97+
diff['deleted'] = {k: dict2[k] for k in (set2 - set1)}
98+
return {k: diff[k] for k in diff if diff[k]}
99+
100+
101+
if __name__ == "__main__":
102+
import doctest
103+
doctest.testmod()

api/views.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -640,15 +640,17 @@ def rollback(self, request, *args, **kwargs):
640640
"""
641641
app = get_object_or_404(models.App, id=self.kwargs['id'])
642642
last_version = app.release_set.latest().version
643-
version = request.DATA.get('version', last_version - 1)
643+
version = int(request.DATA.get('version', last_version - 1))
644644
if version < 1:
645645
return Response(status=status.HTTP_404_NOT_FOUND)
646646
prev = app.release_set.get(version=version)
647647
with transaction.atomic():
648+
summary = "{} rolled back to v{}".format(request.user, version)
648649
app.release_set.create(owner=request.user, version=last_version + 1,
649-
build=prev.build, config=prev.config)
650+
build=prev.build, config=prev.config,
651+
summary=summary)
650652
app.converge()
651-
msg = "Rolled back to {}".format(version)
653+
msg = "Rolled back to v{}".format(version)
652654
return Response(msg, status=status.HTTP_201_CREATED)
653655

654656

client/deis.py

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,14 @@
4545
from __future__ import print_function
4646
from collections import namedtuple
4747
from cookielib import MozillaCookieJar
48+
from datetime import datetime
4849
from getpass import getpass
4950
from itertools import cycle
5051
from threading import Event
5152
from threading import Thread
5253
import glob
5354
import json
55+
import locale
5456
import os.path
5557
import random
5658
import re
@@ -60,15 +62,21 @@
6062
import time
6163
import urlparse
6264
import webbrowser
63-
import yaml
6465

66+
from dateutil import parser
67+
from dateutil import relativedelta
68+
from dateutil import tz
6569
from docopt import docopt
6670
from docopt import DocoptExit
6771
import requests
72+
import yaml
6873

6974
__version__ = '0.4.0'
7075

7176

77+
locale.setlocale(locale.LC_ALL, '')
78+
79+
7280
class Session(requests.Session):
7381
"""
7482
Session for making API requests and interacting with the filesystem
@@ -296,6 +304,42 @@ def get_provider_creds(provider, raise_error=False):
296304
"Missing environment variable: {}".format(missing))
297305

298306

307+
def readable_datetime(datetime_str):
308+
"""
309+
Return a human-readable datetime string from an ECMA-262 (JavaScript)
310+
datetime string.
311+
"""
312+
timezone = tz.tzlocal()
313+
dt = parser.parse(datetime_str).astimezone(timezone)
314+
now = datetime.now(timezone)
315+
delta = relativedelta.relativedelta(now, dt)
316+
# if it happened today, say "2 hours and 1 minute ago"
317+
if delta.days <= 1 and dt.day == now.day:
318+
if delta.hours == 0:
319+
hour_str = ''
320+
elif delta.hours == 1:
321+
hour_str = '1 hour '
322+
else:
323+
hour_str = "{} hours ".format(delta.hours)
324+
if delta.minutes == 0:
325+
min_str = ''
326+
elif delta.minutes == 1:
327+
min_str = '1 minute '
328+
else:
329+
min_str = "{} minutes ".format(delta.minutes)
330+
if not any((hour_str, min_str)):
331+
return 'Just now'
332+
else:
333+
return "{}{}ago".format(hour_str, min_str)
334+
# if it happened yesterday, say "yesterday at 3:23 pm"
335+
yesterday = now + relativedelta.relativedelta(days=-1)
336+
if delta.days <= 2 and dt.day == yesterday.day:
337+
return dt.strftime("Yesterday at %X")
338+
# otherwise return locale-specific date/time format
339+
else:
340+
return dt.strftime('%c %Z')
341+
342+
299343
def trim(docstring):
300344
"""
301345
Function to trim whitespace from docstring
@@ -1926,6 +1970,8 @@ def releases_info(self, args):
19261970
Usage: deis releases:info <version> [--app=<app>]
19271971
"""
19281972
version = args.get('<version>')
1973+
if not version.startswith('v'):
1974+
version = 'v' + version
19291975
app = args.get('--app')
19301976
if not app:
19311977
app = self._session.app
@@ -1945,12 +1991,13 @@ def releases_list(self, args):
19451991
app = args.get('--app')
19461992
if not app:
19471993
app = self._session.app
1948-
response = self._dispatch('get', '/api/apps/{app}/releases'.format(**locals()))
1994+
response = self._dispatch('get', "/api/apps/{app}/releases".format(**locals()))
19491995
if response.status_code == requests.codes.ok: # @UndefinedVariable
1950-
print('=== {0} Releases'.format(app))
1996+
print("=== {} Releases".format(app))
19511997
data = response.json()
19521998
for item in data['results']:
1953-
print('{version} {created}'.format(**item))
1999+
item['created'] = readable_datetime(item['created'])
2000+
print("v{version:<6} {created:<33} {summary}".format(**item))
19542001
else:
19552002
raise ResponseError(response)
19562003

@@ -1963,9 +2010,11 @@ def releases_rollback(self, args):
19632010
app = args.get('--app')
19642011
if not app:
19652012
app = self._session.app
1966-
version = args.get('--version')
2013+
version = args.get('<version>')
19672014
if version:
1968-
body = {'version': version}
2015+
if version.startswith('v'):
2016+
version = version[1:]
2017+
body = {'version': int(version)}
19692018
else:
19702019
body = {}
19712020
url = "/api/apps/{app}/releases/rollback".format(**locals())

client/setup.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
KWARGS = {}
2424
if USE_SETUPTOOLS:
2525
KWARGS = {
26-
'install_requires': ['docopt', 'PyYAML', 'requests'],
26+
'install_requires': ['docopt', 'python-dateutil', 'PyYAML', 'requests'],
2727
'entry_points': {'console_scripts': ['deis = deis:main']},
2828
}
2929
else:
@@ -59,6 +59,9 @@
5959
('.', ['README.rst']),
6060
],
6161
long_description=LONG_DESCRIPTION,
62-
requires=['docopt(>=0.6.1)', 'PyYAML(>=3.10)', 'requests(>=2.1.0)'],
62+
requires=[
63+
'docopt(==0.6.1)', 'python-dateutil(==2.2)',
64+
'PyYAML(==3.10)', 'requests(==2.1.0)',
65+
],
6366
zip_safe=True,
6467
**KWARGS)

dev_requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ South==0.8.4
1919

2020
# Deis client requirements
2121
docopt==0.6.1
22+
python-dateutil==2.2
2223
#PyYAML==3.10
2324
requests==2.1.0
2425

0 commit comments

Comments
 (0)