Upgrade to Pro — share decks privately, control downloads, hide ads and more …

WTF is WF

WTF is WF

A talk of how we structured a greenfield project with good design patterns and an explanation of some of the problems we solved as a result.

Garrett Heinlen

May 13, 2014
Tweet

More Decks by Garrett Heinlen

Other Decks in Programming

Transcript

  1. Lets fix that Wordflyers is a product very similar to

    Reading Eggs, Mathseeds, REX, etc, but targeted only towards teachers and schools.
  2. How WF is structured - high level • grade levels

    ( year level ) • difficulty levels ( 1, 2, 3 ) • units • lessons • activities
  3. History • 3 bloody contractors • 6 months • 0

    knowledge of existing codebase • greenfield project (heck yeah)
  4. We tried our best to keep things as modular as

    possible so testing and reuse would be a breeze
  5. The Test Finished in 1 minute 21.65 seconds 200 examples,

    0 failures, 4 pending Randomized with seed 34036 real 1m31.077s user 1m9.265s sys 0m8.211s
  6. We tried to keep the stack as similar to the

    other apps, but we made good use of PORO's, Services, Policies, Form objects, and the like.
  7. Architecture how we split our apps up • wf-models -

    holder of all things shared • wf-teacher - controller of all things student • wf-student - reciever of fun, travel, and knowledge
  8. wf-models • very thin database layer • shared models •

    connection layer between BlakeDataModels and WF • shared data controller for activity data • shared form objects • shared mailers • and more..!
  9. A model can be a model without inherting from the

    glob known as ActiveRecord really.. it's true
  10. Examples of some shared models: class PointLedger attr_reader :student def

    initialize(student) @student = student end def add(item, amount) create_point_transaction(item, amount) end def deduct(item) create_point_transaction(item, -item.cost) end def flyer_points student.point_transactions.sum(:amount) end def total_points student.point_transactions.where("amount > 0").sum(:amount) end private def create_point_transaction(item, amount) student.point_transactions.create(amount: amount, item_type: item.class.to_s, item_id: item.id) end end
  11. Examples of some shared models: class RewardCalc def initialize(activity_name, attempt_count,

    hint_clicked, within_timer) @diff_level = StarLookup.star_rating_for(activity_name) @attempt_count = attempt_count @hint_clicked = hint_clicked @within_timer = within_timer end def call points = value_for(@diff_level) points += 10 if @hint_clicked points += 20 if @within_timer points += 20 if @attempt_count == 2 points += 40 if @attempt_count == 1 points end private def value_for(diff_level) { 2 => 50, 3 => 80, 4 => 110, 5 => 130 }[diff_level] || 0 end end
  12. Examples of some shared models: # Percentage of completeness, total

    and done. class Progress def initialize(total, done) @total, @done = total, done @percents = ((done.to_f / total) * 100).to_i end attr_reader :total, :done def to_s @percents.to_s end end
  13. Examples of some shared models: class Student < User has_many

    :student_classes has_many :school_classes, through: :student_classes has_many :point_transactions has_many :wish_list_items has_many :gifts has_one :student_profile, foreign_key: 'user_id', autosave: true has_one :student_plan include FlagShihTzu has_flags 1 => :chat_disabled PROFILE_ATTRS = %w{ current_location_id locale country avatar avatar_url school_grade grade_level difficulty_level current_unit_id } PROFILE_ATTRS.each do |attr| delegate attr, "#{attr}=", to: :student_profile end alias_method :profile, :student_profile def initialize(options={}) options = options.with_indifferent_access super(options.except(*profile_attrs)) self.student_profile = StudentProfile.new(options.slice(*profile_attrs)) end def study_plan student_plan.study_plan if student_plan end private def profile_attrs PROFILE_ATTRS end end
  14. Most of our data is stored in Yaml db/word_flyers/lessons/061.yml :unit_id:

    '7_1_7' :activities: - tutorial_text: instructions: - Read and listen to the word list and the tutorial about technical language. sentences: - ! 'In the text Exposed: the tattoo sleeve, the writer has used some words that we don''t use in everyday conversation; for example, dermis, which means the second-deepest layer of the skin.' - Two other words in the list are usually only used in medical or health situations. An autoclave is a sterilising device and antibacterial describes something that kills bacteria. - Some words can be used in everyday conversations but have specialised meanings. Sterilised means treated to kill germs and tattoo means a pattern or picture marked in ink on the skin. Words like dermis, sterilised, autoclave, antibacterial and tattoo are called specialised or technical language. words: - tattoo - sterilised - autoclave - dermis - antibacterial show_instructions: - Are these statements TRUE or FALSE? statements: - All the words in the word list are technical words used to talk about the specialised topic of tattooing. - A technical word is a word not usually used in everyday conversations about general topics. - Sterilised is a word often used in everyday conversation. answers: - true - true - false explanations: - False; sterilised is used in scientific or technical contexts.
  15. Big Daddy - BlakeModel BlakeModel connects BlakeDataModel to our code

    This baby lets us get that yaml data in a ruby'esk way class BlakeModel < OpenStruct module Methods def initialize(id, locale='au') @id, @locale = id, locale # this calls super to make all ze methods super(data) end private # This grabs the data from BlakeDataSource and mimics a # rails like interface that we can interact with and # expect things to fail gracefully def data if id && data = data_yaml data else raise(ActiveRecord::RecordNotFound) end end def data_yaml file_path = "word_flyers/#{category.pluralize}/au/#{id}" BlakeDataSource::YmlLoader.new(file_path).data end end end
  16. we were new we were scared we were afraid to

    learn your stack because we could
  17. How BlakeModel makes things easy class Postcard < BlakeModel self.category

    = 'postcard' end class Souvenir < BlakeModel self.category = 'souvenir' end
  18. How BlakeModel makes things easy A more complicated example class

    Lesson < BlakeModel attr_reader :unit self.category = 'lesson' def activities data[:activities].map.with_index do |conf, index| # name, position, data, lesson_id Activity.new(conf.keys.first, (index + 1), conf.values.first, self.id) end end def activity(index) return if index == 0 activities[index-1] end def unit @unit ||= Unit.find(self.unit_id) end end
  19. We try to keep these models as stupid as possible.

    We only want them to load and show data.
  20. We did not want to add any app specific methods

    or knowledge to this shared layer instead we extend these classes at runtime to provide extra methods
  21. wf-teacher • CRUD for student, school classes, and more! •

    study plans (assignments) management • lesson previews • student messages management • student reports - prefer to be microservice ;) • and more..!
  22. This is a very basic Scaffolding like application to manage

    students, classes, and whatever else.
  23. Extension Setup Here we find the model class and include

    an extension module. config.to_prepare do Dir.entries("app/extensions") .select{ |f| !File.directory? f} .each do |file_name| array = file_name.split("_") array.pop klass_name = array.map(&:capitalize).join klass = klass_name.constantize klass.class_eval do include "#{klass}Extensions".constantize end end end
  24. How we use them module SchoolExtensions extend ActiveSupport::Concern included do

    end def address [street, suburb, state, postcode, country].join(', ') end def all_students_except_from(obj) students.active.where.not(id: obj.student_ids) end def all_students_except(student_id) students.active.where("users.id <> ?", student_id) end module ClassMethods end end
  25. Why do we use these? wf-models stays thin! methods only

    exist where they belong keeps files small!
  26. The scary bits.. allows people to hide a billion methods

    in an extention masking the fact you have a god object. may expect this method to exist in the other apps people are lazy
  27. Step 3 do step 2: class all the things still

    more crap? recursively perform step 3
  28. Form Objects Allows for validations to exist in different context

    and it makes working with multiuple objects in one form very simple.
  29. Simple form object module Forms class StudentForm < Reform::Form include

    Composition properties [:first_name, :last_name, :email, :chat_disabled], on: :student properties [:school_grade, :grade_level, :locale, :country], on: :profile model :student validates :first_name, :last_name, :email, presence: true end end
  30. Controller class StudentsController < ApplicationController def index @form = create_new_form

    end def create form = create_new_form if form.validate(params) form.save do |data, map| # do whatever you want with data # -- we aren't doing this exactly.. # but something similar student = Student.create(map[:student]) profile = StudentProfile.create(map[:profile]) respond_with student end else render :new end end private def create_new_form Forms::StudentForm.new(student: Student.new, profile: StudentProfile.new) end end
  31. View = simple_form_for @form, class: 'form-horizontal' do |f| .form-group =

    f.input :first_name, input_html: { class: 'form-control' } .form-group = f.input :last_name, input_html: { class: 'form-control' } .form-group = f.input :email, input_html: { class: 'form-control' } .form-group = f.input :school_grade, collection: 7..10 = f.submit "Create Student", class: 'btn btn-primary'
  32. Does it work elsewhere? of course! module Service class StudentImporter

    attr_reader :file, :school, :school_class def initialize(file, school, school_class=nil) @file, @school, @school_class = file, school, school_class end def call CSV.foreach(file.path, headers: true) do |row| form = build_form if form.validate(row.to_hash) form.save do |data, map| student = Service::ManageStudent.new(map[:student].merge(map[:profile]), school.id).create school_class.students << student if school_class end end end end private def build_form Forms::StudentForm.new(student: school.students.new, profile: StudentProfile.new) end end end
  33. Example 2 module TableGridHelper def table_grid_for(collection, options={}) collection = collection.to_a.flatten

    partial_name = options.fetch(:partial_name, 'row') headers = options.fetch(:headers) render partial: 'grid', locals: { headers: headers, collection: collection, partial_name: partial_name } end end // app/views/schools/index.html.haml = table_grid_for(@schools, headers: [:name, :slug, :address, :actions])
  34. Example 2 Tabular Data Extraordinaire // app/views/application/_grid.html.haml .filter_form %label Search

    %input#filter .table-responsive %table{"data-filter" => "#filter"} %thead %tr - headers.each_with_index do |header, index| = header.to_s.humanize %tbody - collection.each do |obj| = render partial: partial_name, locals: { obj: obj }
  35. Example 2 Tabular Data Extraordinaire // app/views/application/_row.html.haml %tr[obj] %td =

    link_to obj.first_name, obj %td = obj.last_name %td = obj.email %td.actions = action_links_for(obj)
  36. Student Reports // app/views/student_reports/_row.html.haml %tr[obj.student] %td= check_box_tag 'student_report_ids[]', nil, false

    %td.student_name = link_to obj.student, student_report_path(obj.student.id) %td.grade_level= obj.student.grade_level %td.total_score= obj.total_score %td.total_points= obj.total_points %td.flyer_points= obj.flyer_points
  37. wf-student • learning section • travel the world • games

    lounge • messages • destinations • profile • and more..!
  38. Policy to authorize if a student can see a specific

    activity module Policy class Activity attr_accessor :student, :session, :activity_id def initialize(student, session, activity_id) @student = student @session = session @activity_id = activity_id end # [g] activity id's start at 1 def allowed? if session.attempts.blank? activity_id == "#{session.lesson.id}_1" else session.next_activity_id == activity_id end end def not_allowed? !allowed? end end end
  39. How it's used class ActivitiesController < ApplicationController before_action :ensure_policy def

    show @lesson = lesson_session.lesson @activity = Activity.find(params[:activity_id]) @unit = @lesson.unit end private def ensure_policy if Policy::Activity.new( current_student, lesson_session, params[:activity_id] ).not_allowed? redirect_to activity_path(lesson_session.id, lesson_session.current_activity_id) end end def lesson_session @lesson_session ||= LessonSession.find(params[:id]) end end
  40. How we use presenters module Presenter class Student < Base

    presents :student delegate :school_grade, :first_name, :last_name, :full_name, to: :student def initialize(object, template) @object = object @template = template @ledger = PointLedger.new(object) end def purchases purchases = StudentPurchase.new(student).all if purchases.any? purchases.map do |purchase| content_tag(:li, purchased_item(purchase), class: 'item') end.join.html_safe end end private def purchased_item(purchase) [ content_tag(:p, purchase.item, class: 'item_name'), content_tag(:p, purchase.item.content, class: 'item_content'), ].join.html_safe end end end
  41. How the view looks as a result .items-section .items-section__purchases %h2

    Purchases %ul.items = student_presenter.purchases
  42. Services make complex actions isolated into a small class acts

    on multiple objects at once interact with external services
  43. Base class for purchasing module Purchase class Item include ::Rails.application.routes.url_helpers

    delegate :cost, to: :item def initialize(student, item, args={}) @student, @item = student, item @ledger = ::PointLedger.new(student) @addressee = args.fetch(:addressee, nil) @message = args.fetch(:message, nil) end def purchase possible? && !!::ActiveRecord::Base.transaction do ledger.deduct(item) process end end def redirect_path; shop_path; end private attr_reader :student, :item, :ledger, :addressee, :message def process; true; end def possible? StudentPurchase.new(student).valid?(cost) end end end
  44. Service for purchasing a Postcard module Purchase class Postcard <

    Item private def process if addressee && message ::Service::SendItem.new( item, student, addressee, message ).call else true end end end end
  45. Using services in the wild class PurchasesController < ApplicationController include

    ItemLookup def create purchase_service = service_class(item_type) .new(current_student, item, {addressee: addressee, message: message}) purchase_service.purchase redirect_to purchase_service.redirect_path, notice: 'Item purchased' end private def service_class(item_type) "Purchase::#{item_type.classify}".constantize end def addressee if params[:item] @addressee = Student.find(params[:item][:addressee_id]) end end def message @message = params[:item][:message] if params[:item] end end
  46. Qualifiers We have badges in our system and qualifiers that

    are responsible for the eligibility of these badges app/qualifiers to the rescue
  47. Each Qualifier has 1 public method It contains all the

    business logic for these badge rewards class WorldTravellerQualifier def self.valid?(student, badge) # North America, South America, Europe, # Asia, Africa, Australia, Antarctica. StudentPurchase.new(student).locations .map(&:continent).uniq.size == 7 end end class BusyWeekQualifier extend PurchasedLocationCalc def self.valid?(student, badge) purchased_locations_for_timeframe(student) == 5 end end
  48. How it's used ### somewhere in our app Badge.all.each do

    |badge| ::BadgeQualifier.new(student, badge).award end class BadgeQualifier def initialize(student, badge) @student, @badge = student, badge end def award _award if not_awarded? && valid? end def valid? "#{badge.qualifier_type.classify}Qualifier" .constantize.valid?(student, badge) end private attr_reader :student, :badge def not_awarded? !student.badges.include?(badge) end def _award student.achievements.create!(badge_id: badge.id) end end
  49. A quick example module Destinations::StudentRepresenter include Representable::JSON property :id property

    :full_name property :avatar def avatar if super.present? super.avatar_url(:thumb).to_s else 'avatar.png' end end end
  50. Mapbox is an open source mapping platform for developers and

    designers at enterprise scale. — the marketing team
  51. Why we picked it • easy to customize • can

    be edited via CSS • has a decent API • cheap'ish compared to google (at least when we looked) • because we're hipstars
  52. Things of note • A Lesson can be completed more

    than once. • A LessonSession is a single attempt at completing a lesson. • A student can have many lesson sessions for a single lesson. • The system uses their best lesson session to compute score / correctness.
  53. LessonSession popular public methods • #answer • #process_attempts • #attempts

    • #completed? • #progressed • ::find(id) • ::find_for(student, lesson_id)
  54. Processing the answers class AnswerController < ApplicationController respond_to :json def

    create lesson_session .process_attempts(params[:attempts].values) head :created, location: redirect_url end private def lesson_session @lesson_session ||= LessonSession .find(params[:lesson_session_id]) end end
  55. What's next for WF? • placement / assessment test •

    even reward system • finish teacher reporting • finish design • finish all the activities • games lounge • much more..