Merry Christmas! I AM READY I love you all! new file: .gitignore new file: MANIFEST.in new file: Makefile new file: README.rst new file: development.ini new file: index.html new file: index2.html new file: journal.rst new file: readme-postgres-notes.rst new file: readme-themes.rst new file: remarkbox/__init__.py new file: remarkbox/lib/__init__.py new file: remarkbox/lib/mail.py new file: remarkbox/lib/mail_messages.py new file: remarkbox/lib/notify.py new file: remarkbox/lib/render.py new file: remarkbox/lib/svgtar/__init__.py new file: remarkbox/lib/svgtar/svg.py new file: remarkbox/models/__init__.py new file: remarkbox/models/event.py new file: remarkbox/models/meta.py new file: remarkbox/models/namespace.py new file: remarkbox/models/namespace_request.py new file: remarkbox/models/namespace_user.py new file: remarkbox/models/node.py new file: remarkbox/models/node_cache.py new file: remarkbox/models/notification.py new file: remarkbox/models/oauth.py new file: remarkbox/models/pay_what_you_can.py new file: remarkbox/models/uri.py new file: remarkbox/models/user.py new file: remarkbox/models/vote.py new file: remarkbox/models/watcher.py new file: remarkbox/routes.py new file: remarkbox/scripts/__init__.py new file: remarkbox/scripts/alembic/README new file: remarkbox/scripts/alembic/env.py new file: remarkbox/scripts/alembic/script.py.mako new file: remarkbox/scripts/alembic/versions/00211ed642cc_add_ip_address_column_to_rb_node.py new file: remarkbox/scripts/alembic/versions/0208c10ea67f_create_new_node_cache_table.py new file: remarkbox/scripts/alembic/versions/04e52337756e_autogenerated_indicies.py new file: remarkbox/scripts/alembic/versions/051f8b810f26_namespace_ignore_query_string.py new file: remarkbox/scripts/alembic/versions/1040ee4836af_namespace_description.py new file: remarkbox/scripts/alembic/versions/184a349cd589_uri_accessed_timestamp.py new file: remarkbox/scripts/alembic/versions/256468780798_root_nodes_to_namespace_.py new file: remarkbox/scripts/alembic/versions/2e00b92495e4_node_disabled_timestamp.py new file: remarkbox/scripts/alembic/versions/3ee195d142fb_add_subscription_type_to_namespace.py new file: remarkbox/scripts/alembic/versions/40ded1163f95_add_user_surrogate_columns.py new file: remarkbox/scripts/alembic/versions/43c1e5a99d4e_new_namespace_column_3.py new file: remarkbox/scripts/alembic/versions/4aeda8e797ab_new_namespace_column_for_stylesheet_uris.py new file: remarkbox/scripts/alembic/versions/503a2cef2378_root_stats.py new file: remarkbox/scripts/alembic/versions/591800942adf_node_data_html_column.py new file: remarkbox/scripts/alembic/versions/59592cf325fb_stylesheet_timestamp.py new file: remarkbox/scripts/alembic/versions/5a5d0b68db78_add_root_id_field.py new file: remarkbox/scripts/alembic/versions/6a440d3921d2_added_email_id_column_to_user_table.py new file: remarkbox/scripts/alembic/versions/6bbc92ccc112_add_changed_column_to_node_table.py new file: remarkbox/scripts/alembic/versions/6db39621f46c_namespace_group_conversations.py new file: remarkbox/scripts/alembic/versions/6fc1d0cf350c_notification_columns_to_rb_user_table.py new file: remarkbox/scripts/alembic/versions/76deff88cfd6_create_graph_depth_column_on_node_model.py new file: remarkbox/scripts/alembic/versions/771ef7c868de_added_created_timestamp_column_to_.py new file: remarkbox/scripts/alembic/versions/7a3a4755b4a8_add_hide_unless_approved_column.py new file: remarkbox/scripts/alembic/versions/87fbd2b4e06e_namespace_theme_embed_column.py new file: remarkbox/scripts/alembic/versions/953d42406dbb_namespace_public_column.py new file: remarkbox/scripts/alembic/versions/98a90d2add21_namespace_model.py new file: remarkbox/scripts/alembic/versions/98ced5f6ff36_stripe_id_for_user.py new file: remarkbox/scripts/alembic/versions/a26302bcfa25_add_created_timestamp_column_to_oauth_.py new file: remarkbox/scripts/alembic/versions/a38c12eeff2d_locked_rb_node.py new file: remarkbox/scripts/alembic/versions/a738d6859f18_new_namespace_columns_2.py new file: remarkbox/scripts/alembic/versions/aa2dfb4decda_hide_powered_by_column.py new file: remarkbox/scripts/alembic/versions/b132ec968f0c_pay_what_you_can_table.py new file: remarkbox/scripts/alembic/versions/ba8e83a3b8fd_namespace_stylesheet_embed_column.py new file: remarkbox/scripts/alembic/versions/c9793937e756_make_all_verified_namespace_request_.py new file: remarkbox/scripts/alembic/versions/c97ddfcbc406_approve_column.py new file: remarkbox/scripts/alembic/versions/cbdf3cd4ccba_avatar_size_in_namespace.py new file: remarkbox/scripts/alembic/versions/d1a0ab4f617f_add_has_uri_to_node.py new file: remarkbox/scripts/alembic/versions/f5f88293428a_add_mathjax_column_to_rb_namespace.py new file: remarkbox/scripts/alembic/versions/f7b42b2024fb_namespace_reverse_node_order.py new file: remarkbox/scripts/alembic/versions/fa8402aa1a00_add_link_protection_column_to_namespace.py new file: remarkbox/scripts/dump_sqlite3_to_csv.sh new file: remarkbox/scripts/init_db.py new file: remarkbox/scripts/invalidate_node_cache.py new file: remarkbox/scripts/json_import.py new file: remarkbox/scripts/load_csv_into_postgres.sh new file: remarkbox/scripts/merge_dupes.py new file: remarkbox/scripts/modify_namespace.py new file: remarkbox/scripts/modify_node.py new file: remarkbox/scripts/modify_uris.py new file: remarkbox/scripts/modify_user.py new file: remarkbox/scripts/recompute_node_depths.py new file: remarkbox/scripts/safe_approve_all_nodes.py new file: remarkbox/scripts/send_node_digest_notifications.py new file: remarkbox/static/css/common.css new file: remarkbox/static/css/embed.css new file: remarkbox/static/css/pygments.css new file: remarkbox/static/favicon.ico new file: remarkbox/static/img/remarkbox-logo.png new file: remarkbox/static/img/remarkbox-minified.png new file: remarkbox/static/js/custom.js new file: remarkbox/static/js/iframe-resizer/ie8.polyfils.map new file: remarkbox/static/js/iframe-resizer/ie8.polyfils.min.js new file: remarkbox/static/js/iframe-resizer/iframeResizer.contentWindow.js new file: remarkbox/static/js/iframe-resizer/iframeResizer.contentWindow.map new file: remarkbox/static/js/iframe-resizer/iframeResizer.contentWindow.min.js new file: remarkbox/static/js/iframe-resizer/iframeResizer.js new file: remarkbox/static/js/iframe-resizer/iframeResizer.map new file: remarkbox/static/js/iframe-resizer/iframeResizer.min.js new file: remarkbox/static/js/iframe-resizer/index.js new file: remarkbox/static/js/iframe-resizer/readme.rst new file: remarkbox/static/js/jquery-2.1.3.min.js new file: remarkbox/static/theme/readme.rst new file: remarkbox/templates/base.j2 new file: remarkbox/templates/billing.j2 new file: remarkbox/templates/dynamic-remarkbox.css.j2 new file: remarkbox/templates/edit-node.j2 new file: remarkbox/templates/embed-iframe.txt.j2 new file: remarkbox/templates/home.j2 new file: remarkbox/templates/join-or-log-in.j2 new file: remarkbox/templates/list-namespace-owners.j2 new file: remarkbox/templates/list-namespace-requests.j2 new file: remarkbox/templates/list-nodes.j2 new file: remarkbox/templates/list-notifications.j2 new file: remarkbox/templates/mail_digest_html.j2 new file: remarkbox/templates/mail_digest_text.j2 new file: remarkbox/templates/mail_immediate_html.j2 new file: remarkbox/templates/mail_immediate_text.j2 new file: remarkbox/templates/mail_snippet_html.j2 new file: remarkbox/templates/mail_snippet_text.j2 new file: remarkbox/templates/namespace-settings.j2 new file: remarkbox/templates/new.j2 new file: remarkbox/templates/rss.xml.j2 new file: remarkbox/templates/setup-namespace.j2 new file: remarkbox/templates/show-count.j2 new file: remarkbox/templates/show-node.j2 new file: remarkbox/templates/show-node2.j2 new file: remarkbox/templates/sitemap.xml.j2 new file: remarkbox/templates/snippets/add-to-slack.j2 new file: remarkbox/templates/snippets/create.j2 new file: remarkbox/templates/snippets/csrf.j2 new file: remarkbox/templates/snippets/email.j2 new file: remarkbox/templates/snippets/flash-alerts.j2 new file: remarkbox/templates/snippets/forms.j2 new file: remarkbox/templates/snippets/google-analytics.j2 new file: remarkbox/templates/snippets/javascript-includes.j2 new file: remarkbox/templates/snippets/owner-key-comment.j2 new file: remarkbox/templates/snippets/phone-menu.j2 new file: remarkbox/templates/snippets/powered-by.j2 new file: remarkbox/templates/snippets/search.j2 new file: remarkbox/templates/snippets/snippets.j2 new file: remarkbox/templates/snippets/stripe.j2 new file: remarkbox/templates/snippets/stylesheet-includes.j2 new file: remarkbox/templates/snippets/submit.j2 new file: remarkbox/templates/update-card.j2 new file: remarkbox/templates/user-settings.j2 new file: remarkbox/templates/user-watching.j2 new file: remarkbox/tests/test_models.py new file: remarkbox/tests/test_render.py new file: remarkbox/tests/test_views.py new file: remarkbox/views/__init__.py new file: remarkbox/views/authenticated/__init__.py new file: remarkbox/views/authenticated/authenticated.py new file: remarkbox/views/authenticated/lock.py new file: remarkbox/views/authenticated/notifications.py new file: remarkbox/views/authenticated/oauth.py new file: remarkbox/views/authenticated/stripe.py new file: remarkbox/views/authenticated/topsecret.py new file: remarkbox/views/authenticated/watch.py new file: remarkbox/views/authentication.py new file: remarkbox/views/list_nodes.py new file: remarkbox/views/misc.py new file: remarkbox/views/modify_node.py new file: remarkbox/views/new_thread.py new file: remarkbox/views/reply_node.py new file: requirements-dev.txt new file: requirements-prod.txt new file: requirements-test.txt new file: requirements.py3.txt new file: requirements.txt new file: setup.py new file: test.ini
673 lines
20 KiB
Python
673 lines
20 KiB
Python
from collections import (
|
|
OrderedDict,
|
|
deque,
|
|
)
|
|
|
|
from sqlalchemy import (
|
|
BigInteger, Boolean, Column, Integer, Unicode, UnicodeText, or_, func
|
|
)
|
|
|
|
from sqlalchemy.orm import relationship, backref
|
|
|
|
import uuid
|
|
|
|
from slugify import slugify
|
|
|
|
from remarkbox.lib import (
|
|
timestamp_to_datetime,
|
|
timestamp_to_ago_string,
|
|
timestamp_to_date_string,
|
|
)
|
|
|
|
from remarkbox.lib.render import markdown_to_html
|
|
|
|
from .meta import Base, RBase
|
|
from .meta import UUIDType
|
|
from .meta import now_timestamp, foreign_key, get_object_by_id
|
|
|
|
from .uri import get_or_create_uri
|
|
|
|
from .event import NodeEvent
|
|
|
|
import codecs
|
|
|
|
import logging
|
|
|
|
try:
|
|
unicode("")
|
|
except:
|
|
from six import u as unicode
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
class Node(RBase, Base):
|
|
"""
|
|
Node class acts like a linked list to support nested:
|
|
|
|
Question -> Answers -> Comments
|
|
|
|
Thread -> Posts -> Remarks
|
|
|
|
The `parent_id` column is only used by children nodes.
|
|
|
|
The class will allow 3 layer nesting like StackOverflow or unlimited
|
|
nesting like reddit. The frontend code determines the particular
|
|
nesting strategy.
|
|
|
|
For more information checkout this section of the SQLAlchemy Docs:
|
|
|
|
http://docs.sqlalchemy.org/en/latest/orm/relationships.html#adjacency-list-relationships
|
|
"""
|
|
|
|
id = Column(UUIDType, primary_key=True, index=True)
|
|
root_id = Column(UUIDType, index=True, default=None)
|
|
parent_id = Column(UUIDType, foreign_key("Node", "id"), default=None)
|
|
namespace_id = Column(UUIDType, foreign_key("Namespace", "id"), default=None)
|
|
user_id = Column(UUIDType, foreign_key("User", "id"), default=None)
|
|
user_surrogate_id = Column(
|
|
UUIDType, foreign_key("UserSurrogate", "id"), default=None
|
|
)
|
|
title = Column(Unicode(256), default=None)
|
|
data = Column(UnicodeText, default=None)
|
|
data_html = Column(UnicodeText, default=None)
|
|
# the depth of this node in the thread's graph / tree.
|
|
graph_depth = Column(Integer, nullable=False, default=-1)
|
|
# TODO: someday this should be renamed to created_timestamp
|
|
created = Column(BigInteger, nullable=False)
|
|
# TODO: someday this should be renamed to changed_timestamp
|
|
changed = Column(BigInteger, nullable=False)
|
|
disabled_timestamp = Column(BigInteger)
|
|
disabled = Column(Boolean, default=False)
|
|
verified = Column(Boolean, default=False)
|
|
# a locked thread (root node) prevents new commenting.
|
|
locked = Column(Boolean, default=False)
|
|
# by default comments are approved. unless Namespace hide_unless_approved.
|
|
approved = Column(Boolean, default=True)
|
|
# is there a related Uri model to this node?
|
|
has_uri = Column(Boolean, default=False, nullable=False)
|
|
ip_address = Column(Unicode(45), default=None)
|
|
|
|
# lazy='joined' performs a left join to reduce queries, it's magic.
|
|
user = relationship(argument="User", lazy="joined")
|
|
|
|
# lazy='joined' performs a left join to reduce queries, it's magic.
|
|
user_surrogate = relationship(argument="UserSurrogate", lazy="joined")
|
|
|
|
# lazy='dynamic' returns a query object instead of collection.
|
|
# Reference: http://docs.sqlalchemy.org/en/latest/orm/collections.html
|
|
children = relationship(
|
|
argument="Node",
|
|
lazy="dynamic",
|
|
order_by="Node.created",
|
|
foreign_keys=[parent_id],
|
|
backref=backref("parent", remote_side=[id]),
|
|
)
|
|
|
|
# lazy='dynamic' returns a query object instead of collection.
|
|
# Reference: http://docs.sqlalchemy.org/en/latest/orm/collections.html
|
|
children_desc = relationship(
|
|
argument="Node",
|
|
lazy="dynamic",
|
|
order_by="desc(Node.created)",
|
|
foreign_keys=[parent_id],
|
|
)
|
|
|
|
votes = relationship(argument="Vote", backref="node", order_by="desc(Vote.created)")
|
|
|
|
# set uselist to false to create one-to-one relationship.
|
|
uri = relationship(argument="Uri", uselist=False, back_populates="node")
|
|
|
|
# set uselist to false to create one-to-one relationship.
|
|
namespace = relationship(argument="Namespace", uselist=False)
|
|
|
|
# lazy='joined' performs a left join to reduce queries, it's magic.
|
|
# this is used for denormalized stats like counts, etc.
|
|
cache = relationship(
|
|
argument="NodeCache", lazy="joined", back_populates="node", uselist=False
|
|
)
|
|
|
|
watchers = relationship(argument="Watcher", lazy="dynamic", back_populates="node")
|
|
|
|
events = relationship(argument="NodeEvent", lazy="dynamic", back_populates="node")
|
|
|
|
def __init__(self):
|
|
self.id = uuid.uuid1()
|
|
self.created = now_timestamp()
|
|
self.changed = self.created
|
|
|
|
def get_children(self, order_by="asc"):
|
|
if order_by == "desc":
|
|
return self.children_desc
|
|
return self.children
|
|
|
|
@property
|
|
def verified_children(self):
|
|
return self.children.filter(Node.verified == True)
|
|
|
|
@property
|
|
def unverified_children(self):
|
|
return self.children.filter(Node.verified == False)
|
|
|
|
@property
|
|
def path_to_root(self):
|
|
"""The path from this node to the root node."""
|
|
# TODO: memoize this result? how / when to deal with cache busting?
|
|
node = self
|
|
path_to_root = []
|
|
path_to_root.append(node)
|
|
while node.parent is not None:
|
|
node = node.parent
|
|
path_to_root.append(node)
|
|
return path_to_root
|
|
|
|
@property
|
|
def path_from_root(self):
|
|
"""The path from the root node to this node."""
|
|
return list(reversed(self.path_to_root))
|
|
|
|
@property
|
|
def root2(self):
|
|
"""The origin or top most parent node without a parent."""
|
|
if self.is_root:
|
|
return self
|
|
return self.path_to_root[-1]
|
|
|
|
@property
|
|
def root(self):
|
|
"""The origin or top most parent node without a parent."""
|
|
if self.is_root:
|
|
return self
|
|
return self.dbsession.query(Node).filter(Node.id == self.root_id).one()
|
|
|
|
@property
|
|
def is_root(self):
|
|
if not self.parent_id or not self.root_id or self.root_id == self.id:
|
|
return True
|
|
return False
|
|
|
|
def recompute_depth(self):
|
|
"""recompute the depth of a node in the thread."""
|
|
self.graph_depth = len(self.path_to_root) - 1
|
|
|
|
@property
|
|
def depth(self):
|
|
if self.graph_depth == -1:
|
|
self.recompute_depth()
|
|
return self.graph_depth
|
|
|
|
@property
|
|
def stats(self):
|
|
"""The denormalized stats for this node."""
|
|
from .node_cache import NodeCache
|
|
|
|
if self.cache is None:
|
|
self.cache = NodeCache(self.id)
|
|
return self.cache.stats
|
|
|
|
def score(self):
|
|
# this doesn't actually exist right now. I removed voting from this
|
|
# iteration of the codebase. maybe worth adding in at some point.
|
|
return sum([vote.value for vote in self.votes])
|
|
|
|
def children_order_by_score(self):
|
|
# TODO: this should be optimized, we should craft a query and have
|
|
# database to return results in the expected order instead of multiple
|
|
# queries. First we select children then we sort by selecting each
|
|
# vote for each child and sum the result.
|
|
return sorted(self.children, key=lambda x: x.score(), reverse=True)
|
|
|
|
def new_child(self):
|
|
"""create and return new child node of this node, in memory"""
|
|
child = Node()
|
|
child.parent = self
|
|
child.root_id = self.root_id
|
|
return child
|
|
|
|
def new_event(self, user, action):
|
|
"""create and return new node_event for this node, in memory"""
|
|
event = NodeEvent(node=self, user=user, action=action)
|
|
self.events.append(event)
|
|
return event
|
|
|
|
def avatar_uri(self, **kwargs):
|
|
"""Return invatar URI. Optionally return gravatar URI."""
|
|
u = self.user if self.user else self.user_surrogate
|
|
if self.disabled:
|
|
kwargs["text"] = "-"
|
|
kwargs["bg"] = "#aaaaaa"
|
|
if self.disabled or not self.verified:
|
|
return u.svgtar(**kwargs)
|
|
return u.avatar_uri(**kwargs)
|
|
|
|
@property
|
|
def created_timestamp(self):
|
|
return self.created
|
|
|
|
@property
|
|
def human_created_timestamp(self):
|
|
return timestamp_to_ago_string(self.created)
|
|
|
|
# TODO: take into account user's timezone?
|
|
@property
|
|
def created_date(self):
|
|
return timestamp_to_date_string(self.created)
|
|
|
|
# TODO: take into account user's timezone?
|
|
@property
|
|
def created_datetime(self):
|
|
return timestamp_to_datetime(self.created)
|
|
|
|
@property
|
|
def changed_timestamp(self):
|
|
return self.changed
|
|
|
|
@property
|
|
def human_changed_timestamp(self):
|
|
return timestamp_to_ago_string(self.changed)
|
|
|
|
# TODO: take into account user's timezone?
|
|
@property
|
|
def changed_date(self):
|
|
return timestamp_to_date_string(self.changed)
|
|
|
|
# TODO: take into account user's timezone?
|
|
@property
|
|
def changed_datetime(self):
|
|
return timestamp_to_datetime(self.changed)
|
|
|
|
@property
|
|
def title_ascii(self):
|
|
"""return the ascii version of the title, ignoring errors or None."""
|
|
if self.title:
|
|
return self.title.encode('ascii', 'ignore')
|
|
|
|
@property
|
|
def slug(self):
|
|
"""return slug from title or None"""
|
|
if self.title:
|
|
return slugify(self.title)
|
|
|
|
@property
|
|
def path(self):
|
|
"""return a node's path part of uri"""
|
|
if self.slug is not None:
|
|
return "/{}/{}".format(self.id, self.slug)
|
|
else:
|
|
return "/{}".format(self.id)
|
|
|
|
@property
|
|
def short_id(self):
|
|
"""return the base64 representation of UUID: 36 -> 22 unicode chars"""
|
|
base_64_id = codecs.encode(self.id.bytes, "base64")
|
|
base_64_id = base_64_id.rstrip(b"=\n").replace(b"/", b"_").replace(b"+", b"-")
|
|
return base_64_id.decode("utf-8")
|
|
|
|
@property
|
|
def was_edited(self):
|
|
return not self.created == self.changed
|
|
|
|
@property
|
|
def enabled(self):
|
|
return not self.disabled
|
|
|
|
def set_data(self, data, namespace=None):
|
|
if namespace is None:
|
|
namespace = self.root.namespace
|
|
self.data = data
|
|
self.data_html = markdown_to_html(data, namespace)
|
|
|
|
def _invalidate_cache(self):
|
|
if self.root.cache:
|
|
self.root.cache.invalidate()
|
|
|
|
def edit(self, data):
|
|
"""edit node."""
|
|
self.set_data(data)
|
|
self.changed = now_timestamp()
|
|
self._invalidate_cache()
|
|
|
|
def disable(self):
|
|
"""disable node."""
|
|
self.disabled = True
|
|
self.disabled_timestamp = now_timestamp()
|
|
self._invalidate_cache()
|
|
|
|
def enable(self):
|
|
"""enable node."""
|
|
self.disabled = False
|
|
self.disabled_timestamp = None
|
|
self._invalidate_cache()
|
|
|
|
def verify(self):
|
|
"""verify node."""
|
|
self.verified = True
|
|
self._invalidate_cache()
|
|
|
|
def unverify(self):
|
|
"""unverify node."""
|
|
self.verified = False
|
|
self._invalidate_cache()
|
|
|
|
def approve(self):
|
|
"""verify node."""
|
|
self.approved = True
|
|
self._invalidate_cache()
|
|
|
|
def deny(self):
|
|
"""unverify node."""
|
|
self.approved = False
|
|
self._invalidate_cache()
|
|
|
|
def visible(self):
|
|
if self.disabled:
|
|
return False
|
|
return True
|
|
|
|
def is_owner(self, user):
|
|
"""Return True if given user owns this node, else False."""
|
|
if user and user.authenticated and user == self.user:
|
|
return True
|
|
return False
|
|
|
|
|
|
def get_node_by_id(dbsession, node_id):
|
|
"""Try to get Node object by id or return None."""
|
|
return get_object_by_id(dbsession, node_id, Node)
|
|
|
|
|
|
def get_node_by_uri(dbsession, node_uri):
|
|
return get_or_create_uri(dbsession, node_uri).node
|
|
|
|
|
|
def create_root_node():
|
|
node = Node()
|
|
node.root_id = node.id
|
|
node.graph_depth = 0
|
|
return node
|
|
|
|
|
|
def get_or_create_node_by_uri(dbsession, node_uri, node_title=None):
|
|
"""Try to get Node object by uri or return new Node"""
|
|
uri = get_or_create_uri(dbsession, node_uri)
|
|
|
|
if uri.node is None:
|
|
# create new root node for external page (embed).
|
|
|
|
# imported here to prevent circular import.
|
|
from .namespace import get_or_create_namespace
|
|
|
|
uri.node = create_root_node()
|
|
uri.node.verified = True
|
|
uri.node.has_uri = True
|
|
uri.node.title = node_title
|
|
uri.node.namespace = get_or_create_namespace(dbsession, uri.parsed.hostname)
|
|
|
|
elif node_title:
|
|
# if the external page title has changed, update our database.
|
|
if node_title != uri.node.title:
|
|
uri.node.title = node_title
|
|
dbsession.add(uri.node)
|
|
dbsession.flush()
|
|
|
|
return uri.node
|
|
|
|
|
|
def get_topsecret_roots(dbsession):
|
|
"""Return all verified parent nodes by descending created order."""
|
|
return (
|
|
dbsession.query(Node)
|
|
.filter(Node.parent_id == None, Node.verified == True, Node.disabled == False)
|
|
.order_by(Node.changed.desc())
|
|
)
|
|
|
|
|
|
def get_topsecret_nodes(dbsession, limit=100, offset=0):
|
|
"""Return all nodes by descending created order."""
|
|
return (
|
|
dbsession.query(Node)
|
|
.filter(Node.disabled == False, Node.user_id != None)
|
|
.order_by(Node.changed.desc())
|
|
.limit(limit)
|
|
.offset(offset)
|
|
)
|
|
|
|
|
|
def get_root_nodes(dbsession):
|
|
"""Return a list of all root nodes (nodes without a parent.)"""
|
|
return (
|
|
dbsession.query(Node)
|
|
.filter(Node.parent_id == None)
|
|
.order_by(Node.changed.desc())
|
|
)
|
|
|
|
|
|
def get_root_nodes_by_keywords(dbsession, keywords, namespace=None):
|
|
|
|
# make dictionary mapping of root_node_ids to accumulated scores
|
|
root_scores = {}
|
|
|
|
# make dictionary mapping of root_node_ids to root_node_objects
|
|
root_nodes = {}
|
|
|
|
# a list of matched node objects.
|
|
nodes = []
|
|
|
|
if namespace:
|
|
node_query = namespace.visible_nodes
|
|
else:
|
|
node_query = get_nodes_who_share_roots_query(
|
|
dbsession, get_topsecret_roots(dbsession)
|
|
)
|
|
|
|
for keyword in keywords:
|
|
|
|
# extend nodes, with a list of nodes which match this keyword.
|
|
keyword_filter = Node.data.ilike("%{}%".format(keyword))
|
|
nodes.extend(node_query.filter(keyword_filter).all())
|
|
|
|
# accumulate scores and root node objects.
|
|
for node in nodes:
|
|
if node.root.id not in root_scores:
|
|
root_scores[node.root.id] = 0
|
|
root_scores[node.root.id] += 1
|
|
|
|
if node.root.id not in root_nodes:
|
|
root_nodes[node.root.id] = node.root
|
|
|
|
# sort the scores dictionary by value (score),
|
|
# but get the key (object id),
|
|
# reversed (highest score first)
|
|
sorted_root_ids = sorted(root_scores, key=root_scores.get, reverse=True)
|
|
|
|
# return root node objects in sorted order by score.
|
|
return [root_nodes[root_id] for root_id in sorted_root_ids]
|
|
|
|
|
|
def get_nodes_who_share_roots_query(dbsession, root_nodes):
|
|
# a giant n "or" query, where n is the number of root_nodes.
|
|
if root_nodes.count() == 0:
|
|
# return a "null" query object. still hits database...
|
|
# this allows us to pass around the query object.
|
|
from sqlalchemy.sql import text
|
|
return dbsession.query(Node).filter(text("0 == 1"))
|
|
return dbsession.query(Node).filter(or_(Node.root_id == n.id for n in root_nodes))
|
|
|
|
|
|
def get_nodes_who_share_roots(dbsession, root_nodes):
|
|
return get_nodes_who_share_roots_query(dbsession, root_nodes).all()
|
|
|
|
|
|
def get_nodes_who_share_root(dbsession, root_node, order="oldest-first"):
|
|
nodes = dbsession.query(Node).filter(Node.root_id == root_node.id)
|
|
if order == "oldest-first":
|
|
nodes = nodes.order_by(Node.created)
|
|
elif order == "newest-first":
|
|
nodes = nodes.order_by(Node.created.desc())
|
|
return nodes
|
|
|
|
|
|
def get_node_count_who_share_root(dbsession, root_node):
|
|
return (
|
|
dbsession.query(func.count("id"))
|
|
.select_from(Node)
|
|
.filter(Node.root_id == root_node.id)
|
|
.scalar()
|
|
)
|
|
|
|
|
|
def get_visible_node_count_who_share_root(dbsession, root_node):
|
|
return (
|
|
dbsession.query(func.count("id"))
|
|
.select_from(Node)
|
|
.filter(
|
|
Node.root_id == root_node.id, Node.disabled == False, Node.approved == True
|
|
)
|
|
.scalar()
|
|
)
|
|
|
|
|
|
def get_node_id_map(nodes):
|
|
"""return a dictionary of all nodes who share this root."""
|
|
node_id_map = {}
|
|
for node in nodes:
|
|
node_id_map[node.id] = node
|
|
return node_id_map
|
|
|
|
|
|
def get_graph_from_nodes(nodes):
|
|
"""infinite nested graph or tree of nodes."""
|
|
graph = OrderedDict()
|
|
|
|
# create all node keys in graph.
|
|
for node in nodes:
|
|
graph[node.id] = []
|
|
|
|
for node in nodes:
|
|
if node.parent is not None:
|
|
graph[node.parent.id].append(node.id)
|
|
|
|
return graph
|
|
|
|
|
|
def get_group_conversation_ids(nodes):
|
|
"""Return a list of node ids whose parent is root."""
|
|
if len(list(nodes)) == 0:
|
|
return []
|
|
root = nodes[0].root
|
|
return [node.id for node in nodes if node.parent and node.parent.id == root.id]
|
|
|
|
|
|
# Reference post:
|
|
# http://kmkeen.com/python-trees/2010-09-18-08-55-50-039.html
|
|
# strategy queue.
|
|
def flatten_graph(node_id, graph, include_given_node_id=False):
|
|
"""
|
|
Given a node_id and graph (or tree),
|
|
return a list of every descendant node_id, depth first order.
|
|
"""
|
|
flat_graph = []
|
|
|
|
to_crawl = deque([node_id])
|
|
|
|
while to_crawl:
|
|
current_id = to_crawl.pop()
|
|
|
|
flat_graph.append(current_id)
|
|
|
|
children_ids = graph[current_id]
|
|
|
|
# reverse so that we extend the queue in the expected order.
|
|
children_ids.reverse()
|
|
|
|
to_crawl.extend(children_ids)
|
|
|
|
if include_given_node_id:
|
|
return flat_graph
|
|
return flat_graph[1:]
|
|
|
|
|
|
def get_conversation_graph_from_nodes(nodes, graph=None, node_id_map=None, order="oldest-first"):
|
|
"""
|
|
Given a list of nodes, return a grouped conversation graph which is a
|
|
dictionary of ids where the keys are the top level conversation node ids
|
|
and the values are a list of all decendant node ids.
|
|
|
|
If you already have it computed, you may optionally provide an existing
|
|
graph (dictionary tree of node ids), and/or an existing node_id_map.
|
|
"""
|
|
def get_timestamp(node_id):
|
|
return node_id_map[node_id].created
|
|
|
|
if graph is None:
|
|
graph = get_graph_from_nodes(nodes)
|
|
|
|
if node_id_map is None:
|
|
node_id_map = get_node_id_map(nodes)
|
|
|
|
_reverse = False
|
|
if order == "newest-first":
|
|
_reverse = True
|
|
|
|
convo_graph = OrderedDict()
|
|
|
|
convo_ids = sorted(
|
|
get_group_conversation_ids(nodes),
|
|
key=get_timestamp,
|
|
reverse=_reverse
|
|
)
|
|
|
|
for convo_node_id in convo_ids:
|
|
descendant_ids = flatten_graph(
|
|
convo_node_id,
|
|
graph,
|
|
)
|
|
# sort by timestamp, oldest first.
|
|
convo_graph[convo_node_id] = sorted(
|
|
descendant_ids,
|
|
key=get_timestamp,
|
|
)
|
|
|
|
return convo_graph
|
|
|
|
|
|
def recompute_depth_on_all_node_objects(dbsession, chunk_lenth=500):
|
|
"""Only use this if a many nodes were moved and/or re-nested."""
|
|
|
|
#
|
|
# WARNING, this could take a long time and be computationally expensive
|
|
# both on the host running it, and the database, respectively.
|
|
#
|
|
|
|
import math
|
|
|
|
log.info("loading and querying nodes...")
|
|
|
|
node_count = dbsession.query(Node).count()
|
|
|
|
nodes = dbsession.query(Node).all()
|
|
|
|
log.info("node_count={}".format(node_count))
|
|
|
|
for i, node in enumerate(nodes):
|
|
|
|
# this is the computationally intense part.
|
|
node.recompute_depth()
|
|
dbsession.add(node)
|
|
|
|
if i % chunk_lenth == 0:
|
|
progress = int(math.floor((i / float(node_count)) * 100))
|
|
log.info(
|
|
"Flushing chunk={}/{}, chunk_lenth={} progress={}%".format(
|
|
i, node_count, chunk_lenth, progress
|
|
)
|
|
)
|
|
dbsession.flush()
|
|
|
|
# one final flush to database for the last partial chunk.
|
|
log.info(
|
|
"Flushing chunk={}/{}, chunk_lenth={} progress={}%".format(
|
|
i + 1, node_count, chunk_lenth, 100
|
|
)
|
|
)
|
|
dbsession.flush()
|