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