formol 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (226) hide show
  1. data/MIT-LICENSE +20 -0
  2. data/README.md +147 -0
  3. data/Rakefile +29 -0
  4. data/app/assets/images/formol/read.png +0 -0
  5. data/app/assets/images/formol/unread.png +0 -0
  6. data/app/assets/javascripts/formol/admin/categories.js.coffee +55 -0
  7. data/app/assets/javascripts/formol/admin/forums.js +2 -0
  8. data/app/assets/javascripts/formol/application.js +9 -0
  9. data/app/assets/javascripts/formol/categories.js +2 -0
  10. data/app/assets/javascripts/formol/forums.js +2 -0
  11. data/app/assets/javascripts/formol/poll_votes.js +2 -0
  12. data/app/assets/javascripts/formol/posts.js.coffee +9 -0
  13. data/app/assets/javascripts/formol/preferences.js +2 -0
  14. data/app/assets/javascripts/formol/subscriptions.js.coffee +13 -0
  15. data/app/assets/javascripts/formol/topics.js.coffee +54 -0
  16. data/app/assets/javascripts/lib/jquery.prevent_double_submit.js.coffee +11 -0
  17. data/app/assets/javascripts/markitup/jquery.markitup.js +593 -0
  18. data/app/assets/javascripts/markitup/sets/markdown/set.js +53 -0
  19. data/app/assets/stylesheets/form_struct.css.scss +39 -0
  20. data/app/assets/stylesheets/formol/admin/categories.css.scss +30 -0
  21. data/app/assets/stylesheets/formol/admin/forums.css +4 -0
  22. data/app/assets/stylesheets/formol/categories.css.scss +3 -0
  23. data/app/assets/stylesheets/formol/development.css +4 -0
  24. data/app/assets/stylesheets/formol/forums.css.scss +3 -0
  25. data/app/assets/stylesheets/formol/mixins/after_clear.css.scss +7 -0
  26. data/app/assets/stylesheets/formol/partials/breadcrumb.css.scss +9 -0
  27. data/app/assets/stylesheets/formol/partials/categories/list.css.scss +101 -0
  28. data/app/assets/stylesheets/formol/partials/formol_lists.css.scss +6 -0
  29. data/app/assets/stylesheets/formol/partials/forums/list.css.scss +97 -0
  30. data/app/assets/stylesheets/formol/partials/polls/poll.css.scss +9 -0
  31. data/app/assets/stylesheets/formol/partials/posts/post.css.scss +116 -0
  32. data/app/assets/stylesheets/formol/posts.css.scss +8 -0
  33. data/app/assets/stylesheets/formol/topics.css.scss +42 -0
  34. data/app/assets/stylesheets/markitup/sets/markdown/images/block_code.png +0 -0
  35. data/app/assets/stylesheets/markitup/sets/markdown/images/bold.png +0 -0
  36. data/app/assets/stylesheets/markitup/sets/markdown/images/code.png +0 -0
  37. data/app/assets/stylesheets/markitup/sets/markdown/images/h1.png +0 -0
  38. data/app/assets/stylesheets/markitup/sets/markdown/images/h2.png +0 -0
  39. data/app/assets/stylesheets/markitup/sets/markdown/images/h3.png +0 -0
  40. data/app/assets/stylesheets/markitup/sets/markdown/images/h4.png +0 -0
  41. data/app/assets/stylesheets/markitup/sets/markdown/images/h5.png +0 -0
  42. data/app/assets/stylesheets/markitup/sets/markdown/images/h6.png +0 -0
  43. data/app/assets/stylesheets/markitup/sets/markdown/images/italic.png +0 -0
  44. data/app/assets/stylesheets/markitup/sets/markdown/images/link.png +0 -0
  45. data/app/assets/stylesheets/markitup/sets/markdown/images/list-bullet.png +0 -0
  46. data/app/assets/stylesheets/markitup/sets/markdown/images/list-numeric.png +0 -0
  47. data/app/assets/stylesheets/markitup/sets/markdown/images/picture.png +0 -0
  48. data/app/assets/stylesheets/markitup/sets/markdown/images/preview.png +0 -0
  49. data/app/assets/stylesheets/markitup/sets/markdown/images/quotes.png +0 -0
  50. data/app/assets/stylesheets/markitup/sets/markdown/style.css +57 -0
  51. data/app/assets/stylesheets/markitup/skins/markitup/images/bg-container.png +0 -0
  52. data/app/assets/stylesheets/markitup/skins/markitup/images/bg-editor-bbcode.png +0 -0
  53. data/app/assets/stylesheets/markitup/skins/markitup/images/bg-editor-dotclear.png +0 -0
  54. data/app/assets/stylesheets/markitup/skins/markitup/images/bg-editor-html.png +0 -0
  55. data/app/assets/stylesheets/markitup/skins/markitup/images/bg-editor-json.png +0 -0
  56. data/app/assets/stylesheets/markitup/skins/markitup/images/bg-editor-markdown.png +0 -0
  57. data/app/assets/stylesheets/markitup/skins/markitup/images/bg-editor-textile.png +0 -0
  58. data/app/assets/stylesheets/markitup/skins/markitup/images/bg-editor-wiki.png +0 -0
  59. data/app/assets/stylesheets/markitup/skins/markitup/images/bg-editor-xml.png +0 -0
  60. data/app/assets/stylesheets/markitup/skins/markitup/images/bg-editor.png +0 -0
  61. data/app/assets/stylesheets/markitup/skins/markitup/images/handle.png +0 -0
  62. data/app/assets/stylesheets/markitup/skins/markitup/images/menu.png +0 -0
  63. data/app/assets/stylesheets/markitup/skins/markitup/images/submenu.png +0 -0
  64. data/app/assets/stylesheets/markitup/skins/markitup/style.css +147 -0
  65. data/app/assets/stylesheets/markitup/skins/simple/images/handle.png +0 -0
  66. data/app/assets/stylesheets/markitup/skins/simple/images/menu.png +0 -0
  67. data/app/assets/stylesheets/markitup/skins/simple/images/submenu.png +0 -0
  68. data/app/assets/stylesheets/markitup/skins/simple/style.css +118 -0
  69. data/app/assets/stylesheets/pygments/autumn.css +58 -0
  70. data/app/assets/stylesheets/pygments/borland.css +46 -0
  71. data/app/assets/stylesheets/pygments/bw.css +34 -0
  72. data/app/assets/stylesheets/pygments/colorful.css +61 -0
  73. data/app/assets/stylesheets/pygments/default.css +62 -0
  74. data/app/assets/stylesheets/pygments/emacs.css +61 -0
  75. data/app/assets/stylesheets/pygments/friendly.css +61 -0
  76. data/app/assets/stylesheets/pygments/fruity.css +70 -0
  77. data/app/assets/stylesheets/pygments/manni.css +61 -0
  78. data/app/assets/stylesheets/pygments/murphy.css +61 -0
  79. data/app/assets/stylesheets/pygments/native.css +70 -0
  80. data/app/assets/stylesheets/pygments/pastie.css +60 -0
  81. data/app/assets/stylesheets/pygments/perldoc.css +58 -0
  82. data/app/assets/stylesheets/pygments/tango.css +69 -0
  83. data/app/assets/stylesheets/pygments/trac.css +59 -0
  84. data/app/assets/stylesheets/pygments/vim.css +70 -0
  85. data/app/assets/stylesheets/pygments/vs.css +33 -0
  86. data/app/controllers/formol/admin/admin_controller.rb +11 -0
  87. data/app/controllers/formol/admin/categories_controller.rb +60 -0
  88. data/app/controllers/formol/admin/forums_controller.rb +56 -0
  89. data/app/controllers/formol/application_controller.rb +47 -0
  90. data/app/controllers/formol/categories_controller.rb +21 -0
  91. data/app/controllers/formol/forums_controller.rb +19 -0
  92. data/app/controllers/formol/poll_votes_controller.rb +23 -0
  93. data/app/controllers/formol/posts_controller.rb +71 -0
  94. data/app/controllers/formol/preferences_controller.rb +32 -0
  95. data/app/controllers/formol/subscriptions_controller.rb +29 -0
  96. data/app/controllers/formol/topics_controller.rb +91 -0
  97. data/app/helpers/formol/admin/categories_helper.rb +4 -0
  98. data/app/helpers/formol/admin/forums_helper.rb +4 -0
  99. data/app/helpers/formol/application_helper.rb +35 -0
  100. data/app/helpers/formol/categories_helper.rb +15 -0
  101. data/app/helpers/formol/forums_helper.rb +19 -0
  102. data/app/helpers/formol/poll_votes_helper.rb +4 -0
  103. data/app/helpers/formol/posts_helper.rb +27 -0
  104. data/app/helpers/formol/preferences_helper.rb +4 -0
  105. data/app/helpers/formol/subscriptions_helper.rb +4 -0
  106. data/app/helpers/formol/topics_helper.rb +27 -0
  107. data/app/mailers/formol/subscription_mailer.rb +13 -0
  108. data/app/models/formol/category.rb +42 -0
  109. data/app/models/formol/forum.rb +77 -0
  110. data/app/models/formol/poll/option.rb +20 -0
  111. data/app/models/formol/poll/vote.rb +32 -0
  112. data/app/models/formol/poll.rb +107 -0
  113. data/app/models/formol/post.rb +162 -0
  114. data/app/models/formol/post_observer.rb +9 -0
  115. data/app/models/formol/topic/subscription.rb +16 -0
  116. data/app/models/formol/topic/track.rb +57 -0
  117. data/app/models/formol/topic.rb +185 -0
  118. data/app/models/formol/tracking.rb +45 -0
  119. data/app/models/formol/user_preference.rb +18 -0
  120. data/app/views/formol/admin/categories/_category.html.erb +9 -0
  121. data/app/views/formol/admin/categories/_form.html.erb +8 -0
  122. data/app/views/formol/admin/categories/edit.html.erb +3 -0
  123. data/app/views/formol/admin/categories/index.html.erb +3 -0
  124. data/app/views/formol/admin/categories/new.html.erb +3 -0
  125. data/app/views/formol/admin/forums/_form.html.erb +9 -0
  126. data/app/views/formol/admin/forums/_forum.html.erb +12 -0
  127. data/app/views/formol/admin/forums/edit.html.erb +3 -0
  128. data/app/views/formol/admin/forums/new.html.erb +3 -0
  129. data/app/views/formol/categories/_category.html.erb +5 -0
  130. data/app/views/formol/categories/index.html.erb +5 -0
  131. data/app/views/formol/categories/show.html.erb +3 -0
  132. data/app/views/formol/forums/_forum.html.erb +13 -0
  133. data/app/views/formol/forums/_list.html.erb +16 -0
  134. data/app/views/formol/forums/show.html.erb +18 -0
  135. data/app/views/formol/poll/options/_form.html.erb +1 -0
  136. data/app/views/formol/poll_votes/_form.html.erb +8 -0
  137. data/app/views/formol/polls/_form.html.erb +14 -0
  138. data/app/views/formol/polls/_poll.html.erb +24 -0
  139. data/app/views/formol/posts/_form.html.erb +7 -0
  140. data/app/views/formol/posts/_last_post.html.erb +9 -0
  141. data/app/views/formol/posts/_list.html.erb +3 -0
  142. data/app/views/formol/posts/_post.html.erb +47 -0
  143. data/app/views/formol/posts/edit.html.erb +10 -0
  144. data/app/views/formol/posts/new.html.erb +14 -0
  145. data/app/views/formol/preferences/show.html.erb +11 -0
  146. data/app/views/formol/subscription_mailer/topic_reply.en.html.erb +8 -0
  147. data/app/views/formol/subscription_mailer/topic_reply.fr.html.erb +8 -0
  148. data/app/views/formol/topics/_form.html.erb +20 -0
  149. data/app/views/formol/topics/_list.html.erb +16 -0
  150. data/app/views/formol/topics/_topic.html.erb +13 -0
  151. data/app/views/formol/topics/edit.html.erb +5 -0
  152. data/app/views/formol/topics/new.html.erb +5 -0
  153. data/app/views/formol/topics/show.html.erb +25 -0
  154. data/app/views/layouts/formol/mail.html.erb +9 -0
  155. data/config/locales/en.yml +3 -0
  156. data/config/locales/formol/views/forums/en.yml +13 -0
  157. data/config/locales/formol/views/forums/fr.yml +13 -0
  158. data/config/locales/formol/views/poll_votes/en.yml +5 -0
  159. data/config/locales/formol/views/polls/en.yml.yml +5 -0
  160. data/config/locales/formol/views/posts/en.yml +23 -0
  161. data/config/locales/formol/views/posts/fr.yml +20 -0
  162. data/config/locales/formol/views/topics/en.yml +23 -0
  163. data/config/locales/formol/views/topics/fr.yml +20 -0
  164. data/config/routes.rb +31 -0
  165. data/db/migrate/20111221101158_create_formol_categories.rb +7 -0
  166. data/db/migrate/20111221111417_create_formol_forums.rb +10 -0
  167. data/db/migrate/20111221121010_create_formol_topics.rb +9 -0
  168. data/db/migrate/20111221123515_create_formol_posts.rb +9 -0
  169. data/db/migrate/20111221151653_add_index_on_tables.rb +15 -0
  170. data/db/migrate/20120103163438_add_user_to_models.rb +13 -0
  171. data/db/migrate/20120104132631_add_counter_cache_to_models.rb +12 -0
  172. data/db/migrate/20120104163741_add_pinned_to_topics.rb +7 -0
  173. data/db/migrate/20120105133522_add_position_to_categories.rb +7 -0
  174. data/db/migrate/20120105190708_add_position_to_forums.rb +7 -0
  175. data/db/migrate/20120105212559_add_lock_to_topics.rb +7 -0
  176. data/db/migrate/20120106130944_add_first_post_and_last_post_to_topics.rb +8 -0
  177. data/db/migrate/20120106192951_change_forum_description_to_string.rb +9 -0
  178. data/db/migrate/20120107012517_add_last_post_to_forum.rb +7 -0
  179. data/db/migrate/20120107122921_add_missing_indexes.rb +12 -0
  180. data/db/migrate/20120107123654_add_default_on_forum_counters.rb +10 -0
  181. data/db/migrate/20120108114030_add_views_to_topics.rb +7 -0
  182. data/db/migrate/20120108131533_create_formol_topic_tracks.rb +13 -0
  183. data/db/migrate/20120109115141_create_formol_topic_subscriptions.rb +10 -0
  184. data/db/migrate/20120109220633_create_formol_user_preferences.rb +11 -0
  185. data/db/migrate/20120109223624_add_display_signature_to_posts.rb +7 -0
  186. data/db/migrate/20120109233054_add_display_signatures_and_avatars_to_user_preferences.rb +8 -0
  187. data/db/migrate/20120110152548_create_formol_polls.rb +14 -0
  188. data/db/migrate/20120110152618_create_formol_poll_options.rb +11 -0
  189. data/db/migrate/20120110152632_create_formol_poll_votes.rb +12 -0
  190. data/lib/formol/config.rb +12 -0
  191. data/lib/formol/controllers/auth.rb +9 -0
  192. data/lib/formol/controllers/nested/has_parent_category.rb +11 -0
  193. data/lib/formol/controllers/nested/has_parent_forum.rb +11 -0
  194. data/lib/formol/controllers/nested/has_parent_topic.rb +11 -0
  195. data/lib/formol/controllers.rb +5 -0
  196. data/lib/formol/engine.rb +18 -0
  197. data/lib/formol/formatters/redcarpet/formatter.rb +36 -0
  198. data/lib/formol/formatters/redcarpet/syntax_highlighter.rb +23 -0
  199. data/lib/formol/formatters.rb +2 -0
  200. data/lib/formol/generators/install/install_generator.rb +94 -0
  201. data/lib/formol/generators/install/templates/config/formol.yml +29 -0
  202. data/lib/formol/generators/install/templates/initializer/formol.rb.erb +21 -0
  203. data/lib/formol/generators/install/templates/lib/permissions/special_rights.rb +24 -0
  204. data/lib/formol/generators/install/templates/migrations/add_formol_profile_to_users.rb.erb +7 -0
  205. data/lib/formol/generators/install/templates/migrations/add_posts_count_to_user.rb.erb +7 -0
  206. data/lib/formol/generators.rb +1 -0
  207. data/lib/formol/integration/user.rb +76 -0
  208. data/lib/formol/integration.rb +1 -0
  209. data/lib/formol/permissions/controllers.rb +30 -0
  210. data/lib/formol/permissions/manager.rb +29 -0
  211. data/lib/formol/permissions/profile.rb +24 -0
  212. data/lib/formol/permissions/profiles.rb +44 -0
  213. data/lib/formol/permissions/user.rb +29 -0
  214. data/lib/formol/permissions.rb +5 -0
  215. data/lib/formol/rails/engine.rb +81 -0
  216. data/lib/formol/rails.rb +1 -0
  217. data/lib/formol/version.rb +10 -0
  218. data/lib/formol/view_helpers/assets_helper.rb +11 -0
  219. data/lib/formol/view_helpers/format_helper.rb +12 -0
  220. data/lib/formol/view_helpers.rb +2 -0
  221. data/lib/formol/will_paginate/list_link_renderer.rb +29 -0
  222. data/lib/formol/will_paginate.rb +1 -0
  223. data/lib/formol.rb +61 -0
  224. data/lib/formol_.rb +13 -0
  225. data/lib/tasks/formol_tasks.rake +0 -0
  226. metadata +348 -0
@@ -0,0 +1,77 @@
1
+ module Formol
2
+ class Forum < ActiveRecord::Base
3
+ #Modules
4
+ include RankedModel
5
+ include Tracking
6
+
7
+ #Security
8
+ attr_protected :posts_count, :topics_count
9
+
10
+ #Associations
11
+ belongs_to :category
12
+ belongs_to :last_post, :class_name => 'Formol::Post'
13
+
14
+ has_many :topics, :dependent => :destroy
15
+ has_many :posts, :through => :topics
16
+
17
+ #Validations
18
+ validates :category, :presence => true
19
+ validates :label, :presence => true,
20
+ :length => { :in => 3..32,
21
+ :allow_blank => true },
22
+ :uniqueness => { :scope => :category_id,
23
+ :case_sensitive => false,
24
+ :allow_blank => true }
25
+ validates :description, :length => { :maximum => 128,
26
+ :allow_blank => true }
27
+
28
+ #Normalization
29
+ normalize_attribute :label, :with => [:squish, :blank]
30
+ normalize_attribute :description
31
+
32
+ #Sorting
33
+ ranks :position, :with_same => :category_id
34
+
35
+ #Scopes
36
+ class << self
37
+ # this scope eager loads conveniently topic for using it in breadcrumb
38
+ def ready_for_breadcrumb
39
+ includes(:category)
40
+ end
41
+
42
+ # return all unread forums for a given user
43
+ def unread(user)
44
+ unread_scope_base(user)
45
+ .select('DISTINCT formol_forums.id')
46
+ .joins(:topics => :last_post)
47
+ end
48
+ end
49
+
50
+ #Business
51
+ class << self
52
+ # Sorts topics from an array of ids and sets the new category
53
+ def reorganize(forum_ids, category_id = nil)
54
+ category = Formol::Category.find(category_id) if category_id
55
+
56
+ #TODO: should maybe optimize this?
57
+ (forum_ids || []).each_with_index do |forum_id, index|
58
+ attr = { :position => index }
59
+ attr[:category] = category if category_id
60
+
61
+ find(forum_id).update_attributes(attr)
62
+ end
63
+ end
64
+ end
65
+
66
+ # Register a post being the last one of the current forum
67
+ def register_last_post(post)
68
+ update_attribute(:last_post, post)
69
+ end
70
+
71
+ # Unregister a post by re-assigning last post.
72
+ # Needed when a post is destroyed
73
+ def unregister_last_post
74
+ update_attributes!(:last_post => posts.order('formol_posts.id').last)
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,20 @@
1
+ module Formol
2
+ class Poll::Option < ActiveRecord::Base
3
+ #Security
4
+ attr_protected :poll_id
5
+
6
+ #Associations
7
+ belongs_to :poll
8
+
9
+ has_many :votes
10
+
11
+ #Validations
12
+ validates :poll, :presence => true
13
+ validates :answer, :presence => true,
14
+ :length => { :maximum => 32,
15
+ :allow_blank => true }
16
+
17
+ #Normalization
18
+ normalize_attribute :answer, :with => [:squish, :blank]
19
+ end
20
+ end
@@ -0,0 +1,32 @@
1
+ module Formol
2
+ class Poll::Vote < ActiveRecord::Base
3
+ #Security
4
+ attr_protected :voter_id, :option_id
5
+
6
+ #Associations
7
+ belongs_to :option, :counter_cache => true
8
+ belongs_to :voter, :class_name => Formol.config.user_class
9
+
10
+ has_one :poll, :through => :option
11
+
12
+ #Validations
13
+ validates :option, :presence => true
14
+ validates :voter, :presence => true
15
+
16
+ #Callbacks
17
+ after_create :increment_poll_votes_count
18
+ after_destroy :decrement_poll_votes_count
19
+
20
+ private
21
+
22
+ #Callbacks methods
23
+
24
+ def increment_poll_votes_count
25
+ Formol::Poll.increment_counter(:votes_count, poll.id)
26
+ end
27
+
28
+ def decrement_poll_votes_count
29
+ Formol::Poll.decrement_counter(:votes_count, poll.id)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,107 @@
1
+ module Formol
2
+ class Poll < ActiveRecord::Base
3
+ #Exceptions
4
+ class PollException < Exception; end
5
+ class TooManyVotesError < PollException; end
6
+ class UserAlreadyVoter < PollException; end
7
+ class NoOptionProvided < PollException; end
8
+ class TopicLockedError < PollException; end
9
+ class PollExpiredError < PollException; end
10
+
11
+ #Security
12
+ attr_protected :topic_id
13
+ attr_readonly :topic_id
14
+
15
+ #Virtual attributes
16
+ attr_accessor :poll_vote_ids #to not conflict with has_many :votes
17
+
18
+ #Default values
19
+ default_value_for :duration, 1.week.to_i
20
+ default_value_for :max_options, 1 #default to single mode
21
+
22
+ #Associations
23
+ belongs_to :topic
24
+
25
+ has_many :options
26
+ has_many :votes, :through => :options
27
+ has_many :voters, :through => :votes,
28
+ :class_name => Formol.config.user_class,
29
+ :uniq => true
30
+
31
+ #Nested attributes
32
+ accepts_nested_attributes_for :options, :limit => 20
33
+
34
+ #Validations
35
+ validates :topic, :presence => true
36
+ validates :question, :presence => true,
37
+ :length => { :in => 5..64,
38
+ :allow_blank => true }
39
+ validates :duration, :presence => true,
40
+ :numericality => { :only_integer => true,
41
+ :greater_than => 0,
42
+ :less_than => 3.months.to_i,
43
+ :allow_blank => true }
44
+ validates :max_options, :presence => true,
45
+ :numericality => { :only_integer => true,
46
+ :greater_than => 0,
47
+ :less_than_or_equal_to => Proc.new{ |p| p.options.length },
48
+ :allow_blank => true }
49
+ validates :options, :length => { :in => 1..20 }
50
+
51
+ #Normalization
52
+ normalize_attribute :question, :with => [:squish, :blank]
53
+
54
+ #Callbacks
55
+ before_validation :setup_options_assoc, :on => :create
56
+
57
+ #Business methods
58
+
59
+ def multiple?
60
+ max_options > 1
61
+ end
62
+
63
+ def single?
64
+ !multiple?
65
+ end
66
+
67
+ def expired?
68
+ expires_at < Time.now
69
+ end
70
+
71
+ def expires_at
72
+ created_at + duration.to_i
73
+ end
74
+
75
+ def voted?(user)
76
+ voters.include?(user)
77
+ end
78
+
79
+ def vote(user, option_ids)
80
+ option_ids = [option_ids] unless option_ids.respond_to?(:each)
81
+ option_ids.reject!(&:blank?)
82
+ option_ids.compact!
83
+
84
+ raise NoOptionProvided.new if option_ids.empty?
85
+ raise TooManyVotesError.new if option_ids.length > max_options
86
+ raise UserAlreadyVoter.new if voted?(user)
87
+ raise TopicLockedError.new if topic.locked?
88
+ raise PollExpiredError.new if expired?
89
+
90
+ options.find(option_ids).to_a.each do |option| #?
91
+ option.votes.create!(:voter => user)
92
+ end
93
+ end
94
+
95
+ private
96
+
97
+ #Callback methods
98
+
99
+ # set poll for each options
100
+ # AR doesn't do it by itself and it ruins validations
101
+ def setup_options_assoc
102
+ options.each do |o|
103
+ o.poll = self
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,162 @@
1
+ module Formol
2
+ class Post < ActiveRecord::Base
3
+ #Security
4
+ attr_protected :topic_id, :user_id, :created_by_topic
5
+
6
+ #Virtual attributes
7
+ attr_accessor :created_by_topic, :quote_id, :register_user_as_subscriber
8
+
9
+ #Default values
10
+ default_value_for :created_by_topic, false
11
+ default_value_for :display_signature do |p|
12
+ p.user ? p.user.preference.use_signature : true
13
+ end
14
+
15
+ #Associations
16
+ belongs_to :topic, :touch => true,
17
+ :counter_cache => true
18
+ belongs_to :user, :class_name => Formol.config.user_class,
19
+ :counter_cache => :formol_posts_count
20
+
21
+ has_one :forum, :through => :topic
22
+
23
+ #Validations
24
+ validates :topic, :presence => true
25
+ validates :user, :presence => true
26
+ #Length is set with a minimum of 2 to allow posts with simple "ok" answer
27
+ #Length is set with a maximum of 50 000 because 50Kb is enough for a non
28
+ #binary forum
29
+ validates :content, :presence => true,
30
+ :length => { :in => 2..50_000,
31
+ :allow_blank => true }
32
+
33
+ validate :validates_topic_not_locked
34
+
35
+ #Normalization
36
+ normalize_attribute :content, :with => [:strip, :blank]
37
+
38
+ #Callbacks
39
+ after_initialize :feed_content_with_quote
40
+
41
+ after_save :register_author_as_subscriber
42
+ after_create :increment_forum_counter_cache,
43
+ :register_as_last_post_on_topic,
44
+ :register_as_last_post_on_forum
45
+ after_destroy :decrement_forum_counter_cache,
46
+ :unregister_as_last_post_on_topic,
47
+ :unregister_as_last_post_on_forum
48
+
49
+ #Scopes
50
+ class << self
51
+ # scope for ordering posts conveniently for listing views
52
+ # order by id (no order is random with mysql)
53
+ def ordered_for_listing
54
+ order('formol_posts.id ASC')
55
+ end
56
+
57
+ # scope to get posts conveniently prepared for listing
58
+ def ready_for_listing
59
+ ordered_for_listing.includes(:topic, { :user => :preference })
60
+ end
61
+
62
+ # scope to get last five posts with reverse ordering
63
+ # usefull to have a little history when replying to a topic
64
+ def last_fives
65
+ order('formol_posts.id DESC').limit(5).includes(:user)
66
+ end
67
+ end
68
+
69
+ #Business methods
70
+
71
+ # Read accessor for 'register_user_as_subscriber'
72
+ def register_user_as_subscriber(force_recompute = false)
73
+ if @register_user_as_subscriber.nil? || force_recompute
74
+ @register_user_as_subscriber = compute_register_subscribing
75
+ end
76
+
77
+ @register_user_as_subscriber
78
+ end
79
+
80
+ private
81
+
82
+ # Convert 'register_user_as_subscriber' to boolean
83
+ def register_user_as_subscriber?
84
+ [true, '1', 'true'].include?(register_user_as_subscriber)
85
+ end
86
+
87
+ #Callback methods
88
+
89
+ # Registers author as subscriber if he's not already
90
+ def register_author_as_subscriber
91
+ if register_user_as_subscriber?
92
+ topic.register_subscriber(user) unless topic.subscriber?(user)
93
+ else
94
+ topic.unregister_subscriber(user) if topic.subscriber?(user)
95
+ end
96
+ end
97
+
98
+ # Validates topic is not locked
99
+ # If topic is locked, it will add an error on base (not on topic)
100
+ # This validation does not trigger on first post
101
+ def validates_topic_not_locked
102
+ errors.add(:base, :topic_locked) if topic.locked? && !created_by_topic
103
+ end
104
+
105
+ # Holds counter cache on Forum till AR does not support it on has_one
106
+ def increment_forum_counter_cache
107
+ Formol::Forum.increment_counter(:posts_count, forum.id) if forum.present?
108
+ end
109
+
110
+ # Holds counter cache on Forum till AR does not support it on has_one
111
+ def decrement_forum_counter_cache
112
+ Formol::Forum.decrement_counter(:posts_count, forum.id) if forum.present?
113
+ end
114
+
115
+ # Feed content with quote's content
116
+ # If you're trying to pass quote_id in a POST, your content will be wiped
117
+ # Too bad!
118
+ def feed_content_with_quote
119
+ return true if quote_id.blank?
120
+
121
+ q = topic.posts.find(quote_id)
122
+ formatter = Formol.config.formatter.new
123
+
124
+ # Extract and agnostize it
125
+ content_with_quote = "> **#{q.user.display_name} #{I18n.t('.wrote')}:**\n>\n"
126
+ content_with_quote << formatter.quote(q.content)
127
+
128
+ self.content = content_with_quote
129
+ self.content << "\n\n" #use '<<' to skip normalize_attribute
130
+ self.content
131
+ end
132
+
133
+ # Register current post being the last of its topic
134
+ def register_as_last_post_on_topic
135
+ topic.register_last_post(self)
136
+ end
137
+
138
+ # Unregister current post being the last of its topic
139
+ def unregister_as_last_post_on_topic
140
+ topic.unregister_last_post if topic.last_post == self
141
+ end
142
+
143
+ # Register current post being the last post of its forum
144
+ def register_as_last_post_on_forum
145
+ forum.register_last_post(self)
146
+ end
147
+
148
+ # Unregister current post being the last of its forum
149
+ def unregister_as_last_post_on_forum
150
+ forum.unregister_last_post if forum.last_post == self
151
+ end
152
+
153
+ # Computes 'register_user_as_subscriber
154
+ def compute_register_subscribing
155
+ if topic && user
156
+ topic.subscriber?(user)
157
+ else
158
+ false
159
+ end
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,9 @@
1
+ module Formol
2
+ class PostObserver < ActiveRecord::Observer
3
+ def after_create(post)
4
+ post.topic.subscribers.each do |subscriber|
5
+ SubscriptionMailer.topic_reply(post, subscriber).deliver
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,16 @@
1
+ module Formol
2
+ class Topic::Subscription < ActiveRecord::Base
3
+ #Security
4
+ attr_protected :subscriber_id, :topic_id
5
+
6
+ #Associations
7
+ belongs_to :subscriber, :class_name => Formol.config.user_class
8
+ belongs_to :topic
9
+
10
+ #Validations
11
+ validates :subscriber, :presence => true
12
+ validates :topic, :presence => true
13
+ validates :subscriber_id, :uniqueness => { :scope => :topic_id,
14
+ :allow_blank => true }
15
+ end
16
+ end
@@ -0,0 +1,57 @@
1
+ module Formol
2
+ class Topic::Track < ActiveRecord::Base
3
+ #Security
4
+ attr_protected :user_id, :topic_id, :forum_id, :marked_at
5
+ attr_readonly :user_id, :topic_id, :forum_id
6
+
7
+ #Association
8
+ belongs_to :user, :class_name => Formol.config.user_class
9
+ belongs_to :topic
10
+ belongs_to :forum
11
+
12
+ #Validations
13
+ validates :user_id, :presence => true
14
+ validates :topic_id, :presence => true
15
+ validates :forum_id, :presence => true
16
+ validates :marked_at, :presence => true
17
+
18
+ #Callbacks
19
+ before_validation :assign_forum, :on => :create
20
+ before_validation :touch_marked_at
21
+
22
+ #Business methods
23
+
24
+ class << self
25
+ # mark a given topic for a given user
26
+ # if track already exists, it will be updated otherwhise it will be created
27
+ # handle nil user if forum is browsed without being connected
28
+ def mark_topic_for_user(topic, user)
29
+ return nil if user.nil?
30
+
31
+ track = find_or_initialize_by_topic_id_and_user_id(topic.id, user.id)
32
+ track.mark
33
+
34
+ track
35
+ end
36
+ end
37
+
38
+ def mark
39
+ save
40
+ end
41
+
42
+ private
43
+
44
+ #Callback methods
45
+
46
+ # assigns forum from topic
47
+ # convenient to know if a forum is read or not
48
+ def assign_forum
49
+ self.forum = topic.forum if topic
50
+ end
51
+
52
+ # assigns current time to marked_at
53
+ def touch_marked_at
54
+ self.marked_at = Time.now
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,185 @@
1
+ module Formol
2
+ class Topic < ActiveRecord::Base
3
+ #Modules
4
+ include Tracking
5
+
6
+ #Security
7
+ attr_protected :forum_id, :user_id, :first_post_id, :last_post_id,
8
+ :posts_count, :views_count
9
+
10
+ #Virtual attributes
11
+ attr_accessor :attach_poll
12
+
13
+ #Default values
14
+ default_value_for :attach_poll, false
15
+
16
+ #Associations
17
+ belongs_to :forum, :counter_cache => true
18
+ belongs_to :user, :class_name => Formol.config.user_class
19
+ belongs_to :first_post, :class_name => 'Formol::Post'
20
+ belongs_to :last_post, :class_name => 'Formol::Post'
21
+
22
+ has_many :posts, :dependent => :destroy
23
+ has_many :subscriptions, :dependent => :destroy
24
+ has_many :subscribers, :through => :subscriptions
25
+
26
+ has_one :poll
27
+
28
+ #Nested attributes
29
+ accepts_nested_attributes_for :posts, :limit => 1
30
+ accepts_nested_attributes_for :poll
31
+
32
+ #Validations
33
+ validates :title, :presence => true,
34
+ :length => { :in => 3..48,
35
+ :allow_blank => true }
36
+ validates :forum, :presence => true
37
+ validates :user, :presence => true
38
+ validates :posts, :presence => true
39
+ validates :first_post, :presence => true
40
+
41
+ #Normalization
42
+ normalize_attribute :title, :with => [:squish, :blank]
43
+
44
+ #Callbacks
45
+ before_validation :setup_first_post_assoc, :on => :create
46
+ before_validation :setup_poll_assoc, :on => :create
47
+ before_validation :reject_poll_if_needed
48
+
49
+ #Scopes
50
+ class << self
51
+ # scope for ordering topics conveniently for listing views
52
+ # order by updated_at and id
53
+ # id is used as discard if two topics have been updated at the same second
54
+ def ordered_for_listing
55
+ order('formol_topics.pinned DESC').
56
+ order('formol_topics.updated_at DESC').
57
+ order('formol_topics.id DESC')
58
+ end
59
+
60
+ # scope to get topics conveniently prepared for listing
61
+ def ready_for_listing
62
+ ordered_for_listing.includes(:user, { :last_post => :user }, :posts)
63
+ end
64
+
65
+ # this scope eager loads conveniently topic for using it in breadcrumb
66
+ def ready_for_breadcrumb
67
+ includes(:forum => :category)
68
+ end
69
+
70
+ # return all unread topics for a given forum and a given user
71
+ # A topic is unread if it's
72
+ # * tracked for given user and marked_at is after last post's created_at
73
+ # * not tracked and created after user registered
74
+ def unread(user, opts = {})
75
+ unread_scope_base(user, opts)
76
+ .select('formol_topics.id')
77
+ .joins(:last_post)
78
+ end
79
+ end
80
+
81
+ #Business methods
82
+
83
+ # Increments views counter
84
+ def view!
85
+ # Use class method instead of instance to avoid concurrence problems
86
+ self.class.increment_counter(:views_count, id)
87
+ end
88
+
89
+ # Track current topic for a given user
90
+ def track_for_user!(user)
91
+ Track.mark_topic_for_user(self, user)
92
+ end
93
+
94
+ # Return if one post is the first post or not
95
+ # Should be in Formol::Post class but while :inverse_of does not work
96
+ # on has_many, optimization is easier in this sense
97
+ def first_post?(post)
98
+ post == first_post
99
+ end
100
+
101
+ # Register a post being the last one of the current topic
102
+ def register_last_post(post)
103
+ update_attribute(:last_post, post)
104
+ end
105
+
106
+ # Unregister a post by re-assigning last post.
107
+ # Needed when a post is destroyed
108
+ def unregister_last_post
109
+ update_attribute(:last_post, posts(true).last)
110
+ end
111
+
112
+ # Register a subscriber to the current topic
113
+ def register_subscriber(subscriber)
114
+ subscriptions.create!(:subscriber => subscriber, :topic => self)
115
+ end
116
+
117
+ # Remove a user from subscribers
118
+ def unregister_subscriber(subscriber)
119
+ #subscriptions.where(:subscriber_id => subscriber.id).try(:destroy)
120
+ subscribers.destroy(subscriber)
121
+ end
122
+
123
+ # Tells if a user has subscribed (or not) to the current topic
124
+ def subscriber?(user)
125
+ subscriptions.where(:subscriber_id => user.id).exists?
126
+ end
127
+
128
+ private
129
+
130
+ #Callback methods
131
+
132
+ # Setup poll association
133
+ def setup_poll_assoc
134
+ poll.topic = self if poll
135
+ end
136
+
137
+ # Setup first post's associations
138
+ def setup_first_post_assoc
139
+ #register first post
140
+ self.first_post = posts.first
141
+
142
+ set_first_post_topic
143
+ set_first_post_user
144
+ set_first_post_created_by_topic
145
+ end
146
+
147
+ # Set the first post's user
148
+ # It's just the same as topics
149
+ def set_first_post_user
150
+ # done with 'each' and not 'first' to avoid empty association
151
+ posts.each do |p|
152
+ p.user = self.user
153
+ end
154
+ end
155
+
156
+ # Set the first post's topic
157
+ # If not, nested attributes triggers presence validation of topic in
158
+ # post model and this last fails
159
+ def set_first_post_topic
160
+ # done with 'each' and not 'first' to avoid empty association
161
+ # if association is empty or contains more than 1 post,
162
+ # validations handle it, not this method
163
+ posts.each do |p|
164
+ p.topic = self
165
+ end
166
+ end
167
+
168
+ # Inform first post's topic it's the first one
169
+ # Needed to not perform 'locked' validation
170
+ def set_first_post_created_by_topic
171
+ # done with 'each' and not 'first' to avoid empty association
172
+ # if association is empty or contains more than 1 post,
173
+ # validations handle it, not this method
174
+ posts.each do |p|
175
+ p.created_by_topic = true
176
+ end
177
+ end
178
+
179
+ # Reject poll if attach_poll is false
180
+ # Needed because poll's default values perform validations
181
+ def reject_poll_if_needed
182
+ self.poll = nil if poll && !attach_poll
183
+ end
184
+ end
185
+ end