Merge branch '15361-acr-create-workflow' refs #15361
[arvados.git] / apps / workbench / app / models / arvados_base.rb
index 45a4d8b910941cbdff217c9e94df356844a9690b..b9162c2aec364bd03a34171c9981262304bc9d06 100644 (file)
@@ -1,12 +1,67 @@
-class ArvadosBase < ActiveRecord::Base
-  self.abstract_class = true
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
+class ArvadosBase
+  include ActiveModel::Validations
+  include ActiveModel::Conversion
+  include ActiveModel::Serialization
+  include ActiveModel::Dirty
+  include ActiveModel::AttributeAssignment
+  extend ActiveModel::Naming
+
+  Column = Struct.new("Column", :name)
+
   attr_accessor :attribute_sortkey
+  attr_accessor :create_params
+
+  class Error < StandardError; end
+
+  module Type
+    class Hash < ActiveModel::Type::Value
+      def type
+        :hash
+      end
+
+      def default_value
+        {}
+      end
+
+      private
+      def cast_value(value)
+        (value.class == String) ? ::JSON.parse(value) : value
+      end
+    end
+
+    class Array < ActiveModel::Type::Value
+      def type
+        :array
+      end
+
+      def default_value
+        []
+      end
+
+      private
+      def cast_value(value)
+        (value.class == String) ? ::JSON.parse(value) : value
+      end
+    end
+  end
+
+  def self.arvados_api_client
+    ArvadosApiClient.new_or_current
+  end
+
+  def arvados_api_client
+    ArvadosApiClient.new_or_current
+  end
 
   def self.uuid_infix_object_kind
     @@uuid_infix_object_kind ||=
       begin
         infix_kind = {}
-        $arvados_api_client.discovery[:schemas].each do |name, schema|
+        arvados_api_client.discovery[:schemas].each do |name, schema|
           if schema[:uuidPrefix]
             infix_kind[schema[:uuidPrefix]] =
               'arvados#' + name.to_s.camelcase(:lower)
@@ -21,30 +76,42 @@ class ArvadosBase < ActiveRecord::Base
       end
   end
 
-  def initialize(*args)
-    super(*args)
+  def initialize raw_params={}, create_params={}
+    self.class.permit_attribute_params(raw_params)
+    @create_params = create_params
     @attribute_sortkey ||= {
       'id' => nil,
-      'uuid' => '000',
-      'owner_uuid' => '001',
-      'created_at' => '002',
-      'modified_at' => '003',
-      'modified_by_user_uuid' => '004',
-      'modified_by_client_uuid' => '005',
-      'name' => '050',
-      'tail_uuid' => '100',
-      'head_uuid' => '101',
-      'info' => 'zzz-000',
-      'updated_at' => 'zzz-999'
+      'name' => '000',
+      'owner_uuid' => '002',
+      'event_type' => '100',
+      'link_class' => '100',
+      'group_class' => '100',
+      'tail_uuid' => '101',
+      'head_uuid' => '102',
+      'object_uuid' => '102',
+      'summary' => '104',
+      'description' => '104',
+      'properties' => '150',
+      'info' => '150',
+      'created_at' => '200',
+      'modified_at' => '201',
+      'modified_by_user_uuid' => '202',
+      'modified_by_client_uuid' => '203',
+      'uuid' => '999',
     }
+    @loaded_attributes = {}
+    attributes = self.class.columns.map { |c| [c.name.to_sym, nil] }.to_h.merge(raw_params)
+    attributes.symbolize_keys.each do |name, value|
+      send("#{name}=", value)
+    end
   end
 
   def self.columns
-    return @columns unless @columns.nil?
-    @columns = []
+    @discovered_columns = [] if !defined?(@discovered_columns)
+    return @discovered_columns if @discovered_columns.andand.any?
     @attribute_info ||= {}
-    schema = $arvados_api_client.discovery[:schemas][self.to_s.to_sym]
-    return @columns if schema.nil?
+    schema = arvados_api_client.discovery[:schemas][self.to_s.to_sym]
+    return @discovered_columns if schema.nil?
     schema[:properties].each do |k, coldef|
       case k
       when :etag, :kind
@@ -52,21 +119,74 @@ class ArvadosBase < ActiveRecord::Base
       else
         if coldef[:type] == coldef[:type].downcase
           # boolean, integer, etc.
-          @columns << column(k, coldef[:type].to_sym)
+          @discovered_columns << column(k, coldef[:type])
         else
           # Hash, Array
-          @columns << column(k, :text)
-          serialize k, coldef[:type].constantize
+          @discovered_columns << column(k, coldef[:type], coldef[:type].constantize.new)
         end
-        attr_accessible k
+        attr_reader k
         @attribute_info[k] = coldef
       end
     end
-    @columns
+    @discovered_columns
+  end
+
+  def new_record?
+    # dup method doesn't reset the uuid attr
+    @uuid.nil? || @new_record || false
+  end
+
+  def initialize_dup(other)
+    super
+    @new_record = true
+    @created_at = nil
   end
 
   def self.column(name, sql_type = nil, default = nil, null = true)
-    ActiveRecord::ConnectionAdapters::Column.new(name.to_s, default, sql_type.to_s, null)
+    caster = case sql_type
+              when 'integer'
+                ActiveModel::Type::Integer
+              when 'string', 'text'
+                ActiveModel::Type::String
+              when 'float'
+                ActiveModel::Type::Float
+              when 'datetime'
+                ActiveModel::Type::DateTime
+              when 'boolean'
+                ActiveModel::Type::Boolean
+              when 'Hash'
+                ArvadosBase::Type::Hash
+              when 'Array'
+                ArvadosBase::Type::Array
+              when 'jsonb'
+                ArvadosBase::Type::Hash
+              else
+                raise ArvadosBase::Error.new("Type unknown: #{sql_type}")
+            end
+    define_method "#{name}=" do |val|
+      val = default if val.nil?
+      casted_value = caster.new.cast(val)
+      attribute_will_change!(name) if send(name) != casted_value
+      set_attribute_after_cast(name, casted_value)
+    end
+    Column.new(name.to_s)
+  end
+
+  def set_attribute_after_cast(name, casted_value)
+    instance_variable_set("@#{name}", casted_value)
+  end
+
+  def [](attr_name)
+    begin
+      send(attr_name)
+    rescue
+      Rails.logger.debug "BUG: access non-loaded attribute #{attr_name}"
+      nil
+    end
+  end
+
+  def []=(attr_name, attr_val)
+    send("#{attr_name}=", attr_val)
   end
 
   def self.attribute_info
@@ -79,18 +199,27 @@ class ArvadosBase < ActiveRecord::Base
       raise 'argument to find() must be a uuid string. Acceptable formats: warehouse locator or string with format xxxxx-xxxxx-xxxxxxxxxxxxxxx'
     end
 
+    if self == ArvadosBase
+      # Determine type from uuid and defer to the appropriate subclass.
+      return resource_class_for_uuid(uuid).find(uuid, opts)
+    end
+
     # Only do one lookup on the API side per {class, uuid, workbench
     # request} unless {cache: false} is given via opts.
     cache_key = "request_#{Thread.current.object_id}_#{self.to_s}_#{uuid}"
     if opts[:cache] == false
-      Rails.cache.write cache_key, $arvados_api_client.api(self, '/' + uuid)
+      Rails.cache.write cache_key, arvados_api_client.api(self, '/' + uuid)
     end
     hash = Rails.cache.fetch cache_key do
-      $arvados_api_client.api(self, '/' + uuid)
+      arvados_api_client.api(self, '/' + uuid)
     end
     new.private_reload(hash)
   end
 
+  def self.find?(*args)
+    find(*args) rescue nil
+  end
+
   def self.order(*args)
     ArvadosResourceList.new(self).order(*args)
   end
@@ -107,27 +236,100 @@ class ArvadosBase < ActiveRecord::Base
     ArvadosResourceList.new(self).limit(*args)
   end
 
+  def self.select(*args)
+    ArvadosResourceList.new(self).select(*args)
+  end
+
+  def self.with_count(*args)
+    ArvadosResourceList.new(self).with_count(*args)
+  end
+
+  def self.distinct(*args)
+    ArvadosResourceList.new(self).distinct(*args)
+  end
+
+  def self.include_trash(*args)
+    ArvadosResourceList.new(self).include_trash(*args)
+  end
+
+  def self.recursive(*args)
+    ArvadosResourceList.new(self).recursive(*args)
+  end
+
   def self.eager(*args)
     ArvadosResourceList.new(self).eager(*args)
   end
 
-  def self.all(*args)
-    ArvadosResourceList.new(self).all(*args)
+  def self.all
+    ArvadosResourceList.new(self)
+  end
+
+  def self.permit_attribute_params raw_params
+    # strong_parameters does not provide security in Workbench: anyone
+    # who can get this far can just as well do a call directly to our
+    # database (Arvados) with the same credentials we use.
+    #
+    # The following permit! is necessary even with
+    # "ActionController::Parameters.permit_all_parameters = true",
+    # because permit_all does not permit nested attributes.
+    if !raw_params.is_a? ActionController::Parameters
+      raw_params = ActionController::Parameters.new(raw_params)
+    end
+    raw_params.permit!
+  end
+
+  def self.create raw_params={}, create_params={}
+    x = new(permit_attribute_params(raw_params), create_params)
+    x.save
+    x
+  end
+
+  def self.create! raw_params={}, create_params={}
+    x = new(permit_attribute_params(raw_params), create_params)
+    x.save!
+    x
+  end
+
+  def self.table_name
+    self.name.underscore.pluralize.downcase
+  end
+
+  def update_attributes raw_params={}
+    assign_attributes(self.class.permit_attribute_params(raw_params))
+    save
+  end
+
+  def update_attributes! raw_params={}
+    assign_attributes(self.class.permit_attribute_params(raw_params))
+    save!
   end
 
   def save
     obdata = {}
     self.class.columns.each do |col|
-      obdata[col.name.to_sym] = self.send(col.name.to_sym)
+      # Non-nil serialized values must be sent because we can't tell
+      # whether they've changed. Other than that, any given attribute
+      # is either unchanged (in which case there's no need to send its
+      # old value in the update/create command) or has been added to
+      # #changed by ActiveRecord's #attr= method.
+      if changed.include? col.name or
+          ([Hash, Array].include?(attributes[col.name].class) and
+           @loaded_attributes[col.name])
+        obdata[col.name.to_sym] = self.send col.name
+      end
     end
     obdata.delete :id
     postdata = { self.class.to_s.underscore => obdata }
     if etag
       postdata['_method'] = 'PUT'
       obdata.delete :uuid
-      resp = $arvados_api_client.api(self.class, '/' + uuid, postdata)
+      resp = arvados_api_client.api(self.class, '/' + uuid, postdata)
     else
-      resp = $arvados_api_client.api(self.class, '', postdata)
+      if @create_params
+        @create_params = @create_params.to_unsafe_hash if @create_params.is_a? ActionController::Parameters
+        postdata.merge!(@create_params)
+      end
+      resp = arvados_api_client.api(self.class, '', postdata)
     end
     return false if !resp[:etag] || !resp[:uuid]
 
@@ -142,6 +344,7 @@ class ArvadosBase < ActiveRecord::Base
       end
     end
 
+    changes_applied
     @new_record = false
 
     self
@@ -151,10 +354,18 @@ class ArvadosBase < ActiveRecord::Base
     self.save or raise Exception.new("Save failed")
   end
 
+  def persisted?
+    (!new_record? && !destroyed?) ? true : false
+  end
+
+  def destroyed?
+    !(new_record? || etag || uuid)
+  end
+
   def destroy
     if etag || uuid
       postdata = { '_method' => 'DELETE' }
-      resp = $arvados_api_client.api(self.class, '/' + uuid, postdata)
+      resp = arvados_api_client.api(self.class, '/' + uuid, postdata)
       resp[:etag] && resp[:uuid] && resp
     else
       true
@@ -181,13 +392,13 @@ class ArvadosBase < ActiveRecord::Base
         ok
       end
     end
-    @links = $arvados_api_client.api Link, '', { _method: 'GET', where: o, eager: true }
-    @links = $arvados_api_client.unpack_api_response(@links)
+    @links = arvados_api_client.api Link, '', { _method: 'GET', where: o, eager: true }
+    @links = arvados_api_client.unpack_api_response(@links)
   end
 
   def all_links
     return @all_links if @all_links
-    res = $arvados_api_client.api Link, '', {
+    res = arvados_api_client.api Link, '', {
       _method: 'GET',
       where: {
         tail_kind: self.kind,
@@ -195,7 +406,7 @@ class ArvadosBase < ActiveRecord::Base
       },
       eager: true
     }
-    @all_links = $arvados_api_client.unpack_api_response(res)
+    @all_links = arvados_api_client.unpack_api_response(res)
   end
 
   def reload
@@ -207,9 +418,10 @@ class ArvadosBase < ActiveRecord::Base
     if uuid_or_hash.is_a? Hash
       hash = uuid_or_hash
     else
-      hash = $arvados_api_client.api(self.class, '/' + uuid_or_hash)
+      hash = arvados_api_client.api(self.class, '/' + uuid_or_hash)
     end
     hash.each do |k,v|
+      @loaded_attributes[k.to_s] = true
       if self.respond_to?(k.to_s + '=')
         self.send(k.to_s + '=', v)
       else
@@ -224,6 +436,7 @@ class ArvadosBase < ActiveRecord::Base
       end
     end
     @all_links = nil
+    changes_applied
     @new_record = false
     self
   end
@@ -232,8 +445,14 @@ class ArvadosBase < ActiveRecord::Base
     uuid
   end
 
-  def dup
-    super.forget_uuid!
+  def initialize_copy orig
+    super
+    forget_uuid!
+  end
+
+  def attributes
+    kv = self.class.columns.collect {|c| c.name}.map {|key| [key, send(key)]}
+    kv.to_h
   end
 
   def attributes_for_display
@@ -245,28 +464,68 @@ class ArvadosBase < ActiveRecord::Base
   end
 
   def class_for_display
-    self.class.to_s
+    self.class.to_s.underscore.humanize
+  end
+
+  def self.class_for_display
+    self.to_s.underscore.humanize
+  end
+
+  # Array of strings that are names of attributes that should be rendered as textile.
+  def textile_attributes
+    []
   end
 
   def self.creatable?
-    current_user
+    current_user.andand.is_active && api_exists?(:create)
+  end
+
+  def self.goes_in_projects?
+    false
+  end
+
+  # can this class of object be copied into a project?
+  # override to false on indivudal model classes for which this should not be true
+  def self.copies_to_projects?
+    self.goes_in_projects?
   end
 
   def editable?
     (current_user and current_user.is_active and
      (current_user.is_admin or
-      current_user.uuid == self.owner_uuid))
+      current_user.uuid == self.owner_uuid or
+      new_record? or
+      (respond_to?(:writable_by) ?
+       writable_by.include?(current_user.uuid) :
+       (ArvadosBase.find(owner_uuid).writable_by.include? current_user.uuid rescue false)))) or false
   end
 
-  def attribute_editable?(attr)
-    if "created_at modified_at modified_by_user_uuid modified_by_client_uuid updated_at".index(attr.to_s)
+  def deletable?
+    editable?
+  end
+
+  def self.api_exists?(method)
+    arvados_api_client.discovery[:resources][self.to_s.underscore.pluralize.to_sym].andand[:methods].andand[method]
+  end
+
+  # Array of strings that are the names of attributes that can be edited
+  # with X-Editable.
+  def editable_attributes
+    self.class.columns.map(&:name) -
+      %w(created_at modified_at modified_by_user_uuid modified_by_client_uuid updated_at)
+  end
+
+  def attribute_editable?(attr, ever=nil)
+    if not editable_attributes.include?(attr.to_s)
       false
     elsif not (current_user.andand.is_active)
       false
-    elsif "uuid owner_uuid".index(attr.to_s) or current_user.is_admin
+    elsif attr == 'uuid'
       current_user.is_admin
+    elsif ever
+      true
     else
-      current_user.uuid == self.owner_uuid or current_user.uuid == self.uuid
+      editable?
     end
   end
 
@@ -280,18 +539,18 @@ class ArvadosBase < ActiveRecord::Base
     if opts[:class].is_a? Class
       return opts[:class]
     end
-    if uuid.match /^[0-9a-f]{32}(\+[^,]+)*(,[0-9a-f]{32}(\+[^,]+)*)*$/
+    if uuid.match(/^[0-9a-f]{32}(\+[^,]+)*(,[0-9a-f]{32}(\+[^,]+)*)*$/)
       return Collection
     end
     resource_class = nil
-    uuid.match /^[0-9a-z]{5}-([0-9a-z]{5})-[0-9a-z]{15}$/ do |re|
-      resource_class ||= $arvados_api_client.
+    uuid.match(/^[0-9a-z]{5}-([0-9a-z]{5})-[0-9a-z]{15}$/) do |re|
+      resource_class ||= arvados_api_client.
         kind_class(self.uuid_infix_object_kind[re[1]])
     end
     if opts[:referring_object] and
         opts[:referring_attr] and
-        opts[:referring_attr].match /_uuid$/
-      resource_class ||= $arvados_api_client.
+        opts[:referring_attr].match(/_uuid$/)
+      resource_class ||= arvados_api_client.
         kind_class(opts[:referring_object].
                    attributes[opts[:referring_attr].
                               sub(/_uuid$/, '_kind')])
@@ -299,18 +558,47 @@ class ArvadosBase < ActiveRecord::Base
     resource_class
   end
 
-  def friendly_link_name
-    (name if self.respond_to? :name) || uuid
+  def resource_param_name
+    self.class.to_s.underscore
+  end
+
+  def friendly_link_name lookup=nil
+    (name if self.respond_to? :name) || default_name
   end
 
   def content_summary
-    self.class.to_s
+    self.class_for_display
   end
 
   def selection_label
     friendly_link_name
   end
 
+  def self.default_name
+    self.to_s.underscore.humanize
+  end
+
+  def controller
+    (self.class.to_s.pluralize + 'Controller').constantize
+  end
+
+  def controller_name
+    self.class.to_s.tableize
+  end
+
+  # Placeholder for name when name is missing or empty
+  def default_name
+    if self.respond_to? :name
+      "New #{class_for_display.downcase}"
+    else
+      uuid
+    end
+  end
+
+  def owner
+    ArvadosBase.find(owner_uuid) rescue nil
+  end
+
   protected
 
   def forget_uuid!