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(162), 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", post_update=True) 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: 331self.root = parent.root 332else: 333self.root = self 334repo.last_post_id += 1 335 336notification = Notification({"type": "post", "repo": repo.route, "post": self.identifier}) 337db.session.add(notification) 338db.session.commit() # save the notification to get the ID 339 340# Send a notification to all users who have enabled forum notifications for this repo 341for relationship in RepoFavourite.query.filter_by(repo_route=repo.route, notify_forum=True).all(): 342user = relationship.user 343user_notification = UserNotification(user, notification, 1) 344db.session.add(user_notification) 345db.session.commit() 346celery_tasks.send_notification.apply_async(args=[user_notification.id]) 347 348def update_date(self): 349self.last_updated = datetime.now() 350with db.session.no_autoflush: 351if self.parent is not None: 352self.parent.update_date() 353 354 355class Comment(db.Model): 356identifier = db.Column(db.String(109), unique=True, nullable=False, primary_key=True) 357number = db.Column(db.Integer, nullable=False) 358repo_name = db.Column(db.String(98), db.ForeignKey("repo.route"), nullable=False) 359owner_name = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 360commit_identifier = db.Column(db.String(227), db.ForeignKey("commit.identifier"), nullable=False) 361pr_id = db.Column(db.BigInteger, db.ForeignKey("pull_request.id"), nullable=True) 362 363file = db.Column(db.String(256), nullable=True) 364line_number = db.Column(db.Integer, nullable=True) 365line_type = db.Column(db.SmallInteger, nullable=True, default=0, server_default="0") # 0 is deleted, 1 is modified 366 367state = db.Column(db.SmallInteger, nullable=True, default=1) 368review = db.Column(db.SmallInteger, nullable=True, default=0) 369 370date = db.Column(db.DateTime, default=datetime.now) 371message = db.Column(db.UnicodeText) 372html = db.Column(db.UnicodeText) 373 374repo = db.relationship("Repo", back_populates="comments") 375owner = db.relationship("User", back_populates="comments") 376commit = db.relationship("Commit", back_populates="comments") 377 378def __init__(self, owner, repo, commit, message, file, line_number, pr=None): 379self.identifier = f"{repo.route}/{repo.last_comment_id}" 380self.number = repo.last_comment_id 381self.repo_name = repo.route 382self.repo = repo 383self.owner_name = owner.username 384self.owner = owner 385self.commit_identifier = commit.identifier 386self.commit = commit 387self.message = message 388self.html = markdown.markdown2html(message).prettify() 389self.file = file 390self.line_number = int(line_number[1:]) 391self.line_type = int(line_number[0] == "+") 392if pr: 393self.pr = pr 394 395repo.last_comment_id += 1 396 397@property 398def text(self): 399return self.html 400 401@text.setter 402def text(self, value): 403self.html = markdown.markdown2html(value).prettify() 404self.message = value # message is stored in markdown format for future editing or plaintext display 405 406 407class UserNotification(db.Model): 408id = db.Column(db.Integer, primary_key=True) 409user_username = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 410notification_id = db.Column(db.BigInteger, db.ForeignKey("notification.id")) 411attention_level = db.Column(db.SmallInteger, nullable=False) # 0 is read 412read_time = db.Column(db.DateTime, nullable=True) 413 414user = db.relationship("User", back_populates="notifications") 415notification = db.relationship("Notification", back_populates="notifications") 416 417__table_args__ = (db.UniqueConstraint("user_username", "notification_id", name="_user_notification_uc"),) 418 419def __init__(self, user, notification, level): 420self.user_username = user.username 421self.notification_id = notification.id 422self.attention_level = level 423 424def mark_read(self): 425self.read_time = datetime.utcnow() 426self.attention_level = 0 427 428def mark_unread(self): 429self.attention_level = 4 430 431 432class UserFollow(db.Model): 433id = db.Column(db.Integer, primary_key=True) 434follower_username = db.Column(db.String(32), db.ForeignKey("user.username", ondelete="CASCADE"), nullable=False) 435followed_username = db.Column(db.String(32), db.ForeignKey("user.username", ondelete="CASCADE"), nullable=False) 436 437follower = db.relationship("User", back_populates="followers", foreign_keys=[follower_username]) 438followed = db.relationship("User", back_populates="follows", foreign_keys=[followed_username]) 439 440def __init__(self, follower_username, followed_username): 441self.follower_username = follower_username 442self.followed_username = followed_username 443 444 445class Notification(db.Model): 446id = db.Column(db.BigInteger, primary_key=True, autoincrement=True) 447data = db.Column(db.dialects.postgresql.JSONB, nullable=False, default={}) 448notifications = db.relationship("UserNotification", back_populates="notification") 449timestamp = db.Column(db.DateTime, nullable=False, default=datetime.now) 450 451def __init__(self, json): 452self.data = json 453 454 455class PullRequestResolvesThread(db.Model): 456id = db.Column(db.Integer, primary_key=True) 457pr_id = db.Column(db.BigInteger, db.ForeignKey("pull_request.id"), nullable=False) 458post_id = db.Column(db.String(109), db.ForeignKey("post.identifier"), nullable=False) 459 460pr = db.relationship("PullRequest", back_populates="resolves") 461post = db.relationship("Post", back_populates="resolved_by") 462 463def __init__(self, pr, post): 464self.pr = pr 465self.post = post 466 467 468class PullRequest(db.Model): 469id = db.Column(db.BigInteger, primary_key=True, autoincrement=True) 470head_route = db.Column(db.String(98), db.ForeignKey("repo.route", ondelete="CASCADE"), nullable=False) 471base_route = db.Column(db.String(98), db.ForeignKey("repo.route", ondelete="CASCADE"), nullable=False) 472owner_name = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 473state = db.Column(db.SmallInteger, nullable=False, default=0) # 0 pending, 1 merged, 2 rejected 474 475head = db.relationship("Repo", back_populates="heads", foreign_keys=[head_route]) 476base = db.relationship("Repo", back_populates="bases", foreign_keys=[base_route]) 477 478head_branch = db.Column(db.String(64), nullable=False) 479base_branch = db.Column(db.String(64), nullable=False) 480 481owner = db.relationship("User", back_populates="prs") 482resolves = db.relationship("PullRequestResolvesThread", back_populates="pr") 483timestamp = db.Column(db.DateTime, nullable=False, default=datetime.now) 484 485def __init__(self, head, head_branch, base, base_branch, owner): 486self.head = head 487self.base = base 488self.head_branch = head_branch 489self.base_branch = base_branch 490self.owner = owner 491 492@property 493def resolves_list(self): 494return " ".join([str(post.post.number) for post in self.resolves]) 495 496@resolves_list.setter 497def resolves_list(self, value): 498link_to = [Post.query.filter_by(number=int(number), repo=self.base).first() for number in value.split()] 499resolved_posts = [post.post for post in self.resolves] 500no_longer_resolves = list(set(resolved_posts) - set(link_to)) 501for post in no_longer_resolves: 502db.session.delete(PullRequestResolvesThread.query.filter_by(pr=self, post=post).first()) 503 504for post in link_to: 505if post not in resolved_posts and post is not None and not post.parent: # only top-level posts can be resolved 506db.session.add(PullRequestResolvesThread(self, post)) 507 508db.session.commit() 509 510 511class EmailChangeRequest(db.Model): 512id = db.Column(db.BigInteger, primary_key=True, autoincrement=True) 513user_username = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 514new_email = db.Column(db.String(254), nullable=False) 515code = db.Column(db.String(64), nullable=False) 516expires_on = db.Column(db.DateTime, nullable=False) 517 518user = db.relationship("User", back_populates="email_change_requests") 519 520def __init__(self, user, new_email): 521self.user = user 522self.new_email = new_email 523self.code = hex(secrets.randbits(256)).removeprefix("0x") 524self.expires_on = datetime.now() + timedelta(days=1) 525 526