diff --git a/Gemfile b/Gemfile index 3ca00f6..3cb270b 100644 --- a/Gemfile +++ b/Gemfile @@ -14,6 +14,9 @@ gem "puma", "~> 5.0" gem "pundit" gem "rails", "~> 7.0.3" gem "ransack" +gem "nokogiri" +gem "rubyzip" +gem "combine_pdf" group :development, :test do gem "debug", platforms: %i[ mri mingw x64_mingw ] diff --git a/Gemfile.lock b/Gemfile.lock index 5d8a8ab..94acc8f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -82,6 +82,9 @@ GEM bcrypt (>= 3.1.1) email_validator (~> 2.0) railties (>= 5.0) + combine_pdf (1.0.22) + matrix + ruby-rc4 (>= 0.1.5) concurrent-ruby (1.1.10) crass (1.0.6) debug (1.5.0) @@ -115,6 +118,7 @@ GEM mail (2.7.1) mini_mime (>= 0.1.1) marcel (1.0.2) + matrix (0.4.2) method_source (1.0.0) mini_mime (1.1.2) minitest (5.16.0) @@ -188,6 +192,8 @@ GEM io-console (~> 0.5) request_store (1.5.1) rack (>= 1.4) + ruby-rc4 (0.1.5) + rubyzip (2.3.2) strscan (3.0.3) thor (1.2.1) timecop (0.9.5) @@ -210,9 +216,11 @@ PLATFORMS DEPENDENCIES bootsnap clearance + combine_pdf debug dotenv-rails importmap-rails + nokogiri paper_trail pg (~> 1.1) propshaft @@ -220,6 +228,7 @@ DEPENDENCIES pundit rails (~> 7.0.3) ransack + rubyzip timecop web-console diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 1e9e983..50e6b85 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -23,3 +23,5 @@ ul { } li { padding: 5px 0px; } + +table.noborder { border: 0; margin: 0; } diff --git a/app/controllers/concerns/member_filter.rb b/app/controllers/concerns/member_filter.rb new file mode 100644 index 0000000..4820416 --- /dev/null +++ b/app/controllers/concerns/member_filter.rb @@ -0,0 +1,38 @@ +module MemberFilter + extend ActiveSupport::Concern + + def filtered_members + members = Member.all.order(sort_params.merge(number: :asc)) + + filters = params.permit(:prefers_postal, :display_name, :email, :identification_number, status: [], category: []) + + Rails.logger.info filters + + status = filters.fetch(:status, []) - ['any', ''] + category = filters.fetch(:category, []) - ['any', ''] + + members = members.where(prefers_postal: true) if filters[:prefers_postal] == 'yes' + members = members.where(prefers_postal: false) if filters[:prefers_postal] == 'no' + members = members.where(status: status) if status != [] + members = members.where(category: category) if category != [] + + members.ransack( + display_name_i_cont: filters[:display_name], + email_i_cont: filters[:email], + identification_number_i_cont: filters[:identification_number], + ).result + end + + def sort_params + field, direction = params.fetch(:sort, "").split(".") + + directions = %w[ asc desc ] + fields = %w[ number expires_on joined_on email status display_name ] + + if directions.include?(direction) && fields.include?(field) + { field => direction } + else + { number: :asc } + end + end +end diff --git a/app/controllers/letters_controller.rb b/app/controllers/letters_controller.rb new file mode 100644 index 0000000..bbe6a28 --- /dev/null +++ b/app/controllers/letters_controller.rb @@ -0,0 +1,14 @@ +class LettersController < ApplicationController + before_action :require_login + + include MemberFilter + + # POST /letters + def create + members = filtered_members + + pdf = Letters.generate(params[:template], members) + + send_data pdf, filename: "members.pdf", type: "application/pdf" + end +end diff --git a/app/controllers/members_controller.rb b/app/controllers/members_controller.rb index e90ac85..8b8e85e 100644 --- a/app/controllers/members_controller.rb +++ b/app/controllers/members_controller.rb @@ -3,13 +3,11 @@ class MembersController < ApplicationController before_action :set_member, only: %i[ show edit update delete destroy ] helper_method :sort_params + include MemberFilter + # GET /members def index - params.delete(:status) if params[:status] == 'any' - params.delete(:category) if params[:category] == 'any' - - @members = Member.all.order(sort_params.merge(number: :asc)) - @members = @members.ransack(display_name_or_email_or_identification_number_i_cont: params[:q], status_cont: params[:status], category_cont: params[:category]).result + @members = filtered_members end # GET /members/1 @@ -68,17 +66,4 @@ class MembersController < ApplicationController def member_params params.fetch(:member, {}).permit(:display_name, :email, :identification_number, :category, :address, :joined_on, :expires_on, :wants_mailing_list, :prefers_postal) end - - def sort_params - field, direction = params.fetch(:sort, "").split(".") - - directions = %w[ asc desc ] - fields = %w[ number expires_on joined_on email status display_name ] - - if directions.include?(direction) && fields.include?(field) - { field => direction } - else - { number: :asc } - end - end end diff --git a/app/controllers/notifications_controller.rb b/app/controllers/notifications_controller.rb new file mode 100644 index 0000000..bfbe148 --- /dev/null +++ b/app/controllers/notifications_controller.rb @@ -0,0 +1,19 @@ +class NotificationsController < ApplicationController + before_action :require_login + before_action :set_notification + + # POST /notifications/1/deliver + def deliver + @notification.deliver! + + redirect_to @notification.member + end + + private + # Use callbacks to share common setup or constraints between actions. + def set_notification + @notification = Notification.find(params[:id]) + end +end + + diff --git a/app/lib/letters.rb b/app/lib/letters.rb new file mode 100644 index 0000000..e662191 --- /dev/null +++ b/app/lib/letters.rb @@ -0,0 +1,68 @@ +require 'zip' +require 'nokogiri' +require 'combine_pdf' + +module Letters + def self.apply_template(io, params) + Zip::OutputStream.write_buffer do |out| + Zip::File.open(io) do |zip| + zip.each do |entry| + pp entry.name + out.put_next_entry(entry.name) + if entry.name == "content.xml" + out.write apply_template_xml(entry.get_input_stream.read, params) + elsif !entry.directory? + out.write entry.get_input_stream.read + end + end + end + end + end + + def self.apply_template_xml(xml, params) + doc = Nokogiri::XML(xml) + + doc.xpath("//*[contains(text(), 'DISPLAY_NAME')]").each do |node| + node.content = node.content.gsub("DISPLAY_NAME", params["DISPLAY_NAME"]) + end + + address_lines = params['ADDRESS'].split("\n") + doc.xpath("//*[contains(text(), 'ADDRESS')]").each do |node| + newnodes = [node] + address_lines[1..].map do |line| + node.clone.tap do |c| + c.content = c.content.gsub("ADDRESS", line) + end + end + node.content = node.content.gsub("ADDRESS", address_lines.first) + + newnodes.each_cons(2) do |p, n| + p.add_next_sibling(n) + end + end + + doc.to_xml(save_with: 0) + end + + def self.generate(template, members) + Dir.mktmpdir do |directory| + members.each do |member| + odt = apply_template(template, { + "DISPLAY_NAME" => member.display_name, + "ADDRESS" => member.address, + }) + + File.open("#{directory}/#{member.number}.odt", "wb") { |out| out.write(odt.string) } + end + + `libreoffice --convert-to pdf --outdir #{directory} #{directory}/*.odt` + + pdf = CombinePDF.new + + Dir["#{directory}/*.pdf"].each do |file| + pdf << CombinePDF.load(file) + end + + pdf.to_pdf + end + end +end diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb index f39ae39..b9f355b 100644 --- a/app/mailers/notification_mailer.rb +++ b/app/mailers/notification_mailer.rb @@ -1,4 +1,5 @@ class NotificationMailer < ApplicationMailer + before_action :set_notification # Subject can be set in your I18n file at config/locales/en.yml # with the following lookup: @@ -6,63 +7,32 @@ class NotificationMailer < ApplicationMailer # en.notification_mailer.expiration_in_60d.subject # def expiration_in_60d - @notification = params[:notification] - - mail to: params[:notification].member.email + mail to: @notification.member.email end - # Subject can be set in your I18n file at config/locales/en.yml - # with the following lookup: - # - # en.notification_mailer.expiration_in_30d.subject - # def expiration_in_30d - @notification = params[:notification] - - mail to: params[:notification].member.email + mail to: @notification.member.email end - # Subject can be set in your I18n file at config/locales/en.yml - # with the following lookup: - # - # en.notification_mailer.expired.subject - # def expired - @notification = params[:notification] - - mail to: params[:notification].member.email + mail to: @notification.member.email end - # Subject can be set in your I18n file at config/locales/en.yml - # with the following lookup: - # - # en.notification_mailer.expired_30d_ago.subject - # def expired_30d_ago - @notification = params[:notification] - - mail to: params[:notification].member.email + mail to: @notification.member.email end - # Subject can be set in your I18n file at config/locales/en.yml - # with the following lookup: - # - # en.notification_mailer.expired_60d_ago.subject - # def expired_60d_ago - @notification = params[:notification] - - mail to: params[:notification].member.email + mail to: @notification.member.email end - # Subject can be set in your I18n file at config/locales/en.yml - # with the following lookup: - # - # en.notification_mailer.cancelled.subject - # def cancelled - @notification = params[:notification] + mail to: @notification.member.email + end - mail to: params[:notification].member.email + private + def set_notification + @notification = params[:notification] + @link = @notification.member.regular_ifthenpay_link end end diff --git a/app/views/members/index.html.erb b/app/views/members/index.html.erb index e8556b7..762c99e 100644 --- a/app/views/members/index.html.erb +++ b/app/views/members/index.html.erb @@ -2,18 +2,77 @@

<%= t 'members.index.title' %>

-<%= link_to t('members.index.actions.new'), new_member_path %> +

<%= link_to t('members.index.actions.new'), new_member_path %>

<%= form_with url: members_path, method: :get do |form| %> - <%= form.text_field :q %> - <%= form.select :status, %w[ any active passive pending expired cancelled ], selected: params[:status] %> - <%= form.select :category, %w[ any student employed unemployed retired ], selected: params[:category], multiple: true %> - <%= form.submit 'Search', name: '' %> - <% if params[:q].present? || params[:status].present? || params[:category].present? %> - <%= link_to t('members.index.actions.clear_search'), members_path %> - <% end %> +
+ Filtrar + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
<%= t 'members.attributes.display_name' %><%= form.text_field :display_name, value: params[:display_name] %>
<%= t 'members.attributes.email' %><%= form.text_field :email, value: params[:email] %>
<%= t 'members.attributes.identification_number' %><%= form.text_field :identification_number, value: params[:identification_number] %>
<%= t 'members.attributes.status' %><%= form.select :status, %w[ any active passive pending expired cancelled ], { selected: params[:status] }, { multiple: true } %>
<%= t 'members.attributes.category' %> + <%= form.select :category, %w[ any student employed unemployed retired ], { selected: params[:category] }, { multiple: true } %> +
<%= t 'members.attributes.prefers_postal' %> + <%= form.select :prefers_postal, %w[ any yes no ], selected: params[:prefers_postal] %> +
+ +

<%= form.submit 'Search', name: '' %>

+ + <% if params[:q].present? || params[:status].present? || params[:category].present? %> + <%= link_to t('members.index.actions.clear_search'), members_path %> + <% end %> +
<% end %> +
+ +<%= form_with url: letters_path do |form| %> +
+ Gerar PDF + + <% (params.fetch(:status, []) - ['any', '']).each do |status| %> + <%= form.hidden_field 'status[]', value: status %> + <% end %> + <% (params.fetch(:category, []) - ['any', '']).each do |category| %> + <%= form.hidden_field 'category[]', value: category %> + <% end %> + + <%= form.hidden_field :display_name, value: params[:display_name] %> + <%= form.hidden_field :email, value: params[:email] %> + <%= form.hidden_field :identification_number, value: params[:identification_number] %> + <%= form.hidden_field :prefers_postal, value: params[:prefers_postal] %> + + <%= form.file_field :template, required: true %> + <%= form.submit 'Generate PDF' %> +
+<% end %> + + diff --git a/app/views/members/show.html.erb b/app/views/members/show.html.erb index 08f0ad0..e46670b 100644 --- a/app/views/members/show.html.erb +++ b/app/views/members/show.html.erb @@ -54,12 +54,20 @@ + <% @member.notifications.order(to_be_sent_on: :desc).each do |notification| %> + <% end %>
<%= link_to_current_with_sort t('members.attributes.number'), 'number.asc' %><%= t('notifications.attributes.to_be_sent_on') %> <%= t('notifications.attributes.template') %> <%= t('notifications.attributes.status') %><%= t('members.show.contribution_actions') %>
<%= notification.to_be_sent_on %> <%= notification.template %> <%= notification_status(notification.status) %> + <% if notification.status == 'scheduled' %> + <%= form_with url: deliver_notification_path(notification) do |form| %> + <%= form.submit t('members.show.actions.deliver_notification') %> + <% end %> + <% end %> +
diff --git a/config/locales/pt.yml b/config/locales/pt.yml index f4f2107..3725085 100644 --- a/config/locales/pt.yml +++ b/config/locales/pt.yml @@ -30,8 +30,10 @@ pt: edit: "Editar detalhes" edit_contribution: "Editar" delete_contribution: "Apagar" + deliver_notification: "Enviar agora" contribution_history: "Histórico de contribuições" notifications: "Notificações por correio electrónico" + contribution_actions: "Acções" edit: title: "Editar detalhes de membro" actions: diff --git a/config/routes.rb b/config/routes.rb index e0380c7..95b06d8 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -19,5 +19,12 @@ Rails.application.routes.draw do end end + resources :notifications, only: [] do + member do + post :deliver + end + end + resource :board, only: [:edit, :update] + resource :letters, only: [:create] end