1752e0aeb121a1711e78a2741d308876564be353
[arvados.git] / apps / workbench / app / models / arvados_base.rb
1 # Copyright (C) The Arvados Authors. All rights reserved.
2 #
3 # SPDX-License-Identifier: AGPL-3.0
4
5 class ArvadosBase
6   include ActiveModel::Validations
7   include ActiveModel::Conversion
8   include ActiveModel::Serialization
9   include ActiveModel::Dirty
10   extend ActiveModel::Naming
11
12   Column = Struct.new("Column", :name)
13
14   attr_accessor :attribute_sortkey
15   attr_accessor :create_params
16
17   class Error < StandardError; end
18
19   module Type
20     class Hash < ActiveModel::Type::Value
21       def type
22         :hash
23       end
24
25       def default_value
26         {}
27       end
28
29       private
30       def cast_value(value)
31         (value.class == String) ? ::JSON.parse(value) : value
32       end
33     end
34
35     class Array < ActiveModel::Type::Value
36       def type
37         :array
38       end
39
40       def default_value
41         []
42       end
43
44       private
45       def cast_value(value)
46         (value.class == String) ? ::JSON.parse(value) : value
47       end
48     end
49   end
50
51   def self.arvados_api_client
52     ArvadosApiClient.new_or_current
53   end
54
55   def arvados_api_client
56     ArvadosApiClient.new_or_current
57   end
58
59   def self.uuid_infix_object_kind
60     @@uuid_infix_object_kind ||=
61       begin
62         infix_kind = {}
63         arvados_api_client.discovery[:schemas].each do |name, schema|
64           if schema[:uuidPrefix]
65             infix_kind[schema[:uuidPrefix]] =
66               'arvados#' + name.to_s.camelcase(:lower)
67           end
68         end
69
70         # Recognize obsolete types.
71         infix_kind.
72           merge('mxsvm' => 'arvados#pipelineTemplate', # Pipeline
73                 'uo14g' => 'arvados#pipelineInstance', # PipelineInvocation
74                 'ldvyl' => 'arvados#group') # Project
75       end
76   end
77
78   def initialize raw_params={}, create_params={}
79     self.class.permit_attribute_params(raw_params)
80     @create_params = create_params
81     @attribute_sortkey ||= {
82       'id' => nil,
83       'name' => '000',
84       'owner_uuid' => '002',
85       'event_type' => '100',
86       'link_class' => '100',
87       'group_class' => '100',
88       'tail_uuid' => '101',
89       'head_uuid' => '102',
90       'object_uuid' => '102',
91       'summary' => '104',
92       'description' => '104',
93       'properties' => '150',
94       'info' => '150',
95       'created_at' => '200',
96       'modified_at' => '201',
97       'modified_by_user_uuid' => '202',
98       'modified_by_client_uuid' => '203',
99       'uuid' => '999',
100     }
101     @loaded_attributes = {}
102     attributes = self.class.columns.map { |c| [c.name.to_sym, nil] }.to_h.merge(raw_params)
103     attributes.symbolize_keys.each do |name, value|
104       send("#{name}=", value)
105     end
106 end
107
108   def self.columns
109     return @discovered_columns if @discovered_columns.andand.any?
110     @discovered_columns = []
111     @attribute_info ||= {}
112     schema = arvados_api_client.discovery[:schemas][self.to_s.to_sym]
113     return @discovered_columns if schema.nil?
114     schema[:properties].each do |k, coldef|
115       case k
116       when :etag, :kind
117         attr_reader k
118       else
119         if coldef[:type] == coldef[:type].downcase
120           # boolean, integer, etc.
121           @discovered_columns << column(k, coldef[:type])
122         else
123           # Hash, Array
124           @discovered_columns << column(k, coldef[:type], coldef[:type].constantize.new)
125           # serialize k, coldef[:type].constantize
126         end
127         attr_reader k
128         @attribute_info[k] = coldef
129       end
130     end
131     @discovered_columns
132   end
133
134   def new_record?
135     (uuid == nil) ? true : false
136   end
137
138   def self.column(name, sql_type = nil, default = nil, null = true)
139     caster = case sql_type
140               when 'integer'
141                 ActiveModel::Type::Integer
142               when 'string', 'text'
143                 ActiveModel::Type::String
144               when 'float'
145                 ActiveModel::Type::Float
146               when 'datetime'
147                 ActiveModel::Type::DateTime
148               when 'boolean'
149                 ActiveModel::Type::Boolean
150               when 'Hash'
151                 ArvadosBase::Type::Hash
152               when 'Array'
153                 ArvadosBase::Type::Array
154               else
155                 raise ArvadosBase::Error.new("Type unknown: #{sql_type}")
156             end
157     define_method "#{name}=" do |val|
158       casted_value = caster.new.cast(val || default)
159       attribute_will_change!(name) if send(name) != casted_value
160       set_attribute_after_cast(name, casted_value)
161     end
162     Column.new(name.to_s)
163   end
164
165   def set_attribute_after_cast(name, casted_value)
166     instance_variable_set("@#{name}", casted_value)
167   end
168
169   def [](attr_name)
170     send(attr_name)
171   end
172
173   def []=(attr_name, attr_val)
174     send("#{attr_name}=", attr_val)
175   end
176
177   def self.attribute_info
178     self.columns
179     @attribute_info
180   end
181
182   def self.find(uuid, opts={})
183     if uuid.class != String or uuid.length < 27 then
184       raise 'argument to find() must be a uuid string. Acceptable formats: warehouse locator or string with format xxxxx-xxxxx-xxxxxxxxxxxxxxx'
185     end
186
187     if self == ArvadosBase
188       # Determine type from uuid and defer to the appropriate subclass.
189       return resource_class_for_uuid(uuid).find(uuid, opts)
190     end
191
192     # Only do one lookup on the API side per {class, uuid, workbench
193     # request} unless {cache: false} is given via opts.
194     cache_key = "request_#{Thread.current.object_id}_#{self.to_s}_#{uuid}"
195     if opts[:cache] == false
196       Rails.cache.write cache_key, arvados_api_client.api(self, '/' + uuid)
197     end
198     hash = Rails.cache.fetch cache_key do
199       arvados_api_client.api(self, '/' + uuid)
200     end
201     new.private_reload(hash)
202   end
203
204   def self.find?(*args)
205     find(*args) rescue nil
206   end
207
208   def self.order(*args)
209     ArvadosResourceList.new(self).order(*args)
210   end
211
212   def self.filter(*args)
213     ArvadosResourceList.new(self).filter(*args)
214   end
215
216   def self.where(*args)
217     ArvadosResourceList.new(self).where(*args)
218   end
219
220   def self.limit(*args)
221     ArvadosResourceList.new(self).limit(*args)
222   end
223
224   def self.select(*args)
225     ArvadosResourceList.new(self).select(*args)
226   end
227
228   def self.with_count(*args)
229     ArvadosResourceList.new(self).with_count(*args)
230   end
231
232   def self.distinct(*args)
233     ArvadosResourceList.new(self).distinct(*args)
234   end
235
236   def self.include_trash(*args)
237     ArvadosResourceList.new(self).include_trash(*args)
238   end
239
240   def self.recursive(*args)
241     ArvadosResourceList.new(self).recursive(*args)
242   end
243
244   def self.eager(*args)
245     ArvadosResourceList.new(self).eager(*args)
246   end
247
248   def self.all
249     ArvadosResourceList.new(self)
250   end
251
252   def self.permit_attribute_params raw_params
253     # strong_parameters does not provide security in Workbench: anyone
254     # who can get this far can just as well do a call directly to our
255     # database (Arvados) with the same credentials we use.
256     #
257     # The following permit! is necessary even with
258     # "ActionController::Parameters.permit_all_parameters = true",
259     # because permit_all does not permit nested attributes.
260     if raw_params.is_a? ActionController::Parameters
261       raw_params = raw_params.to_unsafe_h
262     end
263     ActionController::Parameters.new(raw_params).permit!
264   end
265
266   def self.create raw_params={}, create_params={}
267     x = super(permit_attribute_params(raw_params))
268     x.create_params = create_params
269     x
270   end
271
272   def update_attributes raw_params={}
273     super(self.class.permit_attribute_params(raw_params))
274   end
275
276   def save
277     obdata = {}
278     self.class.columns.each do |col|
279       # Non-nil serialized values must be sent because we can't tell
280       # whether they've changed. Other than that, any given attribute
281       # is either unchanged (in which case there's no need to send its
282       # old value in the update/create command) or has been added to
283       # #changed by ActiveRecord's #attr= method.
284       if changed.include? col.name or
285           ([Hash, Array].include?(attributes[col.name].class) and
286            @loaded_attributes[col.name])
287         obdata[col.name.to_sym] = self.send col.name
288       end
289     end
290     obdata.delete :id
291     postdata = { self.class.to_s.underscore => obdata }
292     if etag
293       postdata['_method'] = 'PUT'
294       obdata.delete :uuid
295       resp = arvados_api_client.api(self.class, '/' + uuid, postdata)
296     else
297       postdata.merge!(@create_params) if @create_params
298       resp = arvados_api_client.api(self.class, '', postdata)
299     end
300     return false if !resp[:etag] || !resp[:uuid]
301
302     # set read-only non-database attributes
303     @etag = resp[:etag]
304     @kind = resp[:kind]
305
306     # attributes can be modified during "save" -- we should update our copies
307     resp.keys.each do |attr|
308       if self.respond_to? "#{attr}=".to_sym
309         self.send(attr.to_s + '=', resp[attr.to_sym])
310       end
311     end
312
313     changes_applied
314     @new_record = false
315
316     self
317   end
318
319   def save!
320     self.save or raise Exception.new("Save failed")
321   end
322
323   def destroy
324     if etag || uuid
325       postdata = { '_method' => 'DELETE' }
326       resp = arvados_api_client.api(self.class, '/' + uuid, postdata)
327       resp[:etag] && resp[:uuid] && resp
328     else
329       true
330     end
331   end
332
333   def links(*args)
334     o = {}
335     o.merge!(args.pop) if args[-1].is_a? Hash
336     o[:link_class] ||= args.shift
337     o[:name] ||= args.shift
338     o[:tail_uuid] = self.uuid
339     if all_links
340       return all_links.select do |m|
341         ok = true
342         o.each do |k,v|
343           if !v.nil?
344             test_v = m.send(k)
345             if (v.respond_to?(:uuid) ? v.uuid : v.to_s) != (test_v.respond_to?(:uuid) ? test_v.uuid : test_v.to_s)
346               ok = false
347             end
348           end
349         end
350         ok
351       end
352     end
353     @links = arvados_api_client.api Link, '', { _method: 'GET', where: o, eager: true }
354     @links = arvados_api_client.unpack_api_response(@links)
355   end
356
357   def all_links
358     return @all_links if @all_links
359     res = arvados_api_client.api Link, '', {
360       _method: 'GET',
361       where: {
362         tail_kind: self.kind,
363         tail_uuid: self.uuid
364       },
365       eager: true
366     }
367     @all_links = arvados_api_client.unpack_api_response(res)
368   end
369
370   def reload
371     private_reload(self.uuid)
372   end
373
374   def private_reload(uuid_or_hash)
375     raise "No such object" if !uuid_or_hash
376     if uuid_or_hash.is_a? Hash
377       hash = uuid_or_hash
378     else
379       hash = arvados_api_client.api(self.class, '/' + uuid_or_hash)
380     end
381     hash.each do |k,v|
382       @loaded_attributes[k.to_s] = true
383       if self.respond_to?(k.to_s + '=')
384         self.send(k.to_s + '=', v)
385       else
386         # When ArvadosApiClient#schema starts telling us what to expect
387         # in API responses (not just the server side database
388         # columns), this sort of awfulness can be avoided:
389         self.instance_variable_set('@' + k.to_s, v)
390         if !self.respond_to? k
391           singleton = class << self; self end
392           singleton.send :define_method, k, lambda { instance_variable_get('@' + k.to_s) }
393         end
394       end
395     end
396     @all_links = nil
397     changes_applied
398     @new_record = false
399     self
400   end
401
402   def to_param
403     uuid
404   end
405
406   def initialize_copy orig
407     super
408     forget_uuid!
409   end
410
411   def attributes
412     kv = self.class.columns.collect {|c| c.name}.map {|key| [key, send(key)]}
413     kv.to_h
414   end
415
416   def attributes_for_display
417     self.attributes.reject { |k,v|
418       attribute_sortkey.has_key?(k) and !attribute_sortkey[k]
419     }.sort_by { |k,v|
420       attribute_sortkey[k] or k
421     }
422   end
423
424   def class_for_display
425     self.class.to_s.underscore.humanize
426   end
427
428   def self.class_for_display
429     self.to_s.underscore.humanize
430   end
431
432   # Array of strings that are names of attributes that should be rendered as textile.
433   def textile_attributes
434     []
435   end
436
437   def self.creatable?
438     current_user.andand.is_active && api_exists?(:create)
439   end
440
441   def self.goes_in_projects?
442     false
443   end
444
445   # can this class of object be copied into a project?
446   # override to false on indivudal model classes for which this should not be true
447   def self.copies_to_projects?
448     self.goes_in_projects?
449   end
450
451   def editable?
452     (current_user and current_user.is_active and
453      (current_user.is_admin or
454       current_user.uuid == self.owner_uuid or
455       new_record? or
456       (respond_to?(:writable_by) ?
457        writable_by.include?(current_user.uuid) :
458        (ArvadosBase.find(owner_uuid).writable_by.include? current_user.uuid rescue false)))) or false
459   end
460
461   def deletable?
462     editable?
463   end
464
465   def self.api_exists?(method)
466     arvados_api_client.discovery[:resources][self.to_s.underscore.pluralize.to_sym].andand[:methods].andand[method]
467   end
468
469   # Array of strings that are the names of attributes that can be edited
470   # with X-Editable.
471   def editable_attributes
472     self.class.columns.map(&:name) -
473       %w(created_at modified_at modified_by_user_uuid modified_by_client_uuid updated_at)
474   end
475
476   def attribute_editable?(attr, ever=nil)
477     if not editable_attributes.include?(attr.to_s)
478       false
479     elsif not (current_user.andand.is_active)
480       false
481     elsif attr == 'uuid'
482       current_user.is_admin
483     elsif ever
484       true
485     else
486       editable?
487     end
488   end
489
490   def self.resource_class_for_uuid(uuid, opts={})
491     if uuid.is_a? ArvadosBase
492       return uuid.class
493     end
494     unless uuid.is_a? String
495       return nil
496     end
497     if opts[:class].is_a? Class
498       return opts[:class]
499     end
500     if uuid.match /^[0-9a-f]{32}(\+[^,]+)*(,[0-9a-f]{32}(\+[^,]+)*)*$/
501       return Collection
502     end
503     resource_class = nil
504     uuid.match /^[0-9a-z]{5}-([0-9a-z]{5})-[0-9a-z]{15}$/ do |re|
505       resource_class ||= arvados_api_client.
506         kind_class(self.uuid_infix_object_kind[re[1]])
507     end
508     if opts[:referring_object] and
509         opts[:referring_attr] and
510         opts[:referring_attr].match /_uuid$/
511       resource_class ||= arvados_api_client.
512         kind_class(opts[:referring_object].
513                    attributes[opts[:referring_attr].
514                               sub(/_uuid$/, '_kind')])
515     end
516     resource_class
517   end
518
519   def resource_param_name
520     self.class.to_s.underscore
521   end
522
523   def friendly_link_name lookup=nil
524     (name if self.respond_to? :name) || default_name
525   end
526
527   def content_summary
528     self.class_for_display
529   end
530
531   def selection_label
532     friendly_link_name
533   end
534
535   def self.default_name
536     self.to_s.underscore.humanize
537   end
538
539   def controller
540     (self.class.to_s.pluralize + 'Controller').constantize
541   end
542
543   def controller_name
544     self.class.to_s.tableize
545   end
546
547   # Placeholder for name when name is missing or empty
548   def default_name
549     if self.respond_to? :name
550       "New #{class_for_display.downcase}"
551     else
552       uuid
553     end
554   end
555
556   def owner
557     ArvadosBase.find(owner_uuid) rescue nil
558   end
559
560   protected
561
562   def forget_uuid!
563     self.uuid = nil
564     @etag = nil
565     self
566   end
567
568   def self.current_user
569     Thread.current[:user] ||= User.current if Thread.current[:arvados_api_token]
570     Thread.current[:user]
571   end
572   def current_user
573     self.class.current_user
574   end
575 end