Permalink
Cannot retrieve contributors at this time
Fetching contributors…
| # 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 CondeNet, Inc. | |
| # | |
| # All portions of the code written by CondeNet are Copyright (c) 2006-2008 | |
| # CondeNet, Inc. All Rights Reserved. | |
| ################################################################################ | |
| from copy import copy | |
| import time, hashlib, urllib2 | |
| from datetime import datetime | |
| from lxml import etree | |
| from geolocator import gislib | |
| from pylons import c, g | |
| from pylons.i18n import _ | |
| import sqlalchemy as sa | |
| from r2.lib import wiki_account | |
| from r2.lib.db import tdb_sql as tdb | |
| from r2.lib.db.thing import Thing, Relation, NotFound | |
| from r2.lib.db.operators import lower | |
| from r2.lib.db.userrel import UserRel | |
| from r2.lib.memoize import memoize, clear_memo | |
| from r2.lib.utils import randstr | |
| from r2.lib.strings import strings, plurals | |
| from r2.lib.base import current_login_cookie | |
| from r2.lib.rancode import random_key | |
| class AccountExists(Exception): pass | |
| class NotEnoughKarma(Exception): pass | |
| class Account(Thing): | |
| _data_int_props = Thing._data_int_props + ('report_made', 'report_correct', | |
| 'report_ignored', 'spammer', | |
| 'reported') | |
| _int_prop_prefixes = ('karma_',) | |
| _defaults = dict(pref_numsites = 10, | |
| pref_frame = False, | |
| pref_newwindow = False, | |
| pref_public_votes = False, | |
| pref_kibitz = False, | |
| pref_hide_ups = False, | |
| pref_hide_downs = True, | |
| pref_min_link_score = -2, | |
| pref_min_comment_score = -2, | |
| pref_num_comments = g.num_comments, | |
| pref_lang = 'en', | |
| pref_content_langs = ('en',), | |
| pref_over_18 = False, | |
| pref_compress = False, | |
| pref_organic = True, | |
| pref_show_stylesheets = True, | |
| pref_url = '', | |
| pref_location = '', | |
| pref_latitude = None, | |
| pref_longitude = None, | |
| pref_meetup_notify_enabled = False, | |
| pref_meetup_notify_radius = 50, | |
| pref_show_parent_comments = False, | |
| email = None, | |
| email_validated = True, | |
| confirmation_code = 'abcde', | |
| reported = 0, | |
| report_made = 0, | |
| report_correct = 0, | |
| report_ignored = 0, | |
| spammer = 0, | |
| sort_options = {}, | |
| has_subscribed = False, | |
| pref_media = 'subreddit', | |
| share = {}, | |
| messagebanned = False, | |
| dashboard_visit = datetime(2006,10,1, tzinfo = g.tz), | |
| wiki_association_attempted_at = None, # None or datetime | |
| wiki_account = None # None, str(account name) or the special string '__taken__', if a new | |
| # user didn't get an account because someone else already had the name. | |
| ) | |
| def karma_ups_downs(self, kind, sr = None): | |
| # NOTE: There is a legacy inconsistency in this method. If no subreddit | |
| # is specified, karma from all subreddits will be totaled, with each | |
| # scaled according to its karma multiplier before being summed. But if | |
| # a subreddit IS specified, the return value will NOT be scaled. | |
| assert kind in ('link', 'comment', 'adjustment') | |
| from subreddit import Subreddit # prevent circular import | |
| # If getting karma for a single sr, it's easy | |
| if sr is not None: | |
| ups = getattr(self, 'karma_ups_{0}_{1}'.format(kind, sr.name), 0) | |
| downs = getattr(self, 'karma_downs_{0}_{1}'.format(kind, sr.name), 0) | |
| return (ups, downs) | |
| # Otherwise, loop through attributes and sum all karmas | |
| totals = [0, 0] | |
| for k, v in self._t.iteritems(): | |
| for pre, idx in (('karma_ups_' + kind + '_', 0), | |
| ('karma_downs_' + kind + '_', 1)): | |
| if k.startswith(pre): | |
| karma_sr_name = k[len(pre):] | |
| index = idx | |
| break | |
| else: | |
| continue | |
| multiplier = 1 | |
| if kind == 'link': | |
| try: | |
| karma_sr = Subreddit._by_name(karma_sr_name) | |
| multiplier = karma_sr.post_karma_multiplier | |
| except NotFound: | |
| pass | |
| totals[index] += v * multiplier | |
| return tuple(totals) | |
| def karma(self, *args): | |
| ud = self.karma_ups_downs(*args) | |
| return ud[0] - ud[1] | |
| def percent_up(self): | |
| ups, downs = self.safe_karma_ups_downs | |
| if not downs: | |
| return 100.0 | |
| else: | |
| return float(ups) / float(ups + downs) * 100 | |
| def incr_karma(self, kind, sr, amt_up, amt_down): | |
| def do_incr(prop, amt): | |
| if hasattr(self, prop): | |
| self._incr(prop, amt) | |
| else: | |
| assert self._loaded | |
| setattr(self, prop, amt) | |
| self._commit() | |
| if amt_up: | |
| do_incr('karma_ups_{0}_{1}'.format(kind, sr.name), amt_up) | |
| if amt_down: | |
| do_incr('karma_downs_{0}_{1}'.format(kind, sr.name), amt_down) | |
| from r2.lib.user_stats import expire_user_change # prevent circular import | |
| expire_user_change(self) | |
| @property | |
| def link_karma(self): | |
| return self.karma('link') | |
| @property | |
| def comment_karma(self): | |
| return self.karma('comment') | |
| @property | |
| def adjustment_karma(self): | |
| return self.karma('adjustment') | |
| @property | |
| def safe_karma_ups_downs(self): | |
| karmas = [self.karma_ups_downs(kind) for kind in 'link', 'comment', 'adjustment'] | |
| return tuple(map(sum, zip(*karmas))) | |
| @property | |
| def safe_karma(self): | |
| pair = self.safe_karma_ups_downs | |
| karma = pair[0] - pair[1] | |
| return max(karma, 0) if karma > -1000 else karma | |
| @property | |
| def monthly_karma_ups_downs(self): | |
| from r2.lib.user_stats import cached_monthly_user_change | |
| return cached_monthly_user_change(self) | |
| @property | |
| def monthly_karma(self): | |
| ret = self.monthly_karma_ups_downs | |
| return ret[0] - ret[1] | |
| WIKI_INVITE = 'We were unable to determine if there is a Less Wrong wiki account registered to your account. If you do not have an account and would like one, please go to [your preferences page](/prefs/wikiaccount).' | |
| def attempt_wiki_association(self): | |
| '''Attempt to find a wiki account with the same name as the user.''' | |
| with g.make_lock('wiki_associate_' + self.name): | |
| if self.wiki_association_attempted_at is not None: return | |
| from r2.models.link import Message | |
| self.wiki_association_attempted_at = datetime.now(g.tz) | |
| self.wiki_account = '__error__' | |
| if wiki_account.valid_name(self.name): | |
| try: | |
| if wiki_account.exists(self.name): | |
| self.wiki_account = self.name | |
| else: | |
| self.wiki_account = None | |
| Message._new(Account._by_name(g.admin_account), | |
| self, 'Wiki Account', Account.WIKI_INVITE, None) | |
| except urllib2.URLError as e: | |
| g.log.error('error in attempt_wiki_association()') | |
| self._commit() | |
| def create_associated_wiki_account(self, password, | |
| on_request_error=None, | |
| on_wiki_error=None): | |
| try: | |
| wiki_account.create(self.name, password, self.email) | |
| self.wiki_account = self.name | |
| self._commit() | |
| return True | |
| except urllib2.URLError as e: | |
| g.log.error('URLError creating wiki account') | |
| g.log.error(e) | |
| if on_request_error is not None: on_request_error() | |
| except wiki_account.WikiError as e: | |
| g.log.error('WikiError creating wiki account') | |
| g.log.error(e) | |
| from r2.lib import emailer | |
| if e.message == 'userexists': | |
| emailer.wiki_user_exists_email(self) | |
| else: | |
| emailer.wiki_failed_email(self) | |
| if on_wiki_error is not None: on_wiki_error() | |
| return False | |
| def downvote_cache_key(self, kind): | |
| """kind is 'link' or 'comment'""" | |
| return 'account_%d_%s_downvotes' % (self._id, kind) | |
| def check_downvote(self, vote_kind): | |
| """Checks whether this account has enough karma to cast a downvote. | |
| vote_kind is 'link' or 'comment' depending on the type of vote that's | |
| being cast. | |
| This makes the assumption that the user can't cast a vote for something | |
| on the non-current subreddit. | |
| """ | |
| from r2.models.vote import Vote, Link, Comment | |
| def get_cached_downvotes(content_cls): | |
| kind = content_cls.__name__.lower() | |
| cache_key = self.downvote_cache_key(kind) | |
| downvotes = g.cache.get(cache_key) | |
| if downvotes is None: | |
| vote_cls = Vote.rel(Account, content_cls) | |
| # Get a count of content_cls downvotes | |
| type = tdb.rel_types_id[vote_cls._type_id] | |
| # rt = rel table | |
| # dt = data table | |
| # tt = thing table | |
| rt, account_tt, content_cls_tt, dt = type.rel_table | |
| cols = [ sa.func.count(rt.c.rel_id) ] | |
| where = sa.and_(rt.c.thing1_id == self._id, rt.c.name == '-1') | |
| query = sa.select(cols, where) | |
| downvotes = query.execute().scalar() | |
| g.cache.set(cache_key, downvotes) | |
| return downvotes | |
| link_downvote_karma = get_cached_downvotes(Link) * c.current_or_default_sr.post_karma_multiplier | |
| comment_downvote_karma = get_cached_downvotes(Comment) | |
| karma_spent = link_downvote_karma + comment_downvote_karma | |
| karma_balance = self.safe_karma * 4 | |
| vote_cost = c.current_or_default_sr.post_karma_multiplier if vote_kind == 'link' else 1 | |
| if karma_spent + vote_cost > karma_balance: | |
| points_needed = abs(karma_balance - karma_spent - vote_cost) | |
| msg = strings.not_enough_downvote_karma % (points_needed, plurals.N_points(points_needed)) | |
| raise NotEnoughKarma(msg) | |
| def incr_downvote(self, delta, kind): | |
| """kind is link or comment""" | |
| try: | |
| g.cache.incr(self.downvote_cache_key(kind), delta) | |
| except ValueError, e: | |
| print 'Account.incr_downvote failed with: %s' % e | |
| def make_cookie(self, timestr = None, admin = False): | |
| if not self._loaded: | |
| self._load() | |
| timestr = timestr or time.strftime('%Y-%m-%dT%H:%M:%S') | |
| id_time = str(self._id) + ',' + timestr | |
| to_hash = ','.join((id_time, self.password, g.SECRET)) | |
| if admin: | |
| to_hash += 'admin' | |
| return id_time + ',' + hashlib.sha1(to_hash).hexdigest() | |
| def needs_captcha(self): | |
| return self.safe_karma < 1 | |
| def modhash(self): | |
| to_hash = ','.join((current_login_cookie(), g.SECRET)) | |
| return hashlib.sha1(to_hash).hexdigest() | |
| def valid_hash(self, hash): | |
| return hash == self.modhash() | |
| @classmethod | |
| @memoize('account._by_name') | |
| def _by_name_cache(cls, name, allow_deleted = False): | |
| #relower name here, just in case | |
| deleted = (True, False) if allow_deleted else False | |
| q = cls._query(lower(Account.c.name) == name.lower(), | |
| Account.c._spam == (True, False), | |
| Account.c._deleted == deleted) | |
| q._limit = 1 | |
| l = list(q) | |
| if l: | |
| return l[0]._id | |
| @classmethod | |
| def _by_name(cls, name, allow_deleted = False): | |
| #lower name here so there is only one cache | |
| uid = cls._by_name_cache(name.lower(), allow_deleted) | |
| if uid: | |
| return cls._byID(uid, True) | |
| else: | |
| raise NotFound, 'Account %s' % name | |
| @property | |
| def friends(self): | |
| return self.friend_ids() | |
| def delete(self): | |
| self._deleted = True | |
| self._commit() | |
| clear_memo('account._by_name', Account, self.name.lower(), False) | |
| #remove from friends lists | |
| q = Friend._query(Friend.c._thing2_id == self._id, | |
| Friend.c._name == 'friend', | |
| eager_load = True) | |
| for f in q: | |
| f._thing1.remove_friend(f._thing2) | |
| @property | |
| def subreddits(self): | |
| from subreddit import Subreddit | |
| return Subreddit.user_subreddits(self) | |
| @property | |
| def draft_sr_name(self): | |
| return self.name + "-drafts" | |
| @property | |
| def coords(self): | |
| if self.pref_latitude is not None and self.pref_longitude is not None: | |
| return (self.pref_latitude, self.pref_longitude) | |
| return None | |
| def is_within_radius(self, coords, radius): | |
| return self.coords is not None and \ | |
| gislib.getDistance(self.coords, coords) <= radius | |
| def recent_share_emails(self): | |
| return self.share.get('recent', set([])) | |
| def add_share_emails(self, emails): | |
| if not emails: | |
| return | |
| if not isinstance(emails, set): | |
| emails = set(emails) | |
| self.share.setdefault('emails', {}) | |
| share = self.share.copy() | |
| share_emails = share['emails'] | |
| for e in emails: | |
| share_emails[e] = share_emails.get(e, 0) +1 | |
| share['recent'] = emails | |
| self.share = share | |
| class FakeAccount(Account): | |
| _nodb = True | |
| def valid_cookie(cookie): | |
| try: | |
| uid, timestr, hash = cookie.split(',') | |
| uid = int(uid) | |
| except: | |
| return (False, False) | |
| try: | |
| account = Account._byID(uid, True) | |
| if account._deleted: | |
| return (False, False) | |
| except NotFound: | |
| return (False, False) | |
| if cookie == account.make_cookie(timestr, admin = False): | |
| return (account, False) | |
| elif cookie == account.make_cookie(timestr, admin = True): | |
| return (account, True) | |
| return (False, False) | |
| def valid_login(name, password): | |
| try: | |
| a = Account._by_name(name) | |
| except NotFound: | |
| return False | |
| if not a._loaded: a._load() | |
| return valid_password(a, password) | |
| def valid_password(a, password): | |
| try: | |
| if a.password == passhash(a.name, password, ''): | |
| #add a salt | |
| a.password = passhash(a.name, password, True) | |
| a._commit() | |
| return a | |
| else: | |
| salt = a.password[:3] | |
| if a.password == passhash(a.name, password, salt): | |
| return a | |
| except AttributeError: | |
| return False | |
| def passhash(username, password, salt = ''): | |
| if salt is True: | |
| salt = randstr(3) | |
| tohash = '%s%s %s' % (salt, username, password) | |
| if isinstance(tohash, unicode): | |
| # Force tohash to be a byte string so it can be hashed | |
| tohash = tohash.encode('utf8') | |
| return salt + hashlib.sha1(tohash).hexdigest() | |
| def change_password(user, newpassword): | |
| user.password = passhash(user.name, newpassword, True) | |
| user._commit() | |
| return True | |
| #TODO reset the cache | |
| def register(name, password, email): | |
| try: | |
| a = Account._by_name(name) | |
| raise AccountExists | |
| except NotFound: | |
| a = Account(name = name, | |
| password = passhash(name, password, True)) | |
| a.email = email | |
| a.confirmation_code = random_key(6) | |
| a.email_validated = False | |
| a.wiki_account = '__error__' | |
| a._commit() | |
| from r2.lib import emailer | |
| emailer.confirmation_email(a) | |
| if wiki_account.valid_name(name): | |
| def send_wiki_failed_email(): | |
| emailer.wiki_failed_email(a) | |
| a.create_associated_wiki_account(password, | |
| on_request_error=send_wiki_failed_email) | |
| else: | |
| emailer.wiki_incompatible_name_email(a) | |
| # Clear memoization of both with and without deleted | |
| clear_memo('account._by_name', Account, name.lower(), True) | |
| clear_memo('account._by_name', Account, name.lower(), False) | |
| return a | |
| class Friend(Relation(Account, Account)): pass | |
| Account.__bases__ += (UserRel('friend', Friend),) |