caruby-scat 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,15 @@
1
+ require 'rubygems'
2
+ require 'bundler'
3
+ Bundler.require
4
+
5
+ require 'fileutils'
6
+ require 'redis'
7
+ require 'jinx/helpers/log'
8
+ require 'scat'
9
+
10
+ # the logger
11
+ use Rack::CommonLogger, Jinx.logger(:app => 'Scat', :debug => true)
12
+
13
+ # start the application
14
+ run Scat::App
15
+
@@ -0,0 +1,29 @@
1
+ Feature: Edit
2
+ In order to use Scat
3
+ As a biobank technician
4
+ I submit an edit
5
+
6
+ Scenario: Visit edit
7
+ Given I am on the edit page
8
+ Then I should see the "Protocol" field
9
+ And I should see the "MRN" field
10
+ And I should see the "SPN" field
11
+ And I should see the "Diagnosis" field
12
+ And I should see the "Tissue Site" field
13
+ And I should see the "Quantity" field
14
+ And I should see the "Malignant" field
15
+
16
+ Scenario: Submit an edit
17
+ Given I am on the edit page
18
+ And the protocol "Scat" exists
19
+ When I fill in "Protocol" with "Scat"
20
+ And I fill in "MRN" with "123"
21
+ And I fill in "SPN" with "SP-123"
22
+ And I fill in "Diagnosis" with "[M]Adrenal cortical adenoma NOS"
23
+ And I fill in "Tissue Site" with "Adrenal gland, NOS"
24
+ And I fill in "Quantity" with "4"
25
+ And I check the "Malignant" checkbox
26
+ And I am authorized
27
+ And I click "Save"
28
+ Then the status should show the label
29
+ And the specimen should be saved
@@ -0,0 +1,50 @@
1
+ World(Rack::Test::Methods)
2
+
3
+ Given %r{I am on the (\w+) page} do |page|
4
+ visit("/#{page unless page =~ /^([Hh]ome|[Ee]dit)$/}")
5
+ end
6
+
7
+ # Authorization requires that the CaTissue API is configured with client
8
+ # access properties as described in the caRuby Tissue +README.md+ file.
9
+ Given %q{I am authorized} do
10
+ username = CaTissue.properties[:user].split('@').first
11
+ page.driver.browser.authorize(username, CaTissue.properties[:password])
12
+ end
13
+
14
+ Given %r{the protocol "([^"]*)" exists} do |title|
15
+ Scat::Seed.protocol_for(title).find(:create)
16
+ end
17
+
18
+ When %r{I fill in(?: the)? "([^"]*)"(?: field)? with "([^"]*)"} do |name, text|
19
+ fill_in Scat::Edit.instance.input_id(name), :with => text
20
+ end
21
+
22
+ When %r{I check(?: the)? "([^"]*)(?: checkbox)?"} do |name|
23
+ check Scat::Edit.instance.input_id(name)
24
+ end
25
+
26
+ When %r{I click "([^"]*)"} do |name|
27
+ click_on name
28
+ end
29
+
30
+ Then %r{I should see the "([^"]*)" field} do |name|
31
+ find_field(Scat::Edit.instance.input_id(name)).visible?.should be true
32
+ end
33
+
34
+ Then %q{the status should show the label} do
35
+ find(:status).text.should match /label \d+\.$/
36
+ end
37
+
38
+ Then %q{the specimen should be saved} do
39
+ lbl = /label (\d+)\.$/.match(find(:status).text).captures.first
40
+ lbl.should_not be nil
41
+ CaTissue::Specimen.new(:label => lbl).find.should_not be nil
42
+ end
43
+
44
+ Then %r{^I should see "([^"]*)"$} do |text|
45
+ page.should have_content text
46
+ end
47
+
48
+ After do |scenario|
49
+ save_and_open_page if scenario.failed?
50
+ end
@@ -0,0 +1,18 @@
1
+ require 'rubygems'
2
+ require 'bundler/setup'
3
+ Bundler.require(:test, :development)
4
+
5
+ require 'capybara/cucumber'
6
+ require 'jinx/helpers/log'
7
+ require 'scat'
8
+
9
+ ENV['RACK_ENV'] = 'test'
10
+
11
+ Capybara.app = Scat::App
12
+
13
+ # Open the logger.
14
+ Jinx.logger('test/results/log/scat.log', :debug)
15
+
16
+ def app
17
+ Scat::App
18
+ end
@@ -0,0 +1,2 @@
1
+ require File.dirname(__FILE__) + '/../../test/fixtures/seed'
2
+
@@ -0,0 +1,56 @@
1
+ require 'sinatra'
2
+ require 'haml'
3
+ require 'catissue'
4
+ require 'casmall/authorization'
5
+ require 'scat/autocomplete'
6
+ require 'scat/edit'
7
+
8
+ module Scat
9
+ # The standard Scat application error.
10
+ class ScatError < RuntimeError; end
11
+
12
+ class App < Sinatra::Base
13
+ include CaSmall::Authorization, Autocomplete
14
+
15
+ set :root, File.dirname(__FILE__) + '/..'
16
+
17
+ if development? then
18
+ # Don't generate fancy HTML for stack traces.
19
+ disable :show_exceptions
20
+ # Allow errors to get out of the app so Cucumber can display them.
21
+ enable :raise_errors
22
+ end
23
+
24
+ # The authorization page name.
25
+ set :authorization_realm, 'Please enter your username and caTissue password'
26
+
27
+ enable :sessions
28
+
29
+ # Displays the edit form.
30
+ get '/' do
31
+ haml :edit
32
+ end
33
+
34
+ # Saves the specimen specified in the specimen form.
35
+ post '/' do
36
+ # Save the specimen.
37
+ protect! { Edit.instance.save(params.merge(:user => current_user), session) }
38
+ # Return to the edit form.
39
+ redirect back
40
+ end
41
+
42
+ # Displays the CVs for the given property attribute which match the given input
43
+ # value term.
44
+ get '/autocomplete/*' do |pa|
45
+ protect! { autocomplete(pa.to_sym, params[:term]) }
46
+ end
47
+
48
+ # Sets the status field to the error message.
49
+ error do
50
+ e = env['sinatra.error']
51
+ logger.error(e)
52
+ request.params[:status] = e.message
53
+ redirect back
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,74 @@
1
+ require 'jinx/helpers/inflector'
2
+ require 'catissue/database/controlled_values'
3
+ require 'scat/cache'
4
+
5
+ module Scat
6
+ # Auto-completion mix-in.
7
+ module Autocomplete
8
+ # Fetches controlled caTissue values for the given attribute and value prefix.
9
+ # The supported attributes include the following:
10
+ # * +:clinical_diagnosis+ (+SpecimenCollectionGroup.clinical_diagnosis+)
11
+ # * +:tissue_site+ (+SpecimenCharacteristics.tissue_site+)
12
+ #
13
+ # @param [Symbol] attribute the CV attribute
14
+ # @param [String] prefix the leading value letters
15
+ # @return [String] the JSON representation of the matching values
16
+ def autocomplete(attribute, text)
17
+ # Start up the cache if necessary
18
+ @cache ||= Cache.new
19
+ # Compare lower case.
20
+ text_dc = text.downcase
21
+ # The search term is the first word in the text.
22
+ words = text_dc.split(' ')
23
+ term = words.first
24
+ logger.debug { "Scat is matching the cached #{attribute} values which contain #{term}..." }
25
+ # The hash key, e.g. dx:lymphoma.
26
+ key = KEY_PREFIX_HASH[attribute] + term
27
+ # The CVs which match the term.
28
+ cvs = @cache.get_all(key)
29
+ # Load the CVs if necessary.
30
+ cvs = load_controlled_values(attribute, term, key) if cvs.empty?
31
+ # The CVs which match the entire target.
32
+ matched = words.size == 1 ? cvs : cvs.select { |cv| cv.downcase[text_dc] }
33
+ logger.debug { "#{matched.empty? ? 'No' : matched.size} #{attribute} values match '#{text}'." }
34
+ matched.to_json
35
+ end
36
+
37
+ private
38
+
39
+ KEY_PREFIX_HASH = {
40
+ :clinical_diagnosis => 'dx:',
41
+ :tissue_site => 'ts:'
42
+ }
43
+
44
+ # The template to fetch CVs matching a PID and key term.
45
+ # The SQL cheats a little, since it theoretically could return a CV with null PID
46
+ # whose parentage is not directly or indirectly in the PID. However, this problem
47
+ # does not occur in practice and is relatively benign if it should occur, since at
48
+ # worst it includes a few obviously extraneous matches.
49
+ CV_TMPL = "select value
50
+ from catissue_permissible_value pv
51
+ where (public_id = '%s' or (public_id is null and parent_identifier is not null))
52
+ and value like '%%%s%%'
53
+ and not exists (select 1 from catissue_permissible_value where parent_identifier = pv.identifier)"
54
+
55
+ # @param (see #match)
56
+ # @param [Strimg] the cache key
57
+ # @return (see #match)
58
+ def load_controlled_values(attribute, term, key)
59
+ logger.debug { "Scat is loading the #{attribute} controlled values which match '#{term}'..." }
60
+ # The CV PID, e.g. the :tissue_site PID is Tissue_Site_PID.
61
+ pid = attribute.to_s.split('_').map { |s| s.capitalize_first }.join('_') << '_PID'
62
+ # The SQL is the template formatted with the PID and term.
63
+ sql = CV_TMPL % [pid, term]
64
+ # Cache each fetched CV.
65
+ cvs = CaTissue::Database.current.executor.query(sql).map do |rec|
66
+ cv = rec.first
67
+ @cache.add(key, cv)
68
+ cv
69
+ end
70
+ logger.debug { "Scat loaded #{cvs.size} #{attribute} #{term} controlled values." }
71
+ cvs
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,90 @@
1
+ require 'redis'
2
+
3
+ module Scat
4
+ # The session and global key => value cache wrapper.
5
+ class Cache
6
+ # @return [Redis] the Redis cache data store
7
+ # @raise [ScatError] if the cache could not be started
8
+ def datastore
9
+ @redis ||= discover
10
+ end
11
+
12
+ # Sets the given value to the cache tag set as follows:
13
+ # * If the key is nil, then the cache entry is the tag.
14
+ # * Otherwise, the cache entry is the tag hash entry for the given key.
15
+ # * If the value is nil, then the entry is removed from the cache.
16
+ # * Otherwise, the value is converted to a string, if necessary, and
17
+ # the cache entry is set to the value.
18
+ #
19
+ # @param [String, Symbol] tag the cache tag
20
+ # @param value the value to set
21
+ # @param [String, Symbol, nil] key the cache tag hash key
22
+ def set(tag, value, key=nil)
23
+ if value.nil? then
24
+ key ? datastore.hdel(tag, key) : datastore.rem(tag)
25
+ else
26
+ key ? datastore.hset(tag, key, value) : datastore.set(tag, value)
27
+ end
28
+ end
29
+
30
+ # Adds the given value to the cache tag set. The value is converted to
31
+ # a string, if necessary.
32
+ #
33
+ # @param [String, Symbol] tag the cache tag
34
+ # @param value the value to add
35
+ def add(tag, value)
36
+ datastore.zadd(tag, 0, value)
37
+ end
38
+
39
+ # @param [String, Symbol] tag the cache tag
40
+ # @param [Symbol, nil] the cached tag hash key
41
+ # @return [String, <String>] the matching value or values
42
+ def get(tag, key=nil)
43
+ key ? datastore.hget(tag, key) : datastore.get(tag)
44
+ end
45
+
46
+ # @param [String, Symbol] tag the cache tag
47
+ # @return [<String>] the matching set
48
+ def get_all(tag)
49
+ datastore.zrange(tag, 0, -1)
50
+ end
51
+
52
+ private
53
+
54
+ REDIS_SERVER = File.expand_path('redis-server', File.dirname(__FILE__) + '/../../ext')
55
+
56
+ REDIS_CONF = File.expand_path('redis.conf', File.dirname(__FILE__) + '/../../conf')
57
+
58
+ # Locates the Redis server on the default Redis port.
59
+ #
60
+ # @return [Redis] the Redis client
61
+ # @raise (see #start)
62
+ def discover
63
+ redis = Redis.current
64
+ redis.ping rescue start(redis)
65
+ redis
66
+ end
67
+
68
+ # Starts the Redis server on the default Redis port.
69
+ #
70
+ # @param [Redis] the Redis client
71
+ # @raise [ScatError] if the server command could not be executed
72
+ # @raise [Exception] if the server is not reachable
73
+ def start(redis)
74
+ logger.debug { "Scat is starting the Redis cache server..." }
75
+ unless system(REDIS_SERVER, REDIS_CONF) then
76
+ raise ScatError.new("Scat cannot start the Redis cache server.")
77
+ end
78
+ # Ping the server until loaded.
79
+ 3.times do |n|
80
+ begin
81
+ redis.ping
82
+ logger.debug { "Scat started the Redis cache server." }
83
+ return redis
84
+ rescue
85
+ n < 2 ? sleep(0.5) : raise
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,79 @@
1
+ require 'yaml'
2
+ require 'jinx/helpers/hash'
3
+ require 'scat/field'
4
+
5
+ module Scat
6
+ # The Scat Configuration specifies the edit form fields.
7
+ class Configuration
8
+ # @param [String] the configuration file path
9
+ def initialize(file)
10
+ # the field name => Field hash
11
+ @fld_hash = parse(YAML.load_file(file))
12
+ logger.info("Scat fields: #{@fld_hash.values.pp_s}")
13
+ end
14
+
15
+ # @param [Symbol, String] name the field name or label
16
+ # @return [Field] the corresponding field
17
+ def [](name_or_label)
18
+ # the standardized field name
19
+ key = case name_or_label
20
+ when Symbol then
21
+ name_or_label
22
+ when String then
23
+ Field.name_for(name_or_label).to_sym
24
+ else
25
+ raise ArgumentError.new("Scat field argument not supported: #{name_or_label.qp}")
26
+ end
27
+ @fld_hash[key]
28
+ end
29
+
30
+ # Returns the edit form field specifications.
31
+ #
32
+ # @return [<Field>] the specimen edit field specifications
33
+ def fields
34
+ @fld_hash.values
35
+ end
36
+
37
+ # @param [{String => String}] params the request parameters
38
+ # @return [{Class => {CaRuby::Property => Object}}] the caTissue class => { property => value } hash
39
+ def slice(params)
40
+ hash = Jinx::LazyHash.new { Hash.new }
41
+ params.each do |name, value|
42
+ next if value.nil?
43
+ # Skip the param if it is not obtained from the request.
44
+ field = @fld_hash[name.to_sym] || next
45
+ field.properties.each do |klass, prop|
46
+ # Convert non-string values.
47
+ if prop.type <= Integer or prop.type <= Java::JavaLang::Integer then
48
+ value = value.to_i
49
+ elsif prop.type <= Date or prop.type <= Java::JavaUtil::Date then
50
+ value = DateTime.parse(value)
51
+ elsif prop.type <= Numeric or prop.type <= Java::JavaLang::Number then
52
+ value = value.to_f
53
+ end
54
+ # Generalize a Specimen subclass.
55
+ klass = CaTissue::Specimen if klass < CaTissue::Specimen
56
+ # Add the property value.
57
+ hash[klass][prop.to_sym] = value
58
+ end
59
+ end
60
+ hash
61
+ end
62
+
63
+ private
64
+
65
+ # Parses the field configuration as described in {Field}.
66
+ #
67
+ # @param [{String => String}] config the field label => spec hash
68
+ # @return [{Symbol} => Field] the symbol => Field hash
69
+ def parse(config)
70
+ hash = {}
71
+ config.each do |label, spec|
72
+ field = Field.new(label, spec)
73
+ hash[field.name.to_sym] = field
74
+ end
75
+ hash
76
+ end
77
+ end
78
+ end
79
+
@@ -0,0 +1,160 @@
1
+ require 'singleton'
2
+ require 'jinx/helpers/validation'
3
+ require 'scat/configuration'
4
+ require 'scat/authorization'
5
+ require 'scat/cache'
6
+
7
+ module Scat
8
+ # The Edit helper makes a +CaTissue::Specimen+ from the edit form parameters and creates
9
+ # the specimen in the database.
10
+ class Edit
11
+ include Singleton
12
+
13
+ def initialize
14
+ super
15
+ @conf = Configuration.new(FIELD_CONFIG)
16
+ end
17
+
18
+ # @return (see Configuration#fields)
19
+ def fields
20
+ # Delegate to the configuration.
21
+ @conf.fields
22
+ end
23
+
24
+ # @param (see Configuration#[])
25
+ # @return [String] the corresponding HTML input element id
26
+ def input_id(name_or_label)
27
+ field = @conf[name_or_label]
28
+ if field.nil? then raise ArgumentError.new("Scat field not found: #{name_or_label.qp}") end
29
+ field.input_id
30
+ end
31
+
32
+ # Saves the +CaTissue::Specimen+ object specified in the request parameters.
33
+ #
34
+ # @param [{Symbol => String}] params the request parameters
35
+ # @param [{String => String}] session the current Sinatra session
36
+ # @return [CaTissue::Specimen] the saved specimen
37
+ # @raise [ScatError] if the parameters are insufficient to build a specimen
38
+ # @raise [CaRuby::DatabaseError] if the save is unsuccessful
39
+ def save(params, session)
40
+ logger.debug { "Scat is saving the specimen with parameters #{params.qp}..." }
41
+ pcl_id = session[:protocol_id] if session[:protocol] == params[:protocol]
42
+ site_id = session[:site_id] if pcl_id
43
+ cpr_id = session[:cpr_id] if pcl_id and session[:mrn] == params[:mrn]
44
+ scg_id = session[:scg_id] if cpr_id and session[:spn] == params[:spn]
45
+ # the caTissue class => { property => value } hash
46
+ ph = @conf.slice(params)
47
+ # the collection protocol
48
+ pcl = to_protocol(ph[CaTissue::CollectionProtocol].merge({:identifier => pcl_id}))
49
+ # the current user
50
+ user = params[:user]
51
+ # the collection site
52
+ site = site_id ? CaTissue::Site.new(:identifier => site_id) : to_site(pcl, user)
53
+ # the patient
54
+ pnt = CaTissue::Participant.new(ph[CaTissue::Participant])
55
+ # the CPR parameters
56
+ reg_params = ph[CaTissue::ParticipantMedicalIdentifier].merge(:participant => pnt, :site => site)
57
+ # the CPR
58
+ reg = to_registration(pcl, reg_params)
59
+ reg.identifier = cpr_id
60
+ # the specimen parameters
61
+ spc_params = ph[CaTissue::Specimen].merge(ph[CaTissue::SpecimenCharacteristics])
62
+ # The current user is the biobank specimen receiver.
63
+ spc_params.merge!(:receiver => user)
64
+ # the specimen to save
65
+ spc = to_specimen(pcl, spc_params)
66
+ # the SCG parameters
67
+ scg_params = ph[CaTissue::SpecimenCollectionGroup].merge(:participant => pnt, :site => site)
68
+ # the SCG which contains the specimen
69
+ pcl.add_specimens(spc, scg_params)
70
+ # Save the specimen.
71
+ logger.debug { "Scat is saving #{spc} with content:\n#{spc.dump}" }
72
+ spc.save
73
+ # Format the status message.
74
+ session[:status] = "Created the specimen with label #{spc.label}."
75
+ # Capture the params in the session to refresh the form.
76
+ params.each { |a, v| session[a.to_sym] = v }
77
+ # Capture the ids.
78
+ scg = spc.specimen_collection_group
79
+ session[:scg_id] = scg.identifier
80
+ session[:site_id] = site.identifier
81
+ cpr = scg.registration
82
+ session[:cpr_id] = cpr.identifier
83
+ session[:protocol_id] = cpr.protocol.identifier
84
+ logger.debug { "Scat saved #{spc}." }
85
+ spc
86
+ end
87
+
88
+ private
89
+
90
+ # The field configuration file name.
91
+ FIELD_CONFIG = File.dirname(__FILE__) + '/../../conf/fields.yaml'
92
+
93
+ # Builds the registration object specified in the given parameters.
94
+ #
95
+ # @param [{Symbol => String}] params the registration parameters
96
+ # @return [CaTissue::CollectionProtocolRegistration] the SCG to save
97
+ def to_protocol(params)
98
+ pcl = CaTissue::CollectionProtocol.new(params)
99
+ unless pcl.find then
100
+ raise ScatError.new("Protocol not found: #{pcl.title}")
101
+ end
102
+ pcl
103
+ end
104
+
105
+ # The collection site is the first match for the following criteria:
106
+ # * the first site of the current user
107
+ # * the first protocol site
108
+ # * the first protocol coordinator site
109
+ #
110
+ # @param [CaTissue::CollectionProtocol] protocol the collection protocol
111
+ # @param [CaTissue::User] user the current user
112
+ # @return [CaTissue::Site] the collection site
113
+ def to_site(protocol, user)
114
+ user.find unless user.fetched?
115
+ site = user.sites.first
116
+ return site if site
117
+ protocol.find unless protocol.fetched?
118
+ site = protocol.sites.first
119
+ return site if site
120
+ site = protocol.coordinators.detect_value { |coord| coord.sites.first } or
121
+ raise ScatError.new("Neither the user #{rcvr.email_address} nor the #{pcl.title} protocol administrators have an associated site.")
122
+ end
123
+
124
+ # Builds the registration object specified in the given parameters.
125
+ #
126
+ # @param [CaTissue::CollectionProtocol] protocol the collection protocol
127
+ # @param [{Symbol => String}] params the registration parameters
128
+ # @return [CaTissue::CollectionProtocolRegistration] the SCG to save
129
+ def to_registration(protocol, params)
130
+ # If there is an MRN, then make a PMI
131
+ if params.has_key?(:medical_record_number) then
132
+ CaTissue::ParticipantMedicalIdentifier.new(params)
133
+ end
134
+ # the patient
135
+ pnt = params[:participant]
136
+ # Register the patient.
137
+ protocol.register(pnt)
138
+ end
139
+
140
+ # Builds the +CaTissue::Specimen+ object specified in the given parameters.
141
+ #
142
+ # The default edit form pathological status checkbox value is 'Malignant'.
143
+ # If the user unchecked it, then there is no pathological status parameter.
144
+ # In that case, the pathological status is 'Non-Malignant'.
145
+ #
146
+ # @param [CaTissue::CollectionProtocol] protocol the collection protocol
147
+ # @param [{Symbol => String}] params the Specimen parameters
148
+ # @return [CaTissue::Specimen] the specimen to save
149
+ def to_specimen(protocol, params)
150
+ params[:pathological_status] ||= 'Non-Malignant'
151
+ # The CPE is the sole protocol event.
152
+ cpe = protocol.events.first
153
+ # The specimen requirement is the sole event requirement.
154
+ rqmt = cpe.requirements.first
155
+ # Make the specimen.
156
+ CaTissue::Specimen.create_specimen(params.merge(:requirement => rqmt))
157
+ end
158
+ end
159
+ end
160
+