implement db backend for votes/likes #5

This commit is contained in:
Martin Zimmermann 2013-09-06 17:19:25 +02:00
parent 11c6e4d720
commit c7ee35423b
7 changed files with 198 additions and 22 deletions

View File

@ -55,6 +55,7 @@ url_map = Map([
Rule('/', methods=['HEAD', 'GET'], endpoint=views.comment.get), Rule('/', methods=['HEAD', 'GET'], endpoint=views.comment.get),
Rule('/', methods=['PUT', 'DELETE'], endpoint=views.comment.modify), Rule('/', methods=['PUT', 'DELETE'], endpoint=views.comment.modify),
Rule('/new', methods=['POST'], endpoint=views.comment.create), Rule('/new', methods=['POST'], endpoint=views.comment.create),
Rule('/like', methods=['POST'], endpoint=views.comment.like),
Rule('/admin/', endpoint=views.admin.index) Rule('/admin/', endpoint=views.admin.index)
]) ])

View File

@ -7,6 +7,7 @@ import abc
import time import time
import sqlite3 import sqlite3
from isso.utils import Bloomfilter
from isso.models import Comment from isso.models import Comment
@ -65,8 +66,12 @@ class Abstract:
""" """
return return
@abc.abstractmethod
def like(self, path, id, remote_addr):
"""+1 a given comment. Returns the new like count (may not change because
the creater can't vote on his/her own comment and multiple votes from the
same ip address are ignored as well)."""
return
class SQLite(Abstract): class SQLite(Abstract):
"""A basic :class:`Abstract` implementation using SQLite3. All comments """A basic :class:`Abstract` implementation using SQLite3. All comments
@ -76,7 +81,7 @@ class SQLite(Abstract):
fields = [ fields = [
'path', 'id', 'created', 'modified', 'path', 'id', 'created', 'modified',
'text', 'author', 'hash', 'website', 'parent', 'mode' 'text', 'author', 'hash', 'website', 'parent', 'mode', 'voters'
] ]
def __init__(self, dbpath, moderation): def __init__(self, dbpath, moderation):
@ -89,6 +94,7 @@ class SQLite(Abstract):
'created FLOAT NOT NULL, modified FLOAT, text VARCHAR,' 'created FLOAT NOT NULL, modified FLOAT, text VARCHAR,'
'author VARCHAR(64), hash VARCHAR(32), website VARCHAR(64),' 'author VARCHAR(64), hash VARCHAR(32), website VARCHAR(64),'
'parent INTEGER, mode INTEGER,' 'parent INTEGER, mode INTEGER,'
'likes INTEGER DEFAULT 0, dislikes INTEGER DEFAULT 0, voters BLOB NOT NULL,'
'PRIMARY KEY (id, path))') 'PRIMARY KEY (id, path))')
con.execute("CREATE TABLE IF NOT EXISTS %s;" % sql) con.execute("CREATE TABLE IF NOT EXISTS %s;" % sql)
@ -101,22 +107,29 @@ class SQLite(Abstract):
WHERE rowid=NEW.rowid; WHERE rowid=NEW.rowid;
END;""") END;""")
# threads (path -> title for now)
sql = ('main.threads (path VARCHAR(255) NOT NULL, title TEXT'
'PRIMARY KEY path)')
con.execute("CREATE TABLE IF NOT EXISTS %s;" % sql)
def query2comment(self, query): def query2comment(self, query):
if query is None: if query is None:
return None return None
return Comment( return Comment(
text=query[4], author=query[5], hash=query[6], website=query[7], parent=query[8], text=query[4], author=query[5], hash=query[6], website=query[7], parent=query[8],
path=query[0], id=query[1], created=query[2], modified=query[3], mode=query[9] path=query[0], id=query[1], created=query[2], modified=query[3], mode=query[9],
votes=query[10]
) )
def add(self, path, c): def add(self, path, c, remote_addr):
voters = buffer(Bloomfilter(iterable=[remote_addr]).array)
with sqlite3.connect(self.dbpath) as con: with sqlite3.connect(self.dbpath) as con:
keys = ','.join(self.fields) keys = ','.join(self.fields)
values = ','.join('?' * len(self.fields)) values = ','.join('?' * len(self.fields))
con.execute('INSERT INTO comments (%s) VALUES (%s);' % (keys, values), ( con.execute('INSERT INTO comments (%s) VALUES (%s);' % (keys, values), (
path, 0, c.created, c.modified, c.text, c.author, c.hash, c.website, path, 0, c.created, c.modified, c.text, c.author, c.hash, c.website,
c.parent, self.mode) c.parent, self.mode, voters)
) )
with sqlite3.connect(self.dbpath) as con: with sqlite3.connect(self.dbpath) as con:
@ -159,6 +172,27 @@ class SQLite(Abstract):
(None, path, id)) (None, path, id))
return self.get(path, id) return self.get(path, id)
def like(self, path, id, remote_addr):
with sqlite3.connect(self.dbpath) as con:
rv = con.execute("SELECT likes, dislikes, voters FROM comments" \
+ " WHERE path=? AND id=?", (path, id)).fetchone()
likes, dislikes, voters = rv
if likes + dislikes >= 142:
return likes
bf = Bloomfilter(bytearray(voters), likes + dislikes)
if remote_addr in bf:
return likes
bf.add(remote_addr)
with sqlite3.connect(self.dbpath) as con:
con.execute("UPDATE comments SET likes = likes + 1 WHERE path=? AND id=?", (path, id))
con.execute("UPDATE comments SET voters = ? WHERE path=? AND id=?", (
buffer(bf.array), path, id))
return likes + 1
def retrieve(self, path, mode=5): def retrieve(self, path, mode=5):
with sqlite3.connect(self.dbpath) as con: with sqlite3.connect(self.dbpath) as con:
rv = con.execute("SELECT * FROM comments WHERE path=? AND (? | mode) = ?" \ rv = con.execute("SELECT * FROM comments WHERE path=? AND (? | mode) = ?" \
@ -181,3 +215,4 @@ class SQLite(Abstract):
for item in rv: for item in rv:
yield self.query2comment(item) yield self.query2comment(item)

View File

@ -25,7 +25,7 @@ class Comment(object):
normal and queued using MODE=3. normal and queued using MODE=3.
""" """
protected = ['path', 'id', 'mode', 'created', 'modified', 'hash'] protected = ['path', 'id', 'mode', 'created', 'modified', 'hash', 'votes']
fields = ['text', 'author', 'website', 'parent'] fields = ['text', 'author', 'website', 'parent']
def __init__(self, **kw): def __init__(self, **kw):

View File

@ -3,12 +3,16 @@
# Copyright 2012, Martin Zimmermann <info@posativ.org>. All rights reserved. # Copyright 2012, Martin Zimmermann <info@posativ.org>. All rights reserved.
# License: BSD Style, 2 clauses. see isso/__init__.py # License: BSD Style, 2 clauses. see isso/__init__.py
from __future__ import division
import json import json
import socket import socket
import httplib import httplib
import math
import random import random
import hashlib
import contextlib import contextlib
from string import ascii_letters, digits from string import ascii_letters, digits
@ -46,3 +50,51 @@ def normalize(host):
def mksecret(length): def mksecret(length):
return ''.join(random.choice(ascii_letters + digits) for x in range(length)) return ''.join(random.choice(ascii_letters + digits) for x in range(length))
class Bloomfilter:
"""A space-efficient probabilistic data structure. False-positive rate:
* 1e-05 for <80 elements
* 1e-04 for <105 elements
* 1e-03 for <142 elements
Uses a 256 byte array (2048 bits) and 11 hash functions. 256 byte because
of space efficiency (array is saved for each comment) and 11 hash functions
because of best overall false-positive rate in that range.
-- via Raymond Hettinger
http://code.activestate.com/recipes/577684-bloom-filter/
"""
def __init__(self, array=bytearray(256), elements=0, iterable=()):
self.array = array
self.elements = elements
self.k = 11
self.m = len(array) * 8
for item in iterable:
self.add(item)
def get_probes(self, key):
h = int(hashlib.sha256(key.encode()).hexdigest(), 16)
for _ in range(self.k):
yield h & self.m - 1
h >>= self.k
def add(self, key):
for i in self.get_probes(key):
self.array[i//8] |= 2 ** (i%8)
self.elements += 1
@property
def density(self):
c = ''.join(format(x, '08b') for x in self.array)
return c.count('1') / len(c)
def __contains__(self, key):
return all(self.array[i//8] & (2 ** (i%8)) for i in self.get_probes(key))
def __len__(self):
return self.elements

View File

@ -55,7 +55,7 @@ def create(app, environ, request, uri):
Response('', 400) Response('', 400)
try: try:
rv = app.db.add(uri, comment) rv = app.db.add(uri, comment, request.remote_addr)
except ValueError: except ValueError:
abort(400) # FIXME: custom exception class, error descr abort(400) # FIXME: custom exception class, error descr
@ -120,6 +120,14 @@ def modify(app, environ, request, uri, id):
return resp return resp
@requires(str, 'uri')
@requires(int, 'id')
def like(app, environ, request, uri, id):
nv = app.db.like(uri, id, request.remote_addr)
return Response(str(nv), 200)
def approve(app, environ, request, path, id): def approve(app, environ, request, path, id):
try: try:

View File

@ -23,7 +23,7 @@ class TestSQLite(unittest.TestCase):
def test_get(self): def test_get(self):
rv = self.db.add('/', comment(text='Spam')) rv = self.db.add('/', comment(text='Spam'), '')
c = self.db.get('/', rv.id) c = self.db.get('/', rv.id)
assert c.id == 1 assert c.id == 1
@ -31,9 +31,9 @@ class TestSQLite(unittest.TestCase):
def test_add(self): def test_add(self):
self.db.add('/', comment(text='Foo')) self.db.add('/', comment(text='Foo'), '')
self.db.add('/', comment(text='Bar')) self.db.add('/', comment(text='Bar'), '')
self.db.add('/path/', comment(text='Baz')) self.db.add('/path/', comment(text='Baz'), '')
rv = list(self.db.retrieve('/')) rv = list(self.db.retrieve('/'))
assert rv[0].id == 1 assert rv[0].id == 1
@ -50,14 +50,14 @@ class TestSQLite(unittest.TestCase):
def test_add_return(self): def test_add_return(self):
self.db.add('/', comment(text='1')) self.db.add('/', comment(text='1'), '')
self.db.add('/', comment(text='2')) self.db.add('/', comment(text='2'), '')
assert self.db.add('/path/', comment(text='1')).id == 1 assert self.db.add('/path/', comment(text='1'), '').id == 1
def test_update(self): def test_update(self):
rv = self.db.add('/', comment(text='Foo')) rv = self.db.add('/', comment(text='Foo'), '')
time.sleep(0.1) time.sleep(0.1)
rv = self.db.update('/', rv.id, comment(text='Bla')) rv = self.db.update('/', rv.id, comment(text='Bla'))
c = self.db.get('/', rv.id) c = self.db.get('/', rv.id)
@ -69,15 +69,15 @@ class TestSQLite(unittest.TestCase):
def test_delete(self): def test_delete(self):
rv = self.db.add('/', comment( rv = self.db.add('/', comment(
text='F**CK', author='P*NIS', website='http://somebadhost.org/')) text='F**CK', author='P*NIS', website='http://somebadhost.org/'), '')
assert self.db.delete('/', rv.id) == None assert self.db.delete('/', rv.id) == None
def test_recent(self): def test_recent(self):
self.db.add('/path/', comment(text='2')) self.db.add('/path/', comment(text='2'), '')
for x in range(5): for x in range(5):
self.db.add('/', comment(text='%i' % (x+1))) self.db.add('/', comment(text='%i' % (x+1)), '')
assert len(list(self.db.recent(mode=7))) == 6 assert len(list(self.db.recent(mode=7))) == 6
assert len(list(self.db.recent(mode=7, limit=5))) == 5 assert len(list(self.db.recent(mode=7, limit=5))) == 5
@ -95,13 +95,13 @@ class TestSQLitePending(unittest.TestCase):
def test_retrieve(self): def test_retrieve(self):
self.db.add('/', comment(text='Foo')) self.db.add('/', comment(text='Foo'), '')
assert len(list(self.db.retrieve('/'))) == 0 assert len(list(self.db.retrieve('/'))) == 0
def test_activate(self): def test_activate(self):
self.db.add('/', comment(text='Foo')) self.db.add('/', comment(text='Foo'), '')
self.db.add('/', comment(text='Bar')) self.db.add('/', comment(text='Bar'), '')
self.db.activate('/', 2) self.db.activate('/', 2)
assert len(list(self.db.retrieve('/'))) == 1 assert len(list(self.db.retrieve('/'))) == 1

80
specs/test_vote.py Normal file
View File

@ -0,0 +1,80 @@
import os
import json
import tempfile
import unittest
from werkzeug.test import Client
from werkzeug.wrappers import Response
from isso import Isso
class FakeIP(object):
def __init__(self, app, ip):
self.app = app
self.ip = ip
def __call__(self, environ, start_response):
environ['REMOTE_ADDR'] = self.ip
return self.app(environ, start_response)
class TestVote(unittest.TestCase):
def setUp(self):
fd, self.path = tempfile.mkstemp()
def tearDown(self):
os.unlink(self.path)
def makeClient(self, ip):
app = Isso(self.path, '...', '...', 15*60, "...")
app.wsgi_app = FakeIP(app.wsgi_app, ip)
return Client(app, Response)
def testZeroLikes(self):
rv = self.makeClient("127.0.0.1").post("/new?uri=test", data=json.dumps({"text": "..."}))
assert json.loads(rv.data)['votes'] == 0
def testSingleLike(self):
self.makeClient("127.0.0.1").post("/new?uri=test", data=json.dumps({"text": "..."}))
rv = self.makeClient("0.0.0.0").post("/like?uri=test&id=1")
assert rv.status_code == 200
assert rv.data == "1"
def testSelfLike(self):
bob = self.makeClient("127.0.0.1")
bob.post("/new?uri=test", data=json.dumps({"text": "..."}))
rv = bob.post('/like?uri=test&id=1')
assert rv.status_code == 200
assert rv.data == "0"
def testMultipleLikes(self):
self.makeClient("127.0.0.1").post("/new?uri=test", data=json.dumps({"text": "..."}))
for num in range(15):
rv = self.makeClient("1.2.3.%i" % num).post('/like?uri=test&id=1')
assert rv.status_code == 200
assert rv.data == str(num + 1)
def testTooManyLikes(self):
self.makeClient("127.0.0.1").post("/new?uri=test", data=json.dumps({"text": "..."}))
for num in range(256):
rv = self.makeClient("1.2.3.%i" % num).post('/like?uri=test&id=1')
assert rv.status_code == 200
if num >= 142:
assert rv.data == "142"
else:
assert rv.data == str(num + 1)