class GroupChat(Chat): __tablename__ = "group_chats" id = Column(Integer, ForeignKey("chats.id"), primary_key=True) __mapper_args__ = { "polymorphic_identity": "group", "inherit_condition": id == Chat.id, } title = Column(Unicode(50), nullable=False, default="") topic = Column(UnicodeText, nullable=False, default="") description = Column(UnicodeText, nullable=False, default="") rules = Column(UnicodeText, nullable=False, default="") autosilence = Column(Boolean, nullable=False, default=False) style = Column(SQLAlchemyEnum( "script", "paragraph", "either", name="group_chats_style", ), nullable=False, default="script") level = Column(SQLAlchemyEnum( *level_options.keys(), name="group_chats_level", ), nullable=False, default="sfw") publicity = Column(SQLAlchemyEnum( "listed", "unlisted", "pinned", "admin_only", "private", name="group_chats_publicity", ), nullable=False, default="unlisted") creator_id = Column(Integer, ForeignKey("users.id"), nullable=False) parent_id = Column(Integer, ForeignKey("chats.id")) def __repr__(self): return "<GroupChat #%s: %s>" % (self.id, self.url) def computed_title(self, *args, **kwargs): return self.title def to_dict(self, *args, **kwargs): cd = super(GroupChat, self).to_dict(*args, **kwargs) cd["topic"] = self.topic cd["description"] = self.description cd["rules"] = self.rules cd["autosilence"] = self.autosilence cd["style"] = self.style cd["level"] = self.level cd["publicity"] = self.publicity return cd
class Tag(Base): __tablename__ = "tags" id = Column(Integer, primary_key=True) type_options = { "maturity", "trigger", "type", "fandom", "fandom_wanted", "character", "character_wanted", "gender", "gender_wanted", "misc", } # List to preserve order. maturity_names = ["general", "teen", "mature", "explicit"] type_names = ["fluff", "plot-driven", "sexual", "shippy", "violent"] type = Column(SQLAlchemyEnum(*type_options, name="tags_type"), nullable=False, default="misc") name = Column(Unicode(50), nullable=False) synonym_id = Column(Integer, ForeignKey("tags.id"))
class LogMarker(Base): __tablename__ = "log_markers" chat_id = Column(Integer, ForeignKey("chats.id"), primary_key=True) type = Column(SQLAlchemyEnum( "page_with_system_messages", "page_without_system_messages", name="log_markers_type", ), primary_key=True, default="page_with_system_messages") number = Column(Integer, primary_key=True, autoincrement=False) message_id = Column(Integer, ForeignKey("messages.id"), nullable=False)
class Chat(Base): __tablename__ = "chats" __mapper_args__ = {"polymorphic_on": "type"} id = Column(Integer, primary_key=True) # URL must be alphanumeric. It must normally be capped to 50 characters, # but sub-chats are prefixed with the parent URL and a slash, so we need # 101 characters to fit 50 characters in each half. url = Column(String(101), unique=True) # Group chats allow people to enter in accordance with the publicity # options in the group_chats table, and can have any URL. # PM chats only allow two people to enter and have urls of the form # `pm/<user id 1>/<user id 2>`, with the 2 user IDs in alphabetical # order. # Searched chats allow anyone to enter and have randomly generated URLs. type = Column(SQLAlchemyEnum( "group", "pm", "roulette", "searched", name="chats_type", ), nullable=False, default="group") created = Column(DateTime(), nullable=False, default=now) # Last message time should only update when users send messages, not system # messages. last_message = Column(DateTime(), nullable=False, default=now) # PM chats: "/pm/(username)" # Everything else: self.url def computed_url(self, *args, **kwargs): return self.url # Group chats: self.title # PM chats: "Messaging (username)" # Searched and roulette chats: same as the URL def computed_title(self, *args, **kwargs): return self.url def to_dict(self, *args, **kwargs): return { "id": self.id, "url": self.computed_url(*args, **kwargs), "type": self.type, "title": self.computed_title(*args, **kwargs), }
class AdminTierPermission(Base): __tablename__ = "admin_tier_permissions" admin_tier_id = Column(Integer, ForeignKey("admin_tiers.id"), primary_key=True) permission = Column(SQLAlchemyEnum( "search_characters", "announcements", "broadcast", "user_list", "reset_password", "permissions", "groups", "log", "spamless", "ip_bans", "email_bans", name="admin_tier_permissions_permission", ), primary_key=True) def __repr__(self): return "<AdminTierPermission: %s has %s>" % (self.admin_tier_id, self.permission)
class User(Base): __tablename__ = "users" id = Column(Integer, primary_key=True) # Username must be alphanumeric. username = Column(String(50), nullable=False) # Bcrypt hash. password = Column(String(60), nullable=False) secret_question = Column(Unicode(50)) secret_answer = Column(String(60)) date_of_birth = Column(DateTime()) email_address = Column(String(100)) email_verified = Column(Boolean, nullable=False, default=False) group = Column(SQLAlchemyEnum( "new", "active", "deactivated", "banned", name="users_group", ), nullable=False, default="guest") admin_tier_id = Column(Integer, ForeignKey("admin_tiers.id")) created = Column(DateTime(), nullable=False, default=now) last_online = Column(DateTime(), nullable=False, default=now) last_ip = Column(INET, nullable=False) # Default character for entering group chats default_character_id = Column( Integer, ForeignKey( "characters.id", name="users_default_character_fkey", use_alter=True, )) last_search_mode = Column( SQLAlchemyEnum("roulette", "search", name="user_last_search_mode"), nullable=False, default="roulette", ) # Character info for searching roulette_search_character_id = Column(Integer, ForeignKey("search_characters.id"), nullable=False, default=1) roulette_character_id = Column( Integer, ForeignKey( "characters.id", name="users_roulette_character_fkey", use_alter=True, )) search_character_id = Column(Integer, ForeignKey("search_characters.id"), nullable=False, default=1) name = Column(Unicode(50), nullable=False, default="anonymous") acronym = Column(Unicode(15), nullable=False, default="??") # Must be a hex code. color = Column(Unicode(6), nullable=False, default="000000") quirk_prefix = Column(Unicode(2000), nullable=False, default="") quirk_suffix = Column(Unicode(2000), nullable=False, default="") case = Column(case_options_enum, nullable=False, default="normal") replacements = Column(UnicodeText, nullable=False, default="[]") regexes = Column(UnicodeText, nullable=False, default="[]") search_style = Column( SQLAlchemyEnum("script", "paragraph", "either", name="user_search_style"), nullable=False, default="script", ) search_levels = Column(ARRAY(Unicode(50)), nullable=False, default=["sfw"]) search_filters = Column(ARRAY(Unicode(50)), nullable=False, default=[]) search_age_restriction = Column(Boolean, nullable=False, default=False) pm_age_restriction = Column(Boolean, nullable=False, default=False) # psycopg2 doesn't handle arrays of custom types by default, so we just use strings here. group_chat_styles = Column(ARRAY(Unicode(50)), nullable=False, default=["script"]) group_chat_levels = Column(ARRAY(Unicode(50)), nullable=False, default=["sfw"]) confirm_disconnect = Column(Boolean, nullable=False, default=True) desktop_notifications = Column(Boolean, nullable=False, default=False) show_system_messages = Column(Boolean, nullable=False, default=True) show_user_numbers = Column(Boolean, nullable=False, default=True) show_bbcode = Column(Boolean, nullable=False, default=True) show_timestamps = Column(Boolean, nullable=False, default=False) show_preview = Column(Boolean, nullable=False, default=True) typing_notifications = Column(Boolean, nullable=False, default=True) enable_activity_indicator = Column(Boolean, nullable=False, default=True) timezone = Column(Unicode(255)) theme = Column(Unicode(255)) def __repr__(self): return "<User #%s: %s>" % (self.id, self.username) def set_password(self, password): if not password: raise ValueError("Password can't be blank.") self.password = hashpw(password.encode("utf8"), gensalt()).decode("utf8") def check_password(self, password): return hashpw(password.encode("utf8"), self.password.encode()).decode("utf8") == self.password @property def age(self): if self.date_of_birth is None: return None now = datetime.datetime.now() age = now.year - self.date_of_birth.year date_of_birth = self.date_of_birth if date_of_birth.month == 2 and date_of_birth.day == 29: date_of_birth = date_of_birth + datetime.timedelta(1) if date_of_birth.replace(year=now.year) > now: age -= 1 return age @property def age_group(self): if self.date_of_birth is None: return AgeGroup.unknown return AgeGroup.over_18 if self.age >= 18 else AgeGroup.under_18 @property def level_options(self): return allowed_level_options[self.age_group] @property def is_admin(self): return self.admin_tier_id is not None def has_permission(self, permission): if self.is_admin and permission in self.admin_tier.permissions: return True return False def localize_time(self, input_datetime): utc_datetime = utc.localize(input_datetime) if self.timezone is None: return utc_datetime return utc_datetime.astimezone(timezone(self.timezone)) def to_dict(self, include_options=False): ud = { "id": self.id, "username": self.username, "email_address": self.email_address, "email_verified": self.email_verified, "group": self.group, "is_admin": self.is_admin, "created": time.mktime(self.created.timetuple()), "last_online": time.mktime(self.last_online.timetuple()), "name": self.name, "acronym": self.acronym, "color": self.color, "search_style": self.search_style, "search_levels": self.search_levels, } if include_options: ud["admin_tier"] = self.admin_tier.to_dict( ) if self.is_admin else None ud["default_character"] = self.default_character.to_dict( ) if self.default_character is not None else None ud["search_character"] = self.search_character.to_dict() ud["quirk_prefix"] = self.quirk_prefix ud["quirk_suffix"] = self.quirk_suffix ud["case"] = self.case ud["replacements"] = json.loads(self.replacements) ud["regexes"] = json.loads(self.regexes) ud["timezone"] = self.timezone ud["theme"] = self.theme return ud
class Message(Base): MAX_LENGTH = 10000 __tablename__ = "messages" id = Column(Integer, primary_key=True) chat_id = Column(Integer, ForeignKey("chats.id"), nullable=False) # Can be null because system messages aren't associated with a user. user_id = Column(Integer, ForeignKey("users.id")) posted = Column(DateTime(), nullable=False, default=now) # XXX CONSIDER SPLITTING SYSTEM INTO USER_CHANGE, META_CHANGE ETC. type = Column(SQLAlchemyEnum( "ic", "ooc", "me", "join", "disconnect", "timeout", "user_info", "user_group", "user_action", "chat_meta", "search_info", "spamless", name="messages_type", ), nullable=False, default="ic") # Must be a hex code. color = Column(Unicode(6), nullable=False, default="000000") acronym = Column(Unicode(15), nullable=False, default="") name = Column(Unicode(50), nullable=False, default="") text = Column(UnicodeText, nullable=False) spam_flag = Column(Unicode(15)) def __repr__(self): if len(self.text) < 50: preview = self.text else: preview = self.text[:47] + "..." return "<Message #%s: \"%s\">" % (self.id, preview.encode("utf8")) def to_dict(self, include_user=False, include_spam_flag=True): md = { "id": self.id, "user_number": self.chat_user.number if self.chat_user is not None else None, "posted": time.mktime(self.posted.timetuple()), "type": self.type, "color": self.color, "acronym": self.acronym, "name": self.name, "text": self.text, } if include_user: md["user"] = { "id": self.user.id, "username": self.user.username, } if self.user is not None else None if include_spam_flag: md["spam_flag"] = self.spam_flag return md
class ChatUser(Base): __tablename__ = "chat_users" chat_id = Column(Integer, ForeignKey("chats.id"), primary_key=True) user_id = Column(Integer, ForeignKey("users.id"), primary_key=True) number = Column(Integer, nullable=False) search_character_id = Column(Integer, ForeignKey("search_characters.id"), nullable=False, default=1) subscribed = Column(Boolean, nullable=False, default=False) title = Column(Unicode(50)) notes = Column(UnicodeText) last_online = Column(DateTime(), nullable=False, default=now) # Ignored if the user is an admin or the chat's creator. group = Column(SQLAlchemyEnum( "mod3", "mod2", "mod1", "silent", "user", name="chat_users_group", ), nullable=False, default="user") name = Column(Unicode(50), nullable=False, default="anonymous") acronym = Column(Unicode(15), nullable=False, default="??") # Must be a hex code. color = Column(Unicode(6), nullable=False, default="000000") quirk_prefix = Column(Unicode(2000), nullable=False, default="") quirk_suffix = Column(Unicode(2000), nullable=False, default="") case = Column(case_options_enum, nullable=False, default="normal") replacements = Column(UnicodeText, nullable=False, default="[]") regexes = Column(UnicodeText, nullable=False, default="[]") confirm_disconnect = Column(Boolean, nullable=False, default=True) desktop_notifications = Column(Boolean, nullable=False, default=False) show_system_messages = Column(Boolean, nullable=False, default=True) show_user_numbers = Column(Boolean, nullable=False, default=True) show_bbcode = Column(Boolean, nullable=False, default=True) show_timestamps = Column(Boolean, nullable=False, default=False) show_preview = Column(Boolean, nullable=False, default=True) typing_notifications = Column(Boolean, nullable=False, default=True) enable_activity_indicator = Column(Boolean, nullable=False, default=True) theme = Column(Unicode(255)) # No joins or filtering here so these don't need to be foreign keys. highlighted_numbers = Column(ARRAY(Integer), nullable=False, default=lambda: []) ignored_numbers = Column(ARRAY(Integer), nullable=False, default=lambda: []) draft = Column(UnicodeText) def __repr__(self): return "<ChatUser: %s in %s>" % (self.user, self.chat) @classmethod def from_character(cls, character, **kwargs): # Create a ChatUser using a Character and their user to determine the # default values. return cls(user_id=character.user.id, name=character.name, search_character_id=character.search_character_id, acronym=character.acronym, color=character.color, quirk_prefix=character.quirk_prefix, quirk_suffix=character.quirk_suffix, case=character.case, replacements=character.replacements, regexes=character.regexes, confirm_disconnect=user.confirm_disconnect, desktop_notifications=user.desktop_notifications, show_system_messages=user.show_system_messages, show_user_numbers=user.show_user_numbers, show_bbcode=user.show_bbcode, show_timestamps=user.show_timestamps, show_preview=user.show_preview, typing_notifications=user.typing_notifications, enable_activity_indicator=user.enable_activity_indicator, theme=user.theme, **kwargs) @classmethod def from_user(cls, user, **kwargs): # Create a ChatUser using a User to determine their settings. # Also inherit their default character if they have one and there # isn't one in the arguments. if user.default_character is not None and "name" not in kwargs: dc = user.default_character return cls( user_id=user.id, name=dc.name, search_character_id=dc.search_character_id, acronym=dc.acronym, color=dc.color, quirk_prefix=dc.quirk_prefix, quirk_suffix=dc.quirk_suffix, case=dc.case, replacements=dc.replacements, regexes=dc.regexes, confirm_disconnect=user.confirm_disconnect, desktop_notifications=user.desktop_notifications, show_system_messages=user.show_system_messages, show_user_numbers=user.show_user_numbers, show_bbcode=user.show_bbcode, show_timestamps=user.show_timestamps, show_preview=user.show_preview, typing_notifications=user.typing_notifications, enable_activity_indicator=user.enable_activity_indicator, theme=user.theme, **kwargs) return cls(user_id=user.id, confirm_disconnect=user.confirm_disconnect, desktop_notifications=user.desktop_notifications, show_system_messages=user.show_system_messages, show_user_numbers=user.show_user_numbers, show_bbcode=user.show_bbcode, show_timestamps=user.show_timestamps, show_preview=user.show_preview, typing_notifications=user.typing_notifications, enable_activity_indicator=user.enable_activity_indicator, theme=user.theme, **kwargs) # Group changes and user actions can only be performed on people of the # same group as yourself and lower. To make this easier to check, we store # a numeric value for each rank so we can do a simple less-than-or-equal-to # comparison. group_ranks = { "admin": float("inf"), "creator": float("inf"), "mod3": 3, "mod2": 2, "mod1": 1, "user": 0, "silent": -1, } # Minimum ranks for actions. action_ranks = { "invite": 3, "ban": 3, "kick": 2, # XXX different ranks for each flag? "set_flag": 1, "set_group": 1, "set_topic": 1, "set_info": 1, } @property def computed_group(self): # Group is overridden by chat creator and user status. # Needs joinedload whenever we're getting these. if self.user.is_admin: return "admin" if self.chat.type == "group" and self.chat.creator == self.user: return "creator" return self.group @property def computed_rank(self): return self.group_ranks[self.computed_group] def can(self, action): return self.group_ranks[ self.computed_group] >= self.action_ranks[action] def to_dict(self, include_user=False, include_options=False, include_title_and_notes=False): ucd = { "character": { "name": self.name, "acronym": self.acronym, "color": self.color, }, "meta": { "number": self.number, # Group is overridden by chat creator and user status. # Needs joinedload whenever we're getting these. "group": self.computed_group, }, } if include_options: ucd["character"]["quirk_prefix"] = self.quirk_prefix ucd["character"]["quirk_suffix"] = self.quirk_suffix ucd["character"]["case"] = self.case ucd["character"]["replacements"] = json.loads(self.replacements) ucd["character"]["regexes"] = json.loads(self.regexes) ucd["meta"]["subscribed"] = self.subscribed ucd["meta"]["confirm_disconnect"] = self.confirm_disconnect ucd["meta"]["desktop_notifications"] = self.desktop_notifications ucd["meta"]["show_system_messages"] = self.show_system_messages ucd["meta"]["show_user_numbers"] = self.show_user_numbers ucd["meta"]["show_bbcode"] = self.show_bbcode ucd["meta"]["show_timestamps"] = self.show_timestamps ucd["meta"]["show_preview"] = self.show_preview ucd["meta"]["typing_notifications"] = self.typing_notifications ucd["meta"][ "enable_activity_indicator"] = self.enable_activity_indicator ucd["meta"]["theme"] = self.theme ucd["meta"]["highlighted_numbers"] = self.highlighted_numbers ucd["meta"]["ignored_numbers"] = self.ignored_numbers ucd["draft"] = self.draft or "" if include_options or include_title_and_notes: ucd["title"] = self.title or "" ucd["notes"] = self.notes or "" if include_user: ucd["user"] = { "user_id": self.user.id, "username": self.user.username, } return ucd
over_18 = "over_18" case_options = OrderedDict([ ("alt-lines", "ALTERNATING lines"), ("alternating", "AlTeRnAtInG"), ("inverted", "iNVERTED"), ("lower", "lower case"), ("normal", "Normal"), ("title", "Title Case"), ("upper", "UPPER CASE"), ("proper", "Proper grammar"), ("first-letter", "First letter caps"), ]) case_options_enum = SQLAlchemyEnum(*list(case_options.keys()), name="case") # TODO make this an enum and use sqlalchemy-enum34 level_options = OrderedDict([ ("sfw", "SFW"), ("nsfwv", "NSFW (violent)"), ("nsfws", "NSFW (sexual)"), ("nsfw-extreme", "NSFW extreme"), ]) allowed_level_options = { AgeGroup.unknown: {"sfw", "nsfwv"}, AgeGroup.under_18: {"sfw", "nsfwv"}, AgeGroup.over_18: {"sfw", "nsfwv", "nsfws", "nsfw-extreme"}, }