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