You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
isso/isso/controllers/comments.py

292 lines
9.4 KiB

# -*- encoding: utf-8 -*-
from __future__ import unicode_literals
import re
import time
from sqlalchemy.sql import func, select, not_
from isso.spam import Guard
from isso.utils import Bloomfilter
from isso.models import Comment
from isso.compat import string_types, buffer
class Invalid(Exception):
pass
class Denied(Exception):
pass
class Validator(object):
# from Django appearently, looks good to me *duck*
__url_re = re.compile(
r'^'
r'(https?://)?'
r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|' # domain...
r'localhost|' # localhost...
r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' # ...or ip
r'(?::\d+)?' # optional port
r'(?:/?|[/?]\S+)'
r'$', re.IGNORECASE)
@classmethod
def isurl(cls, text):
return Validator.__url_re.match(text) is not None
@classmethod
def verify(cls, comment):
if not isinstance(comment["parent"], (int, type(None))):
return False, "parent must be an integer or null"
if not isinstance(comment["text"], string_types):
return False, "text must be a string"
if len(comment["text"].rstrip()) < 3:
return False, "text is too short (minimum length: 3)"
for key in ("author", "email", "website"):
if not isinstance(comment[key], (string_types, type(None))):
return False, "%s must be a string or null" % key
if len(comment["email"] or "") > 254:
return False, "http://tools.ietf.org/html/rfc5321#section-4.5.3"
if comment["website"]:
if len(comment["website"]) > 254:
return False, "arbitrary length limit"
if not Validator.isurl(comment["website"]):
return False, "Website not Django-conform"
return True, ""
class Controller(object):
def __init__(self, db, guard=None):
if guard is None:
guard = Guard(db, enabled=False)
self.db = db
self.guard = guard
@classmethod
def Comment(cls, rv):
return Comment(
id=rv[0], parent=rv[1], thread=rv[2], created=rv[3], modified=rv[4],
mode=rv[5], remote_addr=rv[6], text=rv[7], author=rv[8], email=rv[9],
website=rv[10], likes=rv[11], dislikes=rv[12], voters=Bloomfilter(bytearray(rv[13])))
def new(self, remote_addr, thread, obj, moderated=False):
obj.setdefault("text", "")
obj.setdefault("parent", None)
for field in ("email", "author", "website"):
obj.setdefault(field, None)
valid, reason = Validator.verify(obj)
if not valid:
raise Invalid(reason)
if self.guard.enabled:
valid, reason = self.guard.validate(remote_addr, thread, obj)
if not valid:
raise Denied(reason)
obj["id"] = None
obj["thread"] = thread.id
obj["mode"] = 2 if moderated else 1
obj["created"] = time.time()
obj["modified"] = None
obj["remote_addr"] = remote_addr
obj["likes"] = obj["dislikes"] = 0
obj["voters"] = Bloomfilter(iterable=[remote_addr])
if obj["parent"] is not None:
parent = self.get(obj["parent"])
if parent is None:
obj["parent"] = None
elif parent.parent: # normalize to max depth of 1
obj["parent"] = parent.parent
obj = Comment(**obj)
_id = self.db.engine.execute(self.db.comments.insert()
.values((obj.id, obj.parent, obj.thread, obj.created, obj.modified,
obj.mode, obj.remote_addr, obj.text, obj.author, obj.email,
obj.website, obj.likes, obj.dislikes, buffer(obj.voters.array)))
).inserted_primary_key[0]
return obj.new(id=_id)
def edit(self, _id, new):
obj = self.get(_id)
if not obj:
return None
new.setdefault("text", "")
new.setdefault("parent", None)
for field in ("email", "author", "website"):
new.setdefault(field, None)
valid, reason = Validator.verify(new)
if not valid:
raise Invalid(reason)
obj = obj.new(text=new["text"], author=new["author"], email=new["email"],
website=new["website"], modified=time.time())
self.db.engine.execute(self.db.comments.update()
.values(text=obj.text, author=obj.author, email=obj.email,
website=obj.website, modified=obj.modified)
.where(self.db.comments.c.id == _id))
return obj
def get(self, _id):
"""Retrieve comment with :param id: if any.
"""
rv = self.db.engine.execute(
self.db.comments.select(self.db.comments.c.id == _id)).fetchone()
if not rv:
return None
return Controller.Comment(rv)
def all(self, thread, mode=1, after=0, parent='any', order_by='id', limit=None):
stmt = (self.db.comments.select()
.where(self.db.comments.c.thread == thread.id)
.where(self.db.comments.c.mode.op("|")(mode) == self.db.comments.c.mode)
.where(self.db.comments.c.created > after))
if parent != 'any':
stmt = stmt.where(self.db.comments.c.parent == parent)
stmt.order_by(getattr(self.db.comments.c, order_by))
if limit:
stmt.limit(limit)
return list(map(Controller.Comment, self.db.engine.execute(stmt).fetchall()))
def vote(self, remote_addr, _id, like):
"""Vote with +1 or -1 on comment :param id:
Returns True on success (in either direction), False if :param
remote_addr: has already voted. A comment can not be voted by its
original author.
"""
obj = self.get(_id)
if obj is None:
return False
if remote_addr in obj.voters:
return False
if like:
obj = obj.new(likes=obj.likes + 1)
else:
obj = obj.new(dislikes=obj.dislikes + 1)
obj.voters.add(remote_addr)
self.db.engine.execute(self.db.comments.update()
.values(likes=obj.likes, dislikes=obj.dislikes,
voters=buffer(obj.voters.array))
.where(self.db.comments.c.id == _id))
return True
def like(self, remote_addr, _id):
return self.vote(remote_addr, _id, like=True)
def dislike(self, remote_addr, _id):
return self.vote(remote_addr, _id, like=False)
def delete(self, _id):
"""
Delete comment with :param id:
If the comment is referenced by another (not yet deleted) comment, the
comment is *not* removed, but cleared and flagged as deleted.
"""
refs = self.db.engine.execute(
self.db.comments.select(self.db.comments.c.id).where(
self.db.comments.c.parent == _id)).fetchone()
if refs is None:
self.db.engine.execute(
self.db.comments.delete(self.db.comments.c.id == _id))
self.db.engine.execute(
self.db.comments.delete()
.where(self.db.comments.c.mode.op("|")(4) == self.db.comments.c.mode)
.where(not_(self.db.comments.c.id.in_(
select([self.db.comments.c.parent]).where(
self.db.comments.c.parent != None)))))
return None
obj = self.get(_id)
obj = obj.new(text="", author=None, email=None, website=None, mode=4)
self.db.engine.execute(self.db.comments.update()
.values(text=obj.text, author=obj.email, website=obj.website, mode=obj.mode)
.where(self.db.comments.c.id == _id))
return obj
def empty(self):
return self.db.engine.execute(
select([func.count(self.db.comments)])).fetchone()[0] == 0
def count(self, *threads):
"""Retrieve comment count for :param threads:
"""
ids = [getattr(th, "id", None) for th in threads]
threads = dict(
self.db.engine.execute(
select([self.db.comments.c.thread, func.count(self.db.comments)])
.where(self.db.comments.c.thread.in_(ids))
.group_by(self.db.comments.c.thread)).fetchall())
return [threads.get(_id, 0) for _id in ids]
def reply_count(self, thread, mode=5, after=0):
rv = self.db.engine.execute(
select([self.db.comments.c.parent, func.count(self.db.comments)])
.where(self.db.comments.c.thread == thread.id)
# .where(self.db.comments.c.mode.op("|")(mode) == mode)
.where(self.db.comments.c.created)
.group_by(self.db.comments.c.parent)).fetchall()
return dict(rv)
def activate(self, _id):
"""Activate comment :param id: and return True on success.
"""
obj = self.get(_id)
if obj is None:
return False
i = self.db.engine.execute(self.db.comments.update()
.values(mode=1)
.where(self.db.comments.c.id == _id)
.where(self.db.comments.c.mode == 2)).rowcount
return i > 0
def prune(self, delta):
"""Remove comments still in moderation queue older than max-age.
"""
now = time.time()
return self.db.engine.execute(self.db.comments
.delete()
.where(self.db.comments.c.mode == 2)
.where(now - self.db.comments.c.created > delta)).rowcount