Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add per-command tracking to API out output detail page #1563

Merged
merged 17 commits into from Aug 19, 2015
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions gulpfile.js
Expand Up @@ -17,6 +17,7 @@ var gulp = require('gulp'),
// picking up dependencies of the primary entry points and putting any
// limitations on directory structure for entry points.
var sources = {
builds: ['js/detail.js'],
core: [
'js/readthedocs-doc-embed.js',
'js/autocomplete.js',
Expand Down
35 changes: 0 additions & 35 deletions readthedocs/api/base.py
Expand Up @@ -189,41 +189,6 @@ def override_urls(self):
]


class BuildResource(ModelResource):
project = fields.ForeignKey('readthedocs.api.base.ProjectResource', 'project')
version = fields.ForeignKey('readthedocs.api.base.VersionResource', 'version')

class Meta(object):
always_return_data = True
include_absolute_url = True
allowed_methods = ['get', 'post', 'put']
queryset = Build.objects.api()
authentication = PostAuthentication()
authorization = DjangoAuthorization()
filtering = {
"project": ALL_WITH_RELATIONS,
"slug": ALL_WITH_RELATIONS,
"type": ALL_WITH_RELATIONS,
"state": ALL_WITH_RELATIONS,
}

def get_object_list(self, request):
self._meta.queryset = Build.objects.api(user=request.user)
return super(BuildResource, self).get_object_list(request)

def override_urls(self):
return [
url(r"^(?P<resource_name>%s)/schema/$"
% self._meta.resource_name,
self.wrap_view('get_schema'),
name="api_get_schema"),
url(r"^(?P<resource_name>%s)/(?P<project__slug>[a-z-_]+)/$" %
self._meta.resource_name,
self.wrap_view('dispatch_list'),
name="build_list_detail"),
]


class FileResource(ModelResource, SearchMixin):
project = fields.ForeignKey(ProjectResource, 'project', full=True)

Expand Down
33 changes: 33 additions & 0 deletions readthedocs/builds/migrations/0002_build_command_initial.py
@@ -0,0 +1,33 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals

from django.db import models, migrations
import readthedocs.builds.models


class Migration(migrations.Migration):

dependencies = [
('builds', '0001_initial'),
]

operations = [
migrations.CreateModel(
name='BuildCommandResult',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('command', models.TextField(verbose_name='Command')),
('description', models.TextField(verbose_name='Description', blank=True)),
('output', models.TextField(verbose_name='Command output', blank=True)),
('exit_code', models.IntegerField(verbose_name='Command exit code')),
('start_time', models.DateTimeField(verbose_name='Start time')),
('end_time', models.DateTimeField(verbose_name='End time')),
('build', models.ForeignKey(related_name='commands', verbose_name='Build', to='builds.Build')),
],
options={
'ordering': ['start_time'],
'get_latest_by': 'start_time',
},
bases=(readthedocs.builds.models.BuildCommandResultMixin, models.Model),
),
]
66 changes: 59 additions & 7 deletions readthedocs/builds/models.py
Expand Up @@ -11,13 +11,15 @@
from guardian.shortcuts import assign
from taggit.managers import TaggableManager

from readthedocs.privacy.loader import VersionManager, RelatedProjectManager
from readthedocs.privacy.loader import (VersionManager, RelatedProjectManager,
RelatedBuildManager)
from readthedocs.projects.models import Project
from readthedocs.projects import constants
from .constants import (BUILD_STATE, BUILD_TYPES, VERSION_TYPES,
LATEST, NON_REPOSITORY_VERSIONS, STABLE
)
from readthedocs.projects.constants import (PRIVACY_CHOICES, REPO_TYPE_GIT,
REPO_TYPE_HG)

from .constants import (BUILD_STATE, BUILD_TYPES, VERSION_TYPES,
LATEST, NON_REPOSITORY_VERSIONS, STABLE,
BUILD_STATE_FINISHED)
from .version_slug import VersionSlugField


Expand Down Expand Up @@ -70,7 +72,7 @@ class Version(models.Model):
built = models.BooleanField(_('Built'), default=False)
uploaded = models.BooleanField(_('Uploaded'), default=False)
privacy_level = models.CharField(
_('Privacy Level'), max_length=20, choices=constants.PRIVACY_CHOICES,
_('Privacy Level'), max_length=20, choices=PRIVACY_CHOICES,
default=DEFAULT_VERSION_PRIVACY_LEVEL, help_text=_("Level of privacy for this Version.")
)
tags = TaggableManager(blank=True)
Expand Down Expand Up @@ -368,4 +370,54 @@ def get_absolute_url(self):
@property
def finished(self):
'''Return if build has a finished state'''
return self.state == 'finished'
return self.state == BUILD_STATE_FINISHED


class BuildCommandResultMixin(object):
'''Mixin for common command result methods/properties

Shared methods between the database model :py:cls:`BuildCommandResult` and
non-model respresentations of build command results from the API
'''

@property
def successful(self):
'''Did the command exit with a successful exit code'''
return self.exit_code == 0

@property
def failed(self):
'''Did the command exit with a failing exit code

Helper for inverse of :py:meth:`successful`'''
return not self.successful


class BuildCommandResult(BuildCommandResultMixin, models.Model):
build = models.ForeignKey(Build, verbose_name=_('Build'),
related_name='commands')

command = models.TextField(_('Command'))
description = models.TextField(_('Description'), blank=True)
output = models.TextField(_('Command output'), blank=True)
exit_code = models.IntegerField(_('Command exit code'))

start_time = models.DateTimeField(_('Start time'))
end_time = models.DateTimeField(_('End time'))

class Meta:
ordering = ['start_time']
get_latest_by = 'start_time'

objects = RelatedBuildManager()

def __unicode__(self):
return (ugettext(u'Build command {pk} for build {build}')
.format(pk=self.pk, build=self.build))

@property
def run_time(self):
"""Total command runtime in seconds"""
if self.start_time is not None and self.end_time is not None:
diff = self.end_time - self.start_time
return diff.seconds
103 changes: 103 additions & 0 deletions readthedocs/builds/static-src/builds/js/detail.js
@@ -0,0 +1,103 @@
// Build detail view

var ko = window.knockout || require('knockout');
var $ = window.jquery || require('jquery');


function BuildCommand (data) {
var self = this;
self.id = ko.observable(data.id);
self.command = ko.observable(data.command);
self.output = ko.observable(data.output);
self.exit_code = ko.observable(data.exit_code || 0);
self.successful = ko.observable(self.exit_code() == 0);
self.run_time = ko.observable(data.run_time);
self.is_showing = ko.observable(!self.successful());

self.toggleCommand = function () {
self.is_showing(!self.is_showing());
};

self.command_status = ko.computed(function () {
return self.successful() ?
'build-command-successful' :
'build-command-failed';
});
}

function BuildDetailView (instance) {
var self = this,
instance = instance || {};

/* Instance variables */
self.state = ko.observable(instance.state);
self.state_display = ko.observable(instance.state_display);
self.finished = ko.computed(function () {
return self.state() == 'finished';
});
self.date = ko.observable(instance.date);
self.success = ko.observable(instance.success);
self.error = ko.observable(instance.error);
self.length = ko.observable(instance.length);
self.commands = ko.observableArray(instance.commands);
self.display_commands = ko.computed(function () {
var commands_display = [],
commands_raw = self.commands();
for (n in commands_raw) {
var command = new BuildCommand(commands_raw[n]);
commands_display.push(command)
}
return commands_display;
});
self.commit = ko.observable(instance.commit);

/* Others */
self.legacy_output = ko.observable(false);
self.show_legacy_output = function () {
self.legacy_output(true);
};

function poll_api () {
if (self.finished()) {
return;
}
$.getJSON('/api/v2/build/' + instance.id + '/', function (data) {
self.state(data.state);
self.state_display(data.state_display);
self.date(data.date);
self.success(data.success);
self.error(data.error);
self.length(data.length);
self.commit(data.commit);
for (n in data.commands) {
var command = data.commands[n];
var match = ko.utils.arrayFirst(
self.commands(),
function(command_cmp) {
return (command_cmp.id == command.id);
}
);
if (!match) {
self.commands.push(command);
}
}
});

setTimeout(poll_api, 2000);
}

poll_api();
}

BuildDetailView.init = function (instance, domobj) {
var view = new BuildDetailView(instance),
domobj = domobj || $('#build-detail')[0];
ko.applyBindings(view, domobj);
return view;
};

module.exports.BuildDetailView = BuildDetailView;

if (typeof(window) != 'undefined') {
window.build = module.exports;
}