app/models/info_request_event.rb
# == Schema Information
# Schema version: 20230127132719
#
# Table name: info_request_events
#
# id :integer not null, primary key
# info_request_id :integer not null
# event_type :text not null
# created_at :datetime not null
# described_state :string
# calculated_state :string
# last_described_at :datetime
# incoming_message_id :integer
# outgoing_message_id :integer
# comment_id :integer
# updated_at :datetime
# params :jsonb
#
# models/info_request_event.rb:
#
# Copyright (c) 2007 UK Citizens Online Democracy. All rights reserved.
# Email: hello@mysociety.org; WWW: http://www.mysociety.org/
class InfoRequestEvent < ApplicationRecord
extend XapianQueries
EVENT_TYPES = [
'sent',
'resent',
'followup_sent',
'followup_resent',
'edit', # title etc. edited (in admin interface)
'edit_outgoing', # outgoing message edited (in admin interface)
'edit_comment', # comment edited (in admin interface)
'hide_comment', # comment hidden by admin
'report_comment', # comment reported for admin attention by user
'report_request', # a request reported for admin attention by user
'destroy_incoming', # deleted an incoming message (in admin interface)
'destroy_outgoing', # deleted an outgoing message (in admin interface)
'redeliver_incoming', # redelivered an incoming message elsewhere (in admin interface)
'edit_incoming', # incoming message edited (in admin interface)
'edit_attachment', # attachment edited (in admin interface)
'move_request', # changed user or public body (in admin interface)
'hide', # hid a request (in admin interface)
'manual', # you did something in the db by hand
'response', # an incoming message is received
'comment', # an annotation is added
'status_update', # someone updates the status of the request
'overdue', # the request becomes overdue
'very_overdue', # the request becomes very overdue
'embargo_expiring', # an embargo is about to expire
'expire_embargo', # an embargo on the request expires
'set_embargo', # an embargo is added or extended
'send_error', # an error during sending
'refusal_advice', # the results of completing the refusal advice wizard
'public_token' # has the shareable public token been generated or not
].freeze
belongs_to :info_request,
inverse_of: :info_request_events
validates_presence_of :info_request
belongs_to :outgoing_message,
inverse_of: :info_request_events
belongs_to :incoming_message,
inverse_of: :info_request_events
belongs_to :comment,
inverse_of: :info_request_events
has_one :request_classification,
inverse_of: :info_request_event
has_many :user_info_request_sent_alerts,
inverse_of: :info_request_event,
dependent: :destroy
has_many :track_things_sent_emails,
inverse_of: :info_request_event,
dependent: :destroy
has_many :notifications,
inverse_of: :info_request_event,
dependent: :destroy
validates_presence_of :event_type
before_save(if: :only_editing_prominence_to_hide?) do
self.event_type = "hide"
end
after_create :update_request, if: :response?
after_create :invalidate_cached_pages, unless: :no_xapian_reindex
after_commit -> { info_request.create_or_update_request_summary },
on: [:create]
validates_inclusion_of :event_type, in: EVENT_TYPES
# user described state (also update in info_request)
validate :must_be_valid_state
EVENT_TYPES.each do |event_type|
scope "#{event_type}_events", -> { where(event_type: event_type) }
end
attr_accessor :no_xapian_reindex
# Full text search indexing
acts_as_xapian \
texts: [
:search_text_main,
:title
],
values: [
# for QueryParser range searches e.g. 01/01/2008..14/01/2008:
[:created_at, 0, 'range_search', :date],
# for sorting:
[:created_at_numeric, 1, 'created_at', :number],
# TODO: using :number for lack of :datetime support in Xapian values:
[:described_at_numeric, 2, 'described_at', :number],
[:request, 3, 'request_collapse', :string],
[:request_title_collapse, 4, 'request_title_collapse', :string]
],
terms: [
[:calculated_state, 'S', 'status'],
[:requested_by, 'B', 'requested_by'],
[:requested_from, 'F', 'requested_from'],
[:commented_by, 'C', 'commented_by'],
[:request, 'R', 'request'],
[:variety, 'V', 'variety'],
[:latest_variety, 'K', 'latest_variety'],
[:latest_status, 'L', 'latest_status'],
[:waiting_classification, 'W', 'waiting_classification'],
[:filetype, 'T', 'filetype'],
[:tags, 'U', 'tag'],
[:request_public_body_tags, 'X', 'request_public_body_tag']
],
eager_load: [
:outgoing_message,
:comment,
{ info_request: [:user, :public_body, :censor_rules] }
],
if: :indexed_by_search?
def self.count_of_hides_by_week
where(event_type: "hide").group("date(date_trunc('week', created_at))").count.sort
end
# TODO: Can possibly be made private
def request
info_request.url_title
end
def described_at
# For responses, people might have RSS feeds on searches for type of
# response (e.g. successful) in which case we want to date sort by
# when the responses was described as being of the type. For other
# types, just use the create at date.
last_described_at || created_at
end
def incoming_message_selective_columns(fields)
message = IncomingMessage.select("#{ fields }, incoming_messages.info_request_id").
joins('INNER JOIN info_request_events ON incoming_messages.id = incoming_message_id').
where('info_request_events.id = ?', id)
message = message[0]
message.info_request = InfoRequest.find(message.info_request_id) if message
message
end
# clipped = true - means return shorter text. It is used for snippets fore
# performance reasons. Xapian will take the full text.
def search_text_main(clipped = false)
text = ''
if event_type == 'sent'
text = text + outgoing_message.get_text_for_indexing + "\n\n"
elsif event_type == 'followup_sent'
text = text + outgoing_message.get_text_for_indexing + "\n\n"
elsif event_type == 'response'
if clipped
text += get_clipped_response_efficiently
else
text = text + incoming_message.get_text_for_indexing_full + "\n\n"
end
elsif event_type == 'comment'
text = text + comment.body + "\n\n"
end
text
end
# TODO: Can possibly be made private
def title
if event_type == 'sent'
info_request.title
else
''
end
end
# TODO: Can possibly be made private
def tags
# this returns an array of strings, each gets indexed as separate term by acts_as_xapian
info_request.tag_array_for_search
end
def visible
if event_type == 'comment'
comment.visible
else
true
end
end
def params=(new_params)
super(params_for_jsonb(new_params))
# TODO: should really set these explicitly, and stop storing them in
# here, but keep it for compatibility with old way for now
if params[:incoming_message]
self.incoming_message = params[:incoming_message]
end
if params[:outgoing_message]
self.outgoing_message = params[:outgoing_message]
end
self.comment = params[:comment] if params[:comment]
end
# A hash to lazy load Global ID reference models
class Params < Hash
def [](key)
value = super
return value unless value.is_a?(Hash) && value[:gid]
instance = GlobalID::Locator.locate(value[:gid])
self[key] = instance
end
end
def params
params_jsonb = super
Params[params_jsonb.deep_symbolize_keys] if params_jsonb
end
def params_diff
# split out parameters into old/new diffs, and other ones
old_params = {}
new_params = {}
other_params = {}
ignore = {}
params.each do |key, value|
key = key.to_s
if key.match(/^old_(.*)$/)
if params[$1.to_sym] == value
ignore[$1.to_sym] = ''
else
old_params[$1.to_sym] = value
end
elsif params.include?(("old_" + key).to_sym)
new_params[key.to_sym] = value
else
other_params[key.to_sym] = value
end
end
new_params.delete_if { |key, _value| ignore.keys.include?(key) }
{ new: new_params, old: old_params, other: other_params }
end
def is_incoming_message?
incoming_message_id? or (incoming_message if new_record?)
end
def is_outgoing_message?
outgoing_message_id? or (outgoing_message if new_record?)
end
def is_comment?
comment_id? or (comment if new_record?)
end
def resets_due_dates?
is_request_sending? || is_clarification?
end
def is_request_sending?
%w[sent resent].include?(event_type) ||
(event_type == 'send_error' &&
outgoing_message.message_type == 'initial_request')
end
def is_clarification?
waiting_clarification = false
# A follow up is a clarification only if it's the first
# follow up when the request is in a state of
# waiting for clarification
previous_events(reverse: true).each do |event|
if event.described_state == 'waiting_clarification'
waiting_clarification = true
break
end
break if event.event_type == 'followup_sent'
end
waiting_clarification && event_type == 'followup_sent'
end
# Public: Checks to see if any subsequent event now resets due dates
# on the request and resets them if so
def recheck_due_dates
subsequent_events.each do |event|
info_request.set_due_dates(event) if event.resets_due_dates?
end
end
# Display version of status
def display_status
if is_incoming_message?
status = calculated_state
return status.nil? ? _("Response") : InfoRequest.get_status_description(status)
end
if is_outgoing_message?
status = calculated_state
if status
return _("Internal review request") if status == 'internal_review'
return _("Clarification") if status == 'waiting_response'
raise _("unknown status {{status}}", status: status)
end
# TRANSLATORS: "Follow up" in this context means a further
# message sent by the requester to the authority after
# the initial request
return _("Follow up")
end
raise _("display_status only works for incoming and outgoing messages right now")
end
def is_sent_sort?
%w[sent resent].include?(event_type)
end
def is_followup_sort?
%w[followup_sent followup_resent].include?(event_type)
end
def outgoing?
%w[sent followup_sent].include?(event_type)
end
def response?
event_type == 'response'
end
# This method updates the cached column of the InfoRequest that
# stores the last created_at date of relevant events
# when saving or destroying an InfoRequestEvent associated with the request
def update_request
info_request.update_last_public_response_at
end
def invalidate_cached_pages
if comment
NotifyCacheJob.perform_later(comment)
elsif foi_attachment
NotifyCacheJob.perform_later(foi_attachment)
else
NotifyCacheJob.perform_later(info_request)
end
end
def same_email_as_previous_send?
prev_addr = info_request.get_previous_email_sent_to(self)
curr_addr = params[:email]
return true if prev_addr.nil? && curr_addr.nil?
return false if prev_addr.nil? || curr_addr.nil?
MailHandler.address_from_string(prev_addr) == MailHandler.address_from_string(curr_addr)
end
def json_for_api(deep, snippet_highlight_proc = nil)
ret = {
id: id,
event_type: event_type,
# params has possibly sensitive data in it, don't include it
created_at: created_at,
described_state: described_state,
calculated_state: calculated_state,
last_described_at: last_described_at,
incoming_message_id: incoming_message_id,
outgoing_message_id: outgoing_message_id,
comment_id: comment_id
# TODO: would be nice to add links here, but alas the
# code to make them is in views only. See views/request/details.html.erb
# perhaps can call with @template somehow
}
if is_incoming_message? || is_outgoing_message?
ret[:display_status] = display_status
end
if snippet_highlight_proc
ret[:snippet] = snippet_highlight_proc.call(search_text_main(true))
end
if deep
ret[:info_request] = info_request.json_for_api(false)
ret[:public_body] = info_request.public_body.json_for_api
ret[:user] = info_request.user_json_for_api
end
ret
end
def set_calculated_state!(state)
unless calculated_state == state
self.calculated_state = state
self.last_described_at = Time.zone.now
save!
end
end
def foi_attachment
return unless params[:attachment_id]
@foi_attachment ||= FoiAttachment.find(params[:attachment_id])
end
protected
def variety
event_type
end
private
def previous_events(opts = {})
order = opts[:reverse] ? 'created_at DESC' : 'created_at'
events = self.
class.
where(info_request_id: info_request_id).
where('created_at < ?', created_at).
order(order)
end
def subsequent_events(opts = {})
order = opts[:reverse] ? 'created_at DESC' : 'created_at'
events = self.
class.
where(info_request_id: info_request_id).
where('created_at > ?', created_at).
order(order)
end
def sibling_events(opts = {})
order = opts[:reverse] ? 'created_at DESC' : 'created_at'
events = self.class.where(info_request_id: info_request_id).order(order)
end
def params_for_jsonb(params)
params.inject({}) do |memo, (k, v)|
key = k.to_s
# look for keys ending in `_id` and attempt to map to a Ruby class
key = key.sub(/_id$/, '')
if Regexp.last_match
klass_str = key.classify
klass = klass_str.safe_constantize
klass ||= "AlaveteliPro::#{klass_str}".safe_constantize
klass ||= InfoRequest if klass_str == 'Request'
if klass
# attempt to load the object by ID
object = klass.find_by(id: v)
# if object can't be loading, eg, deleted from DB, manually build
# ID/type hash
value = { gid: "gid://app/#{klass}/#{v}" } unless object
else
# without a class, probably not a application model, EG email message
# ID, so revert the change to the key to re-add the `_id`
key = k
end
end
object ||= v if v.is_a?(ApplicationRecord)
if object
# if we have an object, map to a ID/type hash - including version if
# present
value = { gid: object.to_global_id.to_s }
value[:version] = object.version if object.respond_to?(:version)
end
memo[key.to_sym] = value || v
memo
end
end
def only_editing_prominence_to_hide?
event_type == 'edit' &&
params_diff[:new].keys == [:prominence] &&
params_diff[:old][:prominence] == "normal" &&
%w(hidden requester_only backpage).include?(params_diff[:new][:prominence])
end
def get_clipped_response_efficiently
# TODO: this ugly code is an attempt to not always load all the
# columns for an incoming message, which can be *very* large
# (due to all the cached text). We care particularly in this
# case because it's called for every search result on a page
# (to show the search snippet). Actually, we should review if we
# need all this data to be cached in the database at all, and
# then we won't need this horrid workaround.
message = incoming_message_selective_columns("cached_attachment_text_clipped, cached_main_body_text_folded")
clipped_body = message.cached_main_body_text_folded
clipped_attachment = message.cached_attachment_text_clipped
if clipped_body.nil? || clipped_attachment.nil?
# we're going to have to load it anyway
text = incoming_message.get_text_for_indexing_clipped
else
text = clipped_body.gsub("FOLDED_QUOTED_SECTION", " ").strip + "\n\n" + clipped_attachment
end
text + "\n\n"
end
def must_be_valid_state
if described_state && !InfoRequest::State.all.include?(described_state)
errors.add(:described_state, "is not a valid state")
end
end
# INDEXING HELPERS
def indexed_by_search?
if %w[sent followup_sent response comment].include?(event_type)
return false unless info_request.indexed_by_search?
if event_type == 'response' && !incoming_message.indexed_by_search?
return false
end
if %w[sent followup_sent].include?(event_type) && !outgoing_message.indexed_by_search?
return false
end
return false if event_type == 'comment' && !comment.visible
return true
end
false
end
def requested_by
info_request.user_name_slug
end
def requested_from
# acts_as_xapian will detect translated fields via Globalize and add all the
# available locales to the index. But 'requested_from' is not translated directly,
# although it relies on a translated field in PublicBody. Hence, we need to
# manually add all the localized values to the index (Xapian can handle a list
# of values in a term, btw)
info_request.public_body.translations.map(&:url_name)
end
def commented_by
if event_type == 'comment'
comment.user.url_name
else
''
end
end
def request_title_collapse
info_request.url_title(collapse: true)
end
def described_at_numeric
# format it here as no datetime support in Xapian's value ranges
described_at.strftime("%Y%m%d%H%M%S")
end
def created_at_numeric
# format it here as no datetime support in Xapian's value ranges
created_at.strftime("%Y%m%d%H%M%S")
end
def waiting_classification
info_request.awaiting_description == true ? "yes" : "no"
end
def latest_variety
sibling_events(reverse: true).each do |event|
return event.variety unless event.variety.blank?
end
end
def latest_status
sibling_events(reverse: true).each do |event|
return event.calculated_state unless event.calculated_state.blank?
end
end
def filetype
if event_type == 'response'
unless incoming_message
raise "event type is 'response' but no incoming message for event id #{id}"
end
incoming_message.get_present_file_extensions
else
''
end
end
def request_public_body_tags
info_request.public_body.tag_array_for_search
end
end