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) 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) 302line_type = db.Column(db.SmallInteger, nullable=True, default=0, server_default="0") # 0 is deleted, 1 is modified 303 304state = db.Column(db.SmallInteger, nullable=True, default=1) 305review = db.Column(db.SmallInteger, nullable=True, default=0) 306 307date = db.Column(db.DateTime, default=datetime.now) 308message = db.Column(db.UnicodeText) 309html = db.Column(db.UnicodeText) 310 311repo = db.relationship("Repo", back_populates="comments") 312owner = db.relationship("User", back_populates="comments") 313commit = db.relationship("Commit", back_populates="comments") 314 315def __init__(self, owner, repo, commit, message, file, line_number, pr=None): 316self.identifier = f"{repo.route}/{repo.last_comment_id}" 317self.number = repo.last_comment_id 318self.repo_name = repo.route 319self.repo = repo 320self.owner_name = owner.username 321self.owner = owner 322self.commit_identifier = commit.identifier 323self.commit = commit 324self.message = message 325self.html = markdown.markdown2html(message).prettify() 326self.file = file 327self.line_number = int(line_number[1:]) 328self.line_type = int(line_number[0] == "+") 329if pr: 330self.pr = pr 331 332repo.last_comment_id += 1 333 334@property 335def text(self): 336return self.html 337 338@text.setter 339def text(self, value): 340self.html = markdown.markdown2html(value).prettify() 341self.message = value # message is stored in markdown format for future editing or plaintext display 342 343 344class UserNotification(db.Model): 345id = db.Column(db.Integer, primary_key=True) 346user_username = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 347notification_id = db.Column(db.BigInteger, db.ForeignKey("notification.id")) 348attention_level = db.Column(db.SmallInteger, nullable=False) # 0 is read 349read_time = db.Column(db.DateTime, nullable=True) 350 351user = db.relationship("User", back_populates="notifications") 352notification = db.relationship("Notification", back_populates="notifications") 353 354__table_args__ = (db.UniqueConstraint("user_username", "notification_id", name="_user_notification_uc"),) 355 356def __init__(self, user, notification, level): 357self.user_username = user.username 358self.notification_id = notification.id 359self.attention_level = level 360 361def mark_read(self): 362self.read_time = datetime.utcnow() 363self.attention_level = 0 364 365def mark_unread(self): 366self.attention_level = 4 367 368 369class UserFollow(db.Model): 370id = db.Column(db.Integer, primary_key=True) 371follower_username = db.Column(db.String(32), db.ForeignKey("user.username", ondelete="CASCADE"), nullable=False) 372followed_username = db.Column(db.String(32), db.ForeignKey("user.username", ondelete="CASCADE"), nullable=False) 373 374follower = db.relationship("User", back_populates="followers", foreign_keys=[follower_username]) 375followed = db.relationship("User", back_populates="follows", foreign_keys=[followed_username]) 376 377def __init__(self, follower_username, followed_username): 378self.follower_username = follower_username 379self.followed_username = followed_username 380 381 382class Notification(db.Model): 383id = db.Column(db.BigInteger, primary_key=True, autoincrement=True) 384data = db.Column(db.dialects.postgresql.JSONB, nullable=False, default={}) 385notifications = db.relationship("UserNotification", back_populates="notification") 386timestamp = db.Column(db.DateTime, nullable=False, default=datetime.now) 387 388def __init__(self, json): 389self.data = json 390 391 392class PullRequest(db.Model): 393id = db.Column(db.BigInteger, primary_key=True, autoincrement=True) 394head_route = db.Column(db.String(98), db.ForeignKey("repo.route", ondelete="CASCADE"), nullable=False) 395base_route = db.Column(db.String(98), db.ForeignKey("repo.route", ondelete="CASCADE"), nullable=False) 396owner_name = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 397state = db.Column(db.SmallInteger, nullable=False, default=0) # 0 pending, 1 merged, 2 rejected 398 399head = db.relationship("Repo", back_populates="heads", foreign_keys=[head_route]) 400base = db.relationship("Repo", back_populates="bases", foreign_keys=[base_route]) 401 402head_branch = db.Column(db.String(64), nullable=False) 403base_branch = db.Column(db.String(64), nullable=False) 404 405owner = db.relationship("User", back_populates="prs") 406timestamp = db.Column(db.DateTime, nullable=False, default=datetime.now) 407 408def __init__(self, head, head_branch, base, base_branch, owner): 409self.head = head 410self.base = base 411self.head_branch = head_branch 412self.base_branch = base_branch 413self.owner = owner 414 415class EmailChangeRequest(db.Model): 416id = db.Column(db.BigInteger, primary_key=True, autoincrement=True) 417user_username = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 418new_email = db.Column(db.String(254), nullable=False) 419code = db.Column(db.String(64), nullable=False) 420expires_on = db.Column(db.DateTime, nullable=False) 421 422user = db.relationship("User", back_populates="email_change_requests") 423 424def __init__(self, user, new_email): 425self.user = user 426self.new_email = new_email 427self.code = hex(secrets.randbits(256)).removeprefix("0x") 428self.expires_on = datetime.now() + timedelta(days=1) 429 430