This repository has been archived by the owner on Nov 9, 2017. It is now read-only.
/
vote.py
507 lines (395 loc) · 16 KB
/
vote.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
# The contents of this file are subject to the Common Public Attribution
# License Version 1.0. (the "License"); you may not use this file except in
# compliance with the License. You may obtain a copy of the License at
# http://code.reddit.com/LICENSE. The License is based on the Mozilla Public
# License Version 1.1, but Sections 14 and 15 have been added to cover use of
# software over a computer network and provide for limited attribution for the
# Original Developer. In addition, Exhibit A has been modified to be consistent
# with Exhibit B.
#
# Software distributed under the License is distributed on an "AS IS" basis,
# WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for
# the specific language governing rights and limitations under the License.
#
# The Original Code is reddit.
#
# The Original Developer is the Initial Developer. The Initial Developer of
# the Original Code is reddit Inc.
#
# All portions of the code written by reddit are Copyright (c) 2006-2015 reddit
# Inc. All Rights Reserved.
###############################################################################
import collections
from datetime import datetime, timedelta
import json
from uuid import uuid1
from pycassa.types import CompositeType, AsciiType
from pycassa.system_manager import TIME_UUID_TYPE
from pylons import app_globals as g
import pytz
from r2.lib import hooks
from r2.lib.db import tdb_cassandra
from r2.lib.db.tdb_cassandra import (
ASCII_TYPE,
UTF8_TYPE,
)
from r2.lib.utils import Enum, epoch_timestamp
from r2.models import Account
class Vote(object):
DIRECTIONS = Enum("up", "down", "unvote")
SERIALIZED_DIRECTIONS = {
DIRECTIONS.up: 1,
DIRECTIONS.down: -1,
DIRECTIONS.unvote: 0,
}
DESERIALIZED_DIRECTIONS = {
v: k for k, v in SERIALIZED_DIRECTIONS.iteritems()}
def __init__(self, user, thing, direction, date, data=None, effects=None,
get_previous_vote=True, event_data=None):
if not thing.is_votable:
raise TypeError("Can't create vote on unvotable thing %s" % thing)
if direction not in self.DIRECTIONS:
raise ValueError("Invalid vote direction: %s" % direction)
self.user = user
self.thing = thing
self.direction = direction
self.date = date.replace(tzinfo=g.tz)
self.data = data
self.event_data = event_data
# see if the user has voted on this thing before
if get_previous_vote:
self.previous_vote = VoteDetailsByThing.get_vote(user, thing)
if self.previous_vote:
# XXX: why do we keep the old date?
self.date = self.previous_vote.date.replace(tzinfo=g.tz)
else:
self.previous_vote = None
self.effects = VoteEffects(self, effects)
def __eq__(self, other):
return (self.user == other.user and
self.thing == other.thing and
self.direction == other.direction)
def __ne__(self, other):
return not self == other
@classmethod
def serialize_direction(cls, direction):
"""Convert the DIRECTIONS enum to values used when storing."""
if direction not in cls.DIRECTIONS:
raise ValueError("Invalid vote direction: %s" % direction)
return cls.SERIALIZED_DIRECTIONS[direction]
@classmethod
def deserialize_direction(cls, direction):
"""Convert stored vote direction value back to DIRECTIONS enum."""
direction = int(direction)
if direction not in cls.DESERIALIZED_DIRECTIONS:
raise ValueError("Invalid vote direction: %s" % direction)
return cls.DESERIALIZED_DIRECTIONS[direction]
@property
def _id(self):
return "%s_%s" % (self.user._id36, self.thing._id36)
@property
def affected_thing_attr(self):
"""The attr on the thing this vote will increment."""
if not self.effects.affects_score:
return None
if self.is_upvote:
return "_ups"
elif self.is_downvote:
return "_downs"
@property
def is_upvote(self):
return self.direction == self.DIRECTIONS.up
@property
def is_downvote(self):
return self.direction == self.DIRECTIONS.down
@property
def is_self_vote(self):
"""Whether the voter is also the author of the thing voted on."""
return self.user._id == self.thing.author_id
@property
def is_automatic_initial_vote(self):
"""Whether this is the automatic vote cast on things when posted."""
return self.is_self_vote and not self.previous_vote
@property
def delay(self):
"""How long after the thing was posted that the vote was cast."""
if self.is_automatic_initial_vote:
return timedelta(0)
return self.date - self.thing._date
def apply_effects(self):
"""Apply the effects of the vote to the thing that was voted on."""
# remove the old vote
if self.previous_vote and self.previous_vote.affected_thing_attr:
self.thing._incr(self.previous_vote.affected_thing_attr, -1)
# add the new vote
if self.affected_thing_attr:
self.thing._incr(self.affected_thing_attr, 1)
if self.effects.affects_karma:
change = self.effects.karma_change
if self.previous_vote:
change -= self.previous_vote.effects.karma_change
if change:
self.thing.author_slow.incr_karma(
kind=self.thing.affects_karma_type,
sr=self.thing.subreddit_slow,
amt=change,
)
hooks.get_hook("vote.apply_effects").call(vote=self)
def commit(self):
"""Apply the vote's effects and persist it."""
if self.previous_vote and self == self.previous_vote:
return
self.apply_effects()
VotesByAccount.write_vote(self)
# Always update the search index if the thing has fewer than 20 votes.
# When the thing has more votes queue an update less often.
if self.thing.num_votes < 20 or self.thing.num_votes % 10 == 0:
self.thing.update_search_index(boost_only=True)
if self.event_data:
g.events.vote_event(self)
g.stats.simple_event('vote.total')
class VoteEffects(object):
"""Contains details about how a vote affects the thing voted on."""
def __init__(self, vote, effects=None):
"""Initialize a new set of vote effects.
If a dict of previously-determined effects are passed in as `effects`,
those will be used instead of calculating the effects.
"""
self.note_codes = {}
self.validator = None
if effects:
self.affects_score = effects.pop("affects_score")
self.affects_karma = effects.pop("affects_karma")
self.other_effects = effects
else:
hook = hooks.get_hook("vote.get_validator")
self.validator = hook.call_until_return(vote=vote, effects=self)
self.affects_score = self.determine_affects_score(vote)
self.affects_karma = self.determine_affects_karma(vote)
self.other_effects = self.determine_other_effects(vote)
self.karma_change = 0
if self.affects_karma:
if vote.is_upvote:
self.karma_change = 1
elif vote.is_downvote:
self.karma_change = -1
def add_note(self, code, message=None):
self.note_codes[code] = message
@property
def notes(self):
notes = []
for code, message in self.note_codes.iteritems():
note = code
if message:
note += " (%s)" % message
notes.append(note)
return notes
def determine_affects_score(self, vote):
"""Determine whether the vote should affect the thing's score."""
# If it's the automatic upvote on the user's own post, it won't affect
# the score because we create it with a score of 1 already.
if vote.is_automatic_initial_vote:
self.add_note("AUTOMATIC_INITIAL_VOTE")
return False
if vote.previous_vote:
if not vote.previous_vote.effects.affects_score:
self.add_note("PREVIOUS_VOTE_NO_EFFECT")
return False
if self.validator:
affects_score = self.validator.determine_affects_score()
if affects_score is not None:
return affects_score
return True
def determine_affects_karma(self, vote):
"""Determine whether the vote should affect the author's karma."""
from r2.models import Comment
if not self.affects_score:
return False
if vote.previous_vote:
if not vote.previous_vote.effects.affects_karma:
self.add_note("PREVIOUS_VOTE_NO_KARMA")
return False
if not bool(vote.thing.affects_karma_type):
self.add_note("KARMALESS_THING")
return False
# never give karma on stickied comments. Only check distinguished
# comments to avoid fetching the link on most votes, for performance.
if isinstance(vote.thing, Comment) and vote.thing.is_distinguished:
link = vote.thing.link_slow
if vote.thing._id == link.sticky_comment_id:
self.add_note("COMMENT_STICKIED")
return False
if self.validator:
affects_karma = self.validator.determine_affects_karma()
if affects_karma is not None:
return affects_karma
return True
def determine_other_effects(self, vote):
"""Determine any other effects of the vote."""
other_effects = {}
if self.validator:
other_effects.update(self.validator.other_effects)
return other_effects
@property
def serializable_data(self):
"""Return the effects data in a format suitable for storing."""
data = {
"affects_score": self.affects_score,
"affects_karma": self.affects_karma,
}
for key, value in self.other_effects.iteritems():
data[key] = value
if self.notes:
data["notes"] = ", ".join(self.notes)
return data
class VotesByAccount(tdb_cassandra.DenormalizedRelation):
_use_db = False
_read_consistency_level = tdb_cassandra.CL.ONE
@classmethod
def rel(cls, thing_cls):
from r2.models import Comment, Link
if thing_cls == Link:
return LinkVotesByAccount
elif thing_cls == Comment:
return CommentVotesByAccount
raise TypeError("Can't find %r class for %r" % (cls, thing_cls))
@classmethod
def write_vote(cls, vote):
rel = cls.rel(vote.thing.__class__)
rel.create(vote.user, vote.thing, vote=vote)
@classmethod
def value_for(cls, thing1, thing2, vote):
return str(Vote.serialize_direction(vote.direction))
class LinkVotesByAccount(VotesByAccount):
_use_db = True
_views = []
_last_modified_name = "LinkVote"
# this is taken care of in r2.lib.voting:cast_vote
_write_last_modified = False
class CommentVotesByAccount(VotesByAccount):
_use_db = True
_views = []
_last_modified_name = "CommentVote"
# this is taken care of in r2.lib.voting:cast_vote
_write_last_modified = False
class VoteDetailsByThing(tdb_cassandra.View):
_use_db = False
_fetch_all_columns = True
_extra_schema_creation_args = dict(key_validation_class=ASCII_TYPE,
default_validation_class=UTF8_TYPE)
@classmethod
def create(cls, user, thing, vote):
# we don't use the user or thing args, but they need to be there for
# calling this automatically when updating views of a DenormalizedRel
vote_data = vote.data.copy()
# pull the IP out of the data to store it separately with a TTL
ip = vote_data.pop("ip")
effects_data = vote.effects.serializable_data
# split the notes out to store separately
notes = effects_data.pop("notes", None)
data = json.dumps({
"direction": Vote.serialize_direction(vote.direction),
"date": int(epoch_timestamp(vote.date)),
"data": vote_data,
"effects": effects_data,
})
cls._set_values(vote.thing._id36, {vote.user._id36: data})
# write the IP data and notes separately so they can be TTLed
if ip:
VoterIPByThing.create(vote, ip)
if notes:
VoteNote.set(vote, notes)
@classmethod
def get_vote(cls, user, thing):
details = cls.get_details(thing, [user])
if details:
return details[0]
return None
@staticmethod
def convert_old_details(old_data):
if "valid_thing" not in old_data:
return old_data
converted_data = {}
converted_data["direction"] = int(old_data.pop("direction"))
converted_data["date"] = int(old_data.pop("date"))
valid_thing = old_data.pop("valid_thing", True)
valid_user = old_data.pop("valid_user", True)
converted_data["effects"] = {
"affects_score": valid_thing,
"affects_karma": valid_user,
}
if old_data:
converted_data["data"] = old_data
return converted_data
@classmethod
def get_details(cls, thing, voters=None):
from r2.models import Comment, Link
if isinstance(thing, Link):
details_cls = VoteDetailsByLink
elif isinstance(thing, Comment):
details_cls = VoteDetailsByComment
else:
raise ValueError
voter_id36s = None
if voters:
voter_id36s = [voter._id36 for voter in voters]
try:
row = details_cls._byID(thing._id36, properties=voter_id36s)
raw_details = row._values()
except tdb_cassandra.NotFound:
return []
try:
row = VoterIPByThing._byID(thing._fullname, properties=voter_id36s)
ips = row._values()
except tdb_cassandra.NotFound:
ips = {}
details = []
for voter_id36, json_data in raw_details.iteritems():
data = json.loads(json_data)
data = cls.convert_old_details(data)
user = Account._byID36(voter_id36, data=True)
direction = Vote.deserialize_direction(data.pop("direction"))
date = datetime.utcfromtimestamp(data.pop("date"))
effects = data.pop("effects")
data["ip"] = ips.get(voter_id36)
vote = Vote(user, thing, direction, date, data, effects,
get_previous_vote=False)
details.append(vote)
details.sort(key=lambda d: d.date)
return details
@tdb_cassandra.view_of(LinkVotesByAccount)
class VoteDetailsByLink(VoteDetailsByThing):
_use_db = True
@tdb_cassandra.view_of(CommentVotesByAccount)
class VoteDetailsByComment(VoteDetailsByThing):
_use_db = True
class VoterIPByThing(tdb_cassandra.View):
_use_db = True
_ttl = timedelta(days=100)
_fetch_all_columns = True
_extra_schema_creation_args = dict(key_validation_class=ASCII_TYPE,
default_validation_class=UTF8_TYPE)
@classmethod
def create(cls, vote, ip):
cls._set_values(vote.thing._fullname, {vote.user._id36: ip})
class VoteNote(tdb_cassandra.View):
_use_db = True
_connection_pool = 'main'
_compare_with = TIME_UUID_TYPE
_ttl = timedelta(days=100)
@classmethod
def _rowkey(cls, vote):
return '%s_%s' % (vote.user._fullname, vote.thing._fullname)
@classmethod
def set(cls, vote, note):
rowkey = cls._rowkey(vote)
column = {uuid1(): note}
cls._set_values(rowkey, column)
@classmethod
def get(cls, vote):
rowkey = cls._rowkey(vote)
try:
all_notes = cls._byID(rowkey)
except tdb_cassandra.NotFound:
return None
return ", ".join(all_notes._values().values())