Election results in the London Borough of Sutton.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

299 lines
7.4 KiB

  1. #!/usr/bin/env ruby
  2. # Generate a static site
  3. # https://blog.dnsimple.com/2018/03/elapsed-time-with-ruby-the-right-way/
  4. t_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  5. require 'logger'
  6. require 'haml'
  7. require_relative '../models'
  8. OUTPUT_DIR = '_site'
  9. VIEWS_DIR = File.join('..', 'views')
  10. LAYOUT_FN = File.join(VIEWS_DIR, 'layout.haml')
  11. @log = Logger.new($stdout)
  12. @log.level = Logger::INFO
  13. @log.info "Build starts."
  14. @log.info "Output directory is: #{OUTPUT_DIR}"
  15. @pages = 0
  16. class String
  17. def pluralize(num)
  18. if num == 1
  19. return self
  20. end
  21. case self[-1]
  22. when 'y'
  23. self[0..-2] + 'ies'
  24. when 's'
  25. self + "es"
  26. else
  27. self + "s"
  28. end
  29. end
  30. end
  31. def commify(num)
  32. num.to_s.reverse.gsub(/(\d\d\d)(?=\d)(?!\d*\.)/,'\1,').reverse
  33. end
  34. # From http://snippets.dzone.com/posts/show/593
  35. def to_ordinal(num)
  36. num = num.to_i
  37. if (10...20) === num
  38. "#{num}th"
  39. else
  40. g = %w{ th st nd rd th th th th th th }
  41. a = num.to_s
  42. c = a[-1..-1].to_i
  43. a + g[c]
  44. end
  45. end
  46. def format_percent(num)
  47. sprintf("%.0f%%", num)
  48. end
  49. def short_date(d)
  50. d.strftime("%e %b %Y")
  51. end
  52. def long_date(d)
  53. d.strftime("%e %B %Y")
  54. end
  55. # Exception for Labour/Co-operative candidacies
  56. def party_name(labcoop, party_name)
  57. labcoop ? "Labour and Co-operative Party" : party_name
  58. end
  59. def write_page(path_items, template, locals = {})
  60. dir = File.join(path_items)
  61. FileUtils.mkdir_p(dir)
  62. @log.debug dir
  63. fn = File.join(dir, 'index.html')
  64. # https://stackoverflow.com/questions/6125265/using-layouts-in-haml-files-independently-of-rails
  65. html = Haml::Engine.new(File.read(LAYOUT_FN)).render do
  66. Haml::Engine.new(File.read(File.join(VIEWS_DIR, "#{template}.haml"))).render(Object.new, locals)
  67. end
  68. File.write(fn, html)
  69. @log.info fn
  70. @pages += 1
  71. # TODO - add page to sitemap.xml or sitemap.txt
  72. # https://support.google.com/webmasters/answer/183668?hl=en&ref_topic=4581190
  73. end
  74. working_dir = File.join(Dir.pwd, OUTPUT_DIR)
  75. # Recursively delete working directory to ensure no redundant files are left behind from previous builds.
  76. FileUtils.rm_rf(working_dir)
  77. Dir.mkdir(working_dir) unless File.directory?(working_dir)
  78. Dir.chdir(working_dir)
  79. # Copy `public` dir to output dir
  80. FileUtils.copy_entry(File.join('..', 'public'), '.')
  81. # Home page
  82. locals = {
  83. future_elections: Election.future,
  84. past_elections: Election.past
  85. }
  86. write_page('.', 'index', locals)
  87. # Election pages
  88. Election.each do |e|
  89. locals = {
  90. body: Body.first(:slug => e.body.slug),
  91. election: Election.first(:body => e.body, :d => e.d),
  92. elections_for_this_body: Election.all(:body => e.body, :order => [:d]),
  93. total_seats: Candidacy.sum(:seats, :election => e),
  94. total_votes: Candidacy.sum(:votes, :election => e)
  95. }
  96. # There's got to be a better way to do this, either with SQL or Datamapper
  97. locals['total_districts'] = repository(:default).adapter.select("
  98. SELECT district_id
  99. FROM candidacies
  100. WHERE election_id = ?
  101. GROUP BY district_id
  102. ORDER BY district_id
  103. ", e.id).count
  104. locals['results_by_party'] = repository(:default).adapter.select("
  105. SELECT
  106. p.colour,
  107. p.name,
  108. SUM(c.votes) AS votez,
  109. SUM(c.seats) AS seatz,
  110. COUNT(*) AS cands
  111. FROM candidacies c
  112. LEFT JOIN parties p ON p.id = c.party_id
  113. WHERE c.election_id = ?
  114. GROUP BY c.party_id, p.colour, p.name
  115. ORDER BY seatz DESC, votez DESC
  116. ", e.id)
  117. write_page(['bodies', e.body.slug, 'elections', e.d.to_s], 'electionsummary', locals)
  118. # District results for this election (resultsdistrict)
  119. # Loop through all districts in this election
  120. e.candidacies.districts.each do |d|
  121. total_seats = Candidacy.sum(:seats, :district => d, :election => e)
  122. total_votes = Candidacy.sum(:votes, :district => d, :election => e)
  123. poll = Poll.get(d.id, e.id)
  124. locals = {
  125. district: d,
  126. body: d.body,
  127. election: e,
  128. candidacies: Candidacy.all(:district => d, :election => e, :order => [:position]),
  129. total_votes: total_votes,
  130. total_candidates: Candidacy.count(:district => d, :election => e),
  131. total_seats: total_seats,
  132. districts_in_this_election: e.candidacies.districts,
  133. poll: poll
  134. }
  135. locals['share_message'] = nil
  136. if total_seats == 1
  137. locals['share_denominator'] = total_votes
  138. elsif poll && poll.valid_ballot_papers
  139. locals['share_denominator'] = poll.valid_ballot_papers
  140. else
  141. locals['share_denominator'] = total_votes / total_seats
  142. locals['share_message'] = "The vote share percentages have been estimated as we don't have data for the number of valid ballot papers in this poll."
  143. end
  144. # Postgres: All the columns selected when using GROUP BY must either be aggregate functions or appear in the GROUP BY clause
  145. locals['results_by_party'] = repository(:default).adapter.select("
  146. SELECT
  147. p.name AS party_name,
  148. p.colour AS party_colour,
  149. COUNT(c.id) AS num_candidates,
  150. SUM(c.seats) AS num_seats,
  151. SUM(c.votes) AS total_votes
  152. FROM candidacies c
  153. LEFT JOIN parties p
  154. ON c.party_id = p.id
  155. WHERE c.district_id = ?
  156. AND c.election_id = ?
  157. GROUP BY p.name, p.colour
  158. ORDER BY total_votes DESC
  159. ", d.id, e.id)
  160. write_page(['bodies', e.body.slug, 'elections', e.d.to_s, e.body.districts_name, d.slug], 'resultsdistrict', locals)
  161. end
  162. end
  163. # Candidate index
  164. locals = { candidates: Candidate.all(:order => [ :surname, :forenames ]) }
  165. write_page('candidates', 'candidates', locals)
  166. # Candidate pages
  167. # FIXME: What do we do about deleted candidates/redirects?
  168. Candidate.each do |c|
  169. locals = {
  170. candidate: c
  171. }
  172. locals['candidacies'] = repository(:default).adapter.select("
  173. SELECT
  174. e.d,
  175. c.*,
  176. p.name AS party_name,
  177. p.colour AS party_colour,
  178. b.name AS body_name,
  179. b.slug AS body_slug,
  180. b.districts_name AS districts_name,
  181. d.name AS district_name,
  182. d.slug AS district_slug
  183. FROM candidacies c
  184. INNER JOIN elections e
  185. ON c.election_id = e.id
  186. INNER JOIN parties p
  187. ON c.party_id = p.id
  188. INNER JOIN bodies b
  189. ON e.body_id = b.id
  190. INNER JOIN districts d
  191. ON c.district_id = d.id
  192. WHERE c.candidate_id = ?
  193. ORDER BY d
  194. ", c.id)
  195. write_page(['candidates', c.id.to_s], 'candidate', locals)
  196. end
  197. # Bodies index
  198. dir = 'bodies'
  199. FileUtils.mkdir_p(dir)
  200. @log.debug dir
  201. fn = File.join(dir, 'index.html')
  202. FileUtils.touch(fn) # empty file
  203. @log.info fn
  204. # Body detail pages
  205. Body.each do |b|
  206. locals = {
  207. body: b,
  208. districts: District.all(:body => b, :order => [:name])
  209. }
  210. locals['elections'] = repository(:default).adapter.select("
  211. SELECT
  212. e.id,
  213. e.kind,
  214. e.d,
  215. SUM(p.ballot_papers_issued)::float / SUM(p.electorate) * 100 AS turnout_percent
  216. FROM elections e
  217. LEFT JOIN polls p
  218. ON e.id = p.election_id
  219. WHERE e.body_id = ?
  220. GROUP BY p.election_id, e.id
  221. ORDER BY e.d DESC
  222. ", b.id)
  223. write_page(['bodies', b.slug], 'body', locals)
  224. # Districts for this body
  225. b.districts.each do |d|
  226. locals = {
  227. district: d,
  228. body: b
  229. }
  230. write_page(['bodies', b.slug, b.districts_name, d.slug], 'district', locals)
  231. end
  232. end
  233. write_page('about', 'about')
  234. write_page('guides', 'guides')
  235. write_page(%w(guides how-the-parliament-election-works), 'parliament')
  236. write_page(%w(guides how-the-council-election-works), 'election')
  237. @log.info "Build complete. %d pages generated in %0.2f seconds." % [ @pages, Process.clock_gettime(Process::CLOCK_MONOTONIC) - t_start ]