diff --git a/Gemfile b/Gemfile index 678845e36..a1a0ecf7a 100644 --- a/Gemfile +++ b/Gemfile @@ -33,6 +33,7 @@ gem 'omniauth-facebook', '~> 4.0.0' gem 'omniauth-google-oauth2', '~> 0.4.0' gem 'omniauth-twitter', '~> 1.4.0' gem 'paperclip', '~> 5.1.0' +gem 'jquery-fileupload-rails' gem 'paranoia', '~> 2.3.1' gem 'pg', '~> 0.20.0' gem 'pg_search', '~> 2.0.1' diff --git a/Gemfile.lock b/Gemfile.lock index f2a314780..a6437663e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -52,7 +52,7 @@ GEM safely_block (>= 0.1.1) user_agent_parser uuidtools - airbrussh (1.2.0) + airbrussh (1.3.0) sshkit (>= 1.6.1, != 1.7.0) akami (1.3.1) gyoku (>= 0.4.0) @@ -73,7 +73,7 @@ GEM uniform_notifier (~> 1.10.0) byebug (9.0.6) cancancan (1.16.0) - capistrano (3.8.1) + capistrano (3.8.2) airbrussh (>= 1.0.0) i18n rake (>= 10.0.0) @@ -87,7 +87,7 @@ GEM capistrano3-delayed-job (1.7.3) capistrano (~> 3.0, >= 3.0.0) daemons (~> 1.2.4) - capybara (2.14.0) + capybara (2.14.4) addressable mime-types (>= 1.16) nokogiri (>= 1.3.3) @@ -95,17 +95,17 @@ GEM rack-test (>= 0.5.4) xpath (~> 2.0) chronic (0.10.2) - ckeditor (4.2.3) + ckeditor (4.2.4) cocaine orm_adapter (~> 0.5.0) - climate_control (0.1.0) + climate_control (0.2.0) cliver (0.3.2) cocaine (0.5.8) climate_control (>= 0.0.3, < 1.0) - cocoon (1.2.9) - coffee-rails (4.2.1) + cocoon (1.2.10) + coffee-rails (4.2.2) coffee-script (>= 2.2.0) - railties (>= 4.0.0, < 5.2.x) + railties (>= 4.0.0) coffee-script (2.4.1) coffee-script-source execjs @@ -120,11 +120,11 @@ GEM daemons (1.2.4) dalli (2.7.6) database_cleaner (1.5.3) - debug_inspector (0.0.2) - delayed_job (4.1.2) - activesupport (>= 3.0, < 5.1) - delayed_job_active_record (4.1.1) - activerecord (>= 3.0, < 5.1) + debug_inspector (0.0.3) + delayed_job (4.1.3) + activesupport (>= 3.0, < 5.2) + delayed_job_active_record (4.1.2) + activerecord (>= 3.0, < 5.2) delayed_job (>= 3.0, < 5) devise (3.5.10) bcrypt (~> 3.0) @@ -144,10 +144,10 @@ GEM json thread thread_safe - email_spec (2.1.0) + email_spec (2.1.1) htmlentities (~> 4.3.3) launchy (~> 2.1) - mail (~> 2.6.3) + mail (~> 2.6) errbase (0.0.3) erubis (2.7.0) execjs (2.7.0) @@ -158,7 +158,7 @@ GEM railties (>= 3.0.0) faker (1.7.3) i18n (~> 0.5) - faraday (0.11.0) + faraday (0.12.1) multipart-post (>= 1.2, < 3) foundation-rails (6.2.4.0) railties (>= 3.1.0) @@ -170,12 +170,12 @@ GEM activesupport (>= 4.1) railties (>= 4.1) tzinfo (~> 1.2, >= 1.2.2) - geocoder (1.4.3) + geocoder (1.4.4) globalid (0.4.0) activesupport (>= 4.2.0) - graphiql-rails (1.4.1) + graphiql-rails (1.4.2) rails - graphql (1.6.3) + graphql (1.6.4) groupdate (3.2.0) activesupport (>= 3) gyoku (1.3.1) @@ -183,9 +183,10 @@ GEM hashie (3.5.5) highline (1.7.8) htmlentities (4.3.4) - httpi (2.4.1) + httpi (2.4.2) rack - i18n (0.8.4) + socksify + i18n (0.8.6) i18n-tasks (0.9.15) activesupport (>= 4.0.2) ast (>= 2.1.0) @@ -200,6 +201,10 @@ GEM railties (>= 3.1, < 6.0) invisible_captcha (0.9.2) rails (>= 3.2.0) + jquery-fileupload-rails (0.4.7) + actionpack (>= 3.1) + railties (>= 3.1) + sass (>= 3.2) jquery-rails (4.3.1) rails-dom-testing (>= 1, < 3) railties (>= 4.2.0) @@ -224,7 +229,7 @@ GEM knapsack (1.13.3) rake timecop (>= 0.1.0) - kramdown (1.13.2) + kramdown (1.14.0) launchy (2.4.3) addressable (~> 2.3) letter_opener (1.4.1) @@ -259,9 +264,9 @@ GEM nokogiri (1.8.0) mini_portile2 (~> 2.2.0) nori (2.6.0) - oauth (0.5.1) - oauth2 (1.3.1) - faraday (>= 0.8, < 0.12) + oauth (0.5.3) + oauth2 (1.4.0) + faraday (>= 0.8, < 0.13) jwt (~> 1.0) multi_json (~> 1.3) multi_xml (~> 0.5) @@ -328,7 +333,7 @@ GEM bundler (>= 1.3.0, < 2.0) railties (= 4.2.9) sprockets-rails - rails-assets-markdown-it (8.2.1) + rails-assets-markdown-it (8.2.2) rails-deprecated_sanitizer (1.0.3) activesupport (>= 4.2.0.alpha) rails-dom-testing (1.0.8) @@ -386,7 +391,7 @@ GEM sshkit (>= 1.2) safely_block (0.2.0) errbase - sass (3.4.23) + sass (3.4.25) sass-rails (5.0.6) railties (>= 4.0.0, < 6) sass (~> 3.1) @@ -408,12 +413,13 @@ GEM docile (~> 1.1.0) json (>= 1.8, < 3) simplecov-html (~> 0.10.0) - simplecov-html (0.10.0) + simplecov-html (0.10.1) sitemap_generator (5.3.1) builder (~> 3.0) social-share-button (0.10.0) coffee-rails - spring (2.0.1) + socksify (1.7.1) + spring (2.0.2) activesupport (>= 4.2) spring-commands-rspec (1.0.4) spring (>= 0.9.1) @@ -428,19 +434,19 @@ GEM actionpack (>= 4.0) activesupport (>= 4.0) sprockets (>= 3.0.0) - sshkit (1.13.1) + sshkit (1.14.0) net-scp (>= 1.1.2) net-ssh (>= 2.8.0) term-ansicolor (1.6.0) tins (~> 1.0) - terminal-table (1.7.3) - unicode-display_width (~> 1.1.1) + terminal-table (1.8.0) + unicode-display_width (~> 1.1, >= 1.1.1) thor (0.19.4) thread (0.2.2) thread_safe (0.3.6) tilt (2.0.7) - timecop (0.8.1) - tins (1.13.2) + timecop (0.9.1) + tins (1.15.0) turbolinks (2.5.3) coffee-rails turnout (2.4.0) @@ -452,14 +458,14 @@ GEM thread_safe (~> 0.1) uglifier (3.2.0) execjs (>= 0.3.0, < 3) - unicode-display_width (1.1.3) + unicode-display_width (1.3.0) unicorn (5.3.0) kgio (~> 2.6) raindrops (~> 0.7) uniform_notifier (1.10.0) - user_agent_parser (2.3.0) + user_agent_parser (2.3.1) uuidtools (2.1.5) - warden (1.2.6) + warden (1.2.7) rack (>= 1.0) wasabi (3.5.0) httpi (~> 2.0) @@ -473,7 +479,7 @@ GEM websocket-extensions (0.1.2) whenever (0.9.7) chronic (>= 0.6.3) - xpath (2.0.0) + xpath (2.1.0) nokogiri (~> 1.3) PLATFORMS @@ -515,6 +521,7 @@ DEPENDENCIES i18n-tasks (~> 0.9.15) initialjs-rails (~> 0.2.0.5) invisible_captcha (~> 0.9.2) + jquery-fileupload-rails jquery-rails (~> 4.3.1) jquery-ui-rails (~> 6.0.1) kaminari (~> 1.0.1) @@ -558,5 +565,6 @@ DEPENDENCIES web-console (~> 3.3.0) whenever (~> 0.9.7) + BUNDLED WITH - 1.15.1 + 1.15.3 diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index cf2758b78..2ff87100a 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -14,6 +14,7 @@ //= require jquery_ujs //= require jquery-ui/widgets/datepicker //= require jquery-ui/i18n/datepicker-es +//= require jquery-fileupload/basic //= require foundation //= require turbolinks //= require ckeditor/loader @@ -59,6 +60,7 @@ //= require legislation_annotatable //= require watch_form_changes //= require followable +//= require documentable //= require tree_navigator //= require custom @@ -94,6 +96,7 @@ var initialize_modules = function() { App.LegislationAnnotatable.initialize(); App.WatchFormChanges.initialize(); App.TreeNavigator.initialize(); + App.Documentable.initialize(); }; $(function(){ diff --git a/app/assets/javascripts/documentable.js.coffee b/app/assets/javascripts/documentable.js.coffee new file mode 100644 index 000000000..8683ce5e2 --- /dev/null +++ b/app/assets/javascripts/documentable.js.coffee @@ -0,0 +1,101 @@ +App.Documentable = + + initialize: -> + @initializeDirectUploads() + @initializeInterface() + + initializeDirectUploads: -> + + $('input.document_ajax_attachment[type=file]').fileupload + + paramName: "document[attachment]" + + formData: null + + add: (e, data) -> + wrapper = $(e.target).closest('.document') + index = $(e.target).data('index') + is_nested_document = $(e.target).data('nested-document') + $(wrapper).find('.progress-bar-placeholder').empty() + data.progressBar = $(wrapper).find('.progress-bar-placeholder').html('
') + $(wrapper).find('.progress-bar-placeholder').css('display','block') + data.formData = { + "document[title]": $(wrapper).find('input.document-title').val() || data.files[0].name + "index": index, + "nested_document": is_nested_document + } + data.submit() + + change: (e, data) -> + wrapper = $(e.target).parent() + $.each(data.files, (index, file)-> + $(wrapper).find('.file-name').text(file.name) + ) + + progress: (e, data) -> + progress = parseInt(data.loaded / data.total * 100, 10) + $(data.progressBar).find('.loading-bar').css 'width', progress + '%' + return + + initializeInterface: -> + input_files = $('input.document_ajax_attachment[type=file]') + + $.each input_files, (index, file) -> + wrapper = $(file).parent() + App.Documentable.watchRemoveDocumentbutton(wrapper) + + watchRemoveDocumentbutton: (wrapper) -> + remove_document_button = $(wrapper).find('.remove-document') + $(remove_document_button).on 'click', (e) -> + e.preventDefault() + $(wrapper).remove() + $('#new_document_link').show() + $('.max-documents-notice').hide() + + uploadNestedDocument: (id, nested_document, result) -> + $('#' + id).replaceWith(nested_document) + @updateLoadingBar(id, result) + @initialize() + + uploadPlainDocument: (id, nested_document, result) -> + $('#' + id).replaceWith(nested_document) + @updateLoadingBar(id, result) + @initialize() + + updateLoadingBar: (id, result) -> + if result + $('#' + id).find('.loading-bar').addClass 'complete' + else + $('#' + id).find('.loading-bar').addClass 'errors' + $('#' + id).find('.progress-bar-placeholder').css('display','block') + + new: (nested_fields) -> + $(".documents-list").append(nested_fields) + @initialize() + + destroyNestedDocument: (id, notice) -> + $('#' + id).remove() + @updateNotice(notice) + + replacePlainDocument: (id, notice, plain_document) -> + $('#' + id).replaceWith(plain_document) + @updateNotice(notice) + @initialize() + + updateNotice: (notice) -> + if $('[data-alert]').length > 0 + $('[data-alert]').replaceWith(notice) + else + $("body").append(notice) + + updateNewDocumentButton: (link) -> + if $('.document').length >= $('.documents').data('max-documents') + $('#new_document_link').hide() + $('.max-documents-notice').removeClass('hide') + $('.max-documents-notice').show() + else if $('#new_document_link').length > 0 + $('#new_document_link').replaceWith(link) + $('.max-documents-notice').hide() + else + $('.max-documents-notice').hide() + $(link).insertBefore('.documents hr:last') diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index a14cf3383..6db475365 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -15,3 +15,4 @@ @import 'annotator_overrides'; @import 'jquery-ui/datepicker'; @import 'datepicker_overrides'; +@import 'documentable'; diff --git a/app/assets/stylesheets/documentable.scss b/app/assets/stylesheets/documentable.scss new file mode 100644 index 000000000..2aa015a14 --- /dev/null +++ b/app/assets/stylesheets/documentable.scss @@ -0,0 +1,59 @@ +.progress-bar-placeholder { + display: none; +} + +.document-form { + .document .file-name { + margin-top: 0; + } + .progress-bar-placeholder { + margin-bottom: 15px; + } + .document .loading-bar.errors { + margin-top: $line-height * 2; + } +} + +.document { + .button { + font-weight: normal; + } + + .progress-bar { + width: 100%; + background-color: $light-gray; + } + + input.document_ajax_attachment[type=file]{ + display: none; + } + + .file-name { + margin-top: $line-height / 2; + } + + .loading-bar { + height: 5px; + width: 0; + transition: width 500ms ease-out; + + &.uploading { + background-color: $dark-gray; + } + + &.complete { + background-color: $success-color; + width: 100%; + } + + &.errors { + background-color: $alert-color; + width: 100%; + margin-top: $line-height / 2; + } + } + + .loading-bar.no-transition { + transition: none; + } +} diff --git a/app/assets/stylesheets/icons.scss b/app/assets/stylesheets/icons.scss index 782b5cdb2..5e92ee460 100644 --- a/app/assets/stylesheets/icons.scss +++ b/app/assets/stylesheets/icons.scss @@ -97,6 +97,10 @@ content: '\72'; } +.icon-documents::before { + content: '\68'; +} + .icon-proposals::before { content: '\68'; } diff --git a/app/assets/stylesheets/layout.scss b/app/assets/stylesheets/layout.scss index 501b0af6d..920d37323 100644 --- a/app/assets/stylesheets/layout.scss +++ b/app/assets/stylesheets/layout.scss @@ -19,6 +19,7 @@ // 17. Activity // 18. Banners // 19. Recommended Section Home +// 20. Documents // // 01. Global styles @@ -92,6 +93,11 @@ a { color: $link; } +.button.hollow.error { + border-color: $alert-border; + color: $color-alert; +} + .postfix.button { padding: 0; } @@ -2154,7 +2160,6 @@ table { .section-recommended { padding: $line-height * 2 0; - // padding-bottom: $line-height * 2; h2 { margin-bottom: $line-height * 2; @@ -2278,4 +2283,124 @@ table { } } +// 20. Documents +.document-form form { + + .radio-buttons { + label { + margin-right: $line-height; + } + } + + .source-option-link { + input { + padding-bottom: 0; + } + + .error { + margin-bottom: $line-height; + } + + label { + &.error { + margin-bottom: 0; + } + } + } + + .source-option-file { + .file-name { + label { + + @include breakpoint(small medium) { + float: none; + } + + @include breakpoint(large) { + float: left; + } + } + + p { + + @include breakpoint(small medium) { + float: none; + margin-top: 0; + margin-left: 0; + margin-bottom: 0; + } + + @include breakpoint(large) { + float: left; + margin-bottom: 0; + margin-top: $line-height / 2; + margin-left: $line-height; + } + } + } + } + + .attachment-errors { + margin-bottom: $line-height; + } +} + +.documents-list { + + table { + border: 0; + } + + td { + position: relative; + + @include breakpoint(small) { + float: left; + width: 100%; + } + + @include breakpoint(medium) { + float: none; + } + + a { + width: 100%; + } + + &:first-child { + padding-left: $line-height * 1.5; + + @include breakpoint(small) { + width: 100%; + } + + @include breakpoint(medium) { + width: 70%; + } + + @include breakpoint(large) { + width: 80%; + } + } + + &:first-child::before { + color: #007bb7; + content: 'G'; + font-family: "icons" !important; + font-size: rem-calc(24); + left: rem-calc(6); + position: absolute; + top: 0; + + @include breakpoint(small) { + padding-top: rem-calc(12); + } + + @include breakpoint(medium) { + padding-top: rem-calc(22); + } + + } + + } } diff --git a/app/assets/stylesheets/participation.scss b/app/assets/stylesheets/participation.scss index 38a9fb2f8..8d79eca55 100644 --- a/app/assets/stylesheets/participation.scss +++ b/app/assets/stylesheets/participation.scss @@ -248,11 +248,13 @@ .debate-form, .proposal-form, .budget-investment-form, -.spending-proposal-form { +.spending-proposal-form, +.document-form { .icon-debates, .icon-proposals, - .icon-budget { + .icon-budget, + .icon-documents { font-size: rem-calc(50); line-height: $line-height; opacity: 0.5; @@ -262,7 +264,8 @@ color: $debates; } - .icon-proposals { + .icon-proposals, + .icon-documents { color: $proposals; } @@ -294,7 +297,8 @@ } } -.proposal-form { +.proposal-form, +.document-form { .recommendations li::before { color: $proposals; @@ -746,6 +750,12 @@ display: none; } +.document-form{ + max-width: 75rem; + margin-left: auto; + margin-right: auto; +} + .more-info { clear: both; color: $text-medium; diff --git a/app/controllers/budgets/investments_controller.rb b/app/controllers/budgets/investments_controller.rb index 16144cb27..799d1f76d 100644 --- a/app/controllers/budgets/investments_controller.rb +++ b/app/controllers/budgets/investments_controller.rb @@ -44,10 +44,12 @@ module Budgets set_comment_flags(@comment_tree.comments) load_investment_votes(@investment) @investment_ids = [@investment.id] + @document = Document.new(documentable: @investment) end def create @investment.author = current_user + recover_documents_from_cache(@investment) if @investment.save Mailer.budget_investment_created(@investment).deliver_later @@ -104,7 +106,8 @@ module Budgets def investment_params params.require(:budget_investment).permit(:title, :description, :external_url, :heading_id, :tag_list, - :organization_name, :location, :terms_of_service) + :organization_name, :location, :terms_of_service, + documents_attributes: [:id, :title, :attachment, :cached_attachment, :user_id]) end def load_ballot diff --git a/app/controllers/concerns/commentable_actions.rb b/app/controllers/concerns/commentable_actions.rb index 72a427c59..e0a2d7719 100644 --- a/app/controllers/concerns/commentable_actions.rb +++ b/app/controllers/concerns/commentable_actions.rb @@ -63,6 +63,8 @@ module CommentableActions def update resource.assign_attributes(strong_params) + recover_documents_from_cache(resource) + if resource.save redirect_to resource, notice: t("flash.actions.update.#{resource_name.underscore}") else @@ -115,4 +117,11 @@ module CommentableActions nil end + def recover_documents_from_cache(resource) + return false unless resource.try(:documents) + resource.documents = resource.documents.each do |document| + document.set_attachment_from_cached_attachment if document.cached_attachment.present? + end + end + end diff --git a/app/controllers/documents_controller.rb b/app/controllers/documents_controller.rb new file mode 100644 index 000000000..ceb7d191f --- /dev/null +++ b/app/controllers/documents_controller.rb @@ -0,0 +1,100 @@ +class DocumentsController < ApplicationController + before_action :authenticate_user! + before_filter :find_documentable, except: :destroy + before_filter :prepare_new_document, only: [:new, :new_nested] + before_filter :prepare_document_for_creation, only: :create + + load_and_authorize_resource except: :upload + skip_authorization_check only: :upload + + def new + end + + def new_nested + end + + def create + recover_attachments_from_cache + + if @document.save + flash[:notice] = t "documents.actions.create.notice" + redirect_to params[:from] + else + flash[:alert] = t "documents.actions.create.alert" + render :new + end + end + + def destroy + respond_to do |format| + format.html do + if @document.destroy + flash[:notice] = t "documents.actions.destroy.notice" + else + flash[:alert] = t "documents.actions.destroy.alert" + end + redirect_to params[:from] + end + format.js do + if @document.destroy + flash.now[:notice] = t "documents.actions.destroy.notice" + else + flash.now[:alert] = t "documents.actions.destroy.alert" + end + end + end + end + + def destroy_upload + @document = Document.new(cached_attachment: params[:path]) + @document.set_attachment_from_cached_attachment + @document.documentable = @documentable + + if @document.attachment.destroy + flash.now[:notice] = t "documents.actions.destroy.notice" + else + flash.now[:alert] = t "documents.actions.destroy.alert" + end + render :destroy + end + + def upload + @document = Document.new(document_params.merge(user: current_user)) + @document.documentable = @documentable + + if @document.valid? + @document.attachment.save + @document.set_cached_attachment_from_attachment(URI(request.url)) + else + @document.attachment.destroy + end + end + + private + + def document_params + params.require(:document).permit(:title, :documentable_type, :documentable_id, + :attachment, :cached_attachment, :user_id) + end + + def find_documentable + @documentable = params[:documentable_type].constantize.find_or_initialize_by(id: params[:documentable_id]) + end + + def prepare_new_document + @document = Document.new(documentable: @documentable, user_id: current_user.id) + end + + def prepare_document_for_creation + @document = Document.new(document_params) + @document.documentable = @documentable + @document.user = current_user + end + + def recover_attachments_from_cache + if @document.attachment.blank? && @document.cached_attachment.present? + @document.set_attachment_from_cached_attachment + end + end + +end diff --git a/app/controllers/proposals_controller.rb b/app/controllers/proposals_controller.rb index 2a8f71309..75b3996fb 100644 --- a/app/controllers/proposals_controller.rb +++ b/app/controllers/proposals_controller.rb @@ -19,11 +19,13 @@ class ProposalsController < ApplicationController def show super @notifications = @proposal.notifications + @document = Document.new(documentable: @proposal) redirect_to proposal_path(@proposal), status: :moved_permanently if request.path != proposal_path(@proposal) end def create @proposal = Proposal.new(proposal_params.merge(author: current_user)) + recover_documents_from_cache(@proposal) if @proposal.save redirect_to share_proposal_path(@proposal), notice: I18n.t('flash.actions.create.proposal') @@ -75,7 +77,8 @@ class ProposalsController < ApplicationController def proposal_params params.require(:proposal).permit(:title, :question, :summary, :description, :external_url, :video_url, - :responsible_name, :tag_list, :terms_of_service, :geozone_id) + :responsible_name, :tag_list, :terms_of_service, :geozone_id, + documents_attributes: [:id, :title, :attachment, :cached_attachment, :user_id] ) end def retired_params @@ -121,4 +124,5 @@ class ProposalsController < ApplicationController def load_successful_proposals @proposal_successful_exists = Proposal.successful.exists? end + end diff --git a/app/helpers/documentables_helper.rb b/app/helpers/documentables_helper.rb new file mode 100644 index 000000000..4fd737908 --- /dev/null +++ b/app/helpers/documentables_helper.rb @@ -0,0 +1,41 @@ +module DocumentablesHelper + + def documentable_class(documentable) + documentable.class.name.parameterize('_') + end + + def max_documents_allowed(documentable) + documentable.class.max_documents_allowed + end + + def max_file_size(documentable) + bytesToMeg(documentable.class.max_file_size) + end + + def accepted_content_types(documentable) + documentable.class.accepted_content_types + end + + def accepted_content_types_extensions(documentable_class) + documentable_class.accepted_content_types + .collect{ |content_type| ".#{content_type.split("/").last}" } + .join(",") + end + + def humanized_accepted_content_types(documentable) + documentable.class.accepted_content_types + .collect{ |content_type| content_type.split("/").last } + .join(", ") + end + + def documentables_note(documentable) + t "documents.form.note", max_documents_allowed: max_documents_allowed(documentable), + accepted_content_types: humanized_accepted_content_types(documentable), + max_file_size: max_file_size(documentable) + end + + def max_documents_allowed?(documentable) + documentable.documents.count >= documentable.class.max_documents_allowed + end + +end \ No newline at end of file diff --git a/app/helpers/documents_helper.rb b/app/helpers/documents_helper.rb new file mode 100644 index 000000000..17d70068b --- /dev/null +++ b/app/helpers/documents_helper.rb @@ -0,0 +1,89 @@ +module DocumentsHelper + + def document_attachment_file_name(document) + document.attachment_file_name + end + + def errors_on_attachment(document) + document.errors[:attachment].join(', ') if document.errors.key?(:attachment) + end + + def bytesToMeg(bytes) + bytes / Numeric::MEGABYTE + end + + def document_nested_field_name(document, index, field) + parent = document.documentable_type.parameterize.underscore + "#{parent.parameterize}[documents_attributes][#{index}][#{field}]" + end + + def document_nested_field_id(document, index, field) + parent = document.documentable_type.parameterize.underscore + "#{parent.parameterize}_documents_attributes_#{index}_#{field}" + end + + def document_nested_field_wrapper_id(index) + "document_#{index}" + end + + def render_destroy_document_link(document, index) + if document.persisted? + link_to t('documents.form.delete_button'), + document_path(document, index: index, nested_document: true), + method: :delete, + remote: true, + data: { confirm: t('documents.actions.destroy.confirm') }, + class: "delete float-right" + elsif !document.persisted? && document.cached_attachment.present? + link_to t('documents.form.delete_button'), + destroy_upload_documents_path(path: document.cached_attachment, + nested_document: true, + index: index, + documentable_type: document.documentable_type, + documentable_id: document.documentable_id), + method: :delete, + remote: true, + class: "delete float-right" + else + link_to t('documents.form.delete_button'), + "#", + class: "delete float-right remove-document" + end + end + + def render_attachment(document, index) + html = file_field_tag :attachment, + accept: accepted_content_types_extensions(document.documentable_type.constantize), + class: 'document_ajax_attachment', + data: { + url: document_direct_upload_url(document), + cached_attachment_input_field: document_nested_field_id(document, index, :cached_attachment), + multiple: false, + index: index, + nested_document: true + }, + name: document_nested_field_name(document, index, :attachment), + id: document_nested_field_id(document, index, :attachment) + if document.attachment.blank? && document.cached_attachment.blank? + klass = document.errors[:attachment].any? ? "error" : "" + html += label_tag document_nested_field_id(document, index, :attachment), + t("documents.form.attachment_label"), + class: "button hollow #{klass}" + if document.errors[:attachment].any? + html += content_tag :small, class: "error" do + errors_on_attachment(document) + end + end + end + html + end + + def document_direct_upload_url(document) + upload_documents_url( + documentable_type: document.documentable_type, + documentable_id: document.documentable_id, + format: :js + ) + end + +end diff --git a/app/models/abilities/administrator.rb b/app/models/abilities/administrator.rb index bc2fea5d4..b8f80c0cd 100644 --- a/app/models/abilities/administrator.rb +++ b/app/models/abilities/administrator.rb @@ -73,6 +73,7 @@ module Abilities can [:manage], ::Legislation::Question cannot :comment_as_moderator, [::Legislation::Question, Legislation::Annotation] + can [:create, :destroy], Document end end end diff --git a/app/models/abilities/common.rb b/app/models/abilities/common.rb index 33a9d50f6..311b0dade 100644 --- a/app/models/abilities/common.rb +++ b/app/models/abilities/common.rb @@ -36,6 +36,9 @@ module Abilities can [:create, :destroy], Follow + can [:create, :destroy, :new], Document, documentable: { author_id: user.id } + can [:new_nested, :upload, :destroy_upload], Document + unless user.organization? can :vote, Debate can :vote, Comment diff --git a/app/models/budget/investment.rb b/app/models/budget/investment.rb index a5bea4508..0dfd7836c 100644 --- a/app/models/budget/investment.rb +++ b/app/models/budget/investment.rb @@ -1,12 +1,16 @@ class Budget class Investment < ActiveRecord::Base - include Measurable include Sanitizable include Taggable include Searchable include Reclassification include Followable + include Documentable + documentable max_documents_allowed: 3, + max_file_size: 3.megabytes, + accepted_content_types: [ "application/pdf" ] + accepts_nested_attributes_for :documents, allow_destroy: true acts_as_votable acts_as_paranoid column: :hidden_at diff --git a/app/models/concerns/documentable.rb b/app/models/concerns/documentable.rb new file mode 100644 index 000000000..4aeaf6eab --- /dev/null +++ b/app/models/concerns/documentable.rb @@ -0,0 +1,20 @@ +module Documentable + extend ActiveSupport::Concern + + included do + has_many :documents, as: :documentable, dependent: :destroy + end + + module ClassMethods + attr_reader :max_documents_allowed, :max_file_size, :accepted_content_types + + private + + def documentable(options= {}) + @max_documents_allowed = options[:max_documents_allowed] + @max_file_size = options[:max_file_size] + @accepted_content_types = options[:accepted_content_types] + end + end + +end diff --git a/app/models/document.rb b/app/models/document.rb new file mode 100644 index 000000000..7fd82ea33 --- /dev/null +++ b/app/models/document.rb @@ -0,0 +1,81 @@ +class Document < ActiveRecord::Base + include DocumentsHelper + include DocumentablesHelper + has_attached_file :attachment, path: ":rails_root/public/system/:class/:prefix/:style/:filename" + attr_accessor :cached_attachment + + belongs_to :user + belongs_to :documentable, polymorphic: true + + # Disable paperclip security validation due to polymorphic configuration + # Paperclip do not allow to user Procs on valiations definition + do_not_validate_attachment_file_type :attachment + validate :attachment_presence + validate :validate_attachment_content_type, if: -> { attachment.present? } + validate :validate_attachment_size, if: -> { attachment.present? } + validates :title, presence: true + validates :user_id, presence: true + validates :documentable_id, presence: true, if: -> { persisted? } + validates :documentable_type, presence: true, if: -> { persisted? } + + after_save :remove_cached_document, if: -> { valid? && persisted? && cached_attachment.present? } + + def set_cached_attachment_from_attachment(prefix) + self.cached_attachment = if Paperclip::Attachment.default_options[:storage] == :filesystem + attachment.path + else + prefix + attachment.url + end + end + + def set_attachment_from_cached_attachment + self.attachment = if Paperclip::Attachment.default_options[:storage] == :filesystem + File.open(cached_attachment) + else + URI.parse(cached_attachment) + end + end + + Paperclip.interpolates :prefix do |attachment, style| + attachment.instance.prefix(attachment, style) + end + + def prefix(attachment, style) + if !attachment.instance.persisted? + "cached_attachments/user/#{attachment.instance.user_id}" + else + ":attachment/:id_partition" + end + end + + private + + def validate_attachment_size + if documentable.present? && + attachment_file_size > documentable.class.max_file_size + errors[:attachment] = I18n.t("documents.errors.messages.in_between", + min: "0 Bytes", + max: "#{max_file_size(documentable)} MB") + end + end + + def validate_attachment_content_type + if documentable.present? && + !accepted_content_types(documentable).include?(attachment_content_type) + errors[:attachment] = I18n.t("documents.errors.messages.wrong_content_type", + content_type: attachment_content_type, + accepted_content_types: humanized_accepted_content_types(documentable)) + end + end + + def attachment_presence + if attachment.blank? && cached_attachment.blank? + errors[:attachment] = I18n.t("errors.messages.blank") + end + end + + def remove_cached_document + File.delete(cached_attachment) if File.exists?(cached_attachment) + end + +end diff --git a/app/models/proposal.rb b/app/models/proposal.rb index f4bf1694d..bb8ddfa29 100644 --- a/app/models/proposal.rb +++ b/app/models/proposal.rb @@ -9,6 +9,11 @@ class Proposal < ActiveRecord::Base include HasPublicAuthor include Graphqlable include Followable + include Documentable + documentable max_documents_allowed: 3, + max_file_size: 3.megabytes, + accepted_content_types: [ "application/pdf" ] + accepts_nested_attributes_for :documents, allow_destroy: true acts_as_votable acts_as_paranoid column: :hidden_at diff --git a/app/views/budgets/investments/_filter_subnav.html.erb b/app/views/budgets/investments/_filter_subnav.html.erb index a46c33b2d..01fef4657 100644 --- a/app/views/budgets/investments/_filter_subnav.html.erb +++ b/app/views/budgets/investments/_filter_subnav.html.erb @@ -17,6 +17,14 @@ <% end %> +
  • + <%= link_to "#tab-documents" do %> +

    + <%= t("documents.tab") %> + (<%= @investment.documents.count %>) +

    + <% end %> +
  • diff --git a/app/views/budgets/investments/_form.html.erb b/app/views/budgets/investments/_form.html.erb index 94f6283e9..7cf2bb1e2 100644 --- a/app/views/budgets/investments/_form.html.erb +++ b/app/views/budgets/investments/_form.html.erb @@ -21,6 +21,10 @@ <%= f.text_field :external_url %> +
    + <%= render 'documents/nested_documents', documentable: @investment %> +
    +
    <%= f.text_field :location %>
    diff --git a/app/views/budgets/investments/_investment_show.html.erb b/app/views/budgets/investments/_investment_show.html.erb index 434934c7e..3f1890ae8 100644 --- a/app/views/budgets/investments/_investment_show.html.erb +++ b/app/views/budgets/investments/_investment_show.html.erb @@ -4,6 +4,12 @@
    <%= back_link_to budget_investments_path(investment.budget, heading_id: investment.heading) %> + <% if can?(:create, @document) && investment.documents.size < Budget::Investment.max_documents_allowed %> + <%= link_to t("documents.upload_document"), + new_document_path(documentable_id:investment, documentable_type: investment.class.name, from: request.url), + class: 'button hollow float-right' %> + <% end %> +

    <%= investment.title %>

    @@ -14,7 +20,6 @@  •  <%= investment.heading.name %>
    -

    <%= t("budgets.investments.show.code_html", code: investment.id) %> @@ -51,6 +56,7 @@

    <%= t('budgets.investments.show.price_explanation') %>

    <%= investment.price_explanation %>

    <% end %> +