High priority fixes: - T0: Profile page now filters comments by namespace (was leaking cross-site) - T1: URI hostnames and namespace names normalized to lowercase (was causing duplicate threads and "stock comments" bug). Includes merge script. - T2: Thread detail API now paginated with SQL-side filtering (was 502 on 267+ reply threads) Features: - T3: GDPR account deletion (tombstone user with scrubbed PII) and data export - T4: Customizable button text and comment labels per namespace - T5: Self-service namespace deletion for owners - T6: @mention notifications with profile links - T7: Webmention receiving endpoint with h-card extraction - T8: Configurable max nesting depth and collapse depth per namespace - T9: AJAX thread title search to prevent duplicates - T10: Browser push notification support (VAPID/service worker) Docs and housekeeping: - T11: Documented thread_uri behavior when moving embeds - T12/T13: Drafted community replies for resolved feature requests - Collapse depth defaults to infinite (load-more disabled unless configured) 364 tests pass, 4 skipped.
713 lines
21 KiB
Python
713 lines
21 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 empty string"""
|
|
if self.title:
|
|
return slugify(self.title)
|
|
return ""
|
|
|
|
@property
|
|
def path(self):
|
|
"""return a node's path part of uri"""
|
|
if self.slug:
|
|
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, dbsession=None):
|
|
if namespace is None:
|
|
namespace = self.root.namespace
|
|
if dbsession is None:
|
|
dbsession = self.dbsession
|
|
self.data = data
|
|
self.data_html = markdown_to_html(data, namespace, dbsession=dbsession)
|
|
|
|
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",
|
|
limit=None, offset=None,
|
|
exclude_root=False,
|
|
visibility_filters=None):
|
|
"""Return nodes sharing a root, with optional pagination and SQL-side filtering.
|
|
|
|
Args:
|
|
dbsession: SQLAlchemy session.
|
|
root_node: The root Node whose tree to query.
|
|
order: 'oldest-first' or 'newest-first'.
|
|
limit: Maximum number of rows to return (None = unlimited).
|
|
offset: Number of rows to skip (None = 0).
|
|
exclude_root: If True, exclude the root node itself from results.
|
|
visibility_filters: Optional dict of SQL visibility filters to apply.
|
|
Supported keys: disabled (bool), approved (bool), verified (bool).
|
|
|
|
Returns:
|
|
SQLAlchemy query object (call .all() to materialise).
|
|
"""
|
|
nodes = dbsession.query(Node).filter(Node.root_id == root_node.id)
|
|
|
|
if exclude_root:
|
|
nodes = nodes.filter(Node.id != root_node.id)
|
|
|
|
# Apply SQL-side visibility filters
|
|
if visibility_filters:
|
|
if "disabled" in visibility_filters:
|
|
nodes = nodes.filter(Node.disabled == visibility_filters["disabled"])
|
|
if "approved" in visibility_filters:
|
|
nodes = nodes.filter(Node.approved == visibility_filters["approved"])
|
|
if "verified" in visibility_filters:
|
|
nodes = nodes.filter(Node.verified == visibility_filters["verified"])
|
|
|
|
if order == "oldest-first":
|
|
nodes = nodes.order_by(Node.created)
|
|
elif order == "newest-first":
|
|
nodes = nodes.order_by(Node.created.desc())
|
|
|
|
if offset is not None:
|
|
nodes = nodes.offset(offset)
|
|
if limit is not None:
|
|
nodes = nodes.limit(limit)
|
|
|
|
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()
|