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