Source code for assembl.models.auth

"""All classes relative to users and their online identities."""
from future import standard_library
standard_library.install_aliases()
from builtins import str
from builtins import object
from datetime import datetime, timedelta
from itertools import chain, permutations
from functools import total_ordering
import urllib.request, urllib.parse, urllib.error
import hashlib
import simplejson as json
from collections import defaultdict
from enum import IntEnum
import logging
from abc import abstractmethod

from future.utils import as_native_str
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy import (
    Boolean,
    Column,
    String,
    ForeignKey,
    Integer,
    UnicodeText,
    DateTime,
    Time,
    Binary,
    inspect,
    event,
    Index,
    func,
    UniqueConstraint
)
from pyramid.httpexceptions import HTTPUnauthorized
from sqlalchemy.orm import (
    relationship, backref, deferred)
from sqlalchemy.orm.attributes import NO_VALUE
from sqlalchemy.sql.functions import count
from pyramid.security import Everyone
from sqla_rdfbridge.mapping import PatternIriClass

from ..lib import config
from ..lib.utils import get_global_base_url
from ..lib.locale import locale_compatible, locale_ancestry
from ..lib.model_watcher import get_model_watcher
from ..lib.sqla import CrudOperation
from ..lib.sqla_types import (
    URLString, EmailString, EmailUnicode, CaseInsensitiveWord, CoerceUnicode)
from . import Base, DiscussionBoundBase, NamedClassMixin, OriginMixin
from ..auth import *
from assembl.lib.raven_client import capture_exception
from ..semantic.namespaces import (
    SIOC, ASSEMBL, QUADNAMES, FOAF, DCTERMS, RDF)
from ..semantic.virtuoso_mapping import (
    QuadMapPatternS, USER_SECTION, PRIVATE_USER_SECTION,
    AppQuadStorageManager)

log = logging.getLogger(__name__)


# None-tolerant min, max
def minN(a, b):
    if a is None:
        return b
    if b is None:
        return a
    return min(a, b)

def maxN(a, b):
    if a is None:
        return b
    if b is None:
        return a
    return max(a, b)


[docs]class AgentProfile(Base): """An agent identified on the platform. Agents can be :py:class:`User` or simply the author of an imported message; they could also be a group, bot or computer. Agents have at least one :py:class:`AbstractAgentAccount`. """ __tablename__ = "agent_profile" __external_typename = "Agent" __table_args__ = ( Index("agent_profile_name_vidx", func.to_tsvector('simple', 'agent_profile.name'), postgresql_using='gin'), ) rdf_class = FOAF.Agent rdf_sections = (USER_SECTION,) # This is very hackish. We need Posts to point to accounts vs users, # but right now they do not know the accounts. agent_as_account_iri = PatternIriClass( QUADNAMES.agent_as_account_iri, get_global_base_url() + '/data/Agent/%d', None, ('id', Integer, False)) id = Column(Integer, primary_key=True) name = Column(CoerceUnicode(1024), info={'rdf': QuadMapPatternS( None, FOAF.name, sections = (PRIVATE_USER_SECTION,))}) description = Column(UnicodeText, info={'rdf': QuadMapPatternS( None, DCTERMS.description, sections = (PRIVATE_USER_SECTION,))}) type = Column(String(60)) __mapper_args__ = { 'polymorphic_identity': 'agent_profile', 'polymorphic_on': type, 'with_polymorphic': '*' } @as_native_str() def __repr__(self): r = super(AgentProfile, self).__repr__() name = self.name or "" return r[:-1] + name + ">" def get_preferred_email_account(self): if inspect(self).attrs.accounts.loaded_value is NO_VALUE: account = self.db.query(AbstractAgentAccount).filter( (AbstractAgentAccount.profile_id == self.id) & (AbstractAgentAccount.email != None) & (AbstractAgentAccount.email != '')).order_by( AbstractAgentAccount.verified.desc(), AbstractAgentAccount.preferred.desc()).first() if account: return account elif self.accounts: accounts = [a for a in self.accounts if a.email] accounts.sort(key=lambda e: (not e.verified, not e.preferred)) if accounts: return accounts[0] def get_preferred_email(self): preferred_account = self.get_preferred_email_account() if preferred_account is not None: return preferred_account.email def real_name(self): if not self.name: for acc in self.identity_accounts: name = acc.real_name() if name: self.name = name break return self.name def display_name(self): # TODO: Prefer types? if self.name: return self.name for acc in self.identity_accounts: if acc.username: return acc.display_name() for acc in self.accounts: name = acc.display_name() if name: return name
[docs] def merge(self, other_profile): """Merge another profile on this profile, because they are the same entity. This identity is usually found out after an email account is verified, or a social account is added to another account. All foreign keys that refer to the other agent profile must now refer to this one.""" from .social_auth import SocialAuthAccount log.warn("Merging AgentProfiles: %d <= %d" % (self.id, other_profile.id)) session = self.db assert self.id assert not ( isinstance(other_profile, User) and not isinstance(self, User)) my_accounts = {a.signature(): a for a in self.accounts} my_social_emails = {s.email.lower() for s in self.accounts if isinstance(s, SocialAuthAccount) and s.email} for other_account in other_profile.accounts[:]: my_account = my_accounts.get(other_account.signature()) if my_account: # if chrono order of accounts corresponds to merge priority if my_account.prefer_newest_info_on_merge == ( my_account.id > other_account.id): # prefer info from my_account my_account.merge(other_account) session.delete(other_account) else: other_account.merge(my_account) other_account.profile = self session.delete(my_account) elif (isinstance(other_account, EmailAccount) and other_account.email.lower() in my_social_emails): pass else: other_account.profile = self if other_profile.name and not self.name: self.name = other_profile.name for post in other_profile.posts_created[:]: post.creator = self post.creator_id = self.id for extract in other_profile.extracts_attributed[:]: extract.attributed_to = self from .action import Action for action in session.query(Action).filter_by( actor_id=other_profile.id).all(): action.actor = self action.actor_id = self.id my_status_by_discussion = { s.discussion_id: s for s in self.agent_status_in_discussion } with self.db.no_autoflush: for status in other_profile.agent_status_in_discussion[:]: if status.discussion_id in my_status_by_discussion: my_status = my_status_by_discussion[status.discussion_id] my_status.user_created_on_this_discussion |= status.\ user_created_on_this_discussion my_status.first_visit = minN(my_status.first_visit, status.first_visit) my_status.last_visit = maxN(my_status.last_visit, status.last_visit) my_status.first_subscribed = minN( my_status.first_subscribed, status.first_subscribed) my_status.last_unsubscribed = minN( my_status.last_unsubscribed, status.last_unsubscribed) status.delete() else: status.agent_profile = self
def has_permission(self, verb, subject): if self is subject.owner: return True from .permissions import Permission return self.db.query(Permission).filter_by( actor_id=self.id, subject_id=subject.id, verb=verb, allow=True ).one() def avatar_url(self, size=32, app_url=None, email=None): default = config.get('avatar.default_image_url') or \ (app_url and app_url+'/static/img/icon/user.png') offline_mode = config.get('offline_mode') if offline_mode == "true": return default acc = self.get_preferred_email_account() if acc: url = acc.avatar_url(size) if url: return url for acc in self.identity_accounts: url = acc.avatar_url(size) if url: return url # Otherwise: Use the gravatar URL email = email or self.get_preferred_email() if not email: return default default = config.get('avatar.gravatar_default') or default return EmailAccount.avatar_url_for(email, size, default) def external_avatar_url(self): return "/user/id/%d/avatar/" % (self.id,) def get_agent_preload(self, view_def='default'): result = self.generic_json(view_def, user_id=self.id) return json.dumps(result) @classmethod def count_posts_in_discussion_all_profiles(cls, discussion): from .post import Post return dict(discussion.db.query( Post.creator_id, count(Post.id)).filter_by( discussion_id=discussion.id, hidden=False).group_by( Post.creator_id)) def count_posts_in_discussion(self, discussion_id): from .post import Post return self.db.query(Post).filter_by( creator_id=self.id, discussion_id=discussion_id).count()
[docs] def count_posts_in_current_discussion(self): "CAN ONLY BE CALLED FROM API V2" from ..auth.util import get_current_discussion discussion = get_current_discussion() if discussion is None: return None return self.count_posts_in_discussion(discussion.id)
def get_status_in_discussion(self, discussion_id): return self.db.query(AgentStatusInDiscussion).filter_by( discussion_id=discussion_id, profile_id=self.id).first() @property def status_in_current_discussion(self): # Use from api v2 from ..auth.util import get_current_discussion discussion = get_current_discussion() if discussion: return self.get_status_in_discussion(discussion.id) def is_visiting_discussion(self, discussion_id): from assembl.models.discussion import Discussion d = Discussion.get(discussion_id) self.update_agent_status_last_visit(d)
[docs] @classmethod def special_quad_patterns(cls, alias_maker, discussion_id): return [ QuadMapPatternS(cls.agent_as_account_iri.apply(cls.id), SIOC.account_of, cls.iri_class().apply(cls.id), name=QUADNAMES.account_of_self), QuadMapPatternS(cls.agent_as_account_iri.apply(cls.id), RDF.type, SIOC.UserAccount, name=QUADNAMES.pseudo_account_type)]
# True iff the user visits current discussion for the first time @property def is_first_visit(self): status = self.status_in_current_discussion if status: return status.last_visit == status.first_visit return True @property def last_visit(self): status = self.status_in_current_discussion if status: return status.last_visit @property def first_visit(self): status = self.status_in_current_discussion if status: return status.first_visit @property def accepted_tos_version(self): status = self.status_in_current_discussion if status: return status.accepted_tos_version @accepted_tos_version.setter def accepted_tos_version(self, value): status = self.status_in_current_discussion assert status status.accepted_tos_version = int(value) if value else None @property def was_created_on_current_discussion(self): # Use from api v2 status = self.status_in_current_discussion if status: return status.user_created_on_this_discussion return False
[docs] def is_owner(self, user_id): return user_id == self.id
def get_preferred_locale(self): # TODO: per-user preferred locale # Want a 2-letter locale string # Currently expecting only a scalar value, not a list. Might change # In the near future. prefs = self.language_preference prefs.sort() # natural order defined on class if prefs is None or len(prefs) is 0: # Correct way is to get the default from the app global config prefs = config.get_config().\ get('available_languages', 'fr_CA en_CA').split()[0] assert prefs[0] return prefs[0] return prefs[0].locale def successful_social_login(self): self.successful_login(True)
[docs] def successful_login(self, social=False): "A successful email login" self.last_login = datetime.utcnow() if not social: self.last_idealoom_login = self.last_login
def assembl_login_expiry(self): duration = config.get('login_expiry_email', None) if duration is None: # default to no expiry duration = config.get('login_expiry_default', 0) if not duration: return None last_login = self.last_idealoom_login if not last_login: # Return a date saying it's just expired. return datetime.utcnow() - timedelta(1) return last_login + timedelta(float(duration))
[docs] def login_expiry_req(self): """Get login expiry date. May be None.""" from assembl.auth.util import get_current_discussion discussion = None try: # If called from within request discussion = get_current_discussion() except Exception as e: # This is actually called from changes.json, so the request # and discussion are inaccessible in that case. pass return self.login_expiry(discussion)
[docs] def login_expiry(self, discussion=None): """When will this account's login expire, maybe in the context of a specific discussion.""" accounts = [a for a in self.social_accounts if a.verified] autologin = None if discussion: autologin = discussion.preferences['authorization_server_backend'] from ..auth.util import user_has_permission if autologin and not user_has_permission( discussion.id, self.id, P_OVERRIDE_SOCIAL_AUTOLOGIN): # the discussion restricts access to this specific # social identity provider. The override permission # bypasses that, mostly for external moderators. autologin_accs_expiry = [ a.login_expiry() for a in accounts if a.provider_with_idp == autologin] if len(autologin_accs_expiry): if None in autologin_accs_expiry: return None return max(autologin_accs_expiry) # No social login, treat as already expired return datetime.utcnow() - timedelta(1) expiries = [a.login_expiry() for a in accounts] expiries.append(self.assembl_login_expiry()) if None in expiries: return None return max(expiries)
def login_expired(self, discussion): expiry = self.login_expiry(discussion) if expiry is None: return False return expiry < datetime.utcnow()
[docs]class AbstractAgentAccount(Base): """An abstract class for online accounts that identify AgentsProfiles The main subclasses are :py:class:`EmailAccount` and :py:class:`.social_auth.SocialAuthAccount`.""" __tablename__ = "abstract_agent_account" __external_typename = "UserAccount" rdf_class = SIOC.UserAccount rdf_sections = (PRIVATE_USER_SECTION,) prefer_newest_info_on_merge = True id = Column(Integer, primary_key=True) type = Column(String(60)) profile_id = Column( Integer, ForeignKey('agent_profile.id', ondelete='CASCADE', onupdate='CASCADE'), nullable=False, index=True, info={'rdf': QuadMapPatternS(None, SIOC.account_of, sections=(USER_SECTION,))}) profile = relationship('AgentProfile', backref=backref( 'accounts', cascade="all, delete-orphan")) preferred = Column(Boolean(), default=False, server_default='0') verified = Column(Boolean(), default=False, server_default='0') # Note some social accounts don't disclose email (eg twitter), so nullable # Virtuoso + nullable -> no unique index (sigh) # Also, unverified emails are allowed to collide. # IMPORTANT: Use email_ci below when appropriate. email = Column(EmailString(100)) # Access to email as a case-insensitive object, # for comparison and search purposes. @hybrid_property def email_ci(self): return CaseInsensitiveWord(self.email) __table_args__ = ( Index("ix_public_abstract_agent_account_email_ci", func.lower(email)),) # info={'rdf': QuadMapPatternS(None, SIOC.email)} # Note: we could also have a FOAF.mbox, but we'd have to make # them into URLs with mailto: full_name = Column(CoerceUnicode(512)) def get_default_parent_context(self, request=None, user_id=None): return self.profile.get_collection_context( 'accounts', request=request, user_id=user_id)
[docs] def container_url(self): return "/data/AgentProfile/%d/accounts" % (self.profile_id)
[docs] def signature(self): "Identity of signature implies identity of underlying account" return ('abstract_agent_account', self.id)
def merge(self, other): pass
[docs] def is_owner(self, user_id): return self.profile_id == user_id
[docs] @classmethod def restrict_to_owners_condition(cls, query, user_id, alias=None, alias_maker=None): if not alias: if alias_maker: alias = alias_maker.alias_from_class(cls) else: alias = cls return (query, alias.profile_id == user_id)
__mapper_args__ = { 'polymorphic_identity': 'abstract_agent_account', 'polymorphic_on': type, 'with_polymorphic': '*' } crud_permissions = CrudPermissions( P_READ, P_SYSADMIN, P_SYSADMIN, P_SYSADMIN, P_READ, P_READ, P_READ)
[docs] @classmethod def user_can_cls(cls, user_id, operation, permissions): s = super(AbstractAgentAccount, cls).user_can_cls( user_id, operation, permissions) return MAYBE if s is False else s
[docs] def user_can(self, user_id, operation, permissions): # bypass for permission-less new users if user_id == self.profile_id: return True return super(AbstractAgentAccount, self).user_can( user_id, operation, permissions)
[docs] def update_from_json( self, json, user_id=None, context=None, object_importer=None, permissions=None, parse_def_name='default_reverse'): # DO NOT update email... but we still want # to allow to set it on create. if 'email' in json: del json['email'] return super(AbstractAgentAccount, self).update_from_json( json, user_id, context, object_importer, permissions, parse_def_name)
[docs]class EmailAccount(AbstractAgentAccount): """An email account""" __mapper_args__ = { 'polymorphic_identity': 'agent_email_account', } profile_e = relationship(AgentProfile, backref=backref('email_accounts')) def display_name(self): if self.verified: return self.email
[docs] def signature(self): return ('agent_email_account', self.email.lower() if self.email else None)
[docs] def merge(self, other): """Merge another EmailAccount on this one, because they are the same email.""" log.warn("Merging EmailAccounts: %d, %d" % (self.id, other.id)) if other.verified: self.verified = True
def other_account(self): if not self.verified: return self.db.query(self.__class__).filter_by( email_ci=self.email_ci, verified=True).first() def avatar_url(self, size=32, default=None): return self.avatar_url_for(self.email, size, default)
[docs] def unique_query(self): query, _ = super(EmailAccount, self).unique_query() return query.filter_by( type=self.type, email_ci=self.email_ci, verified=True), self.verified
@staticmethod def avatar_url_for(email, size=32, default=None): args = {'s': str(size)} if default: args['d'] = default return "//www.gravatar.com/avatar/%s?%s" % ( hashlib.md5(email.lower().encode('utf-8')).hexdigest(), urllib.parse.urlencode(args)) @staticmethod def get_or_make_profile(session, email, name=None): emails = list(session.query(EmailAccount).filter_by( email_ci=email).all()) # We do not want unverified user emails # This is costly. I should have proper boolean markers emails = [e for e in emails if e.verified or not isinstance(e.profile, User)] user_emails = [e for e in emails if isinstance(e.profile, User)] if user_emails: assert len(user_emails) == 1 return user_emails[0] elif emails: # should also be 1 but less confident. return emails[0] else: profile = AgentProfile(name=name) emailAccount = EmailAccount(email=email, profile=profile) session.add(emailAccount) return emailAccount
[docs]class IdentityProvider(Base): """An identity provider (or sometimes a category of identity providers.) This is a service that provides online identities, expressed as :py:class:`.social_auth.SocialAuthAccount`.""" __tablename__ = "identity_provider" __external_typename = "Usergroup" rdf_class = SIOC.Usergroup rdf_sections = (PRIVATE_USER_SECTION,) id = Column(Integer, primary_key=True) provider_type = Column(String(32), nullable=False) name = Column(String(60), nullable=False, info={'rdf': QuadMapPatternS(None, SIOC.name)}) # TODO: More complicated model, where trust also depends on realm. trust_emails = Column(Boolean, default=True) @classmethod def get_by_type(cls, provider_type, create=True): db = cls.default_db() provider = db.query(cls).filter_by( provider_type=provider_type).first() if create and not provider: # TODO: Better heuristic for name name = provider_type.split("-")[0] provider = cls( provider_type=provider_type, name=name) db.add(provider) db.flush() return provider @classmethod def populate_db(cls, db=None): db = db or cls.default_db() providers = config.get("login_providers") or [] trusted_providers = config.get("trusted_login_providers") or [] if not isinstance(providers, list): providers = providers.split() if not isinstance(trusted_providers, list): trusted_providers = trusted_providers.split() db.execute("lock table %s in exclusive mode" % cls.__table__.name) db_providers = db.query(cls).all() db_providers_by_type = { p.provider_type: p for p in db_providers} for provider in providers: db_provider = db_providers_by_type.get(provider, None) if db_provider is None: db.add(cls( name=provider, provider_type=provider, trust_emails=(provider in trusted_providers))) else: db_provider.trust_emails = (provider in trusted_providers)
[docs]class AgentStatusInDiscussion(DiscussionBoundBase): """Information about a user's activity in a discussion Whether the user has logged in and is subscribed to notifications.""" __tablename__ = 'agent_status_in_discussion' __table_args__ = ( UniqueConstraint('discussion_id', 'profile_id'), ) id = Column(Integer, primary_key=True) discussion_id = Column(Integer, ForeignKey( "discussion.id", ondelete='CASCADE', onupdate='CASCADE'), nullable=False, index=True) discussion = relationship( "Discussion", backref=backref( "agent_status_in_discussion", cascade="all, delete-orphan")) profile_id = Column(Integer, ForeignKey( "agent_profile.id", ondelete='CASCADE', onupdate='CASCADE'), nullable=False, index=True) agent_profile = relationship( AgentProfile, backref=backref( "agent_status_in_discussion", cascade="all, delete-orphan")) first_visit = Column(DateTime) last_visit = Column(DateTime) first_subscribed = Column(DateTime) last_unsubscribed = Column(DateTime) user_created_on_this_discussion = Column(Boolean, server_default='0') last_connected = Column(DateTime) last_disconnected = Column(DateTime) accepted_tos_version = Column(Integer)
[docs] def get_discussion_id(self): return self.discussion_id or self.discussion.id
[docs] @classmethod def get_discussion_conditions(cls, discussion_id, alias_maker=None): return (cls.discussion_id == discussion_id,)
[docs] def is_owner(self, user_id): return user_id == self.profile_id
crud_permissions = CrudPermissions( P_READ, P_ADMIN_DISC, P_ADMIN_DISC, P_ADMIN_DISC, P_READ, P_READ, P_READ)
@event.listens_for(AgentStatusInDiscussion, 'after_insert', propagate=True) def send_user_to_socket_for_asid(mapper, connection, target): agent_profile = target.agent_profile if not target.agent_profile: agent_profile = AgentProfile.get(target.profile_id) agent_profile.send_to_changes( connection, CrudOperation.UPDATE, target.discussion_id)
[docs]class User(NamedClassMixin, OriginMixin, AgentProfile): """ A user of the platform. """ __tablename__ = "user" __mapper_args__ = { 'polymorphic_identity': 'user' } id = Column( Integer, ForeignKey('agent_profile.id', ondelete='CASCADE', onupdate='CASCADE'), primary_key=True ) preferred_email = Column(EmailUnicode(100)) # info={'rdf': QuadMapPatternS(None, FOAF.mbox)}) verified = Column(Boolean(), default=False) password = deferred(Column(Binary(115))) timezone = Column(Time(True)) last_login = Column(DateTime) last_idealoom_login = Column(DateTime) login_failures = Column(Integer, default=0) username = Column(CoerceUnicode(20), unique=True) social_accounts = relationship('SocialAuthAccount') def __init__(self, **kwargs): if kwargs.get('password', None) is not None: from ..auth.password import hash_password kwargs['password'] = hash_password(kwargs['password']) super(User, self).__init__(**kwargs) def get_default_parent_context(self, request=None, user_id=None): from pyramid.threadlocal import get_current_request from ..auth.util import discussion_from_request if not request: request = get_current_request() if request: d = discussion_from_request(request) if d: return d.get_collection_context( 'all_users', request=request, user_id=user_id) return super(User, self).get_default_parent_context( request, user_id=user_id) @property def real_name_p(self): return self.real_name() @classmethod def get_naming_column_name(cls): return "username" @classmethod def getByName(cls, name, session=None, query=None, parent_object=None): if name == 'current': from ..auth.util import get_current_user_id user_id = get_current_user_id() if not user_id: return None return User.get_instance(user_id) @real_name_p.setter def real_name_p(self, name): if name: name = name.strip() if not name: return elif len(name) < 3: if not self.name or len(self.name) < len(name): self.name = name else: self.name = name @property def password_p(self): return "" @password_p.setter def password_p(self, password): from ..auth.password import hash_password if password: self.password = hash_password(password) def check_password(self, password): if self.password: from ..auth.password import verify_password return verify_password(password, self.password) return False def get_preferred_email(self): if self.preferred_email: return self.preferred_email return super(User, self).get_preferred_email()
[docs] def merge(self, other_user): """Merge another user on this one, because they are the same entity. This identity is usually found out after an email account is verified, or a social account is added to another account. All foreign keys that refer to the other user must now refer to this one.""" log.warn("Merging Users: %d <= %d" % (self.id, other_user.id)) super(User, self).merge(other_user) if isinstance(other_user, User): session = self.db if other_user.preferred_email and not self.preferred_email: self.preferred_email = other_user.preferred_email if other_user.last_login: if self.last_login: self.last_login = max( self.last_login, other_user.last_login) else: self.last_login = other_user.last_login self.creation_date = min( self.creation_date, other_user.creation_date) if other_user.password and not self.password: # NOTE: The user may be confused by the implicit change of # password when we destroy the second account. # Use most recent login if other_user.last_login and ( (not self.last_login) or (other_user.last_login > self.last_login)): self.password = other_user.password for extract in other_user.extracts_created[:]: extract.creator = self for idea_content_link in other_user.idealinks_created[:]: idea_content_link.creator = self for attachment in other_user.attachments[:]: attachment.creator = self for role in other_user.roles[:]: role.user = self for role in other_user.local_roles[:]: role.user = self for post in other_user.posts_moderated[:]: post.moderator = self post.moderator_id = self.id for announcement in other_user.announcements_created[:]: announcement.creator = self for announcement in other_user.announcements_updated[:]: announcement.last_updated_by = self if other_user.username and not self.username: self.username = other_user.username other_user.username = None my_lang_pref_signatures = { (lp.locale, lp.source_of_evidence) for lp in self.language_preference } for lang_pref in other_user.language_preference: # TODO: there's been a case here resulting in # two cookie instances if ((lang_pref.locale, lang_pref.source_of_evidence) in my_lang_pref_signatures): # First rough implementation: One has priority. # There is no internal merging that makes sense, # except maybe reordering (punted) lang_pref.delete() else: lang_pref.user_id = self.id # TODO: Ensure consistent order value. old_autoflush = session.autoflush session.autoflush = False for notification_subscription in \ other_user.notification_subscriptions[:]: notification_subscription.user = self notification_subscription.user_id = self.id if notification_subscription.find_duplicate(False) is not None: self.db.delete(notification_subscription) session.autoflush = old_autoflush
def send_email(self, **kwargs): subject = kwargs.get('subject', '') body = kwargs.get('body', '') # Send email. def avatar_url(self, size=32, app_url=None, email=None): return super(User, self).avatar_url( size, app_url, email or self.preferred_email) def display_name(self): if self.name: return self.name if self.username: return self.username return super(User, self).display_name() @property def permissions_for_current_discussion(self): from .discussion import Discussion from pyramid.threadlocal import get_current_request request = get_current_request() discussion_id = request.discussion_id if discussion_id: return {Discussion.uri_generic(discussion_id): request.permissions} return self.get_all_permissions() def get_permissions(self, discussion_id): from ..auth.util import get_permissions return get_permissions(self.id, discussion_id) def get_all_permissions(self): from ..auth.util import get_permissions from .discussion import Discussion permissions = { Discussion.uri_generic(d_id): get_permissions(self.id, d_id) for (d_id,) in self.db.query(Discussion.id)} return permissions
[docs] def send_to_changes(self, connection=None, operation=CrudOperation.UPDATE, discussion_id=None, view_def="changes"): """invoke the modelWatcher on creation/modification""" super(User, self).send_to_changes( connection, operation, discussion_id, view_def) watcher = get_model_watcher() if operation == CrudOperation.UPDATE: watcher.processAccountModified(self.id) elif operation == CrudOperation.CREATE: watcher.processAccountCreated(self.id)
def has_role_in(self, discussion_id, role): from .permissions import Role, LocalUserRole return self.db.query(LocalUserRole).join(Role).filter( LocalUserRole.profile_id == self.id, Role.name == role, LocalUserRole.requested == False, # noqa: E712 LocalUserRole.discussion_id == discussion_id).first() def is_participant(self, discussion_id): return self.has_role_in(discussion_id, R_PARTICIPANT) def create_agent_status_in_discussion(self, discussion): s = self.get_status_in_discussion(discussion.id) if s: return s s = AgentStatusInDiscussion( agent_profile=self, discussion=discussion) self.db.add(s) return s def update_agent_status_last_visit(self, discussion, status=None): agent_status = status or self.create_agent_status_in_discussion(discussion) _now = datetime.utcnow() agent_status.last_visit = _now if not agent_status.first_visit: agent_status.first_visit = _now def update_agent_status_subscribe(self, discussion): # Set the AgentStatusInDiscussion agent_status = self.create_agent_status_in_discussion(discussion) if not agent_status.first_subscribed: _now = datetime.utcnow() agent_status.first_subscribed = _now def update_agent_status_unsubscribe(self, discussion): agent_status = self.create_agent_status_in_discussion(discussion) _now = datetime.utcnow() agent_status.last_unsubscribed = _now def subscribe(self, discussion, role=R_PARTICIPANT): from .permissions import Role, LocalUserRole if not self.has_role_in(discussion.id, role): role = self.db.query(Role).filter_by(name=role).one() self.db.add(LocalUserRole( user=self, role=role, discussion=discussion)) # Set the AgentStatusInDiscussion self.update_agent_status_subscribe(discussion) def unsubscribe(self, discussion, role=R_PARTICIPANT): lur = self.has_role_in(discussion.id, role) if lur: self.db.delete(lur) # Set the AgentStatusInDiscussion self.update_agent_status_unsubscribe(discussion) @classmethod def extra_collections(cls): from assembl.views.traversal import ( RelationCollectionDefinition, AbstractCollectionDefinition, UserNSBoundDictContext) from .discussion import Discussion from .user_key_values import UserPreferenceCollection from .permissions import UserTemplate class NotificationSubscriptionCollection(RelationCollectionDefinition): def __init__(self, cls): super(NotificationSubscriptionCollection, self).__init__( cls, User.notification_subscriptions.property) def decorate_query(self, query, owner_alias, last_alias, parent_instance, ctx): query = super( NotificationSubscriptionCollection, self).decorate_query( query, owner_alias, last_alias, parent_instance, ctx) discussion = ctx.get_instance_of_class(Discussion) if discussion is not None: # Materialize active subscriptions... TODO: Make this batch, # also dematerialize if isinstance(parent_instance, UserTemplate): parent_instance.get_notification_subscriptions() else: parent_instance.get_notification_subscriptions( discussion.id, request=ctx.get_request()) query = query.filter(last_alias.discussion_id == discussion.id) return query def contains(self, parent_instance, instance): if not super(NotificationSubscriptionCollection, self).contains( parent_instance, instance): return False # Don't I need the context to get the discussion? Rats! return True def get_default_view(self): return "extended" class LocalRoleCollection(RelationCollectionDefinition): def __init__(self, cls): super(LocalRoleCollection, self).__init__( cls, User.local_roles.property) def decorate_query(self, query, owner_alias, last_alias, parent_instance, ctx): query = super( LocalRoleCollection, self).decorate_query( query, owner_alias, last_alias, parent_instance, ctx) discussion = ctx.get_instance_of_class(Discussion) if discussion is not None: query = query.filter(last_alias.discussion_id == discussion.id) return query def contains(self, parent_instance, instance): if not super(LocalRoleCollection, self).contains( parent_instance, instance): return False # Don't I need the context to get the discussion? Rats! return True def get_default_view(self): return "default" class PreferencePseudoCollection(AbstractCollectionDefinition): def __init__(self): super(PreferencePseudoCollection, self).__init__( cls, 'preferences', UserPreferenceCollection) def decorate_query( self, query, owner_alias, coll_alias, parent_instance, ctx): log.error("This should not happen") def contains(self, parent_instance, instance): log.error("This should not happen") def make_context(self, parent_ctx): from ..auth.util import ( get_current_user_id, user_has_permission) user_id = parent_ctx._instance.id discussion = None discussion_id = parent_ctx.get_discussion_id() current_user_id = get_current_user_id() if user_id != current_user_id and not user_has_permission( discussion_id, current_user_id, P_SYSADMIN): raise HTTPUnauthorized() if discussion_id: discussion = Discussion.get(discussion_id) coll = UserPreferenceCollection(user_id, discussion) return UserNSBoundDictContext(coll, parent_ctx) return (NotificationSubscriptionCollection(cls), LocalRoleCollection(cls), PreferencePseudoCollection())
[docs] def get_notification_subscriptions_for_current_discussion(self): "CAN ONLY BE CALLED WITH A CURRENT REQUEST" from pyramid.threadlocal import get_current_request request = get_current_request() discussion = request.discussion if discussion is None: return [] return self.get_notification_subscriptions(discussion.id, request=request)
def get_preferences_for_discussion(self, discussion): from .user_key_values import UserPreferenceCollection return UserPreferenceCollection(self.id, discussion) def get_preferences_for_current_discussion(self): from ..auth.util import get_current_discussion discussion = get_current_discussion() if discussion: return self.get_preferences_for_discussion(discussion)
[docs] def get_notification_subscriptions( self, discussion_id, reset_defaults=False, request=None, on_thread=True): """the notification subscriptions for this user and discussion. Includes materialized subscriptions from the template.""" from .notification import ( NotificationSubscription, NotificationSubscriptionStatus, NotificationCreationOrigin, NotificationSubscriptionGlobal) from .discussion import Discussion from ..auth.util import get_roles from .permissions import UserTemplate my_subscriptions = self.db.query(NotificationSubscription).filter_by( discussion_id=discussion_id, user_id=self.id).all() by_class = defaultdict(list) for sub in my_subscriptions: by_class[sub.__class__].append(sub) my_subscriptions_classes = set(by_class.keys()) needed_classes = UserTemplate.get_applicable_notification_subscriptions_classes() missing = set(needed_classes) - my_subscriptions_classes changed = False for cl, subs in by_class.items(): if issubclass(cl, NotificationSubscriptionGlobal): if len(subs) > 1: # This may not actually be an error, in the case of non-global. log.error("There were many subscriptions of class %s" % (cl)) subs.sort(key=lambda sub: sub.id) first_sub = subs[0] for sub in subs[1:]: first_sub.merge(sub) sub.delete() changed = True else: # Is this needed? Looking for mergeable subscriptions in non-global # This code will not be active for some time anyway. local_changed = True while local_changed: local_changed = False for a, b in permutations(subs, 2): if a.id > b.id: continue # break symmetry, merge newer on older if a.can_merge(b): a.merge(b) b.delete() local_changed = True changed = True subs.remove(b) break # inner, re-permute w/o b if changed: self.db.flush() my_subscriptions = list(chain(*list(by_class.items()))) if (not missing) and not reset_defaults: return my_subscriptions discussion = Discussion.get(discussion_id) assert discussion if request is None: my_roles = get_roles(self.id, discussion_id) else: my_roles = request.roles subscribed = defaultdict(bool) for role in my_roles: template, changed = discussion.get_user_template( role, role == R_PARTICIPANT, on_thread) if template is None: continue template_subscriptions = template.get_notification_subscriptions() for subscription in template_subscriptions: subscribed[subscription.__class__] |= subscription.status == NotificationSubscriptionStatus.ACTIVE if reset_defaults: for sub in my_subscriptions[:]: if (sub.creation_origin == NotificationCreationOrigin.DISCUSSION_DEFAULT # only actual defaults and sub.__class__ in subscribed): if (sub.status == NotificationSubscriptionStatus.ACTIVE and not subscribed[sub.__class__]): sub.status = NotificationSubscriptionStatus.INACTIVE_DFT elif (sub.status == NotificationSubscriptionStatus.INACTIVE_DFT and subscribed[sub.__class__]): sub.status = NotificationSubscriptionStatus.ACTIVE def create_missing(include_inactive=False): my_sub_types = self.db.query(NotificationSubscription.type).filter_by( discussion_id=discussion_id, user_id=self.id).distinct().all() my_sub_types = {x for (x,) in my_sub_types} discussion_kwarg = (dict(discussion_id=discussion.id) if discussion.id else dict(discussion=discussion)) return [ cls( user_id=self.id, creation_origin=NotificationCreationOrigin.DISCUSSION_DEFAULT, status=(NotificationSubscriptionStatus.ACTIVE if subscribed[cls] else NotificationSubscriptionStatus.INACTIVE_DFT), **discussion_kwarg ) for cls in needed_classes if (include_inactive or subscribed[cls]) and cls.__mapper_args__['polymorphic_identity'] not in my_sub_types] if on_thread: if self.locked_object_creation(create_missing, NotificationSubscription, 10): # if changes, recalculate my_subscriptions my_subscriptions = self.db.query(NotificationSubscription).filter_by( discussion_id=discussion_id, user_id=self.id).all() else: for ob in create_missing(): self.db.add(ob) my_subscriptions.append(ob) # Now calculate the dematerialized ones (always out-of-thread) defaults = create_missing(True) return chain(my_subscriptions, defaults)
[docs] def user_can(self, user_id, operation, permissions): # bypass for permission-less new users if user_id == self.id: return True return super(User, self).user_can(user_id, operation, permissions)
User.creation_date.info['rdf'] = QuadMapPatternS( None, DCTERMS.created, sections=(PRIVATE_USER_SECTION,))
[docs]class AnonymousUser(DiscussionBoundBase, User): "A fake anonymous user bound to a source." __tablename__ = "anonymous_user" __mapper_args__ = { 'polymorphic_identity': 'anonymous_user' } id = Column( Integer, ForeignKey('user.id', ondelete='CASCADE', onupdate='CASCADE'), primary_key=True ) source_id = Column(Integer, ForeignKey( "content_source.id", ondelete='CASCADE', onupdate='CASCADE'), nullable=False, unique=True) source = relationship( "ContentSource", backref=backref( "anonymous_user", cascade="all, delete-orphan", uselist=False)) def __init__(self, **kwargs): kwargs['verified'] = True kwargs['name'] = "anonymous" super(AnonymousUser, self).__init__(**kwargs) # Create an index for (discussion, role)?
[docs] def get_discussion_id(self): source = self.source or ContentSource.get(self.source_id) return source.discussion_id
[docs] @classmethod def get_discussion_conditions(cls, discussion_id, alias_maker=None): from .generic import ContentSource if alias_maker is None: anonymous_user = cls source = ContentSource else: anonymous_user = alias_maker.alias_from_class(cls) source = alias_maker.alias_from_relns(anonymous_user.source) return (anonymous_user.source_id == source.id, source.discussion_id == discussion_id)
[docs]class PartnerOrganization(DiscussionBoundBase): """A corporate entity that we want to display in the discussion's page""" __tablename__ = "partner_organization" id = Column(Integer, primary_key=True, info={'rdf': QuadMapPatternS(None, ASSEMBL.db_id)}) discussion_id = Column(Integer, ForeignKey( "discussion.id", ondelete='CASCADE'), nullable=False, index=True, info={'rdf': QuadMapPatternS(None, DCTERMS.contributor)}) discussion = relationship( 'Discussion', backref=backref( 'partner_organizations', cascade="all, delete-orphan"), info={'rdf': QuadMapPatternS(None, ASSEMBL.in_conversation)}) name = Column(CoerceUnicode(256), info={'rdf': QuadMapPatternS(None, FOAF.name)}) description = Column(UnicodeText, info={'rdf': QuadMapPatternS(None, DCTERMS.description)}) logo = Column(URLString(), info={'rdf': QuadMapPatternS(None, FOAF.logo)}) homepage = Column(URLString(), info={'rdf': QuadMapPatternS(None, FOAF.homepage)}) is_initiator = Column(Boolean)
[docs] def populate_from_context(self, context): if not(self.discussion or self.discussion_id): from .discussion import Discussion self.discussion = context.get_instance_of_class(Discussion) super(PartnerOrganization, self).populate_from_context(context)
[docs] def unique_query(self): query, _ = super(PartnerOrganization, self).unique_query() return query.filter_by(name=self.name), True
[docs] def get_discussion_id(self): return self.discussion_id or self.discussion.id
[docs] @classmethod def get_discussion_conditions(cls, discussion_id, alias_maker=None): return (cls.discussion_id == discussion_id,)
crud_permissions = CrudPermissions(P_ADMIN_DISC)
[docs]class LanguagePreferenceOrder(IntEnum): Explicit = 0 Cookie = 1 Parameter = 2 DeducedFromTranslation = 3 OS_Default = 4 Discussion = 5
LanguagePreferenceOrder.unique_prefs = ( LanguagePreferenceOrder.Cookie, LanguagePreferenceOrder.Parameter, LanguagePreferenceOrder.OS_Default)
[docs]class LanguagePreferenceCollection(object): """A collection of :py:class:`UserLanguagePreference`, allowing to decide on which languages to display.""" @abstractmethod def find_locale(self, locale): pass @classmethod def getCurrent(cls, req=None): from pyramid.threadlocal import get_current_request # Very very hackish, but this call is costly and frequent. # Let's cache it in the request. Useful for view_def use. if req is None: req = get_current_request() assert req if getattr(req, "lang_prefs", 0) is 0: user_id = req.authenticated_userid if user_id and user_id != Everyone: try: req.lang_prefs = UserLanguagePreferenceCollection(user_id) return req.lang_prefs except Exception: capture_exception() # use my locale negotiator locale = req.locale_name req.lang_prefs = LanguagePreferenceCollectionWithDefault(locale) return req.lang_prefs @abstractmethod def default_locale_code(self): pass @abstractmethod def known_languages(self): return []
[docs]class LanguagePreferenceCollectionWithDefault(LanguagePreferenceCollection): """A LanguagePreferenceCollection with a fallback language.""" def __init__(self, locale_code): self.default_locale = locale_code def default_locale_code(self): return self.default_locale def find_locale(self, locale): if locale_compatible(locale, self.default_locale): return UserLanguagePreference( locale=self.default_locale, source_of_evidence=LanguagePreferenceOrder.Cookie.value) else: return UserLanguagePreference( locale=locale, translate=self.default_locale, source_of_evidence=LanguagePreferenceOrder.Cookie.value) def known_languages(self): return [self.default_locale]
[docs]class UserLanguagePreferenceCollection(LanguagePreferenceCollection): """A LanguagePreferenceCollection that represent one user's preferences.""" def __init__(self, user_id): user = User.get(user_id) user_prefs = user.language_preference assert user_prefs user_prefs.sort(reverse=True) prefs_by_locale = { user_pref.locale: user_pref for user_pref in user_prefs } user_prefs.reverse() prefs_with_trans = [up for up in user_prefs if up.translate] prefs_without_trans = [ up for up in user_prefs if not up.translate] prefs_without_trans_by_loc = { up.locale: up for up in prefs_without_trans} # First look for translation targets for (loc, pref) in list(prefs_by_locale.items()): for n, l in enumerate(locale_ancestry(loc)): if n == 0: continue if l in prefs_by_locale: break prefs_by_locale[l] = pref for pref in prefs_with_trans: for l in locale_ancestry(pref.translate): if l in prefs_without_trans_by_loc: break locale = l new_pref = UserLanguagePreference( locale=locale, source_of_evidence= LanguagePreferenceOrder.DeducedFromTranslation.value, preferred_order=pref.preferred_order) prefs_without_trans.append(new_pref) prefs_without_trans_by_loc[l] = new_pref if l not in prefs_by_locale: prefs_by_locale[l] = new_pref default_pref = None if prefs_with_trans: prefs_with_trans.sort() target_lang_code = prefs_with_trans[0].translate default_pref = prefs_without_trans_by_loc.get( target_lang_code, None) if not default_pref: # using the untranslated locales, if any. prefs_without_trans.sort() # TODO: Or use discussion locales otherwise? # As it stands, the cookie is the fallback. default_pref = ( prefs_without_trans[0] if prefs_without_trans else None) self.user_prefs = prefs_by_locale self.default_pref = default_pref def default_locale_code(self): return self.default_pref.locale def find_locale(self, locale, db=None): # This code needs to mirror # LanguagePreferenceCollection.getPreferenceForLocale for locale in locale_ancestry(locale): if locale in self.user_prefs: return self.user_prefs[locale] if self.default_pref is None: # this should never actually happen return None return UserLanguagePreference( locale=locale, translate=self.default_pref.locale, source_of_evidence=self.default_pref.source_of_evidence, user=None) # Do not give the user or this gets added to session def known_languages(self): return list({pref.translate or pref.locale for pref in self.user_prefs.values()})
[docs]@total_ordering class UserLanguagePreference(Base): """Does this user wants data in this language to be displayed or translated?""" __tablename__ = 'user_language_preference' __table_args__ = (UniqueConstraint( 'user_id', 'locale', 'source_of_evidence'), ) id = Column(Integer, primary_key=True) user_id = Column( Integer, ForeignKey( User.id, ondelete='CASCADE', onupdate='CASCADE'), nullable=False, index=True) locale = Column(String(11), index=True) translate = Column(String(11)) # Sort the preferences within a source_of_evidence # Descending order preference, 0 - is the highest preferred_order = Column(Integer, nullable=False, default=0) # This is the actual evidence source, whose contract is defined in # LanguagePreferenceOrder. They have priority over preferred_order source_of_evidence = Column(Integer, nullable=False) user = relationship('User', backref=backref( 'language_preference', cascade='all, delete-orphan', order_by=source_of_evidence)) crud_permissions = CrudPermissions( P_READ, P_SYSADMIN, P_SYSADMIN, P_SYSADMIN, P_READ, P_READ, P_READ)
[docs] def is_owner(self, user_id): return user_id == self.user_id
def __eq__(self, other): return self is other def __ne__(self, other): return self is not other def __lt__(self, other): if not isinstance(other, self.__class__): return True if self.source_of_evidence != other.source_of_evidence: return self.source_of_evidence < other.source_of_evidence if (self.preferred_order or 0) != (other.preferred_order or 0): return (self.preferred_order or 0) < (other.preferred_order or 0) if (self.id or 0) != (other.id or 0): return (self.id or 0) < (other.id or 0) return id(self) < id(other) def __hash__(self): if self.id: return hash(self.id) return hash(self.user_id) ^ hash(self.source_of_evidence) ^ hash(self.locale) # def set_priority_order(self, code): # # code can be ignored. This value should be updated for each user # # as each preferred language is committed # current_languages = self.db.query(UserLanguagePreference).\ # filter_by(user=self.user).\ # order_by(self.preferred_order).all() # if self.source_of_evidence == 0: # pass
[docs] def unique_query(self): query, _ = super(UserLanguagePreference, self).unique_query() query = query.filter_by( user_id=self.user_id or self.user.id, locale=self.locale, source_of_evidence=self.source_of_evidence) return query, True
@as_native_str() def __repr__(self): return \ "{user_id: %d, locale: %s, translated_to: %s "\ "source_of_evidence: %s, preferred_order: %d}" % ( self.user_id or -1, self.locale, self.translate, LanguagePreferenceOrder(self.source_of_evidence).name, self.preferred_order or 0 )