models.py
Python script, ASCII text executable
1__all__ = [ 2"RepoAccess", 3"RepoFavourite", 4"Repo", 5"UserFollow", 6"UserNotification", 7"User", 8"Notification", 9"PostVote", 10"Post", 11"Commit", 12"PullRequest", 13"EmailChangeRequest", 14"Comment", 15] 16 17import secrets 18import subprocess 19 20import markdown 21from app import app, db, bcrypt 22import git 23from datetime import datetime, timedelta 24from enum import Enum 25from PIL import Image 26from cairosvg import svg2png 27import os 28import config 29import cairosvg 30import random 31import celery_tasks 32 33with (app.app_context()): 34class RepoAccess(db.Model): 35id = db.Column(db.Integer, primary_key=True) 36user_username = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 37repo_route = db.Column(db.String(98), db.ForeignKey("repo.route"), nullable=False) 38access_level = db.Column(db.SmallInteger(), nullable=False) # 0 read-only, 1 read-write, 2 admin 39 40user = db.relationship("User", back_populates="repo_access") 41repo = db.relationship("Repo", back_populates="repo_access") 42 43__table_args__ = (db.UniqueConstraint("user_username", "repo_route", name="_user_repo_uc"),) 44 45def __init__(self, user, repo, level): 46self.user_username = user.username 47self.repo_route = repo.route 48self.access_level = level 49 50 51class RepoFavourite(db.Model): 52id = db.Column(db.Integer, primary_key=True) 53user_username = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 54repo_route = db.Column(db.String(98), db.ForeignKey("repo.route"), nullable=False) 55 56notify_commit = db.Column(db.Boolean, default=False, nullable=False) 57notify_forum = db.Column(db.Boolean, default=False, nullable=False) 58notify_pr = db.Column(db.Boolean, default=False, nullable=False) 59notify_admin = db.Column(db.Boolean, default=False, nullable=False) 60 61user = db.relationship("User", back_populates="favourites") 62repo = db.relationship("Repo", back_populates="favourites") 63 64__table_args__ = (db.UniqueConstraint("user_username", "repo_route", name="_user_repo_uc1"),) 65 66def __init__(self, user, repo): 67self.user_username = user.username 68self.repo_route = repo.route 69 70 71class PostVote(db.Model): 72id = db.Column(db.Integer, primary_key=True) 73user_username = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 74post_identifier = db.Column(db.String(109), db.ForeignKey("post.identifier"), nullable=False) 75vote_score = db.Column(db.SmallInteger(), nullable=False) 76 77user = db.relationship("User", back_populates="votes") 78post = db.relationship("Post", back_populates="votes") 79 80__table_args__ = (db.UniqueConstraint("user_username", "post_identifier", name="_user_post_uc"),) 81 82def __init__(self, user, post, score): 83self.user_username = user.username 84self.post_identifier = post.identifier 85self.vote_score = score 86 87 88class User(db.Model): 89username = db.Column(db.String(32), unique=True, nullable=False, primary_key=True) 90display_name = db.Column(db.Unicode(128), unique=False, nullable=True) 91bio = db.Column(db.Unicode(16384), unique=False, nullable=True) 92password_hashed = db.Column(db.String(60), nullable=False) 93email = db.Column(db.String(254), nullable=True) 94company = db.Column(db.Unicode(64), nullable=True) 95company_url = db.Column(db.String(256), nullable=True) 96url = db.Column(db.String(256), nullable=True) 97show_mail = db.Column(db.Boolean, default=False, nullable=False) 98location = db.Column(db.Unicode(64), nullable=True) 99creation_date = db.Column(db.DateTime, default=datetime.utcnow) 100default_page_length = db.Column(db.SmallInteger, nullable=False, default=32, server_default="32") 101max_post_nesting = db.Column(db.SmallInteger, nullable=False, default=3, server_default="3") 102 103repositories = db.relationship("Repo", back_populates="owner") 104followers = db.relationship("UserFollow", back_populates="followed", foreign_keys="[UserFollow.followed_username]") 105follows = db.relationship("UserFollow", back_populates="follower", foreign_keys="[UserFollow.follower_username]") 106email_change_requests = db.relationship("EmailChangeRequest", back_populates="user") 107repo_access = db.relationship("RepoAccess", back_populates="user") 108votes = db.relationship("PostVote", back_populates="user") 109favourites = db.relationship("RepoFavourite", back_populates="user") 110 111commits = db.relationship("Commit", back_populates="owner") 112posts = db.relationship("Post", back_populates="owner") 113comments = db.relationship("Comment", back_populates="owner") 114prs = db.relationship("PullRequest", back_populates="owner") 115notifications = db.relationship("UserNotification", back_populates="user") 116 117def __init__(self, username, password, email=None, display_name=None): 118self.username = username 119self.password_hashed = bcrypt.generate_password_hash(password, config.HASHING_ROUNDS).decode("utf-8") 120self.email = "" 121if email: 122email_change_request = EmailChangeRequest(self, email) 123db.session.add(email_change_request) 124db.session.flush() 125self.display_name = display_name 126 127# Create the user's directory 128if not os.path.exists(os.path.join(config.REPOS_PATH, username)): 129os.makedirs(os.path.join(config.REPOS_PATH, username)) 130if not os.path.exists(os.path.join(config.USERDATA_PATH, username)): 131os.makedirs(os.path.join(config.USERDATA_PATH, username)) 132 133avatar_name = random.choice(os.listdir(config.DEFAULT_AVATARS_PATH)) 134if os.path.join(config.DEFAULT_AVATARS_PATH, avatar_name).endswith(".svg"): 135cairosvg.svg2png(url=os.path.join(config.DEFAULT_AVATARS_PATH, avatar_name), 136write_to="/tmp/roundabout-avatar.png") 137avatar = Image.open("/tmp/roundabout-avatar.png") 138else: 139avatar = Image.open(os.path.join(config.DEFAULT_AVATARS_PATH, avatar_name)) 140avatar.thumbnail(config.AVATAR_SIZE) 141avatar.save(os.path.join(config.USERDATA_PATH, username, "avatar.png")) 142 143# Create the configuration repo 144config_repo = Repo(self, ".config", 0) 145db.session.add(config_repo) 146notification = Notification({"type": "welcome"}) 147db.session.add(notification) 148db.session.commit() 149 150user_notification = UserNotification(self, notification, 1) 151db.session.add(user_notification) 152db.session.commit() 153celery_tasks.send_notification.apply_async(args=[user_notification.id]) 154 155 156class Repo(db.Model): 157route = db.Column(db.String(98), unique=True, nullable=False, primary_key=True) 158owner_name = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 159name = db.Column(db.String(64), nullable=False) 160owner = db.relationship("User", back_populates="repositories") 161visibility = db.Column(db.SmallInteger(), nullable=False) 162info = db.Column(db.Unicode(512), nullable=True) 163url = db.Column(db.String(256), nullable=True) 164creation_date = db.Column(db.DateTime, default=datetime.utcnow) 165 166default_branch = db.Column(db.String(64), nullable=True, default="") 167 168commits = db.relationship("Commit", back_populates="repo") 169posts = db.relationship("Post", back_populates="repo") 170comments = db.relationship("Comment", back_populates="repo") 171repo_access = db.relationship("RepoAccess", back_populates="repo") 172favourites = db.relationship("RepoFavourite", back_populates="repo") 173heads = db.relationship("PullRequest", back_populates="head", foreign_keys="[PullRequest.head_route]") 174bases = db.relationship("PullRequest", back_populates="base", foreign_keys="[PullRequest.base_route]") 175 176has_site = db.Column(db.SmallInteger, nullable=False, default=0, # 0 means no site, 1 means it's got a site, 2 means it's the user's primary site 177server_default="0") # (the one accessible at username.localhost) 178site_branch = db.Column(db.String(64), nullable=True) 179 180last_post_id = db.Column(db.Integer, nullable=False, default=0, server_default="0") 181last_comment_id = db.Column(db.Integer, nullable=False, default=0, server_default="0") 182 183def __init__(self, owner, name, visibility): 184self.route = f"/{owner.username}/{name}" 185self.name = name 186self.owner_name = owner.username 187self.owner = owner 188self.visibility = visibility 189 190# Add the owner as an admin 191repo_access = RepoAccess(owner, self, 2) 192db.session.add(repo_access) 193 194# Create the directory 195if not os.path.exists(os.path.join(config.REPOS_PATH, self.owner_name, self.name)): 196subprocess.run(["git", "init", self.name], 197cwd=os.path.join(config.REPOS_PATH, self.owner_name)) 198 199 200class Commit(db.Model): 201identifier = db.Column(db.String(227), unique=True, nullable=False, primary_key=True) 202sha = db.Column(db.String(128), nullable=False) 203repo_name = db.Column(db.String(98), db.ForeignKey("repo.route"), nullable=False) 204owner_name = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 205owner_identity = db.Column(db.String(321)) 206receive_date = db.Column(db.DateTime, default=datetime.now) 207author_date = db.Column(db.DateTime) 208message = db.Column(db.UnicodeText) 209repo = db.relationship("Repo", back_populates="commits") 210owner = db.relationship("User", back_populates="commits") 211 212comments = db.relationship("Comment", back_populates="commit") 213 214def __init__(self, sha, owner, repo, date, message, owner_identity): 215self.identifier = f"{repo.route}/{sha}" 216self.sha = sha 217self.repo_name = repo.route 218self.repo = repo 219self.owner_name = owner.username 220self.owner = owner 221self.author_date = datetime.fromtimestamp(int(date)) 222self.message = message 223self.owner_identity = owner_identity 224 225notification = Notification({"type": "commit", "repo": repo.route, "commit": sha}) 226db.session.add(notification) 227db.session.commit() # save the notification to get the ID 228 229# Send a notification to all users who have enabled commit notifications for this repo 230for relationship in RepoFavourite.query.filter_by(repo_route=repo.route, notify_commit=True).all(): 231user = relationship.user 232user_notification = UserNotification(user, notification, 1) 233db.session.add(user_notification) 234db.session.commit() 235celery_tasks.send_notification.apply_async(args=[user_notification.id]) 236 237 238class Post(db.Model): 239identifier = db.Column(db.String(109), unique=True, nullable=False, primary_key=True) 240number = db.Column(db.Integer, nullable=False) 241repo_name = db.Column(db.String(98), db.ForeignKey("repo.route"), nullable=False) 242owner_name = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 243votes = db.relationship("PostVote", back_populates="post") 244vote_sum = db.Column(db.Integer, nullable=False, default=0) 245 246parent_id = db.Column(db.String(109), db.ForeignKey("post.identifier"), nullable=True) 247root_id = db.Column(db.String(109), db.ForeignKey("post.identifier"), nullable=True) 248state = db.Column(db.SmallInteger, nullable=True, default=1) 249 250date = db.Column(db.DateTime, default=datetime.now) 251last_updated = db.Column(db.DateTime, default=datetime.now) 252subject = db.Column(db.Unicode(384)) 253message = db.Column(db.UnicodeText) 254repo = db.relationship("Repo", back_populates="posts") 255owner = db.relationship("User", back_populates="posts") 256parent = db.relationship("Post", back_populates="children", 257primaryjoin="Post.parent_id==Post.identifier", 258foreign_keys="[Post.parent_id]", remote_side="Post.identifier") 259root = db.relationship("Post", 260primaryjoin="Post.root_id==Post.identifier", 261foreign_keys="[Post.root_id]", remote_side="Post.identifier") 262children = db.relationship("Post", 263remote_side="Post.parent_id", 264primaryjoin="Post.identifier==Post.parent_id", 265foreign_keys="[Post.parent_id]") 266 267def __init__(self, owner, repo, parent, subject, message): 268self.identifier = f"{repo.route}/{repo.last_post_id}" 269self.number = repo.last_post_id 270self.repo_name = repo.route 271self.repo = repo 272self.owner_name = owner.username 273self.owner = owner 274self.subject = subject 275self.message = message 276self.parent = parent 277if parent and parent.parent: 278self.root = parent.parent 279elif parent: 280self.root = parent 281else: 282self.root = None 283repo.last_post_id += 1 284 285notification = Notification({"type": "post", "repo": repo.route, "post": self.identifier}) 286db.session.add(notification) 287db.session.commit() # save the notification to get the ID 288 289# Send a notification to all users who have enabled forum notifications for this repo 290for relationship in RepoFavourite.query.filter_by(repo_route=repo.route, notify_forum=True).all(): 291user = relationship.user 292user_notification = UserNotification(user, notification, 1) 293db.session.add(user_notification) 294db.session.commit() 295celery_tasks.send_notification.apply_async(args=[user_notification.id]) 296 297def update_date(self): 298self.last_updated = datetime.now() 299with db.session.no_autoflush: 300if self.parent is not None: 301self.parent.update_date() 302 303 304class Comment(db.Model): 305identifier = db.Column(db.String(109), unique=True, nullable=False, primary_key=True) 306number = db.Column(db.Integer, nullable=False) 307repo_name = db.Column(db.String(98), db.ForeignKey("repo.route"), nullable=False) 308owner_name = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 309commit_identifier = db.Column(db.String(227), db.ForeignKey("commit.identifier"), nullable=False) 310pr_id = db.Column(db.BigInteger, db.ForeignKey("pull_request.id"), nullable=True) 311 312file = db.Column(db.String(256), nullable=True) 313line_number = db.Column(db.Integer, nullable=True) 314line_type = db.Column(db.SmallInteger, nullable=True, default=0, server_default="0") # 0 is deleted, 1 is modified 315 316state = db.Column(db.SmallInteger, nullable=True, default=1) 317review = db.Column(db.SmallInteger, nullable=True, default=0) 318 319date = db.Column(db.DateTime, default=datetime.now) 320message = db.Column(db.UnicodeText) 321html = db.Column(db.UnicodeText) 322 323repo = db.relationship("Repo", back_populates="comments") 324owner = db.relationship("User", back_populates="comments") 325commit = db.relationship("Commit", back_populates="comments") 326 327def __init__(self, owner, repo, commit, message, file, line_number, pr=None): 328self.identifier = f"{repo.route}/{repo.last_comment_id}" 329self.number = repo.last_comment_id 330self.repo_name = repo.route 331self.repo = repo 332self.owner_name = owner.username 333self.owner = owner 334self.commit_identifier = commit.identifier 335self.commit = commit 336self.message = message 337self.html = markdown.markdown2html(message).prettify() 338self.file = file 339self.line_number = int(line_number[1:]) 340self.line_type = int(line_number[0] == "+") 341if pr: 342self.pr = pr 343 344repo.last_comment_id += 1 345 346@property 347def text(self): 348return self.html 349 350@text.setter 351def text(self, value): 352self.html = markdown.markdown2html(value).prettify() 353self.message = value # message is stored in markdown format for future editing or plaintext display 354 355 356class UserNotification(db.Model): 357id = db.Column(db.Integer, primary_key=True) 358user_username = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 359notification_id = db.Column(db.BigInteger, db.ForeignKey("notification.id")) 360attention_level = db.Column(db.SmallInteger, nullable=False) # 0 is read 361read_time = db.Column(db.DateTime, nullable=True) 362 363user = db.relationship("User", back_populates="notifications") 364notification = db.relationship("Notification", back_populates="notifications") 365 366__table_args__ = (db.UniqueConstraint("user_username", "notification_id", name="_user_notification_uc"),) 367 368def __init__(self, user, notification, level): 369self.user_username = user.username 370self.notification_id = notification.id 371self.attention_level = level 372 373def mark_read(self): 374self.read_time = datetime.utcnow() 375self.attention_level = 0 376 377def mark_unread(self): 378self.attention_level = 4 379 380 381class UserFollow(db.Model): 382id = db.Column(db.Integer, primary_key=True) 383follower_username = db.Column(db.String(32), db.ForeignKey("user.username", ondelete="CASCADE"), nullable=False) 384followed_username = db.Column(db.String(32), db.ForeignKey("user.username", ondelete="CASCADE"), nullable=False) 385 386follower = db.relationship("User", back_populates="followers", foreign_keys=[follower_username]) 387followed = db.relationship("User", back_populates="follows", foreign_keys=[followed_username]) 388 389def __init__(self, follower_username, followed_username): 390self.follower_username = follower_username 391self.followed_username = followed_username 392 393 394class Notification(db.Model): 395id = db.Column(db.BigInteger, primary_key=True, autoincrement=True) 396data = db.Column(db.dialects.postgresql.JSONB, nullable=False, default={}) 397notifications = db.relationship("UserNotification", back_populates="notification") 398timestamp = db.Column(db.DateTime, nullable=False, default=datetime.now) 399 400def __init__(self, json): 401self.data = json 402 403 404class PullRequest(db.Model): 405id = db.Column(db.BigInteger, primary_key=True, autoincrement=True) 406head_route = db.Column(db.String(98), db.ForeignKey("repo.route", ondelete="CASCADE"), nullable=False) 407base_route = db.Column(db.String(98), db.ForeignKey("repo.route", ondelete="CASCADE"), nullable=False) 408owner_name = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 409state = db.Column(db.SmallInteger, nullable=False, default=0) # 0 pending, 1 merged, 2 rejected 410 411head = db.relationship("Repo", back_populates="heads", foreign_keys=[head_route]) 412base = db.relationship("Repo", back_populates="bases", foreign_keys=[base_route]) 413 414head_branch = db.Column(db.String(64), nullable=False) 415base_branch = db.Column(db.String(64), nullable=False) 416 417owner = db.relationship("User", back_populates="prs") 418timestamp = db.Column(db.DateTime, nullable=False, default=datetime.now) 419 420def __init__(self, head, head_branch, base, base_branch, owner): 421self.head = head 422self.base = base 423self.head_branch = head_branch 424self.base_branch = base_branch 425self.owner = owner 426 427class EmailChangeRequest(db.Model): 428id = db.Column(db.BigInteger, primary_key=True, autoincrement=True) 429user_username = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 430new_email = db.Column(db.String(254), nullable=False) 431code = db.Column(db.String(64), nullable=False) 432expires_on = db.Column(db.DateTime, nullable=False) 433 434user = db.relationship("User", back_populates="email_change_requests") 435 436def __init__(self, user, new_email): 437self.user = user 438self.new_email = new_email 439self.code = hex(secrets.randbits(256)).removeprefix("0x") 440self.expires_on = datetime.now() + timedelta(days=1) 441 442