]> git.lizzy.rs Git - cheatdb.git/blob - app/models.py
711d9fd24d5d97ba5cfa861d60d6956c82751897
[cheatdb.git] / app / models.py
1 # ContentDB
2 # Copyright (C) 2018  rubenwardy
3 #
4 # This program is free software: you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation, either version 3 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program.  If not, see <https://www.gnu.org/licenses/>.
16
17
18 import enum, datetime
19
20 from app import app, gravatar
21 from urllib.parse import urlparse
22
23 from flask import Flask, url_for
24 from flask_sqlalchemy import SQLAlchemy, BaseQuery
25 from flask_migrate import Migrate
26 from flask_user import login_required, UserManager, UserMixin
27 from sqlalchemy import func, CheckConstraint
28 from sqlalchemy_searchable import SearchQueryMixin
29 from sqlalchemy_utils.types import TSVectorType
30 from sqlalchemy_searchable import make_searchable
31
32
33 # Initialise database
34 db = SQLAlchemy(app)
35 migrate = Migrate(app, db)
36 make_searchable(db.metadata)
37
38
39 class ArticleQuery(BaseQuery, SearchQueryMixin):
40         pass
41
42
43 class UserRank(enum.Enum):
44         BANNED         = 0
45         NOT_JOINED     = 1
46         NEW_MEMBER     = 2
47         MEMBER         = 3
48         TRUSTED_MEMBER = 4
49         EDITOR         = 5
50         MODERATOR      = 6
51         ADMIN          = 7
52
53         def atLeast(self, min):
54                 return self.value >= min.value
55
56         def getTitle(self):
57                 return self.name.replace("_", " ").title()
58
59         def toName(self):
60                 return self.name.lower()
61
62         def __str__(self):
63                 return self.name
64
65         @classmethod
66         def choices(cls):
67                 return [(choice, choice.getTitle()) for choice in cls]
68
69         @classmethod
70         def coerce(cls, item):
71                 return item if type(item) == UserRank else UserRank[item]
72
73
74 class Permission(enum.Enum):
75         EDIT_PACKAGE       = "EDIT_PACKAGE"
76         APPROVE_CHANGES    = "APPROVE_CHANGES"
77         DELETE_PACKAGE     = "DELETE_PACKAGE"
78         CHANGE_AUTHOR      = "CHANGE_AUTHOR"
79         CHANGE_NAME        = "CHANGE_NAME"
80         MAKE_RELEASE       = "MAKE_RELEASE"
81         DELETE_RELEASE     = "DELETE_RELEASE"
82         ADD_SCREENSHOTS    = "ADD_SCREENSHOTS"
83         REIMPORT_META      = "REIMPORT_META"
84         APPROVE_SCREENSHOT = "APPROVE_SCREENSHOT"
85         APPROVE_RELEASE    = "APPROVE_RELEASE"
86         APPROVE_NEW        = "APPROVE_NEW"
87         CHANGE_RELEASE_URL = "CHANGE_RELEASE_URL"
88         CHANGE_USERNAMES   = "CHANGE_USERNAMES"
89         CHANGE_RANK        = "CHANGE_RANK"
90         CHANGE_EMAIL       = "CHANGE_EMAIL"
91         EDIT_EDITREQUEST   = "EDIT_EDITREQUEST"
92         SEE_THREAD         = "SEE_THREAD"
93         CREATE_THREAD      = "CREATE_THREAD"
94         COMMENT_THREAD     = "COMMENT_THREAD"
95         LOCK_THREAD        = "LOCK_THREAD"
96         DELETE_REPLY       = "DELETE_REPLY"
97         EDIT_REPLY         = "EDIT_REPLY"
98         UNAPPROVE_PACKAGE  = "UNAPPROVE_PACKAGE"
99         TOPIC_DISCARD      = "TOPIC_DISCARD"
100         CREATE_TOKEN       = "CREATE_TOKEN"
101         EDIT_MAINTAINERS   = "EDIT_MAINTAINERS"
102         CHANGE_PROFILE_URLS = "CHANGE_PROFILE_URLS"
103
104         # Only return true if the permission is valid for *all* contexts
105         # See Package.checkPerm for package-specific contexts
106         def check(self, user):
107                 if not user.is_authenticated:
108                         return False
109
110                 if self == Permission.APPROVE_NEW or \
111                                 self == Permission.APPROVE_CHANGES    or \
112                                 self == Permission.APPROVE_RELEASE    or \
113                                 self == Permission.APPROVE_SCREENSHOT or \
114                                 self == Permission.SEE_THREAD:
115                         return user.rank.atLeast(UserRank.EDITOR)
116                 else:
117                         raise Exception("Non-global permission checked globally. Use Package.checkPerm or User.checkPerm instead.")
118
119 def display_name_default(context):
120     return context.get_current_parameters()["username"]
121
122 class User(db.Model, UserMixin):
123         id           = db.Column(db.Integer, primary_key=True)
124
125         # User authentication information
126         username     = db.Column(db.String(50, collation="NOCASE"), nullable=False, unique=True, index=True)
127         password     = db.Column(db.String(255), nullable=False, server_default="")
128         reset_password_token = db.Column(db.String(100), nullable=False, server_default="")
129
130         rank         = db.Column(db.Enum(UserRank))
131
132         # Account linking
133         github_username = db.Column(db.String(50, collation="NOCASE"), nullable=True, unique=True)
134         forums_username = db.Column(db.String(50, collation="NOCASE"), nullable=True, unique=True)
135
136         # Access token for webhook setup
137         github_access_token = db.Column(db.String(50), nullable=True, server_default=None)
138
139         # User email information
140         email         = db.Column(db.String(255), nullable=True, unique=True)
141         email_confirmed_at  = db.Column(db.DateTime())
142
143         # User information
144         profile_pic   = db.Column(db.String(255), nullable=True, server_default=None)
145         active        = db.Column("is_active", db.Boolean, nullable=False, server_default="0")
146         display_name  = db.Column(db.String(100), nullable=False, default=display_name_default)
147
148         # Links
149         website_url   = db.Column(db.String(255), nullable=True, default=None)
150         donate_url    = db.Column(db.String(255), nullable=True, default=None)
151
152         # Content
153         notifications = db.relationship("Notification", primaryjoin="User.id==Notification.user_id")
154
155         # causednotifs  = db.relationship("Notification", backref="causer", lazy="dynamic")
156         packages      = db.relationship("Package", backref=db.backref("author", lazy="joined"), lazy="dynamic")
157         requests      = db.relationship("EditRequest", backref="author", lazy="dynamic")
158         threads       = db.relationship("Thread", backref="author", lazy="dynamic")
159         tokens        = db.relationship("APIToken", backref="owner", lazy="dynamic")
160         replies       = db.relationship("ThreadReply", backref="author", lazy="dynamic")
161
162         def __init__(self, username=None, active=False, email=None, password=""):
163                 self.username = username
164                 self.email_confirmed_at = datetime.datetime.now() - datetime.timedelta(days=6000)
165                 self.display_name = username
166                 self.active = active
167                 self.email = email
168                 self.password = password
169                 self.rank = UserRank.NOT_JOINED
170
171         def hasPassword(self):
172                 return self.password != ""
173
174         def canAccessTodoList(self):
175                 return Permission.APPROVE_NEW.check(self) or \
176                                 Permission.APPROVE_RELEASE.check(self) or \
177                                 Permission.APPROVE_CHANGES.check(self)
178
179         def isClaimed(self):
180                 return self.rank.atLeast(UserRank.NEW_MEMBER)
181
182         def getProfilePicURL(self):
183                 if self.profile_pic:
184                         return self.profile_pic
185                 else:
186                         return gravatar(self.email or "")
187
188         def checkPerm(self, user, perm):
189                 if not user.is_authenticated:
190                         return False
191
192                 if type(perm) == str:
193                         perm = Permission[perm]
194                 elif type(perm) != Permission:
195                         raise Exception("Unknown permission given to User.checkPerm()")
196
197                 # Members can edit their own packages, and editors can edit any packages
198                 if perm == Permission.CHANGE_AUTHOR:
199                         return user.rank.atLeast(UserRank.EDITOR)
200                 elif perm == Permission.CHANGE_RANK or perm == Permission.CHANGE_USERNAMES:
201                         return user.rank.atLeast(UserRank.MODERATOR)
202                 elif perm == Permission.CHANGE_EMAIL or perm == Permission.CHANGE_PROFILE_URLS:
203                         return user == self or user.rank.atLeast(UserRank.ADMIN)
204                 elif perm == Permission.CREATE_TOKEN:
205                         if user == self:
206                                 return user.rank.atLeast(UserRank.MEMBER)
207                         else:
208                                 return user.rank.atLeast(UserRank.MODERATOR) and user.rank.atLeast(self.rank)
209                 else:
210                         raise Exception("Permission {} is not related to users".format(perm.name))
211
212         def canCommentRL(self):
213                 one_min_ago = datetime.datetime.utcnow() - datetime.timedelta(minutes=1)
214                 if ThreadReply.query.filter_by(author=self) \
215                                 .filter(ThreadReply.created_at > one_min_ago).count() >= 3:
216                         return False
217
218                 hour_ago = datetime.datetime.utcnow() - datetime.timedelta(hours=1)
219                 if ThreadReply.query.filter_by(author=self) \
220                                 .filter(ThreadReply.created_at > hour_ago).count() >= 20:
221                         return False
222
223                 return True
224
225         def canOpenThreadRL(self):
226                 hour_ago = datetime.datetime.utcnow() - datetime.timedelta(hours=1)
227                 return Thread.query.filter_by(author=self) \
228                         .filter(Thread.created_at > hour_ago).count() < 2
229
230         def __eq__(self, other):
231                 if other is None:
232                         return False
233
234                 if not self.is_authenticated or not other.is_authenticated:
235                         return False
236
237                 assert self.id > 0
238                 return self.id == other.id
239
240 class UserEmailVerification(db.Model):
241         id      = db.Column(db.Integer, primary_key=True)
242         user_id = db.Column(db.Integer, db.ForeignKey("user.id"))
243         email   = db.Column(db.String(100))
244         token   = db.Column(db.String(32))
245         user    = db.relationship("User", foreign_keys=[user_id])
246
247 class Notification(db.Model):
248         id         = db.Column(db.Integer, primary_key=True)
249
250         user_id    = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
251         user       = db.relationship("User", foreign_keys=[user_id])
252
253         causer_id  = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
254         causer     = db.relationship("User", foreign_keys=[causer_id])
255
256         title      = db.Column(db.String(100), nullable=False)
257         url        = db.Column(db.String(200), nullable=True)
258
259         package_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=True)
260         package    = db.relationship("Package", foreign_keys=[package_id])
261
262         created_at = db.Column(db.DateTime, nullable=True, default=datetime.datetime.utcnow)
263
264         def __init__(self, user, causer, title, url, package=None):
265                 if len(title) > 100:
266                         title = title[:99] + "…"
267
268                 self.user    = user
269                 self.causer  = causer
270                 self.title   = title
271                 self.url     = url
272                 self.package = package
273
274
275 class License(db.Model):
276         id      = db.Column(db.Integer, primary_key=True)
277         name    = db.Column(db.String(50), nullable=False, unique=True)
278         is_foss = db.Column(db.Boolean,    nullable=False, default=True)
279
280         def __init__(self, v, is_foss=True):
281                 self.name = v
282                 self.is_foss = is_foss
283
284         def __str__(self):
285                 return self.name
286
287
288 class PackageType(enum.Enum):
289         MOD  = "Mod"
290         GAME = "Game"
291         TXP  = "Texture Pack"
292
293         def toName(self):
294                 return self.name.lower()
295
296         def __str__(self):
297                 return self.name
298
299         @classmethod
300         def get(cls, name):
301                 try:
302                         return PackageType[name.upper()]
303                 except KeyError:
304                         return None
305
306         @classmethod
307         def choices(cls):
308                 return [(choice, choice.value) for choice in cls]
309
310         @classmethod
311         def coerce(cls, item):
312                 return item if type(item) == PackageType else PackageType[item]
313
314
315 class PackagePropertyKey(enum.Enum):
316         name          = "Name"
317         title         = "Title"
318         short_desc     = "Short Description"
319         desc          = "Description"
320         type          = "Type"
321         license       = "License"
322         media_license = "Media License"
323         tags          = "Tags"
324         provides      = "Provides"
325         repo          = "Repository"
326         website       = "Website"
327         issueTracker  = "Issue Tracker"
328         forums        = "Forum Topic ID"
329
330         def convert(self, value):
331                 if self == PackagePropertyKey.tags:
332                         return ",".join([t.title for t in value])
333                 elif self == PackagePropertyKey.provides:
334                         return ",".join([t.name for t in value])
335                 else:
336                         return str(value)
337
338 provides = db.Table("provides",
339         db.Column("package_id",    db.Integer, db.ForeignKey("package.id"), primary_key=True),
340     db.Column("metapackage_id", db.Integer, db.ForeignKey("meta_package.id"), primary_key=True)
341 )
342
343 Tags = db.Table("tags",
344     db.Column("tag_id", db.Integer, db.ForeignKey("tag.id"), primary_key=True),
345     db.Column("package_id", db.Integer, db.ForeignKey("package.id"), primary_key=True)
346 )
347
348 maintainers = db.Table("maintainers",
349     db.Column("user_id", db.Integer, db.ForeignKey("user.id"), primary_key=True),
350     db.Column("package_id", db.Integer, db.ForeignKey("package.id"), primary_key=True)
351 )
352
353 class Dependency(db.Model):
354         id              = db.Column(db.Integer, primary_key=True)
355         depender_id     = db.Column(db.Integer, db.ForeignKey("package.id"),     nullable=True)
356         package_id      = db.Column(db.Integer, db.ForeignKey("package.id"),     nullable=True)
357         package         = db.relationship("Package", foreign_keys=[package_id])
358         meta_package_id = db.Column(db.Integer, db.ForeignKey("meta_package.id"), nullable=True)
359         optional        = db.Column(db.Boolean, nullable=False, default=False)
360         __table_args__  = (db.UniqueConstraint("depender_id", "package_id", "meta_package_id", name="_dependency_uc"), )
361
362         def __init__(self, depender=None, package=None, meta=None, optional=False):
363                 if depender is None:
364                         return
365
366                 self.depender = depender
367                 self.optional = optional
368
369                 packageProvided = package is not None
370                 metaProvided = meta is not None
371
372                 if packageProvided and not metaProvided:
373                         self.package = package
374                 elif metaProvided and not packageProvided:
375                         self.meta_package = meta
376                 else:
377                         raise Exception("Either meta or package must be given, but not both!")
378
379         def getName(self):
380                 if self.meta_package:
381                         return self.meta_package.name
382                 elif self.package:
383                         return self.package.name
384                 else:
385                         assert False
386
387         def __str__(self):
388                 if self.package is not None:
389                         return self.package.author.username + "/" + self.package.name
390                 elif self.meta_package is not None:
391                         return self.meta_package.name
392                 else:
393                         raise Exception("Meta and package are both none!")
394
395         @staticmethod
396         def SpecToList(depender, spec, cache={}):
397                 retval = []
398                 arr = spec.split(",")
399
400                 import re
401                 pattern1 = re.compile("^([a-z0-9_]+)$")
402                 pattern2 = re.compile("^([A-Za-z0-9_]+)/([a-z0-9_]+)$")
403
404                 for x in arr:
405                         x = x.strip()
406                         if x == "":
407                                 continue
408
409                         if pattern1.match(x):
410                                 meta = MetaPackage.GetOrCreate(x, cache)
411                                 retval.append(Dependency(depender, meta=meta))
412                         else:
413                                 m = pattern2.match(x)
414                                 username = m.group(1)
415                                 name     = m.group(2)
416                                 user = User.query.filter_by(username=username).first()
417                                 if user is None:
418                                         raise Exception("Unable to find user " + username)
419
420                                 package = Package.query.filter_by(author=user, name=name).first()
421                                 if package is None:
422                                         raise Exception("Unable to find package " + name + " by " + username)
423
424                                 retval.append(Dependency(depender, package=package))
425
426                 return retval
427
428
429 class Package(db.Model):
430         query_class  = ArticleQuery
431
432         id           = db.Column(db.Integer, primary_key=True)
433
434         # Basic details
435         author_id    = db.Column(db.Integer, db.ForeignKey("user.id"))
436         name         = db.Column(db.Unicode(100), nullable=False)
437         title        = db.Column(db.Unicode(100), nullable=False)
438         short_desc   = db.Column(db.Unicode(200), nullable=False)
439         desc         = db.Column(db.UnicodeText, nullable=True)
440         type         = db.Column(db.Enum(PackageType))
441         created_at   = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
442
443         name_valid = db.CheckConstraint("name ~* '^[a-z0-9_]+$'")
444
445         search_vector = db.Column(TSVectorType("name", "title", "short_desc", "desc", \
446                         weights={ "name": "A", "title": "B", "short_desc": "C", "desc": "D" }))
447
448         license_id   = db.Column(db.Integer, db.ForeignKey("license.id"), nullable=False, default=1)
449         license      = db.relationship("License", foreign_keys=[license_id])
450         media_license_id = db.Column(db.Integer, db.ForeignKey("license.id"), nullable=False, default=1)
451         media_license    = db.relationship("License", foreign_keys=[media_license_id])
452
453         approved     = db.Column(db.Boolean, nullable=False, default=False)
454         soft_deleted = db.Column(db.Boolean, nullable=False, default=False)
455
456         score        = db.Column(db.Float, nullable=False, default=0)
457         score_downloads = db.Column(db.Float, nullable=False, default=0)
458         downloads     = db.Column(db.Integer, nullable=False, default=0)
459
460         review_thread_id = db.Column(db.Integer, db.ForeignKey("thread.id"), nullable=True, default=None)
461         review_thread    = db.relationship("Thread", foreign_keys=[review_thread_id])
462
463         # Downloads
464         repo         = db.Column(db.String(200), nullable=True)
465         website      = db.Column(db.String(200), nullable=True)
466         issueTracker = db.Column(db.String(200), nullable=True)
467         forums       = db.Column(db.Integer,     nullable=True)
468
469         provides = db.relationship("MetaPackage", \
470                         secondary=provides, lazy="select", order_by=db.asc("name"), \
471                         backref=db.backref("packages", lazy="dynamic", order_by=db.desc("score")))
472
473         dependencies = db.relationship("Dependency", backref="depender", lazy="dynamic", foreign_keys=[Dependency.depender_id])
474
475         tags = db.relationship("Tag", secondary=Tags, lazy="select",
476                         backref=db.backref("packages", lazy=True))
477
478         releases = db.relationship("PackageRelease", backref="package",
479                         lazy="dynamic", order_by=db.desc("package_release_releaseDate"))
480
481         screenshots = db.relationship("PackageScreenshot", backref="package",
482                         lazy="dynamic", order_by=db.asc("package_screenshot_id"))
483
484         requests = db.relationship("EditRequest", backref="package",
485                         lazy="dynamic")
486
487         maintainers = db.relationship("User", secondary=maintainers, lazy="subquery")
488
489         def __init__(self, package=None):
490                 if package is None:
491                         return
492
493                 self.author_id = package.author_id
494                 self.created_at = package.created_at
495                 self.approved = package.approved
496
497                 self.maintainers.append(self.author)
498
499                 for e in PackagePropertyKey:
500                         setattr(self, e.name, getattr(package, e.name))
501
502         def getId(self):
503                 return "{}/{}".format(self.author.username, self.name)
504
505         def getIsFOSS(self):
506                 return self.license.is_foss and self.media_license.is_foss
507
508         def getIsOnGitHub(self):
509                 if self.repo is None:
510                         return False
511
512                 url = urlparse(self.repo)
513                 return url.netloc == "github.com"
514
515         def getGitHubFullName(self):
516                 if self.repo is None:
517                         return None
518
519                 url = urlparse(self.repo)
520                 if url.netloc != "github.com":
521                         return None
522
523                 import re
524                 m = re.search(r"^\/([^\/]+)\/([^\/]+)\/?$", url.path)
525                 if m is None:
526                         return
527
528                 user = m.group(1)
529                 repo = m.group(2).replace(".git", "")
530
531                 return (user,repo)
532
533         def getSortedDependencies(self, is_hard=None):
534                 query = self.dependencies
535                 if is_hard is not None:
536                         query = query.filter_by(optional=not is_hard)
537
538                 deps = query.all()
539                 deps.sort(key = lambda x: x.getName())
540                 return deps
541
542         def getSortedHardDependencies(self):
543                 return self.getSortedDependencies(True)
544
545         def getSortedOptionalDependencies(self):
546                 return self.getSortedDependencies(False)
547
548         def getState(self):
549                 if self.approved:
550                         return "approved"
551                 elif self.review_thread_id:
552                         return "thread"
553                 elif (self.type == PackageType.GAME or \
554                                         self.type == PackageType.TXP) and \
555                                 self.screenshots.count() == 0:
556                         return "wip"
557                 elif not self.getDownloadRelease():
558                         return "wip"
559                 elif "Other" in self.license.name or "Other" in self.media_license.name:
560                         return "license"
561                 else:
562                         return "ready"
563
564         def getAsDictionaryKey(self):
565                 return {
566                         "name": self.name,
567                         "author": self.author.display_name,
568                         "type": self.type.toName(),
569                 }
570
571         def getAsDictionaryShort(self, base_url, version=None):
572                 tnurl = self.getThumbnailURL(1)
573                 release = self.getDownloadRelease(version=version)
574                 return {
575                         "name": self.name,
576                         "title": self.title,
577                         "author": self.author.username,
578                         "short_description": self.short_desc,
579                         "type": self.type.toName(),
580                         "release": release and release.id,
581                         "thumbnail": (base_url + tnurl) if tnurl is not None else None
582                 }
583
584         def getAsDictionary(self, base_url, version=None):
585                 tnurl = self.getThumbnailURL(1)
586                 release = self.getDownloadRelease(version=version)
587                 return {
588                         "author": self.author.username,
589                         "name": self.name,
590                         "title": self.title,
591                         "short_description": self.short_desc,
592                         "desc": self.desc,
593                         "type": self.type.toName(),
594                         "created_at": self.created_at.isoformat(),
595
596                         "license": self.license.name,
597                         "media_license": self.media_license.name,
598
599                         "repo": self.repo,
600                         "website": self.website,
601                         "issue_tracker": self.issueTracker,
602                         "forums": self.forums,
603
604                         "provides": [x.name for x in self.provides],
605                         "thumbnail": (base_url + tnurl) if tnurl is not None else None,
606                         "screenshots": [base_url + ss.url for ss in self.screenshots],
607
608                         "url": base_url + self.getDownloadURL(),
609                         "release": release and release.id,
610
611                         "score": round(self.score * 10) / 10
612                 }
613
614         def getThumbnailURL(self, level=2):
615                 screenshot = self.screenshots.filter_by(approved=True).order_by(db.asc(PackageScreenshot.id)).first()
616                 return screenshot.getThumbnailURL(level) if screenshot is not None else None
617
618         def getMainScreenshotURL(self, absolute=False):
619                 screenshot = self.screenshots.filter_by(approved=True).order_by(db.asc(PackageScreenshot.id)).first()
620                 if screenshot is None:
621                         return None
622
623                 if absolute:
624                         from app.utils import abs_url
625                         return abs_url(screenshot.url)
626                 else:
627                         return screenshot.url
628
629         def getDetailsURL(self, absolute=False):
630                 if absolute:
631                         from app.utils import abs_url_for
632                         return abs_url_for("packages.view",
633                                         author=self.author.username, name=self.name)
634                 else:
635                         return url_for("packages.view",
636                                         author=self.author.username, name=self.name)
637
638         def getEditURL(self):
639                 return url_for("packages.create_edit",
640                                 author=self.author.username, name=self.name)
641
642         def getApproveURL(self):
643                 return url_for("packages.approve",
644                                 author=self.author.username, name=self.name)
645
646         def getRemoveURL(self):
647                 return url_for("packages.remove",
648                                 author=self.author.username, name=self.name)
649
650         def getNewScreenshotURL(self):
651                 return url_for("packages.create_screenshot",
652                                 author=self.author.username, name=self.name)
653
654         def getCreateReleaseURL(self):
655                 return url_for("packages.create_release",
656                                 author=self.author.username, name=self.name)
657
658         def getCreateEditRequestURL(self):
659                 return url_for("create_edit_editrequest_page",
660                                 author=self.author.username, name=self.name)
661
662         def getBulkReleaseURL(self):
663                 return url_for("packages.bulk_change_release",
664                         author=self.author.username, name=self.name)
665
666         def getDownloadURL(self):
667                 return url_for("packages.download",
668                                 author=self.author.username, name=self.name)
669
670         def getEditMaintainersURL(self):
671                 return url_for("packages.edit_maintainers",
672                                 author=self.author.username, name=self.name)
673
674         def getRemoveSelfMaintainerURL(self):
675                 return url_for("packages.remove_self_maintainers",
676                                 author=self.author.username, name=self.name)
677
678         def getUpdateFromReleaseURL(self):
679                 return url_for("packages.update_from_release",
680                                 author=self.author.username, name=self.name)
681
682         def getReviewURL(self):
683                 return url_for('packages.review',
684                                 author=self.author.username, name=self.name)
685
686         def getDownloadRelease(self, version=None):
687                 for rel in self.releases:
688                         if rel.approved and (version is None or
689                                         ((rel.min_rel is None or rel.min_rel_id <= version.id) and \
690                                         (rel.max_rel is None or rel.max_rel_id >= version.id))):
691                                 return rel
692
693                 return None
694
695         def checkPerm(self, user, perm):
696                 if not user.is_authenticated:
697                         return False
698
699                 if type(perm) == str:
700                         perm = Permission[perm]
701                 elif type(perm) != Permission:
702                         raise Exception("Unknown permission given to Package.checkPerm()")
703
704                 isOwner = user == self.author
705                 isMaintainer = isOwner or user.rank.atLeast(UserRank.EDITOR) or user in self.maintainers
706
707                 if perm == Permission.CREATE_THREAD:
708                         return user.rank.atLeast(UserRank.MEMBER)
709
710                 # Members can edit their own packages, and editors can edit any packages
711                 elif perm == Permission.MAKE_RELEASE or perm == Permission.ADD_SCREENSHOTS:
712                         return isMaintainer
713
714                 elif perm == Permission.EDIT_PACKAGE or perm == Permission.REIMPORT_META or \
715                                 perm == Permission.APPROVE_CHANGES or perm == Permission.APPROVE_RELEASE:
716                         return isMaintainer and user.rank.atLeast(UserRank.MEMBER if self.approved else UserRank.NEW_MEMBER)
717
718                 # Anyone can change the package name when not approved, but only editors when approved
719                 elif perm == Permission.CHANGE_NAME:
720                         return not self.approved or user.rank.atLeast(UserRank.EDITOR)
721
722                 # Editors can change authors and approve new packages
723                 elif perm == Permission.APPROVE_NEW or perm == Permission.CHANGE_AUTHOR:
724                         return user.rank.atLeast(UserRank.EDITOR)
725
726                 elif perm == Permission.APPROVE_SCREENSHOT:
727                         return isMaintainer and user.rank.atLeast(UserRank.TRUSTED_MEMBER if self.approved else UserRank.NEW_MEMBER)
728
729                 elif perm == Permission.EDIT_MAINTAINERS:
730                         return isOwner or user.rank.atLeast(UserRank.MODERATOR)
731
732                 elif perm == Permission.UNAPPROVE_PACKAGE or perm == Permission.DELETE_PACKAGE:
733                         return user.rank.atLeast(UserRank.EDITOR)
734
735                 elif perm == Permission.CHANGE_RELEASE_URL:
736                         return user.rank.atLeast(UserRank.MODERATOR)
737
738                 else:
739                         raise Exception("Permission {} is not related to packages".format(perm.name))
740
741         def getScoreDict(self):
742                 return {
743                         "author": self.author.username,
744                         "name": self.name,
745                         "score": self.score,
746                         "score_downloads": self.score_downloads,
747                         "score_reviews": self.score - self.score_downloads,
748                         "downloads": self.downloads
749                 }
750
751         def recalcScore(self):
752                 review_scores = [ 100 * r.asSign() for r in self.reviews ]
753                 self.score = self.score_downloads + sum(review_scores)
754
755
756 class MetaPackage(db.Model):
757         id           = db.Column(db.Integer, primary_key=True)
758         name         = db.Column(db.String(100), unique=True, nullable=False)
759         dependencies = db.relationship("Dependency", backref="meta_package", lazy="dynamic")
760
761         mp_name_valid = db.CheckConstraint("name ~* '^[a-z0-9_]+$'")
762
763         def __init__(self, name=None):
764                 self.name = name
765
766         def __str__(self):
767                 return self.name
768
769         @staticmethod
770         def ListToSpec(list):
771                 return ",".join([str(x) for x in list])
772
773         @staticmethod
774         def GetOrCreate(name, cache={}):
775                 mp = cache.get(name)
776                 if mp is None:
777                         mp = MetaPackage.query.filter_by(name=name).first()
778
779                 if mp is None:
780                         mp = MetaPackage(name)
781                         db.session.add(mp)
782
783                 cache[name] = mp
784                 return mp
785
786         @staticmethod
787         def SpecToList(spec, cache={}):
788                 retval = []
789                 arr = spec.split(",")
790
791                 import re
792                 pattern = re.compile("^([a-z0-9_]+)$")
793
794                 for x in arr:
795                         x = x.strip()
796                         if x == "":
797                                 continue
798
799                         if not pattern.match(x):
800                                 continue
801
802                         retval.append(MetaPackage.GetOrCreate(x, cache))
803
804                 return retval
805
806 class Tag(db.Model):
807         id              = db.Column(db.Integer, primary_key=True)
808         name            = db.Column(db.String(100), unique=True, nullable=False)
809         title           = db.Column(db.String(100), nullable=False)
810         backgroundColor = db.Column(db.String(6), nullable=False)
811         textColor       = db.Column(db.String(6), nullable=False)
812         views           = db.Column(db.Integer, nullable=False, default=0)
813
814         def __init__(self, title, backgroundColor="000000", textColor="ffffff"):
815                 self.title           = title
816                 self.backgroundColor = backgroundColor
817                 self.textColor       = textColor
818
819                 import re
820                 regex = re.compile("[^a-z_]")
821                 self.name = regex.sub("", self.title.lower().replace(" ", "_"))
822
823
824 class MinetestRelease(db.Model):
825         id       = db.Column(db.Integer, primary_key=True)
826         name     = db.Column(db.String(100), unique=True, nullable=False)
827         protocol = db.Column(db.Integer, nullable=False, default=0)
828
829         def __init__(self, name=None, protocol=0):
830                 self.name = name
831                 self.protocol = protocol
832
833         def getActual(self):
834                 return None if self.name == "None" else self
835
836         @classmethod
837         def get(cls, version, protocol_num):
838                 if version:
839                         parts = version.strip().split(".")
840                         if len(parts) >= 2:
841                                 major_minor = parts[0] + "." + parts[1]
842                                 query = MinetestRelease.query.filter(MinetestRelease.name.like("{}%".format(major_minor)))
843                                 if protocol_num:
844                                         query = query.filter_by(protocol=protocol_num)
845
846                                 release = query.one_or_none()
847                                 if release:
848                                         return release
849
850                 if protocol_num:
851                         return MinetestRelease.query.filter_by(protocol=protocol_num).first()
852
853                 return None
854
855
856 class PackageRelease(db.Model):
857         id           = db.Column(db.Integer, primary_key=True)
858
859         package_id   = db.Column(db.Integer, db.ForeignKey("package.id"))
860         title        = db.Column(db.String(100), nullable=False)
861         releaseDate  = db.Column(db.DateTime,    nullable=False)
862         url          = db.Column(db.String(200), nullable=False)
863         approved     = db.Column(db.Boolean, nullable=False, default=False)
864         task_id      = db.Column(db.String(37), nullable=True)
865         commit_hash  = db.Column(db.String(41), nullable=True, default=None)
866         downloads    = db.Column(db.Integer, nullable=False, default=0)
867
868         min_rel_id = db.Column(db.Integer, db.ForeignKey("minetest_release.id"), nullable=True, server_default=None)
869         min_rel    = db.relationship("MinetestRelease", foreign_keys=[min_rel_id])
870
871         max_rel_id = db.Column(db.Integer, db.ForeignKey("minetest_release.id"), nullable=True, server_default=None)
872         max_rel    = db.relationship("MinetestRelease", foreign_keys=[max_rel_id])
873
874         # If the release is approved, then the task_id must be null and the url must be present
875         CK_approval_valid = db.CheckConstraint("not approved OR (task_id IS NULL AND (url = '') IS NOT FALSE)")
876
877         def getAsDictionary(self):
878                 return {
879                         "id": self.id,
880                         "title": self.title,
881                         "url": self.url if self.url != "" else None,
882                         "release_date": self.releaseDate.isoformat(),
883                         "commit": self.commit_hash,
884                         "downloads": self.downloads,
885                         "min_protocol": self.min_rel and self.min_rel.protocol,
886                         "max_protocol": self.max_rel and self.max_rel.protocol
887                 }
888
889         def getEditURL(self):
890                 return url_for("packages.edit_release",
891                                 author=self.package.author.username,
892                                 name=self.package.name,
893                                 id=self.id)
894
895         def getDeleteURL(self):
896                 return url_for("packages.delete_release",
897                                 author=self.package.author.username,
898                                 name=self.package.name,
899                                 id=self.id)
900
901         def getDownloadURL(self):
902                 return url_for("packages.download_release",
903                                 author=self.package.author.username,
904                                 name=self.package.name,
905                                 id=self.id)
906
907         def __init__(self):
908                 self.releaseDate = datetime.datetime.now()
909
910         def approve(self, user):
911                 if not self.package.checkPerm(user, Permission.APPROVE_RELEASE):
912                         return False
913
914                 assert self.task_id is None and self.url is not None and self.url != ""
915
916                 self.approved = True
917                 return True
918
919         def checkPerm(self, user, perm):
920                 if not user.is_authenticated:
921                         return False
922
923                 if type(perm) == str:
924                         perm = Permission[perm]
925                 elif type(perm) != Permission:
926                         raise Exception("Unknown permission given to PackageRelease.checkPerm()")
927
928                 isOwner = user == self.package.author
929
930                 if perm == Permission.DELETE_RELEASE:
931                         if user.rank.atLeast(UserRank.ADMIN):
932                                 return True
933
934                         if not (isOwner or user.rank.atLeast(UserRank.EDITOR)):
935                                 return False
936
937                         if not self.package.approved or self.task_id is not None:
938                                 return True
939
940                         count = PackageRelease.query \
941                                         .filter_by(package_id=self.package_id) \
942                                         .filter(PackageRelease.id > self.id) \
943                                         .count()
944
945                         return count > 0
946                 else:
947                         raise Exception("Permission {} is not related to releases".format(perm.name))
948
949
950 # class PackageReview(db.Model):
951 #       id         = db.Column(db.Integer, primary_key=True)
952 #       package_id = db.Column(db.Integer, db.ForeignKey("package.id"))
953 #       thread_id  = db.Column(db.Integer, db.ForeignKey("thread.id"), nullable=False)
954 #       recommend  = db.Column(db.Boolean, nullable=False, default=True)
955
956
957 class PackageScreenshot(db.Model):
958         id         = db.Column(db.Integer, primary_key=True)
959         package_id = db.Column(db.Integer, db.ForeignKey("package.id"))
960         title      = db.Column(db.String(100), nullable=False)
961         url        = db.Column(db.String(100), nullable=False)
962         approved   = db.Column(db.Boolean, nullable=False, default=False)
963
964
965         def getEditURL(self):
966                 return url_for("packages.edit_screenshot",
967                                 author=self.package.author.username,
968                                 name=self.package.name,
969                                 id=self.id)
970
971         def getThumbnailURL(self, level=2):
972                 return self.url.replace("/uploads/", ("/thumbnails/{:d}/").format(level))
973
974
975 class APIToken(db.Model):
976         id           = db.Column(db.Integer, primary_key=True)
977         access_token = db.Column(db.String(34), unique=True)
978
979         name         = db.Column(db.String(100), nullable=False)
980         owner_id     = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
981         # owner is created using backref
982
983         created_at   = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
984
985         package_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=True)
986         package    = db.relationship("Package", foreign_keys=[package_id])
987
988         def canOperateOnPackage(self, package):
989                 if self.package and self.package != package:
990                         return False
991
992                 return package.author == self.owner
993
994
995 class EditRequest(db.Model):
996         id           = db.Column(db.Integer, primary_key=True)
997
998         package_id   = db.Column(db.Integer, db.ForeignKey("package.id"))
999         author_id    = db.Column(db.Integer, db.ForeignKey("user.id"))
1000
1001         title        = db.Column(db.String(100), nullable=False)
1002         desc         = db.Column(db.String(1000), nullable=True)
1003
1004         # 0 - open
1005         # 1 - merged
1006         # 2 - rejected
1007         status       = db.Column(db.Integer, nullable=False, default=0)
1008
1009         changes = db.relationship("EditRequestChange", backref="request",
1010                         lazy="dynamic")
1011
1012         def getURL(self):
1013                 return url_for("view_editrequest_page",
1014                                 author=self.package.author.username,
1015                                 name=self.package.name,
1016                                 id=self.id)
1017
1018         def getApproveURL(self):
1019                 return url_for("approve_editrequest_page",
1020                                 author=self.package.author.username,
1021                                 name=self.package.name,
1022                                 id=self.id)
1023
1024         def getRejectURL(self):
1025                 return url_for("reject_editrequest_page",
1026                                 author=self.package.author.username,
1027                                 name=self.package.name,
1028                                 id=self.id)
1029
1030         def getEditURL(self):
1031                 return url_for("create_edit_editrequest_page",
1032                                 author=self.package.author.username,
1033                                 name=self.package.name,
1034                                 id=self.id)
1035
1036         def applyAll(self, package):
1037                 for change in self.changes:
1038                         change.apply(package)
1039
1040
1041         def checkPerm(self, user, perm):
1042                 if not user.is_authenticated:
1043                         return False
1044
1045                 if type(perm) == str:
1046                         perm = Permission[perm]
1047                 elif type(perm) != Permission:
1048                         raise Exception("Unknown permission given to EditRequest.checkPerm()")
1049
1050                 isOwner = user == self.author
1051
1052                 # Members can edit their own packages, and editors can edit any packages
1053                 if perm == Permission.EDIT_EDITREQUEST:
1054                         return isOwner or user.rank.atLeast(UserRank.EDITOR)
1055
1056                 else:
1057                         raise Exception("Permission {} is not related to packages".format(perm.name))
1058
1059
1060
1061
1062 class EditRequestChange(db.Model):
1063         id           = db.Column(db.Integer, primary_key=True)
1064
1065         request_id   = db.Column(db.Integer, db.ForeignKey("edit_request.id"))
1066         key          = db.Column(db.Enum(PackagePropertyKey), nullable=False)
1067
1068         # TODO: make diff instead
1069         oldValue     = db.Column(db.Text, nullable=True)
1070         newValue     = db.Column(db.Text, nullable=True)
1071
1072         def apply(self, package):
1073                 if self.key == PackagePropertyKey.tags:
1074                         package.tags.clear()
1075                         for tagTitle in self.newValue.split(","):
1076                                 tag = Tag.query.filter_by(title=tagTitle.strip()).first()
1077                                 package.tags.append(tag)
1078
1079                 else:
1080                         setattr(package, self.key.name, self.newValue)
1081
1082
1083 watchers = db.Table("watchers",
1084     db.Column("user_id", db.Integer, db.ForeignKey("user.id"), primary_key=True),
1085     db.Column("thread_id", db.Integer, db.ForeignKey("thread.id"), primary_key=True)
1086 )
1087
1088 class Thread(db.Model):
1089         id         = db.Column(db.Integer, primary_key=True)
1090
1091         package_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=True)
1092         package    = db.relationship("Package", foreign_keys=[package_id])
1093
1094         review_id  = db.Column(db.Integer, db.ForeignKey("package_review.id"), nullable=True)
1095         review     = db.relationship("PackageReview", foreign_keys=[review_id])
1096
1097         author_id  = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
1098         title      = db.Column(db.String(100), nullable=False)
1099         private    = db.Column(db.Boolean, server_default="0", nullable=False)
1100
1101         locked     = db.Column(db.Boolean, server_default="0", nullable=False)
1102
1103         created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
1104
1105         replies    = db.relationship("ThreadReply", backref="thread", lazy="dynamic", \
1106                         order_by=db.asc("thread_reply_id"))
1107
1108         watchers   = db.relationship("User", secondary=watchers, lazy="subquery", \
1109                         backref=db.backref("watching", lazy=True))
1110
1111         def getViewURL(self):
1112                 return url_for("threads.view", id=self.id)
1113
1114         def getSubscribeURL(self):
1115                 return url_for("threads.subscribe", id=self.id)
1116
1117         def getUnsubscribeURL(self):
1118                 return url_for("threads.unsubscribe", id=self.id)
1119
1120         def checkPerm(self, user, perm):
1121                 if not user.is_authenticated:
1122                         return perm == Permission.SEE_THREAD and not self.private
1123
1124                 if type(perm) == str:
1125                         perm = Permission[perm]
1126                 elif type(perm) != Permission:
1127                         raise Exception("Unknown permission given to Thread.checkPerm()")
1128
1129                 isMaintainer = user == self.author or (self.package is not None and self.package.author == user)
1130                 if self.package:
1131                         isMaintainer = isMaintainer or user in self.package.maintainers
1132
1133                 canSee = not self.private or isMaintainer or user.rank.atLeast(UserRank.EDITOR)
1134
1135                 if perm == Permission.SEE_THREAD:
1136                         return canSee
1137
1138                 elif perm == Permission.COMMENT_THREAD:
1139                         return canSee and (not self.locked or user.rank.atLeast(UserRank.MODERATOR))
1140
1141                 elif perm == Permission.LOCK_THREAD:
1142                         return user.rank.atLeast(UserRank.MODERATOR)
1143
1144                 else:
1145                         raise Exception("Permission {} is not related to threads".format(perm.name))
1146
1147 class ThreadReply(db.Model):
1148         id         = db.Column(db.Integer, primary_key=True)
1149         thread_id  = db.Column(db.Integer, db.ForeignKey("thread.id"), nullable=False)
1150         comment    = db.Column(db.String(2000), nullable=False)
1151         author_id  = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
1152         created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
1153
1154         def checkPerm(self, user, perm):
1155                 if not user.is_authenticated:
1156                         return False
1157
1158                 if type(perm) == str:
1159                         perm = Permission[perm]
1160                 elif type(perm) != Permission:
1161                         raise Exception("Unknown permission given to ThreadReply.checkPerm()")
1162
1163                 if perm == Permission.EDIT_REPLY:
1164                         return user == self.author and user.rank.atLeast(UserRank.MEMBER) and not self.thread.locked
1165
1166                 elif perm == Permission.DELETE_REPLY:
1167                         return user.rank.atLeast(UserRank.MODERATOR) and self.thread.replies[0] != self
1168
1169                 else:
1170                         raise Exception("Permission {} is not related to threads".format(perm.name))
1171
1172
1173 class PackageReview(db.Model):
1174         id         = db.Column(db.Integer, primary_key=True)
1175
1176         package_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=True)
1177         package    = db.relationship("Package", foreign_keys=[package_id], backref=db.backref("reviews", lazy=True))
1178
1179         created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
1180
1181         author_id  = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
1182         author     = db.relationship("User", foreign_keys=[author_id], backref=db.backref("reviews", lazy=True))
1183
1184         recommends = db.Column(db.Boolean, nullable=False)
1185
1186         thread     = db.relationship("Thread", uselist=False, back_populates="review")
1187
1188         def asSign(self):
1189                 return 1 if self.recommends else -1
1190
1191         def getEditURL(self):
1192                 return self.package.getReviewURL()
1193
1194         def getDeleteURL(self):
1195                 return url_for("packages.delete_review",
1196                                 author=self.package.author.username,
1197                                 name=self.package.name)
1198
1199
1200 class AuditSeverity(enum.Enum):
1201         NORMAL = 0 # Normal user changes
1202         EDITOR = 1 # Editor changes
1203         MODERATION = 2 # Destructive / moderator changes
1204
1205         def __str__(self):
1206                 return self.name
1207
1208         def getTitle(self):
1209                 return self.name.replace("_", " ").title()
1210
1211         @classmethod
1212         def choices(cls):
1213                 return [(choice, choice.getTitle()) for choice in cls]
1214
1215         @classmethod
1216         def coerce(cls, item):
1217                 return item if type(item) == AuditSeverity else AuditSeverity[item]
1218
1219
1220
1221 class AuditLogEntry(db.Model):
1222         id         = db.Column(db.Integer, primary_key=True)
1223
1224         created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
1225
1226         causer_id  = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
1227         causer     = db.relationship("User", foreign_keys=[causer_id])
1228
1229         severity   = db.Column(db.Enum(AuditSeverity), nullable=False)
1230
1231         title      = db.Column(db.String(100), nullable=False)
1232         url        = db.Column(db.String(200), nullable=True)
1233
1234         package_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=True)
1235         package    = db.relationship("Package", foreign_keys=[package_id])
1236
1237         description = db.Column(db.Text, nullable=True, default=None)
1238
1239         def __init__(self, causer, severity, title, url, package=None, description=None):
1240                 if len(title) > 100:
1241                         title = title[:99] + "…"
1242
1243                 self.causer   = causer
1244                 self.severity = severity
1245                 self.title    = title
1246                 self.url      = url
1247                 self.package  = package
1248                 self.description = description
1249
1250
1251
1252
1253 REPO_BLACKLIST = [".zip", "mediafire.com", "dropbox.com", "weebly.com", \
1254                 "minetest.net", "dropboxusercontent.com", "4shared.com", \
1255                 "digitalaudioconcepts.com", "hg.intevation.org", "www.wtfpl.net", \
1256                 "imageshack.com", "imgur.com"]
1257
1258 class ForumTopic(db.Model):
1259         topic_id  = db.Column(db.Integer, primary_key=True, autoincrement=False)
1260         author_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
1261         author    = db.relationship("User")
1262
1263         wip       = db.Column(db.Boolean, server_default="0")
1264         discarded = db.Column(db.Boolean, server_default="0")
1265
1266         type      = db.Column(db.Enum(PackageType), nullable=False)
1267         title     = db.Column(db.String(200), nullable=False)
1268         name      = db.Column(db.String(30), nullable=True)
1269         link      = db.Column(db.String(200), nullable=True)
1270
1271         posts     = db.Column(db.Integer, nullable=False)
1272         views     = db.Column(db.Integer, nullable=False)
1273
1274         created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
1275
1276         def getRepoURL(self):
1277                 if self.link is None:
1278                         return None
1279
1280                 for item in REPO_BLACKLIST:
1281                         if item in self.link:
1282                                 return None
1283
1284                 return self.link.replace("repo.or.cz/w/", "repo.or.cz/")
1285
1286         def getAsDictionary(self):
1287                 return {
1288                         "author": self.author.username,
1289                         "name":   self.name,
1290                         "type":   self.type.toName(),
1291                         "title":  self.title,
1292                         "id":     self.topic_id,
1293                         "link":   self.link,
1294                         "posts":  self.posts,
1295                         "views":  self.views,
1296                         "is_wip": self.wip,
1297                         "discarded":  self.discarded,
1298                         "created_at": self.created_at.isoformat(),
1299                 }
1300
1301         def checkPerm(self, user, perm):
1302                 if not user.is_authenticated:
1303                         return False
1304
1305                 if type(perm) == str:
1306                         perm = Permission[perm]
1307                 elif type(perm) != Permission:
1308                         raise Exception("Unknown permission given to ForumTopic.checkPerm()")
1309
1310                 if perm == Permission.TOPIC_DISCARD:
1311                         return self.author == user or user.rank.atLeast(UserRank.EDITOR)
1312
1313                 else:
1314                         raise Exception("Permission {} is not related to topics".format(perm.name))
1315
1316
1317 # Setup Flask-User
1318 user_manager = UserManager(app, db, User)
1319
1320 if app.config.get("LOG_SQL"):
1321         import logging
1322         logging.basicConfig()
1323         logging.getLogger('sqlalchemy.engine').setLevel(logging.INFO)