formol 0.0.4

Sign up to get free protection for your applications and to get access to all the features.
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