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