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