sakai-info 0.1.0
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.
- data/CHANGELOG.md +12 -0
- data/LICENSE +8 -0
- data/README.md +148 -0
- data/ROADMAP.md +37 -0
- data/bin/sakai-info +79 -0
- data/lib/sakai-info.rb +62 -0
- data/lib/sakai-info/announcement.rb +170 -0
- data/lib/sakai-info/assignment.rb +281 -0
- data/lib/sakai-info/authz.rb +378 -0
- data/lib/sakai-info/cli.rb +35 -0
- data/lib/sakai-info/cli/help.rb +103 -0
- data/lib/sakai-info/configuration.rb +288 -0
- data/lib/sakai-info/content.rb +300 -0
- data/lib/sakai-info/db.rb +19 -0
- data/lib/sakai-info/gradebook.rb +176 -0
- data/lib/sakai-info/group.rb +119 -0
- data/lib/sakai-info/instance.rb +122 -0
- data/lib/sakai-info/message.rb +122 -0
- data/lib/sakai-info/sakai_object.rb +58 -0
- data/lib/sakai-info/sakai_xml_entity.rb +126 -0
- data/lib/sakai-info/samigo.rb +219 -0
- data/lib/sakai-info/site.rb +752 -0
- data/lib/sakai-info/user.rb +220 -0
- data/lib/sakai-info/version.rb +3 -0
- metadata +90 -0
@@ -0,0 +1,19 @@
|
|
1
|
+
# sakai-info/db.rb
|
2
|
+
# SakaiInfo::DB library
|
3
|
+
#
|
4
|
+
# Created 2012-02-16 daveadams@gmail.com
|
5
|
+
# Last updated 2012-02-19 daveadams@gmail.com
|
6
|
+
#
|
7
|
+
# https://github.com/daveadams/sakai-info
|
8
|
+
#
|
9
|
+
# This software is public domain.
|
10
|
+
#
|
11
|
+
|
12
|
+
module SakaiInfo
|
13
|
+
class DB
|
14
|
+
def self.connect(instance_name = :default)
|
15
|
+
Configuration.get_instance(instance_name).connect
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
@@ -0,0 +1,176 @@
|
|
1
|
+
# sakai-info/gradebook.rb
|
2
|
+
# SakaiInfo::Gradebook library
|
3
|
+
#
|
4
|
+
# Created 2012-02-17 daveadams@gmail.com
|
5
|
+
# Last updated 2012-02-18 daveadams@gmail.com
|
6
|
+
#
|
7
|
+
# https://github.com/daveadams/sakai-info
|
8
|
+
#
|
9
|
+
# This software is public domain.
|
10
|
+
#
|
11
|
+
|
12
|
+
module SakaiInfo
|
13
|
+
class Gradebook < SakaiObject
|
14
|
+
attr_reader :version, :site, :name
|
15
|
+
|
16
|
+
def initialize(id, version, site, name)
|
17
|
+
@id = id
|
18
|
+
@version = version
|
19
|
+
@site = site
|
20
|
+
@name = name
|
21
|
+
end
|
22
|
+
|
23
|
+
@@cache = {}
|
24
|
+
@@cache_by_site_id = {}
|
25
|
+
def self.find(id)
|
26
|
+
if @@cache[id].nil?
|
27
|
+
version = site = name = nil
|
28
|
+
DB.connect.exec("select version, gradebook_uid, name " +
|
29
|
+
"from gb_gradebook_t " +
|
30
|
+
"where id = :id", id) do |row|
|
31
|
+
version = row[0].to_i
|
32
|
+
site = Site.find(row[1])
|
33
|
+
name = row[2]
|
34
|
+
end
|
35
|
+
if version.nil?
|
36
|
+
raise ObjectNotFoundException.new(Gradebook, id)
|
37
|
+
end
|
38
|
+
@@cache[id] = Gradebook.new(id, version, site, name)
|
39
|
+
@@cache_by_site_id[site.id] = @@cache[id]
|
40
|
+
end
|
41
|
+
@@cache[id]
|
42
|
+
end
|
43
|
+
|
44
|
+
def self.find_by_site_id(site_id)
|
45
|
+
if @@cache_by_site_id[site_id].nil?
|
46
|
+
id = version = site = name = nil
|
47
|
+
DB.connect.exec("select id, version, name " +
|
48
|
+
"from gb_gradebook_t " +
|
49
|
+
"where gradebook_uid = :site_id", site_id) do |row|
|
50
|
+
id = row[0].to_i
|
51
|
+
version = row[1].to_i
|
52
|
+
name = row[2]
|
53
|
+
site = Site.find(site_id)
|
54
|
+
end
|
55
|
+
if version.nil?
|
56
|
+
raise ObjectNotFoundException.new(Gradebook, site_id)
|
57
|
+
end
|
58
|
+
@@cache[id] = Gradebook.new(id, version, site, name)
|
59
|
+
@@cache_by_site_id[site_id] = @@cache[id]
|
60
|
+
end
|
61
|
+
@@cache_by_site_id[site_id]
|
62
|
+
end
|
63
|
+
|
64
|
+
def items
|
65
|
+
@items ||= GradableObject.find_by_gradebook_id(@id)
|
66
|
+
end
|
67
|
+
|
68
|
+
def item_count
|
69
|
+
items.length
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
class GradableObject < SakaiObject
|
74
|
+
attr_reader :id, :gradebook, :object_type, :version, :name
|
75
|
+
attr_reader :points_possible, :due_date, :weight
|
76
|
+
|
77
|
+
def initialize(id, gradebook, object_type, version, name,
|
78
|
+
points_possible, due_date, weight)
|
79
|
+
@id = id
|
80
|
+
@gradebook = gradebook
|
81
|
+
@object_type = object_type
|
82
|
+
@version = version
|
83
|
+
@name = name
|
84
|
+
@points_possible = points_possible
|
85
|
+
@due_date = due_date
|
86
|
+
@weight = weight
|
87
|
+
end
|
88
|
+
|
89
|
+
@@cache = {}
|
90
|
+
def self.find(id)
|
91
|
+
if @@cache[id].nil?
|
92
|
+
gradebook = object_type = version = name = points_possible = due_date = weight = nil
|
93
|
+
DB.connect.exec("select gradebook_id, object_type_id, version, " +
|
94
|
+
"name, points_possible, " +
|
95
|
+
"to_char(due_date, 'YYYY-MM-DD'), " +
|
96
|
+
"assignment_weighting " +
|
97
|
+
"from gb_gradable_object_t " +
|
98
|
+
"where id = :id", id) do |row|
|
99
|
+
gradebook = Gradebook.find(row[0].to_i)
|
100
|
+
object_type = row[1].to_i
|
101
|
+
version = row[2].to_i
|
102
|
+
name = row[3]
|
103
|
+
points_possible = row[4].to_f
|
104
|
+
due_date = row[5]
|
105
|
+
weight = row[6].to_f
|
106
|
+
end
|
107
|
+
if version.nil?
|
108
|
+
raise ObjectNotFoundException.new(GradableObject, id)
|
109
|
+
end
|
110
|
+
@@cache[id] = GradableObject.new(id, gradebook, object_type,
|
111
|
+
version, name, points_possible,
|
112
|
+
due_date, weight)
|
113
|
+
end
|
114
|
+
@@cache[id]
|
115
|
+
end
|
116
|
+
|
117
|
+
@@cache_by_gradebook_id = {}
|
118
|
+
def self.find_by_gradebook_id(gradebook_id)
|
119
|
+
if @@cache_by_gradebook_id[gradebook_id].nil?
|
120
|
+
objects = []
|
121
|
+
gradebook = Gradebook.find(gradebook_id)
|
122
|
+
DB.connect.exec("select id, object_type_id, version, " +
|
123
|
+
"name, points_possible, " +
|
124
|
+
"to_char(due_date, 'YYYY-MM-DD'), " +
|
125
|
+
"assignment_weighting " +
|
126
|
+
"from gb_gradable_object_t " +
|
127
|
+
"where gradebook_id = :gradebook_id " +
|
128
|
+
"order by due_date asc", gradebook_id) do |row|
|
129
|
+
id = row[0].to_i
|
130
|
+
object_type = row[1].to_i
|
131
|
+
version = row[2].to_i
|
132
|
+
name = row[3]
|
133
|
+
points_possible = row[4].to_f
|
134
|
+
due_date = row[5]
|
135
|
+
weight = row[6].to_f
|
136
|
+
objects << GradableObject.new(id, gradebook, object_type,
|
137
|
+
version, name, points_possible,
|
138
|
+
due_date, weight)
|
139
|
+
|
140
|
+
end
|
141
|
+
@@cache_by_gradebook_id[gradebook_id] = objects
|
142
|
+
end
|
143
|
+
@@cache_by_gradebook_id[gradebook_id]
|
144
|
+
end
|
145
|
+
|
146
|
+
def default_serialization
|
147
|
+
result = {
|
148
|
+
"id" => self.id,
|
149
|
+
"name" => self.name,
|
150
|
+
"gradebook_id" => self.gradebook.id,
|
151
|
+
"object_type" => self.object_type,
|
152
|
+
"version" => self.version,
|
153
|
+
"points_possible" => self.points_possible,
|
154
|
+
"due_date" => self.due_date,
|
155
|
+
"weight" => self.weight
|
156
|
+
}
|
157
|
+
if self.due_date.nil?
|
158
|
+
result.delete("due_date")
|
159
|
+
end
|
160
|
+
result
|
161
|
+
end
|
162
|
+
|
163
|
+
def summary_serialization
|
164
|
+
result = {
|
165
|
+
"id" => self.id,
|
166
|
+
"name" => self.name,
|
167
|
+
"points_possible" => self.points_possible,
|
168
|
+
"due_date" => self.due_date
|
169
|
+
}
|
170
|
+
if self.due_date.nil?
|
171
|
+
result.delete("due_date")
|
172
|
+
end
|
173
|
+
result
|
174
|
+
end
|
175
|
+
end
|
176
|
+
end
|
@@ -0,0 +1,119 @@
|
|
1
|
+
# sakai-info/group.rb
|
2
|
+
# SakaiInfo::Group library
|
3
|
+
#
|
4
|
+
# Created 2012-02-17 daveadams@gmail.com
|
5
|
+
# Last updated 2012-02-17 daveadams@gmail.com
|
6
|
+
#
|
7
|
+
# https://github.com/daveadams/sakai-info
|
8
|
+
#
|
9
|
+
# This software is public domain.
|
10
|
+
#
|
11
|
+
|
12
|
+
module SakaiInfo
|
13
|
+
class Group < SakaiObject
|
14
|
+
attr_reader :site, :title
|
15
|
+
|
16
|
+
def initialize(id, site, title)
|
17
|
+
@id = id
|
18
|
+
if site.is_a? Site
|
19
|
+
@site = site
|
20
|
+
else
|
21
|
+
# assume the string version is a site_id
|
22
|
+
@site = Site.find(site.to_s)
|
23
|
+
end
|
24
|
+
@title = title
|
25
|
+
end
|
26
|
+
|
27
|
+
@@cache = {}
|
28
|
+
def self.find(id)
|
29
|
+
if @@cache[id].nil?
|
30
|
+
site_id = title = nil
|
31
|
+
DB.connect.exec("select site_id, title from sakai_site_group " +
|
32
|
+
"where group_id = :id", id) do |row|
|
33
|
+
site_id = row[0]
|
34
|
+
title = row[1]
|
35
|
+
@@cache[id] = Group.new(id, site_id, title)
|
36
|
+
end
|
37
|
+
if site_id.nil? or name.nil?
|
38
|
+
raise ObjectNotFoundException.new(Group, id)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
@@cache[id]
|
42
|
+
end
|
43
|
+
|
44
|
+
@@cache_by_site_id = {}
|
45
|
+
def self.find_by_site_id(site_id)
|
46
|
+
if @@cache_by_site_id[site_id].nil?
|
47
|
+
@@cache_by_site_id[site_id] = []
|
48
|
+
site = Site.find(site_id)
|
49
|
+
DB.connect.exec("select group_id, title " +
|
50
|
+
"from sakai_site_group " +
|
51
|
+
"where site_id = :site_id", site_id) do |row|
|
52
|
+
id = row[0]
|
53
|
+
title = row[1]
|
54
|
+
@@cache[id] = Group.new(id, site, title)
|
55
|
+
@@cache_by_site_id[site_id] << @@cache[id]
|
56
|
+
end
|
57
|
+
end
|
58
|
+
@@cache_by_site_id[site_id]
|
59
|
+
end
|
60
|
+
|
61
|
+
def self.count_by_site_id(site_id)
|
62
|
+
count = 0
|
63
|
+
DB.connect.exec("select count(*) from sakai_site_group " +
|
64
|
+
"where site_id=:site_id", site_id) do |row|
|
65
|
+
count = row[0].to_i
|
66
|
+
end
|
67
|
+
count
|
68
|
+
end
|
69
|
+
|
70
|
+
def properties
|
71
|
+
@properties ||= GroupProperty.find_by_group_id(@id)
|
72
|
+
end
|
73
|
+
|
74
|
+
def realm
|
75
|
+
@authz_realm ||= AuthzRealm.find_by_site_id_and_group_id(@site.id, @id)
|
76
|
+
end
|
77
|
+
|
78
|
+
# serialization
|
79
|
+
def default_serialization
|
80
|
+
result = {
|
81
|
+
"id" => self.id,
|
82
|
+
"title" => self.title,
|
83
|
+
"site_id" => self.site.id,
|
84
|
+
"members" => self.realm.user_count,
|
85
|
+
"properties" => self.properties
|
86
|
+
}
|
87
|
+
if result["properties"] == {}
|
88
|
+
result.delete("properties")
|
89
|
+
end
|
90
|
+
result
|
91
|
+
end
|
92
|
+
|
93
|
+
def summary_serialization
|
94
|
+
{
|
95
|
+
"id" => self.id,
|
96
|
+
"title" => self.title,
|
97
|
+
"members" => self.realm.user_count
|
98
|
+
}
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
class GroupProperty
|
103
|
+
attr_reader :name, :value
|
104
|
+
|
105
|
+
def initialize(name, value)
|
106
|
+
@name = name
|
107
|
+
@value = value
|
108
|
+
end
|
109
|
+
|
110
|
+
def self.find_by_group_id(group_id)
|
111
|
+
properties = []
|
112
|
+
DB.connect.exec("select name, value from sakai_site_group_property " +
|
113
|
+
"where group_id=:group_id", group_id) do |row|
|
114
|
+
properties << GroupProperty.new(row[0], row[1].read)
|
115
|
+
end
|
116
|
+
properties
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
@@ -0,0 +1,122 @@
|
|
1
|
+
# sakai-info/instance.rb
|
2
|
+
# SakaiInfo::Instance library
|
3
|
+
#
|
4
|
+
# Created 2012-02-19 daveadams@gmail.com
|
5
|
+
# Last updated 2012-02-19 daveadams@gmail.com
|
6
|
+
#
|
7
|
+
# https://github.com/daveadams/sakai-info
|
8
|
+
#
|
9
|
+
# This software is public domain.
|
10
|
+
#
|
11
|
+
|
12
|
+
module SakaiInfo
|
13
|
+
class ConnectionFailureException < SakaiException; end
|
14
|
+
|
15
|
+
class Instance
|
16
|
+
def self.create(config)
|
17
|
+
case config["dbtype"].downcase
|
18
|
+
when "oracle" then
|
19
|
+
OracleInstance.new(config)
|
20
|
+
when "mysql" then
|
21
|
+
MySqlInstance.new(config)
|
22
|
+
else
|
23
|
+
raise UnsupportedConfigException.new("Database type '#{config["dbtype"]}' is not supported.")
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
class OracleInstance
|
29
|
+
DEFAULT_PORT = 1521
|
30
|
+
|
31
|
+
def initialize(config)
|
32
|
+
# fix NLS_LANG if necessary
|
33
|
+
ENV["NLS_LANG"] ||= "AMERICAN_AMERICA.UTF8"
|
34
|
+
|
35
|
+
# include Oracle driver
|
36
|
+
require 'oci8'
|
37
|
+
|
38
|
+
@username = config["username"]
|
39
|
+
@password = config["password"]
|
40
|
+
if config["host"].nil? or config["host"] == ""
|
41
|
+
@service = config["service"]
|
42
|
+
else
|
43
|
+
@host = config["host"]
|
44
|
+
@port = config["port"].nil? ? DEFAULT_PORT : config["port"].to_i
|
45
|
+
@service = "//#{@host}:#{@port}/#{config["service"]}"
|
46
|
+
end
|
47
|
+
|
48
|
+
# close the connection upon exit
|
49
|
+
at_exit {
|
50
|
+
if @connection
|
51
|
+
begin
|
52
|
+
if @connection.methods.include? :ping
|
53
|
+
@connection.logoff if @connection.ping
|
54
|
+
else
|
55
|
+
@connection.logoff
|
56
|
+
end
|
57
|
+
rescue
|
58
|
+
# it's ok
|
59
|
+
end
|
60
|
+
end
|
61
|
+
}
|
62
|
+
end
|
63
|
+
|
64
|
+
def dbtype
|
65
|
+
"oracle"
|
66
|
+
end
|
67
|
+
|
68
|
+
def alive?
|
69
|
+
is_alive = false
|
70
|
+
begin
|
71
|
+
@connection.exec("select 1 from dual") do |row|
|
72
|
+
if row[0] == 1
|
73
|
+
is_alive = true
|
74
|
+
end
|
75
|
+
end
|
76
|
+
rescue
|
77
|
+
# doesn't matter what the exception is
|
78
|
+
@connection = nil
|
79
|
+
is_alive = false
|
80
|
+
end
|
81
|
+
|
82
|
+
is_alive
|
83
|
+
end
|
84
|
+
|
85
|
+
def connect
|
86
|
+
if @connection and self.alive?
|
87
|
+
return @connection
|
88
|
+
end
|
89
|
+
|
90
|
+
begin
|
91
|
+
@connection = OCI8.new(@username, @password, @service)
|
92
|
+
rescue => e
|
93
|
+
@connection = nil
|
94
|
+
raise ConnectionFailureException.new("Could not connect: #{e}")
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
class MySqlInstance
|
100
|
+
DEFAULT_PORT = 3306
|
101
|
+
|
102
|
+
def initialize(config)
|
103
|
+
@username = config["username"]
|
104
|
+
@password = config["password"]
|
105
|
+
@dbname = config["dbname"]
|
106
|
+
@host = config["host"]
|
107
|
+
@port = config["port"].nil? ? 3306 : config["port"].to_i
|
108
|
+
end
|
109
|
+
|
110
|
+
def dbtype
|
111
|
+
"mysql"
|
112
|
+
end
|
113
|
+
|
114
|
+
def alive?
|
115
|
+
false
|
116
|
+
end
|
117
|
+
|
118
|
+
def connect
|
119
|
+
raise UnsupportedConfigException.new("MySQL will be supported in a future release.")
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
@@ -0,0 +1,122 @@
|
|
1
|
+
# sakai-info/message.rb
|
2
|
+
# SakaiInfo::Message library
|
3
|
+
#
|
4
|
+
# Created 2012-02-17 daveadams@gmail.com
|
5
|
+
# Last updated 2012-02-18 daveadams@gmail.com
|
6
|
+
#
|
7
|
+
# https://github.com/daveadams/sakai-info
|
8
|
+
#
|
9
|
+
# This software is public domain.
|
10
|
+
#
|
11
|
+
|
12
|
+
module SakaiInfo
|
13
|
+
class MessageTypeUUID
|
14
|
+
# TODO: verify whether these are the same in all installs
|
15
|
+
PRIVATE_MESSAGE = "d6404db7-7f6e-487a-00fe-baffce45d84c"
|
16
|
+
FORUM_POST = "7143223e-355a-4865-00dd-cee9310499e4"
|
17
|
+
end
|
18
|
+
|
19
|
+
class UnknownMessageTypeException < SakaiException; end
|
20
|
+
class GenericMessage < SakaiObject
|
21
|
+
def self.count_by_date_and_message_type(count_date, message_type)
|
22
|
+
date_str = nil
|
23
|
+
if count_date.is_a? Time
|
24
|
+
date_str = count_date.strftime("%Y-%m-%d")
|
25
|
+
elsif count_date.is_a? String
|
26
|
+
if count_date =~ /^\d\d\d\d-\d\d-\d\d$/
|
27
|
+
date_str = count_date
|
28
|
+
else
|
29
|
+
raise InvalidDateException
|
30
|
+
end
|
31
|
+
else
|
32
|
+
raise InvalidDateException
|
33
|
+
end
|
34
|
+
|
35
|
+
if not valid_message_type? message_type
|
36
|
+
raise UnknownMessageTypeException
|
37
|
+
end
|
38
|
+
|
39
|
+
count = 0
|
40
|
+
DB.connect.exec("select count(*) from mfr_message_t " +
|
41
|
+
"where message_dtype = :t " +
|
42
|
+
"and to_char(created,'YYYY-MM-DD') = :d",
|
43
|
+
message_type, date_str) do |row|
|
44
|
+
count = row[0].to_i
|
45
|
+
end
|
46
|
+
count
|
47
|
+
end
|
48
|
+
|
49
|
+
private
|
50
|
+
def self.valid_message_type?(s)
|
51
|
+
s =="ME" or s =="PM"
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
class PrivateMessage < GenericMessage
|
56
|
+
def self.count_by_date(d)
|
57
|
+
count_by_date_and_message_type(d, "PM")
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
class ForumPost < GenericMessage
|
62
|
+
def self.count_by_date(d)
|
63
|
+
count_by_date_and_message_type(d, "ME")
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
class Forum < SakaiObject
|
68
|
+
attr_reader :id, :title
|
69
|
+
|
70
|
+
def initialize(id, title)
|
71
|
+
@id = id.to_i
|
72
|
+
@title = title
|
73
|
+
end
|
74
|
+
|
75
|
+
@@cache = {}
|
76
|
+
|
77
|
+
def self.count_by_site_id(site_id)
|
78
|
+
count = 0
|
79
|
+
DB.connect.exec("select count(*) from mfr_open_forum_t " +
|
80
|
+
"where surrogatekey = (select id from mfr_area_t " +
|
81
|
+
"where type_uuid = :type_uuid " +
|
82
|
+
"and context_id = :site_id)",
|
83
|
+
MessageTypeUUID::FORUM_POST, site_id) do |row|
|
84
|
+
count = row[0].to_i
|
85
|
+
end
|
86
|
+
count
|
87
|
+
end
|
88
|
+
|
89
|
+
@@cache_by_site_id = {}
|
90
|
+
def self.find_by_site_id(site_id)
|
91
|
+
if @@cache_by_site_id[site_id].nil?
|
92
|
+
@@cache_by_site_id[site_id] = []
|
93
|
+
DB.connect.exec("select id, title from mfr_open_forum_t " +
|
94
|
+
"where surrogatekey = (select id from mfr_area_t " +
|
95
|
+
"where type_uuid = :type_uuid " +
|
96
|
+
"and context_id = :site_id) order by sort_index",
|
97
|
+
MessageTypeUUID::FORUM_POST, site_id) do |row|
|
98
|
+
id = row[0].to_i.to_s
|
99
|
+
title = row[1]
|
100
|
+
@@cache[id] = Forum.new(id, title)
|
101
|
+
@@cache_by_site_id[site_id] << @@cache[id]
|
102
|
+
end
|
103
|
+
end
|
104
|
+
@@cache_by_site_id[site_id]
|
105
|
+
end
|
106
|
+
|
107
|
+
def default_serialization
|
108
|
+
{
|
109
|
+
"id" => self.id,
|
110
|
+
"title" => self.title
|
111
|
+
}
|
112
|
+
end
|
113
|
+
|
114
|
+
def summary_serialization
|
115
|
+
{
|
116
|
+
"id" => self.id,
|
117
|
+
"title" => self.title
|
118
|
+
}
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|