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

View File

@ -64,6 +64,7 @@ gem 'foodsoft_discourse', path: 'plugins/discourse'
# plugins not enabled by default
#gem 'foodsoft_current_orders', path: 'plugins/current_orders'
#gem 'foodsoft_printer', path: 'plugins/printer'
#gem 'foodsoft_uservoice', path: 'plugins/uservoice'

View File

@ -649,6 +649,11 @@ de:
pdf_font_size: Schriftgrösse
pdf_page_size: Seitenformat
price_markup: Foodcoop Marge
printer_print_order_articles: Artikel PDF drucken
printer_print_order_fax: Fax PDF drucken
printer_print_order_groups: Gruppen PDF drucken
printer_print_order_matrix: Matrix PDF drucken
printer_token: Geheimer Token
stop_ordering_under: Apfelpunkte Minimum
tasks_period_days: Zeitintervall
tasks_upfront_days: Im Voraus
@ -662,6 +667,7 @@ de:
use_iban: IBAN verwenden
use_messages: Nachrichten
use_nick: Benutzernamen verwenden
use_printer: Drucker verwenden
use_wiki: Wiki verwenden
webstats_tracking_code: Code für Websiteanalysetool
tabs:
@ -1109,6 +1115,8 @@ de:
submit:
invite:
create: Einladung verschicken
printer_job:
create: Druckauftrag erstellen
message:
create: Nachricht verschicken
tasks:
@ -1461,6 +1469,7 @@ de:
manage: Bestellverwaltung
ordering: Bestellen!
pickups: Abholtage
printer_jobs: Druckaufträge
title: Bestellungen
tasks: Aufgaben
wiki:
@ -1565,6 +1574,7 @@ de:
confirm_end: |-
Willst Du wirklich die Bestellung %{order} beenden?
Es gibt kein zurück.
confirm_create_printer_job: Für diese Bestellung wurde bereits ein Druckauftrag erstellt. Willst du einen neuen erstellen?
confirm_send_to_supplier: Die Bestellung wurde bereit am %{when} zur Lieferantin geschickt. Willst du sie wirklich erneut schicken?
create_invoice: Rechnung anlegen
description1_order: "%{state} Bestellung von %{supplier} angelegt von %{who},"
@ -1704,6 +1714,18 @@ de:
nojs: Achtung, Cookies und Javascript müssen aktiviert sein! %{link} bitte abschalten.
noscript: NoScript
title: Foodsoft anmelden
printer_jobs:
create:
notice: "%{count} Druckaufträge wurden erstellt."
destroy:
notice: Druckauftrag wurde gelöscht.
index:
finished: Beendet
pending: Ausstehend
queued: Warten auf den Bestellschluss
title: Druckaufträge
show:
title: Druckauftrag %{id}
shared:
articles:
ordered: Bestellt

View File

@ -652,6 +652,11 @@ en:
pdf_font_size: Font size
pdf_page_size: Page size
price_markup: Foodcoop margin
printer_print_order_articles: Print article PDF
printer_print_order_fax: Print fax PDF
printer_print_order_groups: Print group PDF
printer_print_order_matrix: Print matrix PDF
printer_token: Secret token
stop_ordering_under: Minimum apple points
tasks_period_days: Period
tasks_upfront_days: Create upfront
@ -665,6 +670,7 @@ en:
use_iban: Use IBAN
use_messages: Messages
use_nick: Use nicknames
use_printer: Use printer
use_wiki: Enable wiki
webstats_tracking_code: Tracking code
tabs:
@ -1126,6 +1132,8 @@ en:
submit:
invite:
create: send invitation
printer_job:
create: create printer job
message:
create: send message
tasks:
@ -1487,6 +1495,7 @@ en:
manage: Manage orders
ordering: Place order!
pickups: Pickup days
printer_jobs: Printer jobs
title: Orders
tasks: Tasks
wiki:
@ -1591,6 +1600,7 @@ en:
confirm_end: |-
Do you really want to close the order %{order}?
There is no going back.
confirm_create_printer_job: A printer job for this order has been created already. Do you want to create a new one?
confirm_send_to_supplier: The order has been sent to the supplier already on %{when}. Do you really want to send it again?
create_invoice: Add invoice
description1_order: "%{state} order from %{supplier} opened by %{who},"
@ -1730,6 +1740,18 @@ en:
nojs: Attention, cookies and javascript have to be activated! Please switch off %{link}.
noscript: NoScript
title: Foodsoft login
printer_jobs:
create:
notice: Created %{count} printer jobs.
destroy:
notice: Printer job has been deleted.
index:
finished: Finished
pending: Pending
queued: Waiting for order to close
title: Printer jobs
show:
title: Printer job %{id}
shared:
articles:
ordered: Ordered

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

@ -11,7 +11,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20171201000000) do
ActiveRecord::Schema.define(version: 20181201000000) do
create_table "article_categories", force: :cascade do |t|
t.string "name", limit: 255, default: "", null: false
@ -378,6 +378,25 @@ ActiveRecord::Schema.define(version: 20171201000000) do
t.datetime "updated_at", null: false
end
create_table "printer_job_updates", force: :cascade do |t|
t.integer "printer_job_id", 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"], name: "index_printer_job_updates_on_printer_job_id_and_created_at", using: :btree
create_table "printer_jobs", force: :cascade do |t|
t.integer "order_id"
t.string "document", null: false
t.integer "created_by_user_id", null: false
t.integer "finished_by_user_id"
t.datetime "finished_at"
end
add_index "printer_jobs", ["finished_at"], name: "index_printer_jobs_on_finished_at", using: :btree
create_table "settings", force: :cascade do |t|
t.string "var", limit: 255, null: false
t.text "value", limit: 65535

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