Model person (user) profile attributes / properties - best practice, static vs dynamic properties - ruby-on-rails

I couldn't easily find a relevant topic on SO so here we go - a classic problem: I have a User or Person model and I want to model that person's physical properties/attributes (eye color, hair color, skin color, gender, some lifestyle properties like sleep time per night (<8h, ~8h, >8h), smoking, daily sun exposure etc.
I usually solve that problem by creating a separate rails model (database table) for each property because then it is easy to add more options later, edit them, use as a source for <select>, i.e.
class Person
belongs_to :eye_color
belongs_to :skin_color
belongs_to :hair_color
belongs_to :sleep_per_night
belongs_to :sun_exposure
attr_accessible :gender # boolean, m/f
end
class HairColor
has_many :people
attr_accessible :value
end
class EyeColor
has_many :people
attr_accessible :value
end
Person.last.eye_color
...
EyeColor.first.people
But what if there is a lot of those attributes (i.e. 10-15 different phisycal and lifestyle properties). For me it seems like breaking DRY rule, that is, I'm left with many small tables, like eye_colors, which have 3-5 records. Each of those tables has only one meaningful column - value.
I was thinking how you guys solve those problems, maybe by creating a single model, i.e. PersonProperty that has the following structure
person_properties[type, value]
so the previous solution with separate models, i.e. for eye_color and hair_color would look like this (types/classes and values):
# PersonProperty/person_properties:
1. type: 'HairColor', value: 'red'
2. type: 'HairColor', value: 'blond'
3. type: 'SkinColor', value: 'white'
4. type: 'EyeColor', value: 'green'
5. type: 'HairColor', value: 'black'
6. type: 'SkinColor', value: 'yellow'
7. type: 'SleepPerNight', value: 'less than 8h'
8. type: 'SleepPerNight', value: 'more than 8h'
9. type: 'DailySunExposure', value: 'more than 1h'
...
19. type: 'EyeColor', value: 'blue'
...
The above example might be more normalized by splitting the PersonProperty model into two. Or maybe you suggest something else?

I wouldn't suggest a has_one relationship in this case. A User could belongs_to :eye_color, so you can map eyecolors across your users. An EyeColor has_many :users, so that you can do #eye_color.users and get all users with a specific EyeColor. Otherwise you will have to create an EyeColor for every user (or at least the ones with eyes).
The reason I'd suggest this over your PersonProperty solution is because it's easier to maintain and because of the performance gain of delegating these kinds of relations to your database.
UPDATE: If dynamic attributes are what you want, I'd suggest to setup your models like this:
class Person < ActiveRecord::Base
has_many :person_attributes
attr_accessible :gender
accepts_nested_attributes_for :person_attributes
end
class PersonAttribute < ActiveRecord::Base
belongs_to :person_attribute_type
belongs_to :person_attribute_value
belongs_to :person
attr_accessible :person_id, :person_attribute_value_id
end
class PersonAttributeValue < ActiveRecord::Base
has_many :person_attributes
belongs_to :person_attribute_type
attr_accessible :value, :person_attribute_type_id
end
class PersonAttributeType < ActiveRecord::Base
has_many :person_attribute_values
attr_accessible :name, :type
end
This way you can do the following:
#person_attribute_type = PersonAttributeType.create(:name => 'Eye color', :type => 'string')
['green', 'blue', 'brown'].each do |color|
#person_attribute_type.person_attribute.values.build(:value => color)
end
#person_attribute_type.save
#person = Person.new
#person_attribute = #person.person_attributes.build
#person_attribute.person_attribute_value = #person_attribute_type.person_attribute_values.find(:value => 'green')
Of course, you probably won't fill your database through the command line. You will probably be very curious as to how this would work in a form:
class PersonController
# ...
def new
#person = Person.new
PersonAttributeType.all.each do |type|
#person.person_attributes.build(:person_attribute_type = type)
end
end
def create
#person = Person.new(params[:person])
if #person.save
# ...
else
# ...
end
end
def edit
#person = Person.find(params[:id])
PersonAttributeType.where('id NOT IN (?)', #person.person_attributes.map(&:person_attribute_type_id)).each do |type|
#person.person_attributes.build(:person_attribute_type = type)
end
end
# ...
Now the form, based on Formtastic:
semantic_form_for #person do |f|
f.input :gender, :as => :select, :collection => ['Male', 'Female']
f.semantic_fields_for :person_attributes do |paf|
f.input :person_attribute_value, :as => :select, :collection => paf.object.person_attribute_type.person_attributes_values, :label => paf.object.person_attribute_type.name
end
f.buttons
end
Mind that this all is untested, so just try to understand what I'm trying to do here.
BTW, I now realize that the class name PersonAttribute is probably a bit unlucky, because you will have to accepts_nested_attributes_for :person_attributes which would mean you would have to attr_accessible :person_attributes_attributes, but you get my drift I hope.

Related

Ruby: How do I assign values in the controller when using nested forms?

I have 3 models: Employers, Partners and Collaborations.
As an Employer, I want to add a record to my Partner model and to my Collaboration model to be able to indicate a collaboration between a Partner and a Employer. I therefore have the following columns in my database/tabels.
Models
class Employer < ActiveRecord::Base
has_many :collaborations
has_many :partners, :through => :collaborations
end
class Partner < ActiveRecord::Base
has_many :collaborations
has_many :employers, :through => :collaborations
accepts_nested_attributes_for :collaborations
end
class Collaboration < ActiveRecord::Base
belongs_to :employer
belongs_to :partner
end
Tables
Collaborations
employer_id:integer
partner_id:integer
tarive:string
Partners
added_by:integer
name:string
Because I want to be able to add a Partner/Collaboration within 1 form, I use nested forms. So I can add a partner (name, etc) and a collaboration (tarive, etc) in one go.
My (simple_form) form looks like this (I have named_space resource).
Te reduce clutter, I removed as much HTML mark_up as I could, this is not the issue.
Form
/views/employer/partners/_form
= simple_form_for [:employer, #partner], html: { multipart: true } do |f|
Partner
= f.input :name, input_html: { class: 'form-control' }
= f.simple_fields_for :collaborations do |ff|
Tarive
= ff.input :tarive, input_html: { class: 'form-control' }
= f.button :submit, "Save"
My controller looks like
class Employer::PartnersController < ActionController::Base
def new
#partner = Partner.new
#partner.collaborations.build
end
def create
#partner = Partner.new(partner_params)
#partner.collaborations.build
#partner.added_by = current_employer.id
#partner.collaborations.employer_id = current_employer.employer_id
#partner.collaborations.partner_id = #partner.id
#partner.collaborations.added_by = current_employer.id
if #partner.save
redirect_to employer_partner_path(#partner), notice: "Succes!"
else
render 'new'
end
end
def partner_params
params.require(:partner).permit(:id, :name, collaborations_attributes: [:id, :employer_id, :partner_id, :tarive])
end
end
Problem
The problem/question I have is this. The attributes are assigned nicely and added in the model. But I want to add a employer_id as well, which I have in current_employer.employer.id (Devise). I do not want to work with hidden forms, just to avoid this issue.
I assigned 'parent' models always like #partner.added_by = current_employer.id and that works beautifully.
When I use:
#partner.collaborations.employer_id = current_employer.employer_id
I get an error, saying #partner.collaborations.employer_id is empty.
Question
How can I assign a variable to the nested_form (Collaboration) in my controller#create?
Or more specifically: how can I assign current_employer.employer_id to #partner.collaborations.employer_id?
There are several ways:
Merge the params
Deal with objects, not foreign keys
Personally, I feel your create method looks really inefficient. Indeed, you should know about fat model skinny controller - most of your associative logic should be kept in the model.
It could be improved using the following:
#app/controllers/employers/partners_controller.rb
class Employers::PartnersController < ApplicationController
def new
#partner = current_employer.partners.new #-> this *should* build the associated collaborations object
end
def create
#partner = current_employer.partners.new partner_params
#partner.save ? redirect_to(employer_partner_path(#partner), notice: "Succes!") : render('new')
end
private
def partner_params
params.require(:partner).permit(:id, :name, collaborations_attributes: [:tarive]) #when dealing with objects, foreign keys are set automatically
end
end
This would allow you to use:
#app/views/employers/partners/new.html.erb
= simple_form_for #partner do |f| #-> #partner is built off the current_employer object
= f.input :name
= f.simple_fields_for :collaborations do |ff|
= ff.input :tarive
= f.submit
... and the models:
#app/models/partner.rb
class Partner < ActiveRecord::Base
belongs_to :employer, foreign_key: :added_by
has_many :collaborations
has_many :employers, through: :collaborations
accepts_nested_attributes_for :collaborations
end
#app/models/collaboration.rb
class Collaboration < ActiveRecord::Base
belongs_to :employer
belongs_to :partner
belongs_to :creator, foreign_key: :added_by
before_create :set_creator
private
def set_creator
self.creator = self.employer_id #-> will probably need to change
end
end
#app/models/employer.rb
class Employer < ActiveRecord::Base
has_many :collaborations
has_many :employers, through: :collaborations
end
This may not give you the ability to set tarive, however if you cut down the manual declarations in your model, we should be able to look at getting that sorted.
The main thing you need to do is slim down your code in the controller. You're being very specific, and as a consequence, you're encountering problems like that which you mentioned.

Editing a has_one association in ActiveAdmin - destroying when attribute is blanked

I've got a model in which a very small percentage of the objects will have a rather large descriptive text. Trying to keep my database somewhat normalized, I wanted to extract this descriptive text to a separate model, but I'm having trouble creating a sensible workflow in ActiveAdmin.
My models look like this:
class Person < ActiveRecord::Base
has_one :long_description
accepts_nested_attributes_for :long_description, reject_if: proc { |attrs| attrs['text'].blank? }
end
class LongDescription < ActiveRecord::Base
attr_accessible :text, :person_id
belongs_to :person
validates :text, presence: true
end
Currently I've created a form for editing the Person model, looking somewhat like this:
form do |f|
...
f.inputs :for => [
:long_description,
f.object.long_description || LongDescription.new
] do |ld_f|
ld_f.input :text
end
f.actions
end
This works for adding/editing the LongDescription object, and it avdois validating/creating the LongDescription object if no text is entered.
What I'd like to achieve is to also be able to remove the LongDescription object, for example if the text attribute is ever set to an empty string/nil again.
Anyone with better Rails or ActiveAdmin skills than me know how to achieve this?
That seems like an awfully unusual architecture decision, but the implementation is pretty simple:
class LongDescription < ActiveRecord::Base
validates_presence_of :text, on: :create
after_save do
destroy if text.blank?
end
end

Is there an easier way of creating/choosing related data with ActiveAdmin?

Imagine I have the following models:
class Translation < ActiveRecord::Base
has_many :localizations
end
class Localization < ActiveRecord::Base
belongs_to :translation
end
If I do this in ActiveAdmin:
ActiveAdmin.register Localization do
form do |f|
f.input :word
f.input :content
end
end
The association for word will only allow me to choose from existing words. However, I'd like to have the option of creating a new word on the fly. I thought it may be useful to accept nested attributes in the localization model ( but then, I will only have the option of creating a Word, not selecting from existing ones ). How can I solve this problem?
I think you can try using virtual attribute for this
Example(not tested)
class Localization < ActiveRecord::Base
attr_accessor :new_word #virtual attribute
attr_accessible :word_id, :content, :new_word
belongs_to :translation
before_save do
unless #new_word.blank?
self.word = Word.create({:name => #new_word})
end
end
end
The main idea is to create and store new Word instance before saving localization and use it instead of word_id from drop-down.
ActiveAdmin.register Localization do
form do |f|
f.input :word
f.input :content
f.input :new_word, :as => :string
end
end
There is great rails-cast about virtual attributes http://railscasts.com/episodes/167-more-on-virtual-attributes

How can I elegantly construct a form for a model that has a polymorphic association?

Here are my models:
class Lesson < ActiveRecord::Base
belongs_to :topic, :polymorphic => true
validates_presence_of :topic_type, :topic_id
end
class Subject < ActiveRecord::Base
has_many :lessons, :as => :topic
end
class Category < ActiveRecord::Base
has_many :lessons, :as => :topic
end
Now, what I need is a form that will allow the user to create or update Lessons. The questions is, how can I provide a select menu that offers a mix of Subjects and Categories? (To the user, on this particular form, Subjects and Categories are interchangeable, but that's not the case elsewhere.)
Ideally, this would look something like this:
views/lessons/_form.html.haml
= simple_form_for(#lesson) do |f|
= f.input :title
= f.association :topic, :collection => (#subjects + #categories)
That won't work because we'd only be specifying the topic_id, and we need the topic_types as well. But how can we specify those values?
I guess the crux of the problem is that I really want a single select menu that specifies two values corresponding to two different attributes (topic_id and topic_type). Is there any elegant railsy way to do this?
A few notes:
a) Single table inheritance would make this issue go away, but I'd like to avoid this, as Categories and Subjects have their own relationship… I'll spare you the details.
b) I might could pull some javascript shenanigans, yes? But that sounds messy, and if there's a cleaner way to do it, some magic form helper or something, then that's certainly preferable.
c) Though I'm using simple_form, I'm not wedded to it, in case that's complicating matters.
Thanks
If you don't wish to use STI, you can do something similar: create a new model Topic(name:string) which will polymorphically reference Subject or Category.
class Lesson < ActiveRecord::Base
belongs_to :topic
validates_presence_of :topic_id
end
class Topic < ActiveRecord::Base
belongs_to :topicable, :polymorphic => true
end
class Subject < ActiveRecord::Base
has_one :topic, :as => :topicable
has_many :lessons, :through => :topic
accepts_nested_attributes_for :topic
end
class Category < ActiveRecord::Base
has_one :topic, :as => :topicable
has_many :lessons, :through => :topic
accepts_nested_attributes_for :topic
end
In the view where you create a new Subject/Category:
<%= form_for #subject do |subject_form| %>
<%= subject_form.fields_for :topic do |topic_fields| %>
<%= topic_fields.text_field :name %>
<% end %>
<% end %>
After thinking this through, the less dirty implementation IMO would be to hire the JS shenanigans (b):
= f.input_field :topic_type, as: :hidden, class: 'topic_type'
- (#subjects + #categories).each do |topic|
= f.radio_button :topic_id, topic.id, {:'data-type' => topic.class.name, class: 'topic_id'}
With a sprinkle of JS (your needs may vary):
$('input:radio.topic_id').change(function() {
$('input:hidden.topic_type').val($(this).attr('data-type'));
});
Notes:
I use a radio button to select a topic (category or subject) in a list
The class name of each of possible topic is stored in an attribute 'data-type'
When a radio button is selected, the class name is copied to the hidden input via JS
Using: HTML5, jQuery, haml, simple_form

How do you rate multiple attributes of the same object?

I've not seen this feature as a plug in and was wondering how to achieve it, hopefully using rails.
The feature I'm after is the ability to rate one object (a film) by various attributes such as plot, entertainment, originality etc etc on one page/form.
Can anyone help?
I don't think you need a plugin to do just that... you could do the following with AR
class Film < ActiveRecord::Base
has_many :ratings, :as => :rateable
def rating_for(field)
ratings.find_by_name(field).value
end
def rating_for=(field, value)
rating = nil
begin
rating = ratigns.find_by_name(field)
if rating.nil?
rating = Rating.create!(:rateable => self, :name => field, :value => value)
else
rating.update_attributes!(:value => value)
end
rescue ActiveRecord::RecordInvalid
self.errors.add_to_base(rating.errors.full_messages.join("\n"))
end
end
end
class Rating < ActiveRecord::Base
# Has the following field:
# column :rateable_id, Integer
# column :name, String
# column :value, Integer
belongs_to :rateable, :polymorphic => true
validates_inclusion_of :value, :in => (1..5)
validates_uniqueness_of :name, :scope => :rateable_id
end
Of course, with this approach you would have a replication in the Rating name, something that is not that bad (normalize tables just for one field doesn't cut it).
You can also use a plugin ajaxfull-rating
Here's another, possibly more robust rating plugin...it's been around for a while and has been revised to work with Rails 2
http://agilewebdevelopment.com/plugins/acts_as_rateable

Resources