
This commit also introduces a new db which maps path to thread title. The title is read by parsing the HTML for a related <h1> tag using `html5lib`. You can set up SMTP in your configuration (here the defaults): [SMTP] host = localhost port = 465 ssl = on username = password = recipient = sender = In short, by default Isso uses a local SMTP server using SSL without any authentication. An email is send on comment creation to "recipient" from "Ich schrei sonst <sender>". This commit also uses a simple ANSI colorization module from my static blog compiler project. On server startup, Isso will connect to the SMTP server and fall back to a null mailer. It also tries to connect to your website, so if that doesn't work, you probably can't comment on your website either.
278 lines
9.7 KiB
Python
278 lines
9.7 KiB
Python
# -*- encoding: utf-8 -*-
|
|
#
|
|
# Copyright 2012, Martin Zimmermann <info@posativ.org>. All rights reserved.
|
|
# License: BSD Style, 2 clauses. see isso/__init__.py
|
|
|
|
import abc
|
|
import time
|
|
import sqlite3
|
|
|
|
from isso.utils import Bloomfilter
|
|
from isso.models import Comment
|
|
|
|
|
|
class Abstract:
|
|
|
|
__metaclass__ = abc.ABCMeta
|
|
|
|
@abc.abstractmethod
|
|
def __init__(self, dbpath, moderation):
|
|
return
|
|
|
|
@abc.abstractmethod
|
|
def add(self, path, comment):
|
|
"""Add a new comment to the database. Returns a Comment object."""
|
|
return
|
|
|
|
@abc.abstractmethod
|
|
def activate(self, path, id):
|
|
"""Activate comment id if pending and return comment for (path, id)."""
|
|
return
|
|
|
|
@abc.abstractmethod
|
|
def update(self, path, id, comment):
|
|
"""
|
|
Update an existing comment, but only writeable fields such as text,
|
|
author, email, website and parent. This method should set the modified
|
|
field to the current time.
|
|
"""
|
|
return
|
|
|
|
@abc.abstractmethod
|
|
def delete(self, path, id):
|
|
"""
|
|
Delete a comment. There are two distinctions: a comment is referenced
|
|
by another valid comment's parent attribute or stand-a-lone. In this
|
|
case the comment can't be removed without losing depending comments.
|
|
Hence, delete removes all visible data such as text, author, email,
|
|
website sets the mode field to 4.
|
|
|
|
In the second case this comment can be safely removed without any side
|
|
effects."""
|
|
return
|
|
|
|
@abc.abstractmethod
|
|
def retrieve(self, path, mode):
|
|
"""
|
|
Return all comments for `path` with `mode`.
|
|
"""
|
|
return
|
|
|
|
@abc.abstractmethod
|
|
def recent(self, mode=7, limit=None):
|
|
"""
|
|
Return most recent comments with `mode`. If `limit` is None, return
|
|
*all* comments that are currently stored, otherwise limit by `limit`.
|
|
"""
|
|
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
|
|
|
|
@abc.abstractmethod
|
|
def count(self, path=None, mode=1):
|
|
"""return count of comments for path (optional) and mode (defaults to
|
|
visible, not deleted or moderated comments)."""
|
|
return
|
|
|
|
|
|
class Threads(object):
|
|
|
|
def __init__(self, dbpath):
|
|
|
|
self.dbpath = dbpath
|
|
|
|
with sqlite3.connect(self.dbpath) as con:
|
|
sql = ('main.threads (path VARCHAR(255) NOT NULL, title TEXT'
|
|
'PRIMARY KEY path)')
|
|
con.execute("CREATE TABLE IF NOT EXISTS %s;" % sql)
|
|
|
|
def get(self, path):
|
|
with sqlite3.connect(self.dbpath) as con:
|
|
rv = con.execute("SELECT title FROM threads WHERE path=?", (path,)).fetchone()
|
|
if rv is not None:
|
|
return rv[0]
|
|
raise KeyError
|
|
|
|
def add(self, path, title):
|
|
with sqlite3.connect(self.dbpath) as con:
|
|
con.execute("INSERT INTO threads (path, title) VALUES (?, ?)", (path, title))
|
|
return title
|
|
|
|
|
|
class SQLite(Abstract):
|
|
"""A basic :class:`Abstract` implementation using SQLite3. All comments
|
|
share a single database. The tuple (id, path) acts as unique identifier
|
|
for a comment. Multiple comments per path (= that is the URI to your blog
|
|
post) are ordered by that id."""
|
|
|
|
fields = [
|
|
'path', 'id', 'created', 'modified',
|
|
'text', 'author', 'hash', 'website', 'parent', 'mode', 'voters'
|
|
]
|
|
|
|
def __init__(self, dbpath, moderation):
|
|
|
|
self.dbpath = dbpath
|
|
self.mode = 2 if moderation else 1
|
|
|
|
with sqlite3.connect(self.dbpath) as con:
|
|
sql = ('main.comments (path VARCHAR(255) NOT NULL, id INTEGER NOT NULL,'
|
|
'created FLOAT NOT NULL, modified FLOAT, text VARCHAR,'
|
|
'author VARCHAR(64), hash VARCHAR(32), website VARCHAR(64),'
|
|
'parent INTEGER, mode INTEGER,'
|
|
'likes INTEGER DEFAULT 0, dislikes INTEGER DEFAULT 0, voters BLOB NOT NULL,'
|
|
'PRIMARY KEY (id, path))')
|
|
con.execute("CREATE TABLE IF NOT EXISTS %s;" % sql)
|
|
|
|
# increment id if (id, path) is no longer unique
|
|
con.execute("""\
|
|
CREATE TRIGGER IF NOT EXISTS increment AFTER INSERT ON comments
|
|
BEGIN
|
|
UPDATE comments SET
|
|
id=(SELECT MAX(id)+1 FROM comments WHERE path=NEW.path)
|
|
WHERE rowid=NEW.rowid;
|
|
END;""")
|
|
|
|
self.threads = Threads(self.dbpath)
|
|
|
|
def query2comment(self, query):
|
|
if query is None:
|
|
return None
|
|
|
|
return Comment(
|
|
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],
|
|
votes=query[10]
|
|
)
|
|
|
|
def add(self, uri, c, remote_addr):
|
|
voters = buffer(Bloomfilter(iterable=[remote_addr]).array)
|
|
with sqlite3.connect(self.dbpath) as con:
|
|
keys = ','.join(self.fields)
|
|
values = ','.join('?' * len(self.fields))
|
|
con.execute('INSERT INTO comments (%s) VALUES (%s);' % (keys, values), (
|
|
uri, 0, c['created'] or time.time(), None, c["text"], c["author"],
|
|
c["hash"], c["website"], c["parent"], self.mode, voters)
|
|
)
|
|
|
|
with sqlite3.connect(self.dbpath) as con:
|
|
return self.query2comment(
|
|
con.execute('SELECT *, MAX(id) FROM comments WHERE path=?;', (uri, )).fetchone())
|
|
|
|
def activate(self, path, id):
|
|
with sqlite3.connect(self.dbpath) as con:
|
|
con.execute("UPDATE comments SET mode=1 WHERE path=? AND id=? AND mode=2", (path, id))
|
|
return self.get(path, id)
|
|
|
|
def update(self, path, id, values):
|
|
with sqlite3.connect(self.dbpath) as con:
|
|
for key, value in values.iteritems():
|
|
con.execute('UPDATE comments SET %s=? WHERE path=? AND id=?;' % key,
|
|
(value, path, id))
|
|
|
|
with sqlite3.connect(self.dbpath) as con:
|
|
con.execute('UPDATE comments SET modified=? WHERE path=? AND id=?',
|
|
(time.time(), path, id))
|
|
return self.get(path, id)
|
|
|
|
def get(self, path, id):
|
|
with sqlite3.connect(self.dbpath) as con:
|
|
return self.query2comment(con.execute(
|
|
'SELECT * FROM comments WHERE path=? AND id=?;', (path, id)).fetchone())
|
|
|
|
def _remove_stale(self, con, path):
|
|
|
|
sql = ('DELETE FROM',
|
|
' comments',
|
|
'WHERE',
|
|
' path=? AND mode=4 AND id NOT IN (',
|
|
' SELECT',
|
|
' parent',
|
|
' FROM',
|
|
' comments',
|
|
' WHERE path=? AND parent IS NOT NULL)')
|
|
|
|
while con.execute(' '.join(sql), (path, path)).rowcount:
|
|
continue
|
|
|
|
def delete(self, path, id):
|
|
with sqlite3.connect(self.dbpath) as con:
|
|
sql = 'SELECT * FROM comments WHERE path=? AND parent=?'
|
|
refs = con.execute(sql, (path, id)).fetchone()
|
|
|
|
if refs is None:
|
|
con.execute('DELETE FROM comments WHERE path=? AND id=?', (path, id))
|
|
self._remove_stale(con, path)
|
|
return None
|
|
|
|
con.execute('UPDATE comments SET text=? WHERE path=? AND id=?', ('', path, id))
|
|
con.execute('UPDATE comments SET mode=? WHERE path=? AND id=?', (4, path, id))
|
|
for field in ('author', 'website'):
|
|
con.execute('UPDATE comments SET %s=? WHERE path=? AND id=?' % field,
|
|
(None, path, id))
|
|
|
|
self._remove_stale(con, path)
|
|
|
|
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 count(self, path=None, mode=1):
|
|
|
|
if path is not None:
|
|
with sqlite3.connect(self.dbpath) as con:
|
|
return con.execute("SELECT COUNT(*) FROM comments WHERE path=?" \
|
|
+ " AND (? | mode) = ?", (path, mode, mode)).fetchone()
|
|
|
|
with sqlite3.connect(self.dbpath) as con:
|
|
return con.execute("SELECT COUNT(*) FROM comments WHERE" \
|
|
+ "(? | mode) = ?", (path, mode, mode)).fetchone()
|
|
|
|
def retrieve(self, path, mode=5):
|
|
with sqlite3.connect(self.dbpath) as con:
|
|
rv = con.execute("SELECT * FROM comments WHERE path=? AND (? | mode) = ?" \
|
|
+ " ORDER BY id ASC;", (path, mode, mode)).fetchall()
|
|
|
|
for item in rv:
|
|
yield self.query2comment(item)
|
|
|
|
def recent(self, mode=7, limit=None):
|
|
|
|
sql = 'SELECT * FROM comments WHERE (? | mode) = ? ORDER BY created DESC'
|
|
args = [mode, mode]
|
|
|
|
if limit:
|
|
sql += ' LIMIT ?'
|
|
args.append(limit)
|
|
|
|
with sqlite3.connect(self.dbpath) as con:
|
|
rv = con.execute(sql + ';', args).fetchall()
|
|
|
|
for item in rv:
|
|
yield self.query2comment(item)
|
|
|