"""Definition of the discussion class."""
from __future__ import division
from future import standard_library
standard_library.install_aliases()
from builtins import str
from itertools import groupby, chain
import traceback
from datetime import datetime
from collections import defaultdict
import logging
from future.utils import as_native_str
import simplejson as json
from pyramid.security import Allow, ALL_PERMISSIONS
from pyramid.settings import asbool
from pyramid.path import DottedNameResolver
from pyramid.httpexceptions import HTTPUnauthorized
from pyramid.threadlocal import get_current_registry
from sqlalchemy import (
Column,
Integer,
UnicodeText,
DateTime,
Text,
String,
Boolean,
event,
ForeignKey,
func,
inspect,
)
from sqlalchemy.orm import (
relationship, join, subqueryload, joinedload, backref, with_polymorphic)
from sqlalchemy.exc import InvalidRequestError
from sqlalchemy.sql.expression import literal, distinct
from assembl.lib import config
from assembl.lib.utils import slugify, get_global_base_url, full_class_name
from ..lib.sqla_types import URLString, CoerceUnicode
from ..lib.sqla import CrudOperation
from ..lib.locale import strip_country
from ..lib.discussion_creation import IDiscussionCreationCallback
from . import DiscussionBoundBase, NamedClassMixin, OriginMixin
from ..semantic.virtuoso_mapping import QuadMapPatternS
from ..auth import (
P_READ, R_SYSADMIN, P_ADMIN_DISC, R_PARTICIPANT, P_SYSADMIN,
CrudPermissions, Authenticated, Everyone)
from .auth import User
from ..auth.util import get_permissions, permissions_for_state
from .permissions import (
DiscussionPermission, Role, Permission, UserRole, LocalUserRole,
UserTemplate)
from .publication_states import PublicationFlow
from .preferences import Preferences
from ..semantic.namespaces import (CATALYST, ASSEMBL, DCTERMS)
resolver = DottedNameResolver(__package__)
log = logging.getLogger(__name__)
[docs]class Discussion(NamedClassMixin, OriginMixin, DiscussionBoundBase):
"""
The context for a specific IdeaLoom discussion.
Most platform entities exist in the scope of a discussion, and inherit from
:py:class:`assembl.models.DiscussionBoundBase`.
"""
__tablename__ = "discussion"
__external_typename = "Conversation"
rdf_class = CATALYST.Conversation
id = Column(Integer, primary_key=True,
info={'rdf': QuadMapPatternS(None, ASSEMBL.db_id)})
topic = Column(UnicodeText, nullable=False,
info={'rdf': QuadMapPatternS(None, DCTERMS.title)})
slug = Column(CoerceUnicode, nullable=False, unique=True, index=True)
objectives = Column(UnicodeText)
instigator = Column(UnicodeText)
introduction = Column(UnicodeText)
introductionDetails = Column(UnicodeText)
subscribe_to_notifications_on_signup = Column(Boolean, default=True)
web_analytics_piwik_id_site = Column(Integer, nullable=True, default=None)
help_url = Column(URLString, nullable=True, default=None)
logo_url = Column(URLString, nullable=True, default=None)
homepage_url = Column(URLString, nullable=True, default=None)
show_help_in_debate_section = Column(Boolean, default=True)
preferences_id = Column(Integer, ForeignKey(Preferences.id))
creator_id = Column(Integer, ForeignKey('user.id', ondelete="SET NULL"))
idea_pubflow_id = Column(
Integer, ForeignKey(PublicationFlow.id, ondelete="SET NULL", onupdate="CASCADE"))
preferences = relationship(Preferences, backref=backref(
'discussion', uselist=False), cascade="all, delete-orphan", single_parent=True)
creator = relationship('User', backref="discussions_created")
idea_publication_flow = relationship(PublicationFlow)
@classmethod
def get_naming_column_name(cls):
return "slug"
@property
def admin_source(self):
""" Return the admin source for this discussion. Used by notifications
Very naive temporary implementation, to be revised with a proper relationship later """
from .mail import AbstractMailbox
for source in self.sources:
if isinstance(source, AbstractMailbox):
return source
raise ValueError("No source of type AbstractMailbox found to serve as admin source")
def check_url_or_none(self, url):
if url == '':
url = None
if url is not None:
from urllib.parse import urlparse
parsed_url = urlparse(url)
from pyramid.httpexceptions import HTTPBadRequest
if not parsed_url.scheme:
raise HTTPBadRequest(
"The homepage url does not have a scheme. Must be either http or https"
)
if parsed_url.scheme not in (u'http', u'https'):
raise HTTPBadRequest(
"The url has an incorrect scheme. Only http and https are accepted for homepage url"
)
return url
@property
def homepage(self):
return self.homepage_url
@homepage.setter
def homepage(self, url):
url = self.check_url_or_none(url)
self.homepage_url = url
@property
def logo(self):
return self.logo_url
@logo.setter
def logo(self, url):
url = self.check_url_or_none(url)
self.logo_url = url
def read_post_ids(self, user_id):
from .post import Post
from .action import ViewPost
return (x[0] for x in self.db.query(Post.id).join(
ViewPost
).filter(
Post.discussion_id == self.id,
ViewPost.actor_id == user_id,
ViewPost.post_id == Post.id
))
def get_read_posts_ids_preload(self, user_id):
from .post import Post
return json.dumps([
Post.uri_generic(id) for id in self.read_post_ids(user_id)])
def import_from_sources(self, only_new=True):
for source in self.sources:
# refresh after calling
source = self.db.merge(source)
assert source is not None
assert source.id
try:
source.import_content(only_new=only_new)
except Exception:
traceback.print_exc()
def creation_side_effects(self, context):
if not self.table_of_contents:
from .idea_graph_view import TableOfContents
self.table_of_contents = TableOfContents(discussion=self)
yield self.table_of_contents.get_instance_context(
self.get_collection_context("table_of_contents", context))
if not self.preferences:
default_prefs = Preferences.get_default_preferences(self.db)
self.preferences = Preferences(name='discussion_' + self.slug,
cascade_preferences=default_prefs)
yield self.preferences.get_instance_context(
self.get_collection_context("preferences", context))
if not self.next_synthesis:
from .idea_graph_view import Synthesis
self.next_synthesis = Synthesis(discussion=self)
yield self.next_synthesis.get_instance_context(
self.get_collection_context("next_synthesis", context))
participant = self.db.query(Role).filter_by(name=R_PARTICIPANT).one()
ut = UserTemplate(
discussion=self, for_role=participant)
template_ctx = ut.get_instance_context(
self.get_collection_context("user_templates", context))
yield template_ctx
nss, _ = ut.get_notification_subscriptions_and_changed(False)
subs_ctx = ut.get_collection_context(
"notification_subscriptions", template_ctx)
for ns in nss:
yield ns.get_instance_context(subs_ctx)
if not self.idea_publication_flow:
flow_name = self.preferences['default_idea_pub_flow']
if flow_name:
flow = PublicationFlow.getByName(flow_name)
if flow:
self.idea_publication_flow = flow
# yield?
if not self.root_idea:
from .idea import RootIdea
self.root_idea = RootIdea(discussion=self)
if flow:
state_label = self.preferences['default_idea_pub_state']
state = flow.state_by_label(state_label)
if state:
self.root_idea.pub_state = state
yield self.root_idea.get_instance_context(
self.get_collection_context("root_idea", context))
[docs] def unique_query(self):
# DiscussionBoundBase is misleading here
return self.db.query(self.__class__).filter_by(
slug=self.slug), True
@property
def settings_json(self):
if not self.preferences:
return Preferences.property_defaults
from pyramid.threadlocal import get_current_request
request = get_current_request()
if request:
return self.preferences.safe_values_json(request.base_permissions)
return self.preferences.values_json
[docs] def get_discussion_id(self):
return self.id
[docs] def container_url(self):
return "/data/Discussion"
@property
def idea_publication_flow_name(self):
if self.idea_publication_flow:
return self.idea_publication_flow.label
@idea_publication_flow_name.setter
def idea_publication_flow_name(self, name):
pub_flow = PublicationFlow.getByName(name)
assert pub_flow, "Cannot find PublicationFlow " + name
self.idea_publication_flow = pub_flow
def bulk_apply_idea_transition(self, transition_name, user_id, permissions=None):
flow = self.idea_publication_flow
transition = flow.transition_by_label(transition_name)
assert transition, "Cannot find transition " + transition_name
if not permissions:
from pyramid.threadlocal import get_current_request
request = get_current_request()
if request:
permissions = request.base_permissions
else:
permissions = get_permissions(user_id, self.discussion_id)
if transition.req_permission_name not in permissions:
raise HTTPUnauthorized("You need permission %s to apply transition %s" % (
transition.req_permission_name, transition.label))
for idea in self.ideas:
if idea.pub_state_id == transition.source_id:
idea.pub_state_id = transition.target_id
def bulk_change_publication_states(self, changes, user_id, permissions=None):
from .idea import RootIdea
for idea in self.ideas:
dest = changes.get(idea.pub_state_name, None)
if isinstance(idea, RootIdea):
dest = changes.get('@root', dest)
if dest:
assert idea.safe_set_pub_state(dest, user_id)
def reset_idea_publication_flow(self, new_flow_name, default_state_name, correspondances=None):
# this should only be done by sysadmin or discussion_admin
correspondances = correspondances or {}
new_flow = PublicationFlow.getByName(new_flow_name)
assert new_flow, "No publication flow named " + new_flow_name
old_flow = self.idea_publication_flow
for idea in self.ideas:
source_name = idea.pub_state_name
target_name = correspondances.get(source_name, default_state_name)
assert target_name, "Please specify all states or a default"
target_state = new_flow.state_by_label(target_name)
assert target_state, "Could not find target state %s in flow %s" % (
target_name, new_flow_name)
idea.pub_state = target_state
self.idea_publication_flow = new_flow
[docs] @classmethod
def get_discussion_conditions(cls, discussion_id, alias_maker=None):
return (cls.id == discussion_id,)
def get_next_synthesis_id(self):
from .idea_graph_view import Synthesis
from .post import SynthesisPost
return self.db.query(Synthesis.id).outerjoin(
SynthesisPost).filter(
Synthesis.discussion_id == self.id,
SynthesisPost.id == None).first()
def get_next_synthesis(self, full_data=True):
from .idea_graph_view import Synthesis
id = self.get_next_synthesis_id()
query = self.db.query(Synthesis).filter_by(id=id)
if full_data:
query = query.options(
subqueryload('idea_assocs').joinedload('idea').joinedload('title').subqueryload('entries'),
subqueryload('idea_assocs').joinedload('idea').joinedload('synthesis_title').subqueryload('entries'),
subqueryload('idea_assocs').joinedload('idea').joinedload('description').subqueryload('entries'),
subqueryload('idea_assocs').joinedload('idea').subqueryload('widget_links'),
subqueryload('idea_assocs').joinedload('idea').subqueryload('attachments').joinedload('document'),
subqueryload('idea_assocs').joinedload('idea').joinedload('source_links'),
subqueryload('idealink_assocs').joinedload('idea_link'),
subqueryload(Synthesis.published_in_post)
)
else:
query = query.options(
subqueryload('idea_assocs'),
subqueryload('idealink_assocs'),
)
return query.first()
syntheses = relationship('Synthesis')
next_synthesis = relationship('Synthesis',
uselist=False, secondary="outerjoin(Synthesis, SynthesisPost)",
primaryjoin="Discussion.id == Synthesis.discussion_id",
secondaryjoin='SynthesisPost.id == None',
viewonly=True)
def get_last_published_synthesis(self):
from .idea_graph_view import Synthesis
return self.db.query(Synthesis).filter(
Synthesis.discussion_id == self.id and
Synthesis.published_in_post != None
).options(
subqueryload('idea_assocs').joinedload('idea').joinedload('title').subqueryload('entries'),
subqueryload('idea_assocs').joinedload('idea').joinedload('synthesis_title').subqueryload('entries'),
subqueryload('idea_assocs').joinedload('idea').joinedload('description').subqueryload('entries'),
subqueryload('idea_assocs').joinedload('idea').subqueryload('widget_links'),
subqueryload('idea_assocs').joinedload('idea').subqueryload('attachments').joinedload('document'),
subqueryload('idea_assocs').joinedload('idea').joinedload('source_links'),
subqueryload('idealink_assocs').joinedload('idea_link'),
subqueryload(Synthesis.published_in_post)
).order_by(
Synthesis.published_in_post.creation_date.desc()
).first()
# returns a list of published and non-deleted syntheses, as well as the draft of the not yet published synthesis
def get_all_syntheses_query(self, include_unpublished=True, include_tombstones=False):
from .idea_graph_view import Synthesis
from .post import SynthesisPost, PublicationStates
condition = SynthesisPost.publication_state == PublicationStates.PUBLISHED
if not include_tombstones:
condition = condition & SynthesisPost.tombstone_condition()
if include_unpublished:
condition = condition | (SynthesisPost.id == None)
return self.db.query(
Synthesis).outerjoin(SynthesisPost
).options(
subqueryload('subject').subqueryload('entries'),
subqueryload('introduction').subqueryload('entries'),
subqueryload('conclusion').subqueryload('entries'),
subqueryload('idea_assocs').joinedload('idea').joinedload('title').subqueryload('entries'),
subqueryload('idea_assocs').joinedload('idea').joinedload('synthesis_title').subqueryload('entries'),
subqueryload('idea_assocs').joinedload('idea').joinedload('description').subqueryload('entries'),
subqueryload('idea_assocs').joinedload('idea').subqueryload('widget_links'),
subqueryload('idea_assocs').joinedload('idea').subqueryload('attachments').joinedload('document'),
subqueryload('idea_assocs').joinedload('idea').joinedload('source_links'),
subqueryload('idealink_assocs').joinedload('idea_link'),
subqueryload(Synthesis.published_in_post)
).filter(Synthesis.discussion_id == self.id, condition)
def get_permissions_by_role(self):
roleperms = self.db.query(Role.name, Permission.name).select_from(
DiscussionPermission).join(Role, Permission).filter(
DiscussionPermission.discussion_id == self.id).all()
roleperms.sort()
byrole = groupby(roleperms, lambda r_p: r_p[0])
return {r: [p for (r2, p) in rps] for (r, rps) in byrole}
def get_roles_by_permission(self):
permroles = self.db.query(Permission.name, Role.name).select_from(
DiscussionPermission).join(Role, Permission).filter(
DiscussionPermission.discussion_id == self.id).all()
permroles.sort()
byperm = groupby(permroles, lambda p_r: p_r[0])
return {p: [r for (p2, r) in prs] for (p, prs) in byperm}
def get_readers(self):
session = self.db
users = session.query(User).join(
UserRole, Role, DiscussionPermission, Permission).filter(
DiscussionPermission.discussion_id == self.id and
Permission.name == P_READ
).union(self.db.query(User).join(
LocalUserRole, Role, DiscussionPermission, Permission).filter(
DiscussionPermission.discussion_id == self.id and
LocalUserRole.discussion_id == self.id and
Permission.name == P_READ)).all()
if session.query(DiscussionPermission).join(
Role, Permission).filter(
DiscussionPermission.discussion_id == self.id and
Permission.name == P_READ and
Role.name == Authenticated).first():
pass # add a pseudo-authenticated user???
if session.query(DiscussionPermission).join(
Role, Permission).filter(
DiscussionPermission.discussion_id == self.id and
Permission.name == P_READ and
Role.name == Everyone).first():
pass # add a pseudo-anonymous user?
return users
def get_all_agents_preload(self, user=None):
from assembl.views.api.agent import _get_agents_real
from pyramid.threadlocal import get_current_request
request = get_current_request()
assert request
return json.dumps(_get_agents_real(
request, user.id if user else Everyone, 'partial'))
def get_readers_preload(self):
return json.dumps([user.generic_json('partial') for user in self.get_readers()])
def get_ideas_preload(self, user_id):
from assembl.views.api.idea import _get_ideas_real
from pyramid.threadlocal import get_current_request
request = get_current_request()
assert request
return json.dumps(_get_ideas_real(request, user_id=user_id))
def get_idea_links(self):
from .idea import Idea
return Idea.get_all_idea_links(self.id)
def get_idea_and_links(self):
return chain(self.ideas, self.get_idea_links())
def get_top_ideas(self):
from .idea import Idea
return self.db.query(Idea).filter(
Idea.discussion_id == self.id).filter(
~Idea.source_links.any()).all()
def get_related_extracts_preload(self, user_id):
from assembl.views.api.extract import _get_extracts_real
from pyramid.threadlocal import get_current_request
from .idea import Idea
Idea.get_discussion_data(self.id)
request = get_current_request()
assert request
return json.dumps(_get_extracts_real(request, user_id=user_id))
def get_user_permissions(self, user_id):
return get_permissions(user_id, self.id)
def get_user_permissions_preload(self, user_id):
return json.dumps(self.get_user_permissions(user_id))
[docs] def get_base_url(self, require_secure=None):
"""Get the base URL of this server
Tied to discussion so that we can support virtual hosts or
communities in the future and access the urls when we can't rely
on pyramid's current request (such as when celery generates
notifications)
Temporarily equivalent to get_global_base_url
"""
return get_global_base_url(require_secure)
def get_discussion_urls(self):
discussion_url_http = self.get_base_url(False) + "/" + self.slug
discussion_url_https = self.get_base_url(True) + "/" + self.slug
discussion_urls = [discussion_url_http]
if discussion_url_https != discussion_url_http:
discussion_urls.append(discussion_url_https)
return discussion_urls
def check_authorized_email(self, user):
# Check if the user has a verified email from a required domain
from .social_auth import SocialAuthAccount
require_email_domain = self.preferences['require_email_domain']
autologin_backend = self.preferences['authorization_server_backend']
if not (require_email_domain or autologin_backend):
return True
for account in user.accounts:
if not account.verified:
continue
# Note that this allows an account which is either from the SSO
# OR from an allowed domain, if any. In most cases, only one
# validation mechanism will be defined.
if require_email_domain:
email = account.email
if not email or '@' not in email:
continue
email = email.split('@', 1)[-1]
if email.lower() in require_email_domain:
return True
if autologin_backend:
if isinstance(account, SocialAuthAccount):
if account.provider_with_idp == autologin_backend:
return True
return False
@property
def widget_collection_url(self):
return "/data/Conversation/%d/widgets" % (self.id,)
# Properties as a route context
__parent__ = None
@property
def __name__(self):
return self.slug
@property
def __acl__(self):
acls = [(Allow, dp.role.name, dp.permission.name) for dp in self.acls
if inspect(dp).persistent]
acls.append((Allow, R_SYSADMIN, ALL_PERMISSIONS))
return acls
@as_native_str()
def __repr__(self):
r = super(Discussion, self).__repr__()
return r[:-1] + str(self.slug) + ">"
def get_notifications(self):
for widget in self.widgets:
for n in widget.has_notification():
yield n
def get_user_template(self, role_name, autocreate=False, on_thread=True):
template = self.db.query(UserTemplate).join(
Role).filter(Role.name == role_name).join(
Discussion, UserTemplate.discussion).filter(
Discussion.id == self.id).first()
changed = False
if autocreate and not template:
# There is a template user per discussion. If it doesn't exist yet
# create it.
from .notification import (
NotificationCreationOrigin, NotificationSubscriptionFollowSyntheses)
role = self.db.query(Role).filter_by(name=role_name).one()
template = UserTemplate(for_role=role, discussion=self)
self.db.add(template)
self.db.flush()
subs, changed = template.get_notification_subscriptions_and_changed(on_thread)
self.db.flush()
return template, changed
def get_participant_template(self, on_thread=True):
from ..auth import R_PARTICIPANT
return self.get_user_template(R_PARTICIPANT, True, on_thread)
[docs] def reset_notification_subscriptions_from_defaults(self, force=True):
"""Reset all notification subscriptions for this discussion"""
from .notification import (
NotificationSubscription, NotificationSubscriptionStatus, NotificationCreationOrigin)
template, changed = self.get_participant_template()
roles_subscribed = defaultdict(list)
for template in self.user_templates:
template_subscriptions, changed2 = template.get_notification_subscriptions_and_changed()
changed |= changed2
for subscription in template_subscriptions:
if subscription.status == NotificationSubscriptionStatus.ACTIVE:
roles_subscribed[subscription.__class__].append(template.role_id)
if force or changed:
needed_classes = UserTemplate.get_applicable_notification_subscriptions_classes()
for notif_cls in needed_classes:
self.reset_notification_subscriptions_for(notif_cls, roles_subscribed[notif_cls])
def reset_notification_subscriptions_for(self, notif_cls, roles_subscribed):
from .notification import (
NotificationSubscription, NotificationSubscriptionStatus,
NotificationCreationOrigin)
from .auth import AgentStatusInDiscussion
# Make most subscriptions inactive (simpler than deciding which ones should be)
default_ns = self.db.query(notif_cls.id
).join(User, notif_cls.user_id == User.id
).join(LocalUserRole, LocalUserRole.profile_id == User.id
).join(AgentStatusInDiscussion,
AgentStatusInDiscussion.profile_id == User.id
).filter(
LocalUserRole.discussion_id == self.id,
AgentStatusInDiscussion.discussion_id == self.id,
AgentStatusInDiscussion.last_visit != None,
notif_cls.discussion_id == self.id,
notif_cls.creation_origin == NotificationCreationOrigin.DISCUSSION_DEFAULT)
deactivated = default_ns.filter(
notif_cls.status == NotificationSubscriptionStatus.ACTIVE)
if roles_subscribed:
# Make some subscriptions active (back)
activated = default_ns.filter(
LocalUserRole.role_id.in_(roles_subscribed),
notif_cls.status == NotificationSubscriptionStatus.INACTIVE_DFT)
self.db.query(notif_cls
).filter(notif_cls.id.in_(activated.subquery())
).update(
{"status": NotificationSubscriptionStatus.ACTIVE,
"last_status_change_date": datetime.utcnow()},
synchronize_session=False)
# Materialize missing subscriptions
missing_subscriptions_query = self.db.query(User.id
).join(LocalUserRole, LocalUserRole.profile_id == User.id
).join(AgentStatusInDiscussion,
AgentStatusInDiscussion.profile_id == User.id
).outerjoin(notif_cls, (notif_cls.user_id == User.id) & (
notif_cls.discussion_id == self.id)
).filter(LocalUserRole.discussion_id == self.id,
AgentStatusInDiscussion.discussion_id == self.id,
AgentStatusInDiscussion.last_visit != None,
LocalUserRole.role_id.in_(roles_subscribed),
notif_cls.id == None).distinct()
def missing_subscriptions_gen():
return [
notif_cls(
discussion_id=self.id,
user_id=user_id,
creation_origin=NotificationCreationOrigin.DISCUSSION_DEFAULT,
status=NotificationSubscriptionStatus.ACTIVE)
for (user_id,) in missing_subscriptions_query]
self.locked_object_creation(
missing_subscriptions_gen, NotificationSubscription, 10)
# exclude from deactivated query
deactivated = deactivated.except_(
default_ns.filter(
LocalUserRole.role_id.in_(roles_subscribed)))
self.db.query(notif_cls
).filter(notif_cls.id.in_(deactivated.subquery())
).update(
{"status": NotificationSubscriptionStatus.INACTIVE_DFT,
"last_status_change_date": datetime.utcnow()},
synchronize_session=False)
# Should we send them to the socket? We do not at this point.
# changed = deactivated_ids + activated_ids
# changed = self.db.query(NotificationSubscription).filter(
# NotificationSubscription.id.in_(changed))
# for ns in changed:
# ns.send_to_changes(discussion_id=self.id)
def invoke_callbacks_after_creation(self, callbacks=None):
reg = get_current_registry()
# If any of these callbacks throws an exception, the database
# transaction fails and so the Discussion object will not
# be added to the database (Discussion is not created).
known_callbacks = reg.getUtilitiesFor(IDiscussionCreationCallback)
if callbacks is not None:
known_callbacks = {k: v for (k, v) in known_callbacks.items() if k in callbacks}
for name, callback in known_callbacks:
callback.discussionCreated(self)
@classmethod
def extra_collections(cls):
from assembl.views.traversal import (
RelationCollectionDefinition, AbstractCollectionDefinition)
from ..views.traversal import (
UserNsDictCollection, DiscussionPreferenceCollection,
InstanceContext, collection_creation_side_effects)
from .facebook_integration import FacebookSinglePostSource
class AllUsersCollection(AbstractCollectionDefinition):
def __init__(self, cls):
super(AllUsersCollection, self).__init__(cls, 'all_users', User)
def decorate_query(self, query, owner_alias, last_alias, parent_instance, ctx):
from ..auth.util import get_current_user_id
try:
current_user = get_current_user_id()
except RuntimeError:
current_user = None
participants = parent_instance.get_participants_query(
True, False, current_user).subquery()
return query.join(
owner_alias, last_alias.id.in_(participants))
def contains(self, parent_instance, instance):
from ..auth.util import get_current_user_id
try:
current_user = get_current_user_id()
# shortcut
if instance.id == current_user:
return True
except RuntimeError:
pass
participants = parent_instance.get_participants_query(True)
return parent_instance.db.query(
literal(instance.id).in_(participants.subquery())).first()[0]
class AllPubFlowsCollection(AbstractCollectionDefinition):
def __init__(self, cls):
super(AllPubFlowsCollection, self).__init__(cls, 'all_pub_flows', PublicationFlow)
def decorate_query(self, query, owner_alias, last_alias, parent_instance, ctx):
return parent_instance.db.query(PublicationFlow)
def contains(self, parent_instance, instance):
return True
def get_default_view(self):
return 'extended'
class ConnectedUsersCollection(AbstractCollectionDefinition):
def __init__(self, cls):
super(ConnectedUsersCollection, self).__init__(
cls, 'connected_users', User)
def decorate_query(self, query, owner_alias, last_alias, parent_instance, ctx):
from .auth import AgentStatusInDiscussion
return query.join(AgentStatusInDiscussion).join(
owner_alias).filter(
owner_alias.id == parent_instance.id,
AgentStatusInDiscussion.last_connected != None,
((AgentStatusInDiscussion.last_disconnected
< AgentStatusInDiscussion.last_connected) |
(AgentStatusInDiscussion.last_disconnected == None)))
def contains(self, parent_instance, instance):
ast = instance.get_status_in_discussion(parent_instance.id)
if not ast:
return False
return ast.last_connected and (
(ast.last_disconnected < ast.last_connected) or (
ast.last_disconnected is None))
class ActiveWidgetsCollection(RelationCollectionDefinition):
def __init__(self, cls):
super(ActiveWidgetsCollection, self).__init__(
cls, Discussion.widgets, 'active_widgets')
def decorate_query(self, query, owner_alias, last_alias, parent_instance, ctx):
from .widgets import Widget
query = super(ActiveWidgetsCollection, self).decorate_query(
query, owner_alias, last_alias, parent_instance, ctx)
query = Widget.filter_active(query)
return query
def contains(self, parent_instance, instance):
return instance.is_active() and super(
ActiveWidgetsCollection, self).contains(
parent_instance, instance)
@collection_creation_side_effects.register(
inst_ctx=FacebookSinglePostSource, ctx='Discussion.sources')
def add_facebook_source_id(inst_ctx, ctx):
from .generic import ContentSourceIDs
source = inst_ctx._instance
fb_post_id = source.fb_post_id
# I should add sink_post_id as a column, and probably
# a new table for that subclass. Maybe also is_sink as a
# Column; it _seems_ all FacebookSinglePostSource are sinks.
#
# Here is the old code, which assumes kwargs are available:
# is_sink = kwargs.get('is_content_sink', None)
# data = kwargs.get('sink_data', None)
# if is_sink:
# post_id = data.get('post_id', None)
# fb_post_id = data.get('facebook_post_id', None)
raise NotImplementedError("TODO")
post_id = source.sink_post_id
cs = ContentSourceIDs(source=source,
post_id=post_id,
message_id_in_source=fb_post_id)
yield InstanceContext(
inst_ctx['pushed_messages'], cs)
return (AllUsersCollection(cls),
AllPubFlowsCollection(cls),
ConnectedUsersCollection(cls),
ActiveWidgetsCollection(cls),
UserNsDictCollection(cls),
DiscussionPreferenceCollection(cls))
# The list of all users with a role in the discussion
# backref is involved_in_discussion, below
all_participants = relationship(
User, viewonly=True, secondary=LocalUserRole.__table__,
primaryjoin="LocalUserRole.discussion_id == Discussion.id",
secondaryjoin=((LocalUserRole.profile_id == User.id)
& (LocalUserRole.requested == False)),
info={"backref": "User.involved_in_discussion"})
# The list of praticipants actually subscribed to the discussion
# backref is participant_in_discussion, below
simple_participants = relationship(
User, viewonly=True,
secondary=join(LocalUserRole, Role,
((LocalUserRole.role_id == Role.id) & (Role.name == R_PARTICIPANT))),
primaryjoin="LocalUserRole.discussion_id == Discussion.id",
secondaryjoin=((LocalUserRole.profile_id == User.id)
& (LocalUserRole.requested == False)),
info={"backref": "User.participant_in_discussion"})
def get_participants_query(self, ids_only=False, include_readers=False, current_user=None):
from .auth import AgentProfile
from .generic import Content
from .post import Post
from .action import ViewPost
from .idea_content_link import Extract, IdeaContentLink
from .announcement import Announcement
from .attachment import Attachment
from .idea import IdeaLocalUserRole, Idea
post = with_polymorphic(Post, [Post])
attachment = with_polymorphic(Attachment, [Attachment])
db = self.db
queries = [
db.query(LocalUserRole.profile_id.label('user_id')).filter(
LocalUserRole.discussion_id == self.id),
db.query(IdeaLocalUserRole.profile_id.label('user_id')).join(Idea).filter(
Idea.discussion_id == self.id),
db.query(post.creator_id.label('user_id')).filter(
post.discussion_id == self.id),
db.query(Extract.creator_id.label('user_id')).filter(
Extract.discussion_id == self.id),
db.query(Extract.creator_id.label('user_id')).filter(
Extract.discussion_id == self.id),
db.query(Extract.attributed_to_id.label('user_id')).filter(
Extract.discussion_id == self.id),
db.query(IdeaContentLink.creator_id.label('user_id')).join(Content).filter(
Content.discussion_id == self.id),
db.query(Announcement.creator_id.label('user_id')).filter(
Announcement.discussion_id == self.id),
db.query(attachment.creator_id.label('user_id')).filter(
attachment.discussion_id == self.id),
db.query(UserRole.profile_id.label('user_id')),
]
if self.creator_id is not None:
queries.append(db.query(literal(self.creator_id).label('user_id')))
if current_user is not None:
queries.append(db.query(literal(current_user).label('user_id')))
if include_readers:
queries.append(db.query(ViewPost.actor_id.label('user_id')).join(
Content, Content.id==ViewPost.post_id).filter(
Content.discussion_id==self.id))
query = queries[0].union(*queries[1:]).distinct()
if ids_only:
return query
return db.query(AgentProfile).filter(AgentProfile.id.in_(query))
def get_participants(self, ids_only=False):
query = self.get_participants_query(ids_only)
if ids_only:
return (id for (id,) in query.all())
return query.all()
def get_url(self):
from assembl.lib.frontend_urls import FrontendUrls
frontendUrls = FrontendUrls(self)
return frontendUrls.get_discussion_url()
def get_bound_extracts(self):
from .idea_content_link import Extract, IdeaExtractLink
return self.db.query(Extract).join(IdeaExtractLink).filter(
Extract.discussion==self)
def get_extract_graphs_cif(self):
from .idea import Idea
for e in self.get_bound_extracts():
for graph in e.extract_graph_json():
yield graph
def get_discussion_graph_cif(self):
from .post import Post
from .action import ActionOnPost
from .votes import AbstractIdeaVote, LickertIdeaVote, TokenIdeaVote
yield self.generic_json(view_def_name="cif")
for i in chain(
self.views, self.ideas, self.idea_links,
self.posts, self.local_user_roles):
yield i.generic_json(view_def_name="cif")
for s in self.sources:
yield i.generic_json(
view_def_name="cif", permissions=[P_ADMIN_DISC])
for action in self.db.query(ActionOnPost).join(Post).filter_by(
discussion_id=self.id, tombstone_date=None):
yield action.generic_json(
view_def_name="cif", permissions=[P_SYSADMIN])
for vote in self.db.query(AbstractIdeaVote).join(
AbstractIdeaVote.idea).filter_by(
discussion_id=self.id, tombstone_date=None):
yield vote.generic_json(
view_def_name="cif", permissions=[P_ADMIN_DISC])
if isinstance(vote, LickertIdeaVote):
yield vote.vote_spec.generic_json(view_def_name="cif")
elif isinstance(vote, TokenIdeaVote):
yield vote.token_category.generic_json(view_def_name="cif")
for p in self.get_participants():
yield p.generic_json(view_def_name="cif")
for acc in p.accounts:
yield acc.generic_json(
view_def_name="cif", permissions=[P_SYSADMIN])
for e in self.get_bound_extracts():
yield e.generic_json(view_def_name="cif")
yield e.generic_json(view_def_name="cif2")
for t in e.selectors:
yield t.generic_json(view_def_name="cif")
def get_public_graphs_cif(self):
graphs = [x for x in self.get_extract_graphs_cif() if x]
graphs.append({
"@id": "assembl:discussion_%d_data" % (self.id),
"@graph": [x for x in self.get_discussion_graph_cif() if x]
})
return {
"@context": [
"http://purl.org/conversence/jsonld",
{"local": get_global_base_url() + '/data/'}],
"@graph": graphs
}
def get_user_graph_cif(self):
for p in self.get_participants():
yield p.generic_json(view_def_name="cif2")
for acc in p.accounts:
yield acc.generic_json(
view_def_name="cif2", permissions=[P_SYSADMIN])
def get_private_graphs_cif(self):
graphs = [x for x in self.get_user_graph_cif() if x]
return {
"@context": [
"http://purl.org/conversence/jsonld",
{"local": get_global_base_url() + '/data/'}],
"@graph": graphs
}
@property
def creator_name(self):
if self.creator:
return self.creator.name
@property
def creator_email(self):
if self.creator:
return self.creator.get_preferred_email()
def count_contributions_per_agent(
self, start_date=None, end_date=None, as_agent=True):
from .post import Post
from .auth import AgentProfile
query = self.db.query(
func.count(Post.id), Post.creator_id).filter(
Post.discussion_id==self.id,
Post.tombstone_condition())
if start_date:
query = query.filter(Post.creation_date >= start_date)
if end_date:
query = query.filter(Post.creation_date < end_date)
query = query.group_by(Post.creator_id)
results = query.all()
# from highest to lowest
results.sort(reverse=True)
if not as_agent:
return [(id, count) for (count, id) in results]
agent_ids = [ag for (c, ag) in results]
agents = self.db.query(AgentProfile).filter(
AgentProfile.id.in_(agent_ids))
agents_by_id = {ag.id: ag for ag in agents}
return [(agents_by_id[id], count) for (count, id) in results]
def count_new_visitors(
self, start_date=None, end_date=None, as_agent=True):
from .auth import AgentStatusInDiscussion
query = self.db.query(
func.count(AgentStatusInDiscussion.id)).filter_by(
discussion_id=self.id)
if start_date:
query = query.filter(
AgentStatusInDiscussion.first_visit >= start_date)
if end_date:
query = query.filter(
AgentStatusInDiscussion.first_visit < end_date)
return query.first()[0]
def count_post_viewers(
self, start_date=None, end_date=None, as_agent=True):
from .post import Post
from .action import ViewPost
query = self.db.query(
func.count(distinct(ViewPost.actor_id))).join(Post).filter(
Post.discussion_id == self.id)
if start_date:
query = query.filter(ViewPost.creation_date >= start_date)
if end_date:
query = query.filter(ViewPost.creation_date < end_date)
return query.first()[0]
@property
def idea_typology(self):
return self.preferences['idea_typology'] or {}
def idea_typology_as_dot(self, locale=None):
import pygraphviz
locale = locale or self.main_locale
typology = self.idea_typology
G = pygraphviz.AGraph(directed=True)
link_names = {key: value["title"][locale]
for (key, value) in typology.get("links", {}).items()}
for idea_type, data in typology.get("ideas", {}).items():
title = data.get('title', {}).get(locale, idea_type)
G.add_node(idea_type, label=title)
for source, data in typology.get("ideas", {}).items():
links = defaultdict(list)
for link_type, dests in data.get('rules', {}).items():
for dest in dests:
links[dest].append(link_type)
for dest, link_types in links.items():
kwargs = {}
if link_types == ['InclusionRelation']:
kwargs = dict(color="grey", fontcolor="#333333")
G.add_edge(
source, dest, label=";\\n".join([
link_names[link_type] for link_type in link_types]),
**kwargs)
G.layout('dot')
return G
def publication_flow_as_dot(self, locale=None, for_user_id=None):
import pygraphviz
locale = locale or self.main_locale
flow = self.idea_publication_flow
G = pygraphviz.AGraph(directed=True)
default = self.preferences['default_idea_pub_state']
if for_user_id:
base_permissions = get_permissions(for_user_id, self.id)
for state in flow.states:
kwargs = dict(peripheries=2) if state.label == default else {}
name_e = state.name.closest_entry(locale) if state.name else None
name = name_e.value if name_e else state.label
G.add_node(state.label, label=name, **kwargs)
for transition in flow.transitions:
kwargs = {}
name_e = transition.name.closest_entry(locale) if transition.name else None
name = name_e.value if name_e else transition.label
if for_user_id:
state = transition.source
required = transition.req_permission_name
if required not in base_permissions:
if required not in permissions_for_state(
self.id, state.id, for_user_id):
if required not in permissions_for_state(
self.id, state.id, for_user_id, True):
kwargs['color'] = "grey"
else:
kwargs['color'] = "orange"
G.add_edge(transition.source_label, transition.target_label,
label=name, **kwargs)
G.layout('dot')
return G
def as_mind_map(self):
import pygraphviz
from colour import Color
from datetime import datetime
from assembl.models import Idea, IdeaLink, RootIdea
ideas = self.db.query(Idea).filter_by(
tombstone_date=None, discussion_id=self.id).all()
links = self.db.query(IdeaLink).filter_by(
tombstone_date=None).join(Idea, IdeaLink.source_id==Idea.id).filter(
Idea.discussion_id==self.id).all()
G = pygraphviz.AGraph(directed=True, overlap=False)
# G.graph_attr['overlap']='prism'
G.node_attr['penwidth']=0
G.node_attr['shape']='rect'
G.node_attr['style']='filled'
G.node_attr['fillcolor'] = '#efefef'
start_time = min((idea.creation_date for idea in ideas))
end_time = max((idea.last_modified or idea.creation_date for idea in ideas))
end_time = min(datetime.now(), end_time + (end_time - start_time))
root_id = self.root_idea.id
parent_ids = {l.target_id: l.source_id for l in links}
def node_level(node_id):
if node_id == root_id:
return 0
return 1 + node_level(parent_ids[node_id])
for idea in ideas:
if isinstance(idea, RootIdea):
root_id = idea.id
G.add_node(idea.id, label="", style="invis")
else:
level = node_level(idea.id)
age = (end_time - (idea.last_modified or idea.creation_date)).total_seconds() / (end_time - start_time).total_seconds()
log.debug("%d %s %s %s" % (idea.id, start_time, (idea.last_modified or idea.creation_date), end_time))
log.debug("%ld %ld" % ((end_time - (idea.last_modified or idea.creation_date)).total_seconds(),
(end_time - start_time).total_seconds()))
#empirical
color = Color(hsl=(180-(135.0 * age), 0.15, 0.85))
G.add_node(idea.id,
label=idea.short_title or "",
fontsize = 18 - (1.5 * level),
height=(20-(1.5*level))/72.0,
fillcolor=color.hex,
target="idealoom",
URL=idea.get_url())
for link in links:
if link.source_id == root_id:
G.add_edge(link.source_id, link.target_id, style="invis")
else:
G.add_edge(link.source_id, link.target_id)
G.layout(prog='twopi')
return G
crud_permissions = CrudPermissions(
P_SYSADMIN, P_READ, P_ADMIN_DISC, P_SYSADMIN)
@property
def discussion_locales(self):
# Ordered list, not empty.
# TODO: Guard. Each locale should be 2-letter or posix.
# Waiting for utility function.
locales = self.preferences['preferred_locales']
if locales:
return locales
# Use installation settings otherwise.
return [strip_country(l) for l in config.get_config().get(
'available_languages', 'fr en').split()]
@discussion_locales.setter
def discussion_locales(self, locale_list):
# TODO: Guard.
self.preferences['preferred_locales'] = locale_list
# class cache, indexed by discussion id
_discussion_services = {}
@property
def translation_service_class(self):
return self.preferences["translation_service"]
def translation_service(self):
service_class = (self.translation_service_class or
"assembl.nlp.translation_service.LanguageIdentificationService")
service = self._discussion_services.get(self.id, None)
if service and full_class_name(service) != service_class:
service = None
if service is None:
try:
if service_class:
service = resolver.resolve(service_class)(self)
except RuntimeError:
from assembl.nlp.translation_service import \
LanguageIdentificationService
service = LanguageIdentificationService(self)
self._discussion_services[self.id] = service
return service
def remove_translations(self):
# For testing purposes
for post in self.posts:
post.remove_translations()
@property
def main_locale(self):
return self.discussion_locales[0]
[docs] def compose_external_uri(self, *args, **kwargs):
"""
:use_api2 - uses API2 URL path
pass as many nodes you want in the args
"""
composer = ""
base = self.get_base_url()
if kwargs.get('use_api2', True):
base += "/data/"
else:
base += "/"
uri = self.uri(base_uri=base)
composer += uri
for arg in args:
if arg:
composer += "/%s" % arg
return composer
# explicit backref to Discussion.all_participants
User.involved_in_discussion = relationship(
Discussion, viewonly=True, secondary=LocalUserRole.__table__,
secondaryjoin="LocalUserRole.discussion_id == Discussion.id",
primaryjoin=((LocalUserRole.profile_id == User.id)
& (LocalUserRole.requested == False)),
info={"backref": Discussion.all_participants})
# explicit backref to Discussion.simple_participants
User.participant_in_discussion = relationship(
Discussion, viewonly=True,
secondary=join(LocalUserRole, Role,
((LocalUserRole.role_id == Role.id) & (Role.name == R_PARTICIPANT))),
secondaryjoin="LocalUserRole.discussion_id == Discussion.id",
primaryjoin=((LocalUserRole.profile_id == User.id)
& (LocalUserRole.requested == False)),
info={"backref": Discussion.simple_participants})
[docs]def slugify_topic_if_slug_is_empty(discussion, topic, oldvalue, initiator):
"""
if the target doesn't have a slug, slugify the topic and use that.
"""
if not discussion.slug:
discussion.slug = slugify(topic)
event.listen(Discussion.topic, 'set', slugify_topic_if_slug_is_empty)