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