Example #1
0
class Username(db.Model):
    """Represents the username of a user.

    Multiple names can be tied to one user, but only one of
    them can be active and nested within the user class.

    Many to one relationship with `User`.
    """

    __tablename__ = "usernames"

    # The unique username stored in lowercase.
    name = db.Column(db.String(80), primary_key=True)

    # A record of the name as the user typed it.
    case_name = db.Column(db.String(80))

    # The linked user
    user_id = db.Column(db.Integer, db.ForeignKey("users.id"))
    user = db.relationship("User", back_populates="username_objects")

    # Account for case sensitivity in username uniqueness.
    @classmethod
    def create(cls, name, user):
        return cls(name=name.lower(), case_name=name, user=user)

    # Get username object from username.
    @classmethod
    def get(cls, name):
        return db.session.query(cls).get(name.lower())
Example #2
0
class EmailConfirmationCode(db.Model):
    __tablename__ = "email_confirmation_codes"

    code = db.Column(db.String(256), primary_key=True)
    user_id = db.Column(db.Integer, db.ForeignKey("users.id"), primary_key=True)
    user = db.relationship("User")

    used = db.Column(db.Boolean, default=False)

    # Record the email address the user had when making the confirmation request.
    email = db.Column(db.String(120))

    # Use this to determine whether the code is expired.
    date_created = db.Column(db.DateTime, server_default=db.func.now())

    def __init__(self, user_id, email):
        self.code = random_token()
        self.user_id = user_id
        self.email = email

    def url(self):
        return '/register/email/?id=%s&code=%s' % (self.user_id, self.code)

    def expired(self):
        return self.date_created < (datetime.datetime.now() - datetime.timedelta(days=1))

    def invalid(self):
        return self.expired() or self.used or self.email != self.user.email
Example #3
0
class BoardCategory(db.Model):
    """Forum Board Categories.
    Many to Many relationship with `Board`.
    """

    __tablename__ = "forum_board_categories"

    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String(256))
    canon_name = db.Column(db.String(256), unique=True)

    order = db.Column(db.Integer, default=1)

    boards = db.relationship("Board",
        back_populates="category",
        lazy='dynamic'
    )

    def url(self):
        return '/forums/#' + self.canon_name

    def get_boards(self, user=None):
        query = self.boards
        if not (user and user.has_moderation_access()):
            query = query.filter(Board.moderators_only == False)
        return query.order_by(Board.order.asc()).all()

    @classmethod
    def get_categories(cls):
        return db.session.query(cls).order_by(cls.order.asc()).all()

    @classmethod
    def by_canon_name(cls, name):
        return db.session.query(cls).filter(cls.canon_name == name.lower()).one_or_none()
Example #4
0
class Item(db.Model):
    __tablename__ = "items"

    id = db.Column(db.Integer, primary_key=True)

    name = db.Column(db.String(256), unique=True)
    canon_name = db.Column(db.String(256), unique=True)
    description = db.Column(db.String(1024))
    buyback_price = db.Column(db.Integer, default=0)

    category_id = db.Column(db.Integer)

    def __init__(self, *args, **kwargs):
        super(Item, self).__init__(*args, **kwargs)
        if not self.canon_name:
            self.canon_name = canonize(self.name)

    def url(self):
        return '/item/' + self.canon_name

    def image_url(self):
        if is_devserver():
            category_name = self.category_name()
            subpath = ("img" + os.sep + "items" + os.sep + category_name +
                       os.sep + self.canon_name + ".png")
            image_path = (os.path.join(go_up_path(4, (__file__)), "static",
                                       subpath))
            if os.path.isfile(image_path):
                return url_for("static", filename=subpath) + "?v=" + str(
                    get_static_version_id())
        return (app.config['IMAGE_BUCKET_ROOT'] + "/items/" + self.canon_name +
                ".png?v=" + str(get_static_version_id()))

    def category(self):
        return ItemCategory(self.category_id)

    def category_name(self):
        return self.category().name()

    def give_items(self, user_id, count):
        return InventoryItem.give_items(user_id, self.id, count)

    def is_bondable(self):
        return self.category().name() == "minis"

    def is_wearable(self):
        return self.category().name() == "clothes"

    @classmethod
    def make_canon_name(cls, name):
        return canonize(name)

    @classmethod
    def by_canon_name(cls, name):
        return db.session.query(cls).filter(
            cls.canon_name == name.lower()).one_or_none()
Example #5
0
class Notification(db.Model):
    __tablename__ = "notifications"

    id = db.Column(db.Integer, primary_key=True)
    user_id = db.Column(db.Integer, db.ForeignKey('users.id'))
    user = db.relationship("User")

    time = db.Column(db.DateTime, server_default=db.func.now())
    text = db.Column(db.Text())
    unread = db.Column(db.Boolean, default=True)
    link = db.Column(db.String(512))
    count = db.Column(db.Integer, default=1)

    @classmethod
    def send(cls, user_id, text, link):
        notification = (db.session.query(cls).filter(
            cls.user_id == user_id).filter(cls.text == text).filter(
                cls.link == link).filter(cls.unread == True).one_or_none())

        if not notification:
            notification = cls(user_id=user_id, text=text, link=link)
        else:
            notification.count += 1
        db.session.add(notification)
        db.session.commit()
Example #6
0
class Board(db.Model):
    """Forum Boards. Container for threads.
    Many to Many relationship with `BoardCategory`.
    """

    __tablename__ = 'forum_boards'

    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String(256))
    canon_name = db.Column(db.String(256), unique=True)
    description = db.Column(db.Text())

    moderators_only = db.Column(db.Boolean(), default=False)

    order = db.Column(db.Integer)

    category_id = db.Column(db.Integer, db.ForeignKey('forum_board_categories.id'))
    category = db.relationship("BoardCategory",
        back_populates="boards"
    )

    threads = db.relationship("ForumThread", back_populates="board")

    def url(self):
        return "/forums/board/" + self.canon_name + "/"

    def is_news(self):
        return self.canon_name == app.config.get('NEWS_BOARD_CANON_NAME')

    def can_post(self, user):
        result = user and user.has_communication_access()
        if self.is_news():
            result = result and user.has_admin_access()
        return result

    def latest_post(self):
        return (
            db.session.query(ForumPost)
            .join(ForumThread, ForumPost.thread)
            .filter(ForumThread.board_id == self.id)
            .order_by(ForumPost.id.desc())
            .first()
        )

    @classmethod
    def by_canon_name(cls, name):
        return db.session.query(cls).filter(cls.canon_name == name.lower()).one_or_none()
Example #7
0
class SpeciesCoat(db.Model):

    __tablename__ = "species_coats"

    id = db.Column(db.Integer, primary_key=True)
    canon_name = db.Column(db.String(256), unique=True)

    coat_name = db.Column(db.String(80))
    coat_canon_name = db.Column(db.String(80))

    species_id = db.Column(db.Integer, db.ForeignKey("species.id"))
    species = db.relationship("Species")
    description = db.Column(db.Text)

    date_discovered = db.Column(db.DateTime, server_default=db.func.now())

    def __init__(self, *args, **kwargs):
        super(SpeciesCoat, self).__init__(*args, **kwargs)
        if not self.canon_name:
            self.canon_name = self.species.canon_name + '_' + canonize(self.coat_name)

        if not self.coat_canon_name:
            self.coat_canon_name = canonize(self.coat_name)

    @property
    def name(self):
        return self.coat_name + ' ' + self.species.name

    def url(self):
        return '/coat/' + self.coat_canon_name + '/' + self.species.canon_name + '/'

    def image_url(self):
        if is_devserver():
            subpath = ("img" + os.sep + "pets" + os.sep + self.species.canon_name + os.sep + self.coat_name +
            ".png")
            image_path = (os.path.join(go_up_path(4, (__file__)), "static", subpath))
            if os.path.isfile(image_path):
                return url_for("static", filename=subpath) + "?v=" + str(get_static_version_id())
        return (app.config['IMAGE_BUCKET_ROOT'] + "/pets/" + self.species.canon_name + "/" +
            self.coat_name + ".png?v=" + str(get_static_version_id()))

    @classmethod
    def by_canon_name(cls, name):
        return db.session.query(cls).filter(cls.canon_name == name.lower()).one_or_none()
Example #8
0
class MiniFriendship(db.Model):
    """
    A relationship between a pet and their mini. Note that although a pet can
    only have one mini equipped at a time, it keeps a memory of its past minis.
    """
    __tablename__ = "mini_friendships"

    pet_id = db.Column(db.Integer, db.ForeignKey("pets.id"), primary_key=True)
    mini_id = db.Column(db.Integer, db.ForeignKey("items.id"), primary_key=True)

    pet = db.relationship("Pet")
    mini = db.relationship("Item")

    nickname = db.Column(db.String(80))
    description = db.Column(db.String(512))

    @property
    def name(self):
        if self.nickname:
            return self.nickname
        return self.mini.name
Example #9
0
class Title(db.Model):
    """Defines titles that users can hold.
    Examples of titles: admin, programmer, artist, contributor

    Many to one with Users.
    """

    __tablename__ = "titles"

    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(256), unique=True)
    canon_name = db.Column(db.String(256), unique=True)

    users = db.relationship("User",
        secondary='r_user_titles',
        back_populates="titles",
        lazy='dynamic'
    )

    def css_class(self):
        return 'title-' + self.canon_name
Example #10
0
class Species(db.Model):

    __tablename__ = "species"

    id = db.Column(db.Integer, primary_key=True)

    name = db.Column(db.String(80), unique=True)
    canon_name = db.Column(db.String(80), unique=True)

    description = db.Column(db.Text)

    date_discovered = db.Column(db.DateTime, server_default=db.func.now())

    def __init__(self, *args, **kwargs):
        super(Species, self).__init__(*args, **kwargs)
        if not self.canon_name:
            self.canon_name = canonize(self.name)

    @property
    def default_coat(self):
        coat = db.session.query(SpeciesCoat).filter(SpeciesCoat.coat_canon_name == 'common',
            SpeciesCoat.species_id == self.id).one_or_none()
        if not coat:
            coat = db.session.query(SpeciesCoat).filter(
                SpeciesCoat.species_id == self.id).limit(1).one_or_none()
        return coat

    def url(self):
        return '/species/' + self.canon_name + '/'

    def image_url(self):
        return self.default_coat.image_url()

    @classmethod
    def by_canon_name(cls, name):
        return db.session.query(cls).filter(cls.canon_name == name.lower()).one_or_none()
Example #11
0
class LoginSession(db.Model):
    __tablename__ = "sessions"

    id = db.Column(db.String(256), primary_key=True)
    user_id = db.Column(db.Integer,
                        db.ForeignKey("users.id"),
                        primary_key=True)
    user = db.relationship("User")

    expires = db.Column(db.DateTime)

    def __init__(self, user_id, expires):
        self.id = random_token(128)
        self.user_id = user_id
        self.expires = expires

    @property
    def active(self):
        return (self.expires > datetime.datetime.now())
Example #12
0
class InviteCode(db.Model):
    __tablename__ = "invite_codes"

    code = db.Column(db.String(256), primary_key=True)

    # An invite code is considered claimed if a recipient user exists.
    recipient_id = db.Column(db.Integer, db.ForeignKey("users.id"))
    recipient = db.relationship("User", foreign_keys=[recipient_id])

    # The person who originally created the invite code.
    sender_id = db.Column(db.Integer, db.ForeignKey("users.id"))
    sender = db.relationship("User", foreign_keys=[sender_id])

    disabled = db.Column(db.Integer, default=False)

    date_created = db.Column(db.DateTime, server_default=db.func.now())

    def __init__(self, sender_id):
        self.code = random_token()
        self.sender_id = sender_id

    def url(self):
        return '/register/?invite_code=' + self.code
Example #13
0
class PasswordResetCode(db.Model):
    __tablename__ = "password_reset_codes"

    code = db.Column(db.String(256), primary_key=True)
    user_id = db.Column(db.Integer, db.ForeignKey("users.id"), primary_key=True)
    user = db.relationship("User")

    used = db.Column(db.Boolean, default=False)

    # Use this to determine whether the code is expired.
    date_created = db.Column(db.DateTime, server_default=db.func.now())

    def __init__(self, user_id):
        self.code = random_token()
        self.user_id = user_id

    def expired(self):
        return self.date_created < (datetime.datetime.now() - datetime.timedelta(hours=1))

    def invalid(self):
        return self.expired() or self.used

    def url(self):
        return '/login/reset/%s/%s/' % (self.user_id, self.code)
Example #14
0
class Pet(db.Model):

    __tablename__ = "pets"

    id = db.Column(db.Integer, primary_key=True)
    soul_name = db.Column(db.String(80), unique=True)

    guardian_id = db.Column(db.Integer, db.ForeignKey("users.id", name="guardian_id"))
    guardian = db.relationship("User", foreign_keys=[guardian_id], back_populates="pets")

    favorites = db.relationship("Item", secondary="pet_favorites")

    # Only set if the pet is a variation
    coat_id = db.Column(db.Integer, db.ForeignKey("species_coats.id"))
    coat = db.relationship("SpeciesCoat")

    # The pet's current mini.
    mini_id = db.Column(db.Integer, db.ForeignKey("items.id"))
    mini = db.relationship("Item", foreign_keys=[mini_id])

    # Which way is the pet's image facing
    facing_right = db.Column(db.Boolean, default=True)

    # Personal profile information for the pet
    name = db.Column(db.String(80), default="")
    description = db.Column(db.Text, default="")
    pronouns = db.Column(db.String(80), default="They/them")
    date_created = db.Column(db.DateTime, server_default=db.func.now())

    # If either of these is set to a number other than 0, the pet is for sale
    ss_price = db.Column(db.Integer, default=0)
    cc_price = db.Column(db.Integer, default=0)

    def __init__(self, *args, **kwargs):
        super(Pet, self).__init__(*args, **kwargs)

        # Populate pet favorites.
        if not self.favorites:
            self.favorites = db.session.query(Item).order_by(db.func.rand()).limit(4).all()

        if not self.soul_name:
            self.soul_name = Pet.new_soul_name()

        if not self.name:
            self.name = self.soul_name.capitalize()

        if self.guardian_id:
            new_friendship = PetFriendship(pet_id=self.id, guardian_id=self.guardian_id)
            db.session.add(new_friendship)
            db.session.commit()

    def transfer_guardian(self, guardian_id):
        self.guardian_id = guardian_id
        new_friendship = PetFriendship(pet_id=self.id, guardian_id=guardian_id)
        db.session.add(new_friendship)
        db.session.commit()

    @hybrid_property
    def bonding_day(self):
        if not self.friendship:
            return None
        return self.friendship.bonding_day

    @bonding_day.setter
    def set_bonding_day(self, date):
        if not self.friendship:
            return None
        self.friendship.bonding_day = date
        return self.friendship

    @property
    def friendship(self):
        return db.session.query(PetFriendship).get((self.id, self.guardian_id))

    @property
    def mini_friendship(self):
        return db.session.query(MiniFriendship).get((self.id, self.mini_id))

    @property
    def species(self):
        return self.coat.species

    def image_url(self):
        return self.coat.image_url()

    def url(self):
        return '/pet/' + self.soul_name + '/'

    def to_dict(self):
        data = {
            'name': self.name,
            'id': self.id,
            'image_url': self.image_url(),
            'url': self.url(),
        }
        return data

    # Generate a new unique soul name
    @classmethod
    def new_soul_name(cls):
        min_length = 5
        new_name = soul_name(min_length)
        found = db.session.query(cls).filter(cls.soul_name == new_name).one_or_none()
        while found:
            min_length += 1
            new_name = soul_name(min_length)
            found = db.session.query(cls).filter(cls.soul_name == new_name).one_or_none()
        return new_name
Example #15
0
class User(db.Model):
    """The greatest User model of all time.
    """

    __tablename__ = "users"

    id = db.Column(db.Integer, primary_key=True)

    active_username = db.Column(db.String(80), unique=True)

    last_username_change = db.Column(db.DateTime, server_default=db.func.now())
    username_objects = db.relationship("Username", back_populates="user")

    last_action = db.Column(db.DateTime, server_default=db.func.now())
    date_joined = db.Column(db.DateTime, server_default=db.func.now())

    # Email, Password
    email = db.Column(db.String(120), unique=True)
    email_confirmed = db.Column(db.Boolean, default=False)
    password_hash = db.Column(db.String(200))

    # Permissions.
    can_moderate = db.Column(db.Boolean, default=False)
    can_admin = db.Column(db.Boolean, default=False)

    title_id = db.Column(db.Integer, db.ForeignKey("titles.id"))
    title = db.relationship("Title", foreign_keys=[title_id])
    titles = db.relationship("Title",
                             secondary="r_user_titles",
                             back_populates="users")

    post_count = db.Column(db.Integer, default=0)

    # Active Companion
    companion_id = db.Column(db.Integer,
                             db.ForeignKey("pets.id", name="companion_id"))
    companion = db.relationship("Pet", foreign_keys=[companion_id])

    # Human Avatar
    ha_url = db.Column(db.String(100), default="/api/ha/m/")
    pets = db.relationship("Pet",
                           primaryjoin='Pet.guardian_id == User.id',
                           back_populates="guardian",
                           lazy='dynamic',
                           passive_deletes=True)

    # Ban Status
    ban_id = db.Column(db.Integer, db.ForeignKey("ban_logs.id", name="ban_id"))
    ban = db.relationship("BanLog", foreign_keys=[ban_id])

    # Currency
    star_shards = db.Column(db.Integer, default=0)
    cloud_coins = db.Column(db.Integer, default=0)
    bank_account = db.relationship("BankAccount",
                                   uselist=False,
                                   back_populates="user")

    # Misc profile stuff
    gender = db.Column(db.String(100), default="")
    pronouns = db.Column(db.String(200), default="")
    bio = db.Column(db.String(1000), default="Hello, world")
    status = db.Column(db.String(15), default="")

    # Settings
    side_id = db.Column(db.Integer, default=0)
    theme_id = db.Column(db.Integer, default=0)
    notified_on_pings = db.Column(db.Boolean, default=True)
    autosubscribe_threads = db.Column(db.Boolean, default=True)
    autosubscribe_posts = db.Column(db.Boolean, default=False)

    # Used to track linear progress. ie: 0-5 are levels of the tutorial.
    story_level = db.Column(db.Integer, default=0)

    def __repr__(self):
        return '<User %r>' % self.name

    @hybrid_property
    def name(self):
        return self.active_username

    @name.setter
    def set_name(self, name):
        self.active_username = name

    @property
    def usernames(self):
        return [u.name for u in self.username_objects]

    def avatar_url(self):
        # Until we implement human avatars for real...
        return '/static/img/avatar/base.png'

    def url(self):
        return "/user/" + self.name.lower() + "/"

    def title_class(self):
        if self.is_banned():
            return 'title-banned'
        elif self.is_muted():
            return 'title-muted'
        elif self.title:
            return self.title.css_class()
        return 'title-user'

    def story_route(self):
        if self.story_level == 0:
            return 'general.intro_side'
        elif self.story_level == 1:
            return 'general.intro_companion'
        elif self.story_level == 2:
            return 'general.intro_avatar'
        return None

    def side_name(self):
        if self.side_id == 0:
            return 'Sayleus'
        elif self.side_id == 1:
            return 'Luaria'
        return None

    def is_banned(self):
        ban = self.ban
        return ban and ban.ban_type == BanTypes.BAN and ban.active()

    def is_muted(self):
        ban = self.ban
        return ban and ban.ban_type == BanTypes.MUTE and ban.active()

    def has_communication_access(self):
        return self.email_confirmed and not self.is_muted(
        ) and not self.is_banned()

    def has_moderation_access(self):
        return self.can_moderate

    def has_admin_access(self):
        return self.can_admin

    def make_email_confirmation_code(self):
        code = EmailConfirmationCode(self.id, self.email)
        db.session.merge(code)
        db.session.commit()
        return code

    def make_password_reset_code(self):
        code = PasswordResetCode(self.id)
        db.session.merge(code)
        db.session.commit()
        return code

    @validates('email')
    def validate_email(self, key, address):
        return address.lower()

    @validates('cloud_coins')
    def validate_cloud_coins(self, key, cc):
        if cc < 0:
            raise InvalidCurrencyException("Currency cannot be negative!")
        return cc

    @validates('star_shards')
    def validate_star_shards(self, key, ss):
        if ss < 0:
            raise InvalidCurrencyException("Currency cannot be negative!")
        return ss

    @classmethod
    def by_username(cls, username):
        return (db.session.query(cls).join(
            Username, cls.id == Username.user_id).filter(
                Username.name == username.lower()).one_or_none())

    @classmethod
    def from_email(cls, email):
        return db.session.query(cls).filter(
            cls.email == email.lower()).one_or_none()

    @classmethod
    def hash_password(cls, password, salt=None):
        if not salt:
            salt = bcrypt.gensalt()
        return bcrypt.hashpw(password, salt)

    @classmethod
    def check_password(cls, user, password):
        return cls.hash_password(password,
                                 user.password_hash) == user.password_hash

    @classmethod
    def transfer_currency(cls, from_id, to_id, cc=0, ss=0):
        from_user = db.session.query(User).get(from_id)
        to_user = db.session.query(User).get(to_id)
        if cc > from_user.cloud_coins or ss > from_user.star_shards:
            raise InvalidCurrencyException('Insufficient funds!')
        from_user.star_shards -= ss
        from_user.cloud_coins -= cc
        to_user.star_shards += ss
        to_user.cloud_coins += cc

    def __init__(self, username, *args, **kwargs):
        self.active_username = username
        Username.create(username, self)

        self.bank_account = BankAccount()

        super(User, self).__init__(*args, **kwargs)
Example #16
0
class ConversationHandle(db.Model):
    __tablename__ = "conversation_handles"

    conversation_id = db.Column(db.Integer,
                                db.ForeignKey('conversations.id'),
                                primary_key=True)
    user_id = db.Column(db.Integer,
                        db.ForeignKey('users.id'),
                        primary_key=True)

    user = db.relationship("User")

    title = db.Column(db.String(256))
    unread = db.Column(db.Boolean, default=False)
    hidden = db.Column(db.Boolean, default=False)
    last_updated = db.Column(db.DateTime, server_default=db.func.now())

    def url(self):
        if self.unread:
            return '/conversation_read/' + str(self.conversation_id) + '/'
        return '/conversation/' + str(self.conversation_id) + '/'

    def status(self):
        if self.hidden:
            return 'deleted'
        if self.unread:
            return 'unread'
        return 'read'

    def recipients(self):
        return db.session.query(ConversationHandle).filter(
            ConversationHandle.conversation_id == self.conversation_id).all()

    @classmethod
    def read_conversations(cls, keys, user_id):
        if isinstance(keys, (int, long)):  # noqa
            keys = [keys]
        for key in keys:
            try:
                found_conversation = db.session.query(cls).get((key, user_id))
                found_conversation.unread = False
                db.session.add(found_conversation)
            except (flask_sqlalchemy.orm.exc.NoResultFound):
                flash('Message read failed for an unexpected reason.', 'error')
                return False
        db.session.commit()
        return True

    # This marks a user conversation as hidden, it will be unhidden if a new reply is made.
    @classmethod
    def hide_conversations(cls, keys, user_id):
        if isinstance(keys, (int, long)):  # noqa
            keys = [keys]
        for key in keys:
            try:
                found_conversation = db.session.query(cls).get((key, user_id))
                found_conversation.hidden = True
                db.session.add(found_conversation)
            except (flask_sqlalchemy.orm.exc.NoResultFound):
                flash('Message hide failed for an unexpected reason.', 'error')
                return False
        db.session.commit()
        return True
Example #17
0
class ForumThread(db.Model):
    """Forum Threads.
    Many to One relationship with `Board`.
    """

    __tablename__ = "forum_threads"

    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String(256))
    author_id = db.Column(db.Integer, db.ForeignKey('users.id'))
    author = db.relationship("User")

    date_created = db.Column(db.DateTime, server_default=db.func.now())
    date_modified = db.Column(db.DateTime, onupdate=db.func.now())

    is_pinned = db.Column(db.Boolean(), default=False)
    is_locked = db.Column(db.Boolean(), default=False)

    board_id = db.Column(db.Integer, db.ForeignKey('forum_boards.id'))
    board = db.relationship("Board", back_populates="threads")

    posts = db.relationship("ForumPost", back_populates="thread", lazy='dynamic')

    subscribers = db.relationship("User", secondary='forum_thread_subscriptions',
        lazy='dynamic')

    def notify_subscribers(self, post):
        for user in self.subscribers:
            if user.id == post.author_id:
                continue
            Notification.send(user.id, 'Someone has made a new post in the thread: ' + self.title,
                post.url())

    def url(self):
        return "/forums/thread/" + str(self.id) + "-" + canonize(truncate(self.title, 40)) + "/"

    def subscription(self, user):
        return user and db.session.query(ForumSubscription).get((self.id, user.id))

    def can_post(self, user):
        result = user and user.has_communication_access()
        if self.is_locked:
            result = result and user.has_moderation_access()
        return result

    def can_edit(self, user):
        return user and user.has_communication_access() and (user.id == self.author.id or
            user.has_moderation_access())

    def first_post(self):
        return self.posts.order_by(ForumPost.id.asc()).first()

    def reply_count(self):
        return (
            db.session.query(ForumPost)
            .filter(ForumPost.thread_id == self.id)
            .count() - 1
        )

    def latest_post(self):
        return (
            db.session.query(ForumPost)
            .filter(ForumPost.thread_id == self.id)
            .order_by(ForumPost.id.desc())
            .first()
        )