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) 254html = db.Column(db.UnicodeText) 255repo = db.relationship("Repo", back_populates="posts") 256owner = db.relationship("User", back_populates="posts") 257parent = db.relationship("Post", back_populates="children", 258primaryjoin="Post.parent_id==Post.identifier", 259foreign_keys="[Post.parent_id]", remote_side="Post.identifier") 260root = db.relationship("Post", 261primaryjoin="Post.root_id==Post.identifier", 262foreign_keys="[Post.root_id]", remote_side="Post.identifier") 263children = db.relationship("Post", 264remote_side="Post.parent_id", 265primaryjoin="Post.identifier==Post.parent_id", 266foreign_keys="[Post.parent_id]") 267 268def __init__(self, owner, repo, parent, subject, message): 269self.identifier = f"{repo.route}/{repo.last_post_id}" 270self.number = repo.last_post_id 271self.repo_name = repo.route 272self.repo = repo 273self.owner_name = owner.username 274self.owner = owner 275self.subject = subject 276self.message = message 277self.html = markdown.markdown2html(message).prettify() 278self.parent = parent 279if parent and parent.parent: 280self.root = parent.parent 281elif parent: 282self.root = parent 283else: 284self.root = None 285repo.last_post_id += 1 286 287notification = Notification({"type": "post", "repo": repo.route, "post": self.identifier}) 288db.session.add(notification) 289db.session.commit() # save the notification to get the ID 290 291# Send a notification to all users who have enabled forum notifications for this repo 292for relationship in RepoFavourite.query.filter_by(repo_route=repo.route, notify_forum=True).all(): 293user = relationship.user 294user_notification = UserNotification(user, notification, 1) 295db.session.add(user_notification) 296db.session.commit() 297celery_tasks.send_notification.apply_async(args=[user_notification.id]) 298 299def update_date(self): 300self.last_updated = datetime.now() 301with db.session.no_autoflush: 302if self.parent is not None: 303self.parent.update_date() 304 305 306class Comment(db.Model): 307identifier = db.Column(db.String(109), unique=True, nullable=False, primary_key=True) 308number = db.Column(db.Integer, nullable=False) 309repo_name = db.Column(db.String(98), db.ForeignKey("repo.route"), nullable=False) 310owner_name = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 311commit_identifier = db.Column(db.String(227), db.ForeignKey("commit.identifier"), nullable=False) 312pr_id = db.Column(db.BigInteger, db.ForeignKey("pull_request.id"), nullable=True) 313 314file = db.Column(db.String(256), nullable=True) 315line_number = db.Column(db.Integer, nullable=True) 316line_type = db.Column(db.SmallInteger, nullable=True, default=0, server_default="0") # 0 is deleted, 1 is modified 317 318state = db.Column(db.SmallInteger, nullable=True, default=1) 319review = db.Column(db.SmallInteger, nullable=True, default=0) 320 321date = db.Column(db.DateTime, default=datetime.now) 322message = db.Column(db.UnicodeText) 323html = db.Column(db.UnicodeText) 324 325repo = db.relationship("Repo", back_populates="comments") 326owner = db.relationship("User", back_populates="comments") 327commit = db.relationship("Commit", back_populates="comments") 328 329def __init__(self, owner, repo, commit, message, file, line_number, pr=None): 330self.identifier = f"{repo.route}/{repo.last_comment_id}" 331self.number = repo.last_comment_id 332self.repo_name = repo.route 333self.repo = repo 334self.owner_name = owner.username 335self.owner = owner 336self.commit_identifier = commit.identifier 337self.commit = commit 338self.message = message 339self.html = markdown.markdown2html(message).prettify() 340self.file = file 341self.line_number = int(line_number[1:]) 342self.line_type = int(line_number[0] == "+") 343if pr: 344self.pr = pr 345 346repo.last_comment_id += 1 347 348@property 349def text(self): 350return self.html 351 352@text.setter 353def text(self, value): 354self.html = markdown.markdown2html(value).prettify() 355self.message = value # message is stored in markdown format for future editing or plaintext display 356 357 358class UserNotification(db.Model): 359id = db.Column(db.Integer, primary_key=True) 360user_username = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 361notification_id = db.Column(db.BigInteger, db.ForeignKey("notification.id")) 362attention_level = db.Column(db.SmallInteger, nullable=False) # 0 is read 363read_time = db.Column(db.DateTime, nullable=True) 364 365user = db.relationship("User", back_populates="notifications") 366notification = db.relationship("Notification", back_populates="notifications") 367 368__table_args__ = (db.UniqueConstraint("user_username", "notification_id", name="_user_notification_uc"),) 369 370def __init__(self, user, notification, level): 371self.user_username = user.username 372self.notification_id = notification.id 373self.attention_level = level 374 375def mark_read(self): 376self.read_time = datetime.utcnow() 377self.attention_level = 0 378 379def mark_unread(self): 380self.attention_level = 4 381 382 383class UserFollow(db.Model): 384id = db.Column(db.Integer, primary_key=True) 385follower_username = db.Column(db.String(32), db.ForeignKey("user.username", ondelete="CASCADE"), nullable=False) 386followed_username = db.Column(db.String(32), db.ForeignKey("user.username", ondelete="CASCADE"), nullable=False) 387 388follower = db.relationship("User", back_populates="followers", foreign_keys=[follower_username]) 389followed = db.relationship("User", back_populates="follows", foreign_keys=[followed_username]) 390 391def __init__(self, follower_username, followed_username): 392self.follower_username = follower_username 393self.followed_username = followed_username 394 395 396class Notification(db.Model): 397id = db.Column(db.BigInteger, primary_key=True, autoincrement=True) 398data = db.Column(db.dialects.postgresql.JSONB, nullable=False, default={}) 399notifications = db.relationship("UserNotification", back_populates="notification") 400timestamp = db.Column(db.DateTime, nullable=False, default=datetime.now) 401 402def __init__(self, json): 403self.data = json 404 405 406class PullRequest(db.Model): 407id = db.Column(db.BigInteger, primary_key=True, autoincrement=True) 408head_route = db.Column(db.String(98), db.ForeignKey("repo.route", ondelete="CASCADE"), nullable=False) 409base_route = db.Column(db.String(98), db.ForeignKey("repo.route", ondelete="CASCADE"), nullable=False) 410owner_name = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 411state = db.Column(db.SmallInteger, nullable=False, default=0) # 0 pending, 1 merged, 2 rejected 412 413head = db.relationship("Repo", back_populates="heads", foreign_keys=[head_route]) 414base = db.relationship("Repo", back_populates="bases", foreign_keys=[base_route]) 415 416head_branch = db.Column(db.String(64), nullable=False) 417base_branch = db.Column(db.String(64), nullable=False) 418 419owner = db.relationship("User", back_populates="prs") 420timestamp = db.Column(db.DateTime, nullable=False, default=datetime.now) 421 422def __init__(self, head, head_branch, base, base_branch, owner): 423self.head = head 424self.base = base 425self.head_branch = head_branch 426self.base_branch = base_branch 427self.owner = owner 428 429 430class EmailChangeRequest(db.Model): 431id = db.Column(db.BigInteger, primary_key=True, autoincrement=True) 432user_username = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 433new_email = db.Column(db.String(254), nullable=False) 434code = db.Column(db.String(64), nullable=False) 435expires_on = db.Column(db.DateTime, nullable=False) 436 437user = db.relationship("User", back_populates="email_change_requests") 438 439def __init__(self, user, new_email): 440self.user = user 441self.new_email = new_email 442self.code = hex(secrets.randbits(256)).removeprefix("0x") 443self.expires_on = datetime.now() + timedelta(days=1) 444 445