=0;h--)m(j[h]);m(k)}function m(j){var p,h=j,k,e,m;if(m=j.getAttribute(d)){while(h.parentNode&&(h=h.parentNode).nodeType===1&&!(p=h.getAttribute(d)));if(p!==m){h=h.parentNode?h.nodeType===11?0:h.getAttribute(d)||0:0;if(!(e=b[m])){e=f[m];e=g(e,b[h]||f[h]);e.key=++i;b[i]=e}c&&o(m)}j.removeAttribute(d)}else if(c&&(e=a.data(j,"tmplItem"))){o(e.key);b[e.key]=e;h=a.data(j.parentNode,"tmplItem");h=h?h.key:0}if(e){k=e;while(k&&k.key!=h){k.nodes.push(j);k=k.parent}delete e._ctnt;delete e._wrap;a.data(j,"tmplItem",e)}function o(a){a=a+n;e=l[a]=l[a]||g(e,b[e.parent.key+n]||e.parent)}}}function u(a,d,c,b){if(!a)return l.pop();l.push({_:a,tmpl:d,item:this,data:c,options:b})}function w(d,c,b){return a.tmpl(a.template(d),c,b,this)}function x(b,d){var c=b.options||{};c.wrapped=d;return a.tmpl(a.template(b.tmpl),b.data,c,b.item)}function v(d,c){var b=this._wrap;return a.map(a(a.isArray(b)?b.join(""):b).filter(d||"*"),function(a){return c?a.innerText||a.textContent:a.outerHTML||s(a)})}function t(){var b=this.nodes;a.tmpl(null,null,null,this).insertBefore(b[0]);a(b).remove()}})(jQuery); -------------------------------------------------------------------------------- /app/assets/javascripts/opencivicdataapi.js: -------------------------------------------------------------------------------- 1 | var OpenCivicDataApi = (function($){ 2 | function OpenCivicDataApi (api_key) { 3 | if (this === undefined ) { return new OpenCivicDataApi(api_key); } 4 | 5 | this.api_key = api_key; 6 | this.url_stub = 'https://api.opencivicdata.org'; 7 | }; 8 | 9 | OpenCivicDataApi.prototype.people = function (criteria) { 10 | var $def = new $.Deferred(); 11 | var qparams = $.extend(true, {}, criteria); 12 | qparams['apikey'] = this.api_key; 13 | $.ajax(this.url_stub + '/people/', { 14 | 'dataType': 'jsonp', 15 | 'data': qparams 16 | }).done(function(response){ 17 | if ((response.meta == null) || (response.meta.count == null) || (response.meta.count === 0)) { 18 | $def.rejectWith(this, [response]); 19 | } else { 20 | $def.resolveWith(this, [response]); 21 | } 22 | }).fail(function(response){ 23 | $def.rejectWith(this, [response]); 24 | }); 25 | return $def; 26 | }; 27 | 28 | return OpenCivicDataApi; 29 | })(jQuery); 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /app/assets/javascripts/politwoops.js: -------------------------------------------------------------------------------- 1 | (function($){ 2 | var congress_api = new CongressApi(Politwoops.sunlight_api_key); 3 | var ocd_api = new OpenCivicDataApi(Politwoops.sunlight_api_key); 4 | 5 | var swap_attributes = function (elem, attr_a, attr_b) { 6 | var $e = $(elem); 7 | var value_a = $e.attr(attr_a); 8 | var value_b = $e.attr(attr_b); 9 | if ((value_a != null) && (value_b != null)) { 10 | $e.attr(attr_a, value_b); 11 | $e.attr(attr_b, value_a); 12 | } 13 | }; 14 | 15 | $(function(){ 16 | $('img.politician-avatar').error(function(e){ 17 | e.preventDefault(); 18 | swap_attributes(this, 'src', 'data-lateimg-src'); 19 | }); 20 | }); 21 | 22 | $(document).ready(function(){ 23 | $('img.politician-avatar').each(function(ix, img){ 24 | if ($(img).attr('src') !== '') { 25 | swap_attributes(img, 'src', 'data-lateimg-src'); 26 | } 27 | }); 28 | }); 29 | 30 | 31 | 32 | (function($form){ 33 | function extract_name_from_form () { 34 | var last_name = $form.find("input#ln_input").val() || $form.find("input#last_name_input").val(); 35 | var first_name = $form.find("input#fn_input").val() || $form.find("input#first_name_input").val(); 36 | 37 | if ((last_name == null) || (last_name === '')) { 38 | last_name = $form.find("span#last_name").text(); 39 | } 40 | if ((first_name == null) || (first_name === '')) { 41 | first_name = $form.find("span#first_name").text(); 42 | } 43 | return {'first_name': first_name, 'last_name': last_name}; 44 | }; 45 | 46 | function copy_bioguide_identifier_to_form (click) { 47 | var bioguide_id = $(this).text(); 48 | $form.find("input.bioguide_id").val(bioguide_id); 49 | }; 50 | 51 | function copy_ocd_identifier_to_form (click) { 52 | var ocd_id = $(this).text(); 53 | $form.find("input.opencivicdata_id").val(ocd_id); 54 | }; 55 | 56 | var down_arrow = "▼"; 57 | var up_arrow = "▲"; 58 | 59 | $form.find("li.bioguide_id button.expander").toggle( 60 | function showBioguideSuggestions (click) { 61 | var name = extract_name_from_form(); 62 | var $expanded_area = $form.find("li.bioguide_id div.expandable"); 63 | var $leglist = $expanded_area.find("ul.suggestions"); 64 | $leglist.hide().empty(); 65 | $expanded_area.show(); 66 | var name = extract_name_from_form(); 67 | if ((name.last_name == null) || (name.last_name === '')) { 68 | return; 69 | } 70 | congress_api.legislators(name).done(function(response){ 71 | var legislators = response.results; 72 | $("script#bioguide-id-suggestion").tmpl(legislators).appendTo($leglist); 73 | $leglist.find("a.identifier").click(copy_bioguide_identifier_to_form); 74 | $leglist.fadeIn(400); 75 | $form.find("li.bioguide_id button.expander").html(up_arrow); 76 | }); 77 | }, 78 | 79 | function hideBioguideSuggestions () { 80 | $form.find("li.bioguide_id div.expandable").fadeOut(150); 81 | $form.find("li.bioguide_id button.expander").html(down_arrow); 82 | } 83 | ); 84 | 85 | $form.find("li.opencivicdata_id button.expander").toggle( 86 | function showOpenCivicDataSuggestions () { 87 | var $expanded_area = $form.find("li.opencivicdata_id div.expandable"); 88 | var $sugglist = $expanded_area.find("ul.suggestions"); 89 | $sugglist.hide().empty(); 90 | $expanded_area.show(); 91 | var name = extract_name_from_form(); 92 | if ((name.last_name == null) || (name.last_name === '')) { 93 | return; 94 | } 95 | ocd_api.people({'name': name.last_name}).done(function(response){ 96 | var ppl = response.results; 97 | var ppl1 = response.results.filter(function(p){ 98 | return p.name.startsWith(name.first_name); 99 | }); 100 | if (ppl1.length > 0) { 101 | ppl = ppl1; 102 | } 103 | $("script#ocd-id-suggestion").tmpl(ppl).appendTo($sugglist); 104 | $sugglist.find("a.identifier").click(copy_ocd_identifier_to_form); 105 | $sugglist.fadeIn(400); 106 | $form.find("li.opencivicdata_id button.expander").html(up_arrow); 107 | }); 108 | }, 109 | function hideOpenCividDataSuggestions () { 110 | $form.find("li.opencivicdata_id div.expandable").fadeOut(150); 111 | $form.find("li.opencivicdata_id button.expander").html(down_arrow); 112 | $form.find("li.opencivicdata_id ul.suggestions").empty(); 113 | } 114 | ); 115 | 116 | $form.find("input.bioguide_id").change(function(){ 117 | var ocd_id = $form.find(".opencivicdata_id").val(); 118 | if ((ocd_id != null) && (ocd_id !== '')) { 119 | console.log("Going to find OCD id for", $(this).val()); 120 | } 121 | }); 122 | })($("form#admin-politician")); 123 | 124 | 125 | })(jQuery); 126 | 127 | -------------------------------------------------------------------------------- /app/assets/stylesheets/admin.css: -------------------------------------------------------------------------------- 1 | /** Additional admin styles */ 2 | 3 | .admin_header { 4 | position: relative; 5 | height: 70px; 6 | margin-top: -20px; 7 | } 8 | 9 | .admin_header ul.nav { 10 | margin-bottom: 30px; 11 | position: absolute; left: 0; 12 | } 13 | 14 | .admin_header ul.nav li { 15 | display: inline-block; 16 | margin-right: 20px; 17 | font-size: 125%; 18 | } 19 | .admin_header ul.nav li a {text-decoration: underline;} 20 | 21 | .admin_header div.details { 22 | margin-bottom: 10px; 23 | position: absolute; right: 0; 24 | font-size: 90%; 25 | } 26 | 27 | div.admin.review { 28 | margin: 10px 0 10px 0; 29 | } 30 | 31 | div.admin.review input { 32 | padding: 5px 10px; 33 | margin-right: 10px; 34 | font-size: 110%; 35 | } 36 | 37 | div.admin.review span.reviewed_at { 38 | 39 | } 40 | 41 | div.admin.review a.edit { 42 | font-size: 80%; 43 | } 44 | 45 | div.admin.review div.review_message { 46 | margin-top: 15px; margin-bottom: 0; 47 | } 48 | 49 | div.admin.review div.review_message p {margin-bottom: 5px;} 50 | 51 | div.admin.review label.review_message { 52 | display: block; 53 | margin-top: 10px; 54 | } 55 | 56 | div.admin.review textarea.review_message { 57 | display: block; 58 | margin: 10px 0; 59 | height: 100px; width: 80%; 60 | } 61 | 62 | div.admin_error { 63 | margin-bottom: 40px; 64 | padding: 5px 10px; 65 | margin-top: -20px; 66 | } 67 | 68 | #admin-politician ul li {margin: 10px; padding: 5px; width: 420px; } 69 | 70 | #admin-politician img { margin-top: -20px; } 71 | 72 | #admin-politician ul li span.formfield { float: right; } 73 | 74 | #admin-politician ul li span.formfield button.expander { 75 | position: relative; 76 | left: 20px; 77 | width: 20px; 78 | height: 25px; 79 | margin-left: -22px; 80 | padding: 0; 81 | display: inline-block; 82 | font-size: 16px; 83 | } 84 | 85 | #admin-politician ul li div.expandable { 86 | padding: 3px; 87 | margin: 0; 88 | border-width: 0; 89 | } 90 | 91 | #admin-politician ul.suggestions li.suggestion { 92 | padding: 0; 93 | margin: 3px 0 5px 0; 94 | height: 80px; 95 | width: 100%; 96 | border-width: 0; 97 | background: transparent; 98 | } 99 | 100 | #admin-politician ul.suggestions li.suggestion span.name { 101 | vertical-align: top; 102 | font-size: 16px; 103 | text-align: right; 104 | } 105 | 106 | #admin-politician ul.suggestions li.suggestion a.identifier { 107 | vertical-align: bottom; 108 | font-size: 16px; 109 | text-align: right; 110 | white-space: nowrap; 111 | overflow: visible; 112 | } 113 | 114 | #admin-politician ul.suggestions li.suggestion img.photo { 115 | height: 80px; 116 | width: 66px; 117 | margin: 0 6px 0 0; 118 | padding: 0; 119 | border-width: 0; 120 | } 121 | #admin-politician ul.suggestions li.suggestion div.right { 122 | float: right; 123 | width: 340px; 124 | } 125 | 126 | #admin-politician span#name {font-size: 1.2em; font-weight: bold;} 127 | 128 | #admin-politician ul li button { text-indent: 0; border: 1px solid; padding: 5px; } 129 | 130 | #s_input, #suffix_input { width:20px; } 131 | #state { width: 20px ;} 132 | #mn_input, #middle_name_input { width: 30px; } 133 | 134 | #admin-politician #first_name, #admin-politician #middle_name, #admin-politician #last_name, #admin-politician #suffix { float: left; margin-left: 3px;margin-right: 3px} 135 | 136 | #admin-politician #first_name_input, #admin-politician #middle_name_input, #admin-politician #last_name_input, #admin-politician #suffix_input { float: left; margin-left: 3px;margin-right: 3px} 137 | 138 | #admin-list { font-size: 1.2em;} 139 | 140 | #admin-list h1 {width: auto; display: block ; float:none; } 141 | 142 | input { margin: 15px 0; } 143 | 144 | span.error { display: block; } 145 | #admin-politician input { margin: 0} 146 | input.related { width: 250px; } 147 | input.state { width: 30px;} 148 | 149 | table#queue-list { margin-top: 30px; } 150 | table#queue-list tr td:first-child { padding-right: 1em; } 151 | td.numeric { text-align: right; } 152 | 153 | div#annual-report h3 { 154 | margin: 3em 1em 1em 0; 155 | } 156 | div#annual-report ul { 157 | margin: 3em 0 0 0; 158 | } 159 | div#annual-report ol { 160 | margin: 0 0 0 3em; 161 | } 162 | div#party-tweeting table { 163 | border-collapse: collapse; 164 | } 165 | div#party-tweeting table th { 166 | padding: 0.3em; 167 | } 168 | div#party-tweeting table td { 169 | border: 1px solid gray; 170 | text-align: right; 171 | padding: 0.3em; 172 | } 173 | div#party-tweeting table td:first-child { 174 | text-align: left; 175 | } 176 | 177 | 178 | -------------------------------------------------------------------------------- /app/assets/stylesheets/application.css.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, 6 | * or vendor/assets/stylesheets of plugins, if any, can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the top of the 9 | * compiled file, but it's generally better to create a new file per style scope. 10 | * 11 | *= require_self 12 | *= require_tree . 13 | *= stub propublica_base/base 14 | *= stub propublica_base/reset 15 | *= stub propublica_base/propublica-text 16 | *= stub propublica_base/master 17 | *= stub propublica_base/print 18 | *= stub propublica_base/woland 19 | */ -------------------------------------------------------------------------------- /app/assets/stylesheets/propublica_base/README.md: -------------------------------------------------------------------------------- 1 | <%= stylesheet_link_tag "propublica_base/base", :media => "all" %> 2 | <%= stylesheet_link_tag "propublica_base/master", :media => "screen" %> 3 | <%= stylesheet_link_tag "propublica_base/print", :media => "print" %> 4 | <%= stylesheet_link_tag "propublica_base/woland", :media => "screen and (max-width: 480px)" %> 5 | 6 | -------------------------------------------------------------------------------- /app/assets/stylesheets/propublica_base/base.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, 6 | * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the 9 | * compiled file so the styles you add here take precedence over styles defined in any styles 10 | * defined in the other CSS/SCSS files in this directory. It is generally better to create a new 11 | * file per style scope. 12 | * 13 | *= require 'propublica_base/reset' 14 | *= require 'propublica_base/propublica-text' 15 | */ 16 | 17 | -------------------------------------------------------------------------------- /app/assets/stylesheets/propublica_base/propublica-text.css: -------------------------------------------------------------------------------- 1 | body{font:13px/1.23 Helvetica,Arial,sans-serif} 2 | a:focus{outline:1px dotted} 3 | hr{border:0 #ccc solid;border-top-width:1px;clear:both;height:0} 4 | h1,h2,h3,h4,h5,h6,strong{font-weight:bold;} 5 | abbr,acronym {border-bottom:1px dotted #000;cursor:help;} 6 | em{font-style:italic;} 7 | strong{font-weight:bold;} 8 | del{text-decoration:line-through;} 9 | ol{list-style:decimal} 10 | ol li{margin:0 0 5px 15px;} 11 | .article h1{font-size:23px} 12 | .article h2{font-size:19px} 13 | .article h3{font-size:17px} 14 | .article h4{font-size:15px} 15 | .article h5{font-size:13px} 16 | .article h6{font-size:13px} 17 | /*.article h1{font-size:25px} 18 | .article h2{font-size:23px} 19 | .article h3{font-size:21px} 20 | .article h4{font-size:19px} 21 | .article h5{font-size:17px} 22 | .article h6{font-size:15px}*/ 23 | .article > ol{list-style:decimal} 24 | .article > ul{list-style:disc } 25 | .article > li{margin:0 0 5px 15px;} 26 | .article p, 27 | .article dl, 28 | .article hr, 29 | .article h1, 30 | .article h2, 31 | .article h3, 32 | .article h4, 33 | .article h5, 34 | .article h6, 35 | .article ol, 36 | .article ul, 37 | .article pre, 38 | .article table, 39 | .article address, 40 | .article fieldset{margin-bottom:10px} 41 | .article p, 42 | .article blockquote, 43 | .article dl, 44 | .article ol, 45 | .article ul, 46 | .article table, 47 | .article address, 48 | .article fieldset{line-height:1.4;} 49 | 50 | .article-top .article, 51 | .article blockquote, 52 | .content-center .article p, .article-full .article > p, 53 | .content-center .article dl, .article-full .article > dl, 54 | .content-center .article ol, .article-full .article > ol, 55 | .content-center .article ul, .article-full .article > ul, 56 | .content-center .article table, .article-full .article > table, 57 | .content-center .article address, .article-full .article > address, 58 | .content-center .article fieldset, .article-full .article > fieldset{font-size:16px;} 59 | 60 | .article blockquote { margin:15px;padding: 0 1.5em; } 61 | -------------------------------------------------------------------------------- /app/assets/stylesheets/propublica_base/reset.css: -------------------------------------------------------------------------------- 1 | html,body,div,span,applet,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abbr,acronym,address,big,cite,code,del,dfn,em,font,img,ins,kbd,q,s,samp,small,strike,strong,sub,sup,tt,var,b,u,i,center,dl,dt,dd,ol,ul,li,fieldset,form,label,legend,table,caption,tbody,tfoot,thead,tr,th,td{margin:0;padding:0;border:0;outline:0;font-size:100%;vertical-align:baseline;background:transparent}body{line-height:1}ol,ul{list-style:none}blockquote,q{quotes:none}blockquote:before,blockquote:after,q:before,q:after{content:'';content:none}:focus{outline:0}ins{text-decoration:none}del{text-decoration:line-through}table{border-collapse:collapse;border-spacing:0} 2 | -------------------------------------------------------------------------------- /app/assets/stylesheets/search.css.scss: -------------------------------------------------------------------------------- 1 | .search-box { 2 | width: 100%; 3 | padding: 10px 20px; 4 | background: #e9f0f8; 5 | -moz-box-sizing: border-box; 6 | -webkit-box-sizing: border-box; 7 | box-sizing: border-box; 8 | display: inline-block; 9 | margin-bottom: 10px; 10 | // border-top: 2px solid #CCC; 11 | 12 | h3 { 13 | font-family: "Sentinel A", "Sentinel B", Georgia, serif; 14 | line-height: 1.4em; 15 | font-size: 1.5em; 16 | padding: 0 0 8px; 17 | } 18 | 19 | .examples { 20 | font-family: 'Atlas Grotesk Web', Helvetica, sans-serif; 21 | margin: 10px 0 0; 22 | font-size: 11px; 23 | } 24 | } 25 | 26 | .home-search .search-box { 27 | border-top: 2px solid #ddd; 28 | margin-top: 20px; 29 | } 30 | 31 | .form-inline li { 32 | display: block; 33 | float: left; 34 | width: 220px; 35 | margin-right: 20px; 36 | } 37 | 38 | form#filter{ 39 | margin-bottom: 26px; 40 | } 41 | -------------------------------------------------------------------------------- /app/controllers/admin/admin_controller.rb: -------------------------------------------------------------------------------- 1 | class Admin::AdminController < ApplicationController 2 | layout "admin" 3 | 4 | before_filter :admin_only 5 | 6 | protected 7 | 8 | def admin_only 9 | request_url = URI.parse(request.url) 10 | status_json_path = url_for(:action => 'status', 11 | :controller => 'system', 12 | :format => 'json', 13 | :only_path => true) 14 | status_json = request_url.path == status_json_path 15 | 16 | ips = (request.env['HTTP_X_FORWARDED_FOR'] or request.remote_ip).split(',') 17 | ips = Set::new ips 18 | monitoring_ips = Set::new Settings[:monitoring_hosts] 19 | from_monitoring_host = (ips.intersection(monitoring_ips).size > 0) 20 | monitoring_request = (status_json and from_monitoring_host) 21 | 22 | unless (params[:format] == "rss" or monitoring_request) 23 | authenticate_or_request_with_http_basic do |username, password| 24 | username == Settings[:admin][:username] and password == Settings[:admin][:password] 25 | end 26 | end 27 | end 28 | 29 | helper_method :latest_tweet 30 | def latest_tweet 31 | @latest_tweet ||= Tweet.in_order.first 32 | end 33 | 34 | helper_method :latest_deleted_tweet 35 | def latest_deleted_tweet 36 | @latest_deleted_tweet ||= DeletedTweet.in_order.first 37 | end 38 | 39 | helper_method :current_admin_rss 40 | def current_admin_rss 41 | "#{request.url}/#{Settings[:rss_secret]}.rss" 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /app/controllers/admin/offices_controller.rb: -------------------------------------------------------------------------------- 1 | class Admin::OfficesController < Admin::AdminController 2 | 3 | def list 4 | @offices = Office.all 5 | respond_to do |format| 6 | format.html {render} 7 | end 8 | end 9 | def add 10 | respond_to do |format| 11 | format.html {render} 12 | end 13 | 14 | end 15 | 16 | def save 17 | if params[:obj_id] then 18 | office = Office.find(params[:obj_id].to_i) 19 | office.title = params[:title] 20 | office.abbreviation = params[:abbreviation] 21 | else 22 | office = Office.new(:title => params[:title], :abbreviation => params[:abbreviation]) 23 | end 24 | office.save() 25 | redirect_to "/admin/offices/" 26 | 27 | end 28 | 29 | def edit 30 | @office = Office.find(params[:id]) 31 | respond_to do |format| 32 | format.html {render} 33 | end 34 | end 35 | 36 | end 37 | 38 | 39 | -------------------------------------------------------------------------------- /app/controllers/admin/politicians_controller.rb: -------------------------------------------------------------------------------- 1 | class Admin::PoliticiansController < Admin::AdminController 2 | def admin_list 3 | @politicians = Politician.all 4 | respond_to do |format| 5 | format.html { render } 6 | end 7 | end 8 | 9 | def admin_user 10 | begin 11 | @politician = Politician.find(params[:id]) 12 | rescue ActiveRecord::RecordNotFound 13 | @politician = Politician.where(:user_name => params[:id]).first 14 | if @politician.nil? 15 | raise('not found') 16 | end 17 | end 18 | 19 | @parties = Party.all 20 | @offices = Office.all 21 | @account_types = AccountType.all 22 | @related = @politician.get_related_politicians().sort_by(&:user_name) 23 | 24 | @unmoderated = DeletedTweet.where(:reviewed=>false, :politician_id => @politician).length 25 | 26 | respond_to do |format| 27 | format.html { render } 28 | end 29 | end 30 | 31 | def new_user 32 | @parties = Party.all 33 | @offices = Office.all 34 | @account_types = AccountType.all 35 | 36 | respond_to do |format| 37 | format.html { render } 38 | end 39 | end 40 | 41 | def get_twitter_id 42 | require 'twitter' 43 | t = Twitter.user(params[:screen_name]) 44 | @twitter_id = t.id 45 | respond_to do |format| 46 | format.json { render } 47 | end 48 | end 49 | 50 | def save_user 51 | if params[:user_name].to_s == '' 52 | flash[:error] = "You must specify a twitter username." 53 | return redirect_to :back 54 | elsif params[:twitter_id].to_s == '' 55 | flash[:error] = "Could not find the numeric twitter ID for twitter user #{params[:user_name]}" 56 | return redirect_to :back 57 | end 58 | 59 | if params[:id] == '0' then 60 | existing = Politician.where(:user_name => params[:user_name]) 61 | if existing.count == 0 62 | #it's a new add 63 | pol = Politician.create(:twitter_id => params[:twitter_id], 64 | :user_name => params[:user_name]) 65 | else 66 | flash[:error] = "We already track @#{params[:user_name]}" 67 | pol = nil 68 | end 69 | else 70 | pol = Politician.find(params[:id]) || raise("not found") 71 | pol.user_name = params[:user_name] 72 | end 73 | 74 | if not pol.nil? 75 | pol.party = Party.where(:id => params[:party_id]).first 76 | pol.status = params[:status] 77 | if params[:account_type_id] == '0' then 78 | pol.account_type = nil 79 | else 80 | pol.account_type = AccountType.where(:id => params[:account_type_id]).first 81 | end 82 | if params[:office_id] == '0' then 83 | pol.office = nil 84 | else 85 | pol.office = Office.where(:id => params[:office_id]).first 86 | end 87 | 88 | pol.update_attributes(params) 89 | 90 | pol.save! 91 | pol.reset_avatar 92 | end 93 | 94 | if params[:unapprove_all] and params[:unapprove_all] == 'on' then 95 | unmod = DeletedTweet.where(:reviewed=>false, :politician_id => pol) 96 | unmod.each do |utweet| 97 | utweet.approved = 0 98 | utweet.review_message = "Bulk unapproved in admin" 99 | utweet.reviewed = 1 100 | utweet.reviewed_at = Time.now 101 | utweet.save() 102 | 103 | end 104 | end 105 | 106 | if params[:related] then 107 | requested_names = Set.new(params[:related].split(',') 108 | .map(&:strip) 109 | .reject{ |name| name == '' }) 110 | existing_names = Set.new(pol.get_related_politicians.map(&:user_name)) 111 | pol.remove_related_politicians (existing_names - requested_names) 112 | pol.add_related_politicians (requested_names - existing_names) 113 | end 114 | 115 | redirect_to :back 116 | end 117 | 118 | end 119 | -------------------------------------------------------------------------------- /app/controllers/admin/reports_controller.rb: -------------------------------------------------------------------------------- 1 | class Admin::ReportsController < Admin::AdminController 2 | layout "admin" 3 | 4 | before_filter :admin_only 5 | 6 | def annual 7 | earliest_tweet_timestamp = DeletedTweet.minimum(:created) 8 | if earliest_tweet_timestamp.nil? 9 | return not_found 10 | end 11 | @year = (params[:year] && params[:year].to_i) || Date.today.year 12 | if earliest_tweet_timestamp.year > @year 13 | return not_found 14 | end 15 | 16 | @tweet_tally = Tweet.joins(:politician).in_year(@year).count 17 | @delete_tally = DeletedTweet.joins(:politician).in_year(@year).count 18 | @approval_tally = DeletedTweet.joins(:politician).in_year(@year).where(:approved => true).count 19 | @approval_pct = (@delete_tally.zero? && 0) || (@approval_tally * 100 / @delete_tally) 20 | @observed_account_tally = Tweet.joins(:politician).in_year(@year).group(:politician_id).order(nil).count.length 21 | 22 | @tweets_per_account = Tweet.joins(:politician).in_year(@year).group(:politician_id).count 23 | @deletes_per_account = DeletedTweet.joins(:politician).in_year(@year).group(:politician_id).count 24 | @twoops_per_account = DeletedTweet.joins(:politician).in_year(@year).where(:approved => true).group(:politician_id).count 25 | @tweeting_account_tally = @tweets_per_account.length 26 | @deleting_account_tally = @deletes_per_account.length 27 | @deleting_account_pct = (@observed_account_tally.zero? && 0) || (@deleting_account_tally * 100 / @observed_account_tally) 28 | @twooping_account_tally = @twoops_per_account.length 29 | 30 | @top_tweeters = @tweets_per_account.sort_by(&:second).last(5).reverse.map{ |pol_id, cnt| [Politician.find(pol_id), cnt] } 31 | @top_deleters = @deletes_per_account.sort_by(&:second).last(5).reverse.map{ |pol_id, cnt| [Politician.find(pol_id), cnt] } 32 | @top_twoopers = @twoops_per_account.sort_by(&:second).last(5).reverse.map{ |pol_id, cnt| [Politician.find(pol_id), cnt] } 33 | 34 | @top_twoops_rates = @deletes_per_account.select{ |pol_id, deletes| deletes > 5 }.map{ |pol_id, deletes| [pol_id, deletes.to_f * 100 / @tweets_per_account[pol_id].to_f] }.sort_by(&:second).last(10) 35 | @top_twoops_rates = @twoops_per_account.select{ |pol_id, deletes| deletes > 5 }.map{ |pol_id, twoops| [pol_id, twoops.to_f * 100 / @tweets_per_account[pol_id].to_f] }.sort_by(&:second).last(10) 36 | @top_twoopsters = @top_twoops_rates.reverse.map{ |pol_id, pct| [Politician.find(pol_id), pct.round] } 37 | 38 | @tweets_per_party = Tweet.joins(:politician).in_year(@year).group(:party_id).count 39 | @deletes_per_party = DeletedTweet.joins(:politician).in_year(@year).group(:party_id).count 40 | @twoops_per_party = DeletedTweet.joins(:politician).in_year(@year).where(:approved => true).group(:party_id).count 41 | @parties = Party.find(@tweets_per_party.keys) 42 | 43 | @tweeting_accounts_per_party = Tweet.joins(:politician).in_year(@year).group(:party_id).count(:politician_id, :distinct => true) 44 | @deleting_accounts_per_party = DeletedTweet.joins(:politician).in_year(@year).group(:party_id).count(:politician_id, :distinct => true) 45 | @twooping_accounts_per_party = DeletedTweet.joins(:politician).in_year(@year).where(:approved => true).group(:party_id).count(:politician_id, :distinct => true) 46 | end 47 | 48 | end 49 | -------------------------------------------------------------------------------- /app/controllers/admin/system_controller.rb: -------------------------------------------------------------------------------- 1 | class Admin::SystemController < Admin::AdminController 2 | include ApplicationHelper 3 | 4 | def status 5 | # Checks heartbeat files found in the configuration. 6 | # If the file exists and the timestamp is current, the worker 7 | # is running and can be restarted via the web interface. 8 | # If the file is missing (the worker failed to restart) or the 9 | # file exists but the timestamp is old (the process exited abnormally) 10 | # then the process will have to be restarted by the admin. 11 | 12 | expected_heartbeats = Settings[:heartbeats_expected] 13 | @worker_statuses = expected_heartbeats.map do |w| 14 | path = File.join(Settings[:heartbeats_directory], w) 15 | exists = File.exists? path 16 | traceback = nil 17 | started = nil 18 | if exists 19 | begin 20 | heartbeat_contents = File.new(path).read() 21 | meta = JSON.parse(heartbeat_contents) 22 | started = Time.parse(meta.fetch('started')) 23 | rescue JSON::ParserError => e 24 | traceback = heartbeat_contents 25 | end 26 | 27 | mtime = File.mtime(path) 28 | ago = (Time.now - mtime).floor 29 | if ago < 0 30 | status = 'restarting' 31 | elsif ago <= (Settings[:heartbeat_interval] * 1.10) # Allow 10% error 32 | status = 'running' 33 | else 34 | status = 'dead' 35 | end 36 | else 37 | status = 'dead' 38 | end 39 | { 40 | :worker => w, 41 | :status => status, 42 | :last_seen => ago.nil? ? nil : mtime.strftime("%Y-%m-%dT%H:%M:%S%z"), 43 | :started => started.nil? ? nil : started.strftime("%Y-%m-%dT%H:%M:%S%z"), 44 | :uptime => (status == 'running') ? duration_abbrev(mtime - started).to_s : nil, 45 | :traceback => started.nil? ? traceback : nil 46 | } 47 | end 48 | 49 | @last_tweet = Tweet.with_content.order("modified DESC").first 50 | 51 | @queue_stats = [] 52 | queues = Settings.fetch(:beanstalk_queues, nil).values 53 | if queues 54 | beanstalk = Beanstalk::Pool.new(['localhost:11300']) 55 | tubes = beanstalk.list_tubes.map {|k,v| v} .flatten 56 | @queue_stats = queues.map {|q| [ q, (q.in? tubes or nil and beanstalk.stats_tube(q)) ] } 57 | end 58 | 59 | respond_to do |format| 60 | format.html { render :template => "admin/system/status" } 61 | format.json { 62 | last_tweet_fmt = @last_tweet ? @last_tweet.format : nil 63 | render :json => { :workers => @worker_statuses, :last_tweet => last_tweet_fmt, :queue_stats => @queue_stats } 64 | } 65 | end 66 | end 67 | 68 | def restart 69 | if params[:worker].empty? 70 | flash[:error] = "No such worker!" 71 | return redirect_to :action => "status" 72 | end 73 | 74 | expected_heartbeats = Settings[:heartbeats_expected] 75 | if not expected_heartbeats.include? params[:worker] 76 | flash[:error] = "No such worker!" 77 | return redirect_to :action => "status" 78 | end 79 | 80 | path = File.join(Settings[:heartbeats_directory], params[:worker]) 81 | if File.exists? path 82 | future = Time.now + Settings[:heartbeat_interval] * 2 83 | File.utime future, future, path 84 | flash[:error] = "Worker restarting" 85 | return redirect_to :action => "status", :alert => "Worker restarting." 86 | else 87 | flash[:error] = "Worker is dead and it can't be restarted." 88 | return redirect_to :action => "status" 89 | end 90 | end 91 | 92 | def report 93 | if params[:worker].empty? 94 | flash[:error] = "Worker restarting" 95 | return redirect_to :action => "status" 96 | end 97 | 98 | flash[:error] = "Problem reported, thanks." 99 | return redirect_to :action => "status" 100 | end 101 | end 102 | 103 | -------------------------------------------------------------------------------- /app/controllers/admin/tweets_controller.rb: -------------------------------------------------------------------------------- 1 | class Admin::TweetsController < Admin::AdminController 2 | before_filter :load_tweet, :only => [:review, :message] 3 | 4 | # list either unreviewed 5 | def index 6 | @page = [params[:page].to_i, 1].max 7 | @politicians = Politician.active.all 8 | 9 | @tweets = DeletedTweet.in_order.where(:politician_id => @politicians) 10 | 11 | # filter to relevant subset of deleted tweets 12 | @tweets = @tweets.where :reviewed => params[:reviewed], :approved => params[:approved] 13 | 14 | # show unreviewed tweets oldest to newest 15 | if !params[:reviewed] 16 | @tweets = @tweets.reorder "modified ASC" 17 | end 18 | 19 | per_page = params[:per_page] ? params[:per_page].to_i : nil 20 | per_page ||= Tweet.per_page 21 | per_page = 200 if per_page > 200 22 | 23 | @tweets = @tweets.includes(:politician => [:party]).paginate(:page => @page, :per_page => per_page) 24 | @admin = true 25 | 26 | respond_to do |format| 27 | format.html # admin/tweets/index.html.erb 28 | format.rss do 29 | response.headers["Content-Type"] = "application/rss+xml; charset=utf-8" 30 | render "tweets/index" 31 | end 32 | end 33 | 34 | end 35 | 36 | 37 | # approve or unapprove a tweet, mark it as reviewed either way 38 | def review 39 | 40 | review_message = (params[:review_message] || "").strip 41 | 42 | if ["Approve", "Unapprove"].include?(params[:commit]) 43 | approved = (params[:commit] == "Approve") 44 | 45 | if !@tweet.reviewed? and approved and review_message.blank? 46 | flash[@tweet.id] = "You need to add a note about why you're approving this tweet." 47 | redirect_to params[:return_to] 48 | return false 49 | end 50 | 51 | @tweet.approved = approved 52 | @tweet.reviewed = true 53 | @tweet.reviewed_at = Time.now 54 | end 55 | 56 | if not review_message.blank? 57 | @tweet.review_message = review_message 58 | end 59 | 60 | @tweet.save! 61 | expire_action :controller => '/tweets', :action => :index 62 | 63 | redirect_to params[:return_to] 64 | end 65 | 66 | 67 | # filters 68 | 69 | def load_tweet 70 | unless params[:id] and (@tweet = DeletedTweet.find(params[:id])) 71 | render :nothing => true, :status => :not_found 72 | return false 73 | end 74 | end 75 | 76 | end 77 | -------------------------------------------------------------------------------- /app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | class ApplicationController < ActionController::Base 3 | protect_from_forgery 4 | 5 | # let tweet helper methods be available in the controller 6 | helper TweetsHelper 7 | 8 | before_filter :donor_banner 9 | 10 | rescue_from ActiveRecord::RecordNotFound, :with => :file_not_found 11 | 12 | def file_not_found 13 | respond_to do |format| 14 | format.html { render :file => "public/404.html", :status => 404} 15 | end 16 | end 17 | 18 | def donor_banner 19 | @donor_banner_enabled = Settings.fetch(:enable_donor_banner, false) 20 | end 21 | 22 | # needs to become more dynamic somehow 23 | def set_locale 24 | # not sure what this does 25 | I18n::Backend::Simple.send(:include, I18n::Backend::Flatten) 26 | I18n.locale = "en" 27 | end 28 | 29 | def enable_filter_form 30 | @states = Politician.where("state IS NOT NULL").pluck(:state).uniq 31 | @states = @states.sort 32 | 33 | @parties = Party.all 34 | @offices = Office.all 35 | 36 | @politicians = Politician.active 37 | 38 | #check for filters 39 | @filters = {'state' => nil, 'party' => nil, 'office' => nil } 40 | unless params.fetch('state', '').empty? 41 | @politicians = @politicians.where(:state => params[:state]) 42 | @filters['state'] = params[:state] 43 | end 44 | unless params.fetch('party', '').empty? 45 | party = Party.where(:name => params[:party])[0] 46 | @politicians = @politicians.where(:party_id => party) 47 | @filters['party'] = party.name 48 | end 49 | unless params.fetch('office', '').empty? 50 | @politicians = @politicians.where(:office_id => params[:office]) 51 | @filters['office'] = params[:office] 52 | end 53 | end 54 | 55 | end 56 | -------------------------------------------------------------------------------- /app/controllers/errors_controller.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | class ErrorsController < ApplicationController 3 | include ApplicationHelper 4 | 5 | def not_found 6 | render "errors/not_found", :status => 404 7 | end 8 | 9 | def down 10 | # We don't generate a 500 here because this is simply for regenerating a 11 | # static error page. 12 | render "errors/status_5xx" 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/controllers/parties_controller.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | class PartiesController < ApplicationController 3 | 4 | include ApplicationHelper 5 | before_filter :enable_filter_form 6 | 7 | def show 8 | @per_page_options = [20, 50] 9 | @per_page = closest_value((params.fetch :per_page, 0).to_i, @per_page_options) 10 | @page = [params[:page].to_i, 1].max 11 | 12 | @politicians = Party.where(:name => params[:name]).first.politicians.active.map {|politician| politician.id} 13 | @tweets = DeletedTweet.includes(:politician => [:party]).where(:politician_id => @politicians, :approved => true).paginate(:page => params[:page], :per_page => Tweet.per_page) 14 | render "tweets/index" 15 | end 16 | 17 | end 18 | -------------------------------------------------------------------------------- /app/controllers/politicians_controller.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | class PoliticiansController < ApplicationController 3 | 4 | include ApplicationHelper 5 | 6 | caches_action :show, expires_in: 5.minutes, if: proc { (params.keys - ['format', 'action', 'controller']).empty? } 7 | 8 | def show 9 | @per_page_options = [20, 50] 10 | @per_page = closest_value((params.fetch :per_page, 0).to_i, @per_page_options) 11 | @page = [params[:page].to_i, 1].max 12 | 13 | @politician = Politician.active.where(user_name: params[:user_name]).first 14 | raise ActiveRecord::RecordNotFound unless @politician 15 | 16 | # need to get the latest tweet to get correct bio. could do with optimization :) 17 | @latest_tweet = Tweet.in_order.where(politician_id: @politician.id).first 18 | 19 | @related = @politician.get_related_politicians().to_a.sort_by(&:user_name) 20 | @accounts = [@politician] + @related 21 | 22 | @tweet_map = {} 23 | @accounts.each do |ac| 24 | @tweet_map[ac.user_name] = ac.twoops.in_order.includes(:tweet_images).paginate(page: @page, per_page: @per_page) 25 | end 26 | 27 | if @tweet_map.size == 1 28 | @tweet_map['all'] = @tweets = @tweet_map.values.first 29 | else 30 | @tweet_map['all'] = @tweets = DeletedTweet.in_order.includes(:tweet_images).where(politician_id: @accounts.map(&:id), approved: true).paginate(page: @page, per_page: @per_page) 31 | end 32 | 33 | respond_to do |format| 34 | format.html { render } # politicians/show 35 | format.rss do 36 | response.headers["Content-Type"] = "application/rss+xml; charset=utf-8" 37 | render "tweets/index" 38 | end 39 | end 40 | end 41 | 42 | before_filter :enable_filter_form 43 | def all 44 | 45 | @per_page_options = [20, 50] 46 | @per_page = closest_value((params.fetch :per_page, 0).to_i, @per_page_options) 47 | @page = [params[:page].to_i, 1].max 48 | 49 | @filter_action = "/users" 50 | 51 | respond_to do |format| 52 | format.html { 53 | #get all politicians that we're showing 54 | @politicians = @politicians.order('last_name').where(:status => [1, 4]) 55 | @politicians = @politicians.paginate(:page => params[:page], :per_page => @per_page) 56 | render 57 | } 58 | format.csv { 59 | @politicians = @politicians.order('last_name') 60 | render :csv => @politicians, :filename => 'users' 61 | } 62 | end 63 | end 64 | 65 | end 66 | -------------------------------------------------------------------------------- /app/controllers/tweets_controller.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | class TweetsController < ApplicationController 3 | # GET /tweets 4 | # GET /tweets.xml 5 | 6 | require 'RMagick' 7 | include ApplicationHelper 8 | 9 | caches_action :index, 10 | :expires_in => 30.minutes, 11 | :if => proc { (params.keys - ['format', 'action', 'controller']).empty? } 12 | 13 | caches_action :thumbnail 14 | 15 | before_filter :enable_filter_form 16 | 17 | def index 18 | @filter_action = "/" 19 | 20 | if params.has_key?(:see) && params[:see] == :all 21 | @tweets = Tweet.in_order 22 | else 23 | @tweets = DeletedTweet.in_order 24 | end 25 | 26 | @tweets = @tweets.where(:politician_id => @politicians) 27 | tweet_count = 0 #@tweets.count 28 | 29 | if params.has_key?(:q) and params[:q].present? 30 | # Rails prevents injection attacks by escaping things passed in with ? 31 | @query = params[:q] 32 | query = "%#{@query}%" 33 | @search_pols = Politician.where("MATCH(user_name, first_name, middle_name, last_name) AGAINST (?)", query) 34 | @tweets = @tweets.where("content like ? or deleted_tweets.user_name like ? or politician_id in (?)", query, query, @search_pols) 35 | 36 | end 37 | 38 | # only approved tweets 39 | @tweets = @tweets.where(:approved => true) 40 | 41 | @per_page_options = [20, 50] 42 | @per_page = closest_value((params.fetch :per_page, 0).to_i, @per_page_options) 43 | @page = [params[:page].to_i, 1].max 44 | 45 | @tweets = @tweets.includes(:tweet_images, :politician => [:party]).paginate(:page => params[:page], :per_page => @per_page) 46 | 47 | respond_to do |format| 48 | format.html # index.html.erb 49 | format.rss do 50 | response.headers["Content-Type"] = "application/rss+xml; charset=utf-8" 51 | render 52 | end 53 | format.json { render :json => {:meta => {:count => tweet_count}, :tweets => @tweets.map{|tweet| tweet.format } } } 54 | end 55 | end 56 | 57 | # GET /tweets/1 58 | # GET /tweets/1.xml 59 | def show 60 | @tweet = DeletedTweet.includes(:politician).find(params[:id]) 61 | 62 | if (@tweet.politician.status != 1 and @tweet.politician.status != 4) or not @tweet.approved 63 | not_found 64 | end 65 | 66 | respond_to do |format| 67 | format.html # show.html.erb 68 | format.xml { render :xml => @tweet } 69 | format.json { render :json => @tweet.format } 70 | end 71 | end 72 | 73 | def thumbnail 74 | tweet = Tweet.find(params[:tweet_id]) 75 | if not tweet 76 | not_found 77 | end 78 | 79 | images = tweet.tweet_images.all 80 | if not images 81 | not_found 82 | end 83 | 84 | filename = "#{params[:basename]}.#{params[:format]}" 85 | image = images.select do |img| 86 | img.filename == filename 87 | end .first 88 | 89 | if not image 90 | not_found 91 | end 92 | 93 | resp = HTTParty.get(image.url) 94 | img = Magick::Image.from_blob(resp.body) 95 | layer0 = img[0] 96 | aspect_ratio = layer0.columns.to_f / layer0.rows.to_f 97 | if aspect_ratio > 1.0 98 | new_width = 150 99 | new_height = layer0.rows.to_f / aspect_ratio 100 | else 101 | new_width = layer0.columns.to_f / aspect_ratio 102 | new_height = 150 103 | end 104 | thumb = layer0.resize_to_fit(new_width, new_height) 105 | send_data(thumb.to_blob, 106 | :disposition => 'inline', 107 | :type => resp.headers.fetch('content-type', 'application/octet-stream'), 108 | :filename => filename) 109 | end 110 | 111 | end 112 | -------------------------------------------------------------------------------- /app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | module ApplicationHelper 3 | 4 | def light_format(string) 5 | return "" unless string.present? 6 | string = simple_format h(string) 7 | auto_link string 8 | end 9 | 10 | def url_with_params(style, h) 11 | url = URI.parse(request.url) 12 | unless h.empty? 13 | params = Rack::Utils.parse_query(url.query) 14 | params.update h 15 | url.query = params.to_param 16 | end 17 | case style 18 | when :URI 19 | return url.path + '?' + url.query 20 | when :URL 21 | return url.to_s 22 | else 23 | return url.to_s 24 | end 25 | end 26 | 27 | def bound_value (value, range) 28 | return range.first if value < range.first 29 | return range.last if value > range.last 30 | value 31 | end 32 | 33 | def closest_value value, list 34 | return list.min { |a,b| (a-value).abs <=> (b-value).abs } 35 | end 36 | 37 | MINUTE = 60 38 | HOUR = MINUTE * 60 39 | DAY = HOUR * 24 40 | WEEK = DAY * 7 41 | def duration_abbrev(seconds) 42 | 43 | (weeks, seconds) = seconds.divmod WEEK 44 | (days, seconds) = seconds.divmod DAY 45 | (hours, seconds) = seconds.divmod HOUR 46 | (minutes, seconds) = seconds.divmod MINUTE 47 | seconds = seconds.floor 48 | 49 | clauses = [ 50 | ("#{weeks}w" if weeks > 0), 51 | ("#{days}d" if days > 0), 52 | ("#{hours}h" if hours > 0), 53 | ("#{minutes}m" if minutes > 0), 54 | ("#{seconds}s" if seconds > 0) 55 | ] 56 | clauses.join('') 57 | end 58 | 59 | def relative_time(start_time) 60 | diff_seconds = Time.now - start_time 61 | case diff_seconds 62 | when 0 .. 59 63 | "#{diff_seconds} seconds ago" 64 | when 60 .. (3600-1) 65 | minutes = (diff_seconds/60).round 66 | "#{minutes} minutes ago" 67 | when 3600 .. (3600*24-1) 68 | hours = (diff_seconds/3600).round 69 | "#{hours} hours ago" 70 | when (3600*24) .. (3600*24*30) 71 | days = (diff_seconds/(3600*24)).round 72 | "#{days} days ago" 73 | else 74 | start_time.strftime("%m/%d/%Y") 75 | end 76 | end 77 | 78 | end 79 | 80 | -------------------------------------------------------------------------------- /app/helpers/tweets_helper.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | module TweetsHelper 3 | def default_avatar_url (pol) 4 | if pol.female? 5 | "/images/avatar_missing_female.png" 6 | else 7 | "/images/avatar_missing_male.png" 8 | end 9 | end 10 | 11 | def office_title_for (pol) 12 | pol.office.nil? ? '' : pol.office.title 13 | end 14 | 15 | def office_abbr_for (pol) 16 | pol.office.nil? ? '' : pol.office.abbreviation 17 | end 18 | 19 | def party_name_for (pol) 20 | pol.party.nil? ? '' : pol.party.name.upcase 21 | end 22 | 23 | def format_user_name(tweet_content) 24 | tweet_content.gsub(/(@(\w+))/, %Q{\\1}) 25 | end 26 | 27 | def format_hashtag(tweet_content) 28 | tweet_content.gsub(/(#(\w+))/, %Q{\\1}) 29 | end 30 | 31 | def format_retweet_prefix (content, user_name) 32 | "RT @#{user_name}: #{content}" 33 | end 34 | 35 | def format_tweet(tweet) 36 | if tweet.retweeted_id.nil? 37 | content = tweet.content 38 | else 39 | content = format_retweet_prefix(tweet.retweeted_content, 40 | tweet.retweeted_user_name) 41 | end 42 | content = format_hashtag(content) 43 | content = format_user_name(content) 44 | content = auto_link(content, :html => { :target => '_blank' }) 45 | end 46 | 47 | def twitter_url (tweet_user_name, tweet_id) 48 | "http://www.twitter.com/#{tweet_user_name}/status/#{tweet_id}" 49 | end 50 | 51 | def byline(tweet, html = true) 52 | if (Time.now - tweet.modified).to_i > (60 * 60 * 24 * 365) 53 | tweet_time = tweet.modified.strftime("%l:%M %p") 54 | tweet_date = tweet.modified.strftime("%d %b %y") # 03 Jun 12 55 | tweet_when = "at #{tweet_time} on #{tweet_date}" 56 | elsif (Time.now - tweet.modified).to_i > (60 * 60 * 24) 57 | tweet_time = tweet.modified.strftime("%l:%M %p") 58 | tweet_date = tweet.modified.strftime("%d %b") # 03 Jun 59 | tweet_when = "at #{tweet_time} on #{tweet_date}" 60 | else 61 | since_tweet = time_ago_in_words tweet.modified 62 | tweet_when = "#{since_tweet} ago" 63 | end 64 | delete_delay = (tweet.modified - tweet.created).to_i 65 | 66 | delay = if delete_delay > (60 * 60 * 24 * 7) 67 | "after #{pluralize(delete_delay / (60 * 60 * 24 * 7), "week")}" 68 | elsif delete_delay > (60 * 60 * 24) 69 | "after #{pluralize(delete_delay / (60 * 60 * 24), "day")}" 70 | elsif delete_delay > (60 * 60) 71 | "after #{pluralize(delete_delay / (60 * 60), "hour")}" 72 | elsif delete_delay > 60 73 | "after #{pluralize(delete_delay / 60, "minute")}" 74 | elsif delete_delay > 1 75 | "after #{pluralize delete_delay, "second"}" 76 | else 77 | "immediately" 78 | end 79 | 80 | if tweet.retweeted_id.nil? 81 | rt_text = "" 82 | else 83 | orig_url = twitter_url(tweet.retweeted_user_name, tweet.retweeted_id) 84 | rt_text = "Original tweet by @#{tweet.retweeted_user_name}." 85 | end 86 | 87 | if html 88 | source = tweet.details["source"].to_s.html_safe 89 | byline = "#{tweet.details['user']['name']}".html_safe 90 | byline += t(:byline, 91 | :scope => [:politwoops, :tweets], 92 | :retweet => rt_text, 93 | :when => tweet_when, 94 | :what => source, 95 | :delay => delay).html_safe 96 | byline 97 | else 98 | t :byline_text, :scope => [:politwoops, :tweets], :when => tweet_when, :delay => delay 99 | end 100 | end 101 | 102 | def rss_date(time) 103 | time.strftime "%a, %d %b %Y %H:%M:%S %z" 104 | end 105 | 106 | end 107 | -------------------------------------------------------------------------------- /app/models/account_link.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | class AccountLink < ActiveRecord::Base 3 | 4 | belongs_to :link, :class_name => "Politician" 5 | 6 | end 7 | -------------------------------------------------------------------------------- /app/models/account_type.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | class AccountType < ActiveRecord::Base 3 | has_many :politicians 4 | end 5 | -------------------------------------------------------------------------------- /app/models/deleted_tweet.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | class DeletedTweet < Tweet 3 | self.table_name="deleted_tweets" 4 | end 5 | -------------------------------------------------------------------------------- /app/models/office.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | class Office < ActiveRecord::Base 3 | has_many :politicians 4 | default_scope { order("title")} 5 | end 6 | -------------------------------------------------------------------------------- /app/models/party.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | class Party < ActiveRecord::Base 3 | has_many :politicians 4 | 5 | def tweets 6 | Tweet.joins(:politician).where(:politicians => { :party_id => self.id }) 7 | end 8 | 9 | def deleted_tweets 10 | DeletedTweet.joins(:politician).where(:politicians => { :party_id => self.id }) 11 | end 12 | 13 | def twoops 14 | DeletedTweet.joins(:politician).where(:approved => true, :politicians => { :party_id => self.id }) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /app/models/politician.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require "open-uri" 3 | require 'gender_detector' 4 | 5 | class Politician < ActiveRecord::Base 6 | CollectingAndShowing = 1 7 | CollectingNotShowing = 2 8 | NotCollectingOrShowing = 3 9 | NotCollectingButShowing = 4 10 | 11 | has_attached_file :avatar, { :path => ':base_path/avatars/:filename', 12 | :url => "/images/avatars/:filename", 13 | :default_url => '' } 14 | 15 | belongs_to :party 16 | 17 | belongs_to :office 18 | 19 | belongs_to :account_type 20 | 21 | has_many :tweets 22 | has_many :deleted_tweets 23 | 24 | has_many :account_links 25 | has_many :links, :through => :account_links 26 | 27 | #default_scope :order => 'user_name' 28 | 29 | scope :active, -> { where status: [1, 4]} 30 | scope :collecting, -> { where status: [CollectingAndShowing, CollectingNotShowing] } 31 | scope :showing, -> { where status: [CollectingAndShowing, NotCollectingButShowing] } 32 | 33 | validates_uniqueness_of :user_name, :case_sensitive => false 34 | 35 | comma do 36 | user_name 'user_name' 37 | twitter_id 'twitter_id' 38 | party :display_name => 'party_name' 39 | state 'state' 40 | office :title => 'office_title' 41 | account_type :name => 'account_type' 42 | first_name 'first_name' 43 | middle_name 'middle_name' 44 | last_name 'last_name' 45 | suffix 'suffix' 46 | status 'status' 47 | collecting? 'collecting' 48 | showing? 'showing' 49 | bioguide_id 'bioguide_id' 50 | opencivicdata_id 'opencivicdata_id' 51 | end 52 | 53 | def collecting? 54 | [CollectingAndShowing, CollectingNotShowing].include?(status) 55 | end 56 | 57 | def showing? 58 | [CollectingAndShowing, NotCollectingButShowing].include?(status) 59 | end 60 | 61 | def self.guess_gender(name) 62 | # Each SexMachine::Detector instance loads it's own copy of the data file. 63 | # Let's avoid going memory crazy. 64 | @_sexmachine__detector ||= GenderDetector.new 65 | @_sexmachine__detector.get_gender(name) 66 | end 67 | 68 | def guess_gender! 69 | gender_value = Politician.guess_gender(first_name) 70 | gender_map = { :male => 'M', :female => 'F' } 71 | gender_value = gender_map[gender_value] 72 | if gender_value 73 | self.gender = gender_value 74 | save! 75 | end 76 | end 77 | 78 | def male? 79 | gender == 'M' 80 | end 81 | 82 | def female? 83 | gender == 'F' 84 | end 85 | 86 | def ungendered? 87 | !(male? || female?) 88 | end 89 | 90 | def self.ungendered 91 | where(:gender => 'U') 92 | end 93 | 94 | def full_name 95 | return [office && office.abbreviation, first_name, last_name, suffix].join(' ').strip 96 | end 97 | 98 | def add_related_politicians(other_names) 99 | other_names.each do |other_name| 100 | if not other_name.empty? && other_name != self.user_name 101 | other_pol = Politician.find_by_user_name(other_name) 102 | self.links << other_pol 103 | self.save! 104 | end 105 | end 106 | end 107 | 108 | def remove_related_politicians(other_names) 109 | other_names.each do |other_name| 110 | if not other_name.empty? && other_name != self.user_name 111 | other_pol = Politician.find_by_user_name(other_name) 112 | AccountLink.where(:politician_id => self.id, 113 | :link_id => other_pol.id).destroy_all 114 | AccountLink.where(:link_id => self.id, 115 | :politician_id => other_pol.id).destroy_all 116 | end 117 | end 118 | end 119 | 120 | def get_related_politicians 121 | links = AccountLink.where("politician_id = ? or link_id = ?", self.id, self.id) 122 | 123 | politician_ids = links.flat_map{ |l| [l.politician_id, l.link_id] } 124 | .reject{ |pol_id| pol_id == self.id } 125 | Politician.where(:id => politician_ids) 126 | end 127 | 128 | def twoops 129 | deleted_tweets.where(:approved => true) 130 | end 131 | 132 | def reset_avatar(options = {}) 133 | begin 134 | twitter_user = $twitter.user(user_name) 135 | image_url = twitter_user.profile_image_url(:bigger) 136 | 137 | force_reset = options.fetch(:force, false) 138 | 139 | if profile_image_url.nil? || (image_url != profile_image_url) || (profile_image_url != avatar.url) || force_reset 140 | uri = URI::parse(image_url) 141 | extension = File.extname(uri.path) 142 | 143 | uri.open do |remote_file| 144 | Tempfile.open(["#{self.twitter_id}_", extension]) do |tmpfile| 145 | tmpfile.puts remote_file.read().force_encoding('UTF-8') 146 | self.avatar = tmpfile 147 | self.profile_image_url = image_url 148 | self.save! 149 | end 150 | end 151 | end 152 | return [true, nil] 153 | rescue Twitter::Error::Forbidden => e 154 | return [false, e.to_s] 155 | rescue Twitter::Error::NotFound 156 | return [false, "No such user name: #{user_name}"] 157 | end 158 | end 159 | end 160 | -------------------------------------------------------------------------------- /app/models/tweet.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | class Tweet < ActiveRecord::Base 3 | belongs_to :politician 4 | 5 | has_many :tweet_images, :foreign_key => "tweet_id" 6 | 7 | scope :with_content, -> { where.not content: nil} 8 | scope :retweets, -> { where.not retweeted_id: nil} 9 | 10 | before_save :extract_retweet_fields 11 | 12 | cattr_reader :per_page 13 | @@per_page = 10 14 | 15 | def self.in_order 16 | includes(:politician).order('modified DESC') 17 | end 18 | 19 | def self.latest 20 | order('created DESC') 21 | end 22 | 23 | def self.deleted 24 | where(deleted: 1).where.not(content: nil) 25 | end 26 | 27 | def self.in_year(year) 28 | where("created >= #{Date.new(year, 1, 1)}").where("created <= #{Date.new(year, 12, 31)}") 29 | end 30 | 31 | def self.random 32 | Tweet.find(Tweet.pluck(:id).shuffle.first) 33 | end 34 | 35 | def details 36 | JSON.parse(tweet) 37 | end 38 | 39 | def extract_retweeted_status 40 | return nil if tweet.nil? 41 | orig_obj = JSON::parse(tweet) rescue nil 42 | return nil if orig_obj.nil? 43 | return nil if not orig_obj.is_a?(Hash) 44 | return nil if orig_obj["retweeted_status"].nil? 45 | 46 | return orig_obj["retweeted_status"] 47 | end 48 | 49 | def extract_retweet_fields (options = {}) 50 | if retweeted_id.nil? || !options[:overwrite].nil? 51 | orig_hash = extract_retweeted_status 52 | if orig_hash 53 | self.retweeted_id = orig_hash["id"] 54 | self.retweeted_content = orig_hash["text"] 55 | self.retweeted_user_name = orig_hash["user"]["screen_name"] 56 | end 57 | end 58 | end 59 | 60 | def twitter_url 61 | "https://www.twitter.com/#{user_name}/status/#{id}" 62 | end 63 | 64 | def format 65 | { 66 | :created_at => created, 67 | :updated_at => modified, 68 | :id => (id and id.to_s), 69 | :politician_id => politician_id, 70 | :details => details, 71 | :content => content, 72 | :user_name => user_name 73 | } 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /app/models/tweet_image.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | class TweetImage < ActiveRecord::Base 3 | def filename 4 | File.basename(URI.parse(url).path) 5 | end 6 | def basename 7 | File.basename(filename, '.*') 8 | end 9 | def extension 10 | ext = File.extname(filename) 11 | if ext.length > 0 12 | ext = ext[1..-1] 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/services/export/export_fixtures.rb: -------------------------------------------------------------------------------- 1 | module Export 2 | class ExportFixtures < ServiceBase 3 | include Virtus.model 4 | attribute :models 5 | attribute :export_dir, String 6 | 7 | def call 8 | FileUtils.mkdir_p export_dir 9 | models.each do |mdl| 10 | path = File.join(export_dir, "#{mdl.class.table_name}.yml") 11 | key_prefix = mdl.class.table_name.singularize 12 | File.open(path, 'w') do |outf| 13 | key = "#{key_prefix}_#{mdl.id}" 14 | outf.write({key => mdl.attributes}.to_yaml) 15 | end 16 | end 17 | 18 | success "Exported to #{export_dir}" 19 | end 20 | end 21 | end 22 | 23 | -------------------------------------------------------------------------------- /app/services/export/export_tweet_subgraph_fixtures.rb: -------------------------------------------------------------------------------- 1 | module Export 2 | class ExportTweetSubgraphFixtures < ServiceBase 3 | include Virtus.model 4 | attribute :tweet, Tweet 5 | attribute :export_dir, String 6 | 7 | def call 8 | deleted_tweet = DeletedTweet.where(:id => tweet.id).first 9 | pol = tweet.politician 10 | # Call accessors now to fail early 11 | models = [tweet, deleted_tweet, pol, pol.party, pol.office, pol.account_type].compact 12 | ExportFixtures.call(:models => models, :export_dir => export_dir) 13 | end 14 | end 15 | end 16 | 17 | -------------------------------------------------------------------------------- /app/services/failure.rb: -------------------------------------------------------------------------------- 1 | class Failure 2 | attr_reader :message 3 | 4 | def initialize (error=nil) 5 | if error.is_a? Exception 6 | @exception = error 7 | @message = error.to_s 8 | else 9 | @exception = nil 10 | @message = error 11 | end 12 | end 13 | 14 | def success? 15 | false 16 | end 17 | 18 | def to_s 19 | message.blank? and "Failed!" or "Failure: #{message}" 20 | end 21 | end 22 | 23 | -------------------------------------------------------------------------------- /app/services/requeue_tweet.rb: -------------------------------------------------------------------------------- 1 | class RequeueTweet < ServiceBase 2 | include Virtus.model 3 | attribute :tweet, Tweet 4 | attribute :queue_name, String 5 | 6 | def call 7 | decoded = JSON.load(tweet.tweet) # Ensure that it can be decoded. 8 | if tweet.is_a? DeletedTweet 9 | decoded = { 'delete' => { 'status' => { 10 | 'id' => decoded['id'], 11 | 'id_str' => decoded['id_str'], 12 | 'user_id' => decoded['user']['id'], 13 | 'user_id_str' => decoded['user']['id_str'] 14 | } } } 15 | end 16 | beanstalk = Beanstalk::Pool.new(['localhost:11300']) 17 | beanstalk.use(queue_name) 18 | beanstalk.put(JSON.dump(decoded)) 19 | success "Requeued #{tweet.class.table_name.singularize.gsub('_', ' ')} #{tweet.id}" 20 | end 21 | end 22 | 23 | -------------------------------------------------------------------------------- /app/services/service_base.rb: -------------------------------------------------------------------------------- 1 | class ServiceBase 2 | def self.call(*args) 3 | new(*args).call 4 | end 5 | 6 | def self.call_safely(*args) 7 | begin 8 | new(*args).call 9 | rescue => e 10 | Failure.new(e) 11 | end 12 | end 13 | 14 | def success (msg) 15 | Success.new(msg) 16 | end 17 | 18 | def failure (err) 19 | Failure.new(err) 20 | end 21 | end 22 | 23 | -------------------------------------------------------------------------------- /app/services/success.rb: -------------------------------------------------------------------------------- 1 | class Success 2 | attr_reader :message 3 | 4 | def initialize (msg=nil) 5 | @message = msg 6 | end 7 | 8 | def success? 9 | true 10 | end 11 | 12 | def to_s 13 | message.blank? and "Success!" or "Successfully #{message.uncapitalize}" 14 | end 15 | end 16 | 17 | -------------------------------------------------------------------------------- /app/views/admin/offices/add.html.erb: -------------------------------------------------------------------------------- 1 |
8 | 9 | In <%= @year %> we observed <%= number_with_delimiter @tweet_tally %> tweets from 10 | <%= number_with_delimiter @observed_account_tally %> accounts. 11 | 12 | Of those accounts, <%= number_with_delimiter @deleting_account_tally %> accounts 13 | deleted <%= number_with_delimiter @delete_tally %> tweets. Politwoops moderators 14 | approved <%= number_with_delimiter @approval_tally %> 15 | (<%= number_with_delimiter @approval_pct %>%) twoops from 16 | <%= number_with_delimiter @twooping_account_tally %> accounts. 17 | 18 | (some deletions could be of tweets from prior years) 19 |
20 | 21 |No tweeters! What is this world coming to?
40 | <% end %> 41 |No tweet deleters! Are we even on earth?
59 | <% end %> 60 |No approved deletes! Momma? Is that you?
79 | <% end %> 80 |Party | Accounts | Tweets | Deletes | Twoops | Mean Twoops |
---|---|---|---|---|---|
<%= party.display_name %> | 114 |<%= (number_with_delimiter @tweeting_accounts_per_party[party.id]) || 0 %> | 115 |<%= (number_with_delimiter @tweets_per_party[party.id]) || 0 %> | 116 |<%= (number_with_delimiter @deletes_per_party[party.id]) || 0 %> | 117 |<%= (number_with_delimiter @twoops_per_party[party.id]) || 0 %> | 118 |<%= number_with_precision (@twoops_per_party[party.id] || 0).to_f / @tweeting_accounts_per_party[party.id].to_f, :precision => 1 %> | 119 |
No party data make your twoopserver cry :(
126 | <% end %> 127 |Queue | 46 |Pending Jobs | 47 |
---|---|
<%= stats[0] %> | 53 |<%= stats[1].nil? ? 0 : stats[1]['current-jobs-ready'] %> | 54 |
Looks like you've landed on a page that no longer exists
18 |Go back to the home page and try again.
19 |We sure can't delete this error page, but we're working on fixing the problem right now. Check back soon!
20 | 21 | -------------------------------------------------------------------------------- /app/views/layouts/_analytics.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | 15 | 36 | -------------------------------------------------------------------------------- /app/views/layouts/admin.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |This may be an incomplete list. If you think we're missing someone, please email us with their Name, State, Political Party, Office they hold or are seeking and, of course, Twitter handle. Thanks!
8 |
15 | |
22 | 23 | 24 | 25 | <%= pol.first_name %> 26 | <%= pol.middle_name %> 27 | <%= pol.last_name %> 28 | <%= pol.suffix %> 29 | 30 | 31 | | 32 |33 | 34 | <% if pol.state %> <%= pol.state.upcase %> <% end %> 35 | 36 | | 37 |38 | 39 | <% if pol.office %><%= pol.office.title %> <% end %> 40 | 41 | | 42 | 43 | 48 | 49 |50 | 51 | <% if pol.party %> <%= pol.party.display_name %> <% end %> 52 | <% if pol.status == 4 %>inactive <% end %> 53 | 54 | | 55 |
Politwoops tracks deleted tweets by current politicians, including those in office and candidates. Explore the collection of tweets that they didn't want you to see. If you think we're missing someone, please email us with their Name, State, Political Party, Office they hold or are seeking and, of course, Twitter handle.
10 | 11 |Politwoops tracks deleted tweets by current politicians, including those in office and candidates. Explore the collection of tweets that they didn't want you to see. If you think we're missing someone, please email us with their Name, State, Political Party, Office they hold or are seeking and, of course, Twitter handle.
6 | 7 |You may have mistyped the address or the page may have moved.
63 |