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}>'
class EditVote(Base, VoteMixin): """A vote on an :class:`Edit`. They are used to determine whether the edit should be approved. Only user's with a reputation above a certain threshold (or with a certain right) are supposed to be able to apply these (or review annotation edits at all). This might, or might not, be the case. I will have to add assurances. Attributes ---------- edit_id : int The id of the :class:`Edit` the vote is applied to. edit : :class:`Edit` The edit object the vote was applied to. The EditVote class also possesses all of the attributes of :class:`VoteMixin`. """ edit_id = db.Column(db.Integer, db.ForeignKey('edit.id'), index=True) entity = db.relationship('Edit', backref=backref('ballots', lazy='dynamic')) def __repr__(self): prefix = super().__repr__() return f"{prefix}{self.edit}"
def voter(cls): """The voter.""" # the backref is the name of the class lowercased with the word # `ballots` appended return db.relationship( 'User', backref=backref(f'{cls.__name__.lower()}_ballots', lazy='dynamic'))
class CommentVote(Base, VoteMixin): """A class that represents a vote on a comment.""" entity_id = db.Column(db.Integer, db.ForeignKey('comment.id'), index=True) entity = db.relationship('Comment') def __repr__(self): prefix = super().__repr__() return f"{prefix}{self.entity}"
def followers(cls): """Produces the relationship and backreference for followers.""" return db.relationship('User', secondary=f'{cls.__name__.lower()}_followers', backref=backref( f'followed_{cls.__name__.lower()}s', lazy='dynamic'), lazy='dynamic')
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 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)')
class TagRequestVote(Base, VoteMixin): tag_request_id = db.Column(db.Integer, db.ForeignKey('tagrequest.id'), index=True) entity = db.relationship('TagRequest', backref=backref('ballots')) def __repr__(self): prefix = super().__repr__() return f"{prefix}{self.tag_request}"
class CommentFlag(Base, FlagMixin): """A flag for a comment.""" entity_id = db.Column(db.Integer, db.ForeignKey('comment.id'), index=True) entity = db.relationship('Comment', backref=backref('flags', lazy='dynamic')) def __repr__(self): return (f'<CommentFlag on {self.entity_id} on' f'[{self.entity.annotation.id}]>')
class ReputationChange(Base): """An object representing a reputation change event. This thing exists to avoid mischanges to the reputation. It's a ledger so we can audit every rep change and the user can see the events. Attributes ---------- delta : int A number representing the change to the user's reputation. user_id : int The id of the user. timestamp : datetime The time the reputation change happened enum_id : int the id of the reputation change enum user : :class:`User` The user whose reputation was changed. enum : :class:`ReputationEnum` The enum of the particular rep change. """ delta = db.Column(db.Integer, nullable=False) user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) timestamp = db.Column(db.DateTime, default=datetime.utcnow) enum_id = db.Column(db.Integer, db.ForeignKey('reputationenum.id'), nullable=False) user = db.relationship('User', backref='changes') enum = db.relationship('ReputationEnum') type = association_proxy('enum', 'enum') def __repr__(self): return (f'<rep change {self.type} on {self.user.displayname} ' f'{self.timestamp}>') @orm.reconstructor def __init_on_load__(self): # this is cleaner than the entity population of the wiki system, but it # relies on the existence of the enum to call on. Since that is # necessary, wiki can't use the same method. self.entity = getattr(self, self.enum.entity.lower(), None)
class WikiEditVote(Base, VoteMixin): """A Wiki Edit Vote.""" edit_id = db.Column(db.Integer, db.ForeignKey('wikiedit.id'), index=True, nullable=False) entity = db.relationship('WikiEdit', backref=backref('ballots', passive_deletes=True)) def __repr__(self): prefix = super().__repr__() return f"{prefix}{self.edit}"
class WriterConnection(Base): """This is a proxy object that connects writers to editions. It resolves enum_id to the role of the writer's connection to the edition based on the WRITERS tuple. Attributes ---------- writer_id : int The id of the writer in the connection. edition_id : int The id of the edition in the connection. enum_id : int The id of the enumerated role of the connection. writer : :class:`Writer` The writer object in the connection edition : :class:`Edition` The edition object in the connection. enum : str The enumerated string of the writer's role in the connection. """ writer_id = db.Column(db.Integer, db.ForeignKey('writer.id')) edition_id = db.Column(db.Integer, db.ForeignKey('edition.id')) enum_id = db.Column(db.Integer) writer = db.relationship('Writer') edition = db.relationship('Edition') def __init__(self, *args, **kwargs): """Resolves the enum_id to the enum.""" super().__init__(*args, **kwargs) self.enum = WRITERS[self.enum_id] @orm.reconstructor def __init_on_load__(self): """Resolves the enum_id to the enum.""" self.enum = WRITERS[self.enum_id] def __repr__(self): return f'<{self.writer.name} was the {self.enum} of {self.edition}>'
class AnnotationFlag(Base, FlagMixin): """A flagging event for annotations. Uses the AnnotationFlagEnum for templating. Will need an update soon to overhaul the flagging system to be more public. Inherits -------- FlagMixin Attributes ---------- annotation_id : int The int of the :class:`Annotation` object the flag is applied to. entity : :class:`Annotation` The actual annotation. """ annotation_id = db.Column(db.Integer, db.ForeignKey('annotation.id'), index=True) entity = db.relationship('Annotation', backref=backref('flags', lazy='dynamic'))
class AnnotationVote(Base, VoteMixin): """A class that represents a user's vote on an annotation. Attributes ---------- reputationchange_id : int The id of the :class:`ReputationChange` object that accompanies the :class:`Vote`. entity : :class:`Annotation` The :class:`Annotation` the vote has been applied to. The Vote class also possesses all of the attributes of :class:`VoteMixin`. """ annotation_id = db.Column(db.Integer, db.ForeignKey('annotation.id'), index=True) entity = db.relationship('Annotation') def __repr__(self): prefix = super().__repr__() return f"{prefix}{self.entity}"
class WikiEdit(Base, EditMixin, VotableMixin): """A WikiEdit.""" __vote__ = WikiEditVote __reputable__ = 'editor' __approvable__ = 'immediate_wiki_edits' __margin_approvable__ = 'VOTES_FOR_APPROVAL' __margin_rejectable__ = 'VOTES_FOR_REJECTION' entity_id = db.Column(db.Integer, db.ForeignKey('wiki.id'), nullable=False) wiki = db.relationship('Wiki', backref=backref('versions', lazy='dynamic')) def __repr__(self): return f"<WikiEdit on {self.wiki}>" @property def url(self): if self.approved or self.rejected: return url_for('main.view_wiki_edit', wiki_id=self.wiki.id, num=self.num) else: return url_for('admin.review_wiki_edit', wiki_id=self.wiki.id, edit_id=self.id)
def resolver(cls): """The user who resolved the flag.""" return db.relationship('User', foreign_keys=[cls.resolver_id])
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>'
class Line(EnumeratedMixin, SearchableMixin, Base): """A line. This is actually a very important class. It is the only searchable type so far. Eventually this won't be the case. Then I'll have to remember to update this comment. I probably will forget... So if this comment is no longer true, please modify it. Attributes ---------- __searchable__ : list A list of strings that correspond to the attributes that should be indexed in elasticsearch. It's defined by :class:`SearchableMixin` so see that for more information. num : int The line number within the edition. em : str A string corresponding to an enumerated type describing emphasis status. See On Emphasis in the wiki toc : TOC The section the line is in in the TOC. See On the TOC in the Wiki. body : str The actual text of the line. Processed in my processor. text : Text An association proxy to the text for ease of reference. text_title : str The title of the parent text of the edition the line is in. edition : Edition The edition of the parent text. context : list A list of all line's surrounding lines (+/- 5 lines) annotations : BaseQuery An SQLA BaseQuery for all the annotations that contain this line in their target. It's a nasty relationship. """ __searchable__ = ['body', 'text_id', 'edition_id', 'writer_id'] num = db.Column(db.Integer, index=True) body = db.Column(db.Text) em_id = db.Column(db.Integer) toc_id = db.Column(db.Integer, db.ForeignKey('toc.id'), index=True) toc = db.relationship('TOC', backref=backref('lines', lazy='dynamic')) edition_id = db.Column(db.Integer, db.ForeignKey('edition.id'), index=True) edition = db.relationship('Edition', backref=backref('lines', lazy='dynamic')) text = association_proxy('edition', 'text') text_id = association_proxy('edition', 'text_id') context = db.relationship('Line', primaryjoin='and_(remote(Line.num)<=Line.num+1,' 'remote(Line.num)>=Line.num-1,' 'remote(Line.edition_id)==Line.edition_id)', foreign_keys=[num, edition_id], remote_side=[num, edition_id], uselist=True, viewonly=True) annotations = db.relationship( 'Annotation', secondary='edit', primaryjoin='and_(Edit.first_line_num<=foreign(Line.num),' 'Edit.last_line_num>=foreign(Line.num),' 'Edit.edition_id==foreign(Line.edition_id),Edit.current==True)', secondaryjoin='and_(foreign(Edit.entity_id)==Annotation.id,' 'Annotation.active==True)', foreign_keys=[num, edition_id], uselist=True, lazy='dynamic') @property def writers(self): return self.edition.writers @property def writer_id(self): out = [] for tp in self.writers.values(): for writer in tp: out.append(writer.id) return out @property def url(self): """The url for the smallest precedence section to read, in is the line. """ return self.toc.url def __init__(self, *args, **kwargs): self.em_id = EMPHASIS.index(kwargs.pop('em')) super().__init__(*args, **kwargs) @orm.reconstructor def __init_on_load__(self): """Resolves the em_id to the line's emphasis status.""" self.em = EMPHASIS[self.em_id] def __repr__(self): return (f"<l{self.num} {self.edition}>")
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.")
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'))
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
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('_', ' '))
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
class Edit(Base, EditMixin, VotableMixin): """The Edit class, which represents the current state of an :class:`Annotation`. An annotation object is just a HEAD, like in git. Or, rather, a tag? I can't remember how git's model works, but essentially, the annotation object just serves as a pointer to it's current edit. Attributes ---------- edition_id : int The id of the edition to which the annotation is applied. This column is *technically* redundant, but it simplifies some operations. I may eventually (probably sooner than later, actually, now that I know about SQLA's association_proxy), eliminate this column. entity_id : int This points back at the edit's annotation. I do not have a clue why I titled it entity_id. This seems like it could, and should, change. It used to be called the pointer_id. I guess entity_id is a step up from that? I'll make it annotation_id after I finish documenting this and working through other problems. first_line_num : int This corresponds to the :class:`Line` `num` that corresponds to the first line of the target of the annotation's current version, *not* it's id. This is because I do not want the annotation to point to an abstract id, but to a line in a book. Because I absolutely do not want to make this fragile. I want a robust ability to export the annotations. This may be silly, and eventually someone can convince me it is, especially given association_proxies, etc. But for now it's staying like this. last_line_num : int The same as the first_line_num, but the last of the target. first_char_idx : int The string-index of the first character of the first line which corresponds to the character-by-character target of the annotation. I am not currently storing anything but 0 here. Once we write the JavaScript corresponding to char-by-char annotation target selection, this will change. Note: this method seems fragile to me, and I am nervous about it. If I ever begin to edit lines, these could become de-indexed improperly. Then we could get out-of-bounds exceptions all over the place and fail to render pages. I am interested in a more robust solution to this. last_char_idx : int The *last* character of the last line of the target of the annotation. Read first_char_idx for an explanation. Note: I would like for this to always be a negative number. This could *also* result in problems. But I feel like it would be better to reverse index the last character than to forward index the last character. It *feels* more robust. edition : :class:`Edition` The edition object the annotation is applied to. This will become an association_proxy when I get off my fat behind and take care of that. Probably pretty soon. annotation : :class:`Annotation` The annotation the edit is applied to. lines : list A list of all of the lines that are the target of the edit. context : list A list of all the lines that are the target of the edit *plus* five lines on either side of the first and last lines of the target lines. """ __vote__ = EditVote __reputable__ = 'editor' __approvable__ = 'immediate_edits' __margin_approvable__ = 'VOTES_FOR_APPROVAL' __margin_rejectable__ = 'VOTES_FOR_REJECTION' edition_id = db.Column(db.Integer, db.ForeignKey('edition.id'), index=True) edition = db.relationship('Edition') toc_id = db.Column(db.Integer, db.ForeignKey('toc.id'), index=True) toc = db.relationship('TOC') entity_id = db.Column(db.Integer, db.ForeignKey('annotation.id'), index=True) timestamp = db.Column(db.DateTime, default=datetime.utcnow()) first_line_num = db.Column(db.Integer, db.ForeignKey('line.num')) last_line_num = db.Column(db.Integer, db.ForeignKey('line.num'), index=True) first_char_idx = db.Column(db.Integer) last_char_idx = db.Column(db.Integer) annotation = db.relationship('Annotation') tags = db.relationship('Tag', secondary='tags') lines = db.relationship( 'Line', primaryjoin='and_(Line.num>=Edit.first_line_num,' 'Line.num<=Edit.last_line_num, Line.toc_id==Edit.toc_id)', uselist=True, foreign_keys=[toc_id, first_line_num, last_line_num]) context = db.relationship( 'Line', primaryjoin=f'and_(Line.num>=Edit.first_line_num-{CONTEXT},' f'Line.num<=Edit.last_line_num+{CONTEXT},' 'Line.toc_id==Edit.toc_id)', uselist=True, viewonly=True, foreign_keys=[toc_id, first_line_num, last_line_num]) @property def first_line(self): return self.lines[0] @orm.reconstructor def __init_on_load__(self): """Create the hash_id that recognizes when an edit differs from it's previous version to prevent dupe edits. """ s = (f'{self.first_line_num},{self.last_line_num},' f'{self.first_char_idx},{self.last_char_idx},' f'{self.body},{self.tags}') self.hash_id = sha1(s.encode('utf8')).hexdigest() def __init__(self, *args, **kwargs): """This init checks to see if the first line and last line aren't reversed, because that can be a problem. I should probably make it do the same for the characters. It also generates the hash_id to check against the previous edit. """ super().__init__(*args, **kwargs) if self.first_line_num > self.last_line_num: tmp = self.last_line_num self.last_line_num = self.first_line_num self.first_line_num = tmp s = (f'{self.first_line_num},{self.last_line_num},' f'{self.first_char_idx},{self.last_char_idx},' f'{self.body},{self.tags}') self.hash_id = sha1(s.encode('utf8')).hexdigest() def __repr__(self): prefix = super().__repr__() return f"<{prefix} {self.annotation}>" def get_hl(self): """This method is supposed to return the specific lines of the target *and* truncate the first and last line based on the actual character-by-character targetting which is not in effect yet. It is, therefore, currently useless. """ lines = self.lines if self.first_line_num == self.last_line_num: lines[0].line = lines[0].line[self.first_char_idx:self. last_char_idx] else: lines[0].line = lines[0].line[self.first_char_idx:] lines[-1].line = lines[-1].line[:self.last_char_idx] return lines @property def url(self): if self.approved: return url_for('main.view_edit', annotation_id=self.annotation.id, num=self.num) else: return url_for('admin.review_edit', annotation_id=self.annotation.id, edit_id=self.id)
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
def repchange(cls): return db.relationship('ReputationChange', backref=backref(cls.__name__.lower(), uselist=False))
class Annotation(Base, FollowableMixin, LinkableMixin, VotableMixin): """And now, the moment you've been waiting for, the star of the show: the main Annotation data class. Attributes ---------- weight : int The weight of the annotation in upvotes/downvotes. timestamp : datetime The UTC date time when the annotation was first created. locked : bool The flag that indicates whether the annotation is locked from editing. active : bool The flag that indicates the annotation has been deactivated from viewing. ballots : BaseQuery An SQLA BaseQuery of all of the ballots that have been cast for this annotation. annotator : :class`User` The user object of the user who annotated the annotation to begin with. first_line : :class:`Line` The first line of the target of the annotation. I don't know why this is here. I don't know what I use it for. TBD. edition : :class:`Edition` The edition object the annotation is applied to. text : :class:`Text` The text object the edition belongs to upon which the annotation is annotated. HEAD : :class:`Edit` The edit object that is the current version of the annotation object. I want to eventually change this to current, and it seems like it would not be hard. But I am hesitant to do it because then it would not be unique, and it would no longer be easy to change. All it requires to change is a `find . -type f | xargs sed -i 's/HEAD/current/g'` command in the root directory of the project and the change would be complete. But since there are other objects which use the current designation (namely, :class:`Wiki`), once this command is applied and committed, there's really no going back without manually finding it. So for now, this is where it's staying. history : list All of the edits which have been approved, dynamically. all_edits : list All of the edits. All of them. Like, every one of them, period. edit_pending : list A list of edits which are neither approved, nor rejected. Essentially, this just serves to indicate as a bool-like list object that there is an edit pending, because the system is designed never to allow more than one edit pending at a time. This is, in fact, false, and I need to go through the system and fix the bug whereby a user could surreptitiously cause a race condition and have two edits submitted at the same time. lines : list A list of all of the lines that are the target of the annotation. context : list A list of all the lines that are the target of the annotation *plus* five lines on either side of the first and last lines of the target lines. flag_history : list A list of all of the :class:`AnnotationFlag`s which have been applied to the annotation. active_flags : list A list of all of the flags that are currently active on the annotation. Notes ----- The four edit lists are redundant and will need to be eliminated and whittled down. There should only be three: the approved edit history, the rejected edits, and the pending edits. There's no reason for anything else. Even the rejected edits are actually useless. I think I'll leave that out. Never is better than right now. The flags will also have to be refined. I want to make the flag system more public-facing, like Wikipedia's warning templates. And finally, the lines could possibly be changed. The context, for instance, might be prior-context and posterior-context, or something of that nature, instead of packing the same lines into the same list. Perhaps not. TBD. """ __vote__ = AnnotationVote __reputable__ = 'annotator' __linkable__ = 'id' annotator_id = db.Column(db.Integer, db.ForeignKey('user.id'), index=True) edition_id = db.Column(db.Integer, db.ForeignKey('edition.id'), index=True) weight = db.Column(db.Integer, default=0) timestamp = db.Column(db.DateTime, index=True, default=datetime.utcnow) locked = db.Column(db.Boolean, index=True, default=False) active = db.Column(db.Boolean, default=True) ballots = db.relationship('AnnotationVote', lazy='dynamic') annotator = db.relationship('User') @property def first_line(self): return self.HEAD.first_line edition = db.relationship('Edition') text = db.relationship('Text', secondary='edition', backref=backref('annotations', lazy='dynamic'), uselist=False) # relationships to `Edit` HEAD = db.relationship('Edit', primaryjoin='and_(Edit.current==True,' 'Edit.entity_id==Annotation.id)', uselist=False) # history is used for annotation edit history history = db.relationship( 'Edit', primaryjoin='and_(Edit.entity_id==Annotation.id, Edit.approved==True)', lazy='dynamic') # all_edits is used primarily for the cascade delete all_edits = db.relationship('Edit', primaryjoin='Edit.entity_id==Annotation.id', lazy='dynamic') # edit_pending is used primarily as a falsey boolean edit_pending = db.relationship( 'Edit', primaryjoin='and_(Edit.entity_id==Annotation.id, Edit.approved==False, ' 'Edit.rejected==False)') # relationships to `Line` lines = db.relationship( 'Line', secondary='edit', primaryjoin='and_(Annotation.id==Edit.entity_id,Edit.current==True)', secondaryjoin='and_(Line.num>=Edit.first_line_num,' 'Line.num<=Edit.last_line_num,Line.edition_id==Annotation.edition_id)', viewonly=True, uselist=True) context = db.relationship( 'Line', secondary='edit', primaryjoin='and_(Annotation.id==Edit.entity_id,Edit.current==True)', secondaryjoin=f'and_(Line.num>=Edit.first_line_num-{CONTEXT},' f'Line.num<=Edit.last_line_num+{CONTEXT},' 'Line.edition_id==Annotation.edition_id)', viewonly=True, uselist=True) # Relationships to `Flag` flag_history = db.relationship( 'AnnotationFlag', primaryjoin='Annotation.id==AnnotationFlag.annotation_id', lazy='dynamic') active_flags = db.relationship( 'AnnotationFlag', primaryjoin='and_(Annotation.id==AnnotationFlag.annotation_id,' 'AnnotationFlag.resolver_id==None)') def __str__(self): return f'[{self.id}]' @property def url(self): return url_for('main.annotation', annotation_id=self.id) def __init__(self, *ignore, edition, annotator, toc, locked=False, fl, ll, fc, lc, body, tags): """This init method creates the initial :class:`Edit` object. This reduces friction in creating annotations. """ params = [edition, annotator, fl, ll, fc, lc, body, tags] if ignore: raise TypeError("Positional arguments not accepted.") elif None in params: raise TypeError("Keyword arguments cannot be None.") elif not type(tags) == list: raise TypeError("Tags must be a list of tags.") super().__init__(edition=edition, annotator=annotator, locked=locked) current = Edit(annotation=self, approved=True, current=True, editor=annotator, edition=edition, first_line_num=fl, last_line_num=ll, toc=toc, first_char_idx=fc, last_char_idx=lc, body=body, tags=tags, num=0, reason="initial version") db.session.add(current) self.HEAD = current def edit(self, *ignore, editor, reason, fl, ll, fc, lc, body, tags): """This method creates an edit for the annotation. It is much more transparent than creating the edit independently. """ params = [editor, reason, fl, ll, fc, lc, body, tags] if ignore: raise TypeError("Positional arguments not accepted.") elif None in params: raise TypeError("Keyword arguments cannot be None.") elif not isinstance(tags, list): raise TypeError("Tags must be a list of tags.") elif not all(isinstance(tag, Tag) for tag in tags): raise TypeError("Tags must be a list of tag objects.") edit = Edit(toc=self.HEAD.toc, edition=self.edition, editor=editor, num=self.HEAD.num + 1, reason=reason, annotation=self, first_line_num=fl, last_line_num=ll, first_char_idx=fc, last_char_idx=lc, body=body, tags=tags) if edit.hash_id == self.HEAD.hash_id: flash("Your suggested edit is no different from the previous " "version.") return False elif editor == self.annotator or\ editor.is_authorized('immediate_edits'): edit.approved = True self.HEAD.current = False edit.current = True flash("Edit approved.") else: flash("Edit submitted for review.") db.session.add(edit) return True def up_power(self, voter): """An int that represents the user's current upvote power. This is currently set to 10log10 of the user's reputation, floored at 1. """ if not current_app.config['LOG_POWER']: return 1 if voter.reputation <= 1: return 1 else: return int(10 * log10(voter.reputation)) def down_power(self, voter): """An int of the user's down power. This is simply half of the user's up power, but at least one. """ if not current_app.config['LOG_POWER']: return -1 power = self.up_power(voter) if power / 2 <= 1: return -1 else: return -int(power)
def enum(cls): """The actual enum object that this relationship is typed to.""" return db.relationship(f'{cls.__name__}Enum')
def thrower(cls): """The user who threw the flag.""" return db.relationship('User', foreign_keys=[cls.thrower_id])
class Comment(Base, VotableMixin): """A class representing comments on annotations. Attributes ---------- poster_id : int The id of the user who posted the comment. annotation_id : int The id of the annotation the comment is applied to. parent_id : int The id of the parent comment (None if it is a top level comment, i.e., a thread). depth : int The depth level of the comment in a thread. weight : int The weight of the comment. body : str The body of the comment. timestamp : datetime When the comment was posted, in the utc timezone. poster : User The user who posted the comment. annotation : Annotation The annotation the comment is on. parent : Comment The parent of the comment. children : BaseQuery The immediate children of the comment. """ __vote__ = CommentVote __reputable__ = 'poster' poster_id = db.Column(db.Integer, db.ForeignKey('user.id'), index=True, nullable=False) annotation_id = db.Column(db.Integer, db.ForeignKey('annotation.id'), index=True, nullable=False) parent_id = db.Column(db.Integer, db.ForeignKey('comment.id'), index=True, default=None) depth = db.Column(db.Integer, default=0) weight = db.Column(db.Integer, default=0) body = db.Column(db.Text) timestamp = db.Column(db.DateTime, default=datetime.utcnow()) poster = db.relationship('User', backref=backref('comments', lazy='dynamic')) annotation = db.relationship('Annotation', backref=backref('comments', lazy='dynamic')) parent = db.relationship('Comment', remote_side='Comment.id', uselist=False, backref=backref('children', remote_side=[parent_id], lazy='dynamic')) @property def url(self): return url_for('main.comments', annotation_id=self.annotation_id) def __repr__(self): return f'<Comment {self.id} on [{self.annotation_id}]>'