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