diff --git a/app/controllers/admin/spending_proposals_controller.rb b/app/controllers/admin/spending_proposals_controller.rb new file mode 100644 index 000000000..817de285d --- /dev/null +++ b/app/controllers/admin/spending_proposals_controller.rb @@ -0,0 +1,23 @@ +class Admin::SpendingProposalsController < Admin::BaseController + has_filters %w{unresolved accepted rejected}, only: :index + + load_and_authorize_resource + + def index + @spending_proposals = @spending_proposals.includes([:geozone]).send(@current_filter).order(created_at: :desc).page(params[:page]) + end + + def show + end + + def accept + @spending_proposal.accept + redirect_to request.query_parameters.merge(action: :index) + end + + def reject + @spending_proposal.reject + redirect_to request.query_parameters.merge(action: :index) + end + +end diff --git a/app/controllers/moderation/users_controller.rb b/app/controllers/moderation/users_controller.rb index 6ff22f94a..3255bb6cc 100644 --- a/app/controllers/moderation/users_controller.rb +++ b/app/controllers/moderation/users_controller.rb @@ -30,4 +30,4 @@ class Moderation::UsersController < Moderation::BaseController Activity.log(current_user, :block, @user) end -end +end \ No newline at end of file diff --git a/app/controllers/spending_proposals_controller.rb b/app/controllers/spending_proposals_controller.rb new file mode 100644 index 000000000..a7871549f --- /dev/null +++ b/app/controllers/spending_proposals_controller.rb @@ -0,0 +1,32 @@ +class SpendingProposalsController < ApplicationController + before_action :authenticate_user!, except: [:index] + + load_and_authorize_resource + + def index + end + + def new + @spending_proposal = SpendingProposal.new + @featured_tags = ActsAsTaggableOn::Tag.where(featured: true) + end + + def create + @spending_proposal = SpendingProposal.new(spending_proposal_params) + @spending_proposal.author = current_user + + if @spending_proposal.save_with_captcha + redirect_to spending_proposals_path, notice: t('flash.actions.create.notice', resource_name: t("activerecord.models.spending_proposal", count: 1)) + else + @featured_tags = ActsAsTaggableOn::Tag.where(featured: true) + render :new + end + end + + private + + def spending_proposal_params + params.require(:spending_proposal).permit(:title, :description, :external_url, :geozone_id, :terms_of_service, :captcha, :captcha_key) + end + +end \ No newline at end of file diff --git a/app/helpers/geozones_helper.rb b/app/helpers/geozones_helper.rb new file mode 100644 index 000000000..bfc5f9105 --- /dev/null +++ b/app/helpers/geozones_helper.rb @@ -0,0 +1,11 @@ +module GeozonesHelper + + def geozone_name(geozonable) + geozonable.geozone ? geozonable.geozone.name : t("geozones.none") + end + + def geozone_select_options + Geozone.all.order(name: :asc).collect { |g| [ g.name, g.id ] } + end + +end diff --git a/app/models/abilities/administrator.rb b/app/models/abilities/administrator.rb index 4af32a75c..6ba99e4fa 100644 --- a/app/models/abilities/administrator.rb +++ b/app/models/abilities/administrator.rb @@ -34,6 +34,8 @@ module Abilities can [:search, :create, :index, :destroy], ::Moderator can :manage, Annotation + + can :manage, SpendingProposal end end end diff --git a/app/models/abilities/common.rb b/app/models/abilities/common.rb index 181238d2c..4d74ce375 100644 --- a/app/models/abilities/common.rb +++ b/app/models/abilities/common.rb @@ -17,6 +17,8 @@ module Abilities proposal.editable_by?(user) end + can :read, SpendingProposal + can :create, Comment can :create, Debate can :create, Proposal @@ -38,6 +40,7 @@ module Abilities if user.level_two_or_three_verified? can :vote, Proposal can :vote_featured, Proposal + can :create, SpendingProposal end can :create, Annotation diff --git a/app/models/abilities/everyone.rb b/app/models/abilities/everyone.rb index 122d5db2a..377ef7440 100644 --- a/app/models/abilities/everyone.rb +++ b/app/models/abilities/everyone.rb @@ -6,6 +6,7 @@ module Abilities can :read, Debate can :read, Proposal can :read, Comment + can :read, SpendingProposal can :read, Legislation can :read, User can [:search, :read], Annotation diff --git a/app/models/concerns/sanitizable.rb b/app/models/concerns/sanitizable.rb index 65c8431ef..261eccbf2 100644 --- a/app/models/concerns/sanitizable.rb +++ b/app/models/concerns/sanitizable.rb @@ -13,7 +13,7 @@ module Sanitizable end def sanitize_tag_list - self.tag_list = TagSanitizer.new.sanitize_tag_list(self.tag_list) + self.tag_list = TagSanitizer.new.sanitize_tag_list(self.tag_list) if self.class.taggable? end end diff --git a/app/models/geozone.rb b/app/models/geozone.rb new file mode 100644 index 000000000..303671493 --- /dev/null +++ b/app/models/geozone.rb @@ -0,0 +1,3 @@ +class Geozone < ActiveRecord::Base + validates :name, presence: true +end diff --git a/app/models/spending_proposal.rb b/app/models/spending_proposal.rb new file mode 100644 index 000000000..157b5f586 --- /dev/null +++ b/app/models/spending_proposal.rb @@ -0,0 +1,44 @@ +class SpendingProposal < ActiveRecord::Base + include Measurable + include Sanitizable + + apply_simple_captcha + + RESOLUTIONS = ["accepted", "rejected"] + + belongs_to :author, -> { with_hidden }, class_name: 'User', foreign_key: 'author_id' + belongs_to :geozone + + validates :title, presence: true + validates :author, presence: true + validates :description, presence: true + + validates :title, length: { in: 4..SpendingProposal.title_max_length } + validates :description, length: { maximum: SpendingProposal.description_max_length } + validates :resolution, inclusion: { in: RESOLUTIONS, allow_nil: true } + validates :terms_of_service, acceptance: { allow_nil: false }, on: :create + + scope :accepted, -> { where(resolution: "accepted") } + scope :rejected, -> { where(resolution: "rejected") } + scope :unresolved, -> { where(resolution: nil) } + + def accept + update_attribute(:resolution, "accepted") + end + + def reject + update_attribute(:resolution, "rejected") + end + + def accepted? + resolution == "accepted" + end + + def rejected? + resolution == "rejected" + end + + def unresolved? + resolution.blank? + end +end diff --git a/app/views/admin/_menu.html.erb b/app/views/admin/_menu.html.erb index 5f45e96c8..b27a8a73b 100644 --- a/app/views/admin/_menu.html.erb +++ b/app/views/admin/_menu.html.erb @@ -32,6 +32,13 @@ <% end %> +
  • > + <%= link_to admin_spending_proposals_path do %> + + <%= t("admin.menu.spending_proposals") %> + <% end %> +
  • +
  • > <%= link_to admin_users_path do %> diff --git a/app/views/admin/spending_proposals/index.html.erb b/app/views/admin/spending_proposals/index.html.erb new file mode 100644 index 000000000..0f381bb1f --- /dev/null +++ b/app/views/admin/spending_proposals/index.html.erb @@ -0,0 +1,36 @@ +

    <%= t("admin.spending_proposals.index.title") %>

    + +<%= render 'shared/filter_subnav', i18n_namespace: "admin.spending_proposals.index" %> + +

    <%= page_entries_info @spending_proposals %>

    + + + <% @spending_proposals.each do |spending_proposal| %> + + + + + + <% end %> +
    + <%= link_to spending_proposal.title, admin_spending_proposal_path(spending_proposal) %> + + <%= geozone_name(spending_proposal) %> + + <% unless spending_proposal.accepted? %> + <%= link_to t("admin.spending_proposals.actions.accept"), + accept_admin_spending_proposal_path(spending_proposal, request.query_parameters), + method: :put, + data: { confirm: t("admin.actions.confirm") }, + class: "button radius tiny success no-margin" %> + <% end %> + <% unless spending_proposal.rejected? %> + <%= link_to t("admin.spending_proposals.actions.reject"), + reject_admin_spending_proposal_path(spending_proposal, request.query_parameters), + method: :put, + data: { confirm: t("admin.actions.confirm") }, + class: "button radius tiny warning right" %> + <% end %> +
    + +<%= paginate @spending_proposals %> diff --git a/app/views/admin/spending_proposals/show.html.erb b/app/views/admin/spending_proposals/show.html.erb new file mode 100644 index 000000000..121f28940 --- /dev/null +++ b/app/views/admin/spending_proposals/show.html.erb @@ -0,0 +1,28 @@ +

    <%= @spending_proposal.title %>

    + +<%= safe_html_with_links @spending_proposal.description.html_safe %> + +<% if @spending_proposal.external_url.present? %> +

    <%= text_with_links @spending_proposal.external_url %>

    +<% end %> + +

    <%= t("admin.spending_proposals.show.by") %>: <%= link_to @spending_proposal.author.name, admin_user_path(@spending_proposal.author) %>

    +

    <%= t("admin.spending_proposals.show.geozone") %>: <%= geozone_name(@spending_proposal) %>

    +

    <%= l @spending_proposal.created_at, format: :datetime %>

    + +

    + <% unless @spending_proposal.accepted? %> + <%= link_to t("admin.spending_proposals.actions.accept"), + accept_admin_spending_proposal_path(@spending_proposal), + method: :put, + data: { confirm: t("admin.actions.confirm") }, + class: "button radius tiny success no-margin" %> + <% end %> + <% unless @spending_proposal.rejected? %> + <%= link_to t("admin.spending_proposals.actions.reject"), + reject_admin_spending_proposal_path(@spending_proposal), + method: :put, + data: { confirm: t("admin.actions.confirm") }, + class: "button radius tiny warning" %> + <% end %> +

    diff --git a/app/views/layouts/_header.html.erb b/app/views/layouts/_header.html.erb index 78899aba5..a48ff8dbd 100644 --- a/app/views/layouts/_header.html.erb +++ b/app/views/layouts/_header.html.erb @@ -33,14 +33,16 @@
  • + <%= link_to page_path('spending_proposals_info') do %> + <%= t('pages.more_information.titles.spending_proposals_info') %> + <%= t('pages.more_information.description.spending_proposals_info') %> + <% end %> +
  • +
  • <%= link_to page_path('participation_world') do %> <%= t('pages.more_information.titles.participation_world') %> <%= t('pages.more_information.description.participation_world') %> diff --git a/app/views/pages/spending_proposals_info.html.erb b/app/views/pages/spending_proposals_info.html.erb new file mode 100644 index 000000000..bb02f0286 --- /dev/null +++ b/app/views/pages/spending_proposals_info.html.erb @@ -0,0 +1,20 @@ +
    +
    + +
    +

    ¿Cómo funcionan los presupuestos ciudadanos?

    + +

    Explicación detallada del proceso

    +

    Próximamente se podrá encontrar aquí una descripción del proceso de participación ciudadana en los presupuestos.

    + +
    +
    +
    \ No newline at end of file diff --git a/app/views/proposals/_form.html.erb b/app/views/proposals/_form.html.erb index 4c14730fe..97e1ecb6b 100644 --- a/app/views/proposals/_form.html.erb +++ b/app/views/proposals/_form.html.erb @@ -79,6 +79,4 @@ <%= f.submit(class: "button radius", value: t("proposals.#{action_name}.form.submit_button")) %> -<% end %> - - +<% end %> \ No newline at end of file diff --git a/app/views/shared/_errors.html.erb b/app/views/shared/_errors.html.erb index 3ced01dbf..ac8abe7e2 100644 --- a/app/views/shared/_errors.html.erb +++ b/app/views/shared/_errors.html.erb @@ -8,7 +8,7 @@ <% if local_assigns[:message].present? %> <%= message %> <% else %> - <%= t("form.not_saved", resource: t("form.#{resource.class.to_s.downcase}")) %> + <%= t("form.not_saved", resource: t("form.#{resource.class.to_s.underscore}")) %> <% end %> diff --git a/app/views/spending_proposals/_form.html.erb b/app/views/spending_proposals/_form.html.erb new file mode 100644 index 000000000..4805b1bb4 --- /dev/null +++ b/app/views/spending_proposals/_form.html.erb @@ -0,0 +1,46 @@ +<%= form_for(@spending_proposal, url: form_url) do |f| %> + <%= render 'shared/errors', resource: @spending_proposal %> + +
    +
    + <%= f.label :title, t("spending_proposals.form.title") %> + <%= f.text_field :title, maxlength: SpendingProposal.title_max_length, placeholder: t("spending_proposals.form.title"), label: false %> +
    + +
    + <%= f.label :description, t("spending_proposals.form.description") %> + <%= f.cktext_area :description, maxlength: SpendingProposal.description_max_length, ckeditor: { language: I18n.locale }, label: false %> +
    + +
    + <%= f.label :external_url, t("spending_proposals.form.external_url") %> + <%= f.text_field :external_url, placeholder: t("spending_proposals.form.external_url"), label: false %> +
    + +
    + <%= f.label :geozone_id, t("spending_proposals.form.geozone") %> + <%= f.select :geozone_id, geozone_select_options, {include_blank: t("geozones.none"), label: false} %> +
    + +
    + <% if @spending_proposal.new_record? %> + <%= f.label :terms_of_service do %> + <%= f.check_box :terms_of_service, label: false %> + + <%= t("form.accept_terms", + policy: link_to(t("form.policy"), "/privacy", target: "blank"), + conditions: link_to(t("form.conditions"), "/conditions", target: "blank")).html_safe %> + + <% end %> + <% end %> +
    + +
    + <%= f.simple_captcha input_html: { required: false } %> +
    + +
    + <%= f.submit(class: "button radius", value: t("spending_proposals.form.submit_buttons.#{action_name}")) %> +
    +
    +<% end %> \ No newline at end of file diff --git a/app/views/spending_proposals/index.html.erb b/app/views/spending_proposals/index.html.erb new file mode 100644 index 000000000..0bf276894 --- /dev/null +++ b/app/views/spending_proposals/index.html.erb @@ -0,0 +1,16 @@ +<% provide :title do %><%= t('spending_proposals.index.title') %><% end %> +
    +
    +
    +

    <%= t('spending_proposals.index.title') %>

    + +

    <%= t('spending_proposals.index.text') %>

    + + <% if can? :create, SpendingProposal %> + <%= link_to t('spending_proposals.index.create_link'), new_spending_proposal_path, class: 'button radius' %> + <% else %> +

    <%= t('spending_proposals.index.verified_only', verify_account: link_to(t('spending_proposals.index.verify_account'), verification_path)).html_safe %>

    + <% end %> +
    +
    +
    \ No newline at end of file diff --git a/app/views/spending_proposals/new.html.erb b/app/views/spending_proposals/new.html.erb new file mode 100644 index 000000000..c761487ff --- /dev/null +++ b/app/views/spending_proposals/new.html.erb @@ -0,0 +1,27 @@ +
    + +
    + <%= link_to spending_proposals_path, class: "left back" do %> + + <%= t("spending_proposals.new.back_link") %> + <% end %> +

    <%= t("spending_proposals.new.start_new") %>

    +
    + <%= link_to "/spending_proposals_info", target: "_blank" do %> + <%= t("spending_proposals.new.more_info")%> + <% end %> +
    + <%= render "spending_proposals/form", form_url: spending_proposals_url %> +
    + +
    + +

    <%= t("spending_proposals.new.recommendations_title") %>

    +
      +
    • <%= t("spending_proposals.new.recommendation_one") %>
    • +
    • <%= t("spending_proposals.new.recommendation_two") %>
    • +
    • <%= t("spending_proposals.new.recommendation_three") %>
    • +
    +
    + +
    \ No newline at end of file diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml index 6b815cd31..9941114f8 100644 --- a/config/i18n-tasks.yml +++ b/config/i18n-tasks.yml @@ -109,6 +109,7 @@ ignore_unused: - 'admin.comments.index.filter*' - 'admin.debates.index.filter*' - 'admin.proposals.index.filter*' + - 'admin.spending_proposals.index.filter*' - 'admin.organizations.index.filter*' - 'admin.users.index.filter*' - 'admin.activity.show.filter*' diff --git a/config/locales/activerecord.en.yml b/config/locales/activerecord.en.yml index 4e9cd5931..42c29fed8 100644 --- a/config/locales/activerecord.en.yml +++ b/config/locales/activerecord.en.yml @@ -37,6 +37,9 @@ en: proposal: one: "Citizen proposal" other: "Citizen proposals" + spending_proposal: + one: "Spending proposal" + other: "Spending proposals" attributes: comment: body: "Comment" diff --git a/config/locales/activerecord.es.yml b/config/locales/activerecord.es.yml index 998bc4fb6..780595562 100644 --- a/config/locales/activerecord.es.yml +++ b/config/locales/activerecord.es.yml @@ -37,6 +37,9 @@ es: proposal: one: "Propuesta ciudadana" other: "Propuestas ciudadanas" + spending_proposal: + one: "Propuesta de gasto" + other: "Propuestas de gasto" attributes: comment: body: "Comentario" diff --git a/config/locales/admin.en.yml b/config/locales/admin.en.yml index 276e3ca96..5ace18663 100755 --- a/config/locales/admin.en.yml +++ b/config/locales/admin.en.yml @@ -16,6 +16,7 @@ en: hidden_debates: "Hidden debates" hidden_comments: "Hidden comments" hidden_users: "Hidden users" + spending_proposals: "Spending proposals" incomplete_verifications: "Incomplete verifications" organizations: "Organisations" officials: "Officials" @@ -91,6 +92,20 @@ en: all: "All" with_confirmed_hide: "Confirmed" without_confirmed_hide: "Pending" + spending_proposals: + actions: + accept: Accept + reject: Reject + index: + title: "Spending proposals for participatory budgeting" + filter: "Filter" + filters: + unresolved: "Unresolved" + accepted: "Accepted" + rejected: "Rejected" + show: + geozone: "Scope" + by: "Sent by" users: index: title: "Hidden users" diff --git a/config/locales/admin.es.yml b/config/locales/admin.es.yml index b124d60e3..f82dd477c 100644 --- a/config/locales/admin.es.yml +++ b/config/locales/admin.es.yml @@ -16,6 +16,7 @@ es: hidden_debates: "Debates ocultos" hidden_comments: "Comentarios ocultos" hidden_users: "Usuarios bloqueados" + spending_proposals: "Propuestas de gasto" incomplete_verifications: "Verificaciones incompletas" organizations: "Organizaciones" officials: "Cargos públicos" @@ -91,6 +92,20 @@ es: all: "Todas" with_confirmed_hide: "Confirmadas" without_confirmed_hide: "Pendientes" + spending_proposals: + actions: + accept: Aceptar + reject: Rechazar + index: + title: "Propuestas de gasto para presupuestos participativos" + filter: "Filtro" + filters: + unresolved: "Sin resolver" + accepted: "Aceptadas" + rejected: "Rechazadas" + show: + geozone: "Ámbito" + by: "Enviada por" users: index: title: "Usuarios bloqueados" diff --git a/config/locales/en.yml b/config/locales/en.yml index adb19b026..321b4b448 100755 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -29,6 +29,7 @@ en: more_information: "More information" debates: "Debates" proposals: "Proposals" + spending_proposals: "Spending proposals" new_notifications: one: "You have a new notification" other: "You have %{count} new notifications" @@ -75,6 +76,7 @@ en: user: "Account" debate: "Debate" proposal: "Proposal" + spending_proposal: "Spending proposal" verification::sms: "Telephone" verification::letter: "the verification" application: @@ -244,6 +246,31 @@ en: update: form: submit_button: "Save changes" + spending_proposals: + index: + title: "Participatory budgeting" + text: "Here you can send spending proposals to be considered in the frame of the annual participatory budgeting." + create_link: "Create spending proposal" + verified_only: "Only verified users can create spending proposals, %{verify_account}." + verify_account: "verify your account" + new: + back_link: Back + start_new: "Create spending proposal" + more_info: "How do participatory budgeting works?" + recommendations_title: "Recommendations for creating a spending proposal" + recommendation_one: "It's mandatory that the proposal makes reference to a budgetable action." + recommendation_two: "Any proposal or comment suggesting illegal action will be deleted." + recommendation_three: "Try to go into details when describing your spending proposal so the reviewing team undertands your points." + form: + title: "Spending proposal title" + description: "Description" + external_url: "Link to additional documentation" + geozone: "Scope of operation" + submit_buttons: + new: Create + create: Create + geozones: + none: "All city" comments: show: return_to_commentable: "Go back to " @@ -332,6 +359,7 @@ en: user: "the secret code does not match the image" debate: "the secret code does not match the image" proposal: "the secret code does not match the image" + spendingproposal: "the secret code does not match the image" shared: author_info: author_deleted: "User deleted" diff --git a/config/locales/es.yml b/config/locales/es.yml index c6fc3d736..4943b0756 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -29,6 +29,7 @@ es: more_information: "Más información" debates: "Debates" proposals: "Propuestas" + spending_proposals: "Presupuestos ciudadanos" new_notifications: one: "Tienes una nueva notificación" other: "Tienes %{count} notificaciones nuevas" @@ -75,6 +76,7 @@ es: user: "la cuenta" debate: "el debate" proposal: "la propuesta" + spending_proposal: "la propuesta de gasto" verification::sms: "el teléfono" verification::letter: "la verificación" application: @@ -207,7 +209,7 @@ es: proposal_responsible_name: "Nombre y apellidos de la persona que hace esta propuesta" proposal_responsible_name_note: "(individualmente o como representante de un colectivo; no se mostrará públicamente)" tags_label: "Temas" - tags_instructions: "Etiqueta esta propuesta. Puedes elegir entre nuestras propuestas o introducir las que desees." + tags_instructions: "Etiqueta esta propuesta. Puedes elegir entre nuestras sugerencias o introducir las que desees." tags_placeholder: "Escribe las etiquetas que desees separadas por una coma (',')" show: back_link: "Volver" @@ -244,6 +246,31 @@ es: update: form: submit_button: "Guardar cambios" + spending_proposals: + index: + title: "Presupuestos participativos" + text: "Desde esta sección podrás sugerir propuestas de gasto que irán asociadas a las partidas de presupuestos ciudadanos. El requisito principal es que sean propuestas presupuestables." + create_link: "Enviar propuesta de gasto" + verified_only: "Sólo los usuarios verificados pueden crear propuestas de gasto, %{verify_account}." + verify_account: "verifica tu cuenta" + new: + back_link: Volver + start_new: "Crear una propuesta de gasto" + more_info: "¿Cómo funcionan los presupuestos participativos?" + recommendations_title: "Recomendaciones para crear una propuesta de gasto" + recommendation_one: "Es fundamental que haga referencia a una actuación presupuestable." + recommendation_two: "Cualquier propuesta o comentario que implique acciones ilegales será eliminada." + recommendation_three: "Intenta detallar lo máximo posible la propuesta para que el equipo de gobierno encargado de estudiarla tenga las menor dudas posibles." + form: + title: "Título de la propuesta de gasto" + description: "Descripción detallada" + external_url: "Enlace a documentación adicional" + geozone: "Ámbito de actuación" + submit_buttons: + new: Crear + create: Crear + geozones: + none: "Toda la ciudad" comments: show: return_to_commentable: "Volver a " @@ -332,6 +359,7 @@ es: user: "el código secreto no coincide con la imagen" debate: "el código secreto no coincide con la imagen" proposal: "el código secreto no coincide con la imagen" + spendingproposal: "el código secreto no coincide con la imagen" shared: author_info: author_deleted: Usuario eliminado diff --git a/config/locales/pages.en.yml b/config/locales/pages.en.yml index b69694d38..f34e7250a 100755 --- a/config/locales/pages.en.yml +++ b/config/locales/pages.en.yml @@ -35,6 +35,7 @@ en: how_to_use: "Use it in your local government" participation: "Madrid Participation and Transparency y Transparencia - coming news" proposals_info: "How does citizen proposals work?" + spending_proposals_info: "How does participatory budgeting work?" participation_world: "Direct citizen participation in the world" participation_facts: "Facts about citizen participation and direct democracy" faq: "Solution to tecnical problemas (FAQ)" @@ -44,6 +45,7 @@ en: how_to_use: "Use it freely or help us to improve it, it is free software" participation: "Citizen participation, transparency and open government" proposals_info: "Create your own proposals" + spending_proposals_info: "Create your own spending proposals" participation_world: "Systems of citizen participation that exist in the world" participation_facts: "To lose your fear" faq: "Frecuently asked question about tecnical problems" diff --git a/config/locales/pages.es.yml b/config/locales/pages.es.yml index 4e6877482..77a996ba5 100644 --- a/config/locales/pages.es.yml +++ b/config/locales/pages.es.yml @@ -35,6 +35,7 @@ es: how_to_use: "Utilízalo en tu municipio" participation: "Participación y Transparencia en Madrid - Próximas novedades" proposals_info: "¿Cómo funcionan las propuestas ciudadanas?" + spending_proposals_info: "¿Cómo funcionan los presupuestos participativos?" participation_world: "Participación ciudadana directa en el mundo" participation_facts: "Hechos sobre participación ciudadana y democracia directa" faq: "Soluciones a problemas técnicos (FAQ)" @@ -44,6 +45,7 @@ es: how_to_use: "Utilízalo libremente o ayúdanos a mejorarlo, es software libre" participation: "Participación Ciudadana, Transparencia y Gobierno Abierto" proposals_info: "Crea tus propias propuestas" + spending_proposals_info: "Envía tus propuestas de gasto" participation_world: "Sistemas de participación ciudadana que ya existen en el mundo" participation_facts: "Para perderle el miedo" faq: "Preguntas frecuentes sobre problemas técnicos" diff --git a/config/routes.rb b/config/routes.rb index bfdecc59f..d10ffefd7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -61,6 +61,8 @@ Rails.application.routes.draw do end end + resources :spending_proposals, only: [:index, :new, :create] + resources :legislations, only: [:show] resources :annotations do @@ -120,6 +122,13 @@ Rails.application.routes.draw do end end + resources :spending_proposals, only: [:index, :show] do + member do + put :accept + put :reject + end + end + resources :comments, only: :index do member do put :restore diff --git a/db/dev_seeds.rb b/db/dev_seeds.rb index d7a362d9a..8bc525d8e 100644 --- a/db/dev_seeds.rb +++ b/db/dev_seeds.rb @@ -14,6 +14,9 @@ Setting.create(key: 'max_votes_for_proposal_edit', value: '1000') Setting.create(key: 'proposal_code_prefix', value: 'MAD') Setting.create(key: 'votes_for_proposal_success', value: '100') +puts "Creating Geozones" +('A'..'Z').each{ |i| Geozone.create(name: "District #{i}") } + puts "Creating Users" def create_user(email, username = Faker::Name.name) @@ -180,6 +183,25 @@ end Flag.flag(flagger, proposal) end +puts "Creating Spending Proposals" + +resolutions = ["accepted", "rejected", nil] + +(1..30).each do |i| + geozone = Geozone.reorder("RANDOM()").first + author = User.reorder("RANDOM()").first + description = "

    #{Faker::Lorem.paragraphs.join('

    ')}

    " + spending_proposal = SpendingProposal.create!(author: author, + title: Faker::Lorem.sentence(3).truncate(60), + external_url: Faker::Internet.url, + description: description, + created_at: rand((Time.now - 1.week) .. Time.now), + resolution: resolutions.sample, + geozone: [geozone, nil].sample, + terms_of_service: "1") + puts " #{spending_proposal.title}" +end + puts "Creating Legislation" Legislation.create!(title: 'Participatory Democracy', body: 'In order to achieve...') diff --git a/db/migrate/20151218114205_create_spending_proposals.rb b/db/migrate/20151218114205_create_spending_proposals.rb new file mode 100644 index 000000000..5bcd3aba1 --- /dev/null +++ b/db/migrate/20151218114205_create_spending_proposals.rb @@ -0,0 +1,12 @@ +class CreateSpendingProposals < ActiveRecord::Migration + def change + create_table :spending_proposals do |t| + t.string :title + t.text :description + t.integer :author_id + t.string :external_url + + t.timestamps null: false + end + end +end diff --git a/db/migrate/20160104203329_add_spending_proposals_counter_to_tags.rb b/db/migrate/20160104203329_add_spending_proposals_counter_to_tags.rb new file mode 100644 index 000000000..2aade49cb --- /dev/null +++ b/db/migrate/20160104203329_add_spending_proposals_counter_to_tags.rb @@ -0,0 +1,6 @@ +class AddSpendingProposalsCounterToTags < ActiveRecord::Migration + def change + add_column :tags, :spending_proposals_count, :integer, default: 0 + add_index :tags, :spending_proposals_count + end +end diff --git a/db/migrate/20160104203438_add_spending_proposals_indexes.rb b/db/migrate/20160104203438_add_spending_proposals_indexes.rb new file mode 100644 index 000000000..777b0f678 --- /dev/null +++ b/db/migrate/20160104203438_add_spending_proposals_indexes.rb @@ -0,0 +1,5 @@ +class AddSpendingProposalsIndexes < ActiveRecord::Migration + def change + add_index :spending_proposals, :author_id + end +end diff --git a/db/migrate/20160105121132_create_geozones.rb b/db/migrate/20160105121132_create_geozones.rb new file mode 100644 index 000000000..3e43339e9 --- /dev/null +++ b/db/migrate/20160105121132_create_geozones.rb @@ -0,0 +1,11 @@ +class CreateGeozones < ActiveRecord::Migration + def change + create_table :geozones do |t| + t.string :name + t.string :html_map_coordinates + t.string :external_code + + t.timestamps null: false + end + end +end diff --git a/db/migrate/20160107114749_add_geozone_to_spending_proposal.rb b/db/migrate/20160107114749_add_geozone_to_spending_proposal.rb new file mode 100644 index 000000000..62130fd9b --- /dev/null +++ b/db/migrate/20160107114749_add_geozone_to_spending_proposal.rb @@ -0,0 +1,6 @@ +class AddGeozoneToSpendingProposal < ActiveRecord::Migration + def change + add_column :spending_proposals, :geozone_id, :integer, default: nil + add_index :spending_proposals, :geozone_id + end +end diff --git a/db/migrate/20160107132059_add_resolution_to_spending_proposals.rb b/db/migrate/20160107132059_add_resolution_to_spending_proposals.rb new file mode 100644 index 000000000..18365a715 --- /dev/null +++ b/db/migrate/20160107132059_add_resolution_to_spending_proposals.rb @@ -0,0 +1,6 @@ +class AddResolutionToSpendingProposals < ActiveRecord::Migration + def change + add_column :spending_proposals, :resolution, :string, default: nil + add_index :spending_proposals, :resolution + end +end diff --git a/db/schema.rb b/db/schema.rb index 162bd816e..7bc4652d9 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -171,6 +171,14 @@ ActiveRecord::Schema.define(version: 20160108133501) do add_index "flags", ["user_id", "flaggable_type", "flaggable_id"], name: "access_inappropiate_flags", using: :btree add_index "flags", ["user_id"], name: "index_flags_on_user_id", using: :btree + create_table "geozones", force: :cascade do |t| + t.string "name" + t.string "html_map_coordinates" + t.string "external_code" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + create_table "identities", force: :cascade do |t| t.integer "user_id" t.string "provider" @@ -191,7 +199,7 @@ ActiveRecord::Schema.define(version: 20160108133501) do create_table "locks", force: :cascade do |t| t.integer "user_id" t.integer "tries", default: 0 - t.datetime "locked_until", default: '2000-01-01 07:01:01', null: false + t.datetime "locked_until", default: '2000-01-01 00:01:01', null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false end @@ -273,6 +281,21 @@ ActiveRecord::Schema.define(version: 20160108133501) do add_index "simple_captcha_data", ["key"], name: "idx_key", using: :btree + create_table "spending_proposals", force: :cascade do |t| + t.string "title" + t.text "description" + t.integer "author_id" + t.string "external_url" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.integer "geozone_id" + t.string "resolution" + end + + add_index "spending_proposals", ["author_id"], name: "index_spending_proposals_on_author_id", using: :btree + add_index "spending_proposals", ["geozone_id"], name: "index_spending_proposals_on_geozone_id", using: :btree + add_index "spending_proposals", ["resolution"], name: "index_spending_proposals_on_resolution", using: :btree + create_table "taggings", force: :cascade do |t| t.integer "tag_id" t.integer "taggable_id" @@ -287,16 +310,18 @@ ActiveRecord::Schema.define(version: 20160108133501) do add_index "taggings", ["taggable_id", "taggable_type", "context"], name: "index_taggings_on_taggable_id_and_taggable_type_and_context", using: :btree create_table "tags", force: :cascade do |t| - t.string "name", limit: 40 - t.integer "taggings_count", default: 0 - t.boolean "featured", default: false - t.integer "debates_count", default: 0 - t.integer "proposals_count", default: 0 + t.string "name", limit: 40 + t.integer "taggings_count", default: 0 + t.boolean "featured", default: false + t.integer "debates_count", default: 0 + t.integer "proposals_count", default: 0 + t.integer "spending_proposals_count", default: 0 end add_index "tags", ["debates_count"], name: "index_tags_on_debates_count", using: :btree add_index "tags", ["name"], name: "index_tags_on_name", unique: true, using: :btree add_index "tags", ["proposals_count"], name: "index_tags_on_proposals_count", using: :btree + add_index "tags", ["spending_proposals_count"], name: "index_tags_on_spending_proposals_count", using: :btree create_table "users", force: :cascade do |t| t.string "email", default: "" diff --git a/spec/factories.rb b/spec/factories.rb index 6e910a214..70ed26a1d 100644 --- a/spec/factories.rb +++ b/spec/factories.rb @@ -180,6 +180,14 @@ FactoryGirl.define do end end + factory :spending_proposal do + sequence(:title) { |n| "Spending Proposal #{n} title" } + description 'Spend money on this' + external_url 'http://external_documention.org' + terms_of_service '1' + association :author, factory: :user + end + factory :vote do association :votable, factory: :debate association :voter, factory: :user @@ -295,4 +303,7 @@ FactoryGirl.define do association :notifiable, factory: :proposal end + factory :geozone do + sequence(:name) { |n| "District #{n}" } + end end diff --git a/spec/features/admin/spending_proposals_spec.rb b/spec/features/admin/spending_proposals_spec.rb new file mode 100644 index 000000000..a355c13ec --- /dev/null +++ b/spec/features/admin/spending_proposals_spec.rb @@ -0,0 +1,140 @@ +require 'rails_helper' + +feature 'Admin spending proposals' do + + background do + admin = create(:administrator) + login_as(admin.user) + end + + scenario 'Index shows spending proposals' do + spending_proposal = create(:spending_proposal) + visit admin_spending_proposals_path + + expect(page).to have_content(spending_proposal.title) + end + + scenario 'Accept from index' do + spending_proposal = create(:spending_proposal) + visit admin_spending_proposals_path + + click_link 'Accept' + + expect(page).to_not have_content(spending_proposal.title) + + click_link 'Accepted' + expect(page).to have_content(spending_proposal.title) + + expect(spending_proposal.reload).to be_accepted + end + + scenario 'Reject from index' do + spending_proposal = create(:spending_proposal) + visit admin_spending_proposals_path + + click_link 'Reject' + + expect(page).to_not have_content(spending_proposal.title) + + click_link('Rejected') + expect(page).to have_content(spending_proposal.title) + + expect(spending_proposal.reload).to be_rejected + end + + scenario "Current filter is properly highlighted" do + visit admin_spending_proposals_path + expect(page).to_not have_link('Unresolved') + expect(page).to have_link('Accepted') + expect(page).to have_link('Rejected') + + visit admin_spending_proposals_path(filter: 'unresolved') + expect(page).to_not have_link('Unresolved') + expect(page).to have_link('Accepted') + expect(page).to have_link('Rejected') + + visit admin_spending_proposals_path(filter: 'accepted') + expect(page).to have_link('Unresolved') + expect(page).to_not have_link('Accepted') + expect(page).to have_link('Rejected') + + visit admin_spending_proposals_path(filter: 'rejected') + expect(page).to have_link('Accepted') + expect(page).to have_link('Unresolved') + expect(page).to_not have_link('Rejected') + end + + scenario "Filtering proposals" do + create(:spending_proposal, title: "Recent spending proposal") + create(:spending_proposal, title: "Good spending proposal", resolution: "accepted") + create(:spending_proposal, title: "Bad spending proposal", resolution: "rejected") + + visit admin_spending_proposals_path(filter: 'unresolved') + expect(page).to have_content('Recent spending proposal') + expect(page).to_not have_content('Good spending proposal') + expect(page).to_not have_content('Bad spending proposal') + + visit admin_spending_proposals_path(filter: 'accepted') + expect(page).to have_content('Good spending proposal') + expect(page).to_not have_content('Recent spending proposal') + expect(page).to_not have_content('Bad spending proposal') + + visit admin_spending_proposals_path(filter: 'rejected') + expect(page).to have_content('Bad spending proposal') + expect(page).to_not have_content('Good spending proposal') + expect(page).to_not have_content('Recent spending proposal') + end + + scenario "Action links remember the pagination setting and the filter" do + per_page = Kaminari.config.default_per_page + (per_page + 2).times { create(:spending_proposal, resolution: "accepted") } + + visit admin_spending_proposals_path(filter: 'accepted', page: 2) + + click_on('Reject', match: :first, exact: true) + + expect(current_url).to include('filter=accepted') + expect(current_url).to include('page=2') + end + + scenario 'Show' do + spending_proposal = create(:spending_proposal, geozone: create(:geozone)) + visit admin_spending_proposals_path + + click_link spending_proposal.title + + expect(page).to have_content(spending_proposal.title) + expect(page).to have_content(spending_proposal.description) + expect(page).to have_content(spending_proposal.author.name) + expect(page).to have_content(spending_proposal.geozone.name) + end + + scenario 'Accept from show' do + spending_proposal = create(:spending_proposal) + visit admin_spending_proposal_path(spending_proposal) + + click_link 'Accept' + + expect(page).to_not have_content(spending_proposal.title) + + click_link 'Accepted' + expect(page).to have_content(spending_proposal.title) + + expect(spending_proposal.reload).to be_accepted + end + + scenario 'Reject from show' do + spending_proposal = create(:spending_proposal) + visit admin_spending_proposal_path(spending_proposal) + + click_link 'Reject' + + expect(page).to_not have_content(spending_proposal.title) + + click_link('Rejected') + expect(page).to have_content(spending_proposal.title) + + expect(spending_proposal.reload).to be_rejected + end + +end diff --git a/spec/features/spending_proposals_spec.rb b/spec/features/spending_proposals_spec.rb new file mode 100644 index 000000000..6b76b02e0 --- /dev/null +++ b/spec/features/spending_proposals_spec.rb @@ -0,0 +1,66 @@ +require 'rails_helper' + +feature 'Spending proposals' do + + let(:author) { create(:user, :level_two) } + + scenario 'Index' do + visit spending_proposals_path + + expect(page).to_not have_link('Create spending proposal', href: new_spending_proposal_path) + expect(page).to have_link('verify your account') + + login_as(author) + + visit spending_proposals_path + + expect(page).to have_link('Create spending proposal', href: new_spending_proposal_path) + expect(page).to_not have_link('verify your account') + end + + scenario 'Create' do + login_as(author) + + visit new_spending_proposal_path + fill_in 'spending_proposal_title', with: 'Build a skyscraper' + fill_in 'spending_proposal_description', with: 'I want to live in a high tower over the clouds' + fill_in 'spending_proposal_external_url', with: 'http://http://skyscraperpage.com/' + fill_in 'spending_proposal_captcha', with: correct_captcha_text + select 'All city', from: 'spending_proposal_geozone_id' + check 'spending_proposal_terms_of_service' + + click_button 'Create' + + expect(page).to have_content 'Spending proposal created successfully' + end + + scenario 'Captcha is required for proposal creation' do + login_as(author) + + visit new_spending_proposal_path + fill_in 'spending_proposal_title', with: 'Build a skyscraper' + fill_in 'spending_proposal_description', with: 'I want to live in a high tower over the clouds' + fill_in 'spending_proposal_external_url', with: 'http://http://skyscraperpage.com/' + fill_in 'spending_proposal_captcha', with: 'wrongText' + check 'spending_proposal_terms_of_service' + + click_button 'Create' + + expect(page).to_not have_content 'Spending proposal created successfully' + expect(page).to have_content '1 error' + + fill_in 'spending_proposal_captcha', with: correct_captcha_text + click_button 'Create' + + expect(page).to have_content 'Spending proposal created successfully' + end + + scenario 'Errors on create' do + login_as(author) + + visit new_spending_proposal_path + click_button 'Create' + expect(page).to have_content error_message + end + +end diff --git a/spec/helpers/geozones_helper_spec.rb b/spec/helpers/geozones_helper_spec.rb new file mode 100644 index 000000000..d85ff8753 --- /dev/null +++ b/spec/helpers/geozones_helper_spec.rb @@ -0,0 +1,35 @@ +require 'rails_helper' + +describe GeozonesHelper do + + describe "#geozones_name" do + let(:geozone) { create :geozone } + + + it "returns geozone name if present" do + spending_proposal = create(:spending_proposal, geozone: geozone) + expect(geozone_name(spending_proposal)).to eq geozone.name + end + + it "returns default string for no geozone if geozone is blank" do + spending_proposal = create(:spending_proposal, geozone: nil) + expect(geozone_name(spending_proposal)).to eq "All city" + end + end + + describe "#geozone_select_options" do + it "returns array of ids and names ordered by name" do + g1 = create(:geozone, name: "AAA") + g3 = create(:geozone, name: "CCC") + g2 = create(:geozone, name: "BBB") + + select_options = geozone_select_options + + expect(select_options.size).to eq 3 + expect(select_options.first).to eq [g1.name, g1.id] + expect(select_options[1]).to eq [g2.name, g2.id] + expect(select_options.last).to eq [g3.name, g3.id] + end + end + +end diff --git a/spec/models/abilities/administrator_spec.rb b/spec/models/abilities/administrator_spec.rb index 66757ddc0..8df88a46f 100644 --- a/spec/models/abilities/administrator_spec.rb +++ b/spec/models/abilities/administrator_spec.rb @@ -51,4 +51,6 @@ describe "Abilities::Administrator" do it { should_not be_able_to(:comment_as_moderator, proposal) } it { should be_able_to(:manage, Annotation) } + + it { should be_able_to(:manage, SpendingProposal) } end diff --git a/spec/models/abilities/common_spec.rb b/spec/models/abilities/common_spec.rb index 4775cd1ee..d942e2d18 100644 --- a/spec/models/abilities/common_spec.rb +++ b/spec/models/abilities/common_spec.rb @@ -28,6 +28,9 @@ describe "Abilities::Common" do it { should_not be_able_to(:vote, Proposal) } it { should_not be_able_to(:vote_featured, Proposal) } + it { should be_able_to(:index, SpendingProposal) } + it { should_not be_able_to(:create, SpendingProposal) } + it { should_not be_able_to(:comment_as_administrator, debate) } it { should_not be_able_to(:comment_as_moderator, debate) } it { should_not be_able_to(:comment_as_administrator, proposal) } @@ -84,6 +87,8 @@ describe "Abilities::Common" do it { should be_able_to(:vote, Proposal) } it { should be_able_to(:vote_featured, Proposal) } + + it { should be_able_to(:create, SpendingProposal) } end describe "when level 3 verified" do @@ -91,5 +96,7 @@ describe "Abilities::Common" do it { should be_able_to(:vote, Proposal) } it { should be_able_to(:vote_featured, Proposal) } + + it { should be_able_to(:create, SpendingProposal) } end end diff --git a/spec/models/abilities/everyone_spec.rb b/spec/models/abilities/everyone_spec.rb index dacac25e4..3f1e57278 100644 --- a/spec/models/abilities/everyone_spec.rb +++ b/spec/models/abilities/everyone_spec.rb @@ -23,4 +23,7 @@ describe "Abilities::Everyone" do it { should_not be_able_to(:unflag, Proposal) } it { should be_able_to(:show, Comment) } + + it { should be_able_to(:index, SpendingProposal) } + it { should_not be_able_to(:create, SpendingProposal) } end diff --git a/spec/models/geozone_spec.rb b/spec/models/geozone_spec.rb new file mode 100644 index 000000000..a29c4c918 --- /dev/null +++ b/spec/models/geozone_spec.rb @@ -0,0 +1,14 @@ +require 'rails_helper' + +RSpec.describe Geozone, type: :model do + let(:geozone) { build(:geozone) } + + it "should be valid" do + expect(geozone).to be_valid + end + + it "should not be valid without a name" do + geozone.name = nil + expect(geozone).to_not be_valid + end +end diff --git a/spec/models/spending_proposal_spec.rb b/spec/models/spending_proposal_spec.rb new file mode 100644 index 000000000..25bccbfc6 --- /dev/null +++ b/spec/models/spending_proposal_spec.rb @@ -0,0 +1,140 @@ +require 'rails_helper' + +describe SpendingProposal do + let(:spending_proposal) { build(:spending_proposal) } + + it "should be valid" do + expect(spending_proposal).to be_valid + end + + it "should not be valid without an author" do + spending_proposal.author = nil + expect(spending_proposal).to_not be_valid + end + + describe "#title" do + it "should not be valid without a title" do + spending_proposal.title = nil + expect(spending_proposal).to_not be_valid + end + + it "should not be valid when very short" do + spending_proposal.title = "abc" + expect(spending_proposal).to_not be_valid + end + + it "should not be valid when very long" do + spending_proposal.title = "a" * 81 + expect(spending_proposal).to_not be_valid + end + end + + describe "#description" do + it "should be sanitized" do + spending_proposal.description = "" + spending_proposal.valid? + expect(spending_proposal.description).to eq("alert('danger');") + end + + it "should not be valid when very long" do + spending_proposal.description = "a" * 6001 + expect(spending_proposal).to_not be_valid + end + end + + describe "resolution status" do + it "should be valid" do + spending_proposal.resolution = "accepted" + expect(spending_proposal).to be_valid + spending_proposal.resolution = "rejected" + expect(spending_proposal).to be_valid + spending_proposal.resolution = "wrong" + expect(spending_proposal).to_not be_valid + end + + it "can be accepted" do + spending_proposal.accept + expect(spending_proposal.reload.resolution).to eq("accepted") + end + + it "can be rejected" do + spending_proposal.reject + expect(spending_proposal.reload.resolution).to eq("rejected") + end + + describe "#accepted?" do + it "should be true if resolution equals 'accepted'" do + spending_proposal.resolution = "accepted" + expect(spending_proposal.accepted?).to eq true + end + + it "should be false otherwise" do + spending_proposal.resolution = "rejected" + expect(spending_proposal.accepted?).to eq false + spending_proposal.resolution = nil + expect(spending_proposal.accepted?).to eq false + end + end + + describe "#rejected?" do + it "should be true if resolution equals 'rejected'" do + spending_proposal.resolution = "rejected" + expect(spending_proposal.rejected?).to eq true + end + + it "should be false otherwise" do + spending_proposal.resolution = "accepted" + expect(spending_proposal.rejected?).to eq false + spending_proposal.resolution = nil + expect(spending_proposal.rejected?).to eq false + end + end + + describe "#unresolved?" do + it "should be true if resolution is blank" do + spending_proposal.resolution = nil + expect(spending_proposal.unresolved?).to eq true + end + + it "should be false otherwise" do + spending_proposal.resolution = "accepted" + expect(spending_proposal.unresolved?).to eq false + spending_proposal.resolution = "rejected" + expect(spending_proposal.unresolved?).to eq false + end + end + end + + describe "scopes" do + before(:each) do + 2.times { create(:spending_proposal, resolution: "accepted") } + 2.times { create(:spending_proposal, resolution: "rejected") } + 2.times { create(:spending_proposal, resolution: nil) } + end + + describe "unresolved" do + it "should return all spending proposals without resolution" do + unresolved = SpendingProposal.all.unresolved + expect(unresolved.size).to eq(2) + unresolved.each {|u| expect(u.resolution).to be_nil} + end + end + + describe "accepted" do + it "should return all accepted spending proposals" do + accepted = SpendingProposal.all.accepted + expect(accepted.size).to eq(2) + accepted.each {|a| expect(a.resolution).to eq("accepted")} + end + end + + describe "rejected" do + it "should return all rejected spending proposals" do + rejected = SpendingProposal.all.rejected + expect(rejected.size).to eq(2) + rejected.each {|r| expect(r.resolution).to eq("rejected")} + end + end + end + +end