Răsfoiți Sursa

[oozie] Improve Editor usability

Adding help_text to the model fields
Popover help text when editing a Workflow, Workflow action, Coordinator
Order history desc
Fix create Workflow/Coordinator form bug with 'parameters'
Feature: when schedule a workflow, list the already created coordinators
instead if they are some
Romain Rigaux 13 ani în urmă
părinte
comite
aad045a

+ 3 - 1
apps/oozie/src/oozie/forms.py

@@ -38,6 +38,7 @@ class WorkflowForm(forms.ModelForm):
     widgets = {
       'description': forms.TextInput(attrs={'class': 'span5'}),
       'deployment_dir': forms.TextInput(attrs={'class': 'pathChooser', 'style': "width:535px"}),
+      'parameters': forms.widgets.HiddenInput(),
     }
 
 
@@ -147,7 +148,8 @@ class CoordinatorForm(forms.ModelForm):
     model = Coordinator
     exclude = ('owner', 'schema_version', 'deployment_dir')
     widgets = {
-      'description': forms.TextInput(attrs={'class': 'span5'})
+      'description': forms.TextInput(attrs={'class': 'span5'}),
+      'parameters': forms.widgets.HiddenInput(),
     }
 
 

+ 189 - 63
apps/oozie/src/oozie/models.py

@@ -31,14 +31,16 @@ from django.core.validators import RegexValidator
 from django.contrib.auth.models import User
 from django.utils.translation import ugettext as _, ugettext_lazy as _t
 
+from desktop.log.access import access_warn
 from desktop.lib import django_mako
+from desktop.lib.django_util import PopupException
 from hadoop.fs.exceptions import WebHdfsException
 
 from hadoop.fs.hadoopfs import Hdfs
 from liboozie.submittion import Submission
 
 from oozie.management.commands import oozie_setup
-from oozie.conf import REMOTE_SAMPLE_DIR
+from oozie.conf import REMOTE_SAMPLE_DIR, SHARE_JOBS
 from timezones import TIMEZONES
 
 
@@ -50,23 +52,62 @@ name_validator = RegexValidator(regex='[a-zA-Z_][\-_a-zA-Z0-9]{1,39}',
                                 message=_('Please enter a valid value: 40 alphanum chars starting by an alpha'))
 
 
+"""
+Permissions:
+
+A Workflow/Coordinator can be accessed/submitted by its owner, a superuser or by anyone if its 'is_shared'
+property and SHARE_JOBS are set to True.
+
+A Workflow/Coordinator can be modified only by its owner or a superuser.
+
+Permissions checking happens by adding the decorators.
+"""
+class JobManager(models.Manager):
+  def is_accessible_or_exception(self, request, job_id):
+    if job_id is None:
+      return
+    try:
+      job = Job.objects.select_related().get(pk=job_id).get_full_node()
+      if job.is_accessible(request.user):
+        return job
+      else:
+        message = _("Permission denied. %(username)s don't have the permissions to access job %(id)s") % \
+            {'username': request.user.username, 'id': job.id}
+        access_warn(request, message)
+        request.error(message)
+        raise PopupException(message)
+
+    except Job.DoesNotExist:
+      raise PopupException(_('job %(id)s not exist') % {'id': job_id})
+
+  def can_edit_or_exception(self, request, job):
+    if job.is_editable(request.user):
+      return True
+    else:
+      raise PopupException(_('Not allowed to modified this job'))
+
+
 class Job(models.Model):
   """
   Base class for Workflows and Coordinators.
 
   http://incubator.apache.org/oozie/docs/3.2.0-incubating/docs/index.html
   """
-  owner = models.ForeignKey(User, db_index=True)
+  owner = models.ForeignKey(User, db_index=True, help_text=_('Person who can modify the job.'))
   name = models.CharField(max_length=40, blank=False, validators=[name_validator],
-      help_text=_('Name of the design, which must be unique per user'))
-  description = models.CharField(max_length=1024, blank=True)
+      help_text=_('Name of the job, which must be unique per user.'))
+  description = models.CharField(max_length=1024, blank=True, help_text=_('What is the purpose of the job.'))
   last_modified = models.DateTimeField(auto_now=True, db_index=True)
   schema_version = models.CharField(max_length=128, blank=True, default='')
-  deployment_dir = models.CharField(max_length=1024, blank=True, verbose_name=_('HDFS deployment directory'))
-  is_shared = models.BooleanField(default=False, db_index=True)
+  deployment_dir = models.CharField(max_length=1024, blank=True, verbose_name=_('HDFS deployment directory.'),
+                                    help_text=_('The path on the HDFS where all the workflows and '
+                                                'dependencies must be uploaded.'))
+  is_shared = models.BooleanField(default=False, db_index=True,
+                                  help_text=_('Check if you want to have some other users to have access to this job.'))
   parameters = models.TextField(default='[]',
-                                help_text=_t('Configuration parameters (e.g. market=US)'))
+                                help_text=_t('Set some variables of the job (e.g. market=US)'))
 
+  objects = JobManager()
   unique_together = ('owner', 'name')
 
   def save(self):
@@ -116,6 +157,13 @@ class Job(models.Model):
 
     return  [{'name': name, 'value': value} for name, value in params.iteritems()]
 
+  def is_accessible(self, user):
+    return user.is_superuser or self.owner == user or (SHARE_JOBS.get() and self.is_shared)
+
+  def is_editable(self, user):
+    """Only owners or admins can modify a job."""
+    return user.is_superuser or self.owner == user
+
 
 class WorkflowManager(models.Manager):
   def new_workflow(self, owner):
@@ -160,7 +208,7 @@ class Workflow(Job):
   is_single = models.BooleanField(default=False)
   start = models.ForeignKey('Start', related_name='start_workflow', blank=True, null=True)
   end  = models.ForeignKey('End', related_name='end_workflow',  blank=True, null=True)
-
+  # jobxml
   objects = WorkflowManager()
 
   HUE_ID = 'hue-id-w'
@@ -513,9 +561,10 @@ class Node(models.Model):
   """
   PARAM_FIELDS = ()
 
-  name = models.CharField(max_length=40, validators=[name_validator])
-  description = models.CharField(max_length=1024, blank=True, default='')
-  node_type = models.CharField(max_length=64, blank=False)
+  name = models.CharField(max_length=40, validators=[name_validator],
+                          help_text=_('Name of the action, it must be unique by workflow.'))
+  description = models.CharField(max_length=1024, blank=True, default='', help_text=_('What is the purpose of this action.'))
+  node_type = models.CharField(max_length=64, blank=False, help_text=_('The type of action (e.g. MapReduce, Pig...)'))
   workflow = models.ForeignKey(Workflow)
   children = models.ManyToManyField('self', related_name='parents', symmetrical=False, through=Link)
 
@@ -623,7 +672,7 @@ class Node(models.Model):
     copy.save()
     return copy
 
-  def can_edit(self):
+  def is_editable(self):
     return False
 
   def can_move(self):
@@ -654,13 +703,13 @@ class Action(Node):
     # Cloning does not work anymore if not abstract
     abstract = True
 
-  def can_edit(self):
+  def is_editable(self):
     return True
 
   def get_edit_link(self):
     return reverse('oozie:edit_action', kwargs={'action': self.id})
 
-
+# The fields with '[]' as default value are JSON dictionaries
 # When adding a new action, also update
 #  - Action.types below
 #  - Node.get_full_node()
@@ -670,13 +719,19 @@ class Mapreduce(Action):
   node_type = 'mapreduce'
 
   files = models.CharField(max_length=PATH_MAX, default="[]",
-      help_text=_t('List of paths to files to be added to the distributed cache'))
+      help_text=_t('List of names or paths of files to be added to the distributed cache. '
+                   'To force a symlink for a file on the task running directory, use a \'#\' '
+                   'followed by the symlink name. For example \'mycat.sh#cat\'.'))
   archives = models.CharField(max_length=PATH_MAX, default="[]",
-      help_text=_t('List of paths to archives to be added to the distributed cache'))
-  job_properties = models.TextField(default='[]', # JSON dict
-                                    help_text=_t('For the job configuration (e.g. mapred.mapper.class)'))
-  jar_path = models.CharField(max_length=PATH_MAX, help_text=_t('Path to jar files on HDFS'))
-  prepares = models.TextField(default="[]", help_text=_t('List of paths to delete of create before starting the job'))
+      help_text=_t('List of names or paths of the archives to be added to the distributed cache. '
+                   'To force a symlink to the uncompressed archive on the task running directory, use a \'#\' '
+                   'followed by the symlink name. For example \'myarch.zip#myarch\'.'))
+  job_properties = models.TextField(default='[]',
+                                    help_text=_t('For the job configuration (e.g. mapred.job.queue.name=production)'))
+  jar_path = models.CharField(max_length=PATH_MAX,
+                              help_text=_t('Local or absolute path to the %(program)s jar file on HDFS') % {'program': 'MapReduce'})
+  prepares = models.TextField(default="[]", help_text=_t('List of paths to delete or create before starting the application. '
+                                                         'This should be used exclusively for directory cleanup'))
 
   def get_properties(self):
     return json.loads(self.job_properties)
@@ -695,12 +750,20 @@ class Streaming(Action):
   PARAM_FIELDS = ('files', 'archives', 'job_properties', 'mapper', 'reducer')
   node_type = "streaming"
 
-  files = models.CharField(max_length=PATH_MAX, default="[]")
-  archives = models.CharField(max_length=PATH_MAX, default="[]")
-  job_properties = models.TextField(default='[{"name":"oozie.use.system.libpath","value":"true"}]', # JSON dict
-                                    help_text=_t('For the job configuration (e.g. mapred.mapper.class)'))
-  mapper = models.CharField(max_length=PATH_MAX, blank=False)
-  reducer = models.CharField(max_length=PATH_MAX, blank=False)
+  files = models.CharField(max_length=PATH_MAX, default="[]",
+      help_text=_t('List of names or paths of files to be added to the distributed cache. '
+                   'To force a symlink for a file on the task running directory, use a \'#\' '
+                   'followed by the symlink name. For example \'mycat.sh#cat\'.'))
+  archives = models.CharField(max_length=PATH_MAX, default="[]",
+      help_text=_t('List of names or paths of the archives to be added to the distributed cache. '
+                   'To force a symlink to the uncompressed archive on the task running directory, use a \'#\' '
+                   'followed by the symlink name. For example \'myarch.zip#myarch\'.'))
+  job_properties = models.TextField(default='[{"name":"oozie.use.system.libpath","value":"true"}]',
+                                    help_text=_t('For the job configuration (e.g. mapred.job.queue.name=production'))
+  mapper = models.CharField(max_length=PATH_MAX, blank=False,
+                            help_text=_t('The mapper element is used to specify the executable/script to be used as mapper.'))
+  reducer = models.CharField(max_length=PATH_MAX, blank=False,
+                             help_text=_t('The reducer element is used to specify the executable/script to be used as reducer.'))
 
   def get_properties(self):
     return json.loads(self.job_properties)
@@ -718,16 +781,28 @@ class Java(Action):
   node_type = "java"
 
   files = models.CharField(max_length=PATH_MAX, default="[]",
-      help_text=_t('List of paths to files to be added to the distributed cache'))
+      help_text=_t('List of names or paths of files to be added to the distributed cache. '
+                   'To force a symlink for a file on the task running directory, use a \'#\' '
+                   'followed by the symlink name. For example \'mycat.sh#cat\'.'))
   archives = models.CharField(max_length=PATH_MAX, default="[]",
-      help_text=_t('List of paths to archives to be added to the distributed cache'))
-  jar_path = models.CharField(max_length=PATH_MAX, blank=False)
-  main_class = models.CharField(max_length=256, blank=False)
-  args = models.CharField(max_length=4096, blank=True)
-  java_opts = models.CharField(max_length=256, blank=True)
-  job_properties = models.TextField(default='[]', # JSON dict
-                                    help_text=_t('For the job configuration (e.g. mapred.mapper.class)'))
-  prepares = models.TextField(default="[]", help_text=_t('List of paths to delete of create before starting the job'))
+      help_text=_t('List of names or paths of the archives to be added to the distributed cache. '
+                   'To force a symlink to the uncompressed archive on the task running directory, use a \'#\' '
+                   'followed by the symlink name. For example \'myarch.zip#myarch\'.'))
+  jar_path = models.CharField(max_length=PATH_MAX, blank=False,
+                              help_text=_t('Local or absolute path to the %(program)s jar file on HDFS') % {'program': 'Java'})
+  main_class = models.CharField(max_length=256, blank=False,
+                                help_text=_t('Full name of the Java class. e.g. org.apache.hadoop.examples.Grep'))
+  args = models.CharField(max_length=4096, blank=True,
+                          help_text=_t('Arguments of the main method. The value of each arg element is considered a single argument '
+                                       'and they are passed to the main method in the same order.'))
+  java_opts = models.CharField(max_length=256, blank=True,
+                               help_text=_t('Command line parameters which are to be used to start the JVM that will execute '
+                                            'the Java application. Using this element is equivalent to use the mapred.child.java.opts '
+                                            'configuration property'))
+  job_properties = models.TextField(default='[]',
+                                    help_text=_t('For the job configuration (e.g. mapred.job.queue.name=production'))
+  prepares = models.TextField(default="[]", help_text=_t('List of paths to delete or create before starting the application. '
+                                                         'This should be used exclusively for directory cleanup'))
 
   def get_properties(self):
     return json.loads(self.job_properties)
@@ -746,16 +821,21 @@ class Pig(Action):
   PARAM_FIELDS = ('files', 'archives', 'job_properties', 'params', 'prepares')
   node_type = 'pig'
 
-  script_path = models.CharField(max_length=256, blank=False, help_text=_t('Local path'))
-  params = models.TextField(default="[]", help_text=_t('The Pig parameters of the script'))
+  script_path = models.CharField(max_length=256, blank=False, help_text=_t('Local path to the Pig script. e.g. my_script.pig'))
+  params = models.TextField(default="[]", help_text=_t('The Pig parameters of the script. e.g. "-param", "INPUT=${inputDir}"'))
 
   files = models.CharField(max_length=PATH_MAX, default="[]",
-      help_text=_t('List of paths to files to be added to the distributed cache'))
+      help_text=_t('List of names or paths of files to be added to the distributed cache. '
+                   'To force a symlink for a file on the task running directory, use a \'#\' '
+                   'followed by the symlink name. For example \'mycat.sh#cat\'.'))
   archives = models.CharField(max_length=PATH_MAX, default="[]",
-      help_text=_t('List of paths to archives to be added to the distributed cache'))
-  job_properties = models.TextField(default='[{"name":"oozie.use.system.libpath","value":"true"}]', # JSON dict
-                                    help_text=_t('For the job configuration (e.g. mapred.mapper.class)'))
-  prepares = models.TextField(default="[]", help_text=_t('List of paths to delete of create before starting the job'))
+      help_text=_t('List of names or paths of the archives to be added to the distributed cache. '
+                   'To force a symlink to the uncompressed archive on the task running directory, use a \'#\' '
+                   'followed by the symlink name. For example \'myarch.zip#myarch\'.'))
+  job_properties = models.TextField(default='[{"name":"oozie.use.system.libpath","value":"true"}]',
+                                    help_text=_t('For the job configuration (e.g. mapred.job.queue.name=production'))
+  prepares = models.TextField(default="[]", help_text=_t('List of paths to delete or create before starting the application. '
+                                                         'This should be used exclusively for directory cleanup'))
 
   def get_properties(self):
     return json.loads(self.job_properties)
@@ -827,7 +907,7 @@ class Fork(ControlFlow):
   def get_child_join(self):
     return Link.objects.get(parent=self, name='related').child.get_full_node()
 
-  def can_edit(self):
+  def is_editable(self):
     return True
 
   def get_edit_link(self):
@@ -880,12 +960,19 @@ class Coordinator(Job):
   """
   http://incubator.apache.org/oozie/docs/3.2.0-incubating/docs/CoordinatorFunctionalSpec.html
   """
-  frequency_number = models.SmallIntegerField(default=1, choices=FREQUENCY_NUMBERS)
-  frequency_unit = models.CharField(max_length=20, choices=FREQUENCY_UNITS, default='days')
-  timezone = models.CharField(max_length=24, choices=TIMEZONES, default='America/Los_Angeles')
-  start = models.DateTimeField(default=datetime(2012, 07, 01, 0, 0))
-  end = models.DateTimeField(default=datetime(2012, 07, 01, 0, 0) + timedelta(days=3))
-  workflow = models.ForeignKey(Workflow, null=True)
+  frequency_number = models.SmallIntegerField(default=1, choices=FREQUENCY_NUMBERS,
+                                              help_text=_t('It represents the number of units of the rate at which '
+                                                           'data is periodically created.'))
+  frequency_unit = models.CharField(max_length=20, choices=FREQUENCY_UNITS, default='days',
+                                    help_text=_t('It represents the unit of the rate at which data is periodically created.'))
+  timezone = models.CharField(max_length=24, choices=TIMEZONES, default='America/Los_Angeles',
+                              help_text=_t('The timezone of the Coordinator.'))
+  start = models.DateTimeField(default=datetime.today(),
+                               help_text=_t('When we need to start the first workflow.'))
+  end = models.DateTimeField(default=datetime.today() + timedelta(days=3),
+                             help_text=_t('When we need to start the last workflow.'))
+  workflow = models.ForeignKey(Workflow, null=True,
+                               help_text=_t('The corresponding workflow we want to schedule repeatedly.'))
 
   HUE_ID = 'hue-id-w'
 
@@ -978,17 +1065,52 @@ def utc_datetime_format(utc_time):
   return utc_time.strftime("%Y-%m-%dT%H:%MZ")
 
 
-class Dataset(models.Model):
-  name = models.CharField(max_length=40, validators=[name_validator])
-  description = models.CharField(max_length=1024, blank=True, default='')
-  start = models.DateTimeField(default=datetime.today())
-  frequency_number = models.SmallIntegerField(default=1, choices=FREQUENCY_NUMBERS)
-  frequency_unit = models.CharField(max_length=20, choices=FREQUENCY_UNITS, default='days')
-  uri = models.CharField(max_length=1024, default='/data/${YEAR}${MONTH}${DAY}')
-  timezone = models.CharField(max_length=24, choices=TIMEZONES, default='America/Los_Angeles')
-  done_flag = models.CharField(max_length=64, blank=True, default='')
-  coordinator = models.ForeignKey(Coordinator)
+class DatasetManager(models.Manager):
+  def is_accessible_or_exception(self, request, dataset_id):
+    if dataset_id is None:
+      return
+    try:
+      dataset = Dataset.objects.get(pk=dataset_id)
+      if dataset.coordinator.is_accessible(request.user):
+        return dataset
+      else:
+        message = _("Permission denied. %(username)s don't have the permissions to access dataset %(id)s") % \
+            {'username': request.user.username, 'id': dataset.id}
+        access_warn(request, message)
+        request.error(message)
+        raise PopupException(message)
+
+    except Dataset.DoesNotExist:
+      raise PopupException(_('dataset %(id)s not exist') % {'id': dataset_id})
 
+
+class Dataset(models.Model):
+  name = models.CharField(max_length=40, validators=[name_validator],
+                          help_text=_t('The name of the dataset.)'))
+  description = models.CharField(max_length=1024, blank=True, default='',
+                                 help_text=_t('More details about the dataset.'))
+  start = models.DateTimeField(default=datetime.today(),
+                               help_text=_t(' The UTC datetime of the initial instance of the dataset. The initial-instance also provides '
+                                            'the baseline datetime to compute instances of the dataset using multiples of the frequency.'))
+  frequency_number = models.SmallIntegerField(default=1, choices=FREQUENCY_NUMBERS,
+                                              help_text=_t('It represents the number of units of the rate at which '
+                                                           'data is periodically created.'))
+  frequency_unit = models.CharField(max_length=20, choices=FREQUENCY_UNITS, default='days',
+                                    help_text=_t('It represents the unit of the rate at which data is periodically created.'))
+  uri = models.CharField(max_length=1024, default='/data/${YEAR}${MONTH}${DAY}',
+                         help_text=_t('The URI template that identifies the dataset and can be resolved into concrete URIs to identify a particular '
+                                      'dataset instance. The URI consist of constants (e.g. ${YEAR}/${MONTH}) and '
+                                      'configuration properties (e.g. Ex: ${YEAR}/${MONTH})'))
+  timezone = models.CharField(max_length=24, choices=TIMEZONES, default='America/Los_Angeles',
+                              help_text=_t('The timezone of the dataset.'))
+  done_flag = models.CharField(max_length=64, blank=True, default='',
+                               help_text=_t(' The done file for the data set. If done-flag is not specified, then Oozie '
+                                            'configures Hadoop to create a _SUCCESS file in the output directory. If the done '
+                                            'flag is set to empty, then Coordinator looks for the existence of the directory itself.'))
+  coordinator = models.ForeignKey(Coordinator,
+                                  help_text=_t('The coordinator associated with this data.'))
+
+  objects = DatasetManager()
   unique_together = ('coordinator', 'name')
 
   def __unicode__(self):
@@ -1008,16 +1130,20 @@ class Dataset(models.Model):
 
 
 class DataInput(models.Model):
-  name = models.CharField(max_length=40, validators=[name_validator], verbose_name=_('Name of an input variable in the workflow'))
-  dataset = models.OneToOneField(Dataset, verbose_name=_('Pick the dataset representing format of the data input'))
+  name = models.CharField(max_length=40, validators=[name_validator], verbose_name=_('Name of an input variable in the workflow'),
+                          help_text=_t('The name of the variable of the workflow to automatically filled up.'))
+  dataset = models.OneToOneField(Dataset, verbose_name=_('Pick the dataset representing format of the data input'),
+                                 help_text=_t('The pattern of the input data we want to process.'))
   coordinator = models.ForeignKey(Coordinator)
 
   unique_together = ('coordinator', 'name')
 
 
 class DataOutput(models.Model):
-  name = models.CharField(max_length=40, validators=[name_validator], verbose_name=_('Name of an output variable in the workflow'))
-  dataset = models.OneToOneField(Dataset, verbose_name=_('Pick the dataset representing the format of the data output'))
+  name = models.CharField(max_length=40, validators=[name_validator], verbose_name=_('Name of an output variable in the workflow'),
+                          help_text=_t('The name of the variable of the workflow to automatically filled up.'))
+  dataset = models.OneToOneField(Dataset, verbose_name=_('Pick the dataset representing the format of the data output'),
+                                 help_text=_t('The pattern of the output data we want to generate.'))
   coordinator = models.ForeignKey(Coordinator)
 
   unique_together = ('coordinator', 'name')

+ 1 - 0
apps/oozie/src/oozie/templates/editor/create_coordinator.mako

@@ -50,6 +50,7 @@ ${ layout.menubar(section='coordinators') }
              ${ utils.render_field(coordinator_form['name']) }
              ${ utils.render_field(coordinator_form['description']) }
              ${ utils.render_field(coordinator_form['workflow']) }
+             ${ coordinator_form['parameters'] }
            </div>
 
           <hr/>

+ 8 - 8
apps/oozie/src/oozie/templates/editor/create_workflow.mako

@@ -46,14 +46,14 @@ ${ layout.menubar(section='workflows') }
                    ${ utils.render_field(workflow_form['name']) }
                    ${ utils.render_field(workflow_form['description']) }
 
-          <div class="control-group ">
-            <label class="control-label">
-              <a href="#" id="advanced-btn" onclick="$('#advanced-container').toggle('hide')">
-                <i class="icon-share-alt"></i> ${ _('advanced') }</a>
-            </label>
-            <div class="controls">
-            </div>
-          </div>
+              <div class="control-group ">
+                <label class="control-label">
+                  <a href="#" id="advanced-btn" onclick="$('#advanced-container').toggle('hide')">
+                    <i class="icon-share-alt"></i> ${ _('advanced') }</a>
+                </label>
+                <div class="controls">
+                </div>
+              </div>
 
                    <div id="advanced-container" class="hide">
                      ${ utils.render_field(workflow_form['deployment_dir']) }

+ 14 - 5
apps/oozie/src/oozie/templates/editor/edit_coordinator.mako

@@ -213,7 +213,7 @@ ${ layout.menubar(section='coordinators') }
             % endif
           </div>
 
-          <div class="span10">
+          <div class="span9">
             % if coordinator.id:
               <div>
                 <table class="table table-striped table-condensed" cellpadding="0" cellspacing="0">
@@ -286,11 +286,16 @@ ${ layout.menubar(section='coordinators') }
           </thead>
           <tbody>
             % if not history:
-              ${ _('N/A') }
+              <tr>
+                <td>${ _('N/A') }</td><td></td>
+              </tr>
             % endif
             % for record in history:
                 <tr>
-                  <td><a href="${ url('oozie:list_history_record', record_id=record.id) }" data-row-selector="true"></a>${ record.submission_date }</td>
+                  <td>
+                    <a href="${ url('oozie:list_history_record', record_id=record.id) }" data-row-selector="true"></a>
+                    ${ utils.format_date(record.submission_date) }
+                  </td>
                   <td>${ record.oozie_job_id }</td>
                 </tr>
             % endfor
@@ -303,7 +308,7 @@ ${ layout.menubar(section='coordinators') }
   </div>
 
   <div class="form-actions center">
-    <a href="${ url('oozie:list_coordinator') }" class="btn">${ _('Back') }</a>
+    <a href="${ url('oozie:list_coordinators') }" class="btn">${ _('Back') }</a>
     % if can_edit_coordinator:
       <input class="btn btn-primary" data-bind="click: submit" type="submit" value="${ _('Save') }"></input>
     % endif
@@ -390,9 +395,13 @@ ${ layout.menubar(section='coordinators') }
     $("a[data-row-selector='true']").jHueRowSelector();
 
     ko.applyBindings(window.viewModel);
+
+    $("*[rel=popover]").popover({
+      placement: 'right'
+    });
  });
 </script>
 
 % endif
 
-${commonfooter(messages)}
+${ commonfooter(messages) }

+ 11 - 3
apps/oozie/src/oozie/templates/editor/edit_workflow.mako

@@ -128,8 +128,8 @@ ${ layout.menubar(section='workflows') }
 
       <div class="tab-pane" id="properties">
         <div class="row-fluid">
-          <div class="span2"></div>
-          <div class="span10">
+          <div class="span1"></div>
+          <div class="span8">
             <h2>${ _('Properties') }</h2>
             <br/>
               <fieldset>
@@ -147,6 +147,7 @@ ${ layout.menubar(section='workflows') }
             <button data-bind="click: submit" class="btn btn-primary">${ _('Save') }</button>
           % endif
         </div>
+        <div class="span3"></div>
       </div>
 
       <div class="tab-pane" id="history">
@@ -163,7 +164,10 @@ ${ layout.menubar(section='workflows') }
           <tbody>
             % for record in history:
               <tr>
-                <td><a href="${ url('oozie:list_history_record', record_id=record.id) }" data-row-selector="true"></a>${ record.submission_date }</td>
+                <td>
+                  <a href="${ url('oozie:list_history_record', record_id=record.id) }" data-row-selector="true"></a>
+                  ${ utils.format_date(record.submission_date) }
+                </td>
                 <td>${ record.oozie_job_id }</td>
               </tr>
             % endfor
@@ -213,6 +217,10 @@ ${ layout.menubar(section='workflows') }
     ko.applyBindings(window.viewModel);
 
     $("a[data-row-selector='true']").jHueRowSelector();
+
+    $("*[rel=popover]").popover({
+      placement: 'right'
+    });
   });
 </script>
 

+ 169 - 155
apps/oozie/src/oozie/templates/editor/edit_workflow_action.mako

@@ -34,199 +34,209 @@ ${ layout.menubar(section='workflows') }
   </h1>
 
   <br/>
-  <form class="form-horizontal" id="actionForm" action="${ form_url }" method="POST">
-    <fieldset>
-    % for field in action_form:
-      % if field.html_name in ('name', 'description'):
-        ${ utils.render_field(field) }
-      % endif
-    % endfor
 
-    ${ utils.render_constant(_('Action type'), node_type) }
+  <div class="row">
+    <div class="span12">
+    <form class="form-horizontal" id="actionForm" action="${ form_url }" method="POST">
+      <fieldset>
+      % for field in action_form:
+        % if field.html_name in ('name', 'description'):
+          ${ utils.render_field(field) }
+        % endif
+      % endfor
+
+      ${ utils.render_constant(_('Action type'), node_type) }
 
-    <hr/>
+      <hr/>
 
-    <div class="control-group">
-      <label class="control-label"></label>
-      <div class="controls">
-      <p class="alert alert-info span5">
-        ${ _('You can parameterize the values using uppercase') } <code>${"${"}VAR}</code>.
-      </p>
+      <div class="control-group">
+        <label class="control-label"></label>
+        <div class="controls">
+        <p class="alert alert-info span5">
+          ${ _('You can parameterize the values using uppercase') } <code>${"${"}VAR}</code>.
+        </p>
+        </div>
       </div>
-    </div>
 
-    % for field in action_form:
-      % if field.html_name not in ('name', 'description', 'node_type'):
-        ${ utils.render_field(field) }
+      % for field in action_form:
+        % if field.html_name not in ('name', 'description', 'node_type'):
+          ${ utils.render_field(field) }
+        % endif
+      % endfor
+
+      % if 'prepares' in action_form.fields:
+        <div class="control-group" rel="popover"
+            data-original-title="${ action_form['prepares'].label }" data-content="${ action_form['prepares'].help_text }">
+          <label class="control-label">${ _('Prepare') }</label>
+          <div class="controls">
+            <table class="table-condensed designTable" data-bind="visible: prepares().length > 0">
+              <thead>
+                <tr>
+                  <th>${ _('Type') }</th>
+                  <th>${ _('Value') }</th>
+                  <th/>
+                </tr>
+              </thead>
+              <tbody data-bind="foreach: prepares">
+                <tr>
+                  <td>
+                    <span class="span3 required" data-bind="text: type" />
+                  </td>
+                  <td>
+                    <input class="input span5 required pathChooserKo" data-bind="fileChooser: $data, value: value, uniqueName: false" />
+                  </td>
+                  <td><a class="btn" href="#" data-bind="click: $root.removePrepare">${ _('Delete') }</a></td>
+                </tr>
+              </tbody>
+            </table>
+
+            % if len(action_form['prepares'].errors):
+              <div class="alert alert-error">
+                ${ unicode(action_form['prepares'].errors) | n }
+              </div>
+            % endif
+
+            <button class="btn" data-bind="click: addPrepareDelete">${ _('Add delete') }</button>
+            <button class="btn" data-bind="click: addPrepareMkdir">${ _('Add mkdir') }</button>
+          </div>
+        </div>
       % endif
-    % endfor
 
-    % if 'prepares' in action_form.fields:
-      <div class="control-group">
-        <label class="control-label">${ _('Prepare') }</label>
+      % if 'params' in action_form.fields:
+        <div class="control-group" rel="popover"
+            data-original-title="${ action_form['params'].label }" data-content="${ action_form['params'].help_text }">
+          <label class="control-label">${ _('Params') }</label>
+          <div class="controls">
+            <table class="table-condensed designTable" data-bind="visible: params().length > 0">
+              <thead>
+                <tr>
+                  <th>${ _('Type') }</th>
+                  <th>${ _('Value') }</th>
+                  <th/>
+                </tr>
+              </thead>
+              <tbody data-bind="foreach: params">
+                <tr>
+                  <td>
+                    <span class="span3 required" data-bind="text: type" />
+                  </td>
+                  <td>
+                    <input class="input span5 required pathChooserKo" data-bind="fileChooser: $data, value: value, uniqueName: false" />
+                  </td>
+                  <td><a class="btn" href="#" data-bind="click: $root.removeParam">${ _('Delete') }</a></td>
+                </tr>
+              </tbody>
+            </table>
+
+            % if len(action_form['params'].errors):
+              <div class="alert alert-error">
+                ${ unicode(action_form['params'].errors) | n }
+              </div>
+            % endif
+
+            <button class="btn" data-bind="click: addParam">${ _('Add Param') }</button>
+            <button class="btn" data-bind="click: addArgument">${ _('Add Argument') }</button>
+          </div>
+        </div>
+      % endif
+
+      % if 'job_properties' in action_form.fields:
+      <div class="control-group" rel="popover"
+          data-original-title="${ action_form['job_properties'].label }" data-content="${ action_form['job_properties'].help_text }">
+        <label class="control-label">${ _('Job Properties') }</label>
         <div class="controls">
-          <table class="table-condensed designTable" data-bind="visible: prepares().length > 0">
+          <table class="table-condensed designTable" data-bind="visible: properties().length > 0">
             <thead>
               <tr>
-                <th>${ _('Type') }</th>
+                <th>${ _('Property name') }</th>
                 <th>${ _('Value') }</th>
                 <th/>
               </tr>
             </thead>
-            <tbody data-bind="foreach: prepares">
+            <tbody data-bind="foreach: properties">
               <tr>
-                <td>
-                  <span class="span3 required" data-bind="text: type" />
-                </td>
-                <td>
-                  <input class="input span5 required pathChooserKo" data-bind="fileChooser: $data, value: value, uniqueName: false" />
-                </td>
-                <td><a class="btn" href="#" data-bind="click: $root.removePrepare">${ _('Delete') }</a></td>
+                <td><input class="span4 required propKey" data-bind="value: name, uniqueName: false" /></td>
+                <td><input class="span5 required pathChooserKo" data-bind="fileChooser: $data, value: value, uniqueName: false" /></td>
+                <td><a class="btn btn-small" href="#" data-bind="click: $root.removeProp">${ _('Delete') }</a></td>
               </tr>
             </tbody>
           </table>
-
-          % if len(action_form['prepares'].errors):
-            <div class="alert alert-error">
-              ${ unicode(action_form['prepares'].errors) | n }
+          % if len(action_form['job_properties'].errors):
+            <div class="row">
+              <div class="alert alert-error">
+                ${ unicode(action_form['job_properties'].errors) | n }
+              </div>
             </div>
           % endif
 
-          <button class="btn" data-bind="click: addPrepareDelete">${ _('Add delete') }</button>
-          <button class="btn" data-bind="click: addPrepareMkdir">${ _('Add mkdir') }</button>
+          <button class="btn" data-bind="click: addProp">${ _('Add Property') }</button>
         </div>
       </div>
-    % endif
+      % endif
 
-    % if 'params' in action_form.fields:
-      <div class="control-group">
-        <label class="control-label">${ _('Params') }</label>
+      % if 'files' in action_form.fields:
+      <div class="control-group" rel="popover"
+        data-original-title="${ action_form['files'].label }" data-content="${ action_form['files'].help_text }">
+          <label class="control-label">${ _('Files') }</label>
+          <div class="controls">
+              <table class="table-condensed designTable" data-bind="visible: files().length > 0">
+                <tbody data-bind="foreach: files">
+                  <tr>
+                    <td><input class="input span5 required pathChooserKo"
+                            data-bind="fileChooser: $data, value: name, uniqueName: false" />
+                    </td>
+                    <td><a class="btn" href="#" data-bind="click: $root.removeFile">${ _('Delete') }</a></td>
+                  </tr>
+                </tbody>
+              </table>
+              % if len(action_form['files'].errors):
+                <div class="alert alert-error">
+                  ${ unicode(action_form['files'].errors) | n }
+                </div>
+              % endif
+
+              <button class="btn" data-bind="click: addFile">${ _('Add File') }</button>
+          </div>
+      </div>
+      % endif
+
+      % if 'archives' in action_form.fields:
+      <div class="control-group" rel="popover"
+          data-original-title="${ action_form['archives'].label }" data-content="${ action_form['archives'].help_text }">
+        <label class="control-label">${ _('Archives') }</label>
         <div class="controls">
-          <table class="table-condensed designTable" data-bind="visible: params().length > 0">
-            <thead>
-              <tr>
-                <th>${ _('Type') }</th>
-                <th>${ _('Value') }</th>
-                <th/>
-              </tr>
-            </thead>
-            <tbody data-bind="foreach: params">
+          <table class="table-condensed designTable" data-bind="visible: archives().length > 0">
+            <tbody data-bind="foreach: archives">
               <tr>
                 <td>
-                  <span class="span3 required" data-bind="text: type" />
+                  <input class="input span5 required pathChooserKo"
+                      data-bind="fileChooser: $data, value: name, uniqueName: false" />
                 </td>
-                <td>
-                  <input class="input span5 required pathChooserKo" data-bind="fileChooser: $data, value: value, uniqueName: false" />
-                </td>
-                <td><a class="btn" href="#" data-bind="click: $root.removeParam">${ _('Delete') }</a></td>
+                <td><a class="btn" href="#" data-bind="click: $root.removeArchive">${ _('Delete') }</a></td>
               </tr>
             </tbody>
           </table>
-
-          % if len(action_form['params'].errors):
+          % if len(action_form['archives'].errors):
             <div class="alert alert-error">
-              ${ unicode(action_form['params'].errors) | n }
+              ${ unicode(action_form['archives'].errors) | n }
             </div>
           % endif
 
-          <button class="btn" data-bind="click: addParam">${ _('Add Param') }</button>
-          <button class="btn" data-bind="click: addArgument">${ _('Add Argument') }</button>
-        </div>
+          <button class="btn" data-bind="click: addArchive">${ _('Add Archive') }</button>
+         </div>
       </div>
-    % endif
-
-    % if 'job_properties' in action_form.fields:
-    <div class="control-group">
-      <label class="control-label">${ _('Job Properties') }</label>
-      <div class="controls">
-        <table class="table-condensed designTable" data-bind="visible: properties().length > 0">
-          <thead>
-            <tr>
-              <th>${ _('Property name') }</th>
-              <th>${ _('Value') }</th>
-              <th/>
-            </tr>
-          </thead>
-          <tbody data-bind="foreach: properties">
-            <tr>
-              <td><input class="span4 required propKey" data-bind="value: name, uniqueName: false" /></td>
-              <td><input class="span5 required pathChooserKo" data-bind="fileChooser: $data, value: value, uniqueName: false" /></td>
-              <td><a class="btn btn-small" href="#" data-bind="click: $root.removeProp">${ _('Delete') }</a></td>
-            </tr>
-          </tbody>
-        </table>
-        % if len(action_form['job_properties'].errors):
-          <div class="row">
-            <div class="alert alert-error">
-              ${ unicode(action_form['job_properties'].errors) | n }
-            </div>
-          </div>
-        % endif
-
-        <button class="btn" data-bind="click: addProp">${ _('Add Property') }</button>
-      </div>
-    </div>
-    % endif
-
-    % if 'files' in action_form.fields:
-    <div class="control-group">
-        <label class="control-label">${ _('Files') }</label>
-        <div class="controls">
-            <table class="table-condensed designTable" data-bind="visible: files().length > 0">
-              <tbody data-bind="foreach: files">
-                <tr>
-                  <td><input class="input span5 required pathChooserKo"
-                          data-bind="fileChooser: $data, value: name, uniqueName: false" />
-                  </td>
-                  <td><a class="btn" href="#" data-bind="click: $root.removeFile">${ _('Delete') }</a></td>
-                </tr>
-              </tbody>
-            </table>
-            % if len(action_form['files'].errors):
-              <div class="alert alert-error">
-                ${ unicode(action_form['files'].errors) | n }
-              </div>
-            % endif
+      % endif
+      </fieldset>
 
-            <button class="btn" data-bind="click: addFile">${ _('Add File') }</button>
-        </div>
-    </div>
-    % endif
-
-    % if 'archives' in action_form.fields:
-    <div class="control-group">
-      <label class="control-label">${ _('Archives') }</label>
-      <div class="controls">
-        <table class="table-condensed designTable" data-bind="visible: archives().length > 0">
-          <tbody data-bind="foreach: archives">
-            <tr>
-              <td>
-                <input class="input span5 required pathChooserKo"
-                    data-bind="fileChooser: $data, value: name, uniqueName: false" />
-              </td>
-              <td><a class="btn" href="#" data-bind="click: $root.removeArchive">${ _('Delete') }</a></td>
-            </tr>
-          </tbody>
-        </table>
-        % if len(action_form['archives'].errors):
-          <div class="alert alert-error">
-            ${ unicode(action_form['archives'].errors) | n }
-          </div>
+      <div class="form-actions">
+        <a href="${ url('oozie:edit_workflow', workflow=workflow.id) }" class="btn">${ _('Cancel') }</a>
+        % if can_edit_action:
+          <button data-bind="click: submit" class="btn btn-primary">${ _('Save') }</button>
         % endif
-
-        <button class="btn" data-bind="click: addArchive">${ _('Add Archive') }</button>
-       </div>
-    </div>
-    % endif
-    </fieldset>
-
-    <div class="form-actions">
-      <a href="${ url('oozie:edit_workflow', workflow=workflow.id) }" class="btn">${ _('Cancel') }</a>
-      % if can_edit_action:
-        <button data-bind="click: submit" class="btn btn-primary">${ _('Save') }</button>
-      % endif
-    </div>
-  </form>
+      </div>
+    </form>
+  </div>
+  <div class="span1"></div>
 </div>
 
 
@@ -437,6 +447,10 @@ ${ layout.menubar(section='workflows') }
     }
 
     $(".propKey").each(addAutoComplete);
+
+    $("*[rel=popover]").popover({
+      placement: 'right'
+    });
   });
 </script>
 

+ 2 - 2
apps/oozie/src/oozie/templates/editor/gen/workflow-graph-editable.xml.mako

@@ -37,7 +37,7 @@
           ${ hidden }
         % endfor
         <div class="row-fluid">
-            % if node.can_edit():
+            % if node.is_editable():
             <div class="span10 action-link" data-edit="${ node.get_edit_link() }" title="${ _('Edit') }">
           % else:
             <div class="span10">
@@ -47,7 +47,7 @@
             <div class="span2"></div>
         </div>
         <div class="row-fluid">
-          % if node.can_edit():
+          % if node.is_editable():
             <div class="span10 action-link" data-edit="${ node.get_edit_link() }" title="${ _('Edit') }">
           % else:
             <div class="span10">

+ 2 - 2
apps/oozie/src/oozie/templates/editor/gen/workflow-graph-readonly.xml.mako

@@ -37,7 +37,7 @@ from django.utils.translation import ugettext as _
         ${ hidden }
       % endfor
       <div class="row-fluid">
-          % if node.can_edit():
+          % if node.is_editable():
           <div class="span10 action-link" data-edit="${ node.get_edit_link() }" title="${ _('View') }">
         % else:
           <div class="span10">
@@ -47,7 +47,7 @@ from django.utils.translation import ugettext as _
           <div class="span2"></div>
       </div>
       <div class="row-fluid">
-        % if node.can_edit():
+        % if node.is_editable():
           <div class="span10 action-link" data-edit="${ node.get_edit_link() }" title="${ _('View') }">
         % else:
           <div class="span10">

+ 3 - 2
apps/oozie/src/oozie/templates/editor/job_action_properties.mako

@@ -22,7 +22,8 @@
 
 
 <%def name="print_key_value(label, element, form, initial_parameters)">
-  <div class="control-group ko-${element}">
+  <div class="control-group ko-${element}" rel="popover"
+      data-original-title="${ label }" data-content="${ _('Set some variables of the job (e.g. market=US)') }">
     <label class="control-label">${ label }</label>
     <div class="controls">
       <table class="table-condensed designTable" data-bind="visible: ${ element }().length > 0">
@@ -41,7 +42,7 @@
           </tr>
         </tbody>
       </table>
-      % if len(form[element].errors):
+      % if form[element].errors:
         <div class="row">
           <div class="alert alert-error">
             ${ unicode(form[element].errors) | n }

+ 3 - 5
apps/oozie/src/oozie/templates/editor/list_coordinators.mako

@@ -17,8 +17,6 @@
 <%!
   from desktop.views import commonheader, commonfooter
   from django.utils.translation import ugettext as _
-
-  from oozie.views import can_access_job, can_edit_job
 %>
 
 <%namespace name="layout" file="../navigation-bar.mako" />
@@ -72,17 +70,17 @@ ${ layout.menubar(section='coordinators') }
         <tr class="action-row">
           <td class=".btn-large action-column" data-row-selector-exclude="true" style="background-color: white;">
             <input type="radio" name="action" data-row-selector-exclude="true"
-              % if can_edit_job(currentuser, coordinator):
+              % if coordinator.is_editable(currentuser):
                   data-delete-url="${ url('oozie:delete_coordinator', coordinator=coordinator.id) }"
               % endif
-              % if can_access_job(currentuser, coordinator):
+              % if coordinator.is_accessible(currentuser):
                   data-clone-url="${ url('oozie:clone_coordinator', coordinator=coordinator.id) }"
                   data-bundle-url="${ url('oozie:create_coordinator') }"
                   data-submit-url="${ url('oozie:submit_coordinator', coordinator=coordinator.id) }"
               % endif
               >
             </input>
-            % if can_access_job(currentuser, coordinator):
+            % if coordinator.is_accessible(currentuser):
               <a href="${ url('oozie:edit_coordinator', coordinator=coordinator.id) }" data-row-selector="true"/>
             % endif
           </td>

+ 4 - 6
apps/oozie/src/oozie/templates/editor/list_workflows.mako

@@ -17,8 +17,6 @@
 <%!
   from desktop.views import commonheader, commonfooter
   from django.utils.translation import ugettext as _
-
-  from oozie.views import can_access_job, can_edit_job
 %>
 
 <%namespace name="layout" file="../navigation-bar.mako" />
@@ -75,16 +73,16 @@ ${ layout.menubar(section='workflows') }
         <tr class="action-row">
           <td class=".btn-large action-column" data-row-selector-exclude="true" style="background-color: white;">
             <input type="radio" name="action" data-row-selector-exclude="true"
-              % if can_access_job(currentuser, workflow):
+              % if workflow.is_accessible(currentuser):
                   data-submit-url="${ url('oozie:submit_workflow', workflow=workflow.id) }"
-                  data-schedule-url="${ url('oozie:create_coordinator', workflow=workflow.id) }"
+                  data-schedule-url="${ url('oozie:schedule_workflow', workflow=workflow.id) }"
               % endif
-              % if can_edit_job(currentuser, workflow):
+              % if workflow.is_editable(currentuser):
                   data-delete-url="${ url('oozie:delete_workflow', workflow=workflow.id) }"
                   data-clone-url="${ url('oozie:clone_workflow', workflow=workflow.id) }"
               % endif
             />
-            % if can_access_job(currentuser, workflow):
+            % if workflow.is_accessible(currentuser):
               <a href="${ url('oozie:edit_workflow', workflow=workflow.id) }" data-row-selector="true"></a>
             % endif
           </td>

+ 1 - 1
apps/oozie/src/oozie/templates/navigation-bar.mako

@@ -27,7 +27,7 @@
       <ul class="nav nav-pills">
         <li class="${utils.is_selected(section, 'dashboard')}"><a href="${url('oozie:list_oozie_workflows')}">${ _('Dashboard') }</a></li>
         <li class="${utils.is_selected(section, 'workflows')}"><a href="${url('oozie:list_workflows')}">${ _('Workflows') }</a></li>
-        <li class="${utils.is_selected(section, 'coordinators')}"><a href="${url('oozie:list_coordinator')}">${ _('Coordinators') }</a></li>
+        <li class="${utils.is_selected(section, 'coordinators')}"><a href="${url('oozie:list_coordinators')}">${ _('Coordinators') }</a></li>
         <li class="${utils.is_selected(section, 'history')}"><a href="${url('oozie:list_history')}">${ _('History') }</a></li>
       </ul>
     </div>

+ 5 - 4
apps/oozie/src/oozie/templates/utils.inc.mako

@@ -138,15 +138,16 @@
 
 
 <%def name="render_field(field, show_label=True)">
-  %if not field.is_hidden:
-    <% group_class = len(field.errors) and "error" or "" %>
-    <div class="control-group ${group_class}">
+  % if not field.is_hidden:
+    <% group_class = field.errors and "error" or "" %>
+    <div class="control-group ${group_class}"
+      rel="popover" data-original-title="${ field.label }" data-content="${ field.help_text }">
       % if show_label:
         <label class="control-label">${ field.label | n }</label>
       % endif
       <div class="controls">
         ${ field }
-        % if len(field.errors):
+        % if field.errors:
           <span class="help-inline">${ unicode(field.errors) | n }</span>
         % endif
       </div>

+ 3 - 3
apps/oozie/src/oozie/tests.py

@@ -668,13 +668,13 @@ class TestEditor:
     # List
     finish = SHARE_JOBS.set_for_testing(True)
     try:
-      response = client_not_me.get(reverse('oozie:list_coordinator'))
+      response = client_not_me.get(reverse('oozie:list_coordinators'))
       assert_false('MyCoord' in response.content, response.content)
     finally:
       finish()
     finish = SHARE_JOBS.set_for_testing(False)
     try:
-      response = client_not_me.get(reverse('oozie:list_coordinator'))
+      response = client_not_me.get(reverse('oozie:list_coordinators'))
       assert_false('MyCoord' in response.content, response.content)
     finally:
       finish()
@@ -702,7 +702,7 @@ class TestEditor:
     # List
     finish = SHARE_JOBS.set_for_testing(True)
     try:
-      response = client_not_me.get(reverse('oozie:list_coordinator'))
+      response = client_not_me.get(reverse('oozie:list_coordinators'))
       assert_equal(200, response.status_code)
       assert_true('MyCoord' in response.content, response.content)
     finally:

+ 2 - 1
apps/oozie/src/oozie/urls.py

@@ -30,6 +30,7 @@ urlpatterns = patterns(
   url(r'^delete_workflow/(?P<workflow>\d+)$', 'delete_workflow', name='delete_workflow'),
   url(r'^clone_workflow/(?P<workflow>\d+)$', 'clone_workflow', name='clone_workflow'),
   url(r'^submit_workflow/(?P<workflow>\d+)$', 'submit_workflow', name='submit_workflow'),
+  url(r'^schedule_workflow/(?P<workflow>\d+)$', 'schedule_workflow', name='schedule_workflow'),
   url(r'^resubmit_workflow/(?P<oozie_wf_id>[-\w]+)$', 'resubmit_workflow', name='resubmit_workflow'),
 
   url(r'^new_action/(?P<workflow>\d+)/(?P<node_type>\w+)/(?P<parent_action_id>\d+)$', 'new_action', name='new_action'),
@@ -41,7 +42,7 @@ urlpatterns = patterns(
   url(r'^move_up_action/(?P<action>\d+)$', 'move_up_action', name='move_up_action'),
   url(r'^move_down_action/(?P<action>\d+)$', 'move_down_action', name='move_down_action'),
 
-  url(r'^list_coordinator/$', 'list_workflows', name='list_coordinator', kwargs={'job_type': 'coordinators'}),
+  url(r'^list_coordinators/(?P<workflow_id>[-\w]+)?$', 'list_coordinators', name='list_coordinators'),
   url(r'^create_coordinator/(?P<workflow>[-\w]+)?$', 'create_coordinator', name='create_coordinator'),
   url(r'^edit_coordinator/(?P<coordinator>[-\w]+)$', 'edit_coordinator', name='edit_coordinator'),
   url(r'^delete_coordinator/(?P<coordinator>\d+)$', 'delete_coordinator', name='delete_coordinator'),

+ 3 - 4
apps/oozie/src/oozie/views/dashboard.py

@@ -31,8 +31,7 @@ from desktop.log.access import access_warn
 from liboozie.oozie_api import get_oozie
 
 from oozie.conf import OOZIE_JOBS_COUNT
-from oozie.models import History
-from oozie.views.editor import can_access_job_or_exception
+from oozie.models import History, Job
 
 
 LOG = logging.getLogger(__name__)
@@ -113,8 +112,8 @@ def list_oozie_workflow(request, job_id, coordinator_job_id=None):
   hue_coord = history and history.get_coordinator() or History.get_coordinator_from_config(oozie_workflow.conf_dict)
   hue_workflow = (hue_coord and hue_coord.workflow) or (history and history.get_workflow()) or History.get_workflow_from_config(oozie_workflow.conf_dict)
 
-  if hue_coord: can_access_job_or_exception(request, hue_coord.workflow.id)
-  if hue_workflow: can_access_job_or_exception(request, hue_workflow.id)
+  if hue_coord: Job.objects.is_accessible_or_exception(request, hue_coord.workflow.id)
+  if hue_workflow: Job.objects.is_accessible_or_exception(request, hue_workflow.id)
 
   # Add parameters from coordinator to workflow if possible
   parameters = {}

+ 61 - 101
apps/oozie/src/oozie/views/editor.py

@@ -20,7 +20,6 @@ try:
 except ImportError:
   import simplejson as json
 import logging
-import re
 
 
 from django.core.urlresolvers import reverse
@@ -35,17 +34,15 @@ from django.utils.translation import ugettext as _
 
 from desktop.lib.django_util import render, PopupException, extract_field_data
 from desktop.lib.rest.http_client import RestException
-from desktop.log.access import access_warn
 from hadoop.fs.exceptions import WebHdfsException
-from jobsub.models import OozieDesign, OozieMapreduceAction, OozieStreamingAction,\
-  OozieJavaAction
+from jobsub.models import OozieDesign
 from liboozie.submittion import Submission
 
 from oozie.conf import SHARE_JOBS
 from oozie.import_jobsub import convert_jobsub_design
 from oozie.management.commands import oozie_setup
-from oozie.models import Workflow, Node, Link, History, Coordinator,\
-  Mapreduce, Java, Streaming, Dataset, DataInput, DataOutput, Job,\
+from oozie.models import Job, Workflow, Node, Link, History, Coordinator,\
+  Mapreduce, Java, Streaming, Dataset, DataInput, DataOutput,\
   _STD_PROPERTIES_JSON
 from oozie.forms import NodeForm, WorkflowForm, CoordinatorForm, DatasetForm,\
   DataInputForm, DataInputSetForm, DataOutputForm, DataOutputSetForm, LinkForm,\
@@ -55,37 +52,6 @@ from oozie.forms import NodeForm, WorkflowForm, CoordinatorForm, DatasetForm,\
 LOG = logging.getLogger(__name__)
 
 
-"""
-Permissions:
-
-A Workflow/Coordinator can be accessed/submitted by its owner, a superuser or by anyone if its 'is_shared'
-property and SHARE_JOBS are set to True.
-
-A Workflow/Coordinator can be modified only by its owner or a superuser.
-
-Permissions checking happens by adding the decorators.
-"""
-def can_access_job(user, job):
-  return user.is_superuser or job.owner == user or (SHARE_JOBS.get() and job.is_shared)
-
-
-def can_access_job_or_exception(request, job_id):
-  if job_id is None:
-    return
-  try:
-    job = Job.objects.select_related().get(pk=job_id).get_full_node()
-    if can_access_job(request.user, job):
-      return job
-    else:
-      message = _("Permission denied. %(username)s don't have the permissions to access job %(id)s") % \
-          {'username': request.user.username, 'id': job.id}
-      access_warn(request, message)
-      request.error(message)
-      raise PopupException(message)
-
-  except Job.DoesNotExist:
-    raise PopupException(_('job %(id)s not exist') % {'id': job_id})
-
 
 def check_job_access_permission(view_func):
   """
@@ -104,25 +70,13 @@ def check_job_access_permission(view_func):
 
     job = kwargs.get(job_type)
     if job is not None:
-      job = can_access_job_or_exception(request, job)
+      job = Job.objects.is_accessible_or_exception(request, job)
     kwargs[job_type] = job
 
     return view_func(request, *args, **kwargs)
   return wraps(view_func)(decorate)
 
 
-def can_edit_job(user, job):
-  """Only owners or admins can modify a job."""
-  return user.is_superuser or job.owner == user
-
-
-def can_edit_job_or_exception(request, job):
-  if can_edit_job(request.user, job):
-    return True
-  else:
-    raise PopupException('Not allowed to modified this job')
-
-
 def check_job_edition_permission(authorize_get=False):
   """
   Decorator ensuring that the user has the permissions to modify a workflow or coordinator.
@@ -138,7 +92,7 @@ def check_job_edition_permission(authorize_get=False):
 
       job = kwargs.get(job_type)
       if job is not None and not (authorize_get and request.method == 'GET'):
-        can_edit_job_or_exception(request, job)
+        Job.objects.can_edit_or_exception(request, job)
 
       return view_func(request, *args, **kwargs)
     return wraps(view_func)(decorate)
@@ -157,7 +111,7 @@ def check_action_access_permission(view_func):
   def decorate(request, *args, **kwargs):
     action_id = kwargs.get('action')
     action = Node.objects.get(id=action_id).get_full_node()
-    can_access_job_or_exception(request, action.workflow.id)
+    Job.objects.is_accessible_or_exception(request, action.workflow.id)
     kwargs['action'] = action
 
     return view_func(request, *args, **kwargs)
@@ -172,28 +126,29 @@ def check_action_edition_permission(view_func):
   """
   def decorate(request, *args, **kwargs):
     action = kwargs.get('action')
-    can_edit_job_or_exception(request, action.workflow)
+    Job.objects.can_edit_or_exception(request, action.workflow)
 
     return view_func(request, *args, **kwargs)
   return wraps(view_func)(decorate)
 
 
-def can_access_dataset_or_exception(request, dataset_id):
-  if dataset_id is None:
-    return
-  try:
-    dataset = Dataset.objects.get(pk=dataset_id)
-    if can_access_job(request.user, dataset.coordinator):
-      return dataset
-    else:
-      message = _("Permission denied. %(username)s don't have the permissions to access dataset %(id)s") % \
-          {'username': request.user.username, 'id': dataset.id}
-      access_warn(request, message)
-      request.error(message)
-      raise PopupException(message)
+def check_dataset_access_permission(view_func):
+  """
+  Decorator ensuring that the user has access to dataset.
 
-  except Dataset.DoesNotExist:
-    raise PopupException(_('dataset %(id)s not exist') % {'id': dataset_id})
+  Arg: 'dataset'.
+  Return: the dataset or raise an exception
+
+  Notice: its gets an id in input and returns the full object in output (not an id).
+  """
+  def decorate(request, *args, **kwargs):
+    dataset = kwargs.get('dataset')
+    if dataset is not None:
+      dataset = Dataset.objects.is_accessible_or_exception(request, dataset)
+    kwargs['dataset'] = dataset
+
+    return view_func(request, *args, **kwargs)
+  return wraps(view_func)(decorate)
 
 
 def check_dataset_edition_permission(authorize_get=False):
@@ -207,41 +162,37 @@ def check_dataset_edition_permission(authorize_get=False):
     def decorate(request, *args, **kwargs):
       dataset = kwargs.get('dataset')
       if dataset is not None and not (authorize_get and request.method == 'GET'):
-        can_edit_job_or_exception(request, dataset.coordinator)
+        Job.objects.can_edit_or_exception(request, dataset.coordinator)
 
       return view_func(request, *args, **kwargs)
     return wraps(view_func)(decorate)
   return inner
 
 
-def check_dataset_access_permission(view_func):
-  """
-  Decorator ensuring that the user has access to dataset.
+def list_workflows(request):
+  show_setup_app = True
+  data = Workflow.objects
 
-  Arg: 'dataset'.
-  Return: the dataset or raise an exception
+  if not SHARE_JOBS.get() and not request.user.is_superuser:
+    data = data.filter(owner=request.user)
+  else:
+    data = data.filter(Q(is_shared=True) | Q(owner=request.user))
 
-  Notice: its gets an id in input and returns the full object in output (not an id).
-  """
-  def decorate(request, *args, **kwargs):
-    dataset = kwargs.get('dataset')
-    if dataset is not None:
-      dataset = can_access_dataset_or_exception(request, dataset)
-    kwargs['dataset'] = dataset
+  data = data.order_by('-last_modified')
 
-    return view_func(request, *args, **kwargs)
-  return wraps(view_func)(decorate)
+  return render('editor/list_workflows.mako', request, {
+    'jobs': list(data),
+    'currentuser': request.user,
+    'show_setup_app': show_setup_app,
+  })
 
 
-def list_workflows(request, job_type='workflow'):
+def list_coordinators(request, workflow_id=None):
   show_setup_app = True
 
-  if job_type == 'coordinators':
-    data = Coordinator.objects
-    template = "editor/list_coordinators.mako"
-  else:
-    data = Workflow.objects
-    template = "editor/list_workflows.mako"
+  data = Coordinator.objects
+  if workflow_id is not None:
+    data = data.filter(workflow__id=workflow_id)
 
   if not SHARE_JOBS.get() and not request.user.is_superuser:
     data = data.filter(owner=request.user)
@@ -250,7 +201,7 @@ def list_workflows(request, job_type='workflow'):
 
   data = data.order_by('-last_modified')
 
-  return render(template, request, {
+  return render('editor/list_coordinators.mako', request, {
     'jobs': list(data),
     'currentuser': request.user,
     'show_setup_app': show_setup_app,
@@ -279,9 +230,9 @@ def create_workflow(request):
 @check_job_access_permission
 def edit_workflow(request, workflow):
   WorkflowFormSet = inlineformset_factory(Workflow, Node, form=NodeForm, max_num=0, can_order=False, can_delete=False)
-  history = History.objects.filter(submitter=request.user, job=workflow)
+  history = History.objects.filter(submitter=request.user, job=workflow).order_by('-submission_date')
 
-  if request.method == 'POST' and can_edit_job_or_exception(request, workflow):
+  if request.method == 'POST' and Job.objects.can_edit_or_exception(request, workflow):
     try:
       workflow_form = WorkflowForm(request.POST, instance=workflow)
       actions_formset = WorkflowFormSet(request.POST, request.FILES, instance=workflow)
@@ -306,7 +257,7 @@ def edit_workflow(request, workflow):
   actions_formset = WorkflowFormSet(instance=workflow)
 
   graph_options = {}
-  user_can_edit_job = can_edit_job(request.user, workflow)
+  user_can_edit_job = workflow.is_editable(request.user)
   if not user_can_edit_job:
     graph_options = {'template': 'editor/gen/workflow-graph-readonly.xml.mako'}
 
@@ -397,7 +348,7 @@ def resubmit_workflow(request, oozie_wf_id):
     raise PopupException(_('A POST request is required.'))
 
   history = History.objects.get(oozie_job_id=oozie_wf_id)
-  can_access_job_or_exception(request, history.job.id)
+  Job.objects.is_accessible_or_exception(request, history.job.id)
 
   workflow = history.get_workflow().get_full_node()
   properties = history.properties_dict
@@ -407,6 +358,15 @@ def resubmit_workflow(request, oozie_wf_id):
   return redirect(reverse('oozie:list_oozie_workflow', kwargs={'job_id': job_id}))
 
 
+@check_job_access_permission
+def schedule_workflow(request, workflow):
+  if Coordinator.objects.filter(workflow=workflow).exists():
+    request.info(_('You already have some coordinators for this workflow. Please submit one or create a new one.'))
+    return list_coordinators(request, workflow_id=workflow.id)
+  else:
+    return create_coordinator(request, workflow=workflow.id)
+
+
 @check_job_access_permission
 def new_action(request, workflow, node_type, parent_action_id):
   ActionForm = design_form_by_type(node_type)
@@ -447,7 +407,7 @@ def new_action(request, workflow, node_type, parent_action_id):
 def edit_action(request, action):
   ActionForm = design_form_by_type(action.node_type)
 
-  if request.method == 'POST' and can_edit_job_or_exception(request, action.workflow):
+  if request.method == 'POST' and Job.objects.can_edit_or_exception(request, action.workflow):
     action_form = ActionForm(request.POST, instance=action)
     if action_form.is_valid():
       action = action_form.save()
@@ -466,7 +426,7 @@ def edit_action(request, action):
     'node_type': action.node_type,
     'properties_hint': _STD_PROPERTIES_JSON,
     'form_url': reverse('oozie:edit_action', kwargs={'action': action.id}),
-    'can_edit_action': can_edit_job(request.user, action.workflow)
+    'can_edit_action': action.workflow.is_editable(request.user)
   })
 
 
@@ -616,13 +576,13 @@ def delete_coordinator(request, coordinator):
   Submission(request.user, coordinator, request.fs, {}).remove_deployment_dir()
   request.info(_('Coordinator deleted!'))
 
-  return redirect(reverse('oozie:list_coordinator'))
+  return redirect(reverse('oozie:list_coordinators'))
 
 
 @check_job_access_permission
 @check_job_edition_permission(True)
 def edit_coordinator(request, coordinator):
-  history = History.objects.filter(submitter=request.user, job=coordinator)
+  history = History.objects.filter(submitter=request.user, job=coordinator).order_by('-submission_date')
 
   DatasetFormSet = inlineformset_factory(Coordinator, Dataset, form=DatasetForm, max_num=0, can_order=False, can_delete=True)
   DataInputFormSet = inlineformset_factory(Coordinator, DataInput, form=DataInputSetForm, max_num=0, can_order=False, can_delete=True)
@@ -672,7 +632,7 @@ def edit_coordinator(request, coordinator):
     'new_data_input_formset': new_data_input_formset,
     'new_data_output_formset': new_data_output_formset,
     'history': history,
-    'can_edit_coordinator': can_edit_job(request.user, coordinator.workflow),
+    'can_edit_coordinator': coordinator.workflow.is_editable(request.user),
     'parameters': extract_field_data(coordinator_form['parameters'])
   })
 
@@ -829,7 +789,7 @@ def resubmit_coordinator(request, oozie_coord_id):
     raise PopupException(_('A POST request is required.'))
 
   history = History.objects.get(oozie_job_id=oozie_coord_id)
-  can_access_job_or_exception(request, history.job.id)
+  Job.objects.is_accessible_or_exception(request, history.job.id)
 
   coordinator = history.get_coordinator().get_full_node()
   properties = history.properties_dict