Changesets can be listed by changeset number.
The Git repository is here.
- Revision:
- 457
- Log:
Add admin ability to revert an underway poll to an open state.
More sensible entries now presented in the state change menu.
- Author:
- rool
- Date:
- Thu Oct 24 05:58:50 +0100 2013
- Size:
- 12399 Bytes
1 | ######################################################################## |
2 | # File:: poll.rb |
3 | # (C):: Hipposoft 2011 |
4 | # |
5 | # Purpose:: Describe a bounty poll. |
6 | # ---------------------------------------------------------------------- |
7 | # 30-Jan-2011 (ADH): Created. |
8 | ######################################################################## |
9 | |
10 | class Poll < ActiveRecord::Base |
11 | |
12 | acts_as_audited :protect => false, :except => [ :total_for_sorting ] |
13 | |
14 | belongs_to :user |
15 | belongs_to :currency |
16 | has_many :donations |
17 | |
18 | # Limitations and requirements. |
19 | |
20 | MAXLEN_TITLE = 60 |
21 | MAXLEN_STATE = 16 # See STATE MACHINE below |
22 | |
23 | validates_presence_of :title, |
24 | :description, |
25 | :workflow_state |
26 | |
27 | validates_numericality_of :total_integer, |
28 | :total_fraction, |
29 | |
30 | :only_integer => true |
31 | |
32 | validate :currency_alteration_is_permitted |
33 | |
34 | def currency_alteration_is_permitted |
35 | if ( changes.has_key?( 'currency_id' ) && votes > 0 ) |
36 | errors.add( :currency_id, :cannot_change_currency ) |
37 | end |
38 | end |
39 | |
40 | attr_accessible :title, |
41 | :description, |
42 | :currency_id |
43 | |
44 | # Keep a for-sorting cache column up to date. |
45 | # |
46 | def update_sorting_amount |
47 | amount = Currency.simplify( |
48 | self.total_integer, |
49 | self.total_fraction |
50 | ).to_f |
51 | |
52 | self.total_for_sorting = amount |
53 | end |
54 | |
55 | before_save :update_sorting_amount |
56 | |
57 | # How many entries to list per index page? See the Will Paginate plugin: |
58 | # |
59 | # http://wiki.github.com/mislav/will_paginate |
60 | |
61 | def self.per_page |
62 | MAXIMUM_LIST_ITEMS_PER_PAGE |
63 | end |
64 | |
65 | # Search columns for views rendering the "shared/_simple_search.html.erb" |
66 | # view partial and using "appctrl_build_search_conditions" to handle queries. |
67 | |
68 | SEARCH_COLUMNS = %w{ workflow_state#pollhelp_search_states title description total_for_sorting } |
69 | |
70 | # Set up sorting based on current locale. See Application Controller's |
71 | # "set_language_dependent_sorting" method for details. |
72 | |
73 | SORT_COLUMNS = %w{title workflow_state votes total_for_sorting} |
74 | |
75 | def self.set_sorting |
76 | columns = self.translated_sort_columns() |
77 | sort_on( *columns ) |
78 | end |
79 | |
80 | # =========================================================================== |
81 | # TRANSLATION |
82 | # =========================================================================== |
83 | |
84 | # See "prepare_model_for_translation" in "translations_controller.rb" and the |
85 | # migrations. |
86 | # |
87 | # (In Canvass, the Translations Controller is not present - this was imported |
88 | # from Artisan which has a full GUI for translation editing and creation). |
89 | # |
90 | def self.columns_for_translation |
91 | [ 'title', 'description' ] |
92 | end |
93 | |
94 | def self.column_type( name ) |
95 | case name.to_sym |
96 | when :title |
97 | :string |
98 | when :description |
99 | :text |
100 | end |
101 | end |
102 | |
103 | def self.column_options( name ) |
104 | case name.to_sym |
105 | when :title |
106 | { :limit => Poll::MAXLEN_TITLE } |
107 | when :description |
108 | {} |
109 | end |
110 | end |
111 | |
112 | # See the "translatable_columns" plugin: |
113 | # |
114 | # http://github.com/iain/translatable_columns/tree/master |
115 | # http://iain.nl/2008/09/plugin-translatable_columns/ |
116 | |
117 | translatable_columns( *columns_for_translation() ) |
118 | |
119 | def self.translated_column( name ) |
120 | Translation.translated_column( self, name ) # See this for details |
121 | end |
122 | |
123 | def self.untranslated_column( name_with_locale ) |
124 | Translation.untranslated_column( self, name_with_locale ) # See this for details |
125 | end |
126 | |
127 | # Return a list of translated, sortable columns. |
128 | # |
129 | def self.translated_sort_columns |
130 | SORT_COLUMNS.map { | name | self.translated_column( name ) } |
131 | end |
132 | |
133 | # =========================================================================== |
134 | # PERMISSIONS |
135 | # =========================================================================== |
136 | |
137 | # N/A |
138 | |
139 | # =========================================================================== |
140 | # STATE MACHINE |
141 | # =========================================================================== |
142 | |
143 | STATE_INITIAL = :initial # Unused and not valid but kept for convenience and use with InvoiceableHelper methods |
144 | |
145 | STATE_OPEN = :a_open |
146 | STATE_UNDERWAY = :b_underway |
147 | STATE_COMPLETED = :c_completed |
148 | STATE_EXPIRED = :d_expired |
149 | |
150 | # Must use "table_exists?", as Workflow needs to check the database but this |
151 | # class may be examined by migrations before the table is created. |
152 | |
153 | if ( Poll.table_exists? ) |
154 | include Workflow # http://github.com/geekq/workflow |
155 | workflow do |
156 | |
157 | # *********************************************************************** |
158 | # WARNING! You must always wrap state changes herein with a transaction |
159 | # block as other objects may be updated as a result of the state change; |
160 | # inconsistencies will result if one or more of these alterations fail |
161 | # without rollback. |
162 | # |
163 | # WARNING! Take note of the error flagging behaviour of transitions into |
164 | # the STATE_EXPIRED state. |
165 | # *********************************************************************** |
166 | |
167 | # STATE_OPEN: The poll has been created and people can vote on it. |
168 | # |
169 | state STATE_OPEN do |
170 | event :underway, :transitions_to => STATE_UNDERWAY |
171 | event :expired, :transitions_to => STATE_EXPIRED |
172 | end |
173 | |
174 | # STATE_UNDERWAY: The poll has received sufficient votes to attract a |
175 | # developer and work on the associated feature is underway. The poll |
176 | # cannot be voted for. |
177 | # |
178 | # Administrators can still choose to expire a poll if they wish, should |
179 | # the developer be taking "too long" to complete the work. That's up to |
180 | # individual administrators or organisations to assess. |
181 | # |
182 | # Administrators may choose to revert a poll, if it turns out it was |
183 | # not really being developed or a developer halts work. The poll is |
184 | # still wanted, so not expired; it returns to an OPEN state. No money |
185 | # is redistributed. |
186 | # |
187 | # Organisations may choose to pay developers before or after they |
188 | # complete work. The general recommendation is to do so only when the |
189 | # poll reaches a STATE_COMPLETED state, but again, individual |
190 | # administrators or organisations need to decide this themselves. |
191 | # |
192 | state STATE_UNDERWAY do |
193 | event :completed, :transitions_to => STATE_COMPLETED |
194 | event :expired, :transitions_to => STATE_EXPIRED |
195 | event :reverted, :transitions_to => STATE_OPEN |
196 | end |
197 | |
198 | # STATE_COMPLETED: Work on the poll completed; the associated feature is |
199 | # implemented to the satisfaction of the administrators. The poll is |
200 | # now closed/archived and cannot be voted for. |
201 | # |
202 | # The developer(s) who worked on the feature must now be paid, if they |
203 | # haven't already (this is something for administrators to do outside |
204 | # of Canvass, usually through e.g. PayPal). |
205 | # |
206 | state STATE_COMPLETED do |
207 | end |
208 | |
209 | # STATE_EXPIRED: For any reason, administrators may choose to expire a |
210 | # poll. Money allocated to this poll will be redistributed to all other |
211 | # open polls in a linear fashion. |
212 | # |
213 | # If an exception is raised with an empty message, then an error message |
214 | # has been added to the record's "workflow_state" attribute - this will |
215 | # indicate that there are no other open polls using the same currency so |
216 | # donations cannot be redistributed (only if "this" poll has non-zero |
217 | # votes). Non-empty messages indicate a genuine, unexpected fault. |
218 | # |
219 | state STATE_EXPIRED do |
220 | on_entry do |
221 | |
222 | unless ( self.votes.zero? ) |
223 | conditions = { |
224 | :conditions => { |
225 | :workflow_state => Poll::STATE_OPEN.to_s, |
226 | :currency_id => self.currency_id |
227 | } |
228 | } |
229 | |
230 | open_poll_count = Poll.count( conditions ) |
231 | |
232 | if ( open_poll_count.zero? ) |
233 | self.errors.add( :workflow_state, :no_others_open ) |
234 | raise "" # (sic.) - see comments above 'state STATE_EXPIRED do'. |
235 | end |
236 | |
237 | Poll.transaction do |
238 | self.lock! |
239 | |
240 | # Create a redistribution donation for the expired poll. |
241 | |
242 | take_donation = Donation.new |
243 | take_donation.redistribution = true |
244 | take_donation.debit = true |
245 | take_donation.amount_integer = self.total_integer |
246 | take_donation.amount_fraction = self.total_fraction |
247 | take_donation.user_id = 0 |
248 | take_donation.user_name = "-" |
249 | take_donation.user_email = "-" |
250 | take_donation.poll = self |
251 | take_donation.poll_title = self.title |
252 | take_donation.currency = self.currency |
253 | |
254 | take_donation.save! |
255 | take_donation.paid! |
256 | |
257 | self.votes += 1 # Saving happens later, see below |
258 | |
259 | # To avoid rounding errors, keep dividing the amount left in this |
260 | # pot by the number of other open polls to which we have yet to |
261 | # redistribute funds. Round the result according to the poll's |
262 | # currency, add it to one of the found other open polls, subtract |
263 | # it from this poll's total, then go around the loop again, |
264 | # re-dividing over and over until eventually at the last poll |
265 | # there's a divide-by-one as the last remaining amount is added. |
266 | |
267 | polls_remaining = open_poll_count |
268 | conditions[ :lock ] = true |
269 | |
270 | Poll.find_each( conditions ) do | poll | |
271 | |
272 | give_integer, give_fraction = Currency.divide( |
273 | self.total_integer, self.total_fraction, |
274 | polls_remaining.to_s |
275 | ) |
276 | |
277 | give_integer, give_fraction = self.currency.round( |
278 | Currency.simplify( give_integer, give_fraction ) |
279 | ).split( '.' ) |
280 | |
281 | if ( give_integer != "0" || give_fraction != "0" ) |
282 | |
283 | give_donation = Donation.new |
284 | give_donation.redistribution = true |
285 | give_donation.source_poll_id = self.id |
286 | give_donation.source_poll_title = self.title |
287 | give_donation.amount_integer = give_integer |
288 | give_donation.amount_fraction = give_fraction |
289 | give_donation.user_id = 0 |
290 | give_donation.user_name = "-" |
291 | give_donation.user_email = "-" |
292 | give_donation.poll = poll |
293 | give_donation.poll_title = poll.title |
294 | give_donation.currency = poll.currency |
295 | |
296 | give_donation.save! |
297 | give_donation.paid! |
298 | |
299 | self.total_integer, self.total_fraction = Currency.subtract( |
300 | self.total_integer, self.total_fraction, |
301 | give_integer, give_fraction |
302 | ) |
303 | |
304 | end |
305 | |
306 | polls_remaining -= 1 |
307 | |
308 | end # 'Poll.find_each... do...' |
309 | |
310 | # There must always be *exactly* nothing left. |
311 | |
312 | unless ( self.total_integer == '0' && self.total_fraction == '0' ) |
313 | raise "Internal mathematical error during donation redistribution" |
314 | end |
315 | |
316 | self.save! |
317 | |
318 | end # 'Poll.transaction do' |
319 | end # 'unless ( self.votes.zero? )' |
320 | end # 'on_entry do' |
321 | end # 'state STATE_EXPIRED do' |
322 | end # 'workflow do' |
323 | end # 'if ( Poll.table_exists? )' |
324 | |
325 | # =========================================================================== |
326 | # GENERAL |
327 | # =========================================================================== |
328 | |
329 | # Apply a default sort to the given array of model instances. The array is |
330 | # modified in place. |
331 | # |
332 | def self.apply_default_sort_order( array ) |
333 | array.sort! { | x, y | x.title <=> y.title } |
334 | end |
335 | |
336 | def thingytest |
337 | I18n.t("Hello") |
338 | end |
339 | |
340 | # Returns an array of state name symbols to which this object may be |
341 | # transitioned given its current state. |
342 | # |
343 | def allowed_new_states |
344 | allowed_transitions = self.current_state.events.values.collect do | event | |
345 | event.transitions_to |
346 | end |
347 | end |
348 | end |