Canard gem - how should I programatically make an admin user? - ruby-on-rails

I'm using the Canard gem (CanCan + RoleModel) for authentication on Ruby on Rails, but I'm having some issues with making an initial user admin. An Account has Users. When an Account is created, there's an Owner User who has billing details. I'd like that Owner to have the :admin role.
class SomewhereElseInSystem
...
account = Account.register(hash) # Company Admin submits form with
# details of account, owning user,
# and a load more objects we don't
# show here.
end
class Account < ActiveRecord::Base
has_many :users, dependent: :destroy, include: [:profile]
has_one :owner, class_name: 'User', conditions: { owner: true }
accepts_nested_attributes_for :owner
def self.register(details)
...
hash = { name: details[:client_name],
domain: details[:subdomain],
plan: details[:plan],
owner_attributes: {
name: details[:owner_name],
password: details[:owner_password],
password_confirmation: details[:owner_password],
email: details[:owner_email] } }
account = Account.create hash
...
end
end
class User < ActiveRecord::Base
belongs_to :account
acts_as_user roles: [:saas_admin, :admin, :manager]
# owner is a boolean DB field
before_validation :owners_are_admins
protected
def owners_are_admins
return unless owner
self.roles << :admin
end
end
However, I'm finding that when I make an account the associated owner is not an admin, and has no roles set. I've tried several variations on the above and am convinced that I'm missing something basic. Help?
Update:
If I go into the console, I can have great fun seeing 4 non-admins instead of 1 admin and 3 non-admins via the scopes, but a different answer via admin? and the actual fields.
[46] subscriptions » account.users.count
(0.5ms) SELECT COUNT(*) FROM "users" WHERE "users"."account_id" = 1
=> 4
[47] subscriptions » account.users.admins.count
(0.4ms) SELECT COUNT(*) FROM "users" WHERE "users"."account_id" = 1 AND ("users"."roles_mask" & 2 > 0)
[48] subscriptions » account.users.non_admins.count
[48] subscriptions » account.users.non_admins.count
(0.6ms) SELECT COUNT(*) FROM "users" WHERE "users"."account_id" = 1 AND ("users"."roles_mask" & 2 = 0 or "users"."roles_mask" is null)
=> 4
[49] subscriptions » account.users.map(&:admin?)
=> [
[0] false,
[1] false,
[2] false,
[3] true
]
[51] subscriptions » account.users.select{ |user| user.roles_mask & 2 > 0 }.count 0;34# Same as query above
=> 1
But better yet, if I do
account.users.admins # 0
owner = account.owner
owner.roles << :admin # Should be a no-op
owner.save!
account.user.admins # 1!
Clearly my callback isn't doing the equivalent of the << in the console after all. Which I don't understand.

Related

Rails custom validation to ensure a Store always has an Owner

Logically, I'm trying to demote the a Member role from Owner to User. Expected behaviour is to produce an error "Store must have at least one owner".
It's use-case is to support the promotion of other members roles to Owner permissions, but never leave the store without at least one owner. I thought to add a validation to the Member for that, but it appears that isn't sufficient as it let's an Owner get demoted to a User (leaving the store without an Owner). Moreover, a new Owner cannot be set after since store_has_owner will return false.
class Admin::MembersController < Admin::BaseController
def make_user
member = current_company.members.find(params[:id])
if member.update(role: "user")
redirect_to admin_dashboard_path, notice: "#{member.user.email} is now a user."
else
redirect_to admin_dashboard_path, alert: member.errors
end
end
end
Member Exists? (0.5ms) SELECT 1 AS one FROM "members" WHERE "members"."store_id" = $1 AND "members"."role" = $2 LIMIT $3 [["store_id", 1], ["role", 0], ["LIMIT", 1]]
15:15:12 web.1 | ↳ app/models/member.rb:21:in `store_has_owner'
15:15:12 web.1 | Member Update (0.4ms) UPDATE "members" SET "role" = $1, "updated_at" = $2 WHERE "members"."id" = $3 [["role", 1], ["updated_at", "2019-08-26 19:15:12.965431"], ["id", 1]]
15:15:12 web.1 | ↳ app/controllers/admin/members_controller.rb:39:in `make_user'
15:15:12 web.1 | (0.6ms) COMMIT
15:15:12 web.1 | ↳ app/controllers/admin/members_controller.rb:39:in `make_user'
15:15:12 web.1 | Redirected to http://lvh.me:5000/admin/dashboard
15:15:12 web.1 | Completed 302 Found in 24ms (ActiveRecord: 3.8ms | Allocations: 9037)
Store
class Store < ApplicationRecord
has_many :members
has_many :users, through: :members
end
Member
class Member < ApplicationRecord
belongs_to :store
belongs_to :user
enum role: [ :owner, :user ]
validate :store_has_owner
def owner?
self.role == "owner"
end
def store_has_owner
errors.add(:member, "Store must have at least one owner.") unless store.members.owner.exists?
end
end
User
class User < ApplicationRecord
# Automatically remove the associated `members` join records
has_many :members, dependent: :destroy
has_many :stores, through: :members
end
unless clause should be
store.members.any? { |m| (m == self ? self : m).owner? }
There we're finding any member (m) in the members collection which owner? method returns true, i.e. Store has at least one Owner.
There is one caveat though. Accessing store.members causes selecting this relation from database and, because changed role of self is uncommitted yet, it returns that self is an Owner and this check don't work as expected. To avoid this we substitute m with self when they are equal, so m.owner? became self.owner? and returns false as expected.
Here are a few solutions I can think of:
1) This validation does not belongs to the member, but to the store. The store cannot be valid without an owner. Move it to the store and then add validates_associated :store to user.rb.
#store.rb
validate :has_owner
def has_owner
errors.add(:base, "Store must have at least one owner.") unless members.owner.any?
end
This should take care of the problem and is cleaner. Another solution follows. A bit dirtier but...
2) You can deal with this in the workflow of reducing a Member from Owner to User, by making sure there is another owner other than the member you are demoting. So:
# admin/members_controller.rb
def make_user
member = current_company.members.find(params[:id])
if member.update(role: "user") && current_company.has_other_owner_than(member)?
redirect_to admin_dashboard_path, notice: "#{member.user.email} is now a user."
else
redirect_to admin_dashboard_path, alert: member.errors
end
end
# store.rb
def has_other_owner_than?(member)
members.where(role: :owner).not(id: member.id).any? # Rails 5
# or
# members.where("role = ? AND id != ?", Member.roles[:owner], member.id).any? # Rails 4.2
end

Rails Join based on multiple models

I have four models in question: User, Product, Purchase, and WatchedProduct.
I am trying to get a list of products that meet one of the following criteria:
I created the product.
I bought the product.
The product is free, and I have "starred" or "watched" it.
This is what I have so far:
class User < ActiveRecord::Base
...
def special_products
product_ids = []
# products I created
my_products = Product.where(:user_id => self.id).pluck(:id)
# products I purchased
my_purchases = Purchase.where(:buyer_id => self.id).pluck(:product_id)
# free products I watched
my_watched = WatchedProduct.where(:user_id =>self.id).joins(:product).where(products: { price: 0 }).pluck(:product_id)
product_ids.append(my_products)
product_ids.append(my_purchases)
product_ids.append(my_watched)
product_ids # yields something like this => [[1, 2], [], [2]]
# i guess at this point i'd reduce the ids, then look them up again...
product_ids.flatten!
product_ids & product_ids
products = []
product_ids.each do |id|
products.append(Product.find(id))
end
products
end
end
What I am trying to do is get a list of Product models, not a list of IDs or a list of ActiveRecord Relations. I am very new to joins, but is there a way to do all of this in a single join instead of 3 queries, reduce, and re lookup?
First I like adding few scopes
class Product < ActiveRecord::Base
scope :free, -> { where(price: 0) }
scope :bought_or_created_by, lambda do |user_id|
where('user_id = :id OR buyer_id = :id', id: user_id)
end
end
class WatchedProduct < ActiveRecord::Base
scope :by_user, ->(user_id) { where(user_id: user_id) }
end
Then the queries
special_products = (Product.bought_or_created_by(id) + Product.joins(:watched_products).free.merge(WatchedProduct.by_user(id)).uniq
This will return an array of unique products using 2 queries.
Although i am not sure about your model associations but yes you can do all these things in single query somehow like this:
Product.joins([{:user => :watched_products}, :buyer, :purchases]).where(["users.id = :current_buyer && buyers.id = :current_buyer && watched_products.user_id = :current_buyer && purchases.buyer_id = :current_buyer, products.price = 0", :current_buyer => self.id])
I am assuming
Product belongs_to user and buyer
Product has_many purchases
User has_many watched_products

Boolean field indicating that record has belonging association with given field value

My models:
class CountryVisit < ActiveRecord::Base
belongs_to :country
belongs_to :user
validates :country, uniqueness: { scope: user, message: 'already visited' }
end
class User < ActiveRecord::Base
has_many :country_visits
...
end
class Country < ActiveRecord::Base
has_many :country_visits
...
end
I want each country to have a virtual attribute indicating that it has country_visit for given user
Country.for(user)
Here's my incomplete solution (raises ActiveRecord::StatementInvalid: SQLite3::SQLException if I try to perform .count on result):
def self.for(user_id)
Country
.select("countries.*, country_visits.id AS visited")
.joins(
"LEFT OUTER JOIN country_visits ON
country_visits.country_id = countries.code
AND country_visits.user_id = #{user_id}"
)
end
Expected behavior:
user = User.create
#with country visit
c1 = Country.create
cv1 = CountryVisit.create(country: c1, user: user)
#without country visit
c2 = Country.create
results should be:
countries = Country.for(user_id).order('created_at asc')
countries.first.visited # => true
countries.last.visited # => false
Pretty simple problem here actually. If you look at the full error:
ActiveRecord::StatementInvalid: SQLite3::SQLException: unrecognized token:
"#": SELECT countries.*, country_visits.id AS visited FROM "countries" LEFT
OUTER JOIN country_visits ON country_visits.country_id = countries.id AND
country_visits.user_id = #<User:0x007fcb357a5ed8> ORDER BY created_at asc
Notice #<User:0x007fcb357a5ed8> that appears above? That's because in the for method you're passing the full user object, not the user id. And because you're writing raw SQL, active record isn't smart enough to coerce the AR object into an id.
Easiest way to fix this problem is change your country method to this:
def self.for(user) # <-- user
Country
.select("countries.*, country_visits.id AS visited")
.joins(
"LEFT OUTER JOIN country_visits ON
country_visits.country_id = countries.code
AND country_visits.user_id = #{user.id}" # <-- user.id
)
end

How to find the maximum number of associated records?

I have Accounts and Users. Account has_many :users and User belongs_to :account.
What I'm trying to find out is the maximum number of users any single account has.
So, it would need to cycle through all the accounts, sum up the users for each account and return the user count for each account or, ideally, just the maximum user count it found in all of them.
Running Rails 4.0.12 and Ruby 2.1.5.
You can loop all the accounts, and perform a count, but it is very inefficient. Use a JOIN and COUNT.
result = Account.select('accounts.id, COUNT(users.id)').joins(:users).group('accounts.id')
The result will be
#<ActiveRecord::Relation [#<Account id: 6>, #<Account id: 4>, #<Account id: 5>, #<Account id: 1>, #<Account id: 3>]>
and each item attributes are
{"id"=>1, "count"=>1}
Therefore if you take each result you have
results.each do |result|
result.id
# => the account id
result.count
# => the count of user per account
end
To have all in one hash
results.inject({}) do |hash, result|
hash.merge!(result.id => result.count)
end
Instead of making a query for each account, I would suggest updating the query to do group by your field, and order by the count:
User.group('account_id').order('count_all DESC').limit(1).count
Sure you can do:
Account.all.each_with_object({}) do |account, hash|
hash[account.name] = account.users.count
end
That will return a hash of all accounts with their user totals as their value.
Something like:
=> { "Account1" => 200, "Account2" => 50 }
To sort it, do something like
results = Account.all.each_with_object({}) do |account, hash|
hash[account.name] = account.users.count
end
sorted = results.sort_by { |acc, ct| ct }.reverse
Also you can use :counter_cache
class User < ActiveRecord::Base
belongs_to :account, counter_cache: count_of_users
end
class Account < ActiveRecord::Base
has_many :users
end
http://guides.rubyonrails.org/association_basics.html
class Account < ActiveRecord::Base
..
def self.max_number_of_users
all.map {|a| a.users.count}.max
end
..
end
If you want to the maximum number of records that the association have (so you know how many columns to plan for in say a spreadsheet), you can do this in one line:
Account.select('accounts.id, COUNT(users.id) as num').left_outer_joins(:users).group('accounts.id').map{|x| x[:num]}.max

Declarative Authorization with a permissions join table

Users have permission to manage articles for particular combinations of location and category.
For example Dave may be allowed to manage HR articles for Paris. Paul may be allowed to manage Business articles for London.
The models and their associations are as follows:
user has many permissions
permission belongs to user, location and category
category has many articles and permissions
location has many articles and permissions
article belongs to location and category
I can say:
has_permission_on :articles, :to => :manage do
if_attribute :location => { :permissions => { :user_id => is {user.id} }, :category => { :permissions => { :user_id => is {user.id} } }
end
When I call permitted_to?(:delete) on an article record (whose category id is 1893 and location id is 2939), the following queries are run:
SELECT `categories`.* FROM `categories` WHERE `categories`.`id` = 1893 LIMIT 1
SELECT `permissions`.* FROM `permissions` WHERE (`permissions`.category_id = 1893)
SELECT `locations`.* FROM `locations` WHERE `locations`.`id` = 2939 LIMIT 1
SELECT `permissions`.* FROM `permissions` WHERE (`permissions`.location_id = 2939)
What I need to be run is really:
SELECT `permissions`.* FROM `permissions` WHERE (`permissions`.category_id = 1893 AND `permissions`.location_id = 2939)
Is there any way to achieve this?
Update
Ok, so I now have an instance method in the article model:
def permitted_user_ids
Permission.select('user_id').where(:location_id => location_id, :category_id => category_id).map(&:user_id)
end
and my rule is now:
has_permission_on :articles, :to => :manage do
if_attribute :permitted_user_ids => contains { user.id }
end
Now when I call permitted_to?(:delete) (or read/update/create/manage) on an article record whose category id is 1893 and location id is 2939, the following query is run:
SELECT user_id FROM `permissions` WHERE `permissions`.`category_id` = 1893 AND `permissions`.`location_id` = 2939
...which is exactly what I want.
Except, that the with_permissions_to scope is behaving very oddly.
Article.with_permissions_to(:read)
Now generates:
SELECT `articles`.* FROM `articles` WHERE (`articles`.`id` = 9473)
...where 9473 is the ID of the current user!
I am very confused.
You can use subclasses and manage the permissions with cancan.
You can have a top class Article and then all the kind of articles can be subclasses, and in the subclasses you use cancan to manage their permissions.
This should be a comment, but I'm still at 45 :(
You say permission belongs to user, location and category.
Thus, the permissions table would have user_id, location_id and category_id columns.
If that is true, then why are you declaring that category belongs_to permission and location belongs_to permission? Surely the relation should be has_many?
You should use has_many in the model which DOES NOT have the foreign key(category and location), and belongs_to on the model WHICH DOES (permission).
Forgive me if all this is just a typo you made, and what you really meant was permission belongs_to user and has_many location and category.

Resources