Building Mortimer, day 6
We left the project last night in a state of alarm, full of questions - was I going down with a bug, would the world rise to a new day, how about the (latest) would-have-been assassination attempts on the presidential kandidat DJT, more... Time to reflect, and to look forward.
Day 6
With the essential use case checked off I can focus on stuff like
as an admin user I will manage users
This use case leaves amble space for interpretations but a list of current users and some way to invite new ones, and to change the role of a user - I guess that will cover most of the bases.
rails g controller Users
Devise affords us the luxury of signing users up/in/out/more but not any management - on purpose. So I added a controller to do just that, and added a resource in the config/routes.rb
for handling the 7R4 ( 7 Resource Actions ).
Special sauce
15+ years ago, a guy named Ian White, teached a ways to handle resources that really resonated in me hence the following "deroute" – programmer use case if you will:
as a programmer I'd like to not have to repeat myself too much - please DRY the source
Adding a controller in between ApplicationController and the actual resource controller eg UsersController will allow you to abstract away all the 7R4. Mine is called MortimerController and eg the index method will add a few important variables like @resources. I like to squeeze in one more controller, BaseController, where I point to the layout and include things like Authentication and pagination.
Authentication is a module that basically calls Devise authenticate_user!
It does one more thing: opens the heretic Current
object and assigns the current_user to Current.user.
Pagination is a somewhat different kind of animal! I'll comment this out for now and move on – but rest assure: we will get back to this one!
class MortimerController < BaseController
#
# defined in the resourceable concern
# mentioned here for explicitness
#
before_action :set_resource, only: %i[ new show edit update destroy ]
before_action :set_filter, only: %i[ index destroy ]
before_action :set_resources, only: %i[ index destroy ]
before_action :set_resources_stream
include Resourceable
include DefaultActions
include TimezoneLocale
end
Another "time saver" is the addition of a folder named views/application
where I put common view elements like index.html.erb
SSL
I shamelessly copied the good work by Jason Fleetwood-Boldt and so there is nothing to say but - read here:
TimezoneLocale
The trick is to insert a Rails special once again - it is called around_action like this:
module TimezoneLocale
extend ActiveSupport::Concern
#
# about handling date and timezone
# https://nandovieira.com/working-with-dates-on-ruby-on-rails
#
included do
around_action :switch_locale
around_action :user_time_zone if Current.user || Current.tenant
end
private
#
# switch locale to user preferred language - or by params[:locale]
# https://phrase.com/blog/posts/rails-i18n-best-practices/
#
def switch_locale(&action)
locale = extract_locale_from_tld || I18n.default_locale
locale = params.permit![:lang] || locale
locale = params.permit![:locale] || locale
# locale = current_user.preferred_locale if current_user rescue locale
parsed_locale = get_locale_from_user_or_account || locale
I18n.with_locale(parsed_locale, &action)
end
# Get locale from top-level domain (put)
#
# 127.0.0.1 application.com
# 127.0.0.1 application.it
# 127.0.0.1 application.pl
#
# in your /etc/hosts file to try this out locally
#
def extract_locale_from_tld
parsed_locale = request.host.split(".").last
I18n.available_locales.map(&:to_s).include?(parsed_locale) ?
parsed_locale :
nil
end
def get_locale_from_user_or_account
Current.user&.locale ||
Current.tenant&.locale
end
#
# make sure we use the timezone (of the current_user)
#
def user_time_zone(&block)
timezone = Current.user.time_zone ||
Current.tenant&.time_zone rescue nil
timezone.blank? ?
Time.use_zone("Europe/Copenhagen", &block) :
Time.use_zone(timezone, &block)
end
end
TenantHandling
All queries will need either a default_scope or some other way of getting scoped to the tenant in question. I went with this:
module TenantHandling
extend ActiveSupport::Concern
included do
belongs_to :tenant
scope :by_tenant, ->(acc = nil) {
return where(tenant: acc) unless acc.nil?
if Current.user.present?
case Current.user.role
when "superadmin"
Current.user.global_queries? ?
all :
where(tenant: Current.tenant)
when "admin", "user"
where(tenant: Current.tenant)
end
else
if Current.tenant.present?
where(tenant: Current.tenant)
else
all
end
end
}
end
end
UserRegistrationService
In other applications I've enjoyed moving parts of the controller into a service object to "clean" up the controller – it's not necessarily a good thing to "hide" code but if you might be calling this task from various places, it could be valuable to not duplicate code.
class UserRegistrationService
def self.call(user)
if user.persisted?
user.update locale: "da", time_zone: "Europe/Copenhagen"
UserMailer.with(user: user).welcome.deliver_later
Team.create tenant: user.tenant,
name: I18n.t("teams.default_team"),
locale: user.locale,
time_zone: user.time_zone
end
end
end
Compressing Migrations
I could probably compress it into a single migration but now it's in 5-6 – but what I noted of interest is that in the process I had to redo the work numerous times because I was not 100% in the know of how the moving pieces fit together.
You will do rails db:drop
followed by rm db/schema.rb
and the move the migration file contents into fewer files. Follow that with rails db:create
and then rails db:prepare
will pull in any db/seeds.rb
too.
Seeds
Seeding is the process of preparing the app when initially getting installed. I didn't go overboard in prepping - but just a few records would make it easier to get started no matter where the app would get deployed:
tenant = Tenant.find_or_create_by!
name: "Mortimer",
locale: "en",
time_zone: "UTC",
account_color: "bg-blue-200",
tax_number: "N/A"
team = Team.find_or_create_by!
tenant: tenant,
name: "Mortimer",
email: "info@mortimer.pro",
team_color: "bg-blue-200",
locale: "en",
time_zone: "UTC",
active: true
user = User.new(
email: 'john@doe.com',
tenant: tenant,
team: team,
role: 0,
password: 'john!doe',
password_confirmation: 'john!doe'
)
user.skip_confirmation!
user.save!
Phew 😅 that was indeed quite a lot of work (most of it was mostly copy/pasta though as I was 'transposing' from vol 3) and I didn't get one inch closer to managing users, unfortunately. Well, I spent some time setting up the scaffold for easier adding the admin part of the application, and that'll have to count for something too; and HTTPS, yay 😆 – tagging it as 0.5.0_ssl