dscf-core 0.2.8 → 0.2.9
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.
- checksums.yaml +4 -4
- data/app/controllers/concerns/dscf/core/auditable_controller.rb +17 -17
- data/app/controllers/concerns/dscf/core/authenticatable.rb +3 -8
- data/app/controllers/concerns/dscf/core/authorizable.rb +66 -0
- data/app/controllers/concerns/dscf/core/common.rb +49 -19
- data/app/controllers/concerns/dscf/core/file_uploadable.rb +261 -0
- data/app/controllers/concerns/dscf/core/reviewable_controller.rb +31 -1
- data/app/controllers/dscf/core/addresses_controller.rb +0 -5
- data/app/controllers/dscf/core/application_controller.rb +3 -5
- data/app/controllers/dscf/core/auth_controller.rb +16 -11
- data/app/controllers/dscf/core/businesses_controller.rb +0 -5
- data/app/controllers/dscf/core/files_controller.rb +38 -0
- data/app/controllers/dscf/core/permissions_controller.rb +20 -0
- data/app/controllers/dscf/core/role_permissions_controller.rb +54 -0
- data/app/controllers/dscf/core/roles_controller.rb +30 -0
- data/app/controllers/dscf/core/user_roles_controller.rb +56 -0
- data/app/errors/dscf/core/file_upload_error.rb +9 -0
- data/app/jobs/dscf/core/audit_logger_job.rb +1 -3
- data/app/models/concerns/dscf/core/attachable.rb +403 -0
- data/app/models/dscf/core/file_attachment.rb +205 -0
- data/app/models/dscf/core/permission.rb +27 -0
- data/app/models/dscf/core/role.rb +8 -3
- data/app/models/dscf/core/role_permission.rb +18 -0
- data/app/models/dscf/core/user.rb +56 -0
- data/app/models/dscf/core/user_role.rb +23 -3
- data/app/policies/dscf/core/application_policy.rb +62 -0
- data/app/policies/dscf/core/business_policy.rb +29 -0
- data/app/policies/dscf/core/business_type_policy.rb +6 -0
- data/app/policies/dscf/core/permission_policy.rb +6 -0
- data/app/policies/dscf/core/role_policy.rb +25 -0
- data/app/serializers/dscf/core/attachment_serializer.rb +30 -0
- data/app/serializers/dscf/core/permission_serializer.rb +7 -0
- data/app/serializers/dscf/core/role_light_serializer.rb +7 -0
- data/app/serializers/dscf/core/role_permission_serializer.rb +9 -0
- data/app/serializers/dscf/core/role_serializer.rb +2 -2
- data/app/serializers/dscf/core/user_auth_serializer.rb +6 -2
- data/app/serializers/dscf/core/user_role_serializer.rb +2 -3
- data/app/services/dscf/core/file_storage/client.rb +210 -0
- data/app/services/dscf/core/file_storage/uploader.rb +127 -0
- data/app/services/dscf/core/file_storage.rb +44 -0
- data/app/services/dscf/core/token_service.rb +2 -1
- data/config/locales/en.yml +35 -2
- data/config/routes.rb +15 -0
- data/db/migrate/20250821185708_create_dscf_core_roles.rb +3 -1
- data/db/migrate/20250822054547_create_dscf_core_user_roles.rb +3 -1
- data/db/migrate/20260128000000_create_dscf_core_file_attachments.rb +48 -0
- data/db/migrate/20260304000001_create_dscf_core_permissions.rb +19 -0
- data/db/migrate/20260304000002_create_dscf_core_role_permissions.rb +11 -0
- data/lib/dscf/core/permission_registry.rb +58 -0
- data/lib/dscf/core/version.rb +1 -1
- data/lib/dscf/core.rb +12 -1
- data/spec/factories/dscf/core/permissions.rb +14 -0
- data/spec/factories/dscf/core/reviews.rb +0 -1
- data/spec/factories/dscf/core/role_permissions.rb +6 -0
- data/spec/factories/dscf/core/roles.rb +42 -2
- data/spec/factories/dscf/core/user_roles.rb +4 -2
- metadata +33 -3
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dscf
|
|
4
|
+
module Core
|
|
5
|
+
# Include this concern in any model to add file attachment capabilities
|
|
6
|
+
# Works exactly like ActiveStorage's has_one_attached and has_many_attached
|
|
7
|
+
# NO MIGRATION NEEDED per model - just include and declare!
|
|
8
|
+
#
|
|
9
|
+
# @example Single file attachment
|
|
10
|
+
# class UserProfile < ApplicationRecord
|
|
11
|
+
# include Dscf::Core::Attachable
|
|
12
|
+
#
|
|
13
|
+
# has_one_file :avatar
|
|
14
|
+
# has_one_file :cover_photo, max_size: 10.megabytes
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# profile.avatar.attach(params[:avatar])
|
|
18
|
+
# profile.avatar.attached? # => true
|
|
19
|
+
# profile.avatar.url # => "/dscf/core/files/..."
|
|
20
|
+
# profile.avatar.download # => binary data
|
|
21
|
+
# profile.avatar.purge # deletes file
|
|
22
|
+
#
|
|
23
|
+
# @example Multiple files attachment
|
|
24
|
+
# class Product < ApplicationRecord
|
|
25
|
+
# include Dscf::Core::Attachable
|
|
26
|
+
#
|
|
27
|
+
# has_many_files :images
|
|
28
|
+
# has_many_files :documents, allowed_types: %w[application/pdf]
|
|
29
|
+
# end
|
|
30
|
+
#
|
|
31
|
+
# product.images.attach(params[:images])
|
|
32
|
+
# product.images.attached? # => true
|
|
33
|
+
# product.images.count # => 3
|
|
34
|
+
# product.images.first.url # => "/dscf/core/files/..."
|
|
35
|
+
# product.images.purge_all # deletes all
|
|
36
|
+
#
|
|
37
|
+
module Attachable
|
|
38
|
+
extend ActiveSupport::Concern
|
|
39
|
+
|
|
40
|
+
included do
|
|
41
|
+
# Polymorphic association to the central file_attachments table
|
|
42
|
+
has_many :file_attachments,
|
|
43
|
+
class_name: "Dscf::Core::FileAttachment",
|
|
44
|
+
as: :attachable,
|
|
45
|
+
dependent: :destroy
|
|
46
|
+
|
|
47
|
+
# Store attachment configurations
|
|
48
|
+
class_attribute :_file_attachment_configs, default: {}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
class_methods do
|
|
52
|
+
# Define a single file attachment
|
|
53
|
+
# @param name [Symbol] attachment name
|
|
54
|
+
# @param options [Hash] configuration options
|
|
55
|
+
# @option options [Integer] :max_size maximum file size (default: 5MB)
|
|
56
|
+
# @option options [Array<String>] :allowed_types allowed MIME types
|
|
57
|
+
def has_one_file(name, **options)
|
|
58
|
+
_file_attachment_configs[name] = options.merge(multiple: false)
|
|
59
|
+
define_single_attachment_methods(name, options)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Define a multiple file attachment
|
|
63
|
+
# @param name [Symbol] attachment name
|
|
64
|
+
# @param options [Hash] configuration options
|
|
65
|
+
def has_many_files(name, **options)
|
|
66
|
+
_file_attachment_configs[name] = options.merge(multiple: true)
|
|
67
|
+
define_multiple_attachment_methods(name, options)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def define_single_attachment_methods(name, options)
|
|
73
|
+
# Getter: returns a SingleAttachmentProxy (memoized to preserve state like errors)
|
|
74
|
+
ivar_name = "@_attachment_proxy_#{name}"
|
|
75
|
+
define_method(name) do
|
|
76
|
+
instance_variable_get(ivar_name) ||
|
|
77
|
+
instance_variable_set(ivar_name, SingleAttachmentProxy.new(self, name, options))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Direct setter for convenience: model.avatar = file
|
|
81
|
+
define_method("#{name}=") do |file|
|
|
82
|
+
send(name).attach(file)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def define_multiple_attachment_methods(name, options)
|
|
87
|
+
# Getter: returns a MultipleAttachmentProxy (memoized to preserve state)
|
|
88
|
+
ivar_name = "@_attachment_proxy_#{name}"
|
|
89
|
+
define_method(name) do
|
|
90
|
+
instance_variable_get(ivar_name) ||
|
|
91
|
+
instance_variable_set(ivar_name, MultipleAttachmentProxy.new(self, name, options))
|
|
92
|
+
end
|
|
93
|
+
define_method("#{name}=") do |files|
|
|
94
|
+
send(name).attach(files)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# ==========================================================================
|
|
100
|
+
# SingleAttachmentProxy - handles has_one_file attachments
|
|
101
|
+
# ==========================================================================
|
|
102
|
+
class SingleAttachmentProxy
|
|
103
|
+
# Delegate common methods to the underlying FileAttachment record
|
|
104
|
+
delegate :file_key, :filename, :content_type, :size, :human_size,
|
|
105
|
+
:extension, :image?, :pdf?, :video?, :url, :download,
|
|
106
|
+
:metadata, :created_at, :id,
|
|
107
|
+
to: :attachment, allow_nil: true
|
|
108
|
+
|
|
109
|
+
def initialize(record, name, options)
|
|
110
|
+
@record = record
|
|
111
|
+
@name = name.to_s
|
|
112
|
+
@options = options
|
|
113
|
+
@errors = []
|
|
114
|
+
@uploader_context = nil
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Set the user who is uploading the file
|
|
118
|
+
# @param user [ActiveRecord::Base] the user performing the upload
|
|
119
|
+
# @return [self]
|
|
120
|
+
def uploaded_by(user)
|
|
121
|
+
@uploader_context = user
|
|
122
|
+
self
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Attach a file to this record
|
|
126
|
+
# @param file [ActionDispatch::Http::UploadedFile, File, Hash, nil]
|
|
127
|
+
# @return [Boolean] success status
|
|
128
|
+
def attach(file)
|
|
129
|
+
@errors = []
|
|
130
|
+
return detach if file.nil?
|
|
131
|
+
|
|
132
|
+
# Validate and upload the file (pass record class for engine detection)
|
|
133
|
+
uploader = FileStorage::Uploader.new(uploader_options.merge(record_class: @record.class))
|
|
134
|
+
result = uploader.upload(file)
|
|
135
|
+
|
|
136
|
+
unless result
|
|
137
|
+
@errors = uploader.errors
|
|
138
|
+
return false
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Remove existing attachment (if any)
|
|
142
|
+
existing_attachment&.purge
|
|
143
|
+
|
|
144
|
+
# Create new attachment record
|
|
145
|
+
@record.file_attachments.create!(
|
|
146
|
+
name: @name,
|
|
147
|
+
file_key: result[:file_key],
|
|
148
|
+
filename: result[:filename],
|
|
149
|
+
content_type: result[:content_type],
|
|
150
|
+
size: result[:size],
|
|
151
|
+
metadata: result[:metadata] || {},
|
|
152
|
+
uploaded_by: @uploader_context
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
@attachment = nil # Reset cache
|
|
156
|
+
@uploader_context = nil # Reset uploader context
|
|
157
|
+
true
|
|
158
|
+
rescue StandardError => e
|
|
159
|
+
Rails.logger.error("Failed to attach file: #{e.message}")
|
|
160
|
+
@errors << "Failed to attach file: #{e.message}"
|
|
161
|
+
false
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Remove the attached file
|
|
165
|
+
# @return [Boolean]
|
|
166
|
+
def detach
|
|
167
|
+
existing_attachment&.purge
|
|
168
|
+
@attachment = nil
|
|
169
|
+
true
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
alias purge detach
|
|
173
|
+
|
|
174
|
+
# Check if a file is attached
|
|
175
|
+
# @return [Boolean]
|
|
176
|
+
def attached?
|
|
177
|
+
attachment.present? && attachment.attached?
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Get the underlying FileAttachment record
|
|
181
|
+
# @return [FileAttachment, nil]
|
|
182
|
+
def attachment
|
|
183
|
+
@attachment ||= @record.file_attachments.with_name(@name).first
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Check if file exists in storage
|
|
187
|
+
# @return [Boolean]
|
|
188
|
+
def exists?
|
|
189
|
+
attachment&.exists_in_storage? || false
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Get upload errors
|
|
193
|
+
# @return [Array<String>]
|
|
194
|
+
attr_reader :errors
|
|
195
|
+
|
|
196
|
+
# Serialize for JSON
|
|
197
|
+
# @return [Hash, nil]
|
|
198
|
+
def as_json(options = {})
|
|
199
|
+
return nil unless attached?
|
|
200
|
+
|
|
201
|
+
attachment.as_json(options)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
private
|
|
205
|
+
|
|
206
|
+
def existing_attachment
|
|
207
|
+
@record.file_attachments.find_by(name: @name)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def uploader_options
|
|
211
|
+
opts = {}
|
|
212
|
+
opts[:max_size] = @options[:max_size] if @options[:max_size]
|
|
213
|
+
opts[:allowed_types] = @options[:allowed_types] if @options[:allowed_types]
|
|
214
|
+
opts
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# ==========================================================================
|
|
219
|
+
# MultipleAttachmentProxy - handles has_many_files attachments
|
|
220
|
+
# ==========================================================================
|
|
221
|
+
class MultipleAttachmentProxy
|
|
222
|
+
include Enumerable
|
|
223
|
+
|
|
224
|
+
delegate :each, to: :attachments
|
|
225
|
+
|
|
226
|
+
def initialize(record, name, options)
|
|
227
|
+
@record = record
|
|
228
|
+
@name = name.to_s
|
|
229
|
+
@options = options
|
|
230
|
+
@errors = []
|
|
231
|
+
@uploader_context = nil
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Set the user who is uploading the files
|
|
235
|
+
# @param user [ActiveRecord::Base] the user performing the upload
|
|
236
|
+
# @return [self]
|
|
237
|
+
def uploaded_by(user)
|
|
238
|
+
@uploader_context = user
|
|
239
|
+
self
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Attach one or more files
|
|
243
|
+
# @param files [ActionDispatch::Http::UploadedFile, Array<ActionDispatch::Http::UploadedFile>]
|
|
244
|
+
# @param replace [Boolean] whether to replace existing files (default: false)
|
|
245
|
+
# @return [Boolean] success status
|
|
246
|
+
def attach(files, replace: false)
|
|
247
|
+
@errors = []
|
|
248
|
+
files = Array(files).compact
|
|
249
|
+
return true if files.empty?
|
|
250
|
+
|
|
251
|
+
# Purge existing if replacing
|
|
252
|
+
purge_all if replace
|
|
253
|
+
|
|
254
|
+
uploader = FileStorage::Uploader.new(uploader_options)
|
|
255
|
+
current_position = attachments.maximum(:position).to_i
|
|
256
|
+
|
|
257
|
+
files.each do |file|
|
|
258
|
+
result = uploader.upload(file)
|
|
259
|
+
|
|
260
|
+
if result
|
|
261
|
+
current_position += 1
|
|
262
|
+
@record.file_attachments.create!(
|
|
263
|
+
name: @name,
|
|
264
|
+
file_key: result[:file_key],
|
|
265
|
+
filename: result[:filename],
|
|
266
|
+
content_type: result[:content_type],
|
|
267
|
+
size: result[:size],
|
|
268
|
+
metadata: result[:metadata] || {},
|
|
269
|
+
position: current_position,
|
|
270
|
+
uploaded_by: @uploader_context
|
|
271
|
+
)
|
|
272
|
+
else
|
|
273
|
+
@errors.concat(uploader.errors)
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
@attachments = nil # Reset cache
|
|
278
|
+
@uploader_context = nil # Reset uploader context
|
|
279
|
+
@errors.empty?
|
|
280
|
+
rescue StandardError => e
|
|
281
|
+
Rails.logger.error("Failed to attach files: #{e.message}")
|
|
282
|
+
@errors << "Failed to attach files: #{e.message}"
|
|
283
|
+
false
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Remove a specific file by its file_key
|
|
287
|
+
# @param file_key [String]
|
|
288
|
+
# @return [Boolean]
|
|
289
|
+
def detach(file_key)
|
|
290
|
+
att = attachments.find_by(file_key: file_key)
|
|
291
|
+
return false unless att
|
|
292
|
+
|
|
293
|
+
att.purge
|
|
294
|
+
@attachments = nil
|
|
295
|
+
true
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Remove all attached files
|
|
299
|
+
# @return [Boolean]
|
|
300
|
+
def purge_all
|
|
301
|
+
attachments.each(&:purge)
|
|
302
|
+
@attachments = nil
|
|
303
|
+
true
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Check if any files are attached
|
|
307
|
+
# @return [Boolean]
|
|
308
|
+
def attached?
|
|
309
|
+
attachments.exists?
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Get all FileAttachment records for this attachment name
|
|
313
|
+
# @return [ActiveRecord::Relation]
|
|
314
|
+
def attachments
|
|
315
|
+
@attachments ||= @record.file_attachments.with_name(@name).ordered
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Get count of attached files
|
|
319
|
+
# @return [Integer]
|
|
320
|
+
def count
|
|
321
|
+
attachments.count
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
alias size count
|
|
325
|
+
alias length count
|
|
326
|
+
|
|
327
|
+
# Check if empty
|
|
328
|
+
# @return [Boolean]
|
|
329
|
+
def empty?
|
|
330
|
+
!attached?
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# Get first attachment
|
|
334
|
+
# @return [FileAttachment, nil]
|
|
335
|
+
def first
|
|
336
|
+
attachments.first
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Get last attachment
|
|
340
|
+
# @return [FileAttachment, nil]
|
|
341
|
+
def last
|
|
342
|
+
attachments.last
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# Find attachment by file_key
|
|
346
|
+
# @param file_key [String]
|
|
347
|
+
# @return [FileAttachment, nil]
|
|
348
|
+
def find_by_key(file_key)
|
|
349
|
+
attachments.find_by(file_key: file_key)
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# Filter to only images
|
|
353
|
+
# @return [ActiveRecord::Relation]
|
|
354
|
+
def images
|
|
355
|
+
attachments.images
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# Filter to only PDFs
|
|
359
|
+
# @return [ActiveRecord::Relation]
|
|
360
|
+
def pdfs
|
|
361
|
+
attachments.pdfs
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# Filter to only videos
|
|
365
|
+
# @return [ActiveRecord::Relation]
|
|
366
|
+
def videos
|
|
367
|
+
attachments.videos
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# Get total size of all attachments
|
|
371
|
+
# @return [Integer]
|
|
372
|
+
def total_size
|
|
373
|
+
attachments.sum(:size)
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# Get human-readable total size
|
|
377
|
+
# @return [String]
|
|
378
|
+
def human_total_size
|
|
379
|
+
ActiveSupport::NumberHelper.number_to_human_size(total_size)
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# Get upload errors
|
|
383
|
+
# @return [Array<String>]
|
|
384
|
+
attr_reader :errors
|
|
385
|
+
|
|
386
|
+
# Serialize for JSON
|
|
387
|
+
# @return [Array<Hash>]
|
|
388
|
+
def as_json(options = {})
|
|
389
|
+
attachments.map { |att| att.as_json(options) }
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
private
|
|
393
|
+
|
|
394
|
+
def uploader_options
|
|
395
|
+
opts = {}
|
|
396
|
+
opts[:max_size] = @options[:max_size] if @options[:max_size]
|
|
397
|
+
opts[:allowed_types] = @options[:allowed_types] if @options[:allowed_types]
|
|
398
|
+
opts
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
end
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dscf
|
|
4
|
+
module Core
|
|
5
|
+
# FileAttachment model - stores file metadata with polymorphic association
|
|
6
|
+
# This is the central table for ALL file attachments across ALL engines
|
|
7
|
+
#
|
|
8
|
+
# @example Direct usage (rarely needed)
|
|
9
|
+
# attachment = Dscf::Core::FileAttachment.find(1)
|
|
10
|
+
# attachment.download # => binary data
|
|
11
|
+
# attachment.url # => "/dscf/core/files/..."
|
|
12
|
+
# attachment.purge # => deletes from MinIO and database
|
|
13
|
+
#
|
|
14
|
+
# @example Querying across all attachments
|
|
15
|
+
# Dscf::Core::FileAttachment.images # => all image attachments
|
|
16
|
+
# Dscf::Core::FileAttachment.pdfs # => all PDF attachments
|
|
17
|
+
# Dscf::Core::FileAttachment.sum(:size) # => total storage used
|
|
18
|
+
#
|
|
19
|
+
class FileAttachment < ApplicationRecord
|
|
20
|
+
# Associations
|
|
21
|
+
belongs_to :attachable, polymorphic: true
|
|
22
|
+
belongs_to :uploaded_by, polymorphic: true, optional: true
|
|
23
|
+
|
|
24
|
+
# Validations
|
|
25
|
+
validates :name, presence: true
|
|
26
|
+
validates :file_key, presence: true, uniqueness: true
|
|
27
|
+
validates :filename, presence: true
|
|
28
|
+
validates :content_type, presence: true
|
|
29
|
+
validates :size, presence: true, numericality: {greater_than: 0}
|
|
30
|
+
validates :attachable_type, presence: true
|
|
31
|
+
validates :attachable_id, presence: true
|
|
32
|
+
|
|
33
|
+
# Callbacks
|
|
34
|
+
before_destroy :purge_from_storage
|
|
35
|
+
|
|
36
|
+
# Scopes
|
|
37
|
+
scope :with_name, ->(name) { where(name: name.to_s) }
|
|
38
|
+
scope :images, -> { where("content_type LIKE ?", "image/%") }
|
|
39
|
+
scope :pdfs, -> { where(content_type: "application/pdf") }
|
|
40
|
+
scope :videos, -> { where("content_type LIKE ?", "video/%") }
|
|
41
|
+
scope :ordered, -> { order(position: :asc, created_at: :asc) }
|
|
42
|
+
scope :recent, -> { order(created_at: :desc) }
|
|
43
|
+
scope :by_type, ->(type) { where(content_type: type) }
|
|
44
|
+
scope :larger_than, ->(bytes) { where("size > ?", bytes) }
|
|
45
|
+
scope :smaller_than, ->(bytes) { where("size < ?", bytes) }
|
|
46
|
+
scope :uploaded_by, ->(record) { where(uploaded_by: record) }
|
|
47
|
+
|
|
48
|
+
# Class methods for analytics
|
|
49
|
+
class << self
|
|
50
|
+
def total_storage
|
|
51
|
+
sum(:size) || 0
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Ransack configuration for filtering
|
|
55
|
+
def ransackable_attributes(_auth_object = nil)
|
|
56
|
+
%w[id name filename content_type size attachable_type attachable_id
|
|
57
|
+
uploaded_by_type uploaded_by_id position created_at updated_at]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def ransackable_associations(_auth_object = nil)
|
|
61
|
+
%w[attachable uploaded_by]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def storage_by_model
|
|
65
|
+
group(:attachable_type).sum(:size)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def storage_by_content_type
|
|
69
|
+
group(:content_type).sum(:size)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def count_by_model
|
|
73
|
+
group(:attachable_type).count
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Download the actual file content from MinIO
|
|
78
|
+
# @return [String] binary file data
|
|
79
|
+
def download
|
|
80
|
+
result = storage_client.download(file_key)
|
|
81
|
+
result[:data]
|
|
82
|
+
rescue FileStorage::Client::DownloadError => e
|
|
83
|
+
Rails.logger.error("Failed to download file #{file_key}: #{e.message}")
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Get the download URL for this attachment
|
|
88
|
+
# Works across all engines that mount dscf-core
|
|
89
|
+
# @param _expires_in [ActiveSupport::Duration] URL expiration time (reserved for future signed URLs)
|
|
90
|
+
# @return [String] the download URL
|
|
91
|
+
def url(_expires_in: 1.hour)
|
|
92
|
+
# Try to get URL through main app's mounted engine routes (most reliable)
|
|
93
|
+
if defined?(Rails.application) && Rails.application.routes.named_routes.key?(:dscf_core)
|
|
94
|
+
Rails.application.routes.url_helpers.dscf_core.file_download_path(file_key: file_key)
|
|
95
|
+
else
|
|
96
|
+
# Fallback: Use engine routes directly (works when engine is the main app or in tests)
|
|
97
|
+
Dscf::Core::Engine.routes.url_helpers.file_download_path(file_key: file_key)
|
|
98
|
+
end
|
|
99
|
+
rescue StandardError
|
|
100
|
+
# Last resort fallback with configurable base path
|
|
101
|
+
base_path = Dscf::Core.file_download_base_path
|
|
102
|
+
"#{base_path}/#{file_key}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Check if the file exists in MinIO storage
|
|
106
|
+
# @return [Boolean]
|
|
107
|
+
def exists_in_storage?
|
|
108
|
+
storage_client.exists?(file_key)
|
|
109
|
+
rescue StandardError
|
|
110
|
+
false
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Get the file extension
|
|
114
|
+
# @return [String, nil]
|
|
115
|
+
def extension
|
|
116
|
+
return nil unless filename
|
|
117
|
+
|
|
118
|
+
ext = File.extname(filename).delete_prefix(".")
|
|
119
|
+
ext.presence
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Get human-readable file size
|
|
123
|
+
# @return [String, nil]
|
|
124
|
+
def human_size
|
|
125
|
+
return nil unless size
|
|
126
|
+
|
|
127
|
+
ActiveSupport::NumberHelper.number_to_human_size(size)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Check if this is an image file
|
|
131
|
+
# @return [Boolean]
|
|
132
|
+
def image?
|
|
133
|
+
content_type&.start_with?("image/")
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Check if this is a PDF file
|
|
137
|
+
# @return [Boolean]
|
|
138
|
+
def pdf?
|
|
139
|
+
content_type == "application/pdf"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Check if this is a video file
|
|
143
|
+
# @return [Boolean]
|
|
144
|
+
def video?
|
|
145
|
+
content_type&.start_with?("video/")
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Check if attachment is properly persisted
|
|
149
|
+
# @return [Boolean]
|
|
150
|
+
def attached?
|
|
151
|
+
persisted? && file_key.present?
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Delete file from MinIO and destroy the record
|
|
155
|
+
# @return [Boolean]
|
|
156
|
+
def purge
|
|
157
|
+
purge_from_storage
|
|
158
|
+
destroy
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Delete file from MinIO and destroy the record (bang version)
|
|
162
|
+
# @return [Boolean]
|
|
163
|
+
def purge!
|
|
164
|
+
purge_from_storage
|
|
165
|
+
destroy!
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Serialize for JSON API responses
|
|
169
|
+
# @return [Hash]
|
|
170
|
+
def as_json(_options = {})
|
|
171
|
+
{
|
|
172
|
+
id: id,
|
|
173
|
+
file_key: file_key,
|
|
174
|
+
filename: filename,
|
|
175
|
+
content_type: content_type,
|
|
176
|
+
size: size,
|
|
177
|
+
human_size: human_size,
|
|
178
|
+
extension: extension,
|
|
179
|
+
is_image: image?,
|
|
180
|
+
is_pdf: pdf?,
|
|
181
|
+
is_video: video?,
|
|
182
|
+
url: url,
|
|
183
|
+
position: position,
|
|
184
|
+
created_at: created_at&.iso8601,
|
|
185
|
+
metadata: metadata
|
|
186
|
+
}
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
private
|
|
190
|
+
|
|
191
|
+
def storage_client
|
|
192
|
+
@storage_client ||= FileStorage::Client.new
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def purge_from_storage
|
|
196
|
+
return unless file_key.present?
|
|
197
|
+
|
|
198
|
+
storage_client.delete(file_key)
|
|
199
|
+
rescue StandardError => e
|
|
200
|
+
Rails.logger.error("Failed to purge file #{file_key} from storage: #{e.message}")
|
|
201
|
+
# Don't raise - allow the database record to be deleted even if MinIO fails
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module Dscf
|
|
2
|
+
module Core
|
|
3
|
+
class Permission < ApplicationRecord
|
|
4
|
+
validates :code, presence: true, uniqueness: {case_sensitive: false}
|
|
5
|
+
validates :resource, presence: true
|
|
6
|
+
validates :action, presence: true
|
|
7
|
+
validates :engine, presence: true
|
|
8
|
+
validates :resource, uniqueness: {scope: %i[action engine], message: "action and engine combination already exists"}
|
|
9
|
+
|
|
10
|
+
has_many :role_permissions, class_name: "Dscf::Core::RolePermission", dependent: :destroy
|
|
11
|
+
has_many :roles, through: :role_permissions, class_name: "Dscf::Core::Role"
|
|
12
|
+
|
|
13
|
+
scope :active, -> { where(active: true) }
|
|
14
|
+
scope :inactive, -> { where(active: false) }
|
|
15
|
+
scope :by_resource, ->(resource) { where(resource: resource.to_s) }
|
|
16
|
+
scope :by_engine, ->(engine) { where(engine: engine.to_s) }
|
|
17
|
+
|
|
18
|
+
def self.ransackable_attributes(_auth_object = nil)
|
|
19
|
+
%w[id code resource action engine description active created_at updated_at]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.ransackable_associations(_auth_object = nil)
|
|
23
|
+
%w[role_permissions roles]
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -4,15 +4,20 @@ module Dscf
|
|
|
4
4
|
validates :code, presence: true, uniqueness: true
|
|
5
5
|
validates :name, presence: true
|
|
6
6
|
|
|
7
|
-
has_many :user_roles, class_name: "Dscf::Core::UserRole"
|
|
7
|
+
has_many :user_roles, class_name: "Dscf::Core::UserRole", dependent: :destroy
|
|
8
8
|
has_many :users, through: :user_roles, class_name: "Dscf::Core::User"
|
|
9
|
+
has_many :role_permissions, class_name: "Dscf::Core::RolePermission", dependent: :destroy
|
|
10
|
+
has_many :permissions, through: :role_permissions, class_name: "Dscf::Core::Permission"
|
|
11
|
+
|
|
12
|
+
scope :active, -> { where(active: true) }
|
|
13
|
+
scope :inactive, -> { where(active: false) }
|
|
9
14
|
|
|
10
15
|
def self.ransackable_attributes(_auth_object = nil)
|
|
11
|
-
%w[id code name created_at updated_at]
|
|
16
|
+
%w[id code name description active created_at updated_at]
|
|
12
17
|
end
|
|
13
18
|
|
|
14
19
|
def self.ransackable_associations(_auth_object = nil)
|
|
15
|
-
%w[user_roles users
|
|
20
|
+
%w[user_roles users role_permissions permissions]
|
|
16
21
|
end
|
|
17
22
|
end
|
|
18
23
|
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module Dscf
|
|
2
|
+
module Core
|
|
3
|
+
class RolePermission < ApplicationRecord
|
|
4
|
+
belongs_to :role, class_name: "Dscf::Core::Role"
|
|
5
|
+
belongs_to :permission, class_name: "Dscf::Core::Permission"
|
|
6
|
+
|
|
7
|
+
validates :permission_id, uniqueness: {scope: :role_id, message: "already assigned to this role"}
|
|
8
|
+
|
|
9
|
+
def self.ransackable_attributes(_auth_object = nil)
|
|
10
|
+
%w[id role_id permission_id created_at updated_at]
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.ransackable_associations(_auth_object = nil)
|
|
14
|
+
%w[role permission]
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|