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") 183 184has_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 185server_default="0") # (the one accessible at username.localhost) 186site_branch = db.Column(db.String(64), nullable=True) 187 188last_post_id = db.Column(db.Integer, nullable=False, default=0, server_default="0") 189last_comment_id = db.Column(db.Integer, nullable=False, default=0, server_default="0") 190 191def __init__(self, owner, name, visibility): 192self.route = f"/{owner.username}/{name}" 193self.name = name 194self.owner_name = owner.username 195self.owner = owner 196self.visibility = visibility 197 198# Add the owner as an admin 199repo_access = RepoAccess(owner, self, 2) 200db.session.add(repo_access) 201 202# Create the directory 203if not os.path.exists(os.path.join(config.REPOS_PATH, self.owner_name, self.name)): 204subprocess.run(["git", "init", self.name], 205cwd=os.path.join(config.REPOS_PATH, self.owner_name)) 206 207 208class Commit(db.Model): 209identifier = db.Column(db.String(227), unique=True, nullable=False, primary_key=True) 210sha = db.Column(db.String(128), nullable=False) 211repo_name = db.Column(db.String(98), db.ForeignKey("repo.route"), nullable=False) 212owner_name = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 213owner_identity = db.Column(db.String(321)) 214receive_date = db.Column(db.DateTime, default=datetime.now) 215author_date = db.Column(db.DateTime) 216message = db.Column(db.UnicodeText) 217repo = db.relationship("Repo", back_populates="commits") 218owner = db.relationship("User", back_populates="commits") 219 220comments = db.relationship("Comment", back_populates="commit") 221 222def __init__(self, sha, owner, repo, date, message, owner_identity): 223self.identifier = f"{repo.route}/{sha}" 224self.sha = sha 225self.repo_name = repo.route 226self.repo = repo 227self.owner_name = owner.username 228self.owner = owner 229self.author_date = datetime.fromtimestamp(int(date)) 230self.message = message 231self.owner_identity = owner_identity 232 233notification = Notification({"type": "commit", "repo": repo.route, "commit": sha}) 234db.session.add(notification) 235db.session.commit() # save the notification to get the ID 236 237# Send a notification to all users who have enabled commit notifications for this repo 238for relationship in RepoFavourite.query.filter_by(repo_route=repo.route, notify_commit=True).all(): 239user = relationship.user 240user_notification = UserNotification(user, notification, 1) 241db.session.add(user_notification) 242db.session.commit() 243celery_tasks.send_notification.apply_async(args=[user_notification.id]) 244 245 246class Post(db.Model): 247identifier = db.Column(db.String(109), unique=True, nullable=False, primary_key=True) 248number = db.Column(db.Integer, nullable=False) 249repo_name = db.Column(db.String(98), db.ForeignKey("repo.route"), nullable=False) 250owner_name = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 251votes = db.relationship("PostVote", back_populates="post") 252vote_sum = db.Column(db.Integer, nullable=False, default=0) 253 254parent_id = db.Column(db.String(109), db.ForeignKey("post.identifier"), nullable=True) 255root_id = db.Column(db.String(109), db.ForeignKey("post.identifier"), nullable=True) 256state = db.Column(db.SmallInteger, nullable=True, default=1) 257 258date = db.Column(db.DateTime, default=datetime.now) 259last_updated = db.Column(db.DateTime, default=datetime.now) 260subject = db.Column(db.Unicode(384)) 261message = db.Column(db.UnicodeText) 262html = db.Column(db.UnicodeText) 263repo = db.relationship("Repo", back_populates="posts") 264owner = db.relationship("User", back_populates="posts") 265parent = db.relationship("Post", back_populates="children", 266primaryjoin="Post.parent_id==Post.identifier", 267foreign_keys="[Post.parent_id]", remote_side="Post.identifier") 268root = db.relationship("Post", 269primaryjoin="Post.root_id==Post.identifier", 270foreign_keys="[Post.root_id]", remote_side="Post.identifier") 271children = db.relationship("Post", 272remote_side="Post.parent_id", 273primaryjoin="Post.identifier==Post.parent_id", 274foreign_keys="[Post.parent_id]") 275resolved_by = db.relationship("PullRequestResolvesThread", back_populates="post") 276 277def __init__(self, owner, repo, parent, subject, message): 278self.identifier = f"{repo.route}/{repo.last_post_id}" 279self.number = repo.last_post_id 280self.repo_name = repo.route 281self.repo = repo 282self.owner_name = owner.username 283self.owner = owner 284self.subject = subject 285self.message = message 286self.html = markdown.markdown2html(message).prettify() 287self.parent = parent 288if parent and parent.parent: 289self.root = parent.parent 290elif parent: 291self.root = parent 292else: 293self.root = None 294repo.last_post_id += 1 295 296notification = Notification({"type": "post", "repo": repo.route, "post": self.identifier}) 297db.session.add(notification) 298db.session.commit() # save the notification to get the ID 299 300# Send a notification to all users who have enabled forum notifications for this repo 301for relationship in RepoFavourite.query.filter_by(repo_route=repo.route, notify_forum=True).all(): 302user = relationship.user 303user_notification = UserNotification(user, notification, 1) 304db.session.add(user_notification) 305db.session.commit() 306celery_tasks.send_notification.apply_async(args=[user_notification.id]) 307 308def update_date(self): 309self.last_updated = datetime.now() 310with db.session.no_autoflush: 311if self.parent is not None: 312self.parent.update_date() 313 314 315class Comment(db.Model): 316identifier = db.Column(db.String(109), unique=True, nullable=False, primary_key=True) 317number = db.Column(db.Integer, nullable=False) 318repo_name = db.Column(db.String(98), db.ForeignKey("repo.route"), nullable=False) 319owner_name = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 320commit_identifier = db.Column(db.String(227), db.ForeignKey("commit.identifier"), nullable=False) 321pr_id = db.Column(db.BigInteger, db.ForeignKey("pull_request.id"), nullable=True) 322 323file = db.Column(db.String(256), nullable=True) 324line_number = db.Column(db.Integer, nullable=True) 325line_type = db.Column(db.SmallInteger, nullable=True, default=0, server_default="0") # 0 is deleted, 1 is modified 326 327state = db.Column(db.SmallInteger, nullable=True, default=1) 328review = db.Column(db.SmallInteger, nullable=True, default=0) 329 330date = db.Column(db.DateTime, default=datetime.now) 331message = db.Column(db.UnicodeText) 332html = db.Column(db.UnicodeText) 333 334repo = db.relationship("Repo", back_populates="comments") 335owner = db.relationship("User", back_populates="comments") 336commit = db.relationship("Commit", back_populates="comments") 337 338def __init__(self, owner, repo, commit, message, file, line_number, pr=None): 339self.identifier = f"{repo.route}/{repo.last_comment_id}" 340self.number = repo.last_comment_id 341self.repo_name = repo.route 342self.repo = repo 343self.owner_name = owner.username 344self.owner = owner 345self.commit_identifier = commit.identifier 346self.commit = commit 347self.message = message 348self.html = markdown.markdown2html(message).prettify() 349self.file = file 350self.line_number = int(line_number[1:]) 351self.line_type = int(line_number[0] == "+") 352if pr: 353self.pr = pr 354 355repo.last_comment_id += 1 356 357@property 358def text(self): 359return self.html 360 361@text.setter 362def text(self, value): 363self.html = markdown.markdown2html(value).prettify() 364self.message = value # message is stored in markdown format for future editing or plaintext display 365 366 367class UserNotification(db.Model): 368id = db.Column(db.Integer, primary_key=True) 369user_username = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 370notification_id = db.Column(db.BigInteger, db.ForeignKey("notification.id")) 371attention_level = db.Column(db.SmallInteger, nullable=False) # 0 is read 372read_time = db.Column(db.DateTime, nullable=True) 373 374user = db.relationship("User", back_populates="notifications") 375notification = db.relationship("Notification", back_populates="notifications") 376 377__table_args__ = (db.UniqueConstraint("user_username", "notification_id", name="_user_notification_uc"),) 378 379def __init__(self, user, notification, level): 380self.user_username = user.username 381self.notification_id = notification.id 382self.attention_level = level 383 384def mark_read(self): 385self.read_time = datetime.utcnow() 386self.attention_level = 0 387 388def mark_unread(self): 389self.attention_level = 4 390 391 392class UserFollow(db.Model): 393id = db.Column(db.Integer, primary_key=True) 394follower_username = db.Column(db.String(32), db.ForeignKey("user.username", ondelete="CASCADE"), nullable=False) 395followed_username = db.Column(db.String(32), db.ForeignKey("user.username", ondelete="CASCADE"), nullable=False) 396 397follower = db.relationship("User", back_populates="followers", foreign_keys=[follower_username]) 398followed = db.relationship("User", back_populates="follows", foreign_keys=[followed_username]) 399 400def __init__(self, follower_username, followed_username): 401self.follower_username = follower_username 402self.followed_username = followed_username 403 404 405class Notification(db.Model): 406id = db.Column(db.BigInteger, primary_key=True, autoincrement=True) 407data = db.Column(db.dialects.postgresql.JSONB, nullable=False, default={}) 408notifications = db.relationship("UserNotification", back_populates="notification") 409timestamp = db.Column(db.DateTime, nullable=False, default=datetime.now) 410 411def __init__(self, json): 412self.data = json 413 414 415class PullRequestResolvesThread(db.Model): 416id = db.Column(db.Integer, primary_key=True) 417pr_id = db.Column(db.BigInteger, db.ForeignKey("pull_request.id"), nullable=False) 418post_id = db.Column(db.String(109), db.ForeignKey("post.identifier"), nullable=False) 419 420pr = db.relationship("PullRequest", back_populates="resolves") 421post = db.relationship("Post", back_populates="resolved_by") 422 423def __init__(self, pr, post): 424self.pr = pr 425self.post = post 426 427 428class PullRequest(db.Model): 429id = db.Column(db.BigInteger, primary_key=True, autoincrement=True) 430head_route = db.Column(db.String(98), db.ForeignKey("repo.route", ondelete="CASCADE"), nullable=False) 431base_route = db.Column(db.String(98), db.ForeignKey("repo.route", ondelete="CASCADE"), nullable=False) 432owner_name = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 433state = db.Column(db.SmallInteger, nullable=False, default=0) # 0 pending, 1 merged, 2 rejected 434 435head = db.relationship("Repo", back_populates="heads", foreign_keys=[head_route]) 436base = db.relationship("Repo", back_populates="bases", foreign_keys=[base_route]) 437 438head_branch = db.Column(db.String(64), nullable=False) 439base_branch = db.Column(db.String(64), nullable=False) 440 441owner = db.relationship("User", back_populates="prs") 442resolves = db.relationship("PullRequestResolvesThread", back_populates="pr") 443timestamp = db.Column(db.DateTime, nullable=False, default=datetime.now) 444 445def __init__(self, head, head_branch, base, base_branch, owner): 446self.head = head 447self.base = base 448self.head_branch = head_branch 449self.base_branch = base_branch 450self.owner = owner 451 452@property 453def resolves_list(self): 454return " ".join([str(post.post.number) for post in self.resolves]) 455 456@resolves_list.setter 457def resolves_list(self, value): 458link_to = [Post.query.filter_by(number=int(number), repo=self.base).first() for number in value.split()] 459resolved_posts = [post.post for post in self.resolves] 460no_longer_resolves = list(set(resolved_posts) - set(link_to)) 461for post in no_longer_resolves: 462db.session.delete(PullRequestResolvesThread.query.filter_by(pr=self, post=post).first()) 463 464for post in link_to: 465if post not in resolved_posts and post is not None and not post.parent: # only top-level posts can be resolved 466db.session.add(PullRequestResolvesThread(self, post)) 467 468db.session.commit() 469 470 471class EmailChangeRequest(db.Model): 472id = db.Column(db.BigInteger, primary_key=True, autoincrement=True) 473user_username = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 474new_email = db.Column(db.String(254), nullable=False) 475code = db.Column(db.String(64), nullable=False) 476expires_on = db.Column(db.DateTime, nullable=False) 477 478user = db.relationship("User", back_populates="email_change_requests") 479 480def __init__(self, user, new_email): 481self.user = user 482self.new_email = new_email 483self.code = hex(secrets.randbits(256)).removeprefix("0x") 484self.expires_on = datetime.now() + timedelta(days=1) 485 486