safrano 0.3.2 → 0.4.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/lib/odata/attribute.rb +1 -1
- data/lib/odata/batch.rb +24 -10
- data/lib/odata/collection.rb +242 -96
- data/lib/odata/collection_filter.rb +40 -9
- data/lib/odata/collection_media.rb +279 -0
- data/lib/odata/collection_order.rb +46 -36
- data/lib/odata/common_logger.rb +59 -0
- data/lib/odata/entity.rb +268 -54
- data/lib/odata/error.rb +58 -17
- data/lib/odata/expand.rb +123 -0
- data/lib/odata/filter/error.rb +6 -0
- data/lib/odata/filter/parse.rb +4 -12
- data/lib/odata/filter/sequel.rb +11 -13
- data/lib/odata/filter/tree.rb +11 -15
- data/lib/odata/navigation_attribute.rb +150 -0
- data/lib/odata/relations.rb +2 -2
- data/lib/odata/select.rb +42 -0
- data/lib/odata/url_parameters.rb +51 -36
- data/lib/odata/walker.rb +12 -4
- data/lib/safrano.rb +23 -12
- data/lib/{safrano_core.rb → safrano/core.rb} +14 -3
- data/lib/{multipart.rb → safrano/multipart.rb} +51 -29
- data/lib/{odata_rack_builder.rb → safrano/odata_rack_builder.rb} +1 -1
- data/lib/{rack_app.rb → safrano/rack_app.rb} +15 -10
- data/lib/{request.rb → safrano/request.rb} +21 -8
- data/lib/{response.rb → safrano/response.rb} +1 -2
- data/lib/{sequel_join_by_paths.rb → safrano/sequel_join_by_paths.rb} +1 -1
- data/lib/{service.rb → safrano/service.rb} +93 -97
- data/lib/safrano/version.rb +3 -0
- data/lib/sequel/plugins/join_by_paths.rb +11 -10
- metadata +34 -15
@@ -0,0 +1,59 @@
|
|
1
|
+
module Rack
|
2
|
+
class ODataCommonLogger < CommonLogger
|
3
|
+
def call(env)
|
4
|
+
env['safrano.logger_mw'] = self
|
5
|
+
super
|
6
|
+
end
|
7
|
+
|
8
|
+
# Handle https://github.com/rack/rack/pull/1526
|
9
|
+
# new in Rack 2.2.2 : Format has now 11 placeholders instead of 10
|
10
|
+
|
11
|
+
MSG_FUNC = if FORMAT.count('%') == 10
|
12
|
+
lambda { |env, length, status, began_at|
|
13
|
+
FORMAT % [
|
14
|
+
env['HTTP_X_FORWARDED_FOR'] || env['REMOTE_ADDR'] || '-',
|
15
|
+
env['REMOTE_USER'] || '-',
|
16
|
+
Time.now.strftime('%d/%b/%Y:%H:%M:%S %z'),
|
17
|
+
env[REQUEST_METHOD],
|
18
|
+
env[SCRIPT_NAME] + env[PATH_INFO],
|
19
|
+
env[QUERY_STRING].empty? ? '' : "?#{env[QUERY_STRING]}",
|
20
|
+
env[SERVER_PROTOCOL],
|
21
|
+
status.to_s[0..3],
|
22
|
+
length,
|
23
|
+
Utils.clock_time - began_at
|
24
|
+
]
|
25
|
+
}
|
26
|
+
elsif FORMAT.count('%') == 11
|
27
|
+
lambda { |env, length, status, began_at|
|
28
|
+
FORMAT % [
|
29
|
+
env['HTTP_X_FORWARDED_FOR'] || env['REMOTE_ADDR'] || '-',
|
30
|
+
env['REMOTE_USER'] || '-',
|
31
|
+
Time.now.strftime('%d/%b/%Y:%H:%M:%S %z'),
|
32
|
+
env[REQUEST_METHOD],
|
33
|
+
env[SCRIPT_NAME],
|
34
|
+
env[PATH_INFO],
|
35
|
+
env[QUERY_STRING].empty? ? '' : "?#{env[QUERY_STRING]}",
|
36
|
+
env[SERVER_PROTOCOL],
|
37
|
+
status.to_s[0..3],
|
38
|
+
length,
|
39
|
+
Utils.clock_time - began_at
|
40
|
+
]
|
41
|
+
}
|
42
|
+
end
|
43
|
+
|
44
|
+
def batch_log(env, status, header, began_at)
|
45
|
+
length = extract_content_length(header)
|
46
|
+
|
47
|
+
msg = MSG_FUNC.call(env, length, status, began_at)
|
48
|
+
|
49
|
+
logger = @logger || env[RACK_ERRORS]
|
50
|
+
# Standard library logger doesn't support write but it supports << which actually
|
51
|
+
# calls to write on the log device without formatting
|
52
|
+
if logger.respond_to?(:write)
|
53
|
+
logger.write(msg)
|
54
|
+
else
|
55
|
+
logger << msg
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
data/lib/odata/entity.rb
CHANGED
@@ -2,13 +2,16 @@ require 'json'
|
|
2
2
|
require 'rexml/document'
|
3
3
|
require 'safrano.rb'
|
4
4
|
require 'odata/collection.rb' # required for self.class.entity_type_name ??
|
5
|
+
require_relative 'navigation_attribute'
|
5
6
|
|
6
7
|
module OData
|
7
8
|
# this will be mixed in the Model classes (subclasses of Sequel Model)
|
8
|
-
module
|
9
|
+
module EntityBase
|
9
10
|
attr_reader :params
|
10
11
|
attr_reader :uribase
|
11
12
|
|
13
|
+
include EntityBase::NavigationInfo
|
14
|
+
|
12
15
|
# methods related to transitions to next state (cf. walker)
|
13
16
|
module Transitions
|
14
17
|
def allowed_transitions
|
@@ -16,6 +19,7 @@ module OData
|
|
16
19
|
Safrano::TransitionEnd,
|
17
20
|
Safrano::TransitionCount,
|
18
21
|
Safrano::TransitionLinks,
|
22
|
+
Safrano::TransitionValue,
|
19
23
|
Safrano::Transition.new(self.class.transition_attribute_regexp,
|
20
24
|
trans: 'transition_attribute')
|
21
25
|
]
|
@@ -41,6 +45,11 @@ module OData
|
|
41
45
|
[self, :end]
|
42
46
|
end
|
43
47
|
|
48
|
+
def transition_value(_match_result)
|
49
|
+
# $value is only allowd for media entities (or attributes)
|
50
|
+
[self, :end_with_media_value]
|
51
|
+
end
|
52
|
+
|
44
53
|
def transition_links(_match_result)
|
45
54
|
[self, :run_with_links]
|
46
55
|
end
|
@@ -54,8 +63,6 @@ module OData
|
|
54
63
|
def transition_nav_collection(match_result)
|
55
64
|
attrib = match_result[1]
|
56
65
|
[get_related(attrib), :run]
|
57
|
-
# attr_klass = self.class.nav_collection_attribs[attrib].to_s
|
58
|
-
# [get_related(attr_klass), :run]
|
59
66
|
end
|
60
67
|
|
61
68
|
def transition_nav_entity(match_result)
|
@@ -72,7 +79,6 @@ module OData
|
|
72
79
|
self.class.nav_entity_attribs&.each_key do |na_str|
|
73
80
|
@nav_values[na_str.to_sym] = send(na_str)
|
74
81
|
end
|
75
|
-
|
76
82
|
@nav_values
|
77
83
|
end
|
78
84
|
|
@@ -84,30 +90,51 @@ module OData
|
|
84
90
|
@nav_coll
|
85
91
|
end
|
86
92
|
|
87
|
-
def uri_path
|
88
|
-
kla = self.class
|
89
|
-
"#{kla.entity_set_name}(#{pk_uri})"
|
90
|
-
end
|
91
|
-
|
92
93
|
def uri(uriba)
|
93
|
-
"#{uriba}/#{
|
94
|
+
"#{uriba}/#{self.class.entity_set_name}(#{pk_uri})"
|
94
95
|
end
|
96
|
+
D = 'd'.freeze
|
97
|
+
DJ_OPEN = '{"d":'.freeze
|
98
|
+
DJ_CLOSE = '}'.freeze
|
95
99
|
|
96
100
|
# Json formatter for a single entity (probably OData V1/V2 like)
|
97
101
|
def to_odata_json(service:)
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
uribase: @uribase)
|
102
|
+
template = self.class.output_template(@uparms)
|
103
|
+
innerj = service.get_entity_odata_h(entity: self,
|
104
|
+
template: template,
|
105
|
+
uribase: @uribase).to_json
|
106
|
+
"#{DJ_OPEN}#{innerj}#{DJ_CLOSE}"
|
107
|
+
end
|
108
|
+
|
109
|
+
# Json formatter for a single entity reached by navigation $links
|
110
|
+
def to_odata_onelink_json(service:)
|
111
|
+
innerj = service.get_entity_odata_link_h(entity: self,
|
112
|
+
uribase: @uribase).to_json
|
113
|
+
"#{DJ_OPEN}#{innerj}#{DJ_CLOSE}"
|
114
|
+
end
|
115
|
+
|
116
|
+
def selected_values_for_odata(cols)
|
117
|
+
allvals = values_for_odata
|
118
|
+
selvals = {}
|
119
|
+
cols.map(&:to_sym).each { |k| selvals[k] = allvals[k] if allvals.key?(k) }
|
120
|
+
selvals
|
102
121
|
end
|
103
122
|
|
104
123
|
# needed for proper datetime output
|
105
|
-
# TODO design/performance
|
106
|
-
def casted_values
|
124
|
+
# TODO: design/performance
|
125
|
+
def casted_values(cols = nil)
|
126
|
+
vals = case cols
|
127
|
+
when nil
|
128
|
+
values_for_odata
|
129
|
+
else
|
130
|
+
selected_values_for_odata(cols)
|
131
|
+
end
|
132
|
+
|
107
133
|
# WARNING; this code is duplicated in attribute.rb
|
108
134
|
# (and the inverted transformation is in test/client.rb)
|
109
135
|
# will require a more systematic solution some day
|
110
|
-
|
136
|
+
|
137
|
+
vals.transform_values! do |v|
|
111
138
|
case v
|
112
139
|
when Time
|
113
140
|
# try to get back the database time zone and value
|
@@ -115,56 +142,92 @@ module OData
|
|
115
142
|
else
|
116
143
|
v
|
117
144
|
end
|
118
|
-
|
145
|
+
end
|
119
146
|
end
|
120
147
|
|
121
148
|
# post paylod expects the new entity in an array
|
122
149
|
def to_odata_post_json(service:)
|
123
|
-
|
124
|
-
|
150
|
+
innerj = service.get_coll_odata_h(array: [self],
|
151
|
+
template: self.class.default_template,
|
152
|
+
uribase: @uribase).to_json
|
153
|
+
"#{DJ_OPEN}#{innerj}#{DJ_CLOSE}"
|
125
154
|
end
|
126
155
|
|
127
156
|
def type_name
|
128
157
|
self.class.type_name
|
129
158
|
end
|
130
159
|
|
131
|
-
# metadata for json h
|
132
|
-
def metadata_h(uribase:)
|
133
|
-
{ uri: uri(uribase),
|
134
|
-
type: type_name }
|
135
|
-
end
|
136
|
-
|
137
160
|
def copy_request_infos(req)
|
138
161
|
@params = req.params
|
139
162
|
@uribase = req.uribase
|
140
163
|
@do_links = req.walker.do_links
|
164
|
+
@uparms = UrlParameters4Single.new(@params)
|
141
165
|
end
|
142
166
|
|
143
167
|
# Finally Process REST verbs...
|
144
168
|
def odata_get(req)
|
145
169
|
copy_request_infos(req)
|
146
|
-
|
147
|
-
|
148
|
-
|
170
|
+
if req.walker.media_value
|
171
|
+
odata_media_value_get(req)
|
172
|
+
elsif req.accept?(APPJSON)
|
173
|
+
if req.walker.do_links
|
174
|
+
[200, CT_JSON, [to_odata_onelink_json(service: req.service)]]
|
175
|
+
else
|
176
|
+
[200, CT_JSON, [to_odata_json(service: req.service)]]
|
177
|
+
end
|
149
178
|
else # TODO: other formats
|
150
179
|
415
|
151
180
|
end
|
152
181
|
end
|
153
182
|
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
183
|
+
DELETE_REL_AND_ENTY = lambda do |entity, assoc, parent|
|
184
|
+
OData.remove_nav_relation(assoc, parent)
|
185
|
+
entity.destroy(transaction: false)
|
186
|
+
end
|
187
|
+
|
188
|
+
def odata_delete_relation_and_entity(req, assoc, parent)
|
189
|
+
if parent
|
190
|
+
if req.in_changeset
|
191
|
+
# in-changeset requests get their own transaction
|
192
|
+
DELETE_REL_AND_ENTY.call(self, assoc, parent)
|
193
|
+
else
|
194
|
+
db.transaction do
|
195
|
+
DELETE_REL_AND_ENTY.call(self, assoc, parent)
|
196
|
+
end
|
197
|
+
end
|
198
|
+
else
|
199
|
+
destroy(transaction: false)
|
160
200
|
end
|
201
|
+
rescue StandardError => e
|
202
|
+
raise SequelAdapterError.new(e)
|
161
203
|
end
|
162
204
|
|
205
|
+
# TODO: differentiate between POST/PUT/PATCH/MERGE
|
163
206
|
def odata_post(req)
|
164
|
-
|
165
|
-
|
207
|
+
if req.walker.media_value
|
208
|
+
odata_media_value_put(req)
|
209
|
+
elsif req.accept?(APPJSON)
|
210
|
+
data.delete('__metadata')
|
166
211
|
|
167
|
-
|
212
|
+
if req.in_changeset
|
213
|
+
set_fields(data, self.class.data_fields, missing: :skip)
|
214
|
+
save(transaction: false)
|
215
|
+
else
|
216
|
+
update_fields(data, self.class.data_fields, missing: :skip)
|
217
|
+
end
|
218
|
+
|
219
|
+
[202, EMPTY_HASH, to_odata_post_json(service: req.service)]
|
220
|
+
else # TODO: other formats
|
221
|
+
415
|
222
|
+
end
|
223
|
+
end
|
224
|
+
|
225
|
+
def odata_put(req)
|
226
|
+
if req.walker.media_value
|
227
|
+
odata_media_value_put(req)
|
228
|
+
elsif req.accept?(APPJSON)
|
229
|
+
data = JSON.parse(req.body.read)
|
230
|
+
@uribase = req.uribase
|
168
231
|
data.delete('__metadata')
|
169
232
|
|
170
233
|
if req.in_changeset
|
@@ -174,21 +237,20 @@ module OData
|
|
174
237
|
update_fields(data, self.class.data_fields, missing: :skip)
|
175
238
|
end
|
176
239
|
|
177
|
-
|
240
|
+
ARY_204_EMPTY_HASH_ARY
|
178
241
|
else # TODO: other formats
|
179
242
|
415
|
180
243
|
end
|
181
244
|
end
|
182
245
|
|
183
246
|
def odata_patch(req)
|
184
|
-
|
185
|
-
req.with_parsed_data(on_error: on_error) do |data|
|
247
|
+
req.with_parsed_data do |data|
|
186
248
|
data.delete('__metadata')
|
187
249
|
|
188
250
|
# validate payload column names
|
189
251
|
if (invalid = self.class.invalid_hash_data?(data))
|
190
|
-
|
191
|
-
return [422,
|
252
|
+
::OData::Request::ON_CGST_ERROR.call(req)
|
253
|
+
return [422, EMPTY_HASH, ['Invalid attribute name: ', invalid.to_s]]
|
192
254
|
end
|
193
255
|
# TODO: check values/types
|
194
256
|
|
@@ -203,7 +265,7 @@ module OData
|
|
203
265
|
update_fields(data, my_data_fields, missing: :skip)
|
204
266
|
end
|
205
267
|
# patch should return 204 + no content
|
206
|
-
|
268
|
+
ARY_204_EMPTY_HASH_ARY
|
207
269
|
end
|
208
270
|
end
|
209
271
|
|
@@ -218,11 +280,6 @@ module OData
|
|
218
280
|
@child_method.call.count
|
219
281
|
end
|
220
282
|
|
221
|
-
# TODO: fix design
|
222
|
-
def navigated_coll
|
223
|
-
true
|
224
|
-
end
|
225
|
-
|
226
283
|
def dataset
|
227
284
|
@child_dataset_method.call
|
228
285
|
end
|
@@ -236,6 +293,23 @@ module OData
|
|
236
293
|
y.each { |enty| yield enty }
|
237
294
|
end
|
238
295
|
|
296
|
+
# TODO: design... this is not DRY
|
297
|
+
def slug_field
|
298
|
+
superclass.slug_field
|
299
|
+
end
|
300
|
+
|
301
|
+
def type_name
|
302
|
+
superclass.type_name
|
303
|
+
end
|
304
|
+
|
305
|
+
def media_handler
|
306
|
+
superclass.media_handler
|
307
|
+
end
|
308
|
+
|
309
|
+
def default_template
|
310
|
+
superclass.default_template
|
311
|
+
end
|
312
|
+
|
239
313
|
def to_a
|
240
314
|
y = @child_method.call
|
241
315
|
y.to_a
|
@@ -254,6 +328,8 @@ module OData
|
|
254
328
|
# childattrib(collection)
|
255
329
|
@child_method = parent.method(childattrib.to_sym)
|
256
330
|
@child_dataset_method = parent.method("#{childattrib}_dataset".to_sym)
|
331
|
+
@nav_parent = parent
|
332
|
+
@navattr_reflection = parent.class.association_reflections[childattrib.to_sym]
|
257
333
|
prepare_pk
|
258
334
|
prepare_fields
|
259
335
|
# Now in this anonymous Class we can refine the "all, count and []
|
@@ -269,11 +345,134 @@ module OData
|
|
269
345
|
# 'Race[12].RaceType'
|
270
346
|
# where Race[12] would be our self and 'RaceType' is the single
|
271
347
|
# childattrib entity
|
272
|
-
|
273
|
-
|
348
|
+
|
349
|
+
# when the child attribute is nil (because the FK is NULL on DB)
|
350
|
+
# then we return a Nil... wrapper object. This object then
|
351
|
+
# allows to receive a POST operation that would actually create the nav attribute entity
|
352
|
+
|
353
|
+
ret = method(childattrib.to_sym).call || OData::NilNavigationAttribute.new
|
354
|
+
|
355
|
+
ret.set_relation_info(self, childattrib)
|
356
|
+
|
357
|
+
ret
|
274
358
|
end
|
275
359
|
end
|
276
360
|
# end of module ODataEntity
|
361
|
+
module Entity
|
362
|
+
include EntityBase
|
363
|
+
end
|
364
|
+
|
365
|
+
module NonMediaEntity
|
366
|
+
# non media entity metadata for json h
|
367
|
+
def metadata_h(uribase:)
|
368
|
+
{ uri: uri(uribase),
|
369
|
+
type: type_name }
|
370
|
+
end
|
371
|
+
|
372
|
+
def values_for_odata
|
373
|
+
values.dup
|
374
|
+
end
|
375
|
+
|
376
|
+
def odata_delete(req)
|
377
|
+
if req.accept?(APPJSON)
|
378
|
+
# delete
|
379
|
+
begin
|
380
|
+
odata_delete_relation_and_entity(req, @navattr_reflection, @nav_parent)
|
381
|
+
[200, CT_JSON, [{ 'd' => req.service.get_emptycoll_odata_h }.to_json]]
|
382
|
+
rescue SequelAdapterError => e
|
383
|
+
BadRequestSequelAdapterError.new(e).odata_get(req)
|
384
|
+
end
|
385
|
+
else # TODO: other formats
|
386
|
+
415
|
387
|
+
end
|
388
|
+
end
|
389
|
+
|
390
|
+
# in case of a non media entity, we have to return an error on $value request
|
391
|
+
def odata_media_value_get(_req)
|
392
|
+
BadRequestNonMediaValue.odata_get
|
393
|
+
end
|
394
|
+
|
395
|
+
# in case of a non media entity, we have to return an error on $value PUT
|
396
|
+
def odata_media_value_put(_req)
|
397
|
+
BadRequestNonMediaValue.odata_get
|
398
|
+
end
|
399
|
+
end
|
400
|
+
|
401
|
+
module MediaEntity
|
402
|
+
# media entity metadata for json h
|
403
|
+
def metadata_h(uribase:)
|
404
|
+
{ uri: uri(uribase),
|
405
|
+
type: type_name,
|
406
|
+
media_src: media_src(uribase),
|
407
|
+
edit_media: edit_media(uribase),
|
408
|
+
content_type: @values[:content_type] }
|
409
|
+
end
|
410
|
+
|
411
|
+
def media_src(uribase)
|
412
|
+
version = self.class.media_handler.ressource_version(self)
|
413
|
+
"#{uri(uribase)}/$value?version=#{version}"
|
414
|
+
end
|
415
|
+
|
416
|
+
def edit_media(uribase)
|
417
|
+
"#{uri(uribase)}/$value"
|
418
|
+
end
|
419
|
+
|
420
|
+
# directory where to put/find the media files for this entity-type
|
421
|
+
def klass_dir
|
422
|
+
type_name
|
423
|
+
end
|
424
|
+
|
425
|
+
# # this is just ModelKlass/pk as a single string
|
426
|
+
# def qualified_media_path_id
|
427
|
+
# "#{self.class}/#{media_path_id}"
|
428
|
+
# end
|
429
|
+
|
430
|
+
def values_for_odata
|
431
|
+
ret = values.dup
|
432
|
+
ret.delete(:content_type)
|
433
|
+
ret
|
434
|
+
end
|
435
|
+
|
436
|
+
def odata_delete(req)
|
437
|
+
if req.accept?(APPJSON)
|
438
|
+
# delete the MR
|
439
|
+
# delegate to the media handler on collection(ie class) level
|
440
|
+
# TODO error handling
|
441
|
+
|
442
|
+
self.class.media_handler.odata_delete(entity: self)
|
443
|
+
# delete the relation(s) to parent(s) (if any) and then entity
|
444
|
+
odata_delete_relation_and_entity(req, @navattr_reflection, @nav_parent)
|
445
|
+
# result
|
446
|
+
[200, CT_JSON, [{ 'd' => req.service.get_emptycoll_odata_h }.to_json]]
|
447
|
+
else # TODO: other formats
|
448
|
+
415
|
449
|
+
end
|
450
|
+
end
|
451
|
+
|
452
|
+
# real implementation for returning $value for a media entity
|
453
|
+
def odata_media_value_get(req)
|
454
|
+
# delegate to the media handler on collection(ie class) level
|
455
|
+
self.class.media_handler.odata_get(request: req, entity: self)
|
456
|
+
end
|
457
|
+
|
458
|
+
# real implementation for replacing $value for a media entity
|
459
|
+
def odata_media_value_put(req)
|
460
|
+
model = self.class
|
461
|
+
req.with_media_data do |data, mimetype, filename|
|
462
|
+
emdata = { content_type: mimetype }
|
463
|
+
if req.in_changeset
|
464
|
+
set_fields(emdata, model.data_fields, missing: :skip)
|
465
|
+
save(transaction: false)
|
466
|
+
else
|
467
|
+
update_fields(emdata, model.data_fields, missing: :skip)
|
468
|
+
end
|
469
|
+
model.media_handler.replace_file(data: data,
|
470
|
+
entity: self,
|
471
|
+
filename: filename)
|
472
|
+
ARY_204_EMPTY_HASH_ARY
|
473
|
+
end
|
474
|
+
end
|
475
|
+
end
|
277
476
|
|
278
477
|
# for a single public key
|
279
478
|
module EntitySinglePK
|
@@ -281,15 +480,30 @@ module OData
|
|
281
480
|
def pk_uri
|
282
481
|
pk
|
283
482
|
end
|
483
|
+
|
484
|
+
def media_path_id
|
485
|
+
pk.to_s
|
486
|
+
end
|
487
|
+
|
488
|
+
def media_path_ids
|
489
|
+
[pk]
|
490
|
+
end
|
284
491
|
end
|
285
492
|
|
286
493
|
# for multiple key
|
287
494
|
module EntityMultiPK
|
288
495
|
include Entity
|
289
496
|
def pk_uri
|
290
|
-
#
|
291
|
-
|
292
|
-
|
497
|
+
# pk_hash is provided by Sequel
|
498
|
+
pk_hash.map { |k, v| "#{k}='#{v}'" }.join(COMMA)
|
499
|
+
end
|
500
|
+
|
501
|
+
def media_path_id
|
502
|
+
pk_hash.values.join(SPACE)
|
503
|
+
end
|
504
|
+
|
505
|
+
def media_path_ids
|
506
|
+
pk_hash.values
|
293
507
|
end
|
294
508
|
end
|
295
509
|
end
|