card-mod-history 0.11.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,70 @@
1
+ # -*- encoding : utf-8 -*-
2
+ require 'activerecord-import'
3
+
4
+ class Card
5
+ # A _change_ is an alteration to a card's name, type, content, or trash state.
6
+ # Together, {Act acts}, {Action actions}, and {Change changes} comprise a
7
+ # comprehensive {Card card} history tracking system.
8
+ #
9
+ # For example, if a given web submission changes both the name and type of
10
+ # card, that would be recorded as one {Action action} with two
11
+ # {Change changes}.
12
+ #
13
+ # A {Change} records:
14
+ #
15
+ # * the _field_ changed
16
+ # * the new _value_ of that field
17
+ # * the {Action action} of which the change is part
18
+ #
19
+ class Change < ApplicationRecord
20
+ belongs_to :action, foreign_key: :card_action_id,
21
+ inverse_of: :card_changes
22
+
23
+ # lists the database fields for which changes are recorded
24
+ TRACKED_FIELDS = %w[name type_id db_content trash left_id right_id].freeze
25
+
26
+ class << self
27
+ # delete all {Change changes} not associated with an {Action action}
28
+ # (janitorial)
29
+ def delete_actionless
30
+ joins(
31
+ "LEFT JOIN card_actions "\
32
+ "ON card_changes.card_action_id = card_actions.id "
33
+ ).where(
34
+ "card_actions.id is null"
35
+ ).pluck_in_batches(:id) do |group_ids|
36
+ # used to be .delete_all here, but that was failing on large dbs
37
+ Rails.logger.info "deleting batch of changes"
38
+ where("id in (#{group_ids.join ','})").delete_all
39
+ end
40
+ end
41
+
42
+ # Change fields are recorded as integers. #field_index looks up the
43
+ # integer associated with a given field name.
44
+ # @param value [String, Symbol]
45
+ # @return [Integer]
46
+ def field_index value
47
+ value.is_a?(Integer) ? value : TRACKED_FIELDS.index(value.to_s)
48
+ end
49
+
50
+ # look up changes based on field name
51
+ # @param value [String, Symbol]
52
+ # @return [Change]
53
+ def find_by_field_name value
54
+ find_by_field field_index(value)
55
+ end
56
+ end
57
+
58
+ # set field value (integer)
59
+ # @param value [String, Symbol]
60
+ def field= value
61
+ write_attribute(:field, TRACKED_FIELDS.index(value.to_s))
62
+ end
63
+
64
+ # retrieve field name
65
+ # @return [String]
66
+ def field
67
+ TRACKED_FIELDS[read_attribute(:field)]
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,109 @@
1
+ event :update_ancestor_timestamps, :integrate do
2
+ ids = history_ancestor_ids
3
+ return unless ids.present?
4
+ Card.where(id: ids).update_all(updater_id: Auth.current_id, updated_at: Time.now)
5
+ ids.map { |anc_id| Card.expire anc_id.cardname }
6
+ end
7
+
8
+ # track history (acts, actions, changes) on this card
9
+ def history?
10
+ true
11
+ end
12
+
13
+ # all cards whose acts are considered part of this card's history
14
+ def history_card_ids
15
+ nestee_ids << id
16
+ end
17
+
18
+ # all cards who are considered updated if this card's was updated
19
+ def history_parent_ids
20
+ nester_ids
21
+ end
22
+
23
+ def history_ancestor_ids recursion_level=0
24
+ return [] if recursion_level > 5
25
+
26
+ ids = history_parent_ids +
27
+ history_parent_ids.map { |id| Card[id].history_ancestor_ids(recursion_level + 1) }
28
+ ids.flatten
29
+ end
30
+
31
+ # ~~FIXME~~: optimize (no need to instantiate all actions and changes!)
32
+ # Nothing is instantiated here. ActiveRecord is much smarter than you think.
33
+ # Methods like #empty? and #size make sql queries if their receivers are not already
34
+ # loaded -pk
35
+ def first_change?
36
+ # = update or delete
37
+ @current_action.action_type != :create && action_count == 2 &&
38
+ create_action.card_changes.empty?
39
+ end
40
+
41
+ def first_create?
42
+ @current_action.action_type == :create && action_count == 1
43
+ end
44
+
45
+ def action_count
46
+ Card::Action.where(card_id: @current_action.card_id).count
47
+ end
48
+
49
+ # card has account that is responsible for prior acts
50
+ def has_edits?
51
+ Card::Act.where(actor_id: id).where("card_id IS NOT NULL").present?
52
+ end
53
+
54
+ def changed_fields
55
+ Card::Change::TRACKED_FIELDS & (changed_attribute_names_to_save | saved_changes.keys)
56
+ end
57
+
58
+ def nestee_ids
59
+ requiring_id { @nestee_ids ||= nesting_ids(:referee_id, :referer_id) }
60
+ end
61
+
62
+ def nester_ids
63
+ requiring_id { @nester_ids ||= nesting_ids(:referer_id, :referee_id) }
64
+ end
65
+
66
+ def diff_args
67
+ { diff_format: :text }
68
+ end
69
+
70
+ # Delete all changes and old actions and make the last action the create action
71
+ # (that way the changes for that action will be created with the first update)
72
+ def make_last_action_the_initial_action
73
+ delete_all_changes
74
+ old_actions.delete_all
75
+ last_action.update! action_type: :create
76
+ end
77
+
78
+ def clear_history
79
+ delete_all_changes
80
+ delete_old_actions
81
+ end
82
+
83
+ def delete_old_actions
84
+ old_actions.delete_all
85
+ end
86
+
87
+ def delete_all_changes
88
+ Card::Change.where(card_action_id: all_action_ids).delete_all
89
+ end
90
+
91
+ def save_content_draft content
92
+ super
93
+ acts.create do |act|
94
+ act.ar_actions.build(draft: true, card_id: id, action_type: :update)
95
+ .card_changes.build(field: :db_content, value: content)
96
+ end
97
+ end
98
+
99
+ private
100
+
101
+ def nesting_ids return_field, where_field
102
+ Card::Reference.select(return_field).distinct.where(
103
+ ref_type: "I", where_field => id
104
+ ).pluck(return_field).compact
105
+ end
106
+
107
+ def requiring_id
108
+ id ? yield : (return [])
109
+ end
@@ -0,0 +1,124 @@
1
+ ACTS_PER_PAGE = Card.config.acts_per_page
2
+
3
+ format :html do
4
+ def act_from_context
5
+ if (act_id = params["act_id"])
6
+ Act.find(act_id) || raise(Card::NotFound, "act not found")
7
+ else
8
+ card.last_action.act
9
+ end
10
+ end
11
+
12
+ # used (by history and recent)for rendering act lists with legend and paging
13
+ #
14
+ # @param acts [ActiveRecord::Relation] relation that will return acts objects
15
+ # @param context [Symbol] :relative or :absolute
16
+ # @param draft_legend [Symbol] :show or :hide
17
+ def acts_layout acts, context, draft_legend=:hide
18
+ bs_layout container: false, fluid: false do
19
+ html _render_act_legend(draft_legend => :draft_legend)
20
+ row(12) { act_list acts, context }
21
+ row(12) { act_paging acts, context }
22
+ end
23
+ end
24
+
25
+ def act_list acts, context
26
+ act_accordion acts, context do |act, seq|
27
+ fmt = context == :relative ? self : act.card.format(:html)
28
+ fmt.act_listing act, seq, context
29
+ end
30
+ end
31
+
32
+ def act_listing act, seq=nil, context=nil
33
+ opts = act_listing_opts_from_params(seq)
34
+ opts[:slot_class] = "revision-#{act.id} history-slot list-group-item"
35
+ context ||= (params[:act_context] || :absolute).to_sym
36
+ act_renderer(context).new(self, act, opts).render
37
+ end
38
+
39
+ # TODO: consider putting all these under one top-level param, eg:
40
+ # act: { seq: X, diff: [show/hide], action_view: Y }
41
+ def act_listing_opts_from_params seq
42
+ { act_seq: (seq || params["act_seq"]),
43
+ action_view: (params["action_view"] || "summary").to_sym,
44
+ hide_diff: params["hide_diff"].to_s.strip == "true" }
45
+ end
46
+
47
+ def act_accordion acts, context, &block
48
+ accordion_group acts_for_accordion(acts, context, &block), nil, class: "clear-both"
49
+ end
50
+
51
+ def acts_for_accordion acts, context
52
+ clean_acts(current_page_acts(acts)).map do |act|
53
+ with_act_seq(context, acts) do |seq|
54
+ yield act, seq
55
+ end
56
+ end
57
+ end
58
+
59
+ def with_act_seq context, acts
60
+ yield(context == :absolute ? nil : current_act_seq(acts))
61
+ end
62
+
63
+ def current_act_seq acts
64
+ @act_seq = @act_seq ? (@act_seq -= 1) : act_list_starting_seq(acts)
65
+ end
66
+
67
+ def clean_acts acts
68
+ # FIXME: if we get rid of bad act data, this will not be necessary
69
+ # The current
70
+ acts.select(&:card)
71
+ end
72
+
73
+ def current_page_acts acts
74
+ acts.page(acts_page_from_params).per acts_per_page
75
+ end
76
+
77
+ def act_list_starting_seq acts
78
+ acts.size - (acts_page_from_params - 1) * acts_per_page
79
+ end
80
+
81
+ def acts_per_page
82
+ @acts_per_page || ACTS_PER_PAGE
83
+ end
84
+
85
+ def acts_page_from_params
86
+ @acts_page_from_params ||= params["page"].present? ? params["page"].to_i : 1
87
+ end
88
+
89
+ def act_paging acts, context
90
+ wrap_with :div, class: "slotter btn-sm" do
91
+ acts = current_page_acts acts
92
+ opts = { remote: true, theme: "twitter-bootstrap-4" }
93
+ opts[:total_pages] = 10 if limited_paging? context
94
+ paginate acts, opts
95
+ end
96
+ end
97
+
98
+ def limited_paging? context
99
+ context == :absolute && Act.count > 1000
100
+ end
101
+
102
+ def action_icon action_type, extra_class=nil
103
+ icon = case action_type
104
+ when :create then :add_circle
105
+ when :update then :pencil
106
+ when :delete then :remove_circle
107
+ when :draft then :wrench
108
+ end
109
+ icon_tag icon, extra_class
110
+ end
111
+
112
+ private
113
+
114
+ def act_renderer context
115
+ case context
116
+ when :absolute
117
+ Act::ActRenderer::AbsoluteActRenderer
118
+ when :bridge
119
+ Act::ActRenderer::BridgeActRenderer
120
+ else # relative
121
+ Act::ActRenderer::RelativeActRenderer
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,124 @@
1
+ # -*- encoding : utf-8 -*-
2
+
3
+ def all_action_ids
4
+ Card::Action.where(card_id: id).pluck :id
5
+ end
6
+
7
+ def action_from_id action_id
8
+ return unless action_id.is_a?(Integer) || action_id =~ /^\d+$/
9
+
10
+ # if not an integer revision id is probably a mod (e.g. if you request
11
+ # files/:logo/standard.png)
12
+ action = Action.fetch action_id
13
+ return unless action.card_id == id
14
+
15
+ action
16
+ end
17
+
18
+ def old_actions
19
+ actions.where("id != ?", last_action_id)
20
+ end
21
+
22
+ def create_action
23
+ @create_action ||= actions.first
24
+ end
25
+
26
+ def nth_action index
27
+ index = index.to_i
28
+ return unless id && index.positive?
29
+
30
+ Action.where("draft is not true AND card_id = #{id}")
31
+ .order(:id).limit(1).offset(index - 1).first
32
+ end
33
+
34
+ def new_content_action_id
35
+ return unless @current_action && current_action_changes_content?
36
+
37
+ @current_action.id
38
+ end
39
+
40
+ def current_action_changes_content?
41
+ new_card? || @current_action.new_content? || db_content_is_changing?
42
+ end
43
+
44
+ format :html do
45
+ def action_from_context
46
+ if (action_id = voo.action_id || params[:action_id])
47
+ Action.fetch action_id
48
+ else
49
+ card.last_action
50
+ end
51
+ end
52
+
53
+ def action_content action, view_type
54
+ return "" unless action.present?
55
+
56
+ wrap do
57
+ [action_content_toggle(action, view_type),
58
+ content_diff(action, view_type)]
59
+ end
60
+ end
61
+
62
+ def content_diff action, view_type
63
+ diff = action.new_content? && content_changes(action, view_type)
64
+ return "<i>empty</i>" unless diff.present?
65
+
66
+ diff
67
+ end
68
+
69
+ def action_content_toggle action, view_type
70
+ return unless show_action_content_toggle?(action, view_type)
71
+
72
+ toggle_action_content_link action, view_type
73
+ end
74
+
75
+ def show_action_content_toggle? action, view_type
76
+ view_type == :expanded || action.summary_diff_omits_content?
77
+ end
78
+
79
+ def toggle_action_content_link action, view_type
80
+ other_view_type = view_type == :expanded ? :summary : :expanded
81
+ css_class = "revision-#{action.card_act_id} float-right"
82
+ link_to_view "action_#{other_view_type}",
83
+ icon_tag(action_arrow_dir(view_type), class: "md-24"),
84
+ class: css_class,
85
+ path: { action_id: action.id, look_in_trash: true }
86
+ end
87
+
88
+ def action_arrow_dir view_type
89
+ view_type == :expanded ? :triangle_left : :triangle_right
90
+ end
91
+
92
+ def revert_actions_link link_text, path_args, html_args={}
93
+ return unless card.ok? :update
94
+
95
+ path_args.reverse_merge! action: :update, look_in_trash: true, assign: true,
96
+ card: { skip: :validate_renaming }
97
+ html_args.reverse_merge! remote: true, method: :post, rel: "nofollow", path: path_args
98
+ add_class html_args, "slotter"
99
+ link_to link_text, html_args
100
+ end
101
+
102
+ def action_legend
103
+ types = %i[create update delete]
104
+ legend = types.map do |action_type|
105
+ "#{action_icon(action_type)} #{action_type}d"
106
+ end
107
+ legend << _render_draft_legend if voo.show?(:draft_legend)
108
+ "<small>Actions: #{legend.join ' | '}</small>"
109
+ end
110
+
111
+ def content_legend
112
+ legend = [Card::Content::Diff.render_added_chunk("Additions"),
113
+ Card::Content::Diff.render_deleted_chunk("Subtractions")]
114
+ "<small>Content changes: #{legend.join ' | '}</small>"
115
+ end
116
+
117
+ def content_changes action, diff_type, hide_diff=false
118
+ if hide_diff
119
+ action.raw_view
120
+ else
121
+ action.content_diff diff_type
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,8 @@
1
+ # all acts with actions on self and on cards included in self (ie, acts shown in history)
2
+ def history_acts
3
+ @history_acts ||= Act.all_with_actions_on(history_card_ids, true).order id: :desc
4
+ end
5
+
6
+ def draft_acts
7
+ drafts.created_by(Card::Auth.current_id).map(&:act)
8
+ end
@@ -0,0 +1,100 @@
1
+ # must be called on all actions and before :set_name, :process_subcards and
2
+ # :validate_delete_children
3
+ event :assign_action, :initialize, when: :actionable? do
4
+ act = director.need_act
5
+ @current_action = Card::Action.create(
6
+ card_act_id: act.id,
7
+ action_type: action,
8
+ draft: (Env.params["draft"] == "true")
9
+ )
10
+ if @supercard && @supercard != self
11
+ @current_action.super_action = @supercard.current_action
12
+ end
13
+ end
14
+
15
+ # can we store an action? (can be overridden, eg in files)
16
+ def actionable?
17
+ history?
18
+ end
19
+
20
+ event :detect_conflict, :validate, on: :update, when: :edit_conflict? do
21
+ errors.add :conflict, tr(:error_not_latest_revision)
22
+ end
23
+
24
+ def edit_conflict?
25
+ last_action_id_before_edit &&
26
+ last_action_id_before_edit.to_i != last_action_id &&
27
+ (la = last_action) &&
28
+ la.act.actor_id != Auth.current_id
29
+ end
30
+
31
+ # stores changes in the changes table and assigns them to the current action
32
+ # removes the action if there are no changes
33
+ event :finalize_action, :finalize, when: :finalize_action? do
34
+ if changed_fields.present?
35
+ @current_action.update! card_id: id
36
+
37
+ # Note: #last_change_on uses the id to sort by date
38
+ # so the changes for the create changes have to be created before the first change
39
+ store_card_changes_for_create_action if first_change?
40
+ store_card_changes unless first_create?
41
+ # FIXME: a `@current_action.card` call here breaks specs in solid_cache_spec.rb
42
+ elsif @current_action.card_changes.reload.empty?
43
+ @current_action.delete
44
+ @current_action = nil
45
+ end
46
+ end
47
+
48
+ # changes for the create action are stored after the first update
49
+ def store_card_changes_for_create_action
50
+ Card::Action.cache.delete "#{create_action.id}-changes"
51
+ store_each_history_field create_action.id do |field|
52
+ attribute_before_act field
53
+ end
54
+ end
55
+
56
+ def store_card_changes
57
+ store_each_history_field @current_action.id, changed_fields do |field|
58
+ self[field]
59
+ end
60
+ end
61
+
62
+ def store_each_history_field action_id, fields=nil
63
+ fields ||= Card::Change::TRACKED_FIELDS
64
+ if false # Card::Change.supports_import?
65
+ # attach.feature fails with this
66
+ values = fields.map.with_index { |field, index| [index, yield(field), action_id] }
67
+ Card::Change.import [:field, :value, :card_action_id], values #, validate: false
68
+ else
69
+ fields.each do |field|
70
+ Card::Change.create field: field,
71
+ value: yield(field),
72
+ card_action_id: action_id
73
+ end
74
+ end
75
+ end
76
+
77
+ def finalize_action?
78
+ actionable? && current_action
79
+ end
80
+
81
+ event :rollback_actions, :prepare_to_validate, on: :update, when: :rollback_request? do
82
+ update_args = process_revert_actions
83
+ Env.params["revert_actions"] = nil
84
+ update! update_args
85
+ clear_drafts
86
+ abort :success
87
+ end
88
+
89
+ event :finalize_act, after: :finalize_action, when: :act_card? do
90
+ Card::Director.act.update! card_id: id
91
+ end
92
+
93
+ event :remove_empty_act, :integrate_with_delay_final, when: :remove_empty_act? do
94
+ # Card::Director.act.delete
95
+ # Card::Director.act = nil
96
+ end
97
+
98
+ def remove_empty_act?
99
+ act_card? && Director.act&.ar_actions&.reload&.empty?
100
+ end