aboutsummaryrefslogtreecommitdiffstats
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/assets/javascripts/admin.js6
-rw-r--r--app/assets/javascripts/admin/bootstrap-collapse.js138
-rw-r--r--app/assets/javascripts/admin/bootstrap-tab.js130
-rw-r--r--app/assets/javascripts/admin/holidays.js46
-rw-r--r--app/assets/javascripts/bootstrap-collapse.js138
-rw-r--r--app/assets/javascripts/bootstrap-tab.js130
-rw-r--r--app/assets/stylesheets/admin.scss33
-rw-r--r--app/controllers/admin_holiday_imports_controller.rb28
-rw-r--r--app/controllers/admin_holidays_controller.rb67
-rw-r--r--app/controllers/admin_request_controller.rb3
-rw-r--r--app/controllers/application_controller.rb25
-rw-r--r--app/controllers/request_controller.rb12
-rw-r--r--app/controllers/user_controller.rb10
-rw-r--r--app/mailers/outgoing_mailer.rb11
-rw-r--r--app/mailers/request_mailer.rb2
-rw-r--r--app/models/holiday.rb2
-rw-r--r--app/models/holiday_import.rb93
-rw-r--r--app/models/incoming_message.rb158
-rw-r--r--app/models/info_request.rb29
-rw-r--r--app/models/public_body.rb5
-rw-r--r--app/views/admin_general/_admin_navbar.html.erb10
-rw-r--r--app/views/admin_holiday_imports/new.html.erb81
-rw-r--r--app/views/admin_holidays/_edit_form.html.erb14
-rw-r--r--app/views/admin_holidays/_form.html.erb22
-rw-r--r--app/views/admin_holidays/_holiday.html.erb7
-rw-r--r--app/views/admin_holidays/_new_form.html.erb10
-rw-r--r--app/views/admin_holidays/edit.html.erb9
-rw-r--r--app/views/admin_holidays/index.html.erb41
-rw-r--r--app/views/admin_holidays/new.html.erb4
-rw-r--r--app/views/request/followup_preview.html.erb4
-rw-r--r--app/views/request/preview.html.erb2
31 files changed, 556 insertions, 714 deletions
diff --git a/app/assets/javascripts/admin.js b/app/assets/javascripts/admin.js
index 4925a65a4..9402f7f6c 100644
--- a/app/assets/javascripts/admin.js
+++ b/app/assets/javascripts/admin.js
@@ -3,8 +3,10 @@
//= require jquery.ui.tabs
//= require jquery.ui.sortable
//= require jquery.ui.effect-highlight
-//= require admin/bootstrap-collapse
-//= require admin/bootstrap-tab
+//= require bootstrap-collapse
+//= require bootstrap-tab
+//= require bootstrap-dropdown
//= require admin/admin
//= require admin/category-order
+//= require admin/holidays
//= require jquery_ujs
diff --git a/app/assets/javascripts/admin/bootstrap-collapse.js b/app/assets/javascripts/admin/bootstrap-collapse.js
deleted file mode 100644
index 9a364468b..000000000
--- a/app/assets/javascripts/admin/bootstrap-collapse.js
+++ /dev/null
@@ -1,138 +0,0 @@
-/* =============================================================
- * bootstrap-collapse.js v2.0.2
- * http://twitter.github.com/bootstrap/javascript.html#collapse
- * =============================================================
- * Copyright 2012 Twitter, Inc.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- * ============================================================ */
-
-!function( $ ){
-
- "use strict"
-
- var Collapse = function ( element, options ) {
- this.$element = $(element)
- this.options = $.extend({}, $.fn.collapse.defaults, options)
-
- if (this.options["parent"]) {
- this.$parent = $(this.options["parent"])
- }
-
- this.options.toggle && this.toggle()
- }
-
- Collapse.prototype = {
-
- constructor: Collapse
-
- , dimension: function () {
- var hasWidth = this.$element.hasClass('width')
- return hasWidth ? 'width' : 'height'
- }
-
- , show: function () {
- var dimension = this.dimension()
- , scroll = $.camelCase(['scroll', dimension].join('-'))
- , actives = this.$parent && this.$parent.find('.in')
- , hasData
-
- if (actives && actives.length) {
- hasData = actives.data('collapse')
- actives.collapse('hide')
- hasData || actives.data('collapse', null)
- }
-
- this.$element[dimension](0)
- this.transition('addClass', 'show', 'shown')
- this.$element[dimension](this.$element[0][scroll])
-
- }
-
- , hide: function () {
- var dimension = this.dimension()
- this.reset(this.$element[dimension]())
- this.transition('removeClass', 'hide', 'hidden')
- this.$element[dimension](0)
- }
-
- , reset: function ( size ) {
- var dimension = this.dimension()
-
- this.$element
- .removeClass('collapse')
- [dimension](size || 'auto')
- [0].offsetWidth
-
- this.$element[size ? 'addClass' : 'removeClass']('collapse')
-
- return this
- }
-
- , transition: function ( method, startEvent, completeEvent ) {
- var that = this
- , complete = function () {
- if (startEvent == 'show') that.reset()
- that.$element.trigger(completeEvent)
- }
-
- this.$element
- .trigger(startEvent)
- [method]('in')
-
- $.support.transition && this.$element.hasClass('collapse') ?
- this.$element.one($.support.transition.end, complete) :
- complete()
- }
-
- , toggle: function () {
- this[this.$element.hasClass('in') ? 'hide' : 'show']()
- }
-
- }
-
- /* COLLAPSIBLE PLUGIN DEFINITION
- * ============================== */
-
- $.fn.collapse = function ( option ) {
- return this.each(function () {
- var $this = $(this)
- , data = $this.data('collapse')
- , options = typeof option == 'object' && option
- if (!data) $this.data('collapse', (data = new Collapse(this, options)))
- if (typeof option == 'string') data[option]()
- })
- }
-
- $.fn.collapse.defaults = {
- toggle: true
- }
-
- $.fn.collapse.Constructor = Collapse
-
-
- /* COLLAPSIBLE DATA-API
- * ==================== */
-
- $(function () {
- $('body').on('click.collapse.data-api', '[data-toggle=collapse]', function ( e ) {
- var $this = $(this), href
- , target = $this.attr('data-target')
- || e.preventDefault()
- || (href = $this.attr('href')) && href.replace(/.*(?=#[^\s]+$)/, '') //strip for ie7
- , option = $(target).data('collapse') ? 'toggle' : $this.data()
- $(target).collapse(option)
- })
- })
-
-}( window.jQuery ); \ No newline at end of file
diff --git a/app/assets/javascripts/admin/bootstrap-tab.js b/app/assets/javascripts/admin/bootstrap-tab.js
deleted file mode 100644
index 26c9ece75..000000000
--- a/app/assets/javascripts/admin/bootstrap-tab.js
+++ /dev/null
@@ -1,130 +0,0 @@
-/* ========================================================
- * bootstrap-tab.js v2.0.1
- * http://twitter.github.com/bootstrap/javascript.html#tabs
- * ========================================================
- * Copyright 2012 Twitter, Inc.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- * ======================================================== */
-
-
-!function( $ ){
-
- "use strict"
-
- /* TAB CLASS DEFINITION
- * ==================== */
-
- var Tab = function ( element ) {
- this.element = $(element)
- }
-
- Tab.prototype = {
-
- constructor: Tab
-
- , show: function () {
- var $this = this.element
- , $ul = $this.closest('ul:not(.dropdown-menu)')
- , selector = $this.attr('data-target')
- , previous
- , $target
-
- if (!selector) {
- selector = $this.attr('href')
- selector = selector && selector.replace(/.*(?=#[^\s]*$)/, '') //strip for ie7
- }
-
- if ( $this.parent('li').hasClass('active') ) return
-
- previous = $ul.find('.active a').last()[0]
-
- $this.trigger({
- type: 'show'
- , relatedTarget: previous
- })
-
- $target = $(selector)
-
- this.activate($this.parent('li'), $ul)
- this.activate($target, $target.parent(), function () {
- $this.trigger({
- type: 'shown'
- , relatedTarget: previous
- })
- })
- }
-
- , activate: function ( element, container, callback) {
- var $active = container.find('> .active')
- , transition = callback
- && $.support.transition
- && $active.hasClass('fade')
-
- function next() {
- $active
- .removeClass('active')
- .find('> .dropdown-menu > .active')
- .removeClass('active')
-
- element.addClass('active')
-
- if (transition) {
- element[0].offsetWidth // reflow for transition
- element.addClass('in')
- } else {
- element.removeClass('fade')
- }
-
- if ( element.parent('.dropdown-menu') ) {
- element.closest('li.dropdown').addClass('active')
- }
-
- callback && callback()
- }
-
- transition ?
- $active.one($.support.transition.end, next) :
- next()
-
- $active.removeClass('in')
- }
- }
-
-
- /* TAB PLUGIN DEFINITION
- * ===================== */
-
- $.fn.tab = function ( option ) {
- return this.each(function () {
- var $this = $(this)
- , data = $this.data('tab')
- if (!data) $this.data('tab', (data = new Tab(this)))
- if (typeof option == 'string') data[option]()
- })
- }
-
- $.fn.tab.Constructor = Tab
-
-
- /* TAB DATA-API
- * ============ */
-
- $(function () {
- $('body').on('click.tab.data-api', '[data-toggle="tab"], [data-toggle="pill"]', function (e) {
- e.preventDefault()
- $(this).tab('show')
- })
- })
-
-}( window.jQuery ); \ No newline at end of file
diff --git a/app/assets/javascripts/admin/holidays.js b/app/assets/javascripts/admin/holidays.js
new file mode 100644
index 000000000..55eae9e2a
--- /dev/null
+++ b/app/assets/javascripts/admin/holidays.js
@@ -0,0 +1,46 @@
+$(function() {
+
+ // New button loads the 'new' form via AJAX
+ $('#new-holiday-button').click(function(){
+ var new_call = $.ajax({ type: 'GET', url: $(this).attr('href')});
+ new_call.done(function(response) {
+ $('#existing-holidays').before(response);
+ });
+ return false;
+
+ });
+
+ // Each edit button loads the 'edit' form for that holiday via AJAX
+ $('.holiday').each(function(index){
+ var holiday_row = $(this);
+ var edit_button = holiday_row.find('.edit-button');
+ edit_button.click(function(){
+ var edit_call = $.ajax({ type: 'GET', url: holiday_row.data('target') });
+ edit_call.done(function(response) {
+ holiday_row.html(response);
+ });
+ return false;
+ });
+ });
+
+ // Remove button removes form div for holiday from an import set
+ $('.remove-holiday').each(function(index){
+ $(this).click(function(){
+ $(this).parents('.import-holiday-info').remove();
+ return false;
+ });
+ });
+
+ if ($('#holiday_import_source_suggestions').is(':checked')){
+ $('#holiday_import_ical_feed_url').attr("disabled", "disabled");
+ }
+ // Enable and disable the feed element when that is selected as the import source
+ $('#holiday_import_source_feed').click(function(){
+ $('#holiday_import_ical_feed_url').removeAttr("disabled");
+ });
+
+ $('#holiday_import_source_suggestions').click(function(){
+ $('#holiday_import_ical_feed_url').attr("disabled", "disabled");
+ });
+
+});
diff --git a/app/assets/javascripts/bootstrap-collapse.js b/app/assets/javascripts/bootstrap-collapse.js
deleted file mode 100644
index 9a364468b..000000000
--- a/app/assets/javascripts/bootstrap-collapse.js
+++ /dev/null
@@ -1,138 +0,0 @@
-/* =============================================================
- * bootstrap-collapse.js v2.0.2
- * http://twitter.github.com/bootstrap/javascript.html#collapse
- * =============================================================
- * Copyright 2012 Twitter, Inc.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- * ============================================================ */
-
-!function( $ ){
-
- "use strict"
-
- var Collapse = function ( element, options ) {
- this.$element = $(element)
- this.options = $.extend({}, $.fn.collapse.defaults, options)
-
- if (this.options["parent"]) {
- this.$parent = $(this.options["parent"])
- }
-
- this.options.toggle && this.toggle()
- }
-
- Collapse.prototype = {
-
- constructor: Collapse
-
- , dimension: function () {
- var hasWidth = this.$element.hasClass('width')
- return hasWidth ? 'width' : 'height'
- }
-
- , show: function () {
- var dimension = this.dimension()
- , scroll = $.camelCase(['scroll', dimension].join('-'))
- , actives = this.$parent && this.$parent.find('.in')
- , hasData
-
- if (actives && actives.length) {
- hasData = actives.data('collapse')
- actives.collapse('hide')
- hasData || actives.data('collapse', null)
- }
-
- this.$element[dimension](0)
- this.transition('addClass', 'show', 'shown')
- this.$element[dimension](this.$element[0][scroll])
-
- }
-
- , hide: function () {
- var dimension = this.dimension()
- this.reset(this.$element[dimension]())
- this.transition('removeClass', 'hide', 'hidden')
- this.$element[dimension](0)
- }
-
- , reset: function ( size ) {
- var dimension = this.dimension()
-
- this.$element
- .removeClass('collapse')
- [dimension](size || 'auto')
- [0].offsetWidth
-
- this.$element[size ? 'addClass' : 'removeClass']('collapse')
-
- return this
- }
-
- , transition: function ( method, startEvent, completeEvent ) {
- var that = this
- , complete = function () {
- if (startEvent == 'show') that.reset()
- that.$element.trigger(completeEvent)
- }
-
- this.$element
- .trigger(startEvent)
- [method]('in')
-
- $.support.transition && this.$element.hasClass('collapse') ?
- this.$element.one($.support.transition.end, complete) :
- complete()
- }
-
- , toggle: function () {
- this[this.$element.hasClass('in') ? 'hide' : 'show']()
- }
-
- }
-
- /* COLLAPSIBLE PLUGIN DEFINITION
- * ============================== */
-
- $.fn.collapse = function ( option ) {
- return this.each(function () {
- var $this = $(this)
- , data = $this.data('collapse')
- , options = typeof option == 'object' && option
- if (!data) $this.data('collapse', (data = new Collapse(this, options)))
- if (typeof option == 'string') data[option]()
- })
- }
-
- $.fn.collapse.defaults = {
- toggle: true
- }
-
- $.fn.collapse.Constructor = Collapse
-
-
- /* COLLAPSIBLE DATA-API
- * ==================== */
-
- $(function () {
- $('body').on('click.collapse.data-api', '[data-toggle=collapse]', function ( e ) {
- var $this = $(this), href
- , target = $this.attr('data-target')
- || e.preventDefault()
- || (href = $this.attr('href')) && href.replace(/.*(?=#[^\s]+$)/, '') //strip for ie7
- , option = $(target).data('collapse') ? 'toggle' : $this.data()
- $(target).collapse(option)
- })
- })
-
-}( window.jQuery ); \ No newline at end of file
diff --git a/app/assets/javascripts/bootstrap-tab.js b/app/assets/javascripts/bootstrap-tab.js
deleted file mode 100644
index 26c9ece75..000000000
--- a/app/assets/javascripts/bootstrap-tab.js
+++ /dev/null
@@ -1,130 +0,0 @@
-/* ========================================================
- * bootstrap-tab.js v2.0.1
- * http://twitter.github.com/bootstrap/javascript.html#tabs
- * ========================================================
- * Copyright 2012 Twitter, Inc.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- * ======================================================== */
-
-
-!function( $ ){
-
- "use strict"
-
- /* TAB CLASS DEFINITION
- * ==================== */
-
- var Tab = function ( element ) {
- this.element = $(element)
- }
-
- Tab.prototype = {
-
- constructor: Tab
-
- , show: function () {
- var $this = this.element
- , $ul = $this.closest('ul:not(.dropdown-menu)')
- , selector = $this.attr('data-target')
- , previous
- , $target
-
- if (!selector) {
- selector = $this.attr('href')
- selector = selector && selector.replace(/.*(?=#[^\s]*$)/, '') //strip for ie7
- }
-
- if ( $this.parent('li').hasClass('active') ) return
-
- previous = $ul.find('.active a').last()[0]
-
- $this.trigger({
- type: 'show'
- , relatedTarget: previous
- })
-
- $target = $(selector)
-
- this.activate($this.parent('li'), $ul)
- this.activate($target, $target.parent(), function () {
- $this.trigger({
- type: 'shown'
- , relatedTarget: previous
- })
- })
- }
-
- , activate: function ( element, container, callback) {
- var $active = container.find('> .active')
- , transition = callback
- && $.support.transition
- && $active.hasClass('fade')
-
- function next() {
- $active
- .removeClass('active')
- .find('> .dropdown-menu > .active')
- .removeClass('active')
-
- element.addClass('active')
-
- if (transition) {
- element[0].offsetWidth // reflow for transition
- element.addClass('in')
- } else {
- element.removeClass('fade')
- }
-
- if ( element.parent('.dropdown-menu') ) {
- element.closest('li.dropdown').addClass('active')
- }
-
- callback && callback()
- }
-
- transition ?
- $active.one($.support.transition.end, next) :
- next()
-
- $active.removeClass('in')
- }
- }
-
-
- /* TAB PLUGIN DEFINITION
- * ===================== */
-
- $.fn.tab = function ( option ) {
- return this.each(function () {
- var $this = $(this)
- , data = $this.data('tab')
- if (!data) $this.data('tab', (data = new Tab(this)))
- if (typeof option == 'string') data[option]()
- })
- }
-
- $.fn.tab.Constructor = Tab
-
-
- /* TAB DATA-API
- * ============ */
-
- $(function () {
- $('body').on('click.tab.data-api', '[data-toggle="tab"], [data-toggle="pill"]', function (e) {
- e.preventDefault()
- $(this).tab('show')
- })
- })
-
-}( window.jQuery ); \ No newline at end of file
diff --git a/app/assets/stylesheets/admin.scss b/app/assets/stylesheets/admin.scss
index 104f10c75..31fe7e95a 100644
--- a/app/assets/stylesheets/admin.scss
+++ b/app/assets/stylesheets/admin.scss
@@ -80,6 +80,10 @@ body.admin {
}
}
+ .fieldWithErrors input{
+ border: 1px solid #ff0c11;
+ }
+
body.admin blockquote p {
font-size: 13px;
display: inline;
@@ -119,5 +123,34 @@ body.admin {
padding: 3px 0;
}
+ /* Holidays */
+ .day_select {
+ width: 75px;
+ }
+
+ .holiday-description, .holiday-day, .holiday-buttons, .holiday-destroy {
+ padding: 6px 4px;
+ }
+
+ .holiday-description, .holiday-day, .holiday-buttons{
+ display: inline-block;
+ }
+
+ .holiday-description {
+ width: 300px;
+ }
+ .holiday-day {
+ width: 240px;
+ text-align: center;
+ }
+ .holiday-buttons{
+ width: 200px;
+ text-align: right;
+ }
+
+ #import_start_year, #import_end_year {
+ width: 75px;
+ }
+
}
diff --git a/app/controllers/admin_holiday_imports_controller.rb b/app/controllers/admin_holiday_imports_controller.rb
new file mode 100644
index 000000000..8596936f0
--- /dev/null
+++ b/app/controllers/admin_holiday_imports_controller.rb
@@ -0,0 +1,28 @@
+class AdminHolidayImportsController < AdminController
+
+ def new
+ @holiday_import = HolidayImport.new(holiday_import_params)
+ @holiday_import.populate if @holiday_import.valid?
+ end
+
+ def create
+ @holiday_import = HolidayImport.new(holiday_import_params)
+ if @holiday_import.save
+ notice = "Holidays successfully imported"
+ redirect_to admin_holidays_path, :notice => notice
+ else
+ render :new
+ end
+ end
+
+ private
+
+ def holiday_import_params(key = :holiday_import)
+ if params[key]
+ params[key].slice(:holidays_attributes, :start_year, :end_year, :source, :ical_feed_url)
+ else
+ {}
+ end
+ end
+
+end
diff --git a/app/controllers/admin_holidays_controller.rb b/app/controllers/admin_holidays_controller.rb
new file mode 100644
index 000000000..9177ebd44
--- /dev/null
+++ b/app/controllers/admin_holidays_controller.rb
@@ -0,0 +1,67 @@
+class AdminHolidaysController < AdminController
+
+ def index
+ get_all_holidays
+ end
+
+ def new
+ @holiday = Holiday.new
+ if request.xhr?
+ render :partial => 'new_form', :locals => { :holiday => @holiday }
+ else
+ render :action => 'new'
+ end
+ end
+
+ def create
+ @holiday = Holiday.new(holiday_params)
+ if @holiday.save
+ notice = "Holiday successfully created."
+ redirect_to admin_holidays_path, :notice => notice
+ else
+ render :new
+ end
+ end
+
+ def edit
+ @holiday = Holiday.find(params[:id])
+ if request.xhr?
+ render :partial => 'edit_form'
+ else
+ render :action => 'edit'
+ end
+ end
+
+ def update
+ @holiday = Holiday.find(params[:id])
+ if @holiday.update_attributes(holiday_params)
+ flash[:notice] = 'Holiday successfully updated.'
+ redirect_to admin_holidays_path
+ else
+ render :edit
+ end
+ end
+
+ def destroy
+ @holiday = Holiday.find(params[:id])
+ @holiday.destroy
+ notice = "Holiday successfully destroyed"
+ redirect_to admin_holidays_path, :notice => notice
+ end
+
+ private
+
+ def get_all_holidays
+ @holidays_by_year = Holiday.all.group_by { |holiday| holiday.day.year }
+ @years = @holidays_by_year.keys.sort.reverse
+ end
+
+ def holiday_params(key = :holiday)
+ if params[key]
+ params[key].slice(:description, 'day(1i)', 'day(2i)', 'day(3i)')
+ else
+ {}
+ end
+ end
+
+end
diff --git a/app/controllers/admin_request_controller.rb b/app/controllers/admin_request_controller.rb
index e63d5e80a..cbf7b9f4f 100644
--- a/app/controllers/admin_request_controller.rb
+++ b/app/controllers/admin_request_controller.rb
@@ -4,9 +4,8 @@
# Copyright (c) 2007 UK Citizens Online Democracy. All rights reserved.
# Email: hello@mysociety.org; WWW: http://www.mysociety.org/
-require 'ostruct'
-
class AdminRequestController < AdminController
+
def index
@query = params[:query]
if @query
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 1ccf7e5db..a06fa7098 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -30,6 +30,8 @@ class ApplicationController < ActionController::Base
before_filter :check_in_post_redirect
before_filter :session_remember_me
before_filter :set_vary_header
+ before_filter :validate_session_timestamp
+ after_filter :persist_session_timestamp
def set_vary_header
response.headers['Vary'] = 'Cookie'
@@ -121,6 +123,29 @@ class ApplicationController < ActionController::Base
end
end
+ # Set a TTL for non "remember me" sessions so that the cookie
+ # is not replayable forever
+ SESSION_TTL = 3.hours
+ def validate_session_timestamp
+ if session[:user_id] && session.key?(:ttl) && session[:ttl] < SESSION_TTL.ago
+ clear_session_credentials
+ redirect_to signin_path
+ end
+ end
+
+ def persist_session_timestamp
+ session[:ttl] = Time.now if session[:user_id] && !session[:remember_me]
+ end
+
+ # Logout form
+ def clear_session_credentials
+ session[:user_id] = nil
+ session[:user_circumstance] = nil
+ session[:remember_me] = false
+ session[:using_admin] = nil
+ session[:admin_name] = nil
+ end
+
def render_exception(exception)
# In development or the admin interface let Rails handle the exception
# with its stack trace templates
diff --git a/app/controllers/request_controller.rb b/app/controllers/request_controller.rb
index 346aaf384..d529f8dbb 100644
--- a/app/controllers/request_controller.rb
+++ b/app/controllers/request_controller.rb
@@ -770,13 +770,14 @@ class RequestController < ApplicationController
get_attachment_internal(false)
return unless @attachment
- # Prevent spam to magic request address. Note that the binary
- # subsitution method used depends on the content type
- @incoming_message.binary_mask_stuff!(@attachment.body, @attachment.content_type)
# we don't use @attachment.content_type here, as we want same mime type when cached in cache_attachments above
response.content_type = AlaveteliFileTypes.filename_to_mimetype(params[:file_name]) || 'application/octet-stream'
+ # Prevent spam to magic request address. Note that the binary
+ # subsitution method used depends on the content type
+ @incoming_message.apply_masks!(@attachment.body, @attachment.content_type)
+
render :text => @attachment.body
end
@@ -804,10 +805,9 @@ class RequestController < ApplicationController
:body_prefix => render_to_string(:partial => "request/view_html_prefix")
}
)
-
- @incoming_message.html_mask_stuff!(html)
-
response.content_type = 'text/html'
+ @incoming_message.apply_masks!(html, response.content_type)
+
render :text => html
end
diff --git a/app/controllers/user_controller.rb b/app/controllers/user_controller.rb
index baeaab18a..9798ff8e2 100644
--- a/app/controllers/user_controller.rb
+++ b/app/controllers/user_controller.rb
@@ -260,16 +260,8 @@ class UserController < ApplicationController
do_post_redirect post_redirect
end
- # Logout form
- def _do_signout
- session[:user_id] = nil
- session[:user_circumstance] = nil
- session[:remember_me] = false
- session[:using_admin] = nil
- session[:admin_name] = nil
- end
def signout
- self._do_signout
+ clear_session_credentials
if params[:r]
redirect_to params[:r]
else
diff --git a/app/mailers/outgoing_mailer.rb b/app/mailers/outgoing_mailer.rb
index 797bf9fdd..19054b4e2 100644
--- a/app/mailers/outgoing_mailer.rb
+++ b/app/mailers/outgoing_mailer.rb
@@ -21,7 +21,7 @@ class OutgoingMailer < ApplicationMailer
mail(:from => info_request.incoming_name_and_email,
:to => info_request.recipient_name_and_email,
- :subject => info_request.email_subject_request)
+ :subject => info_request.email_subject_request(:html => false))
end
# Later message to public body regarding existing request
@@ -32,7 +32,7 @@ class OutgoingMailer < ApplicationMailer
mail(:from => info_request.incoming_name_and_email,
:to => OutgoingMailer.name_and_email_for_followup(info_request, incoming_message_followup),
- :subject => OutgoingMailer.subject_for_followup(info_request, outgoing_message))
+ :subject => OutgoingMailer.subject_for_followup(info_request, outgoing_message, :html => false))
end
# TODO: the condition checking valid_to_reply_to? also appears in views/request/_followup.html.erb,
@@ -67,11 +67,12 @@ class OutgoingMailer < ApplicationMailer
end
end
# Subject to use for followup
- def OutgoingMailer.subject_for_followup(info_request, outgoing_message)
+ def OutgoingMailer.subject_for_followup(info_request, outgoing_message, options = {})
if outgoing_message.what_doing == 'internal_review'
- return "Internal review of " + info_request.email_subject_request
+ return "Internal review of " + info_request.email_subject_request(:html => options[:html])
else
- return info_request.email_subject_followup(outgoing_message.incoming_message_followup)
+ return info_request.email_subject_followup(:incoming_message => outgoing_message.incoming_message_followup,
+ :html => options[:html])
end
end
# Whether we have a valid email address for a followup
diff --git a/app/mailers/request_mailer.rb b/app/mailers/request_mailer.rb
index 96917d63f..c9decc6db 100644
--- a/app/mailers/request_mailer.rb
+++ b/app/mailers/request_mailer.rb
@@ -20,7 +20,7 @@ class RequestMailer < ApplicationMailer
mail(:from => from_user.name_and_email,
:to => info_request.incoming_name_and_email,
- :subject => info_request.email_subject_followup)
+ :subject => info_request.email_subject_followup(:html => false))
end
# Used when a response is uploaded using the API
diff --git a/app/models/holiday.rb b/app/models/holiday.rb
index 4c4941589..34044683a 100644
--- a/app/models/holiday.rb
+++ b/app/models/holiday.rb
@@ -22,6 +22,8 @@
class Holiday < ActiveRecord::Base
+ validates_presence_of :day
+
def self.holidays
@@holidays ||= all.collect { |h| h.day }.to_set
end
diff --git a/app/models/holiday_import.rb b/app/models/holiday_import.rb
new file mode 100644
index 000000000..c6019fac0
--- /dev/null
+++ b/app/models/holiday_import.rb
@@ -0,0 +1,93 @@
+class HolidayImport
+
+ include ActiveModel::Validations
+
+ attr_accessor :holidays,
+ :ical_feed_url,
+ :start_year,
+ :end_year,
+ :start_date,
+ :end_date,
+ :source,
+ :populated
+
+ validate :all_holidays_valid
+ validates_inclusion_of :source, :in => %w( suggestions feed )
+ validates_presence_of :ical_feed_url,
+ :if => proc { |holiday_import| holiday_import.source == 'feed' }
+
+ def initialize(opts = {})
+ @populated = false
+ @start_year = opts.fetch(:start_year, Time.now.year).to_i
+ @end_year = opts.fetch(:end_year, Time.now.year).to_i
+ @start_date = Date.civil(start_year, 1, 1)
+ @end_date = Date.civil(end_year, 12, 31)
+ @source = opts.fetch(:source, 'suggestions')
+ @ical_feed_url = opts.fetch(:ical_feed_url, nil)
+ @country_code = AlaveteliConfiguration::iso_country_code.downcase
+ self.holidays_attributes = opts.fetch(:holidays_attributes, [])
+ end
+
+ def populate
+ source == 'suggestions' ? populate_from_suggestions : populate_from_ical_feed
+ @populated = true
+ end
+
+ def suggestions_country_name
+ IsoCountryCodes.find(@country_code).name if @country_code
+ end
+
+ def period
+ start_year == end_year ? "#{start_year}" : "#{start_year}-#{end_year}"
+ end
+
+ def save
+ holidays.all?(&:save)
+ end
+
+ def holidays_attributes=(incoming_data)
+ incoming_data.each{ |offset, incoming| self.holidays << Holiday.new(incoming) }
+ end
+
+ def holidays
+ @holidays ||= []
+ end
+
+ private
+
+ def all_holidays_valid
+ errors.add(:base, 'These holidays could not be imported') unless holidays.all?(&:valid?)
+ end
+
+ def populate_from_ical_feed
+ begin
+ cal_file = open(ical_feed_url)
+ cals = Icalendar.parse(cal_file, strict=false)
+ cal = cals.first
+ cal.events.each{ |cal_event| populate_from_ical_event(cal_event) }
+ rescue Errno::ENOENT, Exception => e
+ if e.message == 'Invalid line in calendar string!'
+ errors.add(:ical_feed_url, "Sorry, there's a problem with the format of that feed.")
+ elsif e.message.starts_with 'No such file or directory'
+ errors.add(:ical_feed_url, "Sorry we couldn't find that feed.")
+ else
+ raise e
+ end
+ end
+ end
+
+ def populate_from_ical_event(cal_event)
+ if cal_event.dtstart >= start_date and cal_event.dtstart <= end_date
+ holidays << Holiday.new(:description => cal_event.summary,
+ :day => cal_event.dtstart)
+ end
+ end
+
+ def populate_from_suggestions
+ holiday_info = Holidays.between(start_date, end_date, @country_code.to_sym, :observed)
+ holiday_info.each do |holiday_info_hash|
+ holidays << Holiday.new(:description => holiday_info_hash[:name],
+ :day => holiday_info_hash[:date])
+ end
+ end
+end
diff --git a/app/models/incoming_message.rb b/app/models/incoming_message.rb
index db6722976..658ee969a 100644
--- a/app/models/incoming_message.rb
+++ b/app/models/incoming_message.rb
@@ -52,17 +52,6 @@ class IncomingMessage < ActiveRecord::Base
has_prominence
- # See binary_mask_stuff function below. It just test for inclusion
- # in this hash, not the value of the right hand side.
- DoNotBinaryMask = {
- 'image/tiff' => 1,
- 'image/gif' => 1,
- 'image/jpeg' => 1,
- 'image/png' => 1,
- 'image/bmp' => 1,
- 'application/zip' => 1,
- }
-
# Given that there are in theory many info request events, a convenience method for
# getting the response event
def response_event
@@ -218,111 +207,10 @@ class IncomingMessage < ActiveRecord::Base
end
end
- # Converts email addresses we know about into textual descriptions of them
- def mask_special_emails!(text)
- # TODO: can later display some of these special emails as actual emails,
- # if they are public anyway. For now just be precautionary and only
- # put in descriptions of them in square brackets.
- if self.info_request.public_body.is_followupable?
- text.gsub!(self.info_request.public_body.request_email, _("[{{public_body}} request email]", :public_body => self.info_request.public_body.short_or_long_name))
- end
- text.gsub!(self.info_request.incoming_email, _('[FOI #{{request}} email]', :request => self.info_request.id.to_s) )
- text.gsub!(AlaveteliConfiguration::contact_email, _("[{{site_name}} contact email]", :site_name => AlaveteliConfiguration::site_name) )
- end
-
- # Replaces all email addresses in (possibly binary data) with equal length alternative ones.
- # Also replaces censor items
- def binary_mask_stuff!(text, content_type)
- # See if content type is one that we mask - things like zip files and
- # images may get broken if we try to. We err on the side of masking too
- # much, as many unknown types will really be text.
- if DoNotBinaryMask.include?(content_type)
- return
- end
-
- # Special cases for some content types
- if content_type == 'application/pdf'
- uncompressed_text = nil
- uncompressed_text = AlaveteliExternalCommand.run("pdftk", "-", "output", "-", "uncompress", :stdin_string => text)
- # if we managed to uncompress the PDF...
- if !uncompressed_text.nil? && !uncompressed_text.empty?
- # then censor stuff (making a copy so can compare again in a bit)
- censored_uncompressed_text = uncompressed_text.dup
- self._binary_mask_stuff_internal!(censored_uncompressed_text)
- # if the censor rule removed something...
- if censored_uncompressed_text != uncompressed_text
- # then use the altered file (recompressed)
- recompressed_text = nil
- if AlaveteliConfiguration::use_ghostscript_compression == true
- command = ["gs", "-sDEVICE=pdfwrite", "-dCompatibilityLevel=1.4", "-dPDFSETTINGS=/screen", "-dNOPAUSE", "-dQUIET", "-dBATCH", "-sOutputFile=-", "-"]
- else
- command = ["pdftk", "-", "output", "-", "compress"]
- end
- recompressed_text = AlaveteliExternalCommand.run(*(command + [{:stdin_string=>censored_uncompressed_text}]))
- if recompressed_text.nil? || recompressed_text.empty?
- # buggy versions of pdftk sometimes fail on
- # compression, I don't see it's a disaster in
- # these cases to save an uncompressed version?
- recompressed_text = censored_uncompressed_text
- logger.warn "Unable to compress PDF; problem with your pdftk version?"
- end
- if !recompressed_text.nil? && !recompressed_text.empty?
- text.replace recompressed_text
- end
- end
- end
- return
- end
-
- self._binary_mask_stuff_internal!(text)
- end
-
- # Used by binary_mask_stuff - replace text in place
- def _binary_mask_stuff_internal!(text)
- # Keep original size, so can check haven't resized it
- orig_size = text.mb_chars.size
-
- # Replace ASCII email addresses...
- text.gsub!(MySociety::Validate.email_find_regexp) do |email|
- email.gsub(/[^@.]/, 'x')
- end
-
- # And replace UCS-2 ones (for Microsoft Office documents)...
- # Find emails, by finding them in parts of text that have ASCII
- # equivalents to the UCS-2
- ascii_chars = text.gsub(/\0/, "")
- emails = ascii_chars.scan(MySociety::Validate.email_find_regexp)
-
- # Convert back to UCS-2, making a mask at the same time
- if String.method_defined?(:encode)
- emails.map! do |email|
- # We want the ASCII representation of UCS-2
- [email[0].encode('UTF-16LE').force_encoding('US-ASCII'),
- email[0].gsub(/[^@.]/, 'x').encode('UTF-16LE').force_encoding('US-ASCII')]
- end
- else
- emails.map! {|email| [
- Iconv.conv('ucs-2le', 'ascii', email[0]),
- Iconv.conv('ucs-2le', 'ascii', email[0].gsub(/[^@.]/, 'x'))
- ] }
- end
-
- # Now search and replace the UCS-2 email with the UCS-2 mask
- for email, mask in emails
- text.gsub!(email, mask)
- end
-
- # Replace censor items
- self.info_request.apply_censor_rules_to_binary!(text)
-
- raise "internal error in binary_mask_stuff" if text.mb_chars.size != orig_size
- return text
- end
-
- # Removes censored stuff from from HTML conversion of downloaded binaries
- def html_mask_stuff!(html)
- self.mask_special_emails!(html)
- self.remove_privacy_sensitive_things!(html)
+ def apply_masks!(text, content_type)
+ mask_options = { :censor_rules => info_request.applicable_censor_rules,
+ :masks => info_request.masks }
+ AlaveteliTextMasker.apply_masks!(text, content_type, mask_options)
end
# Lotus notes quoting yeuch!
@@ -346,26 +234,6 @@ class IncomingMessage < ActiveRecord::Base
end
- # Remove emails, mobile phones and other details FOI officers ask us to remove.
- def remove_privacy_sensitive_things!(text)
- # Remove any email addresses - we don't want bounce messages to leak out
- # either the requestor's email address or the request's response email
- # address out onto the internet
- text.gsub!(MySociety::Validate.email_find_regexp, "[email address]")
-
- # Mobile phone numbers
- # http://www.whatdotheyknow.com/request/failed_test_purchases_off_licenc#incoming-1013
- # http://www.whatdotheyknow.com/request/selective_licensing_statistics_i#incoming-550
- # http://www.whatdotheyknow.com/request/common_purpose_training_graduate#incoming-774
- text.gsub!(/(Mobile|Mob)([\s\/]*(Fax|Tel))*\s*:?[\s\d]*\d/, "[mobile number]")
-
- # Remove WhatDoTheyKnow signup links
- text.gsub!(/http:\/\/#{AlaveteliConfiguration::domain}\/c\/[^\s]+/, "[WDTK login link]")
-
- # Remove things from censor rules
- self.info_request.apply_censor_rules_to_text!(text)
- end
-
# Remove quoted sections from emails (eventually the aim would be for this
# to do as good a job as GMail does) TODO: bet it needs a proper parser
@@ -465,9 +333,8 @@ class IncomingMessage < ActiveRecord::Base
raise "main body text more than 1 MB, need to implement clipping like for attachment text, or there is some other MIME decoding problem or similar"
end
- # remove emails for privacy/anti-spam reasons
- self.mask_special_emails!(text)
- self.remove_privacy_sensitive_things!(text)
+ # apply masks for this message
+ apply_masks!(text, 'text/html')
# Remove existing quoted sections
folded_quoted_text = self.remove_lotus_quoting(text, 'FOLDED_QUOTED_SECTION')
@@ -735,7 +602,14 @@ class IncomingMessage < ActiveRecord::Base
text = MySociety::Format.simplify_angle_bracketed_urls(text)
text = CGI.escapeHTML(text)
text = MySociety::Format.make_clickable(text, :contract => 1)
- text.gsub!(/\[(email address|mobile number)\]/, '[<a href="/help/officers#mobiles">\1</a>]')
+
+ # add a helpful link to email addresses and mobile numbers removed
+ # by apply_masks!
+ email_pattern = Regexp.escape(_("email address"))
+ mobile_pattern = Regexp.escape(_("mobile number"))
+ text.gsub!(/\[(#{email_pattern}|#{mobile_pattern})\]/,
+ '[<a href="/help/officers#mobiles">\1</a>]')
+
if collapse_quoted_sections
text = text.gsub(/(\s*FOLDED_QUOTED_SECTION\s*)+/m, "FOLDED_QUOTED_SECTION")
text.strip!
@@ -773,8 +647,8 @@ class IncomingMessage < ActiveRecord::Base
# Returns text version of attachment text
def get_attachment_text_full
text = self._get_attachment_text_internal
- self.mask_special_emails!(text)
- self.remove_privacy_sensitive_things!(text)
+ apply_masks!(text, 'text/html')
+
# This can be useful for memory debugging
#STDOUT.puts 'xxx '+ MySociety::DebugHelpers::allocated_string_size_around_gc
diff --git a/app/models/info_request.rb b/app/models/info_request.rb
index d0052603a..20b7ef9af 100644
--- a/app/models/info_request.rb
+++ b/app/models/info_request.rb
@@ -292,13 +292,18 @@ public
end
# Subject lines for emails about the request
- def email_subject_request
- _('{{law_used_full}} request - {{title}}',:law_used_full=>self.law_used_full,:title=>self.title.html_safe)
+ def email_subject_request(opts = {})
+ html = opts.fetch(:html, true)
+ _('{{law_used_full}} request - {{title}}',
+ :law_used_full => self.law_used_full,
+ :title => (html ? title : title.html_safe))
end
- def email_subject_followup(incoming_message = nil)
+ def email_subject_followup(opts = {})
+ incoming_message = opts.fetch(:incoming_message, nil)
+ html = opts.fetch(:html, true)
if incoming_message.nil? || !incoming_message.valid_to_reply_to? || !incoming_message.subject
- 'Re: ' + self.email_subject_request
+ 'Re: ' + self.email_subject_request(:html => html)
else
if incoming_message.subject.match(/^Re:/i)
incoming_message.subject
@@ -1148,6 +1153,22 @@ public
return binary
end
+ # Masks we apply to text associated with this request convert email addresses
+ # we know about into textual descriptions of them
+ def masks
+ masks = [{ :to_replace => incoming_email,
+ :replacement => _('[FOI #{{request}} email]',
+ :request => id.to_s) },
+ { :to_replace => AlaveteliConfiguration::contact_email,
+ :replacement => _("[{{site_name}} contact email]",
+ :site_name => AlaveteliConfiguration::site_name)} ]
+ if public_body.is_followupable?
+ masks << { :to_replace => public_body.request_email,
+ :replacement => _("[{{public_body}} request email]",
+ :public_body => public_body.short_or_long_name) }
+ end
+ end
+
def is_owning_user?(user)
!user.nil? && (user.id == user_id || user.owns_every_request?)
end
diff --git a/app/models/public_body.rb b/app/models/public_body.rb
index 1929272ea..a9cdfeab2 100644
--- a/app/models/public_body.rb
+++ b/app/models/public_body.rb
@@ -454,11 +454,12 @@ class PublicBody < ActiveRecord::Base
# of updating them
bodies_by_name = {}
set_of_existing = Set.new()
+ internal_admin_body_id = PublicBody.internal_admin_body.id
I18n.with_locale(I18n.default_locale) do
- bodies = (tag.nil? || tag.empty?) ? PublicBody.find(:all) : PublicBody.find_by_tag(tag)
+ bodies = (tag.nil? || tag.empty?) ? PublicBody.find(:all, :include => :translations) : PublicBody.find_by_tag(tag)
for existing_body in bodies
# Hide InternalAdminBody from import notes
- next if existing_body.id == PublicBody.internal_admin_body.id
+ next if existing_body.id == internal_admin_body_id
bodies_by_name[existing_body.name] = existing_body
set_of_existing.add(existing_body.name)
diff --git a/app/views/admin_general/_admin_navbar.html.erb b/app/views/admin_general/_admin_navbar.html.erb
index 62982b3f1..b6e01f049 100644
--- a/app/views/admin_general/_admin_navbar.html.erb
+++ b/app/views/admin_general/_admin_navbar.html.erb
@@ -9,11 +9,17 @@
<li><%= link_to 'Timeline', admin_timeline_path %></li>
<li><%= link_to 'Stats', admin_stats_path %></li>
<li><%= link_to 'Debug', admin_debug_path %></li>
- <li><%= link_to 'Authorities', admin_bodies_path %></li>
- <li><%= link_to 'Categories', admin_categories_path %></li>
+ <li class="dropdown">
+ <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-expanded="false">Authorities<span class="caret"></span></a>
+ <ul class="dropdown-menu" role="menu">
+ <li><%= link_to 'Authorities', admin_bodies_path %></li>
+ <li><%= link_to 'Categories', admin_categories_path %></li>
+ </ul>
+ </li>
<li><%= link_to 'Requests', admin_requests_path %></li>
<li><%= link_to 'Users', admin_users_path %></li>
<li><%= link_to 'Tracks', admin_tracks_path %></li>
+ <li><%= link_to 'Holidays', admin_holidays_path %></li>
<li><%= link_to 'Log out', signout_path %></li>
</ul>
</div>
diff --git a/app/views/admin_holiday_imports/new.html.erb b/app/views/admin_holiday_imports/new.html.erb
new file mode 100644
index 000000000..047f321f9
--- /dev/null
+++ b/app/views/admin_holiday_imports/new.html.erb
@@ -0,0 +1,81 @@
+<% @title = "Create holidays from suggestions or iCal feed" %>
+<h1><%= @title %></h1>
+
+<%= form_for( @holiday_import, :as => 'holiday_import', :url => '', :method => 'get', :html => { :class => 'form-horizontal form-inline' }) do |f| %>
+ <% if @holiday_import.holidays.empty? %>
+ <%= error_messages_for 'holiday_import', :header_message => 'There was a problem with these import settings' %>
+ <% end %>
+ <legend>Import settings</legend>
+ <div>
+ <div class="control-group">
+ <label class="control-label">Choose the years to import holidays for</label>
+ <div class="controls">
+ <label for="import_start_year" class="inline">Start year:</label>
+ <%= f.select :start_year, (Time.now.year)..(Time.now.year + 5) %>
+ <label for="import_end_year" class="inline">End year:</label>
+ <%= f.select :end_year, (Time.now.year)..(Time.now.year + 5) %>
+ </div>
+ </div>
+
+ <div class="control-group">
+ <label class="control-label">Import from built-in suggestions or iCal feed</label>
+ <div class="controls">
+ <label class="radio inline">
+ <%= f.radio_button :source, "suggestions" %>Built-in suggestions
+ </label>
+ <label class="radio inline">
+ <%= f.radio_button :source, "feed" %>iCal feed
+ </label>
+ </div>
+ </div>
+
+ <div class="control-group">
+ <label class="control-label">iCal feed URL:</label>
+ <div class="controls">
+ <%= f.text_field 'ical_feed_url' %>
+ </div>
+ </div>
+
+ <div class="control-group">
+ <input type="submit" value="Show holidays" class="btn btn-primary">
+ </div>
+
+ </div>
+<% end %>
+
+<% if @holiday_import.populated %>
+ <h2>Holidays to import</h2>
+
+ <table class="table table-striped table-condensed">
+ <tbody>
+ <tr>
+ <td>
+ <% if @holiday_import.holidays.empty? %>
+ <% if @holiday_import.source == 'suggestions' %>
+ Sorry, we don't have any built-in suggestions for holiday days in <%= @holiday_import.suggestions_country_name %>.
+ <% else %>
+ Sorry, we couldn't find any holidays in that iCal feed.
+ <% end %>
+ <% else %>
+ <%= form_for( @holiday_import, :as => 'holiday_import', :url => admin_holiday_imports_path, :html => { :class => 'form-inline' } ) do |f| -%>
+ <%= error_messages_for 'holiday_import' %>
+ <legend>
+ <% if @holiday_import.source == 'suggestions' %>
+ Suggested holidays for <%= @holiday_import.suggestions_country_name %> (<%= @holiday_import.period %>)
+ <% else %>
+ Holidays from feed (<%= @holiday_import.period %>)
+ <% end %>
+ </legend>
+ <%= f.fields_for :holidays do |holiday_fields| %>
+ <div class="import-holiday-info">
+ <%= render :partial => 'admin_holidays/form', :locals => {:f => holiday_fields, :context => :import, :holiday => holiday_fields.object } %>
+ </div>
+ <% end%>
+ <%= f.submit "Import", :class => 'btn btn-warning' %>
+ <% end %>
+ <% end %>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+<% end %>
diff --git a/app/views/admin_holidays/_edit_form.html.erb b/app/views/admin_holidays/_edit_form.html.erb
new file mode 100644
index 000000000..b750dbf4c
--- /dev/null
+++ b/app/views/admin_holidays/_edit_form.html.erb
@@ -0,0 +1,14 @@
+<td>
+ <%= form_for(@holiday, :url => admin_holiday_path(@holiday), :html => { :class => 'form-inline edit-holiday-form'}) do |f| -%>
+ <%= render :partial => 'form', :locals => { :f => f, :holiday => @holiday, :context => :edit } %>
+ <% end %>
+
+ <div class="holiday-destroy ">
+ <%= form_for @holiday, :url => admin_holiday_path(@holiday), :method => 'delete', :html => { :class => "form form-inline delete-holiday-form" } do |f| %>
+ <%= f.submit "Destroy",
+ :class => "btn btn-danger",
+ :confirm => 'Are you sure you want to destroy this public holiday?' %>
+ <% end %>
+ </div>
+
+</td>
diff --git a/app/views/admin_holidays/_form.html.erb b/app/views/admin_holidays/_form.html.erb
new file mode 100644
index 000000000..35370e5fc
--- /dev/null
+++ b/app/views/admin_holidays/_form.html.erb
@@ -0,0 +1,22 @@
+<%= error_messages_for 'holiday' %>
+
+<div class="holiday-description">
+ <% if holiday.new_record? %>
+ <%= f.text_field :description, :class => 'input', :placeholder => 'Enter description here' %>
+ <% else %>
+ <%= f.text_field :description, :class => 'input' %>
+ <% end %>
+</div>
+
+<div class="holiday-day">
+ <%= f.date_select :day, { :use_month_numbers => true }, { :class => "day_select" } %>
+</div>
+<div class="holiday-buttons">
+ <% if context == :import %>
+ <%= f.submit "Remove", :class => 'btn remove-holiday' %>
+ <% else %>
+ <%= link_to("Cancel", admin_holidays_path, :class => 'btn') %>
+ <%= f.submit "Save", :class => 'btn btn-warning' %>
+<% end %>
+</div>
+
diff --git a/app/views/admin_holidays/_holiday.html.erb b/app/views/admin_holidays/_holiday.html.erb
new file mode 100644
index 000000000..78818f411
--- /dev/null
+++ b/app/views/admin_holidays/_holiday.html.erb
@@ -0,0 +1,7 @@
+<td>
+ <div class="holiday-description"><%= holiday.description %></div>
+ <div class="holiday-day"><%= holiday.day %></div>
+ <div class="holiday-buttons">
+ <%= link_to 'Edit', edit_admin_holiday_path(holiday), :class => "btn edit-button" %>
+ </div>
+</td>
diff --git a/app/views/admin_holidays/_new_form.html.erb b/app/views/admin_holidays/_new_form.html.erb
new file mode 100644
index 000000000..aee73f426
--- /dev/null
+++ b/app/views/admin_holidays/_new_form.html.erb
@@ -0,0 +1,10 @@
+<table class="table table-striped table-condensed">
+ <tbody>
+ <tr>
+ <td><%= form_for(@holiday, :url => admin_holidays_path, :html => { :class => 'form-inline new-holiday-form'}) do |f| -%>
+ <%= render :partial => 'form', :locals => { :f => f, :holiday => @holiday, :context => :new } %>
+ <% end %>
+ </td>
+ </tr>
+ </tbody>
+</table>
diff --git a/app/views/admin_holidays/edit.html.erb b/app/views/admin_holidays/edit.html.erb
new file mode 100644
index 000000000..8f29c9a44
--- /dev/null
+++ b/app/views/admin_holidays/edit.html.erb
@@ -0,0 +1,9 @@
+<% @title = 'Edit public holiday' %>
+<h1><%= @title %></h1>
+<table class="table table-striped table-condensed">
+ <tbody>
+ <tr>
+ <%= render :partial => 'edit_form' %>
+ </tr>
+ </tbody>
+</table>
diff --git a/app/views/admin_holidays/index.html.erb b/app/views/admin_holidays/index.html.erb
new file mode 100644
index 000000000..d4ee8706b
--- /dev/null
+++ b/app/views/admin_holidays/index.html.erb
@@ -0,0 +1,41 @@
+<% @title = 'Public Holidays' %>
+<h1><%= @title %></h1>
+<p>
+
+ Alaveteli calculates the due dates of requests taking account of the
+ public holidays shown here. If you have set the
+ <code>WORKING_OR_CALENDAR_DAYS</code><a
+ href="http://alaveteli.org/docs/customising/config/#working_or_calendar_days"
+ target="_blank">(docs)</a> setting for Alaveteli to
+ <code>working</code>, the date when a response to a request is
+ officially overdue will be calculated in days that are not weekends
+ or public holidays. If you have set
+ <code>WORKING_OR_CALENDAR_DAYS</code> to <code>calendar</code>, the
+ date will be calculated in calendar days, but if the due date falls
+ on a public holiday or weekend day, then the due date is considered
+ to be the next week day that isn't a holiday.
+
+</p>
+<div class="btn-toolbar">
+ <div class="btn-group">
+ <%= link_to 'New holiday', new_admin_holiday_path, :class => "btn btn-primary", :id => 'new-holiday-button' %>
+ </div>
+ <div class="btn-group">
+ <%= link_to 'Create holidays from suggestions or iCal feed', new_admin_holiday_import_path, :class => "btn btn-warning" %>
+ </div>
+</div>
+
+<div id="existing-holidays">
+ <% @years.each do |year| %>
+ <h2><%= year %></h2>
+ <table class="table table-striped table-condensed">
+ <tbody>
+ <% @holidays_by_year[year].sort_by(&:day).each do |holiday| %>
+ <%= content_tag_for(:tr, holiday, prefix=nil, 'data-target' => edit_admin_holiday_path(holiday)) do %>
+ <%= render :partial => 'holiday', :locals => { :holiday => holiday }%>
+ <% end %>
+ <% end %>
+ </tbody>
+ </table>
+ <% end %>
+</div>
diff --git a/app/views/admin_holidays/new.html.erb b/app/views/admin_holidays/new.html.erb
new file mode 100644
index 000000000..792c32f52
--- /dev/null
+++ b/app/views/admin_holidays/new.html.erb
@@ -0,0 +1,4 @@
+<% @title = 'New public holiday' %>
+<h1><%= @title %></h1>
+
+<%= render :partial => 'new_form' %>
diff --git a/app/views/request/followup_preview.html.erb b/app/views/request/followup_preview.html.erb
index 55afc0245..83978a2f5 100644
--- a/app/views/request/followup_preview.html.erb
+++ b/app/views/request/followup_preview.html.erb
@@ -3,7 +3,7 @@
<div id="followup">
<%= form_for(@outgoing_message, :html => { :id => 'preview_form' }, :url => (@incoming_message.nil? ? show_response_no_followup_url(:id => @info_request.id) : show_response_url(:id => @info_request.id, :incoming_message_id => @incoming_message.id)) + "#followup" ) do |o| %>
-
+
<% if @internal_review %>
<h1><%= _('Now preview your message asking for an internal review') %></h1>
<% else %>
@@ -20,7 +20,7 @@
<div class="correspondence" id="outgoing-0">
<p class="preview_subject">
<strong><%= _('To:') %></strong> <%=h OutgoingMailer.name_for_followup(@info_request, @incoming_message) %>
- <br><strong><%= _('Subject:') %></strong> <%=h OutgoingMailer.subject_for_followup(@info_request, @outgoing_message) %>
+ <br><strong><%= _('Subject:') %></strong> <%= OutgoingMailer.subject_for_followup(@info_request, @outgoing_message, :html => true) %>
</p>
<div class="correspondence_text">
diff --git a/app/views/request/preview.html.erb b/app/views/request/preview.html.erb
index 0265d0328..ddd5ab30c 100644
--- a/app/views/request/preview.html.erb
+++ b/app/views/request/preview.html.erb
@@ -23,7 +23,7 @@
<% else %>
<%=h(@info_request.public_body.name)%>
<% end %>
- <br><strong><%= _('Subject:') %></strong> <%=h @info_request.email_subject_request %>
+ <br><strong><%= _('Subject:') %></strong> <%= @info_request.email_subject_request %>
</p>
<div class="correspondence_text">