Add a printer job queue via the printer plugin

This commit is contained in:
Patrick Gansterer 2019-02-02 12:40:57 +01:00
parent 63e1541aa3
commit c955a6ee40
24 changed files with 561 additions and 1 deletions

61
plugins/printer/README.md Normal file
View file

@ -0,0 +1,61 @@
FoodsoftPrinter
=================
This plugin adds a printer queue to allow mebers to print PDF with one click.
Usually a mini computer with a printer at a room in the foodcoop will wait for
new printer jobs and prints them.
This plugin is not enabled by default. To install it, add uncomment the
corresponding line in the `Gemfile`, or add:
```Gemfile
gem 'foodsoft_printer', path: 'plugins/foodsoft_printer'
```
This plugin introduces the foodcoop config option `printer_token`, which takes
a random string for authentication at the endpoint. Additionally a set of
PDF files can be selected, which will be generated when a print is triggered.
The communication with the printer client happens via two endpoints, which both
require the `printer_token` as `Bearer` token in the `Authorization` header.
* `/:foodcoop/printer/socket`: main WebSocket communication
* `/:foodcoop/printer/:id`: HTTP GET for documents
The main communication happens via JSON messages via an WebSocket connection,
which sends an array of docuement ids to the client, which need to be printed.
Addionally the docuemnt can be downloaded as PDF via a separate endpoint.
The client can updated the status of the documents by sending an object with
the following keys to the server:
* `id` (NBR, REQUIRED): id of the document, which should be updated
* `state` (ENUM): the current sate of the printing progress.
* `message` (STR, REQUIRES `state`): detailed description of the current state
(e.g. download progress)
* `finish` (BOOL): when set to `true` the job will be marked as done
The following values are valid for the `state` property:
* `queued`: the document is not yet ready for printing
* `ready`: the document is ready to be downloaded
* `downloading`: transfer of the document is in progress
* `pending`: download completed, waiting for the printer
* `held`: e.g., for "PIN printing"
* `processing`: printing is in progress
* `stopped`: out of paper, etc.)
* `cancelled`: the user stopped the action
* `aborted`: the printer stopped the action
* `completed`: print was successful
**Example**:
A server sending `{"unfinished_jobs":[12,16]}` via WebSocket indicates that the
two documents `12` and `16` are ready for printing. The client will request the
first document from `/foodcoop/printer/12` and send it to the printer. The
status can be updated by sending `{"id":12,"state":"pending"}` via WebSocket to
the server. Sending `{"id":12,"state":"completed","finish":true}` as soon as
when the printing succeded will mark the job done.
To use this plugin the webserver must support WebSockets. The current
implementation uses socket hijack, which is not supported by `thin`. `puma`
supports that, but might lead to other problems, since it's not well tested
in combination with foodsoft. Please be careful when switching the webserver!
This plugin is part of the foodsoft package and uses the AGPL-3 license (see
foodsoft's LICENSE for the full license text).

40
plugins/printer/Rakefile Normal file
View file

@ -0,0 +1,40 @@
#!/usr/bin/env rake
begin
require 'bundler/setup'
rescue LoadError
puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
end
begin
require 'rdoc/task'
rescue LoadError
require 'rdoc/rdoc'
require 'rake/rdoctask'
RDoc::Task = Rake::RDocTask
end
RDoc::Task.new(:rdoc) do |rdoc|
rdoc.rdoc_dir = 'rdoc'
rdoc.title = 'FoodsoftPrinter'
rdoc.options << '--line-numbers'
rdoc.rdoc_files.include('README.rdoc')
rdoc.rdoc_files.include('lib/**/*.rb')
end
APP_RAKEFILE = File.expand_path("../test/dummy/Rakefile", __FILE__)
load 'rails/tasks/engine.rake'
Bundler::GemHelper.install_tasks
require 'rake/testtask'
Rake::TestTask.new(:test) do |t|
t.libs << 'lib'
t.libs << 'test'
t.pattern = 'test/**/*_test.rb'
t.verbose = false
end
task :default => :test

View file

@ -0,0 +1,58 @@
class PrinterController < ApplicationController
include Concerns::SendOrderPdf
include Tubesock::Hijack
skip_before_filter :authenticate
before_filter :authenticate_printer
before_filter -> { require_plugin_enabled FoodsoftPrinter }
def socket
hijack do |tubesock|
tubesock.onopen do
tubesock.send_data unfinished_jobs
end
tubesock.onmessage do |data|
update_job data
tubesock.send_data unfinished_jobs
end
end
end
def show
job = PrinterJob.find(params[:id])
send_order_pdf job.order, job.document
end
private
def unfinished_jobs
{
unfinished_jobs: PrinterJob.pending.map(&:id)
}.to_json
end
def update_job(data)
json = JSON.parse data, symbolize_names: true
job = PrinterJob.unfinished.find_by_id(json[:id])
return unless job
if json[:state]
job.add_update! json[:state], json[:message]
end
job.finish! if json[:finish]
end
protected
def bearer_token
pattern = /^Bearer /
header = request.headers['Authorization']
header.gsub(pattern, '') if header && header.match(pattern)
end
def authenticate_printer
return head(:unauthorized) unless bearer_token
return head(:forbidden) if bearer_token != FoodsoftConfig[:printer_token]
end
end

View file

@ -0,0 +1,44 @@
class PrinterJobsController < ApplicationController
include Concerns::SendOrderPdf
before_filter -> { require_plugin_enabled FoodsoftPrinter }
def index
jobs = PrinterJob.includes(:printer_job_updates)
@pending_jobs = jobs.pending
@queued_jobs = jobs.queued
@finished_jobs = jobs.finished.order(finished_at: :desc).page(params[:page]).per(@per_page)
end
def create
order = Order.find(params[:order])
state = order.open? ? 'queued' : 'ready'
count = 0
PrinterJob.transaction do
%w(articles fax groups matrix).each do |document|
next unless FoodsoftConfig["printer_print_order_#{document}"]
job = PrinterJob.create! order: order, document: document, created_by: current_user
job.add_update! state
count += 1
end
end
redirect_to order, notice: t('.notice', count: count)
end
def show
@job = PrinterJob.find(params[:id])
end
def document
job = PrinterJob.find(params[:id])
send_order_pdf job.order, job.document
end
def destroy
job = PrinterJob.find(params[:id])
job.finish! current_user
redirect_to printer_jobs_path, notice: t('.notice')
rescue => error
redirect_to printer_jobs_path, t('errors.general_msg', msg: error.message)
end
end

View file

@ -0,0 +1,31 @@
class PrinterJob < ActiveRecord::Base
belongs_to :order
belongs_to :created_by, class_name: 'User', foreign_key: 'created_by_user_id'
belongs_to :finished_by, class_name: 'User', foreign_key: 'finished_by_user_id'
has_many :printer_job_updates
scope :finished, -> { where.not(finished_at: nil) }
scope :unfinished, -> { where(finished_at: nil).order(:id) }
scope :pending, -> { unfinished.includes(:order).where.not(orders: {state: 'open'}) }
scope :queued, -> { unfinished.includes(:order).where(orders: {state: 'open'}) }
def last_update_at
printer_job_updates.order(:created_at).last.try(&:created_at)
end
def last_update_state
printer_job_updates.order(:created_at).last.try(&:state)
end
def add_update!(state, message=nil)
return unless finished_at.nil?
PrinterJobUpdate.create! printer_job: self, state: state, message: message
end
def finish!(user=nil)
return unless finished_at.nil?
update_attributes finished_at: Time.now, finished_by: user
end
end

View file

@ -0,0 +1,5 @@
class PrinterJobUpdate < ActiveRecord::Base
belongs_to :printer_job
end

View file

@ -0,0 +1,7 @@
/ insert_after ':root:last-child'
= config_use_heading form, :use_printer do
= config_input form, :printer_token, as: :string, input_html: {class: 'input-xlarge'}
= config_input form, :printer_print_order_fax, as: :boolean
= config_input form, :printer_print_order_articles, as: :boolean
= config_input form, :printer_print_order_groups, as: :boolean
= config_input form, :printer_print_order_matrix, as: :boolean

View file

@ -0,0 +1,7 @@
/ insert_after 'erb:contains("title t(\'.title\'")'
- content_for :actionbar do
- if FoodsoftPrinter.enabled?
= link_to content_tag(:i, nil, class: 'icon-print'), printer_jobs_path(order: @order), method: :post,
class: "btn#{' btn-primary' unless @order.printer_jobs.any?}",
data: {confirm: @order.printer_jobs.any? && t('.confirm_create_printer_job') },
title: t('helpers.submit.printer_job.create')

View file

@ -0,0 +1,22 @@
.pull-right
- if @finished_jobs.total_pages > 1
.btn-group= items_per_page wrap: false
= pagination_links_remote @finished_jobs
%table.table.table-striped
%thead
%tr
%th= heading_helper(PrinterJob, :id)
%th= heading_helper(PrinterJob, :order)
%th= heading_helper(PrinterJob, :pickup)
%th= heading_helper(PrinterJob, :document)
%th= heading_helper(PrinterJob, :last_update_state)
%th= heading_helper(PrinterJob, :finished_at)
%tbody
- @finished_jobs.each do |j|
%tr
%td= link_to j.id, j
%td= link_to j.order.supplier.name, j.order
%td= format_date j.order.pickup
%td= link_to j.document, document_printer_job_path(j)
%td= j.last_update_state
%td= format_time j.finished_at

View file

@ -0,0 +1,47 @@
- title t('.title')
- unless @pending_jobs.empty? && @queued_jobs.empty?
.well
%h2
- if @pending_jobs.any?
=t '.pending'
- else
=t '.queued'
%table.table.table-striped
%thead
%tr
%th= heading_helper(PrinterJob, :id)
%th= heading_helper(PrinterJob, :order)
%th= heading_helper(PrinterJob, :pickup)
%th= heading_helper(PrinterJob, :document)
%th= heading_helper(PrinterJob, :last_update_at)
%th= heading_helper(PrinterJob, :last_update_state)
%th
%tbody
- @pending_jobs.each do |j|
%tr
%td= link_to j.id, j
%td= link_to j.order.supplier.name, j.order
%td= format_date j.order.pickup
%td= link_to j.document, document_printer_job_path(j)
%td= format_time j.last_update_at
%td= j.last_update_state
%td= link_to t('ui.delete'), j, method: :delete, data: {:confirm => t('ui.confirm_delete', name: j.id)},
class: 'btn btn-mini btn-danger'
- if @pending_jobs.any?
%tr
%td{colspan: 7}
%h2= t '.queued'
- @queued_jobs.each do |j|
%tr
%td= link_to j.id, j
%td= link_to j.order.supplier.name, j.order
%td= format_date j.order.pickup
%td= link_to j.document, document_printer_job_path(j)
%td= format_time j.last_update_at
%td= j.last_update_state
%td= link_to t('ui.delete'), j, method: :delete, data: {:confirm => t('ui.confirm_delete', name: j.id)},
class: 'btn btn-mini btn-danger'
%h2= t '.finished'
#jobsTable= render 'jobs'

View file

@ -0,0 +1 @@
$('#jobsTable').html('#{j(render('jobs'))}');

View file

@ -0,0 +1,36 @@
- title t('.title', id: @job.id)
%p
%b= heading_helper(PrinterJob, :created_by) + ':'
= show_user @job.created_by
%p
%b= heading_helper(PrinterJob, :order) + ':'
= @job.order.supplier.name
%p
%b= heading_helper(PrinterJob, :pickup) + ':'
= @job.order.pickup
%p
%b= heading_helper(PrinterJob, :document) + ':'
= @job.document
- if @job.finished_at
%p
%b= heading_helper(PrinterJob, :finished_at) + ':'
= format_time @job.finished_at
- if @job.finished_by
%p
%b= heading_helper(PrinterJob, :finished_by) + ':'
= show_user @job.finished_by
%table.table.table-striped
%thead
%tr
%th= heading_helper(PrinterJobUpdate, :created_at)
%th= heading_helper(PrinterJobUpdate, :state)
%th= heading_helper(PrinterJobUpdate, :message)
%tbody
- @job.printer_job_updates.each do |u|
%tr
%td= format_time u.created_at
%td= u.state
%td= u.message

View file

@ -0,0 +1,11 @@
Rails.application.routes.draw do
scope '/:foodcoop' do
resources :printer, only: [:show] do
get :socket, on: :collection
end
resources :printer_jobs, only: [:index, :create, :show, :destroy] do
get :document, on: :member
end
end
end

View file

@ -0,0 +1,20 @@
class CreatePrinterJobs < ActiveRecord::Migration
def change
create_table :printer_jobs do |t|
t.references :order
t.string :document, null: false
t.integer :created_by_user_id, null: false
t.integer :finished_by_user_id
t.datetime :finished_at, index: true
end
create_table :printer_job_updates do |t|
t.references :printer_job, null: false
t.datetime :created_at, null: false
t.string :state, null: false
t.text :message
end
add_index :printer_job_updates, [:printer_job_id, :created_at]
end
end

View file

@ -0,0 +1,22 @@
$:.push File.expand_path("../lib", __FILE__)
# Maintain your gem's version:
require "foodsoft_printer/version"
# Describe your gem and declare its dependencies:
Gem::Specification.new do |s|
s.name = "foodsoft_printer"
s.version = FoodsoftPrinter::VERSION
s.authors = ["paroga"]
s.email = ["paroga@paroga.com"]
s.homepage = "https://github.com/foodcoops/foodsoft"
s.summary = "Printer plugin for foodsoft."
s.description = "Add a printer queue to foodsoft."
s.files = Dir["{app,config,db,lib}/**/*"] + ["Rakefile", "README.md"]
s.test_files = Dir["test/**/*"]
s.add_dependency "rails"
s.add_dependency "deface", "~> 1.0"
s.add_dependency "tubesock"
end

View file

@ -0,0 +1,9 @@
require 'foodsoft_printer/engine'
require 'foodsoft_printer/order_printer_jobs'
require 'tubesock'
module FoodsoftPrinter
def self.enabled?
FoodsoftConfig[:use_printer]
end
end

View file

@ -0,0 +1,26 @@
module FoodsoftPrinter
class Engine < ::Rails::Engine
def navigation(primary, context)
return unless FoodsoftPrinter.enabled?
unless primary[:orders].nil?
sub_nav = primary[:orders].sub_navigation
sub_nav.items <<
SimpleNavigation::Item.new(primary, :printer_jobs, I18n.t('navigation.orders.printer_jobs'), context.printer_jobs_path)
end
end
def default_foodsoft_config(cfg)
cfg[:use_printer] = false
end
initializer 'foodsoft_printer.order_printer_jobs' do |app|
if Rails.configuration.cache_classes
OrderPrinterJobs.install
else
ActionDispatch::Reloader.to_prepare do
OrderPrinterJobs.install
end
end
end
end
end

View file

@ -0,0 +1,26 @@
module FoodsoftPrinter
module OrderPrinterJobs
def self.included(base) # :nodoc:
base.class_eval do
has_many :printer_jobs, dependent: :destroy
alias foodsoft_printer_orig_finish! finish!
def finish!(user)
foodsoft_printer_orig_finish!(user)
unless finished?
printer_jobs.unfinished.each do |job|
job.add_update! 'ready'
end
end
end
end
end
def self.install
Order.send :include, self
end
end
end

View file

@ -0,0 +1,3 @@
module FoodsoftPrinter
VERSION = "0.0.1"
end