models.py
Python script, ASCII text executable
1""" 2This module defines Roundabout's SQLAlchemy database models. 3 4Roundabout - git hosting for everyone <https://roundabout-host.com> 5Copyright (C) 2023-2025 Roundabout developers <root@roundabout-host.com> 6 7This program is free software: you can redistribute it and/or modify 8it under the terms of the GNU Affero General Public License as published by 9the Free Software Foundation, either version 3 of the License, or 10(at your option) any later version. 11 12This program is distributed in the hope that it will be useful, 13but WITHOUT ANY WARRANTY; without even the implied warranty of 14MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15GNU Affero General Public License for more details. 16 17You should have received a copy of the GNU Affero General Public License 18along with this program. If not, see <http://www.gnu.org/licenses/>. 19""" 20 21__all__ = [ 22"RepoAccess", 23"RepoFavourite", 24"Repo", 25"UserFollow", 26"UserNotification", 27"User", 28"UserTrust", 29"Notification", 30"PostVote", 31"Post", 32"Commit", 33"PullRequest", 34"EmailChangeRequest", 35"Comment", 36"PullRequestResolvesThread", 37"Label", 38"PostLabel", 39] 40 41import secrets 42import subprocess 43 44import markdown 45from app import app, db, bcrypt 46import git 47from datetime import datetime, timedelta 48from enum import Enum 49from PIL import Image 50from cairosvg import svg2png 51import os 52import config 53import cairosvg 54import random 55import celery_tasks 56 57with (app.app_context()): 58class RepoAccess(db.Model): 59id = db.Column(db.Integer, primary_key=True) 60user_username = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 61repo_route = db.Column(db.String(98), db.ForeignKey("repo.route"), nullable=False) 62access_level = db.Column(db.SmallInteger(), nullable=False) # 0 read-only, 1 read-write, 2 admin 63automatic = db.Column(db.Boolean, default=False, nullable=False, server_default="false") 64 65user = db.relationship("User", back_populates="repo_access") 66repo = db.relationship("Repo", back_populates="repo_access") 67 68__table_args__ = (db.UniqueConstraint("user_username", "repo_route", name="_user_repo_uc"),) 69 70def __init__(self, user, repo, level, automatic=False): 71self.user_username = user.username 72self.repo_route = repo.route 73self.access_level = level 74self.automatic = automatic 75 76 77class RepoFavourite(db.Model): 78id = db.Column(db.Integer, primary_key=True) 79user_username = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 80repo_route = db.Column(db.String(98), db.ForeignKey("repo.route"), nullable=False) 81 82notify_commit = db.Column(db.Boolean, default=False, nullable=False) 83notify_forum = db.Column(db.Boolean, default=False, nullable=False) 84notify_pr = db.Column(db.Boolean, default=False, nullable=False) 85notify_admin = db.Column(db.Boolean, default=False, nullable=False) 86 87user = db.relationship("User", back_populates="favourites") 88repo = db.relationship("Repo", back_populates="favourites") 89 90__table_args__ = (db.UniqueConstraint("user_username", "repo_route", name="_user_repo_uc1"),) 91 92def __init__(self, user, repo): 93self.user_username = user.username 94self.repo_route = repo.route 95 96 97class PostVote(db.Model): 98id = db.Column(db.Integer, primary_key=True) 99user_username = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 100post_identifier = db.Column(db.String(109), db.ForeignKey("post.identifier"), nullable=False) 101vote_score = db.Column(db.SmallInteger(), nullable=False) 102 103user = db.relationship("User", back_populates="votes") 104post = db.relationship("Post", back_populates="votes") 105 106__table_args__ = (db.UniqueConstraint("user_username", "post_identifier", name="_user_post_uc"),) 107 108def __init__(self, user, post, score): 109self.user_username = user.username 110self.post_identifier = post.identifier 111self.vote_score = score 112 113 114class User(db.Model): 115username = db.Column(db.String(32), unique=True, nullable=False, primary_key=True) 116display_name = db.Column(db.Unicode(128), unique=False, nullable=True) 117bio = db.Column(db.Unicode(16384), unique=False, nullable=True) 118password_hashed = db.Column(db.String(60), nullable=False) 119email = db.Column(db.String(254), nullable=True) 120company = db.Column(db.Unicode(64), nullable=True) 121company_URL = db.Column(db.String(256), nullable=True) 122URL = db.Column(db.String(256), nullable=True) 123show_mail = db.Column(db.Boolean, default=False, nullable=False) 124location = db.Column(db.Unicode(64), nullable=True) 125creation_date = db.Column(db.DateTime, default=datetime.utcnow) 126default_page_length = db.Column(db.SmallInteger, nullable=False, default=32, server_default="32") 127max_post_nesting = db.Column(db.SmallInteger, nullable=False, default=3, server_default="3") 128 129repositories = db.relationship("Repo", back_populates="owner", cascade="all, delete-orphan") 130followers = db.relationship("UserFollow", back_populates="followed", foreign_keys="[UserFollow.followed_username]") 131follows = db.relationship("UserFollow", back_populates="follower", foreign_keys="[UserFollow.follower_username]") 132email_change_requests = db.relationship("EmailChangeRequest", back_populates="user") 133repo_access = db.relationship("RepoAccess", back_populates="user") 134votes = db.relationship("PostVote", back_populates="user") 135favourites = db.relationship("RepoFavourite", back_populates="user") 136 137pushes = db.relationship("Commit", back_populates="pusher", foreign_keys="[Commit.pusher_name]") 138posts = db.relationship("Post", back_populates="owner") 139comments = db.relationship("Comment", back_populates="owner") 140prs = db.relationship("PullRequest", back_populates="owner") 141notifications = db.relationship("UserNotification", back_populates="user") 142trusts = db.relationship("UserTrust", back_populates="host", foreign_keys="[UserTrust.host_username]") 143trusted_by = db.relationship("UserTrust", back_populates="trusted", foreign_keys="[UserTrust.trusted_username]") 144 145def __init__(self, username, password, email=None, display_name=None): 146self.username = username 147self.password_hashed = bcrypt.generate_password_hash(password, config.HASHING_ROUNDS).decode("utf-8") 148self.email = "" 149if email: 150email_change_request = EmailChangeRequest(self, email) 151db.session.add(email_change_request) 152db.session.flush() 153self.display_name = display_name 154 155# Create the user's directory 156if not os.path.exists(os.path.join(config.REPOS_PATH, username)): 157os.makedirs(os.path.join(config.REPOS_PATH, username)) 158if not os.path.exists(os.path.join(config.USERDATA_PATH, username)): 159os.makedirs(os.path.join(config.USERDATA_PATH, username)) 160 161avatar_name = random.choice(os.listdir(config.DEFAULT_AVATARS_PATH)) 162if os.path.join(config.DEFAULT_AVATARS_PATH, avatar_name).endswith(".svg"): 163cairosvg.svg2png(url=os.path.join(config.DEFAULT_AVATARS_PATH, avatar_name), 164write_to="/tmp/roundabout-avatar.png") 165avatar = Image.open("/tmp/roundabout-avatar.png") 166else: 167avatar = Image.open(os.path.join(config.DEFAULT_AVATARS_PATH, avatar_name)) 168avatar.thumbnail(config.AVATAR_SIZE) 169avatar.save(os.path.join(config.USERDATA_PATH, username, "avatar.png")) 170 171# Create the configuration repo 172config_repo = Repo(self, ".config", 0) 173db.session.add(config_repo) 174notification = Notification({"type": "welcome"}) 175db.session.add(notification) 176db.session.commit() 177 178user_notification = UserNotification(self, notification, 1) 179db.session.add(user_notification) 180db.session.commit() 181celery_tasks.send_notification.apply_async(args=[user_notification.id]) 182 183 184class UserTrust(db.Model): 185id = db.Column(db.Integer, primary_key=True) 186host_username = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 187trusted_username = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 188trust_level = db.Column(db.SmallInteger, nullable=False) 189 190host = db.relationship("User", back_populates="trusts", foreign_keys=[host_username]) 191trusted = db.relationship("User", back_populates="trusted_by", foreign_keys=[trusted_username]) 192 193__table_args__ = (db.UniqueConstraint("host_username", "trusted_username", name="_host_trusted_uc"),) 194 195def __init__(self, host, trusted, level): 196self.host_username = host.username 197self.trusted_username = trusted.username 198self.trust_level = level 199 200# Add user to all of the host's repositories 201for repo in host.repositories: 202existing_relationship = RepoAccess.query.filter_by(user=trusted, repo=repo).first() 203if existing_relationship: 204continue 205relationship = RepoAccess(trusted, repo, level, automatic=True) 206db.session.add(relationship) 207 208 209def cancel(self): 210"""Remove the trusted user from all of the host's repositories.""" 211relationships = RepoAccess.query.filter(RepoAccess.repo.has(owner_name=self.host_username), RepoAccess.user == self.trusted, RepoAccess.automatic == True) 212relationships.delete() 213db.session.delete(self) 214 215 216class Repo(db.Model): 217route = db.Column(db.String(98), unique=True, nullable=False, primary_key=True) 218owner_name = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 219name = db.Column(db.String(64), nullable=False) 220owner = db.relationship("User", back_populates="repositories") 221visibility = db.Column(db.SmallInteger(), nullable=False) 222info = db.Column(db.Unicode(512), nullable=True) 223url = db.Column(db.String(256), nullable=True) 224creation_date = db.Column(db.DateTime, default=datetime.utcnow) 225 226default_branch = db.Column(db.String(64), nullable=True, default="") 227 228commits = db.relationship("Commit", back_populates="repo", cascade="all, delete-orphan") 229posts = db.relationship("Post", back_populates="repo", cascade="all, delete-orphan") 230comments = db.relationship("Comment", back_populates="repo", 231cascade="all, delete-orphan") 232repo_access = db.relationship("RepoAccess", back_populates="repo", 233cascade="all, delete-orphan") 234favourites = db.relationship("RepoFavourite", back_populates="repo", 235cascade="all, delete-orphan") 236bases = db.relationship("PullRequest", back_populates="base", 237foreign_keys="[PullRequest.base_route]", 238cascade="all, delete-orphan") 239labels = db.relationship("Label", back_populates="repo", cascade="all, delete-orphan") 240 241has_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 242server_default="0") # (the one accessible at username.localhost) 243site_branch = db.Column(db.String(64), nullable=True) 244 245last_post_id = db.Column(db.Integer, nullable=False, default=0, server_default="0") 246last_comment_id = db.Column(db.Integer, nullable=False, default=0, server_default="0") 247 248def __init__(self, owner, name, visibility): 249self.route = f"/{owner.username}/{name}" 250self.name = name 251self.owner_name = owner.username 252self.owner = owner 253self.visibility = visibility 254 255# Add the owner as an admin 256repo_access = RepoAccess(owner, self, 2) 257db.session.add(repo_access) 258 259with db.session.no_autoflush: 260# Add the trusted users to the repo 261for trust in owner.trusts: 262if trust.trust_level > 0: 263repo_access = RepoAccess(trust.trusted, self, trust.trust_level, automatic=True) 264db.session.add(repo_access) 265 266# Create the directory 267if not os.path.exists(os.path.join(config.REPOS_PATH, self.owner_name, self.name)): 268subprocess.run(["git", "init", self.name], 269cwd=os.path.join(config.REPOS_PATH, self.owner_name)) 270 271 272class Commit(db.Model): 273identifier = db.Column(db.String(227), unique=True, nullable=False, primary_key=True) 274sha = db.Column(db.String(128), nullable=False) 275repo_name = db.Column(db.String(98), db.ForeignKey("repo.route"), nullable=False) 276owner_name = db.Column(db.String(128), nullable=False) 277owner_identity = db.Column(db.String(321)) 278pusher_name = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 279receive_date = db.Column(db.DateTime, default=datetime.now) 280author_date = db.Column(db.DateTime) 281message = db.Column(db.UnicodeText) 282repo = db.relationship("Repo", back_populates="commits") 283pusher = db.relationship("User", back_populates="pushes", foreign_keys=[pusher_name]) 284 285comments = db.relationship("Comment", back_populates="commit") 286 287def __init__(self, sha, owner, repo, date, message, owner_identity, pusher, owner_name=None): 288self.identifier = f"{repo.route}/{sha}" 289self.sha = sha 290self.repo_name = repo.route 291self.repo = repo 292if not owner: 293self.owner_name = owner_name 294else: 295self.owner_name = owner.username 296self.pusher_name = pusher.username 297self.pusher = pusher 298self.author_date = datetime.fromtimestamp(int(date)) 299self.message = message 300self.owner_identity = owner_identity 301 302notification = Notification({"type": "commit", "repo": repo.route, "commit": sha}) 303db.session.add(notification) 304db.session.commit() # save the notification to get the ID 305 306# Send a notification to all users who have enabled commit notifications for this repo 307for relationship in RepoFavourite.query.filter_by(repo_route=repo.route, notify_commit=True).all(): 308user = relationship.user 309user_notification = UserNotification(user, notification, 1) 310db.session.add(user_notification) 311db.session.commit() 312celery_tasks.send_notification.apply_async(args=[user_notification.id]) 313 314 315class Label(db.Model): 316identifier = db.Column(db.String(162), unique=True, nullable=False, primary_key=True) 317repo_name = db.Column(db.String(98), db.ForeignKey("repo.route"), nullable=False) 318name = db.Column(db.Unicode(64), nullable=False) 319colour = db.Column(db.Integer, nullable=False, server_default="0") 320 321repo = db.relationship("Repo", back_populates="labels") 322posts = db.relationship("PostLabel", back_populates="label") 323 324def __init__(self, repo, name, colour): 325self.identifier = f"{repo.route}/" + secrets.token_hex(32) # randomise label IDs 326self.name = name 327self.colour = int(colour.removeprefix("#"), 16) 328self.repo_name = repo.route 329 330@property 331def colour_hex(self): 332return f"#{self.colour:06x}" 333 334@colour_hex.setter 335def colour_hex(self, value): 336self.colour = int(value.removeprefix("#"), 16) 337 338 339class PostLabel(db.Model): 340id = db.Column(db.Integer, primary_key=True) 341post_identifier = db.Column(db.String(109), db.ForeignKey("post.identifier"), nullable=False) 342label_identifier = db.Column(db.String(162), db.ForeignKey("label.identifier"), nullable=False) 343 344post = db.relationship("Post", back_populates="labels") 345label = db.relationship("Label", back_populates="posts") 346 347def __init__(self, post, label): 348self.post_identifier = post.identifier 349self.post = post 350self.label = label 351 352 353class Post(db.Model): 354identifier = db.Column(db.String(109), unique=True, nullable=False, primary_key=True) 355number = db.Column(db.Integer, nullable=False) 356repo_name = db.Column(db.String(98), db.ForeignKey("repo.route"), nullable=False) 357owner_name = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 358votes = db.relationship("PostVote", back_populates="post") 359vote_sum = db.Column(db.Integer, nullable=False, default=0) 360 361parent_id = db.Column(db.String(109), db.ForeignKey("post.identifier"), nullable=True) 362root_id = db.Column(db.String(109), db.ForeignKey("post.identifier"), nullable=True) 363state = db.Column(db.SmallInteger, nullable=True, default=1) 364 365date = db.Column(db.DateTime, default=datetime.now) 366last_updated = db.Column(db.DateTime, default=datetime.now) 367subject = db.Column(db.Unicode(384)) 368message = db.Column(db.UnicodeText) 369html = db.Column(db.UnicodeText) 370repo = db.relationship("Repo", back_populates="posts") 371owner = db.relationship("User", back_populates="posts") 372parent = db.relationship("Post", back_populates="children", 373primaryjoin="Post.parent_id==Post.identifier", 374foreign_keys="[Post.parent_id]", remote_side="Post.identifier") 375root = db.relationship("Post", 376primaryjoin="Post.root_id==Post.identifier", 377foreign_keys="[Post.root_id]", remote_side="Post.identifier", post_update=True) 378children = db.relationship("Post", 379remote_side="Post.parent_id", 380primaryjoin="Post.identifier==Post.parent_id", 381foreign_keys="[Post.parent_id]") 382resolved_by = db.relationship("PullRequestResolvesThread", back_populates="post") 383labels = db.relationship("PostLabel", back_populates="post") 384 385def __init__(self, owner, repo, parent, subject, message): 386self.identifier = f"{repo.route}/{repo.last_post_id}" 387self.number = repo.last_post_id 388self.repo_name = repo.route 389self.repo = repo 390self.owner_name = owner.username 391self.owner = owner 392self.subject = subject 393self.message = message 394self.html = markdown.markdown2html(message).prettify() 395self.parent = parent 396if parent: 397self.root = parent.root 398else: 399self.root = self 400repo.last_post_id += 1 401 402notification = Notification({"type": "post", "repo": repo.route, "post": self.identifier}) 403db.session.add(notification) 404db.session.commit() # save the notification to get the ID 405 406# Send a notification to all users who have enabled forum notifications for this repo 407for relationship in RepoFavourite.query.filter_by(repo_route=repo.route, notify_forum=True).all(): 408user = relationship.user 409user_notification = UserNotification(user, notification, 1) 410db.session.add(user_notification) 411db.session.commit() 412celery_tasks.send_notification.apply_async(args=[user_notification.id]) 413 414def update_date(self): 415self.last_updated = datetime.now() 416with db.session.no_autoflush: 417if self.parent is not None: 418self.parent.update_date() 419 420 421class Comment(db.Model): 422identifier = db.Column(db.String(109), unique=True, nullable=False, primary_key=True) 423number = db.Column(db.Integer, nullable=False) 424repo_name = db.Column(db.String(98), db.ForeignKey("repo.route"), nullable=False) 425owner_name = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 426commit_identifier = db.Column(db.String(227), db.ForeignKey("commit.identifier"), nullable=False) 427pr_id = db.Column(db.BigInteger, db.ForeignKey("pull_request.id"), nullable=True) 428 429file = db.Column(db.String(256), nullable=True) 430line_number = db.Column(db.Integer, nullable=True) 431line_type = db.Column(db.SmallInteger, nullable=True, default=0, server_default="0") # 0 is deleted, 1 is modified 432 433state = db.Column(db.SmallInteger, nullable=True, default=1) 434review = db.Column(db.SmallInteger, nullable=True, default=0) 435 436date = db.Column(db.DateTime, default=datetime.now) 437message = db.Column(db.UnicodeText) 438html = db.Column(db.UnicodeText) 439 440repo = db.relationship("Repo", back_populates="comments") 441owner = db.relationship("User", back_populates="comments") 442commit = db.relationship("Commit", back_populates="comments") 443 444def __init__(self, owner, repo, commit, message, file, line_number, pr=None): 445self.identifier = f"{repo.route}/{repo.last_comment_id}" 446self.number = repo.last_comment_id 447self.repo_name = repo.route 448self.repo = repo 449self.owner_name = owner.username 450self.owner = owner 451self.commit_identifier = commit.identifier 452self.commit = commit 453self.message = message 454self.html = markdown.markdown2html(message).prettify() 455self.file = file 456self.line_number = int(line_number[1:]) 457self.line_type = int(line_number[0] == "+") 458if pr: 459self.pr = pr 460 461repo.last_comment_id += 1 462 463@property 464def text(self): 465return self.html 466 467@text.setter 468def text(self, value): 469self.html = markdown.markdown2html(value).prettify() 470self.message = value # message is stored in markdown format for future editing or plaintext display 471 472 473class UserNotification(db.Model): 474id = db.Column(db.Integer, primary_key=True) 475user_username = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 476notification_id = db.Column(db.BigInteger, db.ForeignKey("notification.id")) 477attention_level = db.Column(db.SmallInteger, nullable=False) # 0 is read 478read_time = db.Column(db.DateTime, nullable=True) 479 480user = db.relationship("User", back_populates="notifications") 481notification = db.relationship("Notification", back_populates="notifications") 482 483__table_args__ = (db.UniqueConstraint("user_username", "notification_id", name="_user_notification_uc"),) 484 485def __init__(self, user, notification, level): 486self.user_username = user.username 487self.notification_id = notification.id 488self.attention_level = level 489 490def mark_read(self): 491self.read_time = datetime.utcnow() 492self.attention_level = 0 493 494def mark_unread(self): 495self.attention_level = 4 496 497 498class UserFollow(db.Model): 499id = db.Column(db.Integer, primary_key=True) 500follower_username = db.Column(db.String(32), db.ForeignKey("user.username", ondelete="CASCADE"), nullable=False) 501followed_username = db.Column(db.String(32), db.ForeignKey("user.username", ondelete="CASCADE"), nullable=False) 502 503follower = db.relationship("User", back_populates="followers", foreign_keys=[follower_username]) 504followed = db.relationship("User", back_populates="follows", foreign_keys=[followed_username]) 505 506def __init__(self, follower_username, followed_username): 507self.follower_username = follower_username 508self.followed_username = followed_username 509 510 511class Notification(db.Model): 512id = db.Column(db.BigInteger, primary_key=True, autoincrement=True) 513data = db.Column(db.dialects.postgresql.JSONB, nullable=False, default={}) 514notifications = db.relationship("UserNotification", back_populates="notification") 515timestamp = db.Column(db.DateTime, nullable=False, default=datetime.now) 516 517def __init__(self, json): 518self.data = json 519 520 521class PullRequestResolvesThread(db.Model): 522id = db.Column(db.Integer, primary_key=True) 523pr_id = db.Column(db.BigInteger, db.ForeignKey("pull_request.id"), nullable=False) 524post_id = db.Column(db.String(109), db.ForeignKey("post.identifier"), nullable=False) 525 526pr = db.relationship("PullRequest", back_populates="resolves") 527post = db.relationship("Post", back_populates="resolved_by") 528 529def __init__(self, pr, post): 530self.pr = pr 531self.post = post 532 533 534class PullRequest(db.Model): 535id = db.Column(db.BigInteger, primary_key=True, autoincrement=True) 536head_route = db.Column(db.String(256), nullable=False) 537base_route = db.Column(db.String(98), db.ForeignKey("repo.route", ondelete="CASCADE"), nullable=False) 538owner_name = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 539state = db.Column(db.SmallInteger, nullable=False, default=0) # 0 pending, 1 merged, 2 rejected 540 541base = db.relationship("Repo", back_populates="bases", foreign_keys=[base_route]) 542 543head_branch = db.Column(db.String(64), nullable=False) 544base_branch = db.Column(db.String(64), nullable=False) 545 546owner = db.relationship("User", back_populates="prs") 547resolves = db.relationship("PullRequestResolvesThread", back_populates="pr") 548timestamp = db.Column(db.DateTime, nullable=False, default=datetime.now) 549 550def __init__(self, head_route, head_branch, base, base_branch, owner): 551self.head_route = head_route 552self.base = base 553self.head_branch = head_branch 554self.base_branch = base_branch 555self.owner = owner 556 557@property 558def resolves_list(self): 559return " ".join([str(post.post.number) for post in self.resolves]) 560 561@resolves_list.setter 562def resolves_list(self, value): 563link_to = [Post.query.filter_by(number=int(number), repo=self.base).first() for number in value.split()] 564resolved_posts = [post.post for post in self.resolves] 565no_longer_resolves = list(set(resolved_posts) - set(link_to)) 566for post in no_longer_resolves: 567db.session.delete(PullRequestResolvesThread.query.filter_by(pr=self, post=post).first()) 568 569for post in link_to: 570if post not in resolved_posts and post is not None and not post.parent: # only top-level posts can be resolved 571db.session.add(PullRequestResolvesThread(self, post)) 572 573db.session.commit() 574 575 576class EmailChangeRequest(db.Model): 577id = db.Column(db.BigInteger, primary_key=True, autoincrement=True) 578user_username = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 579new_email = db.Column(db.String(254), nullable=False) 580code = db.Column(db.String(64), nullable=False) 581expires_on = db.Column(db.DateTime, nullable=False) 582 583user = db.relationship("User", back_populates="email_change_requests") 584 585def __init__(self, user, new_email): 586self.user = user 587self.new_email = new_email 588self.code = hex(secrets.randbits(256)).removeprefix("0x") 589self.expires_on = datetime.now() + timedelta(days=1) 590 591