By using this site, you agree to have cookies stored on your device, strictly for functional purposes, such as storing your session and preferences.

Dismiss

 models.py

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