def get_idea_ids_showing_post(cls, post_id): "Given a post, give the ID of the ideas that show this message" from sqlalchemy.sql.functions import func from .idea_content_link import IdeaContentPositiveLink from .post import Post (ancestry, discussion_id, idea_link_ids) = cls.default_db.query( Post.ancestry, Post.discussion_id, func.idea_content_links_above_post(Post.id) ).filter(Post.id==post_id).first() post_path = "%s%d," % (ancestry, post_id) if not idea_link_ids: return [] idea_link_ids = [int(id) for id in idea_link_ids.split(',') if id] # This could be combined with previous in postgres. root_ideas = cls.default_db.query( IdeaContentPositiveLink.idea_id.distinct() ).filter( IdeaContentPositiveLink.idea_id != None, IdeaContentPositiveLink.id.in_(idea_link_ids)).all() if not root_ideas: return [] root_ideas = [x for (x,) in root_ideas] discussion_data = cls.get_discussion_data(discussion_id) counter = cls.prepare_counters(discussion_id) idea_contains = {} for root_idea_id in root_ideas: for idea_id in discussion_data.idea_ancestry(root_idea_id): if idea_id in idea_contains: break idea_contains[idea_id] = counter.paths[idea_id].includes_post(post_path) ideas = [id for (id, incl) in idea_contains.iteritems() if incl] return ideas
def get_idea_ids_showing_post(cls, post_id): "Given a post, give the ID of the ideas that show this message" from sqlalchemy.sql.functions import func from .idea_content_link import IdeaContentPositiveLink from .post import Post (ancestry, discussion_id, idea_link_ids) = cls.default_db.query( Post.ancestry, Post.discussion_id, func.idea_content_links_above_post(Post.id) ).filter(Post.id==post_id).first() post_path = "%s%d," % (ancestry, post_id) if not idea_link_ids: return [] idea_link_ids = [int(id) for id in idea_link_ids.split(',') if id] # This could be combined with previous in postgres. root_ideas = cls.default_db.query( IdeaContentPositiveLink.idea_id.distinct() ).filter( IdeaContentPositiveLink.idea_id != None, IdeaContentPositiveLink.id.in_(idea_link_ids)).all() if not root_ideas: return [] root_ideas = [x for (x,) in root_ideas] discussion_data = cls.get_discussion_data(discussion_id) counter = cls.prepare_counters(discussion_id) idea_contains = {} for root_idea_id in root_ideas: for idea_id in discussion_data.idea_ancestry(root_idea_id): if idea_id in idea_contains: break idea_contains[idea_id] = counter.paths[idea_id].includes_post(post_path) ideas = [id for (id, incl) in idea_contains.iteritems() if incl] return ideas
class Post(Content): """ A Post represents input into the broader discussion taking place on Assembl. It may be a response to another post, it may have responses, and its content may be of any type. """ __tablename__ = "post" id = Column(Integer, ForeignKey( 'content.id', ondelete='CASCADE', onupdate='CASCADE' ), primary_key=True) # This is usually an email, but we won't enforce it because we get some # weird stuff from outside. message_id = Column(CoerceUnicode, nullable=False, index=True, doc="The email-compatible message-id for the post.", info={'rdf': QuadMapPatternS(None, SIOC.id)}) ancestry = Column(String, default="") __table_args__ = ( Index( 'ix_%s_post_ancestry' % (Content.full_schema,), 'ancestry', unique=False, postgresql_ops={'ancestry': 'varchar_pattern_ops'}),) parent_id = Column(Integer, ForeignKey( 'post.id', ondelete='CASCADE', onupdate='SET NULL'), index=True) children = relationship( "Post", foreign_keys=[parent_id], backref=backref('parent', remote_side=[id]), ) publication_state = Column( PublicationStates.db_type(), nullable=False, server_default=PublicationStates.PUBLISHED.name) moderator_id = Column(Integer, ForeignKey( 'user.id', ondelete='SET NULL', onupdate='CASCADE'), nullable=True,) moderated_on = Column(DateTime) moderation_text = Column(UnicodeText) moderator_comment = Column(UnicodeText) # For other moderators moderator = relationship( "User", foreign_keys=[moderator_id], backref=backref('posts_moderated'), ) # All the idea content links of the ancestors of this post idea_content_links_above_post = column_property( func.idea_content_links_above_post(id), deferred=True, expire_on_flush=False) @classmethod def special_quad_patterns(cls, alias_maker, discussion_id): # Don't we need a recursive alias for this? It seems not. return [ QuadMapPatternS( Post.iri_class().apply(cls.id), SIOC.reply_of, cls.iri_class().apply(cls.parent_id), name=QUADNAMES.post_parent, conditions=(cls.parent_id != None,)), ] creator_id = Column( Integer, ForeignKey('agent_profile.id'), nullable=False, index=True, info={'rdf': QuadMapPatternS( None, SIOC.has_creator, AgentProfile.agent_as_account_iri.apply(None))}) creator = relationship(AgentProfile, foreign_keys=[creator_id], backref="posts_created") __mapper_args__ = { 'polymorphic_identity': 'post', 'with_polymorphic': '*' } def is_owner(self, user_id): return self.creator_id == user_id def get_descendants(self): assert self.id descendants = self.db.query(Post).filter( Post.parent_id == self.id).order_by( Content.creation_date) return descendants def is_read(self): # TODO: Make it user-specific. return self.views is not None def get_url(self): from assembl.lib.frontend_urls import FrontendUrls frontendUrls = FrontendUrls(self.discussion) return frontendUrls.get_post_url(self) @staticmethod def shorten_text(text, target_len=120): if len(text) > target_len: text = text[:target_len].rsplit(' ', 1)[0].rstrip() + ' ' return text @staticmethod def shorten_html_text(text, target_len=120): shortened = False html_len = 2 * target_len while True: pure_text = sanitize_text(text[:html_len]) if html_len >= len(text) or len(pure_text) > target_len: shortened = html_len < len(text) text = pure_text break html_len += target_len text = Post.shorten_text(text) if shortened and text[-1] != ' ': text += ' ' return text def get_body_preview(self): if self.publication_state in moderated_publication_states: # TODO: Handle multilingual moderation return LangString.create( self.moderation_text, self.discussion.main_locale) elif self.publication_state in deleted_publication_states: return LangString.EMPTY(self.db) body = self.get_body() is_html = self.get_body_mime_type() == 'text/html' ls = LangString() shortened = False for entry in body.entries: if not entry.value: short = entry.value elif is_html: short = self.shorten_html_text(entry.value) else: short = self.shorten_text(entry.value) if short != entry.value: shortened = True _ = LangStringEntry( value=short, locale_id=entry.locale_id, langstring=ls) if shortened or is_html: return ls else: return body def get_original_body_preview(self): if self.publication_state in moderated_publication_states: # TODO: Handle multilingual moderation return self.moderation_text elif self.publication_state in deleted_publication_states: return LangString.EMPTY(self.db) body = self.get_body().first_original().value is_html = self.get_body_mime_type() == 'text/html' shortened = False if not body: short = body elif is_html: short = self.shorten_html_text(body) else: short = self.shorten_text(body) if short != body: shortened = True if shortened or is_html: return short else: return body def _set_ancestry(self, new_ancestry): self.ancestry = new_ancestry descendant_ancestry = "%s%d," % ( self.ancestry, self.id) for descendant in self.get_descendants(): descendant._set_ancestry(descendant_ancestry) def set_parent(self, parent): self.parent = parent self.db.add(self) self.db.flush() self._set_ancestry("%s%d," % ( parent.ancestry or '', parent.id )) def last_updated(self): ancestry_query_string = "%s%d,%%" % (self.ancestry or '', self.id) query = self.db.query( func.max(Content.creation_date) ).select_from( Post ).join( Content ).filter( or_(Post.ancestry.like(ancestry_query_string), Post.id == self.id) ) return query.scalar() def ancestor_ids(self): ancestor_ids = [ int(ancestor_id) \ for ancestor_id \ in self.ancestry.split(',') \ if ancestor_id ] return ancestor_ids def ancestors(self): ancestors = [ Post.get(ancestor_id) \ for ancestor_id \ in self.ancestor_ids ] return ancestors def prefetch_descendants(self): pass #TODO def visit_posts_depth_first(self, post_visitor): self.prefetch_descendants() self._visit_posts_depth_first(post_visitor, set()) def _visit_posts_depth_first(self, post_visitor, visited): if self in visited: # not necessary in a tree, but let's start to think graph. return False result = post_visitor.visit_post(self) visited.add(self) if result is not PostVisitor.CUT_VISIT: for child in self.children: child._visit_posts_depth_first(post_visitor, visited) def visit_posts_breadth_first(self, post_visitor): self.prefetch_descendants() result = post_visitor.visit_post(self) visited = {self} if result is not PostVisitor.CUT_VISIT: self._visit_posts_breadth_first(post_visitor, visited) def _visit_posts_breadth_first(self, post_visitor, visited): children = [] for child in self.children: if child in visited: continue result = post_visitor.visit_post(child) visited.add(child) if result != PostVisitor.CUT_VISIT: children.append(child) for child in children: child._visit_posts_breadth_first(post_visitor, visited) def has_next_sibling(self): if self.parent_id: return self != self.parent.children[-1] return False @property def has_live_child(self): for child in self.children: if not child.is_tombstone: return True def delete_post(self, cause): """Set the publication state to a deleted state Includes an optimization whereby deleted posts without live descendents are tombstoned. Should be resilient to deletion order.""" self.publication_state = cause if not self.has_live_child: self.is_tombstone = True # If ancestor is deleted without being tombstone, make it tombstone ancestor = self.parent while (ancestor and ancestor.publication_state in deleted_publication_states and not ancestor.is_tombstone and not ancestor.has_live_child): ancestor.is_tombstone = True ancestor = ancestor.parent # As tombstones are an optimization in this case, # allow necromancy. can_be_resurrected = True def undelete_post(self): self.publication_state = PublicationStates.PUBLISHED ancestor = self while ancestor and ancestor.is_tombstone: ancestor.is_tombstone = False ancestor = ancestor.parent def get_subject(self): if self.publication_state in blocking_publication_states: #return None return LangString.EMPTY() if self.subject: return super(Post, self).get_subject() def get_body(self): if self.publication_state in blocking_publication_states: #return None return LangString.EMPTY() if self.body: return super(Post, self).get_body() def get_original_body_as_html(self): if self.publication_state in blocking_publication_states: return LangString.EMPTY(self.db) return super(Post, self).get_original_body_as_html() def get_body_as_text(self): if self.publication_state in blocking_publication_states: return LangString.EMPTY(self.db) return super(Post, self).get_body_as_text() def indirect_idea_content_links(self): from pyramid.threadlocal import get_current_request request = get_current_request() if request: return self.indirect_idea_content_links_with_cache() else: return self.indirect_idea_content_links_without_cache() def indirect_idea_content_links_without_cache(self): "Return all ideaContentLinks related to this post or its ancestors" from .idea_content_link import IdeaContentLink ancestors = filter(None, self.ancestry.split(",")) ancestors = [int(x) for x in ancestors] ancestors.append(self.id) return self.db.query(IdeaContentLink).filter( IdeaContentLink.content_id.in_(ancestors)).all() def filter_idea_content_links_r(self, idea_content_links): """Exclude positive links if a negative link points from the same idea to the same post or a post below. Works on dict representations of IdeaContentLink, a version with instances is TODO.""" from .idea_content_link import IdeaContentNegativeLink from collections import defaultdict icnl_polymap = { cls.external_typename() for cls in IdeaContentNegativeLink.get_subclasses()} neg_links = [icl for icl in idea_content_links if icl["@type"] in icnl_polymap] if not neg_links: return idea_content_links pos_links = [icl for icl in idea_content_links if icl["@type"] not in icnl_polymap] links = [] ancestor_ids = self.ancestry.split(",") ancestor_ids = [int(x or 0) for x in ancestor_ids] ancestor_ids[-1] = self.id neg_link_post_ids = defaultdict(list) for icl in neg_links: neg_link_post_ids[icl["idIdea"]].append( self.get_database_id(icl["idPost"])) for link in pos_links: idea_id = link["idIdea"] if idea_id in neg_link_post_ids: pos_post_id = self.get_database_id(link["idPost"]) for neg_post_id in neg_link_post_ids[idea_id]: if (ancestor_ids.index(neg_post_id) > ancestor_ids.index(pos_post_id)): break else: links.append(link) else: links.append(link) links.extend(neg_links) return links def indirect_idea_content_links_with_cache( self, links_above_post=None, filter=True): "Return all ideaContentLinks related to this post or its ancestors" # WIP: idea_content_links_above_post is still loaded separately # despite not being deferred. Deferring it hits a sqlalchemy bug. # Still appreciable performance gain using it instead of the orm, # and the ICL cache below. # TODO: move in path_utils? links_above_post = (self.idea_content_links_above_post if links_above_post is None else links_above_post) if not links_above_post: return [] from pyramid.threadlocal import get_current_request from .idea_content_link import IdeaContentLink from .idea import Idea icl_polymap = IdeaContentLink.__mapper__.polymorphic_map request = get_current_request() if getattr(request, "_idea_content_link_cache2", None) is None: if getattr(request, "_idea_content_link_cache1", None) is None: icl = with_polymorphic(IdeaContentLink, IdeaContentLink) co = with_polymorphic(Content, Content) request._idea_content_link_cache1 = {x[0]: x for x in self.db.query( icl.id, icl.idea_id, icl.content_id, icl.creator_id, icl.type, icl.creation_date).join(co).filter( co.discussion_id == self.discussion_id)} request._idea_content_link_cache2 = {} def icl_representation(id): if id not in request._idea_content_link_cache2: data = request._idea_content_link_cache1.get(id, None) if data is None: return None request._idea_content_link_cache2[id] = { "@id": IdeaContentLink.uri_generic(data[0]), "idIdea": Idea.uri_generic(data[1]), "idPost": Content.uri_generic(data[2]), "idCreator": AgentProfile.uri_generic(data[3]), "@type": icl_polymap[data[4]].class_.external_typename(), "created": data[5].isoformat() + "Z" } return request._idea_content_link_cache2[id] icls = [icl_representation(int(id)) for id in links_above_post.strip(',').split(',')] if filter: icls = self.filter_idea_content_links_r(icls) return icls def language_priors(self, translation_service): from .auth import User, UserLanguagePreferenceCollection priors = super(Post, self).language_priors(translation_service) creator = self.creator or AgentProfile.get(self.creator_id) if creator and isinstance(creator, User): # probably a language that the user knows try: prefs = UserLanguagePreferenceCollection(creator.id) known_languages = prefs.known_languages() except AssertionError: # user without prefs from pyramid.threadlocal import get_current_request request = get_current_request() if request: known_languages = [request.locale_name] else: return priors known_languages = [] known_languages = {translation_service.asKnownLocale(loc) for loc in known_languages} priors = {k: v * (1 if k in known_languages else 0.7) for (k, v) in priors.iteritems()} for lang in known_languages: if lang not in priors: priors[lang] = 1 return priors @classmethod def extra_collections(cls): from .idea_content_link import IdeaContentLink class IdeaContentLinkCollection(AbstractCollectionDefinition): def __init__(self, cls): super(IdeaContentLinkCollection, self ).__init__(cls, IdeaContentLink) def decorate_query( self, query, owner_alias, last_alias, parent_instance, ctx): parent = owner_alias children = last_alias ancestors = filter(None, parent_instance.ancestry.split(",")) ancestors = [int(x) for x in ancestors] ancestors.append(parent_instance.id) return query.join( parent, children.content_id.in_(ancestors)) def decorate_instance( self, instance, parent_instance, assocs, user_id, ctx, kwargs): pass def contains(self, parent_instance, instance): return instance.content_id == parent_instance.id or ( str(instance.content_id) in parent_instance.ancestry.split(",")) return {'indirect_idea_content_links': IdeaContentLinkCollection(cls)} @classmethod def restrict_to_owners(cls, query, user_id): "filter query according to object owners" return query.filter(cls.creator_id == user_id)