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