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