# == Schema Information
# Schema version: 75
#
# Table name: public_bodies
#
# id :integer not null, primary key
# name :text not null
# short_name :text not null
# request_email :text not null
# version :integer not null
# last_edit_editor :string(255) not null
# last_edit_comment :text not null
# created_at :datetime not null
# updated_at :datetime not null
# url_name :text not null
# home_page :text default(""), not null
# notes :text default(""), not null
# first_letter :string(255) not null
# publication_scheme :text default(""), not null
# charity_number :text default(""), not null
#
# models/public_body.rb:
# A public body, from which information can be requested.
#
# Copyright (c) 2007 UK Citizens Online Democracy. All rights reserved.
# Email: francis@mysociety.org; WWW: http://www.mysociety.org/
#
# $Id: public_body.rb,v 1.140 2009-04-13 09:18:48 tony Exp $
require 'csv'
require 'set'
class PublicBody < ActiveRecord::Base
strip_attributes!
validates_presence_of :name
validates_presence_of :url_name
validates_uniqueness_of :short_name, :if => Proc.new { |pb| pb.short_name != "" }
validates_uniqueness_of :name
has_many :info_requests, :order => 'created_at desc'
has_many :public_body_tags
has_many :track_things, :order => 'created_at desc'
def self.categories_with_headings
[
"Miscellaneous",
[ "other", "Miscellaneous", "miscellaneous" ],
"Central government",
[ "department", "Ministerial departments", "a ministerial department" ],
[ "non_ministerial_department", "Non-ministerial departments", "a non-ministerial department" ],
[ "executive_agency", "Executive agencies", "an executive agency" ],
[ "government_office", "Government offices for the regions", "a government office for the regions" ],
[ "advisory_committee", "Advisory committees", "an advisory committee" ],
[ "awc", "Agricultural wages committees", "an agriculatural wages committee" ],
[ "adhac", "Agricultural dwelling house advisory committees", "an agriculatural dwelling house advisory committee" ],
[ "newdeal", "New Deal for Communities partnership", "a New Deal for Communities partnership" ],
"Local and regional",
[ "local_council", "Local councils", "a local council" ],
[ "parish_council", "Town and Parish councils", "a town/parish council"],
[ "housing_association", "Housing associations", "a housing association"],
[ "almo", "Housing ALMOs", "a housing ALMO"],
[ "municipal_bank", "Municipal bank", "a municipal bank"],
[ "nsbody", "North/south bodies", "a north/south body"],
[ "pbo", "Professional buying organisations", "a professional buying organisation"],
[ "regional_assembly", "Regional assemblies", "a regional assembly"],
[ "rda", "Regional development agencies", "a regional development agency" ],
"Education",
[ "university", "Universities", "a university" ],
[ "university_college", "University colleges", "a university college" ],
[ "cambridge_college", "Cambridge colleges", "a Cambridge college" ],
[ "durham_college", "Durham colleges", "a Durham college" ],
[ "oxford_college", "Oxford colleges", "an Oxford college or permanent private hall" ],
[ "york_college", "York colleges", "a college of the University of York" ],
[ "university_owned_company", "University owned companies", "a university owned company" ],
[ "hei", "Higher education institutions", "a higher educational institution" ],
[ "fei", "Further education institutions", "a further educational institution" ],
[ "research_council", "Research councils", "a research council" ],
[ "lib_board", "Education and library boards", "an education and library board" ],
"Environment",
[ "npa", "National park authorities", "a national park authority" ],
[ "rpa", "Regional park authorities", "a national park authoriy" ],
[ "sea_fishery_committee", "Sea fisheries committees", "a sea fisheries committee" ],
[ "watercompanies", "Water companies", "a water company" ],
[ "idb", "Internal drainage boards", "an internal drainage board" ],
[ "rfdc", "Regional flood defence committees", "a regional flood defence committee" ],
[ "wda", "Waste disposal authorities", "a waste disposal authority" ],
[ "zoo", "Zoos", "a zoo" ],
"Health",
[ "nhstrust", "NHS trusts", "an NHS trust" ],
[ "pct", "Primary care trusts", "a primary care trust" ],
[ "nhswales", "NHS in Wales", "part of the NHS in Wales" ],
[ "nhsni", "NHS in Northern Ireland", "part of the NHS in Northern Ireland" ],
[ "hscr", "Health / social care", "Relating to health / social care" ],
[ "pha", "Port health authorities", "a port health authority"],
[ "sha", "Strategic health authorities", "a strategic health authority" ],
[ "specialha", "Special health authorities", "a special health authority" ],
"Media and culture",
[ "media", "Media", "a media organisation" ],
[ "rcc", "Cultural consortia", "a cultural consortium"],
[ "museum", "Museums and galleries", "a museum or gallery" ],
"Military and security services",
[ "military_college", "Military colleges", "a military college" ],
[ "security_services", "Security services", "a security services body" ],
"Emergency services and the courts",
[ "police", "Police forces", "a police force" ],
[ "police_authority", "Police authorities", "a police authority" ],
[ "dpp", "District policing partnerships", "a district policing partnership" ],
[ "fire_service", "Fire and rescue services", "a fire and rescue service" ],
[ "prob_board", "Probation boards", "a probation board" ],
[ "rules_committee", "Rules commitees", "a rules committee" ],
[ "tribunal", "Tribunals", "a tribunal"],
"Transport",
[ "npte", "Passenger transport executives", "a passenger transport executive" ],
[ "port_authority", "Port authorities", "a port authority" ],
[ "scp", "Safety Camera Partnerships", "a safety camera partnership" ],
[ "srp", "Safer Roads Partnership", "a safer roads partnership" ]
]
end
def self.categories_with_description
self.categories_with_headings.select() { |a| a.instance_of?(Array) }
end
def self.categories
self.categories_with_description.map() { |a| a[0] }
end
def self.categories_by_tag
Hash[*self.categories_with_description.map() { |a| a[0..1] }.flatten]
end
def self.category_singular_by_tag
Hash[*self.categories_with_description.map() { |a| [a[0],a[2]] }.flatten]
end
# like find_by_url_name but also search historic url_name if none found
def self.find_by_urlname(name)
found = PublicBody.find_all_by_url_name(name)
return found.first if found.size == 1
# Shouldn't we just make url_name unique?
raise "Two bodies with the same URL name: #{name}" if found.size > 1
# If none found, then search the history of short names
old = PublicBody::Version.find_all_by_url_name(name)
# :conditions => [ "id in (select public_body_id from public_body_versions where url_name = ?)", name ])
# Maybe return the first one, so we show something relevant,
# rather than throwing an error?
raise "Two bodies with the same historical URL name: #{name}" if old.size > 1
return unless old.size == 1
# does acts_as_versioned provide a method that returns the current version?
return PublicBody.find(old.first.public_body_id)
end
# Set the first letter, which is used for faster queries
before_save(:set_first_letter)
def set_first_letter
self.first_letter = self.name[0,1].upcase
end
def validate
# Request_email can be blank, meaning we don't have details
if self.is_requestable?
unless MySociety::Validate.is_valid_email(self.request_email)
errors.add(:request_email, "doesn't look like a valid email address")
end
end
end
# Can an FOI (etc.) request be made to this body, and if not why not?
def is_requestable?
if self.request_email.nil?
return false
end
return !self.request_email.empty? && self.request_email != 'blank' && self.request_email != 'not_apply'
end
def not_requestable_reason
if self.request_email.empty? or self.request_email == 'blank'
return 'bad_contact'
elsif self.request_email == 'not_apply'
return 'not_apply'
else
raise "requestable_failure_reason called with type that has no reason"
end
end
acts_as_versioned
self.non_versioned_columns << 'created_at' << 'updated_at' << 'first_letter'
class Version
attr_accessor :created_at
def last_edit_comment_for_html_display
text = self.last_edit_comment.strip
text = CGI.escapeHTML(text)
text = MySociety::Format.make_clickable(text)
text = text.gsub(/\n/, '
')
return text
end
end
acts_as_xapian :texts => [ :name, :short_name, :notes ],
:values => [
[ :created_at_numeric, 1, "created_at", :number ] # for sorting
],
:terms => [ [ :variety, 'V', "variety" ] ]
def created_at_numeric
# format it here as no datetime support in Xapian's value ranges
return self.created_at.strftime("%Y%m%d%H%M%S")
end
def variety
"authority"
end
# When name or short name is changed, also change the url name
def short_name=(short_name)
write_attribute(:short_name, short_name)
self.update_url_name
end
def name=(name)
write_attribute(:name, name)
self.update_url_name
end
def update_url_name
url_name = MySociety::Format.simplify_url_part(self.short_or_long_name)
write_attribute(:url_name, url_name)
end
# Return the short name if present, or else long name
def short_or_long_name
if self.short_name.nil? # can happen during construction
self.name
else
self.short_name.empty? ? self.name : self.short_name
end
end
# Given an input string of tags, sets all tags to that string
def tag_string=(tag_string)
tags = tag_string.split(/\s+/).uniq
ActiveRecord::Base.transaction do
for public_body_tag in self.public_body_tags
public_body_tag.destroy
end
for tag in tags
public_body_tag = PublicBodyTag.new(:name => tag)
self.public_body_tags << public_body_tag
public_body_tag.public_body = self
end
end
end
def tag_string
return self.public_body_tags.map { |t| t.name }.join(' ')
end
def has_tag?(tag)
for public_body_tag in self.public_body_tags
if public_body_tag.name == tag
return true
end
end
return false
end
# Find all public bodies with a particular tag
def self.find_by_tag(tag)
return PublicBodyTag.find(:all, :conditions => ['name = ?', tag] ).map { |t| t.public_body }.sort { |a,b| a.name <=> b.name }
end
# Use tags to describe what type of thing this is
def type_of_authority(html = false)
types = []
first = true
for tag in self.public_body_tags
if PublicBody.categories_by_tag.include?(tag.name)
desc = PublicBody.category_singular_by_tag[tag.name]
if first
# terrible that Ruby/Rails doesn't have an equivalent of ucfirst
# (capitalize shockingly converts later characters to lowercase)
desc = desc[0,1].capitalize + desc[1,desc.size]
first = false
end
if html
# XXX this should call proper route helpers, but is in model sigh
desc = '' + desc + ''
end
types.push(desc)
end
end
if types.size > 0
ret = types[0, types.size - 1].join(", ")
if types.size > 1
ret = ret + " and "
end
ret = ret + types[-1]
return ret
else
return "A public authority"
end
end
# Guess home page from the request email, or use explicit override, or nil
# if not known.
def calculated_home_page
# manual override for ones we calculate wrongly
if self.home_page != ''
return self.home_page
end
# extract the domain name from the FOI request email
url = self.request_email_domain
if url.nil?
return nil
end
# add standard URL prefix
return "http://www." + url
end
# Are all requests to this body under the Environmental Information Regulations?
def eir_only?
return self.has_tag?('eir_only')
end
def law_only_short
if self.eir_only?
return "EIR"
else
return "FOI"
end
end
# The "internal admin" is a special body for internal use.
def PublicBody.internal_admin_body
pb = PublicBody.find_by_url_name("internal_admin_authority")
if pb.nil?
pb = PublicBody.new(
:name => 'Internal admin authority',
:short_name => "",
:request_email => MySociety::Config.get("CONTACT_EMAIL", 'contact@localhost'),
:home_page => "",
:notes => "",
:last_edit_editor => "internal_admin",
:last_edit_comment => "Made by PublicBody.internal_admin_body"
)
pb.save!
end
return pb
end
class ImportCSVDryRun < StandardError
end
# Import from CSV. Just tests things and returns messages if dry_run is true.
# Returns an array of [array of errors, array of notes]. If there are errors,
# always rolls back (as with dry_run).
def self.import_csv(csv, tag, dry_run, editor)
errors = []
notes = []
begin
ActiveRecord::Base.transaction do
existing_bodies = PublicBody.find_by_tag(tag)
bodies_by_name = {}
set_of_existing = Set.new()
for existing_body in existing_bodies
bodies_by_name[existing_body.name] = existing_body
set_of_existing.add(existing_body.name)
end
set_of_importing = Set.new()
line = 0
CSV::Reader.parse(csv) do |row|
line = line + 1
name = row[1]
email = row[2]
next if name.nil?
if email.nil?
email = '' # unknown/bad contact is empty string
end
name.strip!
email.strip!
if email != "" && !MySociety::Validate.is_valid_email(email)
errors.push "error: line " + line.to_s + ": invalid email " + email + " for authority '" + name + "'"
next
end
if bodies_by_name[name]
# Already have the public body, just update email
public_body = bodies_by_name[name]
if public_body.request_email != email
notes.push "line " + line.to_s + ": updating email for '" + name + "' from " + public_body.request_email + " to " + email
public_body.request_email = email
public_body.last_edit_editor = editor
public_body.last_edit_comment = 'Updated from spreadsheet'
public_body.save!
end
else
# New public body
notes.push "line " + line.to_s + ": new authority '" + name + "' with email " + email
public_body = PublicBody.new(:name => name, :request_email => email, :short_name => "", :home_page => "", :notes => "", :last_edit_editor => editor, :last_edit_comment => 'Created from spreadsheet')
public_body.tag_string = tag
public_body.save!
end
set_of_importing.add(name)
end
# Give an error listing ones that are to be deleted
deleted_ones = set_of_existing - set_of_importing
if deleted_ones.size > 0
notes.push "notes: Some " + tag + " bodies are in database, but not in CSV file:\n " + Array(deleted_ones).join("\n ") + "\nYou may want to delete them manually.\n"
end
# Rollback if a dry run, or we had errors
if dry_run or errors.size > 0
raise ImportCSVDryRun
end
end
rescue ImportCSVDryRun
# Ignore
end
return [errors, notes]
end
# Does this user have the power of FOI officer for this body?
def is_foi_officer?(user)
user_domain = user.email_domain
our_domain = self.request_email_domain
if user_domain.nil? or our_domain.nil?
return false
end
return our_domain == user_domain
end
def foi_officer_domain_required
return self.request_email_domain
end
# Domain name of the request email
def request_email_domain
return PublicBody.extract_domain_from_email(self.request_email)
end
# Return the domain part of an email address, canonicalised and with common
# extra UK Government server name parts removed.
def PublicBody.extract_domain_from_email(email)
email =~ /@(.*)/
if $1.nil?
return nil
end
# take lower case
ret = $1.downcase
# remove special email domains for UK Government addresses
ret.sub!(".gsi.", ".")
ret.sub!(".x.", ".")
ret.sub!(".pnn.", ".")
return ret
end
def reverse_sorted_versions
self.versions.sort { |a,b| b.version <=> a.version }
end
def sorted_versions
self.versions.sort { |a,b| a.version <=> b.version }
end
end