Merge lp:~roadmr/isitdeployable/existingdeployments into lp:isitdeployable
- existingdeployments
- Merge into trunk
Status: | Merged |
---|---|
Approved by: | Daniel Manrique |
Approved revision: | 314 |
Merge reported by: | Otto Co-Pilot |
Merged at revision: | not available |
Proposed branch: | lp:~roadmr/isitdeployable/existingdeployments |
Merge into: | lp:isitdeployable |
Diff against target: |
696 lines (+404/-26) 16 files modified
dev_config/supervisor.conf (+2/-2) requirements.txt (+2/-2) revtracker/admin.py (+8/-0) revtracker/data.py (+1/-0) revtracker/migrations/0002_existingdeployment.py (+28/-0) revtracker/models.py (+37/-0) revtracker/static/scss/deploy_tracker.scss (+4/-0) revtracker/tasks.py (+46/-0) revtracker/templates/revtracker/index.html (+32/-17) revtracker/templates/revtracker/project_info.html (+16/-0) revtracker/tests/factory.py (+17/-0) revtracker/tests/test_models.py (+30/-0) revtracker/tests/test_tasks.py (+115/-0) revtracker/tests/test_views.py (+4/-4) revtracker/tests/test_views_logged_in.py (+52/-0) revtracker/views.py (+10/-1) |
To merge this branch: | bzr merge lp:~roadmr/isitdeployable/existingdeployments |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Facundo Batista (community) | Approve | ||
Maximiliano Bertacchini | Approve | ||
Review via email: mp+356594@code.launchpad.net |
Commit message
Add querying and displaying of which versions of code are deployed in each service/
Each project can have zero or more "Existing Deployments". Periodically, the service will poke a configured URL for each of these deployments, extract the value from a specific header, and store that for display in both the index page and each project's page.
Since most of our projects expose their current version in a X-VCS-Version or similar, this information can be collected for them, and the index page can then provide at a glance an overview of which versions each project is at.
Description of the change
Add querying and displaying of which versions of code are deployed in each service/
Each project can have zero or more "Existing Deployments". Periodically, the service will poke a configured URL for each of these deployments, extract the value from a specific header, and store that for display in both the index page and each project's page.
Since most of our projects expose their current version in a X-VCS-Version or similar, this information can be collected for them, and the index page can then provide at a glance an overview of which versions each project is at.
Maximiliano Bertacchini (maxiberta) wrote : | # |
Daniel Manrique (roadmr) wrote : | # |
Thanks! Replied below and fixes coming up.
- 313. By Daniel Manrique
-
Space... the final frontier
- 314. By Daniel Manrique
-
PEP8 fixes
Maximiliano Bertacchini (maxiberta) : | # |
Facundo Batista (facundo) wrote : | # |
Looks fine! Added a couple of small questions, but approving it.
Preview Diff
1 | === modified file 'dev_config/supervisor.conf' |
2 | --- dev_config/supervisor.conf 2016-07-25 20:55:05 +0000 |
3 | +++ dev_config/supervisor.conf 2018-10-11 19:24:34 +0000 |
4 | @@ -12,7 +12,7 @@ |
5 | serverurl=unix:///tmp/supervisord.sock |
6 | |
7 | [program:celeryd] |
8 | -command=%(here)s/../virtualenv/bin/python %(here)s/../django_project/celeryapp.py worker --pidfile %(here)s/../tmp/celeryd.pid |
9 | +command=%(here)s/../env/bin/python %(here)s/../django_project/celeryapp.py worker --pidfile %(here)s/../tmp/celeryd.pid |
10 | stdout_logfile=%(here)s/../tmp/celeryd.log |
11 | stderr_logfile=%(here)s/../tmp/celeryd.log |
12 | autostart=true |
13 | @@ -20,7 +20,7 @@ |
14 | startsecs=10 |
15 | |
16 | [program:celerybeat] |
17 | -command=%(here)s/../virtualenv/bin/python %(here)s/../django_project/celeryapp.py beat --pidfile %(here)s/../tmp/celerybeat.pid |
18 | +command=%(here)s/../env/bin/python %(here)s/../django_project/celeryapp.py beat --pidfile %(here)s/../tmp/celerybeat.pid |
19 | stdout_logfile=%(here)s/../tmp/celerybeat.log |
20 | stderr_logfile=%(here)s/../tmp/celerybeat.log |
21 | autostart=true |
22 | |
23 | === modified file 'requirements.txt' |
24 | --- requirements.txt 2018-04-10 19:57:04 +0000 |
25 | +++ requirements.txt 2018-10-11 19:24:34 +0000 |
26 | @@ -43,9 +43,9 @@ |
27 | oops-wsgi==0.0.11 |
28 | Paste==1.7.5.1 |
29 | psycopg2==2.7.3.2 |
30 | -pybars==0.0.4 |
31 | +pybars3==0.9.5 |
32 | pygit2==0.24.2 |
33 | -PyMeta==0.5.0 |
34 | +PyMeta3==0.5.1 |
35 | pyinotify==0.9.3 |
36 | soupmatchers==0.2 |
37 | testresources==0.2.5 |
38 | |
39 | === modified file 'revtracker/admin.py' |
40 | --- revtracker/admin.py 2016-11-11 20:16:01 +0000 |
41 | +++ revtracker/admin.py 2018-10-11 19:24:34 +0000 |
42 | @@ -1,12 +1,20 @@ |
43 | from django.contrib import admin |
44 | from revtracker.models import ( |
45 | DeploymentRequest, |
46 | + ExistingDeployment, |
47 | Project, |
48 | ) |
49 | |
50 | |
51 | +class ExistingDeploymentInline(admin.TabularInline): |
52 | + model = ExistingDeployment |
53 | + fields = ("name", "info_url", "version_header") |
54 | + extra = 2 # Usually only prod and staging |
55 | + |
56 | + |
57 | class ProjectAdmin(admin.ModelAdmin): |
58 | exclude = ('deployed_revision',) |
59 | + inlines = [ExistingDeploymentInline] |
60 | |
61 | |
62 | admin.site.register(Project, ProjectAdmin) |
63 | |
64 | === modified file 'revtracker/data.py' |
65 | --- revtracker/data.py 2018-01-31 00:19:16 +0000 |
66 | +++ revtracker/data.py 2018-10-11 19:24:34 +0000 |
67 | @@ -16,6 +16,7 @@ |
68 | |
69 | from .models import ( |
70 | DeploymentRequest, |
71 | + ExistingDeployment, |
72 | Revision, |
73 | ) |
74 | |
75 | |
76 | === added file 'revtracker/migrations/0002_existingdeployment.py' |
77 | --- revtracker/migrations/0002_existingdeployment.py 1970-01-01 00:00:00 +0000 |
78 | +++ revtracker/migrations/0002_existingdeployment.py 2018-10-11 19:24:34 +0000 |
79 | @@ -0,0 +1,28 @@ |
80 | +# -*- coding: utf-8 -*- |
81 | +# Generated by Django 1.11.9 on 2018-10-11 15:55 |
82 | +from __future__ import unicode_literals |
83 | + |
84 | +from django.db import migrations, models |
85 | +import django.db.models.deletion |
86 | + |
87 | + |
88 | +class Migration(migrations.Migration): |
89 | + |
90 | + dependencies = [ |
91 | + ('revtracker', '0001_initial'), |
92 | + ] |
93 | + |
94 | + operations = [ |
95 | + migrations.CreateModel( |
96 | + name='ExistingDeployment', |
97 | + fields=[ |
98 | + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
99 | + ('name', models.CharField(help_text=b'Descriptive name for the deployment. Examples: Staging, Production, Sandbox.', max_length=512)), |
100 | + ('date_updated', models.DateTimeField(blank=True, null=True)), |
101 | + ('info_url', models.CharField(help_text=b'URL from which to get information about this deployment. Must contain the revno/commit id/somethingto identify the deployed version in an HTTP header.', max_length=512)), |
102 | + ('version_header', models.CharField(help_text=b"Header from the info_url's HTTP response containing thedeployed version's identifier.", max_length=512)), |
103 | + ('current_version', models.CharField(blank=True, help_text=b"Version identifier as of the date this deployment's data was last updated", max_length=512, null=True)), |
104 | + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='revtracker.Project')), |
105 | + ], |
106 | + ), |
107 | + ] |
108 | |
109 | === modified file 'revtracker/models.py' |
110 | --- revtracker/models.py 2018-01-29 14:57:56 +0000 |
111 | +++ revtracker/models.py 2018-10-11 19:24:34 +0000 |
112 | @@ -141,3 +141,40 @@ |
113 | |
114 | def __unicode__(self): |
115 | return u"DeploymentRequest - %s" % self.deploy_revision |
116 | + |
117 | + |
118 | +class ExistingDeployment(models.Model): |
119 | + project = models.ForeignKey(Project) |
120 | + name = models.CharField( |
121 | + max_length=512, |
122 | + help_text=("Descriptive name for the deployment. Examples: " |
123 | + "Staging, Production, Sandbox.") |
124 | + ) |
125 | + date_updated = models.DateTimeField(blank=True, null=True) |
126 | + info_url = models.CharField( |
127 | + max_length=512, |
128 | + help_text=("URL from which to get information about this " |
129 | + "deployment. Must contain the revno/commit id/something " |
130 | + "to identify the deployed version in an HTTP header.") |
131 | + ) |
132 | + version_header = models.CharField( |
133 | + max_length=512, |
134 | + help_text=("Header from the info_url's HTTP response containing the " |
135 | + "deployed version's identifier.") |
136 | + ) |
137 | + current_version = models.CharField( |
138 | + max_length=512, |
139 | + blank=True, null=True, |
140 | + help_text=("Version identifier as of the date this deployment's data " |
141 | + "was last updated")) |
142 | + |
143 | + def __unicode__(self): |
144 | + project = self.project |
145 | + verstring = "No current version" |
146 | + if self.current_version: |
147 | + verstring = "Version %s" % self.current_version |
148 | + datestring = "(Not updated yet)" |
149 | + if self.date_updated: |
150 | + datestring = "(Updated %s)" % self.date_updated |
151 | + return u"%s %s - %s %s" % ( |
152 | + project.name, self.name, verstring, datestring) |
153 | |
154 | === modified file 'revtracker/static/scss/deploy_tracker.scss' |
155 | --- revtracker/static/scss/deploy_tracker.scss 2016-09-28 11:25:47 +0000 |
156 | +++ revtracker/static/scss/deploy_tracker.scss 2018-10-11 19:24:34 +0000 |
157 | @@ -16,6 +16,10 @@ |
158 | @include box-shadow($shadow-color 0 2px 2px 0); |
159 | } |
160 | |
161 | +.noshadowplease { |
162 | + @include box-shadow($shadow-color 0 0 0 0); |
163 | +} |
164 | + |
165 | .section_content { |
166 | margin-top: 20px; |
167 | margin-bottom: 20px; |
168 | |
169 | === modified file 'revtracker/tasks.py' |
170 | --- revtracker/tasks.py 2018-03-01 17:29:26 +0000 |
171 | +++ revtracker/tasks.py 2018-10-11 19:24:34 +0000 |
172 | @@ -3,6 +3,7 @@ |
173 | from email.utils import parseaddr |
174 | import os |
175 | import re |
176 | +import requests |
177 | import shutil |
178 | import tempfile |
179 | |
180 | @@ -19,6 +20,15 @@ |
181 | from django.conf import settings |
182 | from django.contrib.auth.models import User |
183 | from django.db.models import Max |
184 | +from django.utils.timezone import now |
185 | +from requests.exceptions import ( |
186 | + ConnectionError, |
187 | + HTTPError, |
188 | + InvalidSchema, |
189 | + InvalidURL, |
190 | + MissingSchema, |
191 | + Timeout, |
192 | + ) |
193 | import pygit2 |
194 | from six.moves.urllib.parse import ( |
195 | unquote, |
196 | @@ -654,6 +664,26 @@ |
197 | return revisions |
198 | |
199 | |
200 | +def get_currently_deployed_version(info_url, version_header): |
201 | + try: |
202 | + headers = requests.utils.default_headers() |
203 | + headers['user-agent'] += " (isitdeployable)" |
204 | + response = requests.head(info_url, headers=headers, timeout=2) |
205 | + response.raise_for_status() |
206 | + except (MissingSchema, |
207 | + InvalidSchema, |
208 | + InvalidURL, |
209 | + ConnectionError, |
210 | + Timeout, |
211 | + HTTPError,) as exc: |
212 | + print("Couldn't get %s: %r" % (info_url, exc)) |
213 | + return (None, None) |
214 | + if version_header not in response.headers: |
215 | + print("%s did not have %s header" % (info_url, version_header)) |
216 | + return (None, None) |
217 | + return (response.headers[version_header], now()) |
218 | + |
219 | + |
220 | def update_revisions(project_id): |
221 | load_plugins() |
222 | project = Project.objects.get(pk=project_id) |
223 | @@ -690,15 +720,31 @@ |
224 | branch.unlock() |
225 | |
226 | |
227 | +def update_existing_deployments(project_id): |
228 | + project = Project.objects.get(pk=project_id) |
229 | + for ed in project.existingdeployment_set.all(): |
230 | + (version, when) = get_currently_deployed_version(ed.info_url, ed.version_header) |
231 | + if version and when: |
232 | + ed.current_version = version |
233 | + ed.date_updated = when |
234 | + ed.save() |
235 | + |
236 | + |
237 | @shared_task |
238 | def update_revisions_task(project_id): |
239 | update_revisions(project_id) |
240 | |
241 | |
242 | @shared_task |
243 | +def update_existing_deployments_task(project_id): |
244 | + update_existing_deployments(project_id) |
245 | + |
246 | + |
247 | +@shared_task |
248 | def update_all_projects(): |
249 | for project in Project.objects.all(): |
250 | # Have the subtask expire before the next one will be started to |
251 | # prevent them piling up if the workers pause. |
252 | expires = settings.CELERYBEAT_SCHEDULE['update_all_projects']['schedule'].seconds |
253 | update_revisions_task.subtask((project.pk,), expires=expires).apply_async() |
254 | + update_existing_deployments_task.subtask((project.pk,), expires=expires).apply_async() |
255 | |
256 | === modified file 'revtracker/templates/revtracker/index.html' |
257 | --- revtracker/templates/revtracker/index.html 2014-03-28 16:30:03 +0000 |
258 | +++ revtracker/templates/revtracker/index.html 2018-10-11 19:24:34 +0000 |
259 | @@ -1,24 +1,39 @@ |
260 | <div id="projects"> |
261 | {{#if projects}} |
262 | - {{#each projects}} |
263 | + {{#each projects}} |
264 | + <div class="project section sixteen columns alpha"> |
265 | <a href="{{url}}"> |
266 | - <div class="project section sixteen columns alpha"> |
267 | - <div class="section_content clearfix"> |
268 | - <div class="project_name six columns alpha main">{{name}}</div> |
269 | - <div class="undeployed three columns"> |
270 | - {{undeployed_count}} undeployed revisions |
271 | - </div> |
272 | - <div class="deployable three columns"> |
273 | - {{deployable_count}} deployable revisions |
274 | - </div> |
275 | - <div class="qa_needed three columns omega"> |
276 | - {{needing_qa_count}} revisions need QA |
277 | - </div> |
278 | + <div class="section_content clearfix"> |
279 | + <div class="project_name six columns alpha main">{{name}}</div> |
280 | + <div class="undeployed three columns"> |
281 | + {{undeployed_count}} undeployed revisions |
282 | + </div> |
283 | + <div class="deployable three columns"> |
284 | + {{deployable_count}} deployable revisions |
285 | + </div> |
286 | + <div class="qa_needed three columns omega"> |
287 | + {{needing_qa_count}} revisions need QA |
288 | </div> |
289 | </div> |
290 | </a> |
291 | - {{/each}} |
292 | - {{else}} |
293 | - <div>No projects registered.</div> |
294 | - {{/if}} |
295 | + {{#if existing_deployments}} |
296 | + <div class="section_content clearfix"> |
297 | + <div class="alpha six columns main">Existing Deployments</div> |
298 | + <div class="existing_deployment three columns">Name</div> |
299 | + <div class="existing_deployment three columns">Current version</div> |
300 | + <div class="existing_deployment three columns omega">Updated on</div> |
301 | + {{#each existing_deployments}} |
302 | + <div class="alpha six columns main"> </div> |
303 | + <div class="existing_deployment three columns">{{this.name}}</div> |
304 | + <div class="existing_deployment three columns">{{this.current_version}}</div> |
305 | + <div class="existing_deployment three columns omega">{{this.date_updated}}</div> |
306 | + {{/each}} |
307 | + </div> |
308 | + {{/if}} |
309 | + </div> |
310 | + {{/each}} |
311 | +</div> |
312 | +{{else}} |
313 | +<div>No projects registered.</div> |
314 | +{{/if}} |
315 | </div> |
316 | |
317 | === modified file 'revtracker/templates/revtracker/project_info.html' |
318 | --- revtracker/templates/revtracker/project_info.html 2016-09-22 12:39:14 +0000 |
319 | +++ revtracker/templates/revtracker/project_info.html 2018-10-11 19:24:34 +0000 |
320 | @@ -15,6 +15,22 @@ |
321 | {{#if revision_blocking_deployment}} |
322 | <div id="blocking_revision" class="global_info immediate_attention">{{revision_blocking_deployment.title}} is blocking deployment</div> |
323 | {{/if}} |
324 | + {{#if existing_deployments}} |
325 | + <div class=" section fifteen columns alpha noshadowplease"> |
326 | + <div class="section_content clearfix"> |
327 | + <div class="alpha four columns main">Existing Deployments</div> |
328 | + <div class="existing_deployment three columns">Name</div> |
329 | + <div class="existing_deployment three columns">Current version</div> |
330 | + <div class="existing_deployment three columns omega">Updated on</div> |
331 | + {{#each existing_deployments}} |
332 | + <div class="alpha four columns main"> </div> |
333 | + <div class="existing_deployment three columns">{{this.name}}</div> |
334 | + <div class="existing_deployment three columns">{{this.current_version}}</div> |
335 | + <div class="existing_deployment three columns omega">{{this.date_updated}}</div> |
336 | + {{/each}} |
337 | + </div> |
338 | + </div> |
339 | + {{/if}} |
340 | </div> |
341 | |
342 | {{#if deployment_request}} |
343 | |
344 | === modified file 'revtracker/tests/factory.py' |
345 | --- revtracker/tests/factory.py 2018-01-30 01:38:57 +0000 |
346 | +++ revtracker/tests/factory.py 2018-10-11 19:24:34 +0000 |
347 | @@ -12,6 +12,7 @@ |
348 | |
349 | from revtracker.models import ( |
350 | DeploymentRequest, |
351 | + ExistingDeployment, |
352 | Project, |
353 | Revision, |
354 | ) |
355 | @@ -90,3 +91,19 @@ |
356 | user.is_active = is_active |
357 | user.save() |
358 | return user |
359 | + |
360 | + def make_existing_deployment(self, project=None, name=None, |
361 | + date_updated=None, info_url=None, |
362 | + version_header=None, current_version=None): |
363 | + if project is None: |
364 | + project = self.make_project() |
365 | + if name is None: |
366 | + name = "Existing deployment for %s" % project.name |
367 | + if info_url is None: |
368 | + info_url = "http://example.com/_status/check" |
369 | + if version_header is None: |
370 | + version_header = "X-Vcs-Revision" |
371 | + return ExistingDeployment.objects.create( |
372 | + project=project, name=name, date_updated=date_updated, |
373 | + info_url=info_url, version_header=version_header, |
374 | + current_version=current_version) |
375 | |
376 | === added file 'revtracker/tests/test_models.py' |
377 | --- revtracker/tests/test_models.py 1970-01-01 00:00:00 +0000 |
378 | +++ revtracker/tests/test_models.py 2018-10-11 19:24:34 +0000 |
379 | @@ -0,0 +1,30 @@ |
380 | +from django.utils.timezone import now |
381 | + |
382 | +from revtracker.tests import TestCase |
383 | + |
384 | + |
385 | +class ExistingDeploymentTests(TestCase): |
386 | + |
387 | + def test_unicode_never_updated(self): |
388 | + depl = self.factory.make_existing_deployment() |
389 | + expected_str = ( |
390 | + "%s %s - No current version (Not updated " |
391 | + "yet)" % (depl.project.name, depl.name)) |
392 | + self.assertEqual(str(depl), expected_str) |
393 | + |
394 | + def test_unicode_updated_empty_version(self): |
395 | + when = now() |
396 | + depl = self.factory.make_existing_deployment(date_updated=when) |
397 | + expected_str = ( |
398 | + "%s %s - No current version (Updated %s)" |
399 | + % (depl.project.name, depl.name, when)) |
400 | + self.assertEqual(str(depl), expected_str) |
401 | + |
402 | + def test_unicode_updated_with_version(self): |
403 | + when = now() |
404 | + depl = self.factory.make_existing_deployment( |
405 | + date_updated=when, current_version="deadbeef") |
406 | + expected_str = ( |
407 | + "%s %s - Version deadbeef (Updated %s)" |
408 | + % (depl.project.name, depl.name, when)) |
409 | + self.assertEqual(str(depl), expected_str) |
410 | |
411 | === modified file 'revtracker/tests/test_tasks.py' |
412 | --- revtracker/tests/test_tasks.py 2018-03-01 17:29:26 +0000 |
413 | +++ revtracker/tests/test_tasks.py 2018-10-11 19:24:34 +0000 |
414 | @@ -1,5 +1,7 @@ |
415 | from email.utils import parseaddr |
416 | +import mock |
417 | import os.path |
418 | +import requests |
419 | import shutil |
420 | import tempfile |
421 | from test.test_support import captured_stdout |
422 | @@ -7,6 +9,7 @@ |
423 | from breezy.controldir import ControlDir |
424 | from breezy.tests import TestCaseWithTransport |
425 | from django.test.utils import override_settings |
426 | +from django.utils.timezone import now |
427 | import pygit2 |
428 | from six.moves.urllib.request import pathname2url |
429 | from testscenarios import WithScenarios |
430 | @@ -670,3 +673,115 @@ |
431 | finally: |
432 | if self.vcs == VCS.VCS_BAZAAR: |
433 | branch_with_lock.unlock() |
434 | + |
435 | + |
436 | +class GetCurrentlyDeployedVersionTests(TestCase): |
437 | + |
438 | + def test_response_contains_requested_header(self): |
439 | + expected_version = "deadbeef" |
440 | + version_header = "x-foo-version" |
441 | + the_moment = now() |
442 | + mock_response = mock.Mock() |
443 | + mock_response.headers = {version_header: expected_version} |
444 | + with mock.patch('revtracker.tasks.requests') as mock_requests, mock.patch('revtracker.tasks.now') as mock_now: |
445 | + mock_now.return_value = the_moment |
446 | + mock_requests.head.return_value = mock_response |
447 | + result = tasks.get_currently_deployed_version("http://foo.example", version_header) |
448 | + self.assertEqual(result, (expected_version, the_moment)) |
449 | + |
450 | + def test_response_doesnt_contain_requested_header(self): |
451 | + expected_version = "deadbeef" |
452 | + version_header = "x-foo-version" |
453 | + mock_response = mock.Mock() |
454 | + mock_response.headers = {version_header + "lalala": expected_version} |
455 | + with captured_stdout() as cstdout, mock.patch('revtracker.tasks.requests') as mock_requests: |
456 | + mock_requests.head.return_value = mock_response |
457 | + result = tasks.get_currently_deployed_version("http://foo.example", version_header) |
458 | + self.assertEqual(result, (None, None)) |
459 | + self.assertIn("http://foo.example did not have %s header" % version_header, cstdout.getvalue()) |
460 | + |
461 | + def test_http_request_exception_missing_schema(self): |
462 | + with captured_stdout() as cstdout, mock.patch('revtracker.tasks.requests') as mock_requests: |
463 | + mock_requests.head.side_effect = requests.exceptions.MissingSchema() |
464 | + result = tasks.get_currently_deployed_version("doesnt-matter", "doesnt-matter-either") |
465 | + self.assertEqual(result, (None, None)) |
466 | + self.assertIn("Couldn't get doesnt-matter: MissingSchema()", cstdout.getvalue()) |
467 | + |
468 | + def test_http_request_exception_invalid_schema(self): |
469 | + with captured_stdout() as cstdout, mock.patch('revtracker.tasks.requests') as mock_requests: |
470 | + mock_requests.head.side_effect = requests.exceptions.InvalidSchema() |
471 | + result = tasks.get_currently_deployed_version("doesnt-matter", "doesnt-matter-either") |
472 | + self.assertEqual(result, (None, None)) |
473 | + self.assertIn("Couldn't get doesnt-matter: InvalidSchema()", cstdout.getvalue()) |
474 | + |
475 | + def test_http_request_exception_invalid_url(self): |
476 | + with captured_stdout() as cstdout, mock.patch('revtracker.tasks.requests') as mock_requests: |
477 | + mock_requests.head.side_effect = requests.exceptions.InvalidURL() |
478 | + result = tasks.get_currently_deployed_version("doesnt-matter", "doesnt-matter-either") |
479 | + self.assertEqual(result, (None, None)) |
480 | + self.assertIn("Couldn't get doesnt-matter: InvalidURL()", cstdout.getvalue()) |
481 | + |
482 | + def test_http_request_exception_connection_error(self): |
483 | + with captured_stdout() as cstdout, mock.patch('revtracker.tasks.requests') as mock_requests: |
484 | + mock_requests.head.side_effect = requests.exceptions.ConnectionError() |
485 | + result = tasks.get_currently_deployed_version("doesnt-matter", "doesnt-matter-either") |
486 | + self.assertEqual(result, (None, None)) |
487 | + self.assertIn("Couldn't get doesnt-matter: ConnectionError()", cstdout.getvalue()) |
488 | + |
489 | + def test_http_request_exception_timeout(self): |
490 | + with captured_stdout() as cstdout, mock.patch('revtracker.tasks.requests') as mock_requests: |
491 | + mock_requests.head.side_effect = requests.exceptions.Timeout() |
492 | + result = tasks.get_currently_deployed_version("doesnt-matter", "doesnt-matter-either") |
493 | + self.assertEqual(result, (None, None)) |
494 | + self.assertIn("Couldn't get doesnt-matter: Timeout()", cstdout.getvalue()) |
495 | + |
496 | + def test_http_request_exception_http_error(self): |
497 | + mock_response = mock.Mock() |
498 | + with captured_stdout() as cstdout, mock.patch('revtracker.tasks.requests') as mock_requests: |
499 | + mock_requests.head.return_value = mock_response |
500 | + mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError() |
501 | + result = tasks.get_currently_deployed_version("doesnt-matter", "doesnt-matter-either") |
502 | + self.assertEqual(result, (None, None)) |
503 | + mock_response.raise_for_status.assert_called_once() |
504 | + self.assertIn("Couldn't get doesnt-matter: HTTPError()", cstdout.getvalue()) |
505 | + |
506 | + |
507 | +class UpdateExistingDeploymentsTests(TestCase): |
508 | + |
509 | + def test_happy_path(self): |
510 | + expected_version = "deadbeef" |
511 | + version_header = "x-foo-version" |
512 | + info_url = "http://foo.example" |
513 | + project = self.factory.make_project() |
514 | + existing_deployment = self.factory.make_existing_deployment( |
515 | + project=project, info_url=info_url, version_header=version_header) |
516 | + |
517 | + the_moment = now() |
518 | + mock_response = mock.Mock() |
519 | + mock_response.headers = {version_header: expected_version} |
520 | + |
521 | + with mock.patch('revtracker.tasks.requests') as mock_requests, mock.patch('revtracker.tasks.now') as mock_now: |
522 | + mock_now.return_value = the_moment |
523 | + mock_requests.head.return_value = mock_response |
524 | + tasks.update_existing_deployments(project.pk) |
525 | + existing_deployment.refresh_from_db() |
526 | + self.assertEqual(existing_deployment.current_version, expected_version) |
527 | + self.assertEqual(existing_deployment.date_updated, the_moment) |
528 | + |
529 | + def test_no_revision_info(self): |
530 | + expected_version = "deadbeef" |
531 | + version_header = "x-foo-version" |
532 | + info_url = "http://foo.example" |
533 | + project = self.factory.make_project() |
534 | + existing_deployment = self.factory.make_existing_deployment( |
535 | + project=project, info_url=info_url, version_header=version_header) |
536 | + |
537 | + mock_response = mock.Mock() |
538 | + mock_response.headers = {version_header + "lalala": expected_version} |
539 | + |
540 | + with mock.patch('revtracker.tasks.requests') as mock_requests: |
541 | + mock_requests.head.return_value = mock_response |
542 | + tasks.update_existing_deployments(project.pk) |
543 | + existing_deployment.refresh_from_db() |
544 | + self.assertEqual(existing_deployment.current_version, None) |
545 | + self.assertEqual(existing_deployment.date_updated, None) |
546 | |
547 | === modified file 'revtracker/tests/test_views.py' |
548 | --- revtracker/tests/test_views.py 2018-01-30 01:38:57 +0000 |
549 | +++ revtracker/tests/test_views.py 2018-10-11 19:24:34 +0000 |
550 | @@ -48,7 +48,7 @@ |
551 | def test_query_count(self): |
552 | project = self.factory.make_project() |
553 | self.factory.make_revision(project=project, status=Revision.STATUS_OK) |
554 | - self.assertNumQueries(5, views.index_data) |
555 | + self.assertNumQueries(6, views.index_data) |
556 | |
557 | def test_query_count_multiple(self): |
558 | project = self.factory.make_project() |
559 | @@ -56,7 +56,7 @@ |
560 | project = self.factory.make_project() |
561 | self.factory.make_revision(project=project, status=Revision.STATUS_OK) |
562 | # FIXME: O(projects) |
563 | - self.assertNumQueries(8, views.index_data) |
564 | + self.assertNumQueries(9, views.index_data) |
565 | |
566 | |
567 | class GetProjectInfoTests(TestCase): |
568 | @@ -140,13 +140,13 @@ |
569 | def test_query_count(self): |
570 | project = self.factory.make_project() |
571 | self.factory.make_revision(project=project, status=Revision.STATUS_OK) |
572 | - self.assertNumQueries(4, views.get_project_info, project) |
573 | + self.assertNumQueries(5, views.get_project_info, project) |
574 | |
575 | def test_query_count_two_revisions(self): |
576 | project = self.factory.make_project() |
577 | self.factory.make_revision(project=project, status=Revision.STATUS_OK) |
578 | self.factory.make_revision(project=project, status=Revision.STATUS_OK) |
579 | - self.assertNumQueries(4, views.get_project_info, project) |
580 | + self.assertNumQueries(5, views.get_project_info, project) |
581 | |
582 | def assertNumQueries(self, *args, **kwargs): |
583 | from django.db import connection |
584 | |
585 | === modified file 'revtracker/tests/test_views_logged_in.py' |
586 | --- revtracker/tests/test_views_logged_in.py 2018-01-30 23:36:44 +0000 |
587 | +++ revtracker/tests/test_views_logged_in.py 2018-10-11 19:24:34 +0000 |
588 | @@ -19,6 +19,8 @@ |
589 | TestCase, |
590 | ) |
591 | |
592 | +from testtools import matchers |
593 | + |
594 | |
595 | @override_settings(ROOT_URLCONF='revtracker.urls') |
596 | class IndexViewTests(TestCase): |
597 | @@ -109,6 +111,25 @@ |
598 | soupmatchers.Tag('revisions needing QA', 'div', attrs={'class':'qa_needed three columns omega'}, |
599 | text=re.compile("0 revisions need QA"))))) |
600 | |
601 | + def test_index_shows_no_existing_deployments(self): |
602 | + self.factory.make_project() |
603 | + response = self.client.get(reverse('index')) |
604 | + self.assertThat(response, matchers.Not(soupmatchers.HTMLResponseHas( |
605 | + html_matches=soupmatchers.Tag("existing deployments", "div", text="Existing Deployments")))) |
606 | + |
607 | + def test_index_shows_existing_deployments(self): |
608 | + project = self.factory.make_project() |
609 | + for ed in range(2): |
610 | + self.factory.make_existing_deployment(project=project, name=str(ed)) |
611 | + response = self.client.get(reverse('index')) |
612 | + self.assertThat(response, soupmatchers.HTMLResponseHas( |
613 | + html_matches=soupmatchers.Tag("existing deployments", "div", text="Existing Deployments"))) |
614 | + for ed in project.existingdeployment_set.all(): |
615 | + self.assertThat(response, soupmatchers.HTMLResponseHas( |
616 | + html_matches=soupmatchers.Tag("existing deployment", "div", text=ed.project.name))) |
617 | + self.assertThat(response, soupmatchers.HTMLResponseHas( |
618 | + html_matches=soupmatchers.Tag("existing deployment", "div", text=ed.current_version))) |
619 | + |
620 | |
621 | @override_settings(ROOT_URLCONF='revtracker.urls') |
622 | class ProjectViewTests(TestCase): |
623 | @@ -144,6 +165,37 @@ |
624 | self.assertThat(response, soupmatchers.HTMLResponseHas( |
625 | html_matches=ProjectDeployedRevision(text="Currently deployed: %s" % revision.description))) |
626 | |
627 | + def test_project_view_shows_no_existing_deployment_if_none(self): |
628 | + project = self.factory.make_project() |
629 | + response = self.client.get(reverse('project_view', args=(project.name,))) |
630 | + self.assertThat(response, matchers.Not(soupmatchers.HTMLResponseHas( |
631 | + html_matches=soupmatchers.Tag("existing deployments", "div", text="Existing deployments:")))) |
632 | + |
633 | + def test_project_view_shows_one_existing_deployment(self): |
634 | + project = self.factory.make_project() |
635 | + existing_deployment = self.factory.make_existing_deployment(project=project) |
636 | + response = self.client.get(reverse('project_view', args=(project.name,))) |
637 | + self.assertThat(response, soupmatchers.HTMLResponseHas( |
638 | + html_matches=soupmatchers.Tag("existing deployments", "div", text="Existing Deployments"))) |
639 | + for ed in project.existingdeployment_set.all(): |
640 | + self.assertThat(response, soupmatchers.HTMLResponseHas( |
641 | + html_matches=soupmatchers.Tag("existing deployment", "div", text=ed.project.name))) |
642 | + self.assertThat(response, soupmatchers.HTMLResponseHas( |
643 | + html_matches=soupmatchers.Tag("existing deployment", "div", text=ed.current_version))) |
644 | + |
645 | + def test_project_view_shows_two_existing_deployments(self): |
646 | + project = self.factory.make_project() |
647 | + for ed in range(2): |
648 | + self.factory.make_existing_deployment(project=project, name=str(ed)) |
649 | + response = self.client.get(reverse('project_view', args=(project.name,))) |
650 | + self.assertThat(response, soupmatchers.HTMLResponseHas( |
651 | + html_matches=soupmatchers.Tag("existing deployments", "div", text="Existing Deployments"))) |
652 | + for ed in project.existingdeployment_set.all(): |
653 | + self.assertThat(response, soupmatchers.HTMLResponseHas( |
654 | + html_matches=soupmatchers.Tag("existing deployment", "div", text=ed.project.name))) |
655 | + self.assertThat(response, soupmatchers.HTMLResponseHas( |
656 | + html_matches=soupmatchers.Tag("existing deployment", "div", text=ed.current_version))) |
657 | + |
658 | def test_project_view_all_revisions_deployed(self): |
659 | project = self.factory.make_project() |
660 | response = self.client.get(reverse('project_view', args=(project.name,))) |
661 | |
662 | === modified file 'revtracker/views.py' |
663 | --- revtracker/views.py 2018-01-29 15:34:06 +0000 |
664 | +++ revtracker/views.py 2018-10-11 19:24:34 +0000 |
665 | @@ -68,6 +68,11 @@ |
666 | } |
667 | except DeploymentRequest.DoesNotExist: |
668 | pass |
669 | + info['existing_deployments'] = [ |
670 | + {'name': ed.name, 'current_version': ed.current_version or "(Not updated yet)", |
671 | + 'date_updated': str(ed.date_updated or "(Not updated yet)")} |
672 | + for ed in project.existingdeployment_set.all()] |
673 | + |
674 | undeployed_revisions = data.project_undeployed_revisions(project, |
675 | data.project_deployed_revno(project)) |
676 | return info, undeployed_revisions |
677 | @@ -102,6 +107,10 @@ |
678 | info['undeployed_count'] = len(undeployed_revisions) |
679 | info['deployable_count'] = len(deployable_revisions) |
680 | info['needing_qa_count'] = len(data.project_revisions_needing_qa(undeployed_revisions)) |
681 | + info['existing_deployments'] = [ |
682 | + {'name': ed.name, 'current_version': ed.current_version or "(Not updated yet)", |
683 | + 'date_updated': str(ed.date_updated or "(Not updated yet)")} |
684 | + for ed in project.existingdeployment_set.all()] |
685 | return info |
686 | |
687 | |
688 | @@ -185,7 +194,7 @@ |
689 | |
690 | |
691 | def index_data(): |
692 | - projects = Project.objects.all().prefetch_related('revision_set') |
693 | + projects = Project.objects.all().prefetch_related('existingdeployment_set', 'revision_set') |
694 | info = dict() |
695 | info['projects'] = [index_project_to_info(project) for project in projects] |
696 | return info |
Looks good to me, but have a few comments/questions inline. Thanks!