b4um_generators 0.3.0 → 0.4.2

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 (30) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +2 -2
  3. data/CHANGELOG.md +72 -0
  4. data/README.de.md +338 -4
  5. data/README.md +330 -5
  6. data/lib/b4um_generators/version.rb +1 -1
  7. data/lib/generators/b4um/admin/admin_generator.rb +725 -0
  8. data/lib/generators/b4um/admin/templates/add_admin_fields_migration.rb.tt +10 -0
  9. data/lib/generators/b4um/admin/templates/admin_base_controller.rb.tt +5 -0
  10. data/lib/generators/b4um/admin/templates/admin_dashboard.html.erb.tt +19 -0
  11. data/lib/generators/b4um/admin/templates/admin_dashboard_controller.rb.tt +6 -0
  12. data/lib/generators/b4um/admin/templates/admin_users_controller.rb.tt +70 -0
  13. data/lib/generators/b4um/admin/templates/admin_users_index.html.erb.tt +110 -0
  14. data/lib/generators/b4um/admin/templates/b4um_admin.rake.tt +106 -0
  15. data/lib/generators/b4um/authentication/authentication_generator.rb +436 -23
  16. data/lib/generators/b4um/comments/comments_generator.rb +39 -9
  17. data/lib/generators/b4um/guest/guest_generator.rb +673 -0
  18. data/lib/generators/b4um/guest/templates/add_guest_fields_migration.rb.tt +12 -0
  19. data/lib/generators/b4um/guest/templates/b4um_guests.rake.tt +12 -0
  20. data/lib/generators/b4um/guest/templates/edit.html.erb.tt +156 -0
  21. data/lib/generators/b4um/guest/templates/guest_expiration_warning.html.erb.tt +12 -0
  22. data/lib/generators/b4um/guest/templates/guests_controller.rb.tt +88 -0
  23. data/lib/generators/b4um/guest/templates/new.html.erb.tt +136 -0
  24. data/lib/generators/b4um/help/help_generator.rb +162 -0
  25. data/lib/generators/b4um/in_place/in_place_generator.rb +132 -7
  26. data/lib/generators/b4um/readable/readable_generator.rb +8 -5
  27. data/lib/generators/b4um/scaffold/scaffold_generator.rb +213 -73
  28. data/lib/generators/b4um/scaffold/templates/_form.html.erb.tt +18 -14
  29. data/lib/generators/b4um/sortable/sortable_generator.rb +104 -36
  30. metadata +16 -1
@@ -0,0 +1,673 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/migration"
5
+
6
+ module B4um
7
+ module Generators
8
+ class GuestGenerator < Rails::Generators::NamedBase
9
+ include Rails::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ class_option :required,
14
+ type: :string,
15
+ desc: "Fields required for guest access. Example: name,email"
16
+
17
+ class_option :expires_in,
18
+ type: :numeric,
19
+ default: 7,
20
+ desc: "Number of days before guest access expires"
21
+
22
+ def initialize(args, options, config)
23
+ super
24
+
25
+ validate_authentication_model
26
+ validate_authentication_setup
27
+ validate_required_fields
28
+ validate_expiration
29
+ end
30
+
31
+ def create_guest_fields_migration
32
+ return if guest_fields_exist?
33
+
34
+ migration_template(
35
+ "add_guest_fields_migration.rb.tt",
36
+ "db/migrate/add_guest_fields_to_#{authentication_model_name.pluralize}.rb"
37
+ )
38
+ end
39
+
40
+ def self.next_migration_number(dirname)
41
+ migration_number = Time.now.utc.strftime("%Y%m%d%H%M%S").to_i
42
+
43
+ existing_numbers =
44
+ Dir[File.join(dirname, "*.rb")]
45
+ .filter_map do |path|
46
+ File.basename(path)[/\A(\d+)_/, 1]&.to_i
47
+ end
48
+
49
+ migration_number += 1 while existing_numbers.include?(migration_number)
50
+
51
+ migration_number.to_s
52
+ end
53
+
54
+ def add_guest_status_helpers
55
+ model_path =
56
+ "app/models/#{authentication_model_name}.rb"
57
+
58
+ full_model_path = File.join(
59
+ destination_root,
60
+ model_path
61
+ )
62
+
63
+ model = File.read(full_model_path)
64
+
65
+ missing_helpers = []
66
+
67
+ unless model.include?("def guest_expired?")
68
+ missing_helpers << <<~RUBY
69
+ def guest_expired?
70
+ guest? &&
71
+ guest_expires_at.present? &&
72
+ guest_expires_at <= Time.current
73
+ end
74
+ RUBY
75
+ end
76
+
77
+ unless model.include?("def guest_expiring_soon?")
78
+ missing_helpers << <<~RUBY
79
+ def guest_expiring_soon?
80
+ guest? &&
81
+ guest_expires_at.present? &&
82
+ !guest_expired? &&
83
+ guest_expires_at <= 24.hours.from_now
84
+ end
85
+ RUBY
86
+ end
87
+
88
+ return if missing_helpers.empty?
89
+
90
+ class_end_position = model.rindex(/^end\s*$/)
91
+
92
+ unless class_end_position
93
+ raise Thor::Error,
94
+ "Could not find the end of #{authentication_class_name}."
95
+ end
96
+
97
+ guest_helpers =
98
+ missing_helpers
99
+ .map { |helper| helper.lines.map { |line| " #{line}" }.join }
100
+ .join("\n")
101
+
102
+ model.insert(
103
+ class_end_position,
104
+ "\n#{guest_helpers}\n"
105
+ )
106
+
107
+ File.write(
108
+ full_model_path,
109
+ model
110
+ )
111
+ end
112
+
113
+ def add_guest_validations
114
+ model_path =
115
+ "app/models/#{authentication_model_name}.rb"
116
+
117
+ full_model_path = File.join(
118
+ destination_root,
119
+ model_path
120
+ )
121
+
122
+ model = File.read(full_model_path)
123
+
124
+ model.gsub!(
125
+ /^(\s*validates\s+:[^\n]+,\s+presence:\s+true)\s*$/,
126
+ '\1, unless: :guest?'
127
+ )
128
+
129
+ validations = guest_required_validations
130
+
131
+ missing_validations =
132
+ validations.reject do |validation|
133
+ model.include?(validation.strip)
134
+ end
135
+
136
+ if missing_validations.any?
137
+ class_end_position = model.rindex(/^end\s*$/)
138
+
139
+ unless class_end_position
140
+ raise Thor::Error,
141
+ "Could not find the end of #{authentication_class_name}."
142
+ end
143
+
144
+ model.insert(
145
+ class_end_position,
146
+ "\n#{missing_validations.join}"
147
+ )
148
+ end
149
+
150
+ File.write(
151
+ full_model_path,
152
+ model
153
+ )
154
+ end
155
+
156
+ def guest_required_validations
157
+ boolean_fields, presence_fields =
158
+ guest_required_fields.partition do |field|
159
+ guest_field_type(field) == :boolean
160
+ end
161
+
162
+ validations = []
163
+
164
+ if presence_fields.any?
165
+ fields =
166
+ presence_fields
167
+ .map { |field| ":#{field}" }
168
+ .join(", ")
169
+
170
+ validations << " validates #{fields}, " \
171
+ "presence: true, if: :guest?\n"
172
+ end
173
+
174
+ boolean_fields.each do |field|
175
+ validations << " validates :#{field}, " \
176
+ "acceptance: true, if: :guest?\n"
177
+ end
178
+
179
+ validations
180
+ end
181
+
182
+ def add_guest_expiration_to_authentication
183
+ controller_path =
184
+ "app/controllers/application_controller.rb"
185
+
186
+ full_controller_path = File.join(
187
+ destination_root,
188
+ controller_path
189
+ )
190
+
191
+ controller = File.read(full_controller_path)
192
+
193
+ return if controller.include?(
194
+ "if #{authentication_model_name}&.guest_expired?"
195
+ )
196
+
197
+ simple_method_pattern = /
198
+ ^\s*def\ #{Regexp.escape(current_authentication_method)}\n
199
+ \s+@#{Regexp.escape(current_authentication_method)}\s*\|\|=\s*
200
+ #{Regexp.escape(authentication_class_name)}\.find_by\(
201
+ id:\s*session\[:#{Regexp.escape(authentication_model_name)}_id\]
202
+ \)\n
203
+ \s*end
204
+ /x
205
+
206
+ blocked_method_pattern = /
207
+ ^\s*def\ #{Regexp.escape(current_authentication_method)}\n
208
+ \s+@#{Regexp.escape(current_authentication_method)}\s*\|\|=\s*
209
+ #{Regexp.escape(authentication_class_name)}\.find_by\(
210
+ id:\s*session\[:#{Regexp.escape(authentication_model_name)}_id\]
211
+ \)\n
212
+ \s*
213
+ if\s+@#{Regexp.escape(current_authentication_method)}&\.blocked\?\n
214
+ \s+session\.delete\(
215
+ :#{Regexp.escape(authentication_model_name)}_id
216
+ \)\n
217
+ \s+@#{Regexp.escape(current_authentication_method)}\s*=\s*nil\n
218
+ \s*end\n
219
+ \s*
220
+ @#{Regexp.escape(current_authentication_method)}\n
221
+ \s*end
222
+ /x
223
+
224
+ if controller.match?(blocked_method_pattern)
225
+ replacement =
226
+ guest_aware_authentication_method(
227
+ include_blocked_check: true
228
+ )
229
+
230
+ pattern = blocked_method_pattern
231
+ elsif controller.match?(simple_method_pattern)
232
+ replacement =
233
+ guest_aware_authentication_method(
234
+ include_blocked_check: false
235
+ )
236
+
237
+ pattern = simple_method_pattern
238
+ else
239
+ raise Thor::Error,
240
+ "Could not update #{current_authentication_method} " \
241
+ "in app/controllers/application_controller.rb"
242
+ end
243
+
244
+ controller.sub!(
245
+ pattern,
246
+ replacement
247
+ )
248
+
249
+ File.write(
250
+ full_controller_path,
251
+ controller
252
+ )
253
+ end
254
+
255
+ def add_guest_expiration_view_helper
256
+ controller_path =
257
+ "app/controllers/application_controller.rb"
258
+
259
+ full_controller_path = File.join(
260
+ destination_root,
261
+ controller_path
262
+ )
263
+
264
+ controller = File.read(full_controller_path)
265
+
266
+ return if controller.include?(
267
+ "helper_method :guest_expiring_soon?"
268
+ )
269
+
270
+ helper_code = <<~RUBY
271
+
272
+ helper_method :guest_expiring_soon?
273
+
274
+ def guest_expiring_soon?
275
+ #{current_authentication_method}&.guest_expiring_soon? || false
276
+ end
277
+ RUBY
278
+
279
+ class_end_position = controller.rindex(/^end\s*$/)
280
+
281
+ unless class_end_position
282
+ raise Thor::Error,
283
+ "Could not find the end of ApplicationController."
284
+ end
285
+
286
+ controller.insert(
287
+ class_end_position,
288
+ helper_code
289
+ )
290
+
291
+ File.write(
292
+ full_controller_path,
293
+ controller
294
+ )
295
+ end
296
+
297
+ def create_guest_cleanup_task
298
+ template(
299
+ "b4um_guests.rake.tt",
300
+ "lib/tasks/b4um_guests.rake"
301
+ )
302
+ end
303
+
304
+ def create_guest_expiration_warning
305
+ template(
306
+ "guest_expiration_warning.html.erb.tt",
307
+ "app/views/shared/_guest_expiration_warning.html.erb"
308
+ )
309
+ end
310
+
311
+ def add_guest_expiration_warning_to_layout
312
+ layout_path =
313
+ "app/views/layouts/application.html.erb"
314
+
315
+ full_layout_path = File.join(
316
+ destination_root,
317
+ layout_path
318
+ )
319
+
320
+ return unless File.exist?(full_layout_path)
321
+
322
+ layout = File.read(full_layout_path)
323
+
324
+ warning_render =
325
+ '<%= render "shared/guest_expiration_warning" %>'
326
+
327
+ return if layout.include?(warning_render)
328
+
329
+ flash_render =
330
+ '<%= render "shared/flash" %>'
331
+
332
+ return unless layout.include?(flash_render)
333
+
334
+ layout.sub!(
335
+ flash_render,
336
+ "#{flash_render}\n #{warning_render}"
337
+ )
338
+
339
+ File.write(
340
+ full_layout_path,
341
+ layout
342
+ )
343
+ end
344
+
345
+ def create_guests_controller
346
+ template(
347
+ "guests_controller.rb.tt",
348
+ "app/controllers/guests_controller.rb"
349
+ )
350
+ end
351
+
352
+ def add_guest_routes
353
+ routes_path = File.join(
354
+ destination_root,
355
+ "config/routes.rb"
356
+ )
357
+
358
+ routes = File.read(routes_path)
359
+
360
+ guest_routes = [
361
+ 'get "/guest", to: "guests#new", as: :guest',
362
+ 'post "/guest", to: "guests#create"',
363
+ 'delete "/guest", to: "guests#destroy"',
364
+ 'get "/guest/convert", to: "guests#edit", as: :convert_guest',
365
+ 'patch "/guest/convert", to: "guests#update"'
366
+ ]
367
+
368
+ missing_routes = guest_routes.reject do |route|
369
+ routes.include?(route)
370
+ end
371
+
372
+ return if missing_routes.empty?
373
+
374
+ route_block_end = routes.rindex(/^end\s*$/)
375
+
376
+ unless route_block_end
377
+ raise Thor::Error,
378
+ "Could not find the routes block in config/routes.rb."
379
+ end
380
+
381
+ routes.insert(
382
+ route_block_end,
383
+ "#{missing_routes.join("\n")}\n"
384
+ )
385
+
386
+ File.write(
387
+ routes_path,
388
+ routes
389
+ )
390
+ end
391
+
392
+ def create_guest_form
393
+ template(
394
+ "new.html.erb.tt",
395
+ "app/views/guests/new.html.erb"
396
+ )
397
+ end
398
+
399
+ def create_guest_conversion_form
400
+ template(
401
+ "edit.html.erb.tt",
402
+ "app/views/guests/edit.html.erb"
403
+ )
404
+ end
405
+
406
+ private
407
+
408
+ def validate_authentication_model
409
+ model_path = File.join(
410
+ destination_root,
411
+ "app/models",
412
+ "#{authentication_model_name}.rb"
413
+ )
414
+
415
+ return if File.exist?(model_path)
416
+
417
+ raise Thor::Error,
418
+ "Authentication model #{authentication_class_name} was not found. " \
419
+ "Expected: app/models/#{authentication_model_name}.rb"
420
+ end
421
+
422
+ def validate_authentication_setup
423
+ application_controller_path = File.join(
424
+ destination_root,
425
+ "app/controllers/application_controller.rb"
426
+ )
427
+
428
+ authentication_helper =
429
+ "helper_method :#{current_authentication_method}, :logged_in?"
430
+
431
+ authentication_installed =
432
+ File.exist?(application_controller_path) &&
433
+ File.read(application_controller_path).include?(authentication_helper)
434
+
435
+ unless authentication_installed
436
+ raise Thor::Error,
437
+ "b4um authentication is not installed for " \
438
+ "#{authentication_class_name}. " \
439
+ "Run: bin/rails generate b4um:authentication " \
440
+ "#{authentication_class_name}"
441
+ end
442
+
443
+ application_controller =
444
+ File.read(application_controller_path)
445
+
446
+ current_user_assignment =
447
+ "@#{current_authentication_method} ||= " \
448
+ "#{authentication_class_name}.find_by(" \
449
+ "id: session[:#{authentication_model_name}_id])"
450
+
451
+ guest_aware_current_user =
452
+ "if #{authentication_model_name}&.guest_expired?"
453
+
454
+ unless application_controller.include?(current_user_assignment) ||
455
+ application_controller.include?(guest_aware_current_user)
456
+ raise Thor::Error,
457
+ "Could not find the current authentication method " \
458
+ "in app/controllers/application_controller.rb"
459
+ end
460
+
461
+ sessions_controller_path = File.join(
462
+ destination_root,
463
+ "app/controllers/sessions_controller.rb"
464
+ )
465
+
466
+ unless File.exist?(sessions_controller_path)
467
+ raise Thor::Error,
468
+ "SessionsController not found: " \
469
+ "app/controllers/sessions_controller.rb"
470
+ end
471
+
472
+ sessions_controller = File.read(sessions_controller_path)
473
+
474
+ session_assignment =
475
+ "session[:#{authentication_model_name}_id] = " \
476
+ "#{authentication_model_name}.id"
477
+
478
+ return if sessions_controller.include?(session_assignment)
479
+
480
+ raise Thor::Error,
481
+ "Could not find the authentication session assignment " \
482
+ "in app/controllers/sessions_controller.rb"
483
+ end
484
+
485
+ def validate_required_fields
486
+ required_fields = options[:required]
487
+ .to_s
488
+ .split(",")
489
+ .map(&:strip)
490
+ .reject(&:empty?)
491
+
492
+ if required_fields.empty?
493
+ raise Thor::Error,
494
+ "At least one required guest field must be specified."
495
+ end
496
+
497
+ unknown_fields = required_fields - guest_model_fields
498
+
499
+ unless unknown_fields.empty?
500
+ raise Thor::Error,
501
+ "Unknown required guest field: #{unknown_fields.first}"
502
+ end
503
+
504
+ invalid_fields = required_fields - guest_available_fields
505
+
506
+ return if invalid_fields.empty?
507
+
508
+ raise Thor::Error,
509
+ "Invalid required guest field: #{invalid_fields.first}"
510
+ end
511
+
512
+ def validate_expiration
513
+ return if options[:expires_in].to_i >= 1
514
+
515
+ raise Thor::Error,
516
+ "Guest expiration must be at least one day."
517
+ end
518
+
519
+ def guest_fields_exist?
520
+ fields = guest_model_fields
521
+
522
+ fields.include?("guest") &&
523
+ fields.include?("guest_expires_at")
524
+ end
525
+
526
+ def guest_model_fields
527
+ schema_path = File.join(
528
+ destination_root,
529
+ "db/schema.rb"
530
+ )
531
+
532
+ return [] unless File.exist?(schema_path)
533
+
534
+ schema = File.read(schema_path)
535
+ table_name = authentication_model_name.pluralize
536
+
537
+ table_match = schema.match(
538
+ /create_table "#{Regexp.escape(table_name)}".*?do \|t\|(.*?)^\s*end/m
539
+ )
540
+
541
+ return [] unless table_match
542
+
543
+ table_match[1].scan(
544
+ /^\s*t\.\w+\s+"([^"]+)"/
545
+ ).flatten
546
+ end
547
+
548
+ def guest_available_fields
549
+ excluded_fields = %w[
550
+ id
551
+ admin
552
+ blocked
553
+ guest
554
+ guest_expires_at
555
+ created_at
556
+ updated_at
557
+ ]
558
+
559
+ sensitive_field_pattern = /(?:password|token|secret|digest)/i
560
+
561
+ guest_model_fields.reject do |field|
562
+ excluded_fields.include?(field) ||
563
+ field.match?(sensitive_field_pattern)
564
+ end
565
+ end
566
+
567
+ def guest_required_fields
568
+ options[:required]
569
+ .to_s
570
+ .split(",")
571
+ .map(&:strip)
572
+ .reject(&:empty?)
573
+ end
574
+
575
+ def account_required_fields
576
+ model_path = File.join(
577
+ destination_root,
578
+ "app/models",
579
+ "#{authentication_model_name}.rb"
580
+ )
581
+
582
+ model = File.read(model_path)
583
+
584
+ model
585
+ .scan(
586
+ /validates\s+((?::[a-zA-Z_]\w*\s*,?\s*)+),\s*presence:\s+true/
587
+ )
588
+ .flat_map do |match|
589
+ match.first.scan(/:([a-zA-Z_]\w*)/).flatten
590
+ end
591
+ .uniq
592
+ end
593
+
594
+ def guest_field_type(field)
595
+ schema_path = File.join(
596
+ destination_root,
597
+ "db/schema.rb"
598
+ )
599
+
600
+ schema = File.read(schema_path)
601
+ table_name = authentication_model_name.pluralize
602
+
603
+ table_match = schema.match(
604
+ /create_table "#{Regexp.escape(table_name)}".*?do \|t\|(.*?)^\s*end/m
605
+ )
606
+
607
+ return :string unless table_match
608
+
609
+ field_match = table_match[1].match(
610
+ /^\s*t\.(\w+)\s+"#{Regexp.escape(field)}"/
611
+ )
612
+
613
+ return :string unless field_match
614
+
615
+ field_match[1].to_sym
616
+ end
617
+
618
+ def authentication_model_name
619
+ name.underscore
620
+ end
621
+
622
+ def authentication_class_name
623
+ name.camelize
624
+ end
625
+
626
+ def current_authentication_method
627
+ "current_#{authentication_model_name}"
628
+ end
629
+
630
+ def guest_aware_authentication_method(include_blocked_check:)
631
+ lines = [
632
+ "def #{current_authentication_method}",
633
+ " return @#{current_authentication_method} " \
634
+ "if defined?(@#{current_authentication_method})",
635
+ "",
636
+ " #{authentication_model_name} = " \
637
+ "#{authentication_class_name}.find_by(",
638
+ " id: session[:#{authentication_model_name}_id]",
639
+ " )",
640
+ "",
641
+ " if #{authentication_model_name}&.guest_expired?",
642
+ " session.delete(:#{authentication_model_name}_id)",
643
+ " #{authentication_model_name}.destroy!",
644
+ " @#{current_authentication_method} = nil",
645
+ " return nil",
646
+ " end"
647
+ ]
648
+
649
+ if include_blocked_check
650
+ lines.push(
651
+ "",
652
+ " if #{authentication_model_name}&.blocked?",
653
+ " session.delete(:#{authentication_model_name}_id)",
654
+ " @#{current_authentication_method} = nil",
655
+ " return nil",
656
+ " end"
657
+ )
658
+ end
659
+
660
+ lines.push(
661
+ "",
662
+ " @#{current_authentication_method} = " \
663
+ "#{authentication_model_name}",
664
+ "end"
665
+ )
666
+
667
+ lines
668
+ .map { |line| line.empty? ? line : " #{line}" }
669
+ .join("\n")
670
+ end
671
+ end
672
+ end
673
+ end
@@ -0,0 +1,12 @@
1
+ class AddGuestFieldsTo<%= authentication_class_name.pluralize %> < ActiveRecord::Migration[8.0]
2
+ def change
3
+ <% unless guest_model_fields.include?("guest") -%>
4
+ add_column :<%= authentication_model_name.pluralize %>, :guest, :boolean,
5
+ default: false, null: false
6
+ <% end -%>
7
+ <% unless guest_model_fields.include?("guest_expires_at") -%>
8
+ add_column :<%= authentication_model_name.pluralize %>,
9
+ :guest_expires_at, :datetime
10
+ <% end -%>
11
+ end
12
+ end
@@ -0,0 +1,12 @@
1
+ namespace :b4um do
2
+ namespace :guests do
3
+ desc "Delete expired guest accounts"
4
+
5
+ task cleanup: :environment do
6
+ <%= authentication_class_name %>
7
+ .where(guest: true)
8
+ .where("guest_expires_at <= ?", Time.current)
9
+ .destroy_all
10
+ end
11
+ end
12
+ end