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