implement db backend for votes/likes #5
This commit is contained in:
parent
11c6e4d720
commit
c7ee35423b
@ -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)
|
||||||
])
|
])
|
||||||
|
47
isso/db.py
47
isso/db.py
@ -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)
|
||||||
|
|
||||||
|
@ -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):
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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:
|
||||||
|
@ -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
80
specs/test_vote.py
Normal 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)
|
||||||
|
|
Loading…
Reference in New Issue
Block a user