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