Commits

dready committed 9eafa58

* send html build result email with pygmentized Traceback
* email built from markdown template
* track execution time
* customized display of admin forms
* added new model fields and use South for migration
* use django-urls to include full url to build/recipe in email
* reimplement schedule.py as a proper django management command (with options)
* new settings: FF_BUILD_RESULT_EMAIL_SUBJECT and FF_BUILD_RESULT_EMAIL_HTML
* recipe detail view

  • Participants
  • Parent commits aa99e18

Comments (0)

Files changed (24)

 src/fabric_factory.egg-info
 *.swp
 *.pyc
+.idea

File requirements.txt

 -e git://github.com/bitprophet/fabric.git#egg=fabric
 -e svn+http://code.djangoproject.com/svn/django/trunk#egg=django
 -e hg+http://bitbucket.org/andrewgodwin/south/@853f767dbf55a67ced45839fb5c4bd19575827f0#egg=South-tip
+django-extensions==0.4.1
+Markdown==2.0.3
+Pygments==1.3.1

File src/factory/__init__.py

File contents unchanged.

File src/factory/admin.py

 from factory.models import FabfileRecipe, Build
 
 class FabfileRecipeAdmin(admin.ModelAdmin):
+    list_display = ('name', 'schedule', 'repos')
     prepopulated_fields = {"slug": ("name",)}
     form = FabfileRecipeForm
-    
+
+
 class BuildAdmin(admin.ModelAdmin):
     list_display = ('name', 'task', 'executed', 'success')
+    fieldsets = (
+        (None, {
+            'fields': ('name', 'task', 'fabfile_recipe', 'branch', 'revision')
+        }),
+        ('Execution', {
+            'fields': ('executed', 'executed_datetime', 'execution_time',
+                       'success', 'output', 'error')
+        })
+    )
+
+    readonly_fields = (
+        'executed_datetime',
+        'execution_time',
+        'success',
+    )
 
 admin.site.register(FabfileRecipe, FabfileRecipeAdmin)
 admin.site.register(Build, BuildAdmin)

File src/factory/mail.py

+import logging
+from django.core.mail import EmailMultiAlternatives, EmailMessage
+from django.conf import settings
+import markdown
+from django.template.loader import render_to_string
+from pygments.formatters import HtmlFormatter
+
+
+DEFAULT_SUBJECT_TEMPLATE = "%(build)s Build Results (%(status)s)"
+
+def send_build_email(instance, **kwargs):
+    if not instance.executed:
+        return
+
+    if not instance.fabfile_recipe.notify:
+        return
+
+    email_results = getattr(settings, 'FF_EMAIL_BUILD_RESULTS', False)
+    if not email_results or (email_results is "failed" and instance.success):
+        return
+
+
+    recipients = [v.strip() for v in instance.fabfile_recipe.notify.split(',')]
+    subject_tpl = (getattr(settings, 'FF_BUILD_RESULT_EMAIL_SUBJECT', None) or DEFAULT_SUBJECT_TEMPLATE)
+    subject = subject_tpl % dict(build=instance,
+                                 status=("success" if instance.success else "failure"))
+
+    ctx = dict(task=instance.task,
+               recipe=instance.fabfile_recipe,
+               output=instance.output,
+               error=instance.error,
+               build=instance)
+
+    # render email content
+    text_content = render_to_string("factory/build_result_mail.txt", ctx)
+    if getattr(settings, 'FF_BUILD_RESULT_EMAIL_HTML', True):
+        msg = EmailMultiAlternatives(subject,
+                                     text_content,
+                                     settings.SERVER_EMAIL,
+                                     recipients)
+        msg.attach_alternative(format_html(text_content), 'text/html')
+    else:
+        msg = EmailMessage(subject,
+                           text_content,
+                           settings.SERVER_EMAIL,
+                           recipients)
+
+    try:
+        msg.send()
+    except:
+        # in production, if sending this email fails, django is unlikely to send the error mail.
+        # in debug, we can't really tell if this came from loaddata (fixtures)..
+        # so we just ignore the error
+        logging.getLogger(__name__).exception("unable to send build email")
+
+
+
+
+def format_html(text):
+    # TODO: implement our replacement for markdown.extensions.codehilite that passes ``noclasses`` to ``HtmlFormatter``
+    #       so that the output code will use inline styles, for better compatibility with mail clients esp. Gmail
+    html = markdown.markdown(text, ['codehilite'])
+    css = HtmlFormatter().get_style_defs('.codehilite')
+    return """<html>
+    <head><style>%s</style></head>
+    <body>%s</body>
+    </html>""" % (css,
+                  html)

File src/factory/management/__init__.py

Empty file added.

File src/factory/management/commands/__init__.py

Empty file added.

File src/factory/management/commands/schedule.py

+"""
+Migrate management command.
+"""
+
+from django.core.management.base import BaseCommand, CommandError
+from factory.models import FabfileRecipe, Build
+from datetime import datetime
+from optparse import make_option
+
+
+DEFAULT_TASK = 'build_and_test'
+
+class Command(BaseCommand):
+    args = '[-b <branch>] [-r <revision>] [<recipe1-slug> [<recipe2-slug> ..]]'
+    help = 'schedule a build for the given recipe, or all recipes with "schedule" enabled if no recipe-slug was specified'
+
+    option_list = BaseCommand.option_list + (
+    make_option('-b',
+                dest='branch',
+                default='',
+                help='branch to check out'),
+    make_option('-r',
+                dest='revision',
+                default='',
+                help='revision to check out')
+    )
+
+
+    def handle(self, *recipe_slugs, **options):
+        recipes = []
+        qs = FabfileRecipe.objects.all()
+        if recipe_slugs:
+        # manually get the recipes instead of using "slug__in" so as to detect errors
+            for slug in recipe_slugs:
+                try:
+                    recipes.append(qs.get(slug=slug))
+                except FabfileRecipe.DoesNotExist:
+                    raise CommandError('recipe "%s" does not exist' % slug)
+        else:
+            recipes = qs.filter(schedule=True)
+
+        for recipe in recipes:
+            name = "%s %s" % (recipe.name, datetime.now().strftime("%d/%m/%Y"))
+            extra_fields = {}
+            if options['branch']:
+                extra_fields['branch'] = options['branch']
+            if options['revision']:
+                extra_fields['revision'] = options['revision']
+
+            b = Build.objects.create(name=name,
+                                     fabfile_recipe=recipe,
+                                     **extra_fields)

File src/factory/migrations/0002_auto__del_field_build_environement__add_field_build_started_datetime__.py

+# encoding: utf-8
+import datetime
+from south.db import db
+from south.v2 import SchemaMigration
+from django.db import models
+
+class Migration(SchemaMigration):
+
+    def forwards(self, orm):
+        
+        # Deleting field 'Build.environement'
+        db.delete_column('factory_build', 'environement')
+
+        # Adding field 'Build.started_datetime'
+        db.add_column('factory_build', 'started_datetime', self.gf('django.db.models.fields.DateTimeField')(null=True, blank=True), keep_default=False)
+
+        # Adding field 'FabfileRecipe.branch'
+        db.add_column('factory_fabfilerecipe', 'branch', self.gf('django.db.models.fields.CharField')(default='', max_length=255, blank=True), keep_default=False)
+
+        # Adding field 'FabfileRecipe.revision'
+        db.add_column('factory_fabfilerecipe', 'revision', self.gf('django.db.models.fields.CharField')(default='', max_length=255, blank=True), keep_default=False)
+
+
+    def backwards(self, orm):
+        
+        # Adding field 'Build.environement'
+        db.add_column('factory_build', 'environement', self.gf('django.db.models.fields.TextField')(null=True, blank=True), keep_default=False)
+
+        # Deleting field 'Build.started_datetime'
+        db.delete_column('factory_build', 'started_datetime')
+
+        # Deleting field 'FabfileRecipe.branch'
+        db.delete_column('factory_fabfilerecipe', 'branch')
+
+        # Deleting field 'FabfileRecipe.revision'
+        db.delete_column('factory_fabfilerecipe', 'revision')
+
+
+    models = {
+        'factory.build': {
+            'Meta': {'object_name': 'Build'},
+            'created_datetime': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'auto_now_add': 'True', 'blank': 'True'}),
+            'error': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'executed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'executed_datetime': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
+            'fabfile_recipe': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['factory.FabfileRecipe']"}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'output': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'revision': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'started_datetime': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
+            'success': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'task': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+        },
+        'factory.fabfilerecipe': {
+            'Meta': {'object_name': 'FabfileRecipe'},
+            'branch': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
+            'file': ('django.db.models.fields.files.FileField', [], {'max_length': '100'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'notify': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
+            'projdir': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'repos': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
+            'revision': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
+            'schedule': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '50', 'db_index': 'True'})
+        }
+    }
+
+    complete_apps = ['factory']

File src/factory/migrations/0003_auto__add_field_build_branch__add_field_fabfilerecipe_task.py

+# encoding: utf-8
+import datetime
+from south.db import db
+from south.v2 import SchemaMigration
+from django.db import models
+
+class Migration(SchemaMigration):
+
+    def forwards(self, orm):
+        
+        # Adding field 'Build.branch'
+        db.add_column('factory_build', 'branch', self.gf('django.db.models.fields.CharField')(default='', max_length=255, blank=True), keep_default=False)
+
+        # Adding field 'FabfileRecipe.task'
+        db.add_column('factory_fabfilerecipe', 'task', self.gf('django.db.models.fields.CharField')(default='build_and_test', max_length=255), keep_default=False)
+
+
+    def backwards(self, orm):
+        
+        # Deleting field 'Build.branch'
+        db.delete_column('factory_build', 'branch')
+
+        # Deleting field 'FabfileRecipe.task'
+        db.delete_column('factory_fabfilerecipe', 'task')
+
+
+    models = {
+        'factory.build': {
+            'Meta': {'object_name': 'Build'},
+            'branch': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
+            'created_datetime': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'auto_now_add': 'True', 'blank': 'True'}),
+            'error': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'executed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'executed_datetime': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
+            'fabfile_recipe': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['factory.FabfileRecipe']"}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'output': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'revision': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'started_datetime': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
+            'success': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'task': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'})
+        },
+        'factory.fabfilerecipe': {
+            'Meta': {'object_name': 'FabfileRecipe'},
+            'branch': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
+            'file': ('django.db.models.fields.files.FileField', [], {'max_length': '100'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'notify': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
+            'projdir': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
+            'repos': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
+            'revision': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
+            'schedule': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '50', 'db_index': 'True'}),
+            'task': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+        }
+    }
+
+    complete_apps = ['factory']

File src/factory/models.py

 from factory.storage import FileSystemStorageUuidName
 from factory import signals
 from datetime import datetime
-
+from url_mixin import UrlMixin
 
 # Create your models here.
-class FabfileRecipe(models.Model):
+from django.contrib.sites.models import Site
+
+class FabfileRecipe(models.Model, UrlMixin):
     name = models.CharField(max_length=255)
     slug = models.SlugField()
     notify = models.CharField(max_length=255, blank=True,
                               help_text='Comma-separated email addresses of people to notify of build results')
-    schedule = models.BooleanField() # or a charfield that lets you specify a job ID that matches the cronjob that will call schedule.py
+    schedule = models.BooleanField(help_text='Enable this recipe to be built periodically')
     file = models.FileField(upload_to='factory/fabfiles')
+    task = models.CharField(max_length=255,
+                            help_text='Space separated list of tasks to run, unless explicitly overriden by build')
 
     repos = models.CharField(max_length=255, blank=True,
                              help_text=u'URL of git repository to clone')
-    projdir = models.CharField(max_length=255,
-                               help_text=u'directory to cd into after clone, '
+
+    branch = models.CharField(max_length=255, blank=True,
+                              help_text=u'Branch to check out')
+
+    revision = models.CharField(max_length=255, blank=True,
+                                help_text=u'Revision to check out')
+
+    projdir = models.CharField(max_length=255, blank=True,
+                               help_text=u'Directory to change into after clone, '
                                          u'including the name of the work tree')
 
-    # default task
-    #task = models.CharField(max_length=255)
-    # proj repository (for "standard" fabfile)
-    #url = models.URLField(verify_exists=False, max_length=200, blank=True)
-    
+    @models.permalink
+    def get_url_path(self):
+        return ('factory.views.recipe_detail', [str(self.id)])
+
     def __unicode__(self):
         return self.name
 
 
-class Build(models.Model):
+class Build(models.Model, UrlMixin):
     name = models.CharField(max_length=255)
-    task = models.CharField(max_length=255,
-                            help_text='Space separated list of tasks')
+    task = models.CharField(max_length=255, blank=True,
+                            help_text='Space separated list of tasks to run.')
     fabfile_recipe = models.ForeignKey(FabfileRecipe)
 
-    # we can perhaps use this for the git revision to use?
-    revision = models.CharField(max_length=255, null=True, blank=True)
+    branch = models.CharField(max_length=255, blank=True,
+                              help_text=u'Branch to check out')
 
-    executed = models.BooleanField()
-    executed_datetime = models.DateTimeField(blank=True, null=True)
-    success = models.BooleanField()
-    environement = models.TextField(null=True, blank=True)
-    output = models.TextField(null=True, blank=True)
-    error = models.TextField(null=True, blank=True)
-    created_datetime = models.DateTimeField(auto_now_add=True, auto_now=True)
+    revision = models.CharField(max_length=255, null=True, blank=True,
+                                help_text=u'Revision of the repository this build was checked out under')
 
-    #execution_time = models.IntegerField()
-    #started_datetime = models.DateTimeField(blank=True, null=True)
-    # from above (both reported by worker), we can find out the queued time
+    executed = models.BooleanField(help_text=u'Has this been built yet?')
+    executed_datetime = models.DateTimeField(blank=True, null=True,
+                                             help_text=u'Date and time this build was completed')
+    success = models.BooleanField(help_text=u'If build was executed, this indicates success')
+    output = models.TextField(null=True, blank=True, help_text=u'Captured stdout from build')
+    error = models.TextField(null=True, blank=True, help_text=u'Captured stderr from build')
+    created_datetime = models.DateTimeField(auto_now_add=True, auto_now=True,
+                                            help_text=u'Date and time this build was created')
+    started_datetime = models.DateTimeField(blank=True, null=True,
+                                            help_text=u'Date and time this build was started')
+
+
+    @property
+    def execution_time(self):
+        if not self.executed_datetime or not self.started_datetime:
+            return ''
+
+        delta = self.executed_datetime - self.started_datetime
+        out = ''
+        if delta.days:
+            out = '%d day(s) ' % delta.days
+
+        out += '%.02f minutes' % (delta.seconds/60.0)
+
+        return out
+
+    
+    @models.permalink
+    def get_url_path(self):
+        return ('factory.views.build_detail', [str(self.id)])
+
+
 
     def __unicode__(self):
         return self.name
 
     def save(self, force_insert=False, force_update=False, **kwargs):
-        if self.executed:
+        if self.executed and not self.executed_datetime:
             self.executed_datetime = datetime.now()
+
+        if not self.task:
+            self.task = self.fabfile_recipe.task
+
         return super(Build, self).save(force_insert, force_update, **kwargs)
 
     def make_build_package(self):

File src/factory/signals.py

 from django.db.models.signals import pre_save
-from django.core.mail import send_mail
-from django.conf import settings
-import logging
-
-
-def send_build_email(instance, **kwargs):
-    if not instance.executed:
-        return
-
-    if not instance.fabfile_recipe.notify:
-        return
-
-    email_results = getattr(settings, 'FF_EMAIL_BUILD_RESULTS', False)
-    if not email_results or (email_results is "failed" and instance.success):
-        return
-
-    subject = '%s Build Results (%s)' % (instance, "success" if instance.success else "failure")
-    msg = """
-    Tasks: %(task)s
-    Recipe: %(recipe)s
-    ------------------------------------------------
-    Output
-    ------------------------------------------------
-    %(output)s
-
-
-    ------------------------------------------------
-    Errors
-    ------------------------------------------------
-    %(error)s
-    """ % dict(task=instance.task,
-               recipe=instance.fabfile_recipe,
-               output=instance.output,
-               error=instance.error)
-
-    try:
-        recipients = [v.strip() for v in instance.fabfile_recipe.notify.split(',')]
-        send_mail(subject, msg, settings.SERVER_EMAIL,
-                  recipients, fail_silently=False)
-    except:
-        # in production, if sending this email fails, django is unlikely to send the error mail.
-        # in debug, we can't really tell if this came from loaddata (fixtures).. 
-        # so we just ignore the error
-        logging.getLogger(__name__).exception("unable to send build email")
-
+from .mail import send_build_email
 
 
 

File src/factory/templatetags/__init__.py

Empty file added.

File src/factory/templatetags/factory.py

+from django import template
+from django.utils.safestring import mark_safe
+
+register = template.Library()
+
+
+@register.filter
+def indent(value, indent_text="\t"):
+    """ add the given ``indent_text`` to each line in the given ``value`` """
+    return mark_safe(indent_text + ("\n" + indent_text).join(value.split('\n')))
+
+

File src/factory/tests/__init__.py

File contents unchanged.

File src/factory/url_mixin.py

+# from http://github.com/simonw/django-urls/blob/master/django_urls/base.py
+from django.contrib.sites.models import Site
+from django.conf import settings
+import urlparse
+
+class UrlMixin(object):
+
+    def get_url(self):
+        if hasattr(self.get_url_path, 'dont_recurse'):
+            raise NotImplemented
+        try:
+            path = self.get_url_path()
+        except NotImplemented:
+            raise
+        protocol = getattr(settings, "PROTOCOL", "http")
+        domain = Site.objects.get_current().domain
+        port = getattr(settings, "PORT", "")
+        if port:
+            assert port.startswith(":"), "The PORT setting must have a preceeding ':'."
+        return "%s://%s%s%s" % (protocol, domain, port, path)
+    get_url.dont_recurse = True
+
+    def get_url_path(self):
+        if hasattr(self.get_url, 'dont_recurse'):
+            raise NotImplemented
+        try:
+            url = self.get_url()
+        except NotImplemented:
+            raise
+        bits = urlparse.urlparse(url)
+        return urlparse.urlunparse(('', '') + bits[2:])
+    get_url_path.dont_recurse = True

File src/factory/urls.py

 from factory.views import build_lists
 
 urlpatterns = patterns('',
+    url(r'^recipe/(?P<slug>[\w_\-]+)/$', 'factory.views.recipe_detail'),
     url(r'^build/create/$', 'factory.views.build_create'),
     url(r'^build/update/(?P<object_id>\d+)/$', 'factory.views.build_update',
-     name="build_update"),
+        name="build_update"),
     url(r'^build/(?P<object_id>\d+)/$', 'factory.views.build_detail'),
-    
+
     url(r'^build/list/$', build_lists["all"],
         name="build_list_all"),
     url(r'^build/list/success/$', build_lists["success"],

File src/factory/views.py

 from django.shortcuts import get_object_or_404
 from django.shortcuts import render_to_response
 from django.contrib.sites.models import Site
+from datetime import datetime, tzinfo
 
 from django.views.generic.list_detail import object_detail, object_list
 from django.views.generic.create_update import create_object, update_object
                          template_name='factory/build_detail.html',
                          context_processors=None)
 
+
+def recipe_detail(request, slug):
+    return object_detail(request, queryset=FabfileRecipe.objects.all(),
+                         slug=slug,
+                         template_name='factory/recipe_detail.html',
+                         context_processors=None)
+
+
+
+def log_error(func):
+    def _call_func(*args, **argd):
+        try:
+            return func(*args, **argd)
+        except Exception, e:
+            print e
+            import traceback
+            print traceback.format_exc(e)
+        except:
+            print "error!!!!"
+            import traceback
+            traceback.print_exc()
+    return _call_func
+
+@log_error
 def build_update(request, object_id):
     """
     Update information related to the build which has
     id == object_id    
     """
+    new_post_dict = request.POST.copy()
+    if 'executed_datetime' not in new_post_dict:
+        # if not given, we assume now (assuming this is posted to us rather soon after the build was done)
+        new_post_dict['executed_datetime'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
+
+    request.POST = new_post_dict
     return update_object(request, model=Build, object_id=object_id,
                 form_class = BuildForm,
                 template_name='factory/build_form.html',

File src/project/settings.py

     'django.contrib.sessions',
     'django.contrib.sites',
     'django.contrib.admin',
+    'django_extensions',
     'south',
-    
+
     'factory',
     "worker", # added only to be able to run the test suite
 )
                     filemode="a")
 
 FF_EMAIL_BUILD_RESULTS = "failed" # (True|False|"failed") - "failed" will email only if build failed
+FF_BUILD_RESULT_EMAIL_SUBJECT = "%(build)s Build Results (%(status)s)"
+FF_BUILD_RESULT_EMAIL_HTML = True
 
 try:
     from project.local_settings import *
-except:
+except ImportError:
+    # most likely doesn't exist, ignore
+    # other errors like SyntaxError should bubble up
     pass

File src/project/templates/factory/build_result_mail.html

+{# this is currently unused since we're using the txt template in markdown format and generating html from it #}
+<p>
+	Build result is available here: {{ build }}
+</p>
+
+<p>
+	<strong>Recipe</strong>: {{ recipe }}
+</p>
+
+<p>
+	<strong>Tasks</strong>: {{ task }}
+</p>
+
+<h3>Errors</h3>
+<pre>
+{{ error }}
+</pre>
+
+<h3>Output</h3>
+<pre>
+{{ output }}
+</pre>
+
+Full build result is available here: {{ build }}

File src/project/templates/factory/build_result_mail.txt

+{% load factory %}
+Build: [{{ build }}]({{ build.get_url }})
+
+Recipe: [{{ recipe }}]({{ recipe.get_url }})
+
+Status: {% if build.success %}SUCESS{% else %}FAILED{% endif %}
+
+Tasks: {{ task }}
+
+
+---------------------------------------------------------------
+Errors
+===============================================================
+    :::pytb
+{{ error|indent:"    " }}
+
+
+
+* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
+Output
+===============================================================
+{{ output|indent:"    " }}
+
+* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
+Build URL: {{ build.get_url }}

File src/project/templates/factory/recipe_detail.html

+{% extends "factory/base.html" %}
+
+{% block content %}
+<h1>{{ object }}</h1>
+<h2>Status</h2>
+<ul id="id">
+    <li>Notify:{{ object.notify }}</li>
+    <li>Schedule: {{ object.schedule }}</li>
+    <li>Repository URL: {{ object.repos }}</li>
+    <li>Branch: {{ object.branch }}</li>
+    <li>Revision: {{ object.revision }}</li>
+    <li>Task: {{ object.task }}</li>
+    <li><a href="{{ object.file.url }}">fabfile</a></li>
+</ul>
+<h2>Runner</h2>
+<a href="{{ object.get_build_package_url }}">download</a>
+
+
+{% endblock %}

File src/worker/__init__.py

 from imp import load_source
 from shutil import rmtree
 from StringIO import StringIO
+from datetime import datetime
 import logging
+import traceback
 import os
 import sys
 import tarfile
 
 
     def execute_task(self):
+        self.started = datetime.now()
         #a bit of hackery there to import this particular fabfile
         logging.debug("Worker try to execute the task : %s" %
                       self.task)
             'environment':'',
             'output': str(self.output),
             'error':str(self.error),
+            'started_datetime': self.started.strftime('%Y-%m-%d %H:%M:%S'),
         }
         if self.success:
             values['success'] = 'on'
-        logging.debug("Post the values : %s" %values)
+        logging.debug("Post the values : %s" % (values,))
         data = urllib.urlencode(values)
         request = urllib2.Request(self.post_back_url, data)
         fd=urllib2.urlopen(request)
                 rmtree(f)
 
 
-    @staticmethod
-    def _execute_task_from_fabfile(fabfile_path, task, slug, repos, projdir):
+    def _execute_task_from_fabfile(self, fabfile_path, task, slug, repos, projdir):
         from fabric.api import env
         print "***** repos=%s" % repos
         env['ff_slug'] = slug
                 failed = True
             except Exception, e:
                 logging.error("%s %s" %(e.__class__(), str(e)))
-                return (output.getvalue(), error.getvalue() + '\n' + str(e), False)
+                errorout = "Error running task %s, traceback follows:\n%s\nCaptured output from STDERR:\n%s" % \
+                    (task, traceback.format_exc(), error.getvalue())
+                return (output.getvalue(),
+                        errorout,
+                        False)
             finally:
                 sys.stdout = sys.__stdout__
                 sys.stderr = sys.__stderr__

File src/worker/run_worker.py

+#!/usr/bin/env python
+
+
 import os
 import urllib2
 try:
     json_string = response.read()
     logging.debug('json response from the factory server \n %s' %json_string)
     worker_dict = json.loads(json_string)
-   
+
     if worker_dict:
         worker = Worker(name=worker_dict['name'], task=worker_dict['task'],
                     post_back_url=worker_dict['post_back_url'],