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