<commit_before>
import datetime
import json
import iso8601
import semantic_version

from base64 import b64encode, b64decode
from flask import url_for, current_app, session
from flask.ext.login import current_user, UserMixin
from hashlib import sha256
from keyczar.keys import RsaPrivateKey, RsaPublicKey
from uuid import uuid4

from . import ONEUP_STATES, STAR_STATES, PLANET_STATES, \
    PersonaNotFoundError, UnauthorizedError, notification_signals, \
    CHANGE_TYPES, logger, planet_sort_rank
from .helpers import epoch_seconds

from database import cache, db

request_objects = notification_signals.signal('request-objects')


class Serializable():
    """ Make SQLAlchemy models json serializable

    Attributes:
        _insert_required: Default attributes to include in export
        _update_required: Default attributes to include in export with update=True
    """
    id = None
    modified = None

    _insert_required = ["id", "modified"]
    _update_required = ["id", "modified"]

    def authorize(self, action, author_id=None):
        """Return True if this object authorizes `action` for `author_id`

        Args:
            action (String): Action to be performed (see Synapse.CHANGE_TYPES)
            author_id (String): Persona ID that wants to perform the action

        Returns:
            Boolean: True if authorized
        """
        if action not in CHANGE_TYPES:
            return False
        return True

    def export(self, exclude=[], include=None, update=False):
        """Return this object as a dict.

        Args:
            update (Bool): Export only attributes defined in `self._update_required`

        Returns:
            Dict: The serialized object

        Raises:
            KeyError: If a key was not found
        """
        attr_names = self._update_required if update is True else self._insert_required

        if include:
            attr_names = include
        else:
            attr_names = [a for a in attr_names if a not in exclude]

        return {attr: str(getattr(self, attr)) for attr in attr_names}

    def json(self, update=False):
        """Return this object JSON encoded.

        Args:
            update (Boolean): (optiona) See export docstring

        Returns:
            Str: JSON-encoded serialized instance
        """
        return json.dumps(self.export(update=update), indent=4)

    @classmethod
    def validate_changeset(cls, changeset, update=False):
        """Check whether changeset contains all keys defined as required for this class.

        Args:
            changeset(dict): See created_from_changeset, update_from_changeset
            update(Bool): If True use cls._update_required instead of cls._insert_required

        Returns:
            List: Missing keys
        """
        required_keys = cls._update_required if update else cls._insert_required
        missing = list()

        for k in required_keys:
            if k not in changeset.keys():
                missing.append(k)
        return missing

    @staticmethod
    def create_from_changeset(changeset, stub=None, update_sender=None, update_recipient=None):
        """Create a new instance from a changeset.

        Args:
            changeset (dict): Dictionary of model values. Requires all keys
                defined in cls._insert_required with class-specific values.
            stub (Serializable): (Optional) model instance whose values will be
                overwritten with those defined in changeset.
            update_sender (Persona): (Optional) author of this changeset. Will be
                used as recipient of subsequent object requests.
            update_recipient (Persona): (Optional) recipient of this changeset.
                Will be used as sender of subsequent object requests.

        Returns:
            Serializable: Instance created from changeset

        Raises:
            KeyError: Missing key in changeset
            TypeError: Argument has wrong type
            ValueError: Argument value cannot be processed
        """
        raise NotImplementedError()

    def update_from_changeset(self, changeset, update_sender=None, update_recipient=None):
        """Update self with new values in changeset

        Args:
            changeset (dict): Dictionary of model values. Requires all keys
                defined in self._update_required with class-specific values.
            update_sender (Persona): (Optional) author of this changeset. Will be
                used as recipient of subsequent object requests.
            update_recipient (Persona): (Optional) recipient of this changeset.
                Will be used as sender of subsequent object requests.

        Returns:
            Serializable: Updated instance

        Raises:
            KeyError: Missing key in changeset
            TypeError: Argument has wrong type
            ValueError: Argument value cannot be processed
        """
        raise NotImplementedError()


class PersonaAssociation(db.Model):
    """Connects user accounts and personas"""
    __tablename__ = "persona_association"
    left_id = db.Column(db.String(32), db.ForeignKey('user.id'), primary_key=True)
    right_id = db.Column(db.String(32), db.ForeignKey('persona.id'), primary_key=True)
    active = db.Column(db.Boolean(), default=False)
    persona = db.relationship("Persona", backref="associations")


class User(UserMixin, db.Model):
    """A user of the website"""

    __tablename__ = 'user'
    id = db.Column(db.String(32), primary_key=True, default=uuid4().hex)
    email = db.Column(db.String(128))
    created = db.Column(db.DateTime)
    modified = db.Column(db.DateTime)
    pw_hash = db.Column(db.String(64))
    active = db.Column(db.Boolean(), default=False)
    authenticated = db.Column(db.Boolean(), default=True)
    associations = db.relationship('PersonaAssociation', lazy="dynamic", backref="user")
    signup_code = db.Column(db.String(128))

    def __repr__(self):
        return "<User {}>".format(self.email.decode('utf-8'))

    def check_password(self, password):
        """Return True if password matches user password

        Args:
            password (String): Password entered by user in login form
        """
        pw_hash = sha256(password).hexdigest()
        return self.pw_hash == pw_hash

    def set_password(self, password):
        """Set password to a new value

        Args:
            password (String): Plaintext value of the new password
        """
        pw_hash = sha256(password).hexdigest()
        self.pw_hash = pw_hash

    def get_id(self):
        return self.id

    def is_authenticated(self):
        return self.authenticated

    def is_active(self):
        return self.active

    def is_anonymous(self):
        return False

    def valid_signup_code(self, signup_code):
        """Return True if the given signup code is valid, and less than 7 days
        have passed since signup.

        Args:
            signup_code (String): 128byte string passed in registration email
        """
        if signup_code != self.signup_code:
            return False

        if (datetime.datetime.utcnow() - self.created) > datetime.timedelta(days=7):
            return False

        return True

    @property
    def active_persona(self):
        try:
            return self.associations.filter_by(active=True).first().persona
        except AttributeError:
            # no persona associated
            return None


t_identity_vesicles = db.Table(
    'identity_vesicles',
    db.Column('identity_id', db.String(32), db.ForeignKey('identity.id')),
    db.Column('vesicle_id', db.String(32), db.ForeignKey('vesicle.id'))
)


class Identity(Serializable, db.Model):
    """Abstract identity, superclass of Persona and Group

    Attributes:
        _insert_required: Attributes that are serialized
        id: 32 byte ID generated by uuid4().hex
        username: Public username of the Identity, max 80 bytes
        crypt_private: Private encryption RSA key, JSON encoded KeyCzar export
        crypt_public: Public encryption RSA key, JSON encoded KeyCzar export
        sign_private: Private signing RSA key, JSON encoded KeyCzar export
        sign_public: Public signing RSA key, JSON encoded KeyCzar export
        modified: Last time this Identity object was modified, defaults to now
        vesicles: List of Vesicles that describe this Identity object
        profile: Starmap containing this Identity's profile page

    """

    __tablename__ = "identity"

    _insert_required = ["id", "username", "crypt_public", "sign_public", "modified", "profile_id"]
    _update_required = ["id", "modified"]

    _stub = db.Column(db.Boolean, default=False)
    id = db.Column(db.String(32), primary_key=True)
    kind = db.Column(db.String(32))
    created = db.Column(db.DateTime)
    modified = db.Column(db.DateTime, default=datetime.datetime.utcnow())
    username = db.Column(db.String(80))
    crypt_private = db.Column(db.Text)
    crypt_public = db.Column(db.Text)
    sign_private = db.Column(db.Text)
    sign_public = db.Column(db.Text)

    vesicles = db.relationship(
        'Vesicle',
        secondary='identity_vesicles',
        primaryjoin='identity_vesicles.c.identity_id==identity.c.id',
        secondaryjoin='identity_vesicles.c.vesicle_id==vesicle.c.id')

    profile_id = db.Column(db.String(32), db.ForeignKey('starmap.id'))
    profile = db.relationship('Starmap', backref="contexts", primaryjoin='starmap.c.id==identity.c.profile_id')

    __mapper_args__ = {
        'polymorphic_identity': 'identity',
        'polymorphic_on': kind
    }

    def __repr__(self):
        try:
            name = self.username.encode('utf-8')
        except AttributeError:
            name = ""
        return "<ID @{} [{}]>".format(name, self.id[:6])

    def authorize(self, action, author_id=None):
        """Return True if this Identity authorizes `action` for `author_id`

        Args:
            action (String): Action to be performed (see Synapse.CHANGE_TYPES)
            author_id (String): Identity ID that wants to perform the action

        Returns:
            Boolean: True if authorized
        """
        if Serializable.authorize(self, action, author_id=author_id):
            return (self.id == author_id)
        return False

    def controlled(self):
        """
        Return True if currently active User controls this Persona and
            all neccessary keys are available
        """
        if self.crypt_private is not None and self.sign_private is not None:
            if self.id == session.get("active_persona"):
                return True
            if session.get("active_persona") and Persona.query.get(session.get("active_persona")).user == self.user:
                return True
        return False

    @staticmethod
    def list_controlled():
        if session.get("active_persona"):
            controlled_user = User.query.join(PersonaAssociation).filter(PersonaAssociation.right_id == session["active_persona"]).first()

            return [asc.persona for asc in controlled_user.associations] if controlled_user else []
        else:
            return []

    def generate_keys(self, password):
        """ Generate new RSA keypairs for signing and encrypting. Commit to DB afterwards! """

        # TODO: Store keys encrypted
        rsa1 = RsaPrivateKey.Generate()
        self.sign_private = str(rsa1)
        self.sign_public = str(rsa1.public_key)

        rsa2 = RsaPrivateKey.Generate()
        self.crypt_private = str(rsa2)
        self.crypt_public = str(rsa2.public_key)

    def encrypt(self, data):
        """ Encrypt data using RSA """

        key_public = RsaPublicKey.Read(self.crypt_public)
        return b64encode(key_public.Encrypt(data))

    def decrypt(self, cypher):
        """ Decrypt cyphertext using RSA """

        cypher = b64decode(cypher)
        key_private = RsaPrivateKey.Read(self.crypt_private)
        return key_private.Decrypt(cypher)

    def sign(self, data):
        """ Sign data using RSA """

        key_private = RsaPrivateKey.Read(self.sign_private)
        signature = key_private.Sign(data)
        return b64encode(signature)

    def verify(self, data, signature_b64):
        """ Verify a signature using RSA """

        signature = b64decode(signature_b64)
        key_public = RsaPublicKey.Read(self.sign_public)
        return key_public.Verify(data, signature)

    @staticmethod
    def create_from_changeset(changeset, stub=None, update_sender=None, update_recipient=None, kind=None, request_sources=True):
        """See Serializable.create_from_changeset

        Args:
            kind (class): Pass a class to create new entity from
            request_sources (bool): Request linked objects
        """
        request_list = list()

        modified_dt = iso8601.parse_date(changeset["modified"]).replace(tzinfo=None)

        if stub:
            ident = stub
            ident.id = changeset["id"]
            ident.username = changeset["username"]
            ident.crypt_public = changeset["crypt_public"]
            ident.sign_public = changeset["sign_public"]
            ident.modified = modified_dt
            ident._stub = False
        else:
            if kind is None:
                kind = Identity

            ident = kind(
                id=changeset["id"],
                username=changeset["username"],
                crypt_public=changeset["crypt_public"],
                sign_public=changeset["sign_public"],
                modified=modified_dt,
            )

        # Update profile
        profile = Starmap.query.get(changeset["profile_id"])
        if profile is None or profile.get_state() == -1:
            request_list.append({
                "type": "Starmap",
                "id": changeset["profile_id"],
                "author_id": update_recipient.id if update_recipient else None,
                "recipient_id": update_sender.id if update_sender else None,
                "id": changeset["profile_id"]
            })

        if profile is None:
            profile = Starmap(id=changeset["profile_id"])
            profile.state = -1

        ident.profile = profile

        logger.info("Created {} from changeset, now requesting {} linked objects".format(
            ident, len(request_list)))

        if request_sources:
            for req in request_list:
                request_objects.send(Identity.create_from_changeset, message=req)
        else:
            logger.info("Not requesting linked sources for new {}".format(kind))

        return ident

    def update_from_changeset(self, changeset, update_sender=None, update_recipient=None):
        """See Serializable.update_from_changeset"""
        request_list = list()

        # Update modified
        modified_dt = iso8601.parse_date(changeset["modified"]).replace(tzinfo=None)
        self.modified = modified_dt

        # Update username
        if "username" in changeset:
            self.username = changeset["username"]
            logger.info("Updated {}'s {}".format(self.username, "username"))

        # Update profile
        if "profile_id" in changeset:
            profile = Starmap.query.get(changeset["profile_id"])
            if profile is None or profile.get_state() == -1:
                request_list.append({
                    "type": "Starmap",
                    "id": changeset["profile_id"],
                    "author_id": update_recipient.id,
                    "recipient_id": update_sender.id,
                })
                logger.info("Requested {}'s {}".format(self.username, "profile starmap"))
            else:
                self.profile = profile
                logger.info("Updated {}'s {}".format(self.username, "profile starmap"))

        logger.info("Updated {} identity from changeset. Requesting {} objects.".format(self, len(request_list)))

        for req in request_list:
            request_objects.send(Identity.create_from_changeset, message=req)

#
# Setup follower relationship on Persona objects
#

t_contacts = db.Table('contacts',
    db.Column('left_id', db.String(32), db.ForeignKey('persona.id')),
    db.Column('right_id', db.String(32), db.ForeignKey('persona.id')),
    db.UniqueConstraint('left_id', 'right_id', name='_uc_contacts')
)

t_groups_followed = db.Table('groups_followed',
    db.Column('persona_id', db.String(32), db.ForeignKey('persona.id')),
    db.Column('group_id', db.String(32), db.ForeignKey('group.id'))
)


class Persona(Identity):
    """A Persona represents a user profile

    Attributes:
        email: An email address, max 120 bytes
        contacts: List of this Persona's contacts
        index: Starmap containing all Star's this Persona publishes to its contacts
        myelin_offset: Datetime of last request for Vesicles sent to this Persona

    """
    __mapper_args__ = {
        'polymorphic_identity': 'persona'
    }

    _insert_required = Identity._insert_required + ["email", "index_id", "contacts", "groups"]
    _update_required = Identity._update_required

    id = db.Column(db.String(32), db.ForeignKey('identity.id'), primary_key=True)
    email = db.Column(db.String(120))
    auth = db.Column(db.String(32), default=uuid4().hex)
    session_id = db.Column(db.String(32), default=uuid4().hex)
    last_connected = db.Column(db.DateTime, default=datetime.datetime.now())

    contacts = db.relationship('Persona',
        secondary='contacts',
        lazy="dynamic",
        remote_side='contacts.c.right_id',
        primaryjoin='contacts.c.left_id==persona.c.id',
        secondaryjoin='contacts.c.right_id==persona.c.id')

    groups_followed = db.relationship('Group',
        secondary='groups_followed',
        primaryjoin='groups_followed.c.persona_id==persona.c.id',
        secondaryjoin='groups_followed.c.group_id==group.c.id')

    index_id = db.Column(db.String(32), db.ForeignKey('starmap.id'))
    index = db.relationship('Starmap', primaryjoin='starmap.c.id==persona.c.index_id')

    # Myelin offset stores the date at which the last Vesicle receieved from Myelin was created
    myelin_offset = db.Column(db.DateTime)

    def __repr__(self):
        try:
            name = self.username.encode('utf-8')
        except AttributeError:
            name = ""
        return "<Persona @{} [{}]>".format(name, self.id[:6])

    def activate(self):
        if current_user.is_anonymous:
            return UnauthorizedError("Need to be logged in to activate Personas")

        if not self.associations[0].user == current_user:
            raise UnauthorizedError("You can't activate foreign Personas")

        for asc in PersonaAssociation.query.filter(PersonaAssociation.user==current_user, PersonaAssociation.active==True):
            if asc.active and not (asc == self.associations[0]):
                asc.active = False
                db.session.add(asc)

        self.associations[0].active = True
        db.session.add(self.associations[0])
        db.session.commit()

    def authorize(self, action, author_id=None):
        """Return True if this Persona authorizes `action` for `author_id`

        Args:
            action (String): Action to be performed (see Synapse.CHANGE_TYPES)
            author_id (String): Persona ID that wants to perform the action

        Returns:
            Boolean: True if authorized
        """
        if Identity.authorize(self, action, author_id=author_id):
            return (self.id == author_id)
        return False

    def get_email_hash(self):
        """Return sha256 hash of this user's email address"""
        return sha256(self.email).hexdigest()

    def get_absolute_url(self):
        return url_for('persona', id=self.id)

    def export(self, exclude=[], include=None, update=False):
        exclude = set(exclude + ["contacts", "groups", "groups_followed"])
        data = Identity.export(self, exclude=exclude, include=include, update=update)

        data["contacts"] = list()
        for contact in self.contacts:
            data["contacts"].append({
                "id": contact.id,
            })

        data["groups"] = list()
        for group in self.groups:
            data["groups"].append({
                "id": group.id,
            })

        data["groups_followed"] = list()
        for group in self.groups_followed:
            data["groups_followed"].append({
                "id": group.id
            })

        return data

    def reset(self):
        """Reset session_id"""
        self.session_id = uuid4().hex
        self.auth = uuid4().hex
        return self.session_id

    def timeout(self):
        return self.last_connected + current_app.config['SESSION_EXPIRATION_TIME']

    @staticmethod
    def request_persona(persona_id):
        """Return a Persona profile, loading it from Glia if neccessary

        Args:
            persona_id (String): ID of the required Persona

        Returns:
            Persona: If a record was found
            None: If no record was found
        """
        from synapse import ElectricalSynapse
        electrical = ElectricalSynapse()
        return electrical.get_persona(persona_id)

    @staticmethod
    def create_from_changeset(changeset, stub=None, update_sender=None, update_recipient=None):
        """See Serializable.create_from_changeset"""
        p = Identity.create_from_changeset(changeset,
            stub=stub, update_sender=update_sender, update_recipient=update_recipient, kind=Persona)

        request_list = list()

        p.email = changeset["email"]

        # Update index
        index = Starmap.query.get(changeset["index_id"])
        if index is None or index.get_state() == -1:
            request_list.append({
                "type": "Starmap",
                "id": changeset["index_id"],
                "author_id": update_recipient.id,
                "recipient_id": update_sender.id,
            })

        if index is None:
            index = Starmap(id=changeset["index_id"])
            index.state = -1

        p.index = index

        # Update contacts
        missing_contacts = p.update_contacts(changeset["contacts"])
        for mc in missing_contacts:
            request_list.append({
                "type": "Persona",
                "id": mc,
                "author_id": update_recipient.id,
                "recipient_id": update_sender.id,
            })

        # Request unknown groups
        groups_to_check = set(changeset["groups"] + changeset["groups_followed"])
        for group_info in groups_to_check:
            group = Group.query.get(group_info["id"])
            if group is None:
                request_list.append({
                    "type": "Group",
                    "id": group_info["id"],
                    "author_id": update_recipient.id,
                    "recipient_id": update_sender.id,
                })

        logger.info("Made {} a Persona object, now requesting {} linked objects".format(
            p, len(request_list)))

        for req in request_list:
            request_objects.send(Persona.create_from_changeset, message=req)

        return p

    def update_from_changeset(self, changeset, update_sender=None, update_recipient=None):
        """See Serializable.update_from_changeset"""
        Identity.update_from_changeset(self, changeset,
            update_sender=update_sender, update_recipient=update_recipient)

        logger.info("Now applying Persona-specific updates for {}".format(self))

        request_list = list()

        # Update email
        if "email" in changeset:
            if isinstance(changeset["email"], str):
                self.email = changeset["email"]
                logger.info("Updated {}'s {}".format(self.username, "email"))
            else:
                logger.warning("Invalid `email` supplied in update for {}\n\n".format(
                    self, changeset))

        # Update index
        if "index_id" in changeset:
            index = Starmap.query.get(changeset["index_id"])
            if index is None or index.get_state() == -1:
                request_list.append({
                    "type": "Starmap",
                    "id": changeset["index_id"],
                    "author_id": update_recipient.id,
                    "recipient_id": update_sender.id,
                })
                logger.info("Requested {}'s new {}".format(self.username, "index starmap"))
            else:
                self.index = index
                logger.info("Updated {}'s {}".format(self.username, "index starmap"))

        # Update contacts
        if "contacts" in changeset:
            missing_contacts = self.update_contacts(changeset["contacts"])
            for mc in missing_contacts:
                request_list.append({
                    "type": "Persona",
                    "id": mc,
                    "author_id": update_recipient.id,
                    "recipient_id": update_sender.id,
                })

        # Request unknown groups
        groups_1 = changeset.get('groups') or []
        groups_2 = changeset.get('groups_followed') or []
        groups_to_check = groups_1 + groups_2

        for group_info in groups_to_check:
            group = Group.query.get(group_info["id"])
            if group is None:
                request_list.append({
                    "type": "Group",
                    "id": group_info["id"],
                    "author_id": update_recipient.id,
                    "recipient_id": update_sender.id,
                })

        logger.info("Updated {} from changeset. Requesting {} objects.".format(self, len(request_list)))

        for req in request_list:
            request_objects.send(Persona.update_from_changeset, message=req)

    def update_contacts(self, contact_list):
        """Update Persona's contacts from a list of the new contacts

        Args:
            contact_list (list): List of dictionaries with keys:
                id (String) -- 32 byte ID of the contact

        Returns:
            list: List of missing Persona IDs to be requested
        """
        updated_contacts = 0
        request_list = list()

        # stale_contacts contains all old contacts at first, all current
        # contacts get then removed so that the remaining can get deleted
        stale_contacts = set(self.contacts)

        for contact in contact_list:
            c = Persona.query.get(contact["id"])

            if c is None:
                c = Persona(id=contact["id"], _stub=True)

            if c._stub is True:
                request_list.append(contact["id"])

            try:
                # Old and new contact; remove from stale list
                stale_contacts.remove(c)
            except KeyError:
                # New contact
                self.contacts.append(c)
                updated_contacts += 1

        # Remove old contacts that are not new contacts
        for contact in stale_contacts:
            self.contacts.remove(contact)

        logger.info("Updated {}'s contacts: {} added, {} removed, {} requested".format(
            self.username, updated_contacts, len(stale_contacts), len(request_list)))

        return request_list

    @property
    def user(self):
        return self.associations[0].user

    def toggle_following_group(self, group):
        """Toggle whether this Persona is following a group.

        Args:
            group (Group): Group entity to be (un)followed

        Returns:
            boolean -- True if the group is now being followed, False if not
        """
        following = False

        try:
            self.groups_followed.remove(group)
        except ValueError:
            self.groups_followed.append(group)
            following = True

        return following

    def toggle_group_membership(self, group):
        """Toggle whether this Persona is member of a group.

        Also enables group following for this Persona/Group.

        Args:
            group (Group): Group entity to be become member of

        Returns:
            Updated GroupMemberAssociation object or None if it was deleted
        """
        if group not in self.groups_followed:
            logger.info("Setting {} to follow {}.".format(self, group))
            self.toggle_following_group(group)

        gms = GroupMemberAssociation.query.filter_by(group_id=group.id). \
            filter_by(persona_id=self.id).first()

        if gms is None:
            logger.info("Enabling membership of {} in {}".format(self, group))
            gms = GroupMemberAssociation(<commit_msg>Add log messages for follower updates<commit_after>
import datetime
import json
import iso8601
import semantic_version

from base64 import b64encode, b64decode
from flask import url_for, current_app, session
from flask.ext.login import current_user, UserMixin
from hashlib import sha256
from keyczar.keys import RsaPrivateKey, RsaPublicKey
from uuid import uuid4

from . import ONEUP_STATES, STAR_STATES, PLANET_STATES, \
    PersonaNotFoundError, UnauthorizedError, notification_signals, \
    CHANGE_TYPES, logger, planet_sort_rank
from .helpers import epoch_seconds

from database import cache, db

request_objects = notification_signals.signal('request-objects')


class Serializable():
    """ Make SQLAlchemy models json serializable

    Attributes:
        _insert_required: Default attributes to include in export
        _update_required: Default attributes to include in export with update=True
    """
    id = None
    modified = None

    _insert_required = ["id", "modified"]
    _update_required = ["id", "modified"]

    def authorize(self, action, author_id=None):
        """Return True if this object authorizes `action` for `author_id`

        Args:
            action (String): Action to be performed (see Synapse.CHANGE_TYPES)
            author_id (String): Persona ID that wants to perform the action

        Returns:
            Boolean: True if authorized
        """
        if action not in CHANGE_TYPES:
            return False
        return True

    def export(self, exclude=[], include=None, update=False):
        """Return this object as a dict.

        Args:
            update (Bool): Export only attributes defined in `self._update_required`

        Returns:
            Dict: The serialized object

        Raises:
            KeyError: If a key was not found
        """
        attr_names = self._update_required if update is True else self._insert_required

        if include:
            attr_names = include
        else:
            attr_names = [a for a in attr_names if a not in exclude]

        return {attr: str(getattr(self, attr)) for attr in attr_names}

    def json(self, update=False):
        """Return this object JSON encoded.

        Args:
            update (Boolean): (optiona) See export docstring

        Returns:
            Str: JSON-encoded serialized instance
        """
        return json.dumps(self.export(update=update), indent=4)

    @classmethod
    def validate_changeset(cls, changeset, update=False):
        """Check whether changeset contains all keys defined as required for this class.

        Args:
            changeset(dict): See created_from_changeset, update_from_changeset
            update(Bool): If True use cls._update_required instead of cls._insert_required

        Returns:
            List: Missing keys
        """
        required_keys = cls._update_required if update else cls._insert_required
        missing = list()

        for k in required_keys:
            if k not in changeset.keys():
                missing.append(k)
        return missing

    @staticmethod
    def create_from_changeset(changeset, stub=None, update_sender=None, update_recipient=None):
        """Create a new instance from a changeset.

        Args:
            changeset (dict): Dictionary of model values. Requires all keys
                defined in cls._insert_required with class-specific values.
            stub (Serializable): (Optional) model instance whose values will be
                overwritten with those defined in changeset.
            update_sender (Persona): (Optional) author of this changeset. Will be
                used as recipient of subsequent object requests.
            update_recipient (Persona): (Optional) recipient of this changeset.
                Will be used as sender of subsequent object requests.

        Returns:
            Serializable: Instance created from changeset

        Raises:
            KeyError: Missing key in changeset
            TypeError: Argument has wrong type
            ValueError: Argument value cannot be processed
        """
        raise NotImplementedError()

    def update_from_changeset(self, changeset, update_sender=None, update_recipient=None):
        """Update self with new values in changeset

        Args:
            changeset (dict): Dictionary of model values. Requires all keys
                defined in self._update_required with class-specific values.
            update_sender (Persona): (Optional) author of this changeset. Will be
                used as recipient of subsequent object requests.
            update_recipient (Persona): (Optional) recipient of this changeset.
                Will be used as sender of subsequent object requests.

        Returns:
            Serializable: Updated instance

        Raises:
            KeyError: Missing key in changeset
            TypeError: Argument has wrong type
            ValueError: Argument value cannot be processed
        """
        raise NotImplementedError()


class PersonaAssociation(db.Model):
    """Connects user accounts and personas"""
    __tablename__ = "persona_association"
    left_id = db.Column(db.String(32), db.ForeignKey('user.id'), primary_key=True)
    right_id = db.Column(db.String(32), db.ForeignKey('persona.id'), primary_key=True)
    active = db.Column(db.Boolean(), default=False)
    persona = db.relationship("Persona", backref="associations")


class User(UserMixin, db.Model):
    """A user of the website"""

    __tablename__ = 'user'
    id = db.Column(db.String(32), primary_key=True, default=uuid4().hex)
    email = db.Column(db.String(128))
    created = db.Column(db.DateTime)
    modified = db.Column(db.DateTime)
    pw_hash = db.Column(db.String(64))
    active = db.Column(db.Boolean(), default=False)
    authenticated = db.Column(db.Boolean(), default=True)
    associations = db.relationship('PersonaAssociation', lazy="dynamic", backref="user")
    signup_code = db.Column(db.String(128))

    def __repr__(self):
        return "<User {}>".format(self.email.decode('utf-8'))

    def check_password(self, password):
        """Return True if password matches user password

        Args:
            password (String): Password entered by user in login form
        """
        pw_hash = sha256(password).hexdigest()
        return self.pw_hash == pw_hash

    def set_password(self, password):
        """Set password to a new value

        Args:
            password (String): Plaintext value of the new password
        """
        pw_hash = sha256(password).hexdigest()
        self.pw_hash = pw_hash

    def get_id(self):
        return self.id

    def is_authenticated(self):
        return self.authenticated

    def is_active(self):
        return self.active

    def is_anonymous(self):
        return False

    def valid_signup_code(self, signup_code):
        """Return True if the given signup code is valid, and less than 7 days
        have passed since signup.

        Args:
            signup_code (String): 128byte string passed in registration email
        """
        if signup_code != self.signup_code:
            return False

        if (datetime.datetime.utcnow() - self.created) > datetime.timedelta(days=7):
            return False

        return True

    @property
    def active_persona(self):
        try:
            return self.associations.filter_by(active=True).first().persona
        except AttributeError:
            # no persona associated
            return None


t_identity_vesicles = db.Table(
    'identity_vesicles',
    db.Column('identity_id', db.String(32), db.ForeignKey('identity.id')),
    db.Column('vesicle_id', db.String(32), db.ForeignKey('vesicle.id'))
)


class Identity(Serializable, db.Model):
    """Abstract identity, superclass of Persona and Group

    Attributes:
        _insert_required: Attributes that are serialized
        id: 32 byte ID generated by uuid4().hex
        username: Public username of the Identity, max 80 bytes
        crypt_private: Private encryption RSA key, JSON encoded KeyCzar export
        crypt_public: Public encryption RSA key, JSON encoded KeyCzar export
        sign_private: Private signing RSA key, JSON encoded KeyCzar export
        sign_public: Public signing RSA key, JSON encoded KeyCzar export
        modified: Last time this Identity object was modified, defaults to now
        vesicles: List of Vesicles that describe this Identity object
        profile: Starmap containing this Identity's profile page

    """

    __tablename__ = "identity"

    _insert_required = ["id", "username", "crypt_public", "sign_public", "modified", "profile_id"]
    _update_required = ["id", "modified"]

    _stub = db.Column(db.Boolean, default=False)
    id = db.Column(db.String(32), primary_key=True)
    kind = db.Column(db.String(32))
    created = db.Column(db.DateTime)
    modified = db.Column(db.DateTime, default=datetime.datetime.utcnow())
    username = db.Column(db.String(80))
    crypt_private = db.Column(db.Text)
    crypt_public = db.Column(db.Text)
    sign_private = db.Column(db.Text)
    sign_public = db.Column(db.Text)

    vesicles = db.relationship(
        'Vesicle',
        secondary='identity_vesicles',
        primaryjoin='identity_vesicles.c.identity_id==identity.c.id',
        secondaryjoin='identity_vesicles.c.vesicle_id==vesicle.c.id')

    profile_id = db.Column(db.String(32), db.ForeignKey('starmap.id'))
    profile = db.relationship('Starmap', backref="contexts", primaryjoin='starmap.c.id==identity.c.profile_id')

    __mapper_args__ = {
        'polymorphic_identity': 'identity',
        'polymorphic_on': kind
    }

    def __repr__(self):
        try:
            name = self.username.encode('utf-8')
        except AttributeError:
            name = ""
        return "<ID @{} [{}]>".format(name, self.id[:6])

    def authorize(self, action, author_id=None):
        """Return True if this Identity authorizes `action` for `author_id`

        Args:
            action (String): Action to be performed (see Synapse.CHANGE_TYPES)
            author_id (String): Identity ID that wants to perform the action

        Returns:
            Boolean: True if authorized
        """
        if Serializable.authorize(self, action, author_id=author_id):
            return (self.id == author_id)
        return False

    def controlled(self):
        """
        Return True if currently active User controls this Persona and
            all neccessary keys are available
        """
        if self.crypt_private is not None and self.sign_private is not None:
            if self.id == session.get("active_persona"):
                return True
            if session.get("active_persona") and Persona.query.get(session.get("active_persona")).user == self.user:
                return True
        return False

    @staticmethod
    def list_controlled():
        if session.get("active_persona"):
            controlled_user = User.query.join(PersonaAssociation).filter(PersonaAssociation.right_id == session["active_persona"]).first()

            return [asc.persona for asc in controlled_user.associations] if controlled_user else []
        else:
            return []

    def generate_keys(self, password):
        """ Generate new RSA keypairs for signing and encrypting. Commit to DB afterwards! """

        # TODO: Store keys encrypted
        rsa1 = RsaPrivateKey.Generate()
        self.sign_private = str(rsa1)
        self.sign_public = str(rsa1.public_key)

        rsa2 = RsaPrivateKey.Generate()
        self.crypt_private = str(rsa2)
        self.crypt_public = str(rsa2.public_key)

    def encrypt(self, data):
        """ Encrypt data using RSA """

        key_public = RsaPublicKey.Read(self.crypt_public)
        return b64encode(key_public.Encrypt(data))

    def decrypt(self, cypher):
        """ Decrypt cyphertext using RSA """

        cypher = b64decode(cypher)
        key_private = RsaPrivateKey.Read(self.crypt_private)
        return key_private.Decrypt(cypher)

    def sign(self, data):
        """ Sign data using RSA """

        key_private = RsaPrivateKey.Read(self.sign_private)
        signature = key_private.Sign(data)
        return b64encode(signature)

    def verify(self, data, signature_b64):
        """ Verify a signature using RSA """

        signature = b64decode(signature_b64)
        key_public = RsaPublicKey.Read(self.sign_public)
        return key_public.Verify(data, signature)

    @staticmethod
    def create_from_changeset(changeset, stub=None, update_sender=None, update_recipient=None, kind=None, request_sources=True):
        """See Serializable.create_from_changeset

        Args:
            kind (class): Pass a class to create new entity from
            request_sources (bool): Request linked objects
        """
        request_list = list()

        modified_dt = iso8601.parse_date(changeset["modified"]).replace(tzinfo=None)

        if stub:
            ident = stub
            ident.id = changeset["id"]
            ident.username = changeset["username"]
            ident.crypt_public = changeset["crypt_public"]
            ident.sign_public = changeset["sign_public"]
            ident.modified = modified_dt
            ident._stub = False
        else:
            if kind is None:
                kind = Identity

            ident = kind(
                id=changeset["id"],
                username=changeset["username"],
                crypt_public=changeset["crypt_public"],
                sign_public=changeset["sign_public"],
                modified=modified_dt,
            )

        # Update profile
        profile = Starmap.query.get(changeset["profile_id"])
        if profile is None or profile.get_state() == -1:
            request_list.append({
                "type": "Starmap",
                "id": changeset["profile_id"],
                "author_id": update_recipient.id if update_recipient else None,
                "recipient_id": update_sender.id if update_sender else None,
                "id": changeset["profile_id"]
            })

        if profile is None:
            profile = Starmap(id=changeset["profile_id"])
            profile.state = -1

        ident.profile = profile

        logger.info("Created {} from changeset, now requesting {} linked objects".format(
            ident, len(request_list)))

        if request_sources:
            for req in request_list:
                request_objects.send(Identity.create_from_changeset, message=req)
        else:
            logger.info("Not requesting linked sources for new {}".format(kind))

        return ident

    def update_from_changeset(self, changeset, update_sender=None, update_recipient=None):
        """See Serializable.update_from_changeset"""
        request_list = list()

        # Update modified
        modified_dt = iso8601.parse_date(changeset["modified"]).replace(tzinfo=None)
        self.modified = modified_dt

        # Update username
        if "username" in changeset:
            self.username = changeset["username"]
            logger.info("Updated {}'s {}".format(self.username, "username"))

        # Update profile
        if "profile_id" in changeset:
            profile = Starmap.query.get(changeset["profile_id"])
            if profile is None or profile.get_state() == -1:
                request_list.append({
                    "type": "Starmap",
                    "id": changeset["profile_id"],
                    "author_id": update_recipient.id,
                    "recipient_id": update_sender.id,
                })
                logger.info("Requested {}'s {}".format(self.username, "profile starmap"))
            else:
                self.profile = profile
                logger.info("Updated {}'s {}".format(self.username, "profile starmap"))

        logger.info("Updated {} identity from changeset. Requesting {} objects.".format(self, len(request_list)))

        for req in request_list:
            request_objects.send(Identity.create_from_changeset, message=req)

#
# Setup follower relationship on Persona objects
#

t_contacts = db.Table('contacts',
    db.Column('left_id', db.String(32), db.ForeignKey('persona.id')),
    db.Column('right_id', db.String(32), db.ForeignKey('persona.id')),
    db.UniqueConstraint('left_id', 'right_id', name='_uc_contacts')
)

t_groups_followed = db.Table('groups_followed',
    db.Column('persona_id', db.String(32), db.ForeignKey('persona.id')),
    db.Column('group_id', db.String(32), db.ForeignKey('group.id'))
)


class Persona(Identity):
    """A Persona represents a user profile

    Attributes:
        email: An email address, max 120 bytes
        contacts: List of this Persona's contacts
        index: Starmap containing all Star's this Persona publishes to its contacts
        myelin_offset: Datetime of last request for Vesicles sent to this Persona

    """
    __mapper_args__ = {
        'polymorphic_identity': 'persona'
    }

    _insert_required = Identity._insert_required + ["email", "index_id", "contacts", "groups"]
    _update_required = Identity._update_required

    id = db.Column(db.String(32), db.ForeignKey('identity.id'), primary_key=True)
    email = db.Column(db.String(120))
    auth = db.Column(db.String(32), default=uuid4().hex)
    session_id = db.Column(db.String(32), default=uuid4().hex)
    last_connected = db.Column(db.DateTime, default=datetime.datetime.now())

    contacts = db.relationship('Persona',
        secondary='contacts',
        lazy="dynamic",
        remote_side='contacts.c.right_id',
        primaryjoin='contacts.c.left_id==persona.c.id',
        secondaryjoin='contacts.c.right_id==persona.c.id')

    groups_followed = db.relationship('Group',
        secondary='groups_followed',
        primaryjoin='groups_followed.c.persona_id==persona.c.id',
        secondaryjoin='groups_followed.c.group_id==group.c.id')

    index_id = db.Column(db.String(32), db.ForeignKey('starmap.id'))
    index = db.relationship('Starmap', primaryjoin='starmap.c.id==persona.c.index_id')

    # Myelin offset stores the date at which the last Vesicle receieved from Myelin was created
    myelin_offset = db.Column(db.DateTime)

    def __repr__(self):
        try:
            name = self.username.encode('utf-8')
        except AttributeError:
            name = ""
        return "<Persona @{} [{}]>".format(name, self.id[:6])

    def activate(self):
        if current_user.is_anonymous:
            return UnauthorizedError("Need to be logged in to activate Personas")

        if not self.associations[0].user == current_user:
            raise UnauthorizedError("You can't activate foreign Personas")

        for asc in PersonaAssociation.query.filter(PersonaAssociation.user==current_user, PersonaAssociation.active==True):
            if asc.active and not (asc == self.associations[0]):
                asc.active = False
                db.session.add(asc)

        self.associations[0].active = True
        db.session.add(self.associations[0])
        db.session.commit()

    def authorize(self, action, author_id=None):
        """Return True if this Persona authorizes `action` for `author_id`

        Args:
            action (String): Action to be performed (see Synapse.CHANGE_TYPES)
            author_id (String): Persona ID that wants to perform the action

        Returns:
            Boolean: True if authorized
        """
        if Identity.authorize(self, action, author_id=author_id):
            return (self.id == author_id)
        return False

    def get_email_hash(self):
        """Return sha256 hash of this user's email address"""
        return sha256(self.email).hexdigest()

    def get_absolute_url(self):
        return url_for('persona', id=self.id)

    def export(self, exclude=[], include=None, update=False):
        exclude = set(exclude + ["contacts", "groups", "groups_followed"])
        data = Identity.export(self, exclude=exclude, include=include, update=update)

        data["contacts"] = list()
        for contact in self.contacts:
            data["contacts"].append({
                "id": contact.id,
            })

        data["groups"] = list()
        for group in self.groups:
            data["groups"].append({
                "id": group.id,
            })

        data["groups_followed"] = list()
        for group in self.groups_followed:
            data["groups_followed"].append({
                "id": group.id
            })

        return data

    def reset(self):
        """Reset session_id"""
        self.session_id = uuid4().hex
        self.auth = uuid4().hex
        return self.session_id

    def timeout(self):
        return self.last_connected + current_app.config['SESSION_EXPIRATION_TIME']

    @staticmethod
    def request_persona(persona_id):
        """Return a Persona profile, loading it from Glia if neccessary

        Args:
            persona_id (String): ID of the required Persona

        Returns:
            Persona: If a record was found
            None: If no record was found
        """
        from synapse import ElectricalSynapse
        electrical = ElectricalSynapse()
        return electrical.get_persona(persona_id)

    @staticmethod
    def create_from_changeset(changeset, stub=None, update_sender=None, update_recipient=None):
        """See Serializable.create_from_changeset"""
        p = Identity.create_from_changeset(changeset,
            stub=stub, update_sender=update_sender, update_recipient=update_recipient, kind=Persona)

        request_list = list()

        p.email = changeset["email"]

        # Update index
        index = Starmap.query.get(changeset["index_id"])
        if index is None or index.get_state() == -1:
            request_list.append({
                "type": "Starmap",
                "id": changeset["index_id"],
                "author_id": update_recipient.id,
                "recipient_id": update_sender.id,
            })

        if index is None:
            index = Starmap(id=changeset["index_id"])
            index.state = -1

        p.index = index

        # Update contacts
        missing_contacts = p.update_contacts(changeset["contacts"])
        for mc in missing_contacts:
            request_list.append({
                "type": "Persona",
                "id": mc,
                "author_id": update_recipient.id,
                "recipient_id": update_sender.id,
            })

        # Request unknown groups
        groups_to_check = set(changeset["groups"] + changeset["groups_followed"])
        for group_info in groups_to_check:
            group = Group.query.get(group_info["id"])
            if group is None:
                request_list.append({
                    "type": "Group",
                    "id": group_info["id"],
                    "author_id": update_recipient.id,
                    "recipient_id": update_sender.id,
                })

        logger.info("Made {} a Persona object, now requesting {} linked objects".format(
            p, len(request_list)))

        for req in request_list:
            request_objects.send(Persona.create_from_changeset, message=req)

        return p

    def update_from_changeset(self, changeset, update_sender=None, update_recipient=None):
        """See Serializable.update_from_changeset"""
        Identity.update_from_changeset(self, changeset,
            update_sender=update_sender, update_recipient=update_recipient)

        logger.info("Now applying Persona-specific updates for {}".format(self))

        request_list = list()

        # Update email
        if "email" in changeset:
            if isinstance(changeset["email"], str):
                self.email = changeset["email"]
                logger.info("Updated {}'s {}".format(self.username, "email"))
            else:
                logger.warning("Invalid `email` supplied in update for {}\n\n".format(
                    self, changeset))

        # Update index
        if "index_id" in changeset:
            index = Starmap.query.get(changeset["index_id"])
            if index is None or index.get_state() == -1:
                request_list.append({
                    "type": "Starmap",
                    "id": changeset["index_id"],
                    "author_id": update_recipient.id,
                    "recipient_id": update_sender.id,
                })
                logger.info("Requested {}'s new {}".format(self.username, "index starmap"))
            else:
                self.index = index
                logger.info("Updated {}'s {}".format(self.username, "index starmap"))

        # Update contacts
        if "contacts" in changeset:
            missing_contacts = self.update_contacts(changeset["contacts"])
            for mc in missing_contacts:
                request_list.append({
                    "type": "Persona",
                    "id": mc,
                    "author_id": update_recipient.id,
                    "recipient_id": update_sender.id,
                })

        # Request unknown groups
        groups_1 = changeset.get('groups') or []
        groups_2 = changeset.get('groups_followed') or []
        groups_to_check = groups_1 + groups_2

        for group_info in groups_to_check:
            group = Group.query.get(group_info["id"])
            if group is None:
                request_list.append({
                    "type": "Group",
                    "id": group_info["id"],
                    "author_id": update_recipient.id,
                    "recipient_id": update_sender.id,
                })

        logger.info("Updated {} from changeset. Requesting {} objects.".format(self, len(request_list)))

        for req in request_list:
            request_objects.send(Persona.update_from_changeset, message=req)

    def update_contacts(self, contact_list):
        """Update Persona's contacts from a list of the new contacts

        Args:
            contact_list (list): List of dictionaries with keys:
                id (String) -- 32 byte ID of the contact

        Returns:
            list: List of missing Persona IDs to be requested
        """
        updated_contacts = 0
        request_list = list()

        # stale_contacts contains all old contacts at first, all current
        # contacts get then removed so that the remaining can get deleted
        stale_contacts = set(self.contacts)

        for contact in contact_list:
            c = Persona.query.get(contact["id"])

            if c is None:
                c = Persona(id=contact["id"], _stub=True)

            if c._stub is True:
                request_list.append(contact["id"])

            try:
                # Old and new contact; remove from stale list
                stale_contacts.remove(c)
            except KeyError:
                # New contact
                self.contacts.append(c)
                updated_contacts += 1

        # Remove old contacts that are not new contacts
        for contact in stale_contacts:
            self.contacts.remove(contact)

        logger.info("Updated {}'s contacts: {} added, {} removed, {} requested".format(
            self.username, updated_contacts, len(stale_contacts), len(request_list)))

        return request_list

    @property
    def user(self):
        return self.associations[0].user

    def toggle_following_group(self, group):
        """Toggle whether this Persona is following a group.

        Args:
            group (Group): Group entity to be (un)followed

        Returns:
            boolean -- True if the group is now being followed, False if not
        """
        following = False

        try:
            self.groups_followed.remove(group)
            logger.info("{} is not following {} anymore".format(self, group))
        except ValueError:
            self.groups_followed.append(group)
            following = True
            logger.info("{} is now following {}".format(self, group))

        return following

    def toggle_group_membership(self, group):
        """Toggle whether this Persona is member of a group.

        Also enables group following for this Persona/Group.

        Args:
            group (Group): Group entity to be become member of

        Returns:
            Updated GroupMemberAssociation object or None if it was deleted
        """
        if group not in self.groups_followed:
            logger.info("Setting {} to follow {}.".format(self, group))
            self.toggle_following_group(group)

        gms = GroupMemberAssociation.query.filter_by(group_id=group.id). \
            filter_by(persona_id=self.id).first()

        if gms is None:
            logger.info("Enabling membership of {} in {}".format(self, group))
            gms = GroupMemberAssociation(
{'fraction_non_alphanumeric': 0.0992515230635335, 'fraction_numerical': 0.004873803307223673, 'mean_word_length': 2.324903061519764, 'pattern_counts': {'":': 72, '<': 9, '<?xml version=': 0, '>': 11, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'dir': 'git-commits-cleaned', 'id': '3553079', 'n_tokens_mistral': 16193, 'n_tokens_neox': 15367, 'n_words': 4118}