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