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