]> git.lizzy.rs Git - cheatdb.git/blob - app/models.py
Add badges next to packages awaiting approval list
[cheatdb.git] / app / models.py
1 # Content DB
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, SQLAlchemyAdapter
27 from sqlalchemy.orm import validates
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         MAKE_RELEASE       = "MAKE_RELEASE"
80         ADD_SCREENSHOTS    = "ADD_SCREENSHOTS"
81         APPROVE_SCREENSHOT = "APPROVE_SCREENSHOT"
82         APPROVE_RELEASE    = "APPROVE_RELEASE"
83         APPROVE_NEW        = "APPROVE_NEW"
84         CHANGE_RELEASE_URL = "CHANGE_RELEASE_URL"
85         CHANGE_DNAME       = "CHANGE_DNAME"
86         CHANGE_RANK        = "CHANGE_RANK"
87         CHANGE_EMAIL       = "CHANGE_EMAIL"
88         EDIT_EDITREQUEST   = "EDIT_EDITREQUEST"
89         SEE_THREAD         = "SEE_THREAD"
90         CREATE_THREAD      = "CREATE_THREAD"
91         UNAPPROVE_PACKAGE  = "UNAPPROVE_PACKAGE"
92         TOPIC_DISCARD      = "TOPIC_DISCARD"
93
94         # Only return true if the permission is valid for *all* contexts
95         # See Package.checkPerm for package-specific contexts
96         def check(self, user):
97                 if not user.is_authenticated:
98                         return False
99
100                 if self == Permission.APPROVE_NEW or \
101                                 self == Permission.APPROVE_CHANGES    or \
102                                 self == Permission.APPROVE_RELEASE    or \
103                                 self == Permission.APPROVE_SCREENSHOT or \
104                                 self == Permission.SEE_THREAD:
105                         return user.rank.atLeast(UserRank.EDITOR)
106                 else:
107                         raise Exception("Non-global permission checked globally. Use Package.checkPerm or User.checkPerm instead.")
108
109 class User(db.Model, UserMixin):
110         id           = db.Column(db.Integer, primary_key=True)
111
112         # User authentication information
113         username     = db.Column(db.String(50, collation="NOCASE"), nullable=False, unique=True, index=True)
114         password     = db.Column(db.String(255), nullable=True)
115         reset_password_token = db.Column(db.String(100), nullable=False, server_default="")
116
117         rank         = db.Column(db.Enum(UserRank))
118
119         # Account linking
120         github_username = db.Column(db.String(50, collation="NOCASE"), nullable=True, unique=True)
121         forums_username = db.Column(db.String(50, collation="NOCASE"), nullable=True, unique=True)
122
123         # User email information
124         email         = db.Column(db.String(255), nullable=True, unique=True)
125         confirmed_at  = db.Column(db.DateTime())
126
127         # User information
128         profile_pic   = db.Column(db.String(255), nullable=True, server_default=None)
129         active        = db.Column("is_active", db.Boolean, nullable=False, server_default="0")
130         display_name  = db.Column(db.String(100), nullable=False, server_default="")
131
132         # Content
133         notifications = db.relationship("Notification", primaryjoin="User.id==Notification.user_id")
134
135         # causednotifs  = db.relationship("Notification", backref="causer", lazy="dynamic")
136         packages      = db.relationship("Package", backref="author", lazy="dynamic")
137         requests      = db.relationship("EditRequest", backref="author", lazy="dynamic")
138         threads       = db.relationship("Thread", backref="author", lazy="dynamic")
139         replies       = db.relationship("ThreadReply", backref="author", lazy="dynamic")
140
141         def __init__(self, username, active=False, email=None, password=None):
142                 self.username = username
143                 self.confirmed_at = datetime.datetime.now() - datetime.timedelta(days=6000)
144                 self.display_name = username
145                 self.active = active
146                 self.email = email
147                 self.password = password
148                 self.rank = UserRank.NOT_JOINED
149
150         def canAccessTodoList(self):
151                 return Permission.APPROVE_NEW.check(self) or \
152                                 Permission.APPROVE_RELEASE.check(self) or \
153                                 Permission.APPROVE_CHANGES.check(self)
154
155         def isClaimed(self):
156                 return self.rank.atLeast(UserRank.NEW_MEMBER)
157
158         def getProfilePicURL(self):
159                 if self.profile_pic:
160                         return self.profile_pic
161                 else:
162                         return gravatar(self.email or "")
163
164         def checkPerm(self, user, perm):
165                 if not user.is_authenticated:
166                         return False
167
168                 if type(perm) == str:
169                         perm = Permission[perm]
170                 elif type(perm) != Permission:
171                         raise Exception("Unknown permission given to User.checkPerm()")
172
173                 # Members can edit their own packages, and editors can edit any packages
174                 if perm == Permission.CHANGE_AUTHOR:
175                         return user.rank.atLeast(UserRank.EDITOR)
176                 elif perm == Permission.CHANGE_RANK or perm == Permission.CHANGE_DNAME:
177                         return user.rank.atLeast(UserRank.MODERATOR)
178                 elif perm == Permission.CHANGE_EMAIL:
179                         return user == self or (user.rank.atLeast(UserRank.MODERATOR) and user.rank.atLeast(self.rank))
180                 else:
181                         raise Exception("Permission {} is not related to users".format(perm.name))
182
183         def canCommentRL(self):
184                 hour_ago = datetime.datetime.utcnow() - datetime.timedelta(hours=1)
185                 return ThreadReply.query.filter_by(author=self) \
186                         .filter(ThreadReply.created_at > hour_ago).count() < 4
187
188         def canOpenThreadRL(self):
189                 hour_ago = datetime.datetime.utcnow() - datetime.timedelta(hours=1)
190                 return Thread.query.filter_by(author=self) \
191                         .filter(Thread.created_at > hour_ago).count() < 2
192
193 class UserEmailVerification(db.Model):
194         id      = db.Column(db.Integer, primary_key=True)
195         user_id = db.Column(db.Integer, db.ForeignKey("user.id"))
196         email   = db.Column(db.String(100))
197         token   = db.Column(db.String(32))
198         user    = db.relationship("User", foreign_keys=[user_id])
199
200 class Notification(db.Model):
201         id        = db.Column(db.Integer, primary_key=True)
202         user_id   = db.Column(db.Integer, db.ForeignKey("user.id"))
203         causer_id = db.Column(db.Integer, db.ForeignKey("user.id"))
204         user      = db.relationship("User", foreign_keys=[user_id])
205         causer    = db.relationship("User", foreign_keys=[causer_id])
206
207         title     = db.Column(db.String(100), nullable=False)
208         url       = db.Column(db.String(200), nullable=True)
209
210         def __init__(self, us, cau, titl, ur):
211                 self.user   = us
212                 self.causer = cau
213                 self.title  = titl
214                 self.url    = ur
215
216
217 class License(db.Model):
218         id      = db.Column(db.Integer, primary_key=True)
219         name    = db.Column(db.String(50), nullable=False, unique=True)
220         is_foss = db.Column(db.Boolean,    nullable=False, default=True)
221
222         def __init__(self, v, is_foss=True):
223                 self.name = v
224                 self.is_foss = is_foss
225
226         def __str__(self):
227                 return self.name
228
229
230 class PackageType(enum.Enum):
231         MOD  = "Mod"
232         GAME = "Game"
233         TXP  = "Texture Pack"
234
235         def toName(self):
236                 return self.name.lower()
237
238         def __str__(self):
239                 return self.name
240
241         @classmethod
242         def get(cls, name):
243                 try:
244                         return PackageType[name.upper()]
245                 except KeyError:
246                         return None
247
248         @classmethod
249         def choices(cls):
250                 return [(choice, choice.value) for choice in cls]
251
252         @classmethod
253         def coerce(cls, item):
254                 return item if type(item) == PackageType else PackageType[item]
255
256
257 class PackagePropertyKey(enum.Enum):
258         name          = "Name"
259         title         = "Title"
260         short_desc     = "Short Description"
261         desc          = "Description"
262         type          = "Type"
263         license       = "License"
264         media_license = "Media License"
265         tags          = "Tags"
266         provides      = "Provides"
267         repo          = "Repository"
268         website       = "Website"
269         issueTracker  = "Issue Tracker"
270         forums        = "Forum Topic ID"
271
272         def convert(self, value):
273                 if self == PackagePropertyKey.tags:
274                         return ",".join([t.title for t in value])
275                 elif self == PackagePropertyKey.provides:
276                         return ",".join([t.name for t in value])
277                 else:
278                         return str(value)
279
280 provides = db.Table("provides",
281         db.Column("package_id",    db.Integer, db.ForeignKey("package.id"), primary_key=True),
282     db.Column("metapackage_id", db.Integer, db.ForeignKey("meta_package.id"), primary_key=True)
283 )
284
285 tags = db.Table("tags",
286     db.Column("tag_id", db.Integer, db.ForeignKey("tag.id"), primary_key=True),
287     db.Column("package_id", db.Integer, db.ForeignKey("package.id"), primary_key=True)
288 )
289
290 class Dependency(db.Model):
291         id              = db.Column(db.Integer, primary_key=True)
292         depender_id     = db.Column(db.Integer, db.ForeignKey("package.id"),     nullable=True)
293         package_id      = db.Column(db.Integer, db.ForeignKey("package.id"),     nullable=True)
294         package         = db.relationship("Package", foreign_keys=[package_id])
295         meta_package_id = db.Column(db.Integer, db.ForeignKey("meta_package.id"), nullable=True)
296         optional        = db.Column(db.Boolean, nullable=False, default=False)
297         __table_args__  = (db.UniqueConstraint('depender_id', 'package_id', 'meta_package_id', name='_dependency_uc'), )
298
299         def __init__(self, depender=None, package=None, meta=None):
300                 if depender is None:
301                         return
302
303                 self.depender = depender
304
305                 packageProvided = package is not None
306                 metaProvided = meta is not None
307
308                 if packageProvided and not metaProvided:
309                         self.package = package
310                 elif metaProvided and not packageProvided:
311                         self.meta_package = meta
312                 else:
313                         raise Exception("Either meta or package must be given, but not both!")
314
315         def __str__(self):
316                 if self.package is not None:
317                         return self.package.author.username + "/" + self.package.name
318                 elif self.meta_package is not None:
319                         return self.meta_package.name
320                 else:
321                         raise Exception("Meta and package are both none!")
322
323         @staticmethod
324         def SpecToList(depender, spec, cache={}):
325                 retval = []
326                 arr = spec.split(",")
327
328                 import re
329                 pattern1 = re.compile("^([a-z0-9_]+)$")
330                 pattern2 = re.compile("^([A-Za-z0-9_]+)/([a-z0-9_]+)$")
331
332                 for x in arr:
333                         x = x.strip()
334                         if x == "":
335                                 continue
336
337                         if pattern1.match(x):
338                                 meta = MetaPackage.GetOrCreate(x, cache)
339                                 retval.append(Dependency(depender, meta=meta))
340                         else:
341                                 m = pattern2.match(x)
342                                 username = m.group(1)
343                                 name     = m.group(2)
344                                 user = User.query.filter_by(username=username).first()
345                                 if user is None:
346                                         raise Exception("Unable to find user " + username)
347
348                                 package = Package.query.filter_by(author=user, name=name).first()
349                                 if package is None:
350                                         raise Exception("Unable to find package " + name + " by " + username)
351
352                                 retval.append(Dependency(depender, package=package))
353
354                 return retval
355
356
357 class Package(db.Model):
358         query_class  = ArticleQuery
359
360         id           = db.Column(db.Integer, primary_key=True)
361
362         # Basic details
363         author_id    = db.Column(db.Integer, db.ForeignKey("user.id"))
364         name         = db.Column(db.String(100), nullable=False)
365         title        = db.Column(db.Unicode(100), nullable=False)
366         short_desc   = db.Column(db.Unicode(200), nullable=False)
367         desc         = db.Column(db.UnicodeText, nullable=True)
368         type         = db.Column(db.Enum(PackageType))
369         created_at   = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
370
371         search_vector = db.Column(TSVectorType("title", "short_desc", "desc"))
372
373         license_id   = db.Column(db.Integer, db.ForeignKey("license.id"), nullable=False, default=1)
374         license      = db.relationship("License", foreign_keys=[license_id])
375         media_license_id = db.Column(db.Integer, db.ForeignKey("license.id"), nullable=False, default=1)
376         media_license    = db.relationship("License", foreign_keys=[media_license_id])
377
378         approved     = db.Column(db.Boolean, nullable=False, default=False)
379         soft_deleted = db.Column(db.Boolean, nullable=False, default=False)
380
381         score        = db.Column(db.Float, nullable=False, default=0)
382
383         review_thread_id = db.Column(db.Integer, db.ForeignKey("thread.id"), nullable=True, default=None)
384         review_thread    = db.relationship("Thread", foreign_keys=[review_thread_id])
385
386         # Downloads
387         repo         = db.Column(db.String(200), nullable=True)
388         website      = db.Column(db.String(200), nullable=True)
389         issueTracker = db.Column(db.String(200), nullable=True)
390         forums       = db.Column(db.Integer,     nullable=True)
391
392         provides = db.relationship("MetaPackage", secondary=provides, lazy="subquery",
393                         backref=db.backref("packages", lazy="dynamic"))
394
395         dependencies = db.relationship("Dependency", backref="depender", lazy="dynamic", foreign_keys=[Dependency.depender_id])
396
397         tags = db.relationship("Tag", secondary=tags, lazy="subquery",
398                         backref=db.backref("packages", lazy=True))
399
400         releases = db.relationship("PackageRelease", backref="package",
401                         lazy="dynamic", order_by=db.desc("package_release_releaseDate"))
402
403         screenshots = db.relationship("PackageScreenshot", backref="package",
404                         lazy="dynamic", order_by=db.asc("package_screenshot_id"))
405
406         requests = db.relationship("EditRequest", backref="package",
407                         lazy="dynamic")
408
409         def __init__(self, package=None):
410                 if package is None:
411                         return
412
413                 self.author_id = package.author_id
414                 self.created_at = package.created_at
415                 self.approved = package.approved
416
417                 for e in PackagePropertyKey:
418                         setattr(self, e.name, getattr(package, e.name))
419
420         def getState(self):
421                 if self.approved:
422                         return "approved"
423                 elif self.review_thread_id:
424                         return "thread"
425                 elif (self.type == PackageType.GAME or \
426                                         self.type == PackageType.TXP) and \
427                                 self.screenshots.count() == 0:
428                         return "wip"
429                 elif not self.getDownloadRelease():
430                         return "wip"
431                 elif "Other" in self.license.name or "Other" in self.media_license.name:
432                         return "license"
433                 else:
434                         return "ready"
435
436         def getAsDictionaryShort(self, base_url, version=None, protonum=None):
437                 tnurl = self.getThumbnailURL(1)
438                 release = self.getDownloadRelease(version=version, protonum=protonum)
439                 return {
440                         "name": self.name,
441                         "title": self.title,
442                         "author": self.author.display_name,
443                         "short_description": self.short_desc,
444                         "type": self.type.toName(),
445                         "release": release and release.id,
446                         "thumbnail": (base_url + tnurl) if tnurl is not None else None,
447                         "score": round(self.score * 10) / 10
448                 }
449
450         def getAsDictionary(self, base_url, version=None, protonum=None):
451                 tnurl = self.getThumbnailURL(1)
452                 release = self.getDownloadRelease(version=version, protonum=protonum)
453                 return {
454                         "author": self.author.display_name,
455                         "name": self.name,
456                         "title": self.title,
457                         "short_description": self.short_desc,
458                         "desc": self.desc,
459                         "type": self.type.toName(),
460                         "created_at": self.created_at,
461
462                         "license": self.license.name,
463                         "media_license": self.media_license.name,
464
465                         "repo": self.repo,
466                         "website": self.website,
467                         "issue_tracker": self.issueTracker,
468                         "forums": self.forums,
469
470                         "provides": [x.name for x in self.provides],
471                         "thumbnail": (base_url + tnurl) if tnurl is not None else None,
472                         "screenshots": [base_url + ss.url for ss in self.screenshots],
473
474                         "url": base_url + self.getDownloadURL(),
475                         "release": release and release.id,
476
477                         "score": round(self.score * 10) / 10
478                 }
479
480         def getThumbnailURL(self, level=2):
481                 screenshot = self.screenshots.filter_by(approved=True).order_by(db.asc(PackageScreenshot.id)).first()
482                 return screenshot.getThumbnailURL(level) if screenshot is not None else None
483
484         def getMainScreenshotURL(self):
485                 screenshot = self.screenshots.filter_by(approved=True).order_by(db.asc(PackageScreenshot.id)).first()
486                 return screenshot.url if screenshot is not None else None
487
488         def getDetailsURL(self):
489                 return url_for("package_page",
490                                 author=self.author.username, name=self.name)
491
492         def getEditURL(self):
493                 return url_for("create_edit_package_page",
494                                 author=self.author.username, name=self.name)
495
496         def getApproveURL(self):
497                 return url_for("approve_package_page",
498                                 author=self.author.username, name=self.name)
499
500         def getRemoveURL(self):
501                 return url_for("remove_package_page",
502                                 author=self.author.username, name=self.name)
503
504         def getNewScreenshotURL(self):
505                 return url_for("create_screenshot_page",
506                                 author=self.author.username, name=self.name)
507
508         def getCreateReleaseURL(self):
509                 return url_for("create_release_page",
510                                 author=self.author.username, name=self.name)
511
512         def getCreateEditRequestURL(self):
513                 return url_for("create_edit_editrequest_page",
514                                 author=self.author.username, name=self.name)
515
516         def getBulkReleaseURL(self):
517                 return url_for("bulk_change_release_page",
518                         author=self.author.username, name=self.name)
519
520         def getDownloadURL(self):
521                 return url_for("package_download_page",
522                                 author=self.author.username, name=self.name)
523
524         def getDownloadRelease(self, version=None, protonum=None):
525                 if version is None and protonum is not None:
526                         version = MinetestRelease.query.filter(MinetestRelease.protocol >= int(protonum)).first()
527                         if version is not None:
528                                 version = version.id
529                         else:
530                                 version = 10000000
531
532
533                 for rel in self.releases:
534                         if rel.approved and (version is None or
535                                         ((rel.min_rel is None or rel.min_rel_id <= version) and \
536                                         (rel.max_rel is None or rel.max_rel_id >= version))):
537                                 return rel
538
539                 return None
540
541         def getDownloadCount(self):
542                 counter = 0
543                 for release in self.releases:
544                         counter += release.downloads
545                 return counter
546
547         def checkPerm(self, user, perm):
548                 if not user.is_authenticated:
549                         return False
550
551                 if type(perm) == str:
552                         perm = Permission[perm]
553                 elif type(perm) != Permission:
554                         raise Exception("Unknown permission given to Package.checkPerm()")
555
556                 isOwner = user == self.author
557
558                 if perm == Permission.CREATE_THREAD:
559                         return user.rank.atLeast(UserRank.MEMBER)
560
561                 # Members can edit their own packages, and editors can edit any packages
562                 if perm == Permission.MAKE_RELEASE or perm == Permission.ADD_SCREENSHOTS:
563                         return isOwner or user.rank.atLeast(UserRank.EDITOR)
564
565                 if perm == Permission.EDIT_PACKAGE or perm == Permission.APPROVE_CHANGES:
566                         if isOwner:
567                                 return user.rank.atLeast(UserRank.MEMBER if self.approved else UserRank.NEW_MEMBER)
568                         else:
569                                 return user.rank.atLeast(UserRank.EDITOR)
570
571                 # Editors can change authors and approve new packages
572                 elif perm == Permission.APPROVE_NEW or perm == Permission.CHANGE_AUTHOR:
573                         return user.rank.atLeast(UserRank.EDITOR)
574
575                 elif perm == Permission.APPROVE_RELEASE or perm == Permission.APPROVE_SCREENSHOT:
576                         return user.rank.atLeast(UserRank.TRUSTED_MEMBER if isOwner else UserRank.EDITOR)
577
578                 # Moderators can delete packages
579                 elif perm == Permission.DELETE_PACKAGE or perm == Permission.UNAPPROVE_PACKAGE \
580                                 or perm == Permission.CHANGE_RELEASE_URL:
581                         return user.rank.atLeast(UserRank.MODERATOR)
582
583                 else:
584                         raise Exception("Permission {} is not related to packages".format(perm.name))
585
586         def recalcScore(self):
587                 self.score = 10
588
589                 if self.forums is not None:
590                         topic = ForumTopic.query.get(self.forums)
591                         if topic:
592                                 days   = (datetime.datetime.now() - topic.created_at).days
593                                 months = days / 30
594                                 years  = days / 365
595                                 self.score = topic.views / max(years, 0.0416) + 80*min(max(months, 0.5), 6)
596
597                 if self.getMainScreenshotURL() is None:
598                         self.score *= 0.8
599
600                 if not self.license.is_foss or not self.media_license.is_foss:
601                         self.score *= 0.1
602
603 class MetaPackage(db.Model):
604         id           = db.Column(db.Integer, primary_key=True)
605         name         = db.Column(db.String(100), unique=True, nullable=False)
606         dependencies = db.relationship("Dependency", backref="meta_package", lazy="dynamic")
607
608         def __init__(self, name=None):
609                 self.name = name
610
611         def __str__(self):
612                 return self.name
613
614         @staticmethod
615         def ListToSpec(list):
616                 return ",".join([str(x) for x in list])
617
618         @staticmethod
619         def GetOrCreate(name, cache={}):
620                 mp = cache.get(name)
621                 if mp is None:
622                         mp = MetaPackage.query.filter_by(name=name).first()
623
624                 if mp is None:
625                         mp = MetaPackage(name)
626                         db.session.add(mp)
627
628                 cache[name] = mp
629                 return mp
630
631         @staticmethod
632         def SpecToList(spec, cache={}):
633                 retval = []
634                 arr = spec.split(",")
635
636                 import re
637                 pattern = re.compile("^([a-z0-9_]+)$")
638
639                 for x in arr:
640                         x = x.strip()
641                         if x == "":
642                                 continue
643
644                         if not pattern.match(x):
645                                 continue
646
647                         retval.append(MetaPackage.GetOrCreate(x, cache))
648
649                 return retval
650
651 class Tag(db.Model):
652         id              = db.Column(db.Integer,    primary_key=True)
653         name            = db.Column(db.String(100), unique=True, nullable=False)
654         title           = db.Column(db.String(100), nullable=False)
655         backgroundColor = db.Column(db.String(6),   nullable=False)
656         textColor       = db.Column(db.String(6),   nullable=False)
657
658         def __init__(self, title, backgroundColor="000000", textColor="ffffff"):
659                 self.title           = title
660                 self.backgroundColor = backgroundColor
661                 self.textColor       = textColor
662
663                 import re
664                 regex = re.compile("[^a-z_]")
665                 self.name = regex.sub("", self.title.lower().replace(" ", "_"))
666
667
668 class MinetestRelease(db.Model):
669         id       = db.Column(db.Integer, primary_key=True)
670         name     = db.Column(db.String(100), unique=True, nullable=False)
671         protocol = db.Column(db.Integer, nullable=False, default=0)
672
673         def __init__(self, name=None):
674                 self.name = name
675
676         def getActual(self):
677                 return None if self.name == "None" else self
678
679
680 class PackageRelease(db.Model):
681         id           = db.Column(db.Integer, primary_key=True)
682
683         package_id   = db.Column(db.Integer, db.ForeignKey("package.id"))
684         title        = db.Column(db.String(100), nullable=False)
685         releaseDate  = db.Column(db.DateTime,    nullable=False)
686         url          = db.Column(db.String(200), nullable=False)
687         approved     = db.Column(db.Boolean, nullable=False, default=False)
688         task_id      = db.Column(db.String(37), nullable=True)
689         commit_hash  = db.Column(db.String(41), nullable=True, default=None)
690         downloads    = db.Column(db.Integer, nullable=False, default=0)
691
692         min_rel_id = db.Column(db.Integer, db.ForeignKey("minetest_release.id"), nullable=True, server_default=None)
693         min_rel    = db.relationship("MinetestRelease", foreign_keys=[min_rel_id])
694
695         max_rel_id = db.Column(db.Integer, db.ForeignKey("minetest_release.id"), nullable=True, server_default=None)
696         max_rel    = db.relationship("MinetestRelease", foreign_keys=[max_rel_id])
697
698
699         def getEditURL(self):
700                 return url_for("edit_release_page",
701                                 author=self.package.author.username,
702                                 name=self.package.name,
703                                 id=self.id)
704
705         def getDownloadURL(self):
706                 return url_for("download_release_page",
707                                 author=self.package.author.username,
708                                 name=self.package.name,
709                                 id=self.id)
710
711
712         def __init__(self):
713                 self.releaseDate = datetime.datetime.now()
714
715         def approve(self, user):
716                 if not self.package.checkPerm(user, Permission.APPROVE_RELEASE):
717                         return False
718
719                 assert(self.task_id is None and self.url is not None and self.url != "")
720
721                 self.approved = True
722                 return True
723
724
725 class PackageReview(db.Model):
726         id         = db.Column(db.Integer, primary_key=True)
727         package_id = db.Column(db.Integer, db.ForeignKey("package.id"))
728         thread_id  = db.Column(db.Integer, db.ForeignKey("thread.id"), nullable=False)
729         recommend  = db.Column(db.Boolean, nullable=False, default=True)
730
731
732 class PackageScreenshot(db.Model):
733         id         = db.Column(db.Integer, primary_key=True)
734         package_id = db.Column(db.Integer, db.ForeignKey("package.id"))
735         title      = db.Column(db.String(100), nullable=False)
736         url        = db.Column(db.String(100), nullable=False)
737         approved   = db.Column(db.Boolean, nullable=False, default=False)
738
739
740         def getEditURL(self):
741                 return url_for("edit_screenshot_page",
742                                 author=self.package.author.username,
743                                 name=self.package.name,
744                                 id=self.id)
745
746         def getThumbnailURL(self, level=2):
747                 return self.url.replace("/uploads/", ("/thumbnails/{:d}/").format(level))
748
749
750
751 class EditRequest(db.Model):
752         id           = db.Column(db.Integer, primary_key=True)
753
754         package_id   = db.Column(db.Integer, db.ForeignKey("package.id"))
755         author_id    = db.Column(db.Integer, db.ForeignKey("user.id"))
756
757         title        = db.Column(db.String(100), nullable=False)
758         desc         = db.Column(db.String(1000), nullable=True)
759
760         # 0 - open
761         # 1 - merged
762         # 2 - rejected
763         status       = db.Column(db.Integer, nullable=False, default=0)
764
765         changes = db.relationship("EditRequestChange", backref="request",
766                         lazy="dynamic")
767
768         def getURL(self):
769                 return url_for("view_editrequest_page",
770                                 author=self.package.author.username,
771                                 name=self.package.name,
772                                 id=self.id)
773
774         def getApproveURL(self):
775                 return url_for("approve_editrequest_page",
776                                 author=self.package.author.username,
777                                 name=self.package.name,
778                                 id=self.id)
779
780         def getRejectURL(self):
781                 return url_for("reject_editrequest_page",
782                                 author=self.package.author.username,
783                                 name=self.package.name,
784                                 id=self.id)
785
786         def getEditURL(self):
787                 return url_for("create_edit_editrequest_page",
788                                 author=self.package.author.username,
789                                 name=self.package.name,
790                                 id=self.id)
791
792         def applyAll(self, package):
793                 for change in self.changes:
794                         change.apply(package)
795
796
797         def checkPerm(self, user, perm):
798                 if not user.is_authenticated:
799                         return False
800
801                 if type(perm) == str:
802                         perm = Permission[perm]
803                 elif type(perm) != Permission:
804                         raise Exception("Unknown permission given to EditRequest.checkPerm()")
805
806                 isOwner = user == self.author
807
808                 # Members can edit their own packages, and editors can edit any packages
809                 if perm == Permission.EDIT_EDITREQUEST:
810                         return isOwner or user.rank.atLeast(UserRank.EDITOR)
811
812                 else:
813                         raise Exception("Permission {} is not related to packages".format(perm.name))
814
815
816
817
818 class EditRequestChange(db.Model):
819         id           = db.Column(db.Integer, primary_key=True)
820
821         request_id   = db.Column(db.Integer, db.ForeignKey("edit_request.id"))
822         key          = db.Column(db.Enum(PackagePropertyKey), nullable=False)
823
824         # TODO: make diff instead
825         oldValue     = db.Column(db.Text, nullable=True)
826         newValue     = db.Column(db.Text, nullable=True)
827
828         def apply(self, package):
829                 if self.key == PackagePropertyKey.tags:
830                         package.tags.clear()
831                         for tagTitle in self.newValue.split(","):
832                                 tag = Tag.query.filter_by(title=tagTitle.strip()).first()
833                                 package.tags.append(tag)
834
835                 else:
836                         setattr(package, self.key.name, self.newValue)
837
838
839 watchers = db.Table("watchers",
840     db.Column("user_id", db.Integer, db.ForeignKey("user.id"), primary_key=True),
841     db.Column("thread_id", db.Integer, db.ForeignKey("thread.id"), primary_key=True)
842 )
843
844 class Thread(db.Model):
845         id         = db.Column(db.Integer, primary_key=True)
846
847         package_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=True)
848         package    = db.relationship("Package", foreign_keys=[package_id])
849
850         author_id  = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
851         title      = db.Column(db.String(100), nullable=False)
852         private    = db.Column(db.Boolean, server_default="0")
853
854         created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
855
856         replies    = db.relationship("ThreadReply", backref="thread", lazy="dynamic")
857
858         watchers   = db.relationship("User", secondary=watchers, lazy="subquery", \
859                                                 backref=db.backref("watching", lazy=True))
860
861
862         def getSubscribeURL(self):
863                 return url_for("thread_subscribe_page",
864                                 id=self.id)
865
866         def getUnsubscribeURL(self):
867                 return url_for("thread_unsubscribe_page",
868                                 id=self.id)
869
870         def checkPerm(self, user, perm):
871                 if not user.is_authenticated:
872                         return not self.private
873
874                 if type(perm) == str:
875                         perm = Permission[perm]
876                 elif type(perm) != Permission:
877                         raise Exception("Unknown permission given to Thread.checkPerm()")
878
879                 isOwner = user == self.author or (self.package is not None and self.package.author == user)
880
881                 if perm == Permission.SEE_THREAD:
882                         return not self.private or isOwner or user.rank.atLeast(UserRank.EDITOR)
883
884                 else:
885                         raise Exception("Permission {} is not related to threads".format(perm.name))
886
887 class ThreadReply(db.Model):
888         id         = db.Column(db.Integer, primary_key=True)
889         thread_id  = db.Column(db.Integer, db.ForeignKey("thread.id"), nullable=False)
890         comment    = db.Column(db.String(500), nullable=False)
891         author_id  = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
892         created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
893
894
895 REPO_BLACKLIST = [".zip", "mediafire.com", "dropbox.com", "weebly.com", \
896                 "minetest.net", "dropboxusercontent.com", "4shared.com", \
897                 "digitalaudioconcepts.com", "hg.intevation.org", "www.wtfpl.net", \
898                 "imageshack.com", "imgur.com"]
899
900 class ForumTopic(db.Model):
901         topic_id  = db.Column(db.Integer, primary_key=True, autoincrement=False)
902         author_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
903         author    = db.relationship("User")
904
905         wip       = db.Column(db.Boolean, server_default="0")
906         discarded = db.Column(db.Boolean, server_default="0")
907
908         type      = db.Column(db.Enum(PackageType), nullable=False)
909         title     = db.Column(db.String(200), nullable=False)
910         name      = db.Column(db.String(30), nullable=True)
911         link      = db.Column(db.String(200), nullable=True)
912
913         posts     = db.Column(db.Integer, nullable=False)
914         views     = db.Column(db.Integer, nullable=False)
915
916         created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
917
918         def getRepoURL(self):
919                 if self.link is None:
920                         return None
921
922                 for item in REPO_BLACKLIST:
923                         if item in self.link:
924                                 return None
925
926                 return self.link.replace("repo.or.cz/w/", "repo.or.cz/")
927
928         def getAsDictionary(self):
929                 return {
930                         "author": self.author.username,
931                         "name":   self.name,
932                         "type":   self.type.toName(),
933                         "title":  self.title,
934                         "id":     self.topic_id,
935                         "link":   self.link,
936                         "posts":  self.posts,
937                         "views":  self.views,
938                         "is_wip": self.wip,
939                         "discarded":  self.discarded,
940                         "created_at": self.created_at.isoformat(),
941                 }
942
943         def checkPerm(self, user, perm):
944                 if not user.is_authenticated:
945                         return False
946
947                 if type(perm) == str:
948                         perm = Permission[perm]
949                 elif type(perm) != Permission:
950                         raise Exception("Unknown permission given to ForumTopic.checkPerm()")
951
952                 if perm == Permission.TOPIC_DISCARD:
953                         return self.author == user or user.rank.atLeast(UserRank.EDITOR)
954
955                 else:
956                         raise Exception("Permission {} is not related to topics".format(perm.name))
957
958
959 # Setup Flask-User
960 db_adapter = SQLAlchemyAdapter(db, User)        # Register the User model
961 user_manager = UserManager(db_adapter, app)     # Initialize Flask-User