The Rails way to validate a web of classes - ruby-on-rails

My app has many interrelationships like:
# Company
has_many :programs
has_many :projects
has_many :users
# Project
has_many :users
has_many :programs
belongs_to :company
# User
belongs_to :project
has_many :programs
belongs_to :company
# Program
belongs_to :project
belongs_to :user
belongs_to :company
Every program must belong to a project and user, BOTH OF WHICH belong to current_user.company.
Approach 1 - controller upon create/update
#program = Program.new(program_params)
#program.company = current_user.company
#allowed_projects = current_user.company.projects
unless #allowed_projects.include? #program.project
raise Exception
end
Approach 2 - model-based validation
before_save :ensure_all_allowed
def ensure_all_allowed
current_user = ???
self.company_id = current_user.company_id
# Then a similar validation to above for self.project_id
end
I feel these are both awkward and not 'the Rails way'.
I assume Approach 2 is the better method because it'll save all this awkward controller code and hold better to the MVC standard.
How can I validate these items correctly?

It's actually somewhat problematic to access current user in a model. It's not impossible, but it requires an around_action that will load the current user in the model class in a thread safe way.
Better would be to assign the current user in the controller
#program.user = current_user
#program.company = #program.user.company
Then do the validation in the model
validate :project_must_be_allowed
def project_must_be_allowed
unless company.projects include project
errors.add(:project, "Project is not valid for company.")
end
end
However, it would be a more normalized setup if you did through relationships
class Company
has_many :users
has_many :projects, through: :users
That way your 'projects' table doesn't need a company_id
You could still do the validation as I described but you'd have to add one method to the model...
def company
user.company
end
or more simply...
delegate :company, to: :user

Since Program has a belongs to relation to both user a and project you can setup some simple validations without worrying about the current_user. This is desirable from a MVC standpoint models should not be aware of the session or the request.
class Program < ActiveRecord::Base
# ...
validates_presence_of :user, :company, :project
# the unless conditions are there avoid the program blowing
# up with nil errors - but the presence validation above covers
# those scenarios
validate :user_must_belong_to_company,
unless: -> { company.nil? || user.nil? }
validate :project_must_belong_to_company,
unless: -> { company.nil? || project.nil? }
def user_must_belong_to_company
unless self.company == self.user.company
errors.add(:user, "must belong to same company as user.")
end
end
def project_must_belong_to_company
unless self.company == self.project.company
errors.add(:company, "must belong to same company as project.")
end
end
end
But I'm thinking that this is just a symtom of some bad relation design choices.
What you probably need is a series of many to many relations - it does not seem very realistic that a project can only have one user or a program either for that part.
class Company
has_many :users
has_many :projects
has_many :assignments, through :projects
has_many :programs, through :projects
end
class User
belongs_to :company
has_many :projects, through: :assignments
end
class Project
has_many :assignments, class_name: 'ProjectAssignment'
has_many :users, through: :assignments
belongs_to :company
end
# you can really call this whatever floats you boat
class ProjectAssignment
belongs_to :user
belongs_to :project
end
class Program
belongs_to :project
has_one :company, through: :project
has_many :assignments, class_name: 'ProgramAssignment'
has_many :users, through: :assignments
end
# you can really call this whatever floats you boat
class ProgramAssignment
belongs_to :user
belongs_to :program
end
That would automatically eliminate the problem with the company since it gets it through a parent relation.
The second problem that a user should not be able to create programs in a project he / she is not a member of sounds like something which should instead be handled on the authorization level - not in a validation.
Pundit example:
class ProgramPolicy < ApplicationPolicy
# ...
def create?
record.project.users.include?(user)
end
end
CanCanCan example:
class Ability
include CanCan::Ability
def initialize(user)
user ||= User.new # guest user (not logged in)
can :create, Program do |p|
p.project.users.include?(user)
end
end
end

Related

Button to connect an association in Rails is not working. Transfer of ownership

I am working my first Rails project, an adoption app and trying to bridge an association to a new potential owner in Rails. My controller action is moving through my adoption_request method, but no changes are being persisted to my join table in ActiveRecord. Can someone please tell me what I am missing here?
The app:
Owners sign up or log in to their account. They can add their Ferret using a form. Later, the Owner may want to create an Opportunity listing to adopt/rehome their animal. People browsing should be able to click on an Opportunity they are interested in, which should establish an association in the join table Opportunity, :adopter_id.
My Models:
class Owner < ApplicationRecord
has_secure_password
has_many :ferrets, dependent: :destroy
has_many :opportunities, dependent: :destroy
has_many :ferret_adoptions, through: :opportunities, source: :ferret
accepts_nested_attributes_for :ferrets, :opportunities
end
class Ferret < ApplicationRecord
belongs_to :owner
has_many :opportunities
has_many :owners, through: :opportunities
end
class Opportunity < ApplicationRecord
belongs_to :ferret
belongs_to :owner
end
In Opportunities Controller, my adoption_request method:
def adoption_request
#owner = Owner.find(session[:owner_id])
#opportunity = Opportunity.find(params[:id])
#opportunity.adopter_id = [] << current_user.id
current_user.req_id = [] << #opportunity.id
flash[:message] = "Adoption request submitted."
redirect_to questions_path
end
I am using a button to do this, but I am open to change that if something may work better:
<button><%= link_to 'Adoption Request', adoption_request_path, method: :post %> <i class='fas fa-heart' style='color:crimson'></i></button>
As an Owner when I click the button to make an Adoption Request, I am seeing all the working parts in byebug, and I am being redirected to the next page with the success message as if everything worked, but there is no Association actually being persisted to the database.
I appreciate any feedback you can offer.
I'm assuming here that Opportunity should represent something like a listing (it needs a less vague name).
If so you're missing a model and its table if ever want more then one user to be able to respond to an Opportunity:
class Owner < ApplicationRecord
has_secure_password
has_many :ferrets, dependent: :destroy
has_many :opportunities, dependent: :destroy
has_many :adoption_requests_as_adopter,
foreign_key: :adopter_id,
class_name: 'AdoptionRequest'
has_many :adoption_requests_as_owner,
through: :opportunities,
source: :adoption_requests
accepts_nested_attributes_for :ferrets, :opportunities
end
class Ferret < ApplicationRecord
belongs_to :owner
has_many :opportunities
has_many :owners, through: :opportunities
has_many :adoption_requests, through: :opportunities
end
class Opportunity < ApplicationRecord
belongs_to :ferret
belongs_to :owner
has_many :adoption_requests
end
class AdoptionRequest < ApplicationRecord
belongs_to :adopter, class_name: 'Owner' # ???
belongs_to :opportunity
has_one :ferret, through: :opportunity
has_one :owner, through: :opportunity
end
If you just have a adopter_id on your opportunities table it can only ever hold a single value.
I would just set the route / controller up as a normal CRUD controller for a nested resource:
# routes.rb
resources :opportunities do
resources :adoption_requests, only: [:create, :index]
end
<%= button_to "Adopt this ferret", opportunity_adoption_requests_path(#opportunity), method: :post %>
class AdoptionRequestsController < ApplicationController
before_action :set_opportunity
# #todo authorize so that it can only be viewed by the owner
# GET /opportunities/1/adoption_requests
def index
#adoption_requests = #opportunity.adoption_requests
end
# #todo authorize so that a current owner can't create adoption_requests
# for their own ferrets
# POST /opportunities/1/adoption_requests
def create
#adoption_request = #opportunity.adoption_requests.new(
adopter: current_user
)
if #adoption_request.save
redirect_to #opportunity, notice: 'Thank you for your reply! The owner of the ferret will be notified.'
# #todo send notification to owner
else
redirect_to #opportunity, notice: 'Oh noes!'
end
end
private
def set_opportunity
#opportunity = Opportunity.find(params[:opportunity_id])
end
end
Its only later when the owner actually accepts a adoption_request that you will actually update the opportunity and this is a seperate question for a later time.

Convinient way to implement user roles in my app

Thanks for reading!
I'm currently working on my new app and searching for the best way to implement next feature
By scenario I need to implement "As a user a have role in the location"
WHAT I HAVE DONE:
Scenario:
When user adds new location to the profile
one of the requred fields is "role". That could be "guest", "manager" or "seller". What's the best way to accomplish his in the model side?
I accomplished this with has_many_through assosiation
CONTROLLER:
def create
#location = Location.new(location_params)
#location.profiles << current_user.profile
#set user role
current_user.profile.profile_location_throughs.where(location_id: location.id).set_role(params[:location][:role])
respond_to do |format|
if #location.save
....
end
end
end
MODELS:
class Profile < ActiveRecord::Base do
has_many :profile_location_throughs
has_many :locations, through: :profile_location_throughs
end
class Location < ActiveRecord::Base do
has_many :profile_location_throughs
has_many :locations, through: :profile_location_throughs
end
class ProfileLocationThrough < ActiveRecord::Base
# with boolean fields: manager, seller, guest
belongs_to :location
belongs_to :profile
def set_role(role)
case role
when "guest"
self.guest = true
when "seller"
self.seller = true
when "manager"
self.manager = true
end
end
end
=====
QUESTION:
Could you suggest more beatiful way to implement his feature?
There are several ways to do role based authorization.
The simplest way is by adding a enum to the users themselves:
class Profile < ApplicationRecord
enum role: [:guest, :seller, :manager]
end
This is pretty limited though as it only allows "global" roles.
If you want resource scoped roles you need a join table.
class Profile < ApplicationRecord
has_many :roles
has_many :locations, through: :roles
def has_role?(role, location = nil)
self.roles.exists?( { name: role, location: location}.compact )
end
def add_role(role, location)
self.roles.create!( { name: role, location: location } )
end
end
class Role < ApplicationRecord
belongs_to :profile
belongs_to :location
end
class Location < ApplicationRecord
has_many :roles
has_many :profiles, through: :roles
end
In this example we are simply using a string for the roles.name column. You could also use an enum if the kinds of roles are limited. If you want to use the same Role model (no pun intended) to scope roles on different kinds of resources you can use a polymorphic belongs_to relationship.
class Role < ApplicationRecord
belongs_to :profile
belongs_to :resource, polymorphic: true
end
class Location < ApplicationRecord
has_many :roles, as: :resource
has_many :profiles, through: :roles
end
class OtherThing < ApplicationRecord
has_many :roles, as: :resource
has_many :profiles, through: :roles
end
Note that roles are just one part of an authentication solution. You would combine this with a authorization lib such as Pundit or CanCanCan which defines the rules about what role gets to do what and enforces those rules.
Rolify - Role management library with resource scoping

ActiveRecord polymorphic association with unique constraint

I have a site that allows users to log in via multiple services (LinkedIn, Email, Twitter, etc..).
I have the below structure set up to model a User and their multiple identities. Basically a user can have multiple identieis, but only one of a given type (e.g. can't have 2 Twitter identiteis).
I decided to set it up as a polymorphic relationship, as drawn below. Basically there's a middle table identities that maps a User entry to multiple *_identity tables.
The associations are as follows (shown only for LinkedInIdentity, but can be extrapolated)
# /app/models/user.rb
class User < ActiveRecord::Base
has_many :identities
has_one :linkedin_identity, through: :identity, source: :identity, source_type: "LinkedinIdentity"
...
end
# /app/models/identity
class Identity < ActiveRecord::Base
belongs_to :user
belongs_to :identity, polymorphic: true
...
end
# /app/models/linkedin_identity.rb
class LinkedinIdentity < ActiveRecord::Base
has_one :identity, as: :identity
has_one :user, through: :identity
...
end
The problem I'm running into is with the User model. Since it can have multiple identities, I use has_many :identities. However, for a given identity type (e.g. LinkedIn), I used has_one :linkedin_identity ....
The problem is that the has_one statement is through: :identity, and there's no singular association called :identity. There's only a plural :identities
> User.first.linkedin_identity
ActiveRecord::HasManyThroughAssociationNotFoundError: Could not find the association :identity in model User
Any way around this?
I would do it like so - i've changed the relationship name between Identity and the others to external_identity, since saying identity.identity is just confusing, especially when you don't get an Identity record back. I'd also put a uniqueness validation on Identity, which will prevent the creation of a second identity of the same type for any user.
class User < ActiveRecord::Base
has_many :identities
has_one :linkedin_identity, through: :identity, source: :identity, source_type: "LinkedinIdentity"
end
# /app/models/identity
class Identity < ActiveRecord::Base
#fields: user_id, external_identity_id
belongs_to :user
belongs_to :external_identity, polymorphic: true
validates_uniqueness_of :external_identity_type, :scope => :user_id
...
end
# /app/models/linkedin_identity.rb
class LinkedinIdentity < ActiveRecord::Base
# Force the table name to be singular
self.table_name = "linkedin_identity"
has_one :identity
has_one :user, through: :identity
...
end
EDIT - rather than make the association for linkedin_identity, you could always just have a getter and setter method.
#User
def linkedin_identity
(identity = self.identities.where(external_identity_type: "LinkedinIdentity").includes(:external_identity)) && identity.external_identity
end
def linkedin_identity_id
(li = self.linkedin_identity) && li.id
end
def linkedin_identity=(linkedin_identity)
self.identities.build(external_identity: linkedin_identity)
end
def linkedin_identity_id=(li_id)
self.identities.build(external_identity_id: li_id)
end
EDIT2 - refactored the above to be more form-friendly: you can use the linkedin_identity_id= method as a "virtual attribute", eg if you have a form field like "user[linkedin_identity_id]", with the id of a LinkedinIdentity, you can then do #user.update_attributes(params[:user]) in the controller in the usual way.
Here is an idea that has worked wonderfully over here for such as case. (My case is a tad diffferent since all identites are in the same table, subclasses of the same base type).
class EmailIdentity < ActiveRecord::Base
def self.unique_for_user
false
end
def self.to_relation
'emails'
end
end
class LinkedinIdentity < ActiveRecord::Base
def self.unique_for_user
true
end
def self.to_relation
'linkedin'
end
end
class User < ActiveRecord::Base
has_many :identities do
[LinkedinIdentity EmailIdentity].each do |klass|
define_method klass.to_relation do
res = proxy_association.select{ |identity| identity.is_a? klass }
res = res.first if klass.unique_for_user
res
end
end
end
end
You can then
#user.identities.emails
#user.identities.linkedin

has_one association with chaining include

We have a Company, CompanyUser, User and Rating model defined like this:
Company model
class Company < ActiveRecord::Base
has_many :company_users
has_many :users, through: :company_users
has_one :company_owner, where(is_owner: true), class_name: 'CompanyUser', foreign_key: :user_id
has_one :owner, through: :company_owner
end
There is an is_owner flag in the company_users table to identify the owner of the company.
CompanyUser model
class CompanyUser < ActiveRecord::Base
belongs_to :company
belongs_to :user
belongs_to :owner, class_name: 'User', foreign_key: :user_id
end
User model
class User < ActiveRecord::Base
has_many :company_users
has_many :companies, through: :company_users
has_many :ratings
end
Rating model
class Rating
belongs_to :user
belongs_to :job
end
I am able to find the owner of a company, by the following code:
#owner = #company.owner
I need to get the ratings and the jobs of the owner along with the owner. I can do this
#owner = #company.owner
#ratings = #owner.ratings.includes(:job)
But we have already used #owner.ratings at many places in the view, and it is difficult to change all the references in the views as it is a pretty big view spanning in several partials. I tried the following to get the ratings along with the owner
#owner = #company.owner.includes(:ratings => :job)
But this gives me error as #company.owner seems to give a User object and it does not seem to support chaining.
Is there a way I can get the included associations (ratings and job) inside the #owner object?
You should be able to do this with:
#owner = Company.where(id: #company.id).includes(owner: {ratings: :job}).owner
However this is not very clean. Much better would be to actually change #company variable:
#company = Company.includes(owner: {ratings: :job}).find(params[:company_id]) # or params id or any other call you're currently using to get the company.
Company built that way will already have everything included, so:
#owner = #company.owner
will pass a model with preloaded associations.

Associations, Joins and Scopes

I have the following setup:
class Program
has_many :participants
end
class Participant
belongs_to :user
end
class User
has_many :participants
end
I want class method or scope to return all the programs in which a certain user participates. Here's what I have so far:
def self.where_user_participates(user)
Program.joins(:participants).where('participants.user_id' => user.id)
end
I believe that works but I am not in love with it. I prefer not to talk about 'id's but use the associations, but I could not get it to work, e.g.:
def self.where_user_participates(user)
Program.joins(:participants).where('participants.user' => user)
end
How can I improve this? And is it true that official 'scope's are not needed and a class method is 'best practice' in Rails 3?
class Program
has_many :participants
end
class Participant
belongs_to :user
belongs_to :program
end
class User
has_many :participants
has_many :programs, :through => :participants
end
Then to get the programs call:
user.programs

Resources