psc 0.0.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.
Files changed (47) hide show
  1. data/.gitignore +13 -0
  2. data/.rvmrc +2 -0
  3. data/.yardopts +4 -0
  4. data/CHANGELOG.md +4 -0
  5. data/Gemfile +20 -0
  6. data/LICENSE +20 -0
  7. data/README.md +163 -0
  8. data/Rakefile +69 -0
  9. data/cucumber.yml +13 -0
  10. data/features/step_definitions/eval_steps.rb +14 -0
  11. data/features/step_definitions/setup_steps.rb +7 -0
  12. data/features/studies.feature +21 -0
  13. data/features/support/env.rb +33 -0
  14. data/features/support/int_psc.rb +165 -0
  15. data/int-psc/README-int-psc.md +57 -0
  16. data/int-psc/hsqldb/baseline.log +197 -0
  17. data/int-psc/hsqldb/baseline.properties +17 -0
  18. data/int-psc/hsqldb/baseline.script +295 -0
  19. data/int-psc/hsqldb/datasource.properties +18 -0
  20. data/int-psc/hsqldb/datasource.script +2205 -0
  21. data/int-psc/jetty/jetty-runner-7.4.0.v20110414.jar +0 -0
  22. data/int-psc/state/ABC 1200.xml +115 -0
  23. data/int-psc/state/int-psc-state.xml +64 -0
  24. data/lib/psc.rb +47 -0
  25. data/lib/psc/client.rb +35 -0
  26. data/lib/psc/connection.rb +96 -0
  27. data/lib/psc/faraday.rb +11 -0
  28. data/lib/psc/faraday/http_basic.rb +35 -0
  29. data/lib/psc/faraday/psc_token.rb +42 -0
  30. data/lib/psc/faraday/string_is_xml.rb +25 -0
  31. data/lib/psc/version.rb +3 -0
  32. data/meta.rakefile +30 -0
  33. data/psc.gemspec +33 -0
  34. data/spec/fixtures/studies-json.http +37 -0
  35. data/spec/middleware_helper.rb +18 -0
  36. data/spec/psc/client_spec.rb +39 -0
  37. data/spec/psc/connection_spec.rb +156 -0
  38. data/spec/psc/faraday/http_basic_spec.rb +15 -0
  39. data/spec/psc/faraday/psc_token_spec.rb +38 -0
  40. data/spec/psc/faraday/string_is_xml_spec.rb +49 -0
  41. data/spec/psc/version_spec.rb +11 -0
  42. data/spec/psc_spec.rb +16 -0
  43. data/spec/spec_helper.rb +15 -0
  44. data/tasks/int-psc.rake +84 -0
  45. data/tasks/psc/TODO +3 -0
  46. data/tasks/psc/state.rb +393 -0
  47. metadata +311 -0
@@ -0,0 +1,11 @@
1
+ require File.expand_path("../../spec_helper.rb", __FILE__)
2
+
3
+ describe Psc, "::VERSION" do
4
+ it "exists" do
5
+ lambda { Psc::VERSION }.should_not raise_error
6
+ end
7
+
8
+ it "has 3 or 4 dot separated parts" do
9
+ Psc::VERSION.split('.').size.should be_between(3, 4)
10
+ end
11
+ end
@@ -0,0 +1,16 @@
1
+ require File.expand_path("../spec_helper", __FILE__)
2
+
3
+ describe Psc do
4
+ describe '.xml' do
5
+ it "provides an xml builder configured for PSC XML" do
6
+ Psc.xml('period', :id => 'foo') { |xml| xml.tag!('planned-activity') }.should ==
7
+ "<period id=\"foo\" xmlns=\"http://bioinformatics.northwestern.edu/ns/psc\">\n <planned-activity/>\n</period>\n"
8
+ end
9
+ end
10
+
11
+ describe '.xml_namespace' do
12
+ it 'is a hash suitable for use with nokogiri xpath' do
13
+ Psc.xml_namespace['psc'].should == 'http://bioinformatics.northwestern.edu/ns/psc'
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,15 @@
1
+ require 'bundler'
2
+ Bundler.setup
3
+
4
+ require 'rspec'
5
+ require 'webmock/rspec'
6
+ require File.expand_path("../middleware_helper.rb", __FILE__)
7
+
8
+ $LOAD_PATH.unshift File.expand_path("../../lib", __FILE__)
9
+ require 'psc'
10
+
11
+ RSpec.configure do
12
+ def http_fixture(name)
13
+ File.new(File.expand_path("../fixtures/#{name}.http", __FILE__))
14
+ end
15
+ end
@@ -0,0 +1,84 @@
1
+ require 'faraday'
2
+
3
+ require File.expand_path('../psc/state.rb', __FILE__)
4
+ require File.expand_path('../../features/support/int_psc.rb', __FILE__)
5
+
6
+ namespace 'int-psc' do
7
+ desc 'Ensure a psc.war is available for tests. Downloads the latest nightly if not.'
8
+ task :war => IntPsc.warfile
9
+
10
+ namespace :war do
11
+ directory IntPsc.path('downloads')
12
+
13
+ file IntPsc.path('downloads', 'archive.zip') => IntPsc.path('downloads') do |task|
14
+ sh [
15
+ 'curl',
16
+ '-o', task.name,
17
+ "'https://ctms-ci.nubic.northwestern.edu/hudson/job/PSC%20nightly%20distribution/lastSuccessfulBuild/artifact/*zip*/archive.zip'"
18
+ ].join(' ')
19
+ end
20
+
21
+ file IntPsc.warfile do
22
+ # only download if a psc.war wasn't separately provided
23
+ task(IntPsc.path('downloads', 'archive.zip')).invoke
24
+
25
+ cd IntPsc.path('downloads') do
26
+ sh "unzip -o archive.zip"
27
+ distpkg = Dir['archive/psc/target/artifacts/psc*.zip'].first
28
+ fail "No dist package in downloaded archive" unless distpkg
29
+ sh "unzip -o '#{distpkg}'"
30
+ pkgwar = Dir['psc-*/psc.war'].first
31
+ fail "No war in dist package" unless pkgwar
32
+
33
+ mkdir_p File.dirname(IntPsc.warfile)
34
+ cp pkgwar, IntPsc.warfile
35
+ end
36
+ end
37
+ end
38
+
39
+ task :clean_baseline do
40
+ rm_rf IntPsc.path('hsqldb')
41
+ end
42
+
43
+ task :recreate_baseline => [IntPsc.warfile, :clean_baseline] do
44
+ IntPsc.run('baseline') do
45
+ puts
46
+ puts "Please run through PSC's setup flow and create an all-powerful user with the"
47
+ puts "credentials superuser/superuser. PSC is running at"
48
+ puts
49
+ puts " #{IntPsc.url}"
50
+ puts
51
+ puts "When complete, come back here and press any key."
52
+ puts
53
+ STDIN.getc
54
+ end
55
+ end
56
+
57
+ task :clean_datasource do
58
+ cd IntPsc.path('hsqldb') do
59
+ Dir['baseline.*'].each { |fn| cp fn, fn.sub(/^baseline/, 'datasource') }
60
+ end
61
+ end
62
+
63
+ desc 'Recreate the PSC integrated test instance from the state data in int-psc/state'
64
+ task :rebuild => [IntPsc.warfile, :clean_datasource] do
65
+ IntPsc.run do |int_psc|
66
+ int_psc.apply_state_and_mark_readonly
67
+ end
68
+ # the copied log file is not needed in the locked database
69
+ rm path('hsqldb', 'datasource.log')
70
+ end
71
+
72
+ desc 'Start up the integrated test PSC instance to poke around'
73
+ task :examine do
74
+ IntPsc.run do
75
+ puts "Integrated test PSC running at #{IntPsc.url}.\nPress any key to shut down."
76
+ STDIN.getc
77
+ end
78
+ end
79
+
80
+ desc 'Purge the logs for the integration test PSC instance'
81
+ task :clean_logs do
82
+ rm_rf IntPsc.path('deploy-base', 'logs')
83
+ end
84
+ end
@@ -0,0 +1,3 @@
1
+ # TODO: state.rb is copied from PSC's demo creator tool for
2
+ # bootstrapping purposes. Eventually, it should be cleaned up and
3
+ # added as feature of psc.rb.
@@ -0,0 +1,393 @@
1
+ require 'faraday'
2
+ require 'nokogiri'
3
+ require 'builder'
4
+ require 'highline'
5
+
6
+ module Psc
7
+ class State
8
+ attr_accessor :sites, :templates, :registrations
9
+
10
+ def apply(connection)
11
+ StateApplier.new(self).apply(connection)
12
+ end
13
+
14
+ def template(ident)
15
+ templates.detect { |t| t.assigned_identifier == ident } or fail "No template #{ident}"
16
+ end
17
+
18
+ def self.from_file(path)
19
+ self.new.tap do |state|
20
+ doc = Nokogiri::XML(open(path))
21
+ state.sites = read_sites(doc)
22
+ state.templates = read_templates(doc, File.dirname(path))
23
+ state.registrations = read_registrations(doc)
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def self.parse_date(s)
30
+ case s
31
+ when /(\d{4})-(\d{1,2})-(\d{1,2})/
32
+ Date.new($1.to_i, $2.to_i, $3.to_i)
33
+ when /^\d+$/
34
+ RelativeDate.new s.to_i
35
+ end
36
+ end
37
+
38
+ def self.read_sites(doc)
39
+ doc.xpath('/psc-state/site').collect do |site_elt|
40
+ Site.new(site_elt['name'], site_elt['assigned-identifier'])
41
+ end
42
+ end
43
+
44
+ def self.read_templates(doc, basedir)
45
+ doc.xpath('/psc-state/template').collect do |t_elt|
46
+ ident = t_elt['assigned-identifier']
47
+ Template.create(ident,
48
+ :filename =>
49
+ if filename = t_elt['file']
50
+ if filename =~ %r{^/}
51
+ filename
52
+ else
53
+ File.join(basedir, filename)
54
+ end
55
+ else
56
+ File.join(basedir, "#{ident}.xml")
57
+ end,
58
+ :participating_sites => t_elt.xpath('participating-site').collect { |ps_elt|
59
+ ParticipatingSite.create(
60
+ ps_elt['assigned-identifier'],
61
+ :approval => case ps_elt['approval']
62
+ when "false"
63
+ false
64
+ else
65
+ parse_date(ps_elt['approval'])
66
+ end
67
+ )
68
+ })
69
+ end
70
+ end
71
+
72
+ def self.read_registrations(doc)
73
+ doc.xpath('/psc-state/registration').collect do |reg_elt|
74
+ Registration.create(
75
+ :subject => read_subject(reg_elt.xpath('subject').first),
76
+ :study_sites => reg_elt.xpath('study-site').collect { |study_elt|
77
+ StudySite.create(
78
+ %w(template site primary_coordinator study_subject_identifier desired_assignment_identifier).
79
+ inject({}) { |h, k| h[k] = study_elt[k.gsub('_', '-')]; h }.
80
+ merge(
81
+ :scheduled_segments => study_elt.xpath('scheduled-segment').collect { |seg_elt|
82
+ ScheduledSegment.create(seg_elt['segment'],
83
+ :mode => seg_elt['mode'],
84
+ :start => parse_date(seg_elt['start'])
85
+ )
86
+ })
87
+ )
88
+ })
89
+ end
90
+ end
91
+
92
+ def self.read_subject(subject_elt)
93
+ Subject.create(
94
+ %w(first_name last_name person_id gender).inject({}) { |h, k|
95
+ h[k] = subject_elt[k.sub('_', '-')]; h
96
+ }.merge(
97
+ :birth_date => parse_date(subject_elt['birth-date']),
98
+ :properties => subject_elt.xpath('subject-property').
99
+ collect { |sp_elt| [sp_elt['name'], sp_elt['value']] }
100
+ )
101
+ )
102
+ end
103
+ end
104
+
105
+ module BulkSettable
106
+ def self.included(struct_class)
107
+ struct_class.send(:extend, ClassMethods)
108
+ end
109
+
110
+ def attributes=(map)
111
+ map.each do |k, v|
112
+ setter = "#{k}="
113
+ if self.respond_to?(setter)
114
+ self.send setter, v
115
+ end
116
+ end
117
+ end
118
+
119
+ module ClassMethods
120
+ def create(*args)
121
+ attrs = args.pop
122
+ i = self.new(*args)
123
+ i.attributes = attrs
124
+ i
125
+ end
126
+ end
127
+ end
128
+
129
+ class RelativeDate
130
+ include Comparable
131
+
132
+ attr_reader :days
133
+
134
+ def initialize(days)
135
+ @days = days
136
+ end
137
+
138
+ def <=>(other)
139
+ self.days <=> other.days
140
+ end
141
+
142
+ def to_date
143
+ Date.today + days
144
+ end
145
+
146
+ def to_s
147
+ to_date.to_s
148
+ end
149
+ end
150
+
151
+ class Site < Struct.new(:name, :assigned_identifier)
152
+ include BulkSettable
153
+
154
+ def apply(connection)
155
+ connection.put(Psc.build_uri_path('sites', assigned_identifier),
156
+ Psc.xml('site', 'site-name' => name, 'assigned-identifier' => assigned_identifier).to_s,
157
+ 'Content-Type' => 'text/xml')
158
+ end
159
+ end
160
+
161
+ class Template < Struct.new(:assigned_identifier)
162
+ include BulkSettable
163
+
164
+ attr_accessor :filename, :participating_sites
165
+
166
+ def apply(connection)
167
+ connection.put(Psc.build_uri_path('studies', assigned_identifier, 'template'),
168
+ File.read(filename), 'Content-Type' => 'text/xml')
169
+ (participating_sites || []).each do |ps|
170
+ ps.apply(connection, self)
171
+ end
172
+ end
173
+
174
+ def document
175
+ @document ||= Nokogiri::XML(open(filename, 'r'))
176
+ end
177
+
178
+ def resolve_segment(ident)
179
+ s = (
180
+ segment_index.detect { |s|
181
+ ident.downcase.split(/\s*:\s*/) == [s[:epoch_name].downcase, s[:name].downcase]
182
+ } ||
183
+ segment_index.detect { |s| ident == s[:id] } ||
184
+ segment_index.detect { |s| ident == s[:name] }
185
+ )
186
+ raise "Unable to resolve segment #{ident.inspect}" unless s
187
+ s[:id]
188
+ end
189
+
190
+ private
191
+
192
+ def segment_index
193
+ # TODO: this will not resolve the enclosing epoch correctly for
194
+ # segments added in amendments.
195
+ @ss_index ||= document.css('epoch').inject({}) { |h, ep_elt|
196
+ (h[ep_elt['name']] ||= []).push(
197
+ *ep_elt.css('study-segment').collect { |ss_elt|
198
+ { :id => ss_elt['id'], :name => ss_elt['name'] }
199
+ })
200
+ h
201
+ }.collect { |epoch, segments|
202
+ segments.each { |seg| seg[:epoch_name] = epoch }; segments
203
+ }.flatten
204
+ end
205
+ end
206
+
207
+ class ParticipatingSite < Struct.new(:assigned_identifier)
208
+ include BulkSettable
209
+
210
+ attr_accessor :approval
211
+
212
+ def approval
213
+ @approval = RelativeDate.new(0) if @approval.nil?
214
+ @approval
215
+ end
216
+
217
+ def apply(connection, template)
218
+ study_site_path = Psc.build_uri_path(
219
+ 'studies', template.assigned_identifier, 'sites', assigned_identifier)
220
+ connection.put study_site_path, "<study-site-link/>", 'Content-Type' => 'text/xml'
221
+ if approval
222
+ template.document.css('amendment').
223
+ collect { |am_elt| [am_elt['date'], am_elt['name']].compact }.
224
+ sort { |a, b| a[0] <=> b[0] }.
225
+ collect { |pair| pair.join('~') }.
226
+ each do |am_ident|
227
+ connection.post "#{study_site_path}/approvals",
228
+ Psc.xml('amendment-approval', :date => approval.to_s, :amendment => am_ident).to_s,
229
+ 'Content-Type' => 'text/xml'
230
+ end
231
+ end
232
+ end
233
+ end
234
+
235
+ class Registration
236
+ include BulkSettable
237
+ attr_accessor :subject, :study_sites
238
+
239
+ def apply(connection, state)
240
+ study_sites.each do |study_site|
241
+ study_site.apply(connection, subject, state)
242
+ end
243
+ end
244
+ end
245
+
246
+ class Subject
247
+ include BulkSettable
248
+ attr_accessor :first_name, :last_name, :gender, :birth_date, :person_id, :properties
249
+
250
+ def build_on(xml_builder)
251
+ xml_builder.subject(
252
+ 'first-name' => first_name,
253
+ 'last-name' => last_name,
254
+ 'gender' => gender,
255
+ 'person-id' => person_id,
256
+ 'birth-date' => birth_date.to_s
257
+ ) { |subj_elt|
258
+ (properties || []).each { |prop|
259
+ subj_elt.property(:name => prop[0], :value => prop[1])
260
+ }
261
+ }
262
+ end
263
+ end
264
+
265
+ class StudySite
266
+ include BulkSettable
267
+ attr_accessor :template, :site, :study_subject_identifier, :primary_coordinator,
268
+ :desired_assignment_identifier, :scheduled_segments
269
+
270
+ def apply(connection, subject, state)
271
+ template_details = state.template(template)
272
+
273
+ schedule_resp = connection.post(
274
+ Psc.build_uri_path('studies', template, 'sites', site, 'subject-assignments'),
275
+ Psc.xml(
276
+ 'registration',
277
+ {
278
+ 'subject-coordinator-name' => primary_coordinator,
279
+ 'desired-assignment-id' => desired_assignment_identifier,
280
+ 'study-subject-id' => study_subject_identifier,
281
+ 'first-study-segment-id' => template_details.resolve_segment(
282
+ scheduled_segments.first.identifier),
283
+ 'date' => (scheduled_segments.first.start || Psc::RelativeDate.new(0)).to_s
284
+ }.reject { |k, v| !v }
285
+ ) { |reg_elt| subject.build_on(reg_elt) },
286
+ 'Content-Type' => 'text/xml'
287
+ )
288
+
289
+ schedule_url = schedule_resp.headers['Location']
290
+
291
+ scheduled_segments[1, scheduled_segments.size].each { |ss|
292
+ ss.apply(connection, template_details, schedule_url)
293
+ }
294
+ end
295
+ end
296
+
297
+ class ScheduledSegment < Struct.new(:identifier)
298
+ include BulkSettable
299
+ attr_accessor :start, :mode
300
+
301
+ def mode
302
+ @mode ||= "per-protocol"
303
+ end
304
+
305
+ def apply(connection, template, schedule_url)
306
+ connection.post(schedule_url,
307
+ Psc.xml('next-scheduled-study-segment',
308
+ 'study-segment-id' => template.resolve_segment(identifier),
309
+ 'start-date' => start.to_s,
310
+ 'mode' => mode
311
+ ), 'Content-Type' => 'text/xml')
312
+ end
313
+ end
314
+
315
+ class StateApplier
316
+ def initialize(state, out=DefaultOutput.new)
317
+ @state = state
318
+ @out = out
319
+ end
320
+
321
+ def apply(connection)
322
+ c = ConnectionProxy.new(connection, @out)
323
+ puts (@state.sites || []).collect { |s|
324
+ @out.monitor("Adding site #{s.assigned_identifier}") { s.apply(c) }
325
+ }.compact.tap { |result| return false if result.size > 0 }
326
+
327
+ (@state.templates || []).collect { |t|
328
+ @out.monitor("Adding template #{t.assigned_identifier}") { t.apply(c) }
329
+ }.compact.tap { |result| return false if result.size > 0 }
330
+
331
+ (@state.registrations || []).collect { |r|
332
+ @out.monitor("Registering #{r.subject.first_name} #{r.subject.last_name}") {
333
+ r.apply(c, @state)
334
+ }
335
+ }.compact.tap { |result| return false if result.size > 0 }
336
+ end
337
+
338
+ class ConnectionProxy
339
+ def initialize(conn, out)
340
+ @conn = conn
341
+ @out = out
342
+ end
343
+
344
+ def make_request(method, *args)
345
+ @out.trace("#{method.to_s.upcase} #{args.first}")
346
+ resp = @conn.send(method, *args)
347
+ if resp.respond_to?(:status) && resp.status > 299
348
+ raise resp.body
349
+ end
350
+ resp
351
+ end
352
+
353
+ def method_missing(m, *args)
354
+ if %w(get put post delete patch).include?(m.to_s)
355
+ make_request(m, *args)
356
+ else
357
+ super
358
+ end
359
+ end
360
+ end
361
+
362
+ class DefaultOutput
363
+ def initialize
364
+ @hl = HighLine.new
365
+ HighLine.color_scheme = HighLine::SampleColorScheme.new
366
+ end
367
+
368
+ def monitor(msg)
369
+ @hl.say("<%= color('*', :info) %> #{msg}")
370
+ begin
371
+ yield
372
+ nil
373
+ rescue => e
374
+ @hl.say("<%= color(' -', :error) %> #{e}")
375
+ false
376
+ end
377
+ end
378
+
379
+ def trace(msg)
380
+ @hl.say("<%= color(' +', :info) %> #{msg}")
381
+ end
382
+ end
383
+ end
384
+
385
+ def self.xml(root_name, root_attributes={}, &block)
386
+ root_attributes['xmlns'] = 'http://bioinformatics.northwestern.edu/ns/psc'
387
+ Builder::XmlMarkup.new(:indent => 2).tag!(root_name, root_attributes, &block)
388
+ end
389
+
390
+ def self.build_uri_path(*parts)
391
+ parts.collect { |p| URI.encode p }.join('/')
392
+ end
393
+ end