Ejemplo n.º 1
0
class ReputationEnum(Base, EnumMixin):
    """An enum for a ReputationChange.

    Inherits
    --------
    EnumMixin

    Attributes
    ----------
    default_delta : int
        An integer representing the default reputation change value. That is to
        say, the amount by which the event will change the user's reputation.
    """
    default_delta = db.Column(db.Integer, nullable=False)
    entity = db.Column(db.String(128))
    display = db.Column(db.String(128))
Ejemplo n.º 2
0
class TagRequest(Base, FollowableMixin, VotableMixin):
    __vote__ = TagRequestVote
    __reputable__ = 'requester'
    __approvable__ = 'approve_tag_requests'
    __margin_approvable__ = 'VOTES_FOR_REQUEST_APPROVAL'
    __margin_rejectable__ = 'VOTES_FOR_REQUEST_REJECTION'

    tag = db.Column(db.String(127), index=True)

    weight = db.Column(db.Integer, default=0, index=True)
    approved = db.Column(db.Boolean, default=False, index=True)
    rejected = db.Column(db.Boolean, default=False, index=True)
    timestamp = db.Column(db.DateTime, index=True, default=datetime.utcnow)

    wiki_id = db.Column(db.Integer, db.ForeignKey('wiki.id'), nullable=False)
    wiki = db.relationship('Wiki', backref=backref('tagrequest', uselist=False))

    requester_id = db.Column(db.Integer, db.ForeignKey('user.id'), index=True)
    requester = db.relationship('User', backref='tag_requests')

    @property
    def url(self):
        return url_for('requests.view_tag_request', request_id=self.id)

    def __init__(self, *args, **kwargs):
        description = kwargs.pop('description', None)
        description = "This wiki is blank." if not description else description
        super().__init__(*args, **kwargs)
        self.wiki = Wiki(body=description, entity_string=str(self))

    def __repr__(self):
        return f'<Request for {self.tag}>'
Ejemplo n.º 3
0
class EnumMixin:
    """Any enumerated class that has more than 4 types should be use this
    EnumMixin. LineEnum and Right seem to be the biggest examples.

    Attributes
    ----------
    enum : str
        A string for holding an enumerated type.
    """
    enum = db.Column(db.String(128), index=True)

    def __repr__(self):
        return f"<{type(self).__name__} {self.enum}>"

    def __str__(self):
        if hasattr(self, 'display'):
            return self.display
        else:
            return self.enum
Ejemplo n.º 4
0
class Wiki(Base):
    """An actual wiki. It has some idiosyncracies I'm not fond of, notably in
    init_on_load. But it's modelled after my Annotation system.
    """
    entity_string = db.Column(db.String(191), index=True)

    current = db.relationship('WikiEdit',
                              primaryjoin='and_(WikiEdit.entity_id==Wiki.id,'
                              'WikiEdit.current==True)',
                              uselist=False,
                              lazy='joined')
    edits = db.relationship('WikiEdit',
                            primaryjoin='WikiEdit.entity_id==Wiki.id',
                            lazy='dynamic')
    edit_pending = db.relationship(
        'WikiEdit',
        primaryjoin='and_(WikiEdit.entity_id==Wiki.id,'
        'WikiEdit.approved==False, WikiEdit.rejected==False)',
        passive_deletes=False)

    @orm.reconstructor
    def __init_on_load__(self):
        """The primary purpose of this method is to load the entity reference.
        """
        # This is a hack and needs to be improved.
        # Basically, we rely on the backref defined on the entity in order to
        # get the entity. I really want to make this more explicit and less
        # hacky.
        self.entity = list(
            filter(None, [
                self.writer, self.text, self.tag, self.edition,
                self.textrequest, self.tagrequest
            ]))[0]

    def __init__(self, *args, **kwargs):
        """Creating a new wiki also populates the first edit."""
        body = kwargs.pop('body', None)
        body = 'This wiki is currently blank.' if not body else body
        super().__init__(*args, **kwargs)
        self.versions.append(
            WikiEdit(current=True,
                     body=body,
                     approved=True,
                     reason='Initial Version.'))

    def __repr__(self):
        return f'<Wiki HEAD {str(self.entity)} at version {self.current.num}>'

    def edit(self, editor, body, reason):
        """Edit the wiki, creatinga new WikiEdit."""
        edit = WikiEdit(wiki=self,
                        num=self.current.num + 1,
                        editor=editor,
                        body=body,
                        reason=reason)
        db.session.add(edit)
        if editor.is_authorized('immediate_wiki_edits'):
            edit.approved = True
            self.current.current = False
            edit.current = True
            flash("The edit has been applied.")
        else:
            flash("The edit has been submitted for peer review.")
Ejemplo n.º 5
0
class TOC(EnumeratedMixin, Base):
    num = db.Column(db.Integer, index=True)
    precedence = db.Column(db.Integer, default=1, index=True)
    body = db.Column(db.String(200), index=True)
    haslines = db.Column(db.Boolean, index=True, default=False)

    prev_id = db.Column(db.Integer, db.ForeignKey('toc.id'), index=True)
    prev = db.relationship('TOC',
                           uselist=False,
                           remote_side='TOC.id',
                           foreign_keys=[prev_id],
                           backref=backref('next',
                                           uselist=False,
                                           remote_side='TOC.prev_id'))

    parent_id = db.Column(db.Integer, db.ForeignKey('toc.id'), index=True)
    parent = db.relationship('TOC',
                             uselist=False,
                             remote_side='TOC.id',
                             foreign_keys=[parent_id],
                             backref=backref('children',
                                             remote_side='TOC.parent_id',
                                             lazy='dynamic'))

    edition_id = db.Column(db.Integer, db.ForeignKey('edition.id'), index=True)
    edition = db.relationship('Edition',
                              backref=backref('toc', lazy='dynamic'))
    text = association_proxy('edition', 'text')

    @property
    def url(self):
        if self.haslines:
            return url_for('main.read',
                           text_url=self.text.url_name,
                           edition_num=self.edition.num,
                           toc_id=self.id)
        return None

    @property
    def section(self):
        parents = self.parents
        nums = [str(p.num) for p in parents]
        nums.append(str(self.num))
        return '.'.join(nums)

    @property
    def parents(self):
        def _parents(toc, struct):
            """Recursive function for the purpose of this method."""
            struct.insert(0, toc)
            if toc.parent:
                return _parents(toc.parent, struct)
            else:
                return struct

        if self.parent:
            return _parents(self.parent, [])
        else:
            return []

    def __repr__(self):
        s = ['<']
        for parent in self.parents:
            s.append(f'{parent.enum} {parent.num} ')
        s.append(f'{self.enum} {self.num} of {self.edition}>')
        return ''.join(s)

    def __str__(self):
        return self.body
Ejemplo n.º 6
0
class Text(Base, FollowableMixin, LinkableMixin, SearchLinesMixin):
    __linkable__ = 'title'
    """The text-object. A text is more a categorical, or philosophical concept.
    In essence, a book can have any number of editions, ranging from different
    translations to re-edited or updated versions (which is more common with
    non-fiction texts, and usually just consists of an added
    preface/introduction).

    Because of this dynamic, a text can is just a shell with a wiki that
    editions point to. Editions are the heart of the program. But the Text has a
    primary edition to which all annotations should be directed for a work
    unless they regard textual issues that differ between translations or
    editions.

    Attributes
    ----------
    title : string
        The title string of the object
    sort_title : string
        A string by which these objects will be sorted (i.e., so that it doesn't
        contain modifier words like "the" or "a".
    wiki_id : int
        The id of the wiki object.
    published : Date
        A date corresponding to the publication date of the first edition of the
        text irl.
    timestamp : DateTime
        This is just a timestamp marking when the text was added to the
        database. It's really unnecessary, tbh. Eventually to be eliminated.
    followers : BaseQuery
        An SQLA BaseQuery object for all the followers of the text.
    wiki : :class:`Wiki`
        A wiki object describing the text.
    editions : BaseQuery
        An SQLA BaseQuery object for all the editions pointing to this text.
    primary : :class:`Edition`
        An edition object that represents the primary edition of the text (the
        one we direct all annotations to unless they're about textual issues,
        etc.)
    url : string
        A url string for the main view page of the text object.
    url_name : string
        A string that can be used in a url for the text_url parameter.
    """
    @classmethod
    def get_by_url(cls, url):
        """This is a helper function that takes the output of url_name and
        uses it to get the object that matches it.
        """
        return cls.query.filter_by(title=url.replace('_', ' '))

    @classmethod
    def get_object_by_link(cls, name):
        """Get the object by the name."""
        if not cls.__linkable__:
            raise AttributeError("Class does not have a __linkable__ "
                                 "attribute.")
        obj = cls.query.filter(getattr(cls, cls.__linkable__) == name).first()
        return obj

    @classmethod
    def link(cls, name):
        """Override the LinkableMixin link method."""
        idents = name.split(':')
        obj = cls.get_object_by_link(idents[0])
        if not obj:
            return name
        else:
            if len(idents) == 1:
                return f'<a href="{obj.url}">{name}</a>'
            else:
                try:
                    return obj.link_edition(idents[1:])
                except AttributeError:
                    return name

    def link_edition(self, idents):
        """This is specifically to link other editions and line numbers."""
        edition_num = idents[0] if idents[0].isdigit() else 1
        edition = self.editions.filter_by(num=edition_num).first()
        if not edition:
            raise AttributeError("No edition.")
        line_nums = None
        for ident in idents:
            if 'l' in ident:
                line_nums = ident
        if line_nums:
            url = url_for('main.lines',
                          text_url=self.url_name,
                          edition_num=edition_num,
                          nums=line_nums)
            return f"<a href=\"{url}\">{str(edition)} {line_nums}</a>"
        else:
            return f"<a href=\"{edition.url}\">{str(edition)}</a>"

    title = db.Column(db.String(128), index=True)
    sort_title = db.Column(db.String(128), index=True)
    wiki_id = db.Column(db.Integer, db.ForeignKey('wiki.id'), nullable=False)
    published = db.Column(db.Date)
    timestamp = db.Column(db.DateTime, default=datetime.utcnow())

    wiki = db.relationship('Wiki', backref=backref('text', uselist=False))
    editions = db.relationship('Edition', lazy='dynamic')
    primary = db.relationship(
        'Edition',
        primaryjoin='and_(Edition.text_id==Text.id,Edition.primary==True)',
        uselist=False)

    @property
    def url(self):
        """This returns the actual internally-resolved url for the text's main
        view page.
        """
        return url_for('main.text', text_url=self.url_name)

    @property
    def url_name(self):
        """Returns the name of the object (title) translated in to a
        url-utilizable string (i.e., no spaces).

        Notes
        -----
        This method right now is very simple. The issue might become more
        complex when we have works with titles that have punctuation marks in
        them. eventually we might have to modify this method to translate those
        characters into url-acceptable characters (e.g., escape them).
        """
        return self.title.replace(' ', '_')

    def __init__(self, *args, **kwargs):
        """This init method creates a wiki for the object with the supplied
        description.
        """
        description = kwargs.pop('description', None)
        description = "This wiki is blank." if not description else description
        super().__init__(*args, **kwargs)
        self.wiki = Wiki(body=description, entity_string=str(self))

    def __repr__(self):
        return f'<Text {self.id}: {self.title}>'

    def __str__(self):
        return self.title
Ejemplo n.º 7
0
class Writer(Base, FollowableMixin, LinkableMixin, SearchLinesMixin):
    """The writer model. This used to be a lot more complicated but has become
    fairly elegant. All historical contributors to the text are writers, be they
    editors, translators, authors, or whatever the heck else we end up coming up
    with (perhaps with the exception of annotator, we'll see).

    Attributes
    ----------
    name : string
        The full name of the writer
    family_name : string
        The family name of the writer. We store this as an actual value in the
        db instead of computing it on the fly because of varying naming
        conventions (e.g., Chinese names). And we need it for sorting purposes.
    birth_date : Date
        The birthdate of the writer.
    death_date : Date
        The deathdate of the writer.
    wiki_id : int
        The id of the descriptive wiki object.
    timestamp : datetime
        The timestamp of when the writer was added to the database. Superfluous.
    followers : BaseQuery
        An SQLA BaseQuery of all of the followers of the writer.
    connections : BaseQuery
        An SQLA BaseQuery of all of the connection objects for the writer (i.e.,
        the writer's relationships to various editions).
    wiki : :class:`Wiki`
        The descriptive wiki object.
    annotations : BaseQuery
        An SQLA BaseQuery for all of the annotations on all of the editions the
        writer is responsible for.
    """
    __linkable__ = 'name'

    name = db.Column(db.String(128), index=True)
    family_name = db.Column(db.String(128), index=True)
    birth_date = db.Column(db.Date, index=True)
    death_date = db.Column(db.Date, index=True)
    wiki_id = db.Column(db.Integer, db.ForeignKey('wiki.id'), nullable=False)
    timestamp = db.Column(db.DateTime, index=True, default=datetime.utcnow)

    connections = db.relationship('WriterConnection', lazy='dynamic')
    wiki = db.relationship('Wiki', backref=backref('writer', uselist=False))
    annotations = db.relationship(
        'Annotation',
        secondary='join(WriterConnection, Edition)',
        primaryjoin='Writer.id==WriterConnection.writer_id',
        secondaryjoin='Annotation.edition_id==Edition.id',
        lazy='dynamic')

    @property
    def url(self):
        """The url for the main view page for the writer."""
        return url_for('main.writer', writer_url=self.url_name)

    @property
    def url_name(self):
        """A string that can be used for urls (i.e., it has all the spaces
        replaced with underscores.
        """
        return self.name.replace(' ', '_')

    def __init__(self, *args, **kwargs):
        """Creates a descriptive wiki as well."""
        description = kwargs.pop('description', None)
        description = 'This writer does not have a biography yet.'\
            if not description else description
        super().__init__(*args, **kwargs)
        self.wiki = Wiki(body=description, entity_string=str(self))

    @orm.reconstructor
    def __init_on_load__(self):
        """Creates a dictionary mapping the writer's role to the editions."""
        self.works = defaultdict(list)
        for conn in self.connections.all():
            self.works[conn.enum].append(conn.edition)

    def __repr__(self):
        return f'<Writer: {self.name}>'

    def __str__(self):
        return self.name

    @classmethod
    def get_by_url(cls, name):
        """This is a helper function that takes the output of url_name and
        uses it to get the object that matches it.
        """
        return cls.query.filter_by(name=url.replace('_', ' '))
Ejemplo n.º 8
0
class Edition(Base, FollowableMixin, SearchLinesMixin):
    """The Edition model. This is actually more central to the app than the Text
    object. The edition has all of the writer connections, annotations, and
    lines connected to it.

    Attributes
    ----------
    num : int
        The edition number of the edition. I.e., edition 1, 2, etc.
    primary : bool
        A boolean switch to represent whether or not the specific edition is the
        primary edition for it's parent text. Only one *should* be primary. If
        more than one is primary, this is an error and needs to be corrected.
    published : DateTime
        The publication date of the specific edition.
    timestamp : DateTime
        Simple timestamp for when the Edition was created.
    connections : BaseQuery
        A filterable BaseQuery of WriterConnections.
    text_title : str
        A proxy for the title of the text. Mostly used so that the Annotation
        can query it easily with it's own association_proxy.
    tochide : boolean
        A flag for disabling the showing of the toc dispay enum because the toc
        body is complete on it's own.
    verse : boolean
        A flag for disabling the line concatenation that happens on cell phones.
        It will eventually become (either) more useful, or less. Unsure yet.
    """
    _title = db.Column(db.String(235), default=None)
    num = db.Column(db.Integer, default=1)
    text_id = db.Column(db.Integer, db.ForeignKey('text.id'))
    primary = db.Column(db.Boolean, default=False)
    wiki_id = db.Column(db.Integer, db.ForeignKey('wiki.id'), nullable=False)
    published = db.Column(db.DateTime)
    timestamp = db.Column(db.DateTime, default=datetime.utcnow())

    tochide = db.Column(db.Boolean, default=True)
    verse = db.Column(db.Boolean, default=False)

    annotations = db.relationship('Annotation', lazy='dynamic')
    connections = db.relationship('WriterConnection', lazy='dynamic')
    wiki = db.relationship('Wiki', backref=backref('edition', uselist=False))
    text = db.relationship('Text')
    text_title = association_proxy('text', 'title')

    @property
    def url(self):
        """A string title of the edition. Basically consists of the parent
        text's title and an asterisk for the primary edition or 'Edition #'.
        """
        return url_for('main.edition',
                       text_url=self.text.url_name,
                       edition_num=self.num)

    @property
    def title(self):
        """Returns the title representation string of the edition."""
        if self._title:
            return f'{self._title}*' if self.primary else self._title
        return (f'{self.text_title}*'
                if self.primary else f"{self.text_title} - Ed. #{self.num}")

    @property
    def edition_title(self):
        """A string for the edition similar to title but *not* including the
        parent text's title.
        """
        if self._title:
            return f'{self._title}*' if self.primary else self._title
        return (f"Edition #{self.num} - Primary"
                if self.primary else f"Edition #{self.num}")

    @property
    def tocstruct(self):
        byparentid = defaultdict(list)
        for toc in self.toc:
            byparentid[toc.parent_id].append(toc)
        return byparentid

    def __init__(self, *args, **kwargs):
        """Creates a wiki for the edition with the provided description."""
        description = kwargs.pop('description', None)
        description = 'This wiki is blank.' if not description else description
        super().__init__(*args, **kwargs)
        self.wiki = Wiki(body=description, entity_string=str(self))

    @orm.reconstructor
    def __init_on_load__(self):
        """Creates a defaultdict of lists of writers based on their connection
        type (e.g., author, editor, translator, etc.)
        """
        self._create_writers()

    def _create_writers(self):
        """This creates the _writers attr. It's abstracted for use by writers()
        and __init_on_load__().
        """
        self._writers = defaultdict(list)
        for conn in self.connections.all():
            self._writers[conn.enum].append(conn.writer)

    @property
    def writers(self):
        """This by using a property to access the writerslist I ensure that it
        will always exist before it is accessed. This prevents the error I get
        when first creating this thing where I try to access writers and it
        doesn't exist when indexing on a first run through.
        """
        if not hasattr(self, '_writers'):
            self._create_writers()
        return self._writers

    def __repr__(self):
        return f'<{self.title}>'

    def __str__(self):
        return self.title

    def get_lines(self, nums):
        if len(nums) >= 2:
            line_query = self.lines.filter(Line.num >= nums[0],
                                           Line.num <= nums[-1])
        else:
            line_query = self.lines.filter(Line.num == nums[0])
        return line_query
Ejemplo n.º 9
0
class Tag(Base, FollowableMixin, LinkableMixin):
    __linkable__ = 'tag'
    """A class representing tags.

    Attributes
    ----------
    tag : str
        The name of the tag
    locked : bool
        The locked status of the tag (i.e., whether it is available for ordinary
        users to apply to their annotations).
    wiki_id : int
        The id of the tag's wiki.
    wiki : :class:`Wiki`
        The tag's :class:`Wiki` object.
    annotations : :class:`BaseQuery`
        A :class:`BaseQuery` object of all of the annotations which currently
        have this tag applied to them.
    """
    @classmethod
    def intersect(cls, tags):
        """Get the annotations at the intersection of multiple tags.

        Parameters
        ----------
        tags : tuple
            A tuple of strings corresponding to the names of the tags to be
            intersected.

        Returns
        -------
        :class:`BaseQuery`
            A :class:`BaseQuery` object that can be used to get all of the
            annotations.

        Raises
        ------
        TypeError
            If the first argument is not a tuple.
        TypeError
            If the elements of the tuple are not all strings.
        """
        if not isinstance(tags, tuple):
            raise TypeError('Tags argument must be a tuple of strings.')
        if not all(isinstance(tag, str) for tag in tags):
            raise TypeError("The tags tuple must consist of only strings.")

        queries = []
        for tag in tags:
            queries.append(cls.query.filter_by(tag=tag).first().annotations)
        query = queries[0].intersect(*queries[1:])
        return query

    @classmethod
    def union(cls, tags):
        """Get the annotations at the union of multiple tags.

        Parameters
        ----------
        tags : tuple
            A tuple of strings corresponding to the names of the tags to be
            unioned.

        Returns
        -------
        BaseQuery
            A :class:`BaseQuery` object that can be used to get all of the
            annotations.

        Raises
        ------
        TypeError
            If the first argument is not a tuple.
        TypeError
            If the elements of the tuple are not all strings.
        """
        if not isinstance(tags, tuple):
            raise TypeError('Tags argument must be a tuple of strings.')
        if not all(isinstance(tag, str) for tag in tags):
            raise TypeError("The tags tuple must consist of only strings.")

        queries = []
        for tag in tags:
            queries.append(cls.query.filter_by(tag=tag).first().annotations)
        query = queries[0].union(*queries[1:])
        return query

    tag = db.Column(db.String(128), index=True, unique=True)
    locked = db.Column(db.Boolean, default=False)
    wiki_id = db.Column(db.Integer, db.ForeignKey('wiki.id'), nullable=False)

    wiki = db.relationship('Wiki', backref=backref('tag', uselist=False))
    annotations = db.relationship(
        'Annotation',
        secondary='join(tags, Edit, and_(tags.c.edit_id==Edit.id,'
        'Edit.current==True))',
        primaryjoin='Tag.id==tags.c.tag_id',
        secondaryjoin='and_(Edit.entity_id==Annotation.id,'
        'Annotation.active==True)',
        lazy='dynamic')

    @property
    def url(self):
        """The url for the main view page for the tag."""
        return url_for('main.tag', tag=self.tag)

    def __init__(self, *args, **kwargs):
        """Creation of a tag also creates a :class:`Wiki`."""
        description = kwargs.pop('description', None)
        description = ("This tag has no description yet."
                       if not description else description)
        super().__init__(*args, **kwargs)
        self.wiki = Wiki(body=description, entity_string=str(self))

    def __repr__(self):
        return f'<Tag {self.id}: {self.tag}>'

    def __str__(self):
        return f'<div class="tag">{self.tag}</div>'
Ejemplo n.º 10
0
class EditMixin:
    """A mixin to store edits. This is to be used for anything that can be
    edited so that we can have an edit history.

    Notes
    -----
    Currently, the only methods I have implemented here are rollback and reject
    because of the different naming strategies of the votes. THIS CAN BE
    CORRECTED. I believe I have to investigate Meta classes or something like
    that to make sure that something is implemented or something. But this
    EditMixin can be tied in in such a way that those methods can be collapsed.
    I need to do this.

    Attributes
    ----------
    num : int
        The number of the edit in the edit history.
    current : bool
        A boolean indicating withether this is the current state of the parent
        object or not. There should only ever be one per object. There should
        always be at least one. If there are more than or less than one for an
        object THAT IS A PROBLEM.
    weight : int
        The weight of the object (basically the difference between upvotes and
        downvotes, but a bit more complicated than that.
    approved : bool
        Whether the edit has been approved or not.
    rejected : bool
        Whether the edit has been rejected or not. `approved` and `rejected`
        should not both be True, but they can both be False (namely, immediately
        after being created, before it is reviewed).
    reason : str
        A string explaining the reason for the edit. Will probably be abused and
        ignored. But good practice.
    timestamp : DateTime
        When the edit was made.
    body : str
        The actual body of the Edit.
    """
    num = db.Column(db.Integer, default=1)
    current = db.Column(db.Boolean, index=True, default=False)
    weight = db.Column(db.Integer, default=0)
    approved = db.Column(db.Boolean, index=True, default=False)
    rejected = db.Column(db.Boolean, index=True, default=False)
    reason = db.Column(db.String(191))
    timestamp = db.Column(db.DateTime, default=datetime.utcnow(), index=True)
    body = db.Column(db.Text)

    @declared_attr
    def editor_id(cls):
        """The id of the editor."""
        return db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False,
                         default=1)

    @declared_attr
    def editor(cls):
        """The actual user object of the editor. The backref is the class name
        lowercased with an `s` appended
        """
        return db.relationship(
            'User', backref=backref(f'{cls.__name__.lower()}s', lazy='dynamic'))

    @declared_attr
    def previous(cls):
        """The previous edit."""
        return db.relationship(
            f'{cls.__name__}',
            primaryjoin=f'and_(remote({cls.__name__}.entity_id)'
            f'==foreign({cls.__name__}.entity_id),'
            f'remote({cls.__name__}.num)==foreign({cls.__name__}.num-1),'
            f'remote({cls.__name__}.rejected)==False)')

    @declared_attr
    def priors(cls):
        """A list of all prior edits"""
        return db.relationship(
            f'{cls.__name__}',
            primaryjoin=f'and_(remote({cls.__name__}.entity_id)=='
            f'foreign({cls.__name__}.entity_id),'
            f'remote({cls.__name__}.num)<=foreign({cls.__name__}.num-1))',
            uselist=True)

    def __repr__(self):
        return f"<Edit {self.num} on "
Ejemplo n.º 11
0
class User(UserMixin, Base):
    """The User class.

    Inherits
    --------
    UserMixin

    Attributes
    ----------
    displayname : str
        We follow StackExchange in using displaynames that are non-unique and
        can be changed. The user is primarily defined by his email.
    email : str
        The primary string-based identifier of the user. Must be unique. Have
        not worked on email validation yet, need to.
    password_hash : str
        The FlaskLogin defined password hashing structure. I want to investigate
        the security of this and make modifications before going live (e.g.,
        check algorithm, salt, etc.).
    reputation : int
        The user's reputation. This is affected by :class:`ReputationChange`
        objects.
    locked : int
        This is a boolean to lock the user's account from logging in for
        security purposes.
    about_me : str
        A text of seemingly any length that allows the user to describe
        themselves. I might convert this to a wiki. Not sure if it's worth it or
        not. Probably not. About me history? Extreme. On the other hand, it
        leverages the existing wiki system just like Wikipedia does.
    last_seen : DateTime
        A timestamp for when the user last made a database-modification.
    rights : list
        A list of all of the :class:`AdminRight` objects the user has.
    annotations : BaseQuery
        An SQLA BaseQuery of all the annotations the user has authored.
    followed_<class>s : BaseQuery
        A BaseQuery of all of the <class>'s that the user is currently
        following. The <class> is the class's name lowercased (same as the table
        name).
    followers : BaseQuery
        A BaseQuery of all of the User's that follow this User.
    """
    displayname = db.Column(db.String(64), index=True)
    email = db.Column(db.String(128), index=True, unique=True)
    password_hash = db.Column(db.String(128))
    reputation = db.Column(db.Integer, default=0)
    locked = db.Column(db.Boolean, default=False)
    about_me = db.Column(db.Text)
    last_seen = db.Column(db.DateTime, default=datetime.utcnow)

    reputation_changes = db.relationship('ReputationChange', lazy='dynamic')
    rights = db.relationship('AdminRight', secondary='rights')
    annotations = db.relationship('Annotation', lazy='dynamic')
    active_flags = db.relationship(
        'UserFlag',
        primaryjoin='and_(User.id==UserFlag.user_id,'
        'UserFlag.resolver_id==None)',
        passive_deletes=True)

    # Because this is a self-referential many-to-many it is defined explicitly
    # as opposed to using my FollowableMixin
    followed_users = db.relationship(
        'User',
        secondary=db.Table(
            'user_flrs',
            db.Column('follower_id', db.Integer, db.ForeignKey('user.id')),
            db.Column('followed_id', db.Integer, db.ForeignKey('user.id'))),
        primaryjoin='user_flrs.c.follower_id==User.id',
        secondaryjoin='user_flrs.c.followed_id==User.id',
        backref=db.backref('followers', lazy='dynamic'),
        lazy='dynamic')

    @property
    def url(self):
        """A string that represents the url for this user object (i.e., the
        user's profile page.
        """
        return url_for("user.profile", user_id=self.id)

    @property
    def readable_reputation(self):
        """A string that represents a nicely formatted reputation of this user.
        """
        if self.reputation >= 1000000:
            return f'{round(self.reputation/1000000)}m'
        elif self.reputation >= 1000:
            return f'{round(self.reputation/1000)}k'
        else:
            return f'{self.reputation}'

    def avatar(self, size):
        """A link to the gravatar for this user. I will probably eventually want
        to simply eliminate avatars. Especially gravatars as they are a security
        vulnerability.
        """
        digest = md5(self.email.lower().encode('utf-8')).hexdigest()
        return f'https://www.gravatar.com/avatar/{digest}?d=identicon&s={size}'

    def __repr__(self):
        return f"<User {self.displayname}>"

    def __str__(self):
        return self.displayname

    def update_last_seen(self):
        """A method that will update the user's last seen timestamp."""
        self.last_seen = datetime.utcnow()

    def repchange(self, enumstring):
        """Change a user's reputation given the enumstring."""
        enum = ReputationEnum.query.filter_by(enum=enumstring).first()
        if not enum:
            return
        repchange = ReputationChange(enum=enum,
                                     user=self,
                                     delta=enum.default_delta)
        if self.reputation + repchange.delta <= 0:
            repchange.delta = -self.reputation
        self.reputation += repchange.delta
        return repchange

    def rollback_repchange(self, repchange):
        """Rollback a user's reputation given a reputationchange."""
        if not repchange:
            return
        if self.reputation - repchange.delta < 0:
            delta = -self.reputation
        else:
            delta = repchange.delta
        self.reputation -= delta
        db.session.delete(repchange)

    # Password routes
    def set_password(self, password):
        """Set the password for the user."""
        self.password_hash = generate_password_hash(password)

    def check_password(self, password):
        """Check that the password provided is the user's current password."""
        return check_password_hash(self.password_hash, password)

    def get_reset_password_token(self, expires_in=600):
        """Generate a reset_password token for the user's emailed link to reset
        the password.
        """
        return jwt.encode(
            {
                'reset_password': self.id,
                'exp': time() + expires_in
            },
            app.config['SECRET_KEY'],
            algorithm='HS256').decode('utf-8')

    @staticmethod
    def verify_reset_password_token(token):
        """A static method to verify a reset password token's validity."""
        try:
            id = jwt.decode(token,
                            app.config['SECRET_KEY'],
                            algorithms=['HS256'])['reset_password']
        except:
            return
        return User.query.get(id)

    # admin authorization methods
    def is_authorized(self, right):
        """Check if a user is authorized with a particular right."""
        r = AdminRight.query.filter_by(enum=right).first()
        if not r:
            return False
        return r in self.rights or (r.min_rep and self.reputation >= r.min_rep)

    def is_auth_all(self, rights):
        """This is like is_authorized but takes a list and only returns true if
        the user is authorized for all the rights in the list.
        """
        for right in rights:
            if not self.is_authorized(right):
                return False
        return True

    def is_auth_any(self, rights):
        """Tests if user is authorized for *any* of the rights in the list."""
        for right in rights:
            if self.is_authorized(right):
                return True
        return False

    def authorize(self, right):
        """Authorize a user with a right.

        Notes
        -----
        This is different from is_authorized (in fact, it uses is_authorized):
        this method will throw a 403 abort if the user is not authorized. It
        simplifies some logic.
        """
        if self.is_authorized(right):
            pass
        else:
            abort(403)

    def get_vote(self, obj):
        """Get the vote on the object. If the object does not have a __vote__
        attribute, it's not going to work and a TypeError will be raised.

        This method works for every votable class and can be tested as a Falsey
        for whether or whether not the user has voted on the object.
        """
        if not obj.__vote__:
            raise TypeError("The requested object is missing an `__vote__` "
                            "attribute.")
        vote_cls = obj.__vote__
        return vote_cls.query.filter(vote_cls.voter == self,
                                     vote_cls.entity == obj).first()