Implement letter generation

This commit is contained in:
Hugo Peixoto 2022-07-16 11:27:43 +01:00
parent 2e7b03e9ef
commit 2705ad1611
13 changed files with 252 additions and 68 deletions

View File

@ -14,6 +14,9 @@ gem "puma", "~> 5.0"
gem "pundit" gem "pundit"
gem "rails", "~> 7.0.3" gem "rails", "~> 7.0.3"
gem "ransack" gem "ransack"
gem "nokogiri"
gem "rubyzip"
gem "combine_pdf"
group :development, :test do group :development, :test do
gem "debug", platforms: %i[ mri mingw x64_mingw ] gem "debug", platforms: %i[ mri mingw x64_mingw ]

View File

@ -82,6 +82,9 @@ GEM
bcrypt (>= 3.1.1) bcrypt (>= 3.1.1)
email_validator (~> 2.0) email_validator (~> 2.0)
railties (>= 5.0) railties (>= 5.0)
combine_pdf (1.0.22)
matrix
ruby-rc4 (>= 0.1.5)
concurrent-ruby (1.1.10) concurrent-ruby (1.1.10)
crass (1.0.6) crass (1.0.6)
debug (1.5.0) debug (1.5.0)
@ -115,6 +118,7 @@ GEM
mail (2.7.1) mail (2.7.1)
mini_mime (>= 0.1.1) mini_mime (>= 0.1.1)
marcel (1.0.2) marcel (1.0.2)
matrix (0.4.2)
method_source (1.0.0) method_source (1.0.0)
mini_mime (1.1.2) mini_mime (1.1.2)
minitest (5.16.0) minitest (5.16.0)
@ -188,6 +192,8 @@ GEM
io-console (~> 0.5) io-console (~> 0.5)
request_store (1.5.1) request_store (1.5.1)
rack (>= 1.4) rack (>= 1.4)
ruby-rc4 (0.1.5)
rubyzip (2.3.2)
strscan (3.0.3) strscan (3.0.3)
thor (1.2.1) thor (1.2.1)
timecop (0.9.5) timecop (0.9.5)
@ -210,9 +216,11 @@ PLATFORMS
DEPENDENCIES DEPENDENCIES
bootsnap bootsnap
clearance clearance
combine_pdf
debug debug
dotenv-rails dotenv-rails
importmap-rails importmap-rails
nokogiri
paper_trail paper_trail
pg (~> 1.1) pg (~> 1.1)
propshaft propshaft
@ -220,6 +228,7 @@ DEPENDENCIES
pundit pundit
rails (~> 7.0.3) rails (~> 7.0.3)
ransack ransack
rubyzip
timecop timecop
web-console web-console

View File

@ -23,3 +23,5 @@ ul {
} }
li { padding: 5px 0px; } li { padding: 5px 0px; }
table.noborder { border: 0; margin: 0; }

View File

@ -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

View File

@ -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

View File

@ -3,13 +3,11 @@ class MembersController < ApplicationController
before_action :set_member, only: %i[ show edit update delete destroy ] before_action :set_member, only: %i[ show edit update delete destroy ]
helper_method :sort_params helper_method :sort_params
include MemberFilter
# GET /members # GET /members
def index def index
params.delete(:status) if params[:status] == 'any' @members = filtered_members
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
end end
# GET /members/1 # GET /members/1
@ -68,17 +66,4 @@ class MembersController < ApplicationController
def member_params def member_params
params.fetch(:member, {}).permit(:display_name, :email, :identification_number, :category, :address, :joined_on, :expires_on, :wants_mailing_list, :prefers_postal) params.fetch(:member, {}).permit(:display_name, :email, :identification_number, :category, :address, :joined_on, :expires_on, :wants_mailing_list, :prefers_postal)
end 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 end

View File

@ -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

68
app/lib/letters.rb Normal file
View File

@ -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

View File

@ -1,4 +1,5 @@
class NotificationMailer < ApplicationMailer class NotificationMailer < ApplicationMailer
before_action :set_notification
# Subject can be set in your I18n file at config/locales/en.yml # Subject can be set in your I18n file at config/locales/en.yml
# with the following lookup: # with the following lookup:
@ -6,63 +7,32 @@ class NotificationMailer < ApplicationMailer
# en.notification_mailer.expiration_in_60d.subject # en.notification_mailer.expiration_in_60d.subject
# #
def expiration_in_60d def expiration_in_60d
@notification = params[:notification] mail to: @notification.member.email
mail to: params[:notification].member.email
end 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 def expiration_in_30d
@notification = params[:notification] mail to: @notification.member.email
mail to: params[:notification].member.email
end 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 def expired
@notification = params[:notification] mail to: @notification.member.email
mail to: params[:notification].member.email
end 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 def expired_30d_ago
@notification = params[:notification] mail to: @notification.member.email
mail to: params[:notification].member.email
end 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 def expired_60d_ago
@notification = params[:notification] mail to: @notification.member.email
mail to: params[:notification].member.email
end 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 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
end end

View File

@ -2,18 +2,77 @@
<h1><%= t 'members.index.title' %></h1> <h1><%= t 'members.index.title' %></h1>
<%= link_to t('members.index.actions.new'), new_member_path %> <p><%= link_to t('members.index.actions.new'), new_member_path %></p>
<%= form_with url: members_path, method: :get do |form| %> <%= form_with url: members_path, method: :get do |form| %>
<%= form.text_field :q %> <fieldset>
<%= form.select :status, %w[ any active passive pending expired cancelled ], selected: params[:status] %> <legend>Filtrar</legend>
<%= form.select :category, %w[ any student employed unemployed retired ], selected: params[:category], multiple: true %>
<%= form.submit 'Search', name: '' %> <table class='noborder lined'>
<% if params[:q].present? || params[:status].present? || params[:category].present? %> <tr>
<%= link_to t('members.index.actions.clear_search'), members_path %> <td><%= t 'members.attributes.display_name' %></td>
<% end %> <td><%= form.text_field :display_name, value: params[:display_name] %></td>
</tr>
<tr>
<td><%= t 'members.attributes.email' %></td>
<td><%= form.text_field :email, value: params[:email] %></td>
</tr>
<tr>
<td><%= t 'members.attributes.identification_number' %></td>
<td><%= form.text_field :identification_number, value: params[:identification_number] %></td>
</tr>
<tr>
<td><%= t 'members.attributes.status' %></td>
<td><%= form.select :status, %w[ any active passive pending expired cancelled ], { selected: params[:status] }, { multiple: true } %></td>
</tr>
<tr>
<td><%= t 'members.attributes.category' %></td>
<td>
<%= form.select :category, %w[ any student employed unemployed retired ], { selected: params[:category] }, { multiple: true } %>
</td>
</tr>
<tr>
<td><%= t 'members.attributes.prefers_postal' %></td>
<td>
<%= form.select :prefers_postal, %w[ any yes no ], selected: params[:prefers_postal] %>
</td>
</tr>
</td>
</tr>
</table>
<p><%= form.submit 'Search', name: '' %></p>
<% if params[:q].present? || params[:status].present? || params[:category].present? %>
<%= link_to t('members.index.actions.clear_search'), members_path %>
<% end %>
</fieldset>
<% end %> <% end %>
<br>
<%= form_with url: letters_path do |form| %>
<fieldset>
<legend>Gerar PDF</legend>
<% (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' %>
</fieldset>
<% end %>
<table class='zebra'> <table class='zebra'>
<tr> <tr>
<th><%= link_to_current_with_sort t('members.attributes.number'), 'number.asc' %></th> <th><%= link_to_current_with_sort t('members.attributes.number'), 'number.asc' %></th>

View File

@ -54,12 +54,20 @@
<th><%= t('notifications.attributes.to_be_sent_on') %></th> <th><%= t('notifications.attributes.to_be_sent_on') %></th>
<th><%= t('notifications.attributes.template') %></th> <th><%= t('notifications.attributes.template') %></th>
<th><%= t('notifications.attributes.status') %></th> <th><%= t('notifications.attributes.status') %></th>
<th><%= t('members.show.contribution_actions') %></th>
</tr> </tr>
<% @member.notifications.order(to_be_sent_on: :desc).each do |notification| %> <% @member.notifications.order(to_be_sent_on: :desc).each do |notification| %>
<tr> <tr>
<td><%= notification.to_be_sent_on %></td> <td><%= notification.to_be_sent_on %></td>
<td><code><%= notification.template %></code></td> <td><code><%= notification.template %></code></td>
<td><%= notification_status(notification.status) %></td> <td><%= notification_status(notification.status) %></td>
<td>
<% if notification.status == 'scheduled' %>
<%= form_with url: deliver_notification_path(notification) do |form| %>
<%= form.submit t('members.show.actions.deliver_notification') %>
<% end %>
<% end %>
</td>
</tr> </tr>
<% end %> <% end %>
</table> </table>

View File

@ -30,8 +30,10 @@ pt:
edit: "Editar detalhes" edit: "Editar detalhes"
edit_contribution: "Editar" edit_contribution: "Editar"
delete_contribution: "Apagar" delete_contribution: "Apagar"
deliver_notification: "Enviar agora"
contribution_history: "Histórico de contribuições" contribution_history: "Histórico de contribuições"
notifications: "Notificações por correio electrónico" notifications: "Notificações por correio electrónico"
contribution_actions: "Acções"
edit: edit:
title: "Editar detalhes de membro" title: "Editar detalhes de membro"
actions: actions:

View File

@ -19,5 +19,12 @@ Rails.application.routes.draw do
end end
end end
resources :notifications, only: [] do
member do
post :deliver
end
end
resource :board, only: [:edit, :update] resource :board, only: [:edit, :update]
resource :letters, only: [:create]
end end