X-Git-Url: https://git.lizzy.rs/?a=blobdiff_plain;f=app%2Fmodels.py;h=58cf99201978476a325a8680875bc0cdef505eb5;hb=2fb2f1ae49e1e7e7069523f3d65f21a117571dd3;hp=b1cfbb576d8091502f9d5191aed1a4059779bd92;hpb=0db49efe4a8cc32d3060a00a557d993111693b8d;p=cheatdb.git diff --git a/app/models.py b/app/models.py index b1cfbb5..58cf992 100644 --- a/app/models.py +++ b/app/models.py @@ -15,19 +15,29 @@ # along with this program. If not, see . +import enum, datetime + +from app import app, gravatar +from urllib.parse import urlparse + from flask import Flask, url_for -from flask_sqlalchemy import SQLAlchemy +from flask_sqlalchemy import SQLAlchemy, BaseQuery from flask_migrate import Migrate -from urllib.parse import urlparse -from app import app, gravatar -from datetime import datetime -from sqlalchemy.orm import validates -from flask_user import login_required, UserManager, UserMixin, SQLAlchemyAdapter -import enum +from flask_user import login_required, UserManager, UserMixin +from sqlalchemy import func, CheckConstraint +from sqlalchemy_searchable import SearchQueryMixin +from sqlalchemy_utils.types import TSVectorType +from sqlalchemy_searchable import make_searchable + # Initialise database db = SQLAlchemy(app) migrate = Migrate(app, db) +make_searchable(db.metadata) + + +class ArticleQuery(BaseQuery, SearchQueryMixin): + pass class UserRank(enum.Enum): @@ -66,20 +76,29 @@ class Permission(enum.Enum): APPROVE_CHANGES = "APPROVE_CHANGES" DELETE_PACKAGE = "DELETE_PACKAGE" CHANGE_AUTHOR = "CHANGE_AUTHOR" + CHANGE_NAME = "CHANGE_NAME" MAKE_RELEASE = "MAKE_RELEASE" + DELETE_RELEASE = "DELETE_RELEASE" ADD_SCREENSHOTS = "ADD_SCREENSHOTS" APPROVE_SCREENSHOT = "APPROVE_SCREENSHOT" APPROVE_RELEASE = "APPROVE_RELEASE" APPROVE_NEW = "APPROVE_NEW" CHANGE_RELEASE_URL = "CHANGE_RELEASE_URL" - CHANGE_DNAME = "CHANGE_DNAME" + CHANGE_USERNAMES = "CHANGE_USERNAMES" CHANGE_RANK = "CHANGE_RANK" CHANGE_EMAIL = "CHANGE_EMAIL" EDIT_EDITREQUEST = "EDIT_EDITREQUEST" SEE_THREAD = "SEE_THREAD" CREATE_THREAD = "CREATE_THREAD" + COMMENT_THREAD = "COMMENT_THREAD" + LOCK_THREAD = "LOCK_THREAD" + DELETE_REPLY = "DELETE_REPLY" + EDIT_REPLY = "EDIT_REPLY" UNAPPROVE_PACKAGE = "UNAPPROVE_PACKAGE" TOPIC_DISCARD = "TOPIC_DISCARD" + CREATE_TOKEN = "CREATE_TOKEN" + EDIT_MAINTAINERS = "EDIT_MAINTAINERS" + CHANGE_PROFILE_URLS = "CHANGE_PROFILE_URLS" # Only return true if the permission is valid for *all* contexts # See Package.checkPerm for package-specific contexts @@ -96,12 +115,15 @@ class Permission(enum.Enum): else: raise Exception("Non-global permission checked globally. Use Package.checkPerm or User.checkPerm instead.") +def display_name_default(context): + return context.get_current_parameters()["username"] + class User(db.Model, UserMixin): id = db.Column(db.Integer, primary_key=True) # User authentication information username = db.Column(db.String(50, collation="NOCASE"), nullable=False, unique=True, index=True) - password = db.Column(db.String(255), nullable=True) + password = db.Column(db.String(255), nullable=False, server_default="") reset_password_token = db.Column(db.String(100), nullable=False, server_default="") rank = db.Column(db.Enum(UserRank)) @@ -110,35 +132,44 @@ class User(db.Model, UserMixin): github_username = db.Column(db.String(50, collation="NOCASE"), nullable=True, unique=True) forums_username = db.Column(db.String(50, collation="NOCASE"), nullable=True, unique=True) + # Access token for webhook setup + github_access_token = db.Column(db.String(50), nullable=True, server_default=None) + # User email information email = db.Column(db.String(255), nullable=True, unique=True) - confirmed_at = db.Column(db.DateTime()) + email_confirmed_at = db.Column(db.DateTime()) # User information profile_pic = db.Column(db.String(255), nullable=True, server_default=None) active = db.Column("is_active", db.Boolean, nullable=False, server_default="0") - display_name = db.Column(db.String(100), nullable=False, server_default="") + display_name = db.Column(db.String(100), nullable=False, default=display_name_default) + + # Links + website_url = db.Column(db.String(255), nullable=True, default=None) + donate_url = db.Column(db.String(255), nullable=True, default=None) # Content notifications = db.relationship("Notification", primaryjoin="User.id==Notification.user_id") # causednotifs = db.relationship("Notification", backref="causer", lazy="dynamic") - packages = db.relationship("Package", backref="author", lazy="dynamic") + packages = db.relationship("Package", backref=db.backref("author", lazy="joined"), lazy="dynamic") requests = db.relationship("EditRequest", backref="author", lazy="dynamic") threads = db.relationship("Thread", backref="author", lazy="dynamic") + tokens = db.relationship("APIToken", backref="owner", lazy="dynamic") replies = db.relationship("ThreadReply", backref="author", lazy="dynamic") - def __init__(self, username, active=False, email=None, password=None): - import datetime - + def __init__(self, username=None, active=False, email=None, password=""): self.username = username - self.confirmed_at = datetime.datetime.now() - datetime.timedelta(days=6000) + self.email_confirmed_at = datetime.datetime.now() - datetime.timedelta(days=6000) self.display_name = username self.active = active self.email = email self.password = password self.rank = UserRank.NOT_JOINED + def hasPassword(self): + return self.password != "" + def canAccessTodoList(self): return Permission.APPROVE_NEW.check(self) or \ Permission.APPROVE_RELEASE.check(self) or \ @@ -165,13 +196,46 @@ class User(db.Model, UserMixin): # Members can edit their own packages, and editors can edit any packages if perm == Permission.CHANGE_AUTHOR: return user.rank.atLeast(UserRank.EDITOR) - elif perm == Permission.CHANGE_RANK or perm == Permission.CHANGE_DNAME: + elif perm == Permission.CHANGE_RANK or perm == Permission.CHANGE_USERNAMES: return user.rank.atLeast(UserRank.MODERATOR) - elif perm == Permission.CHANGE_EMAIL: + elif perm == Permission.CHANGE_EMAIL or perm == Permission.CHANGE_PROFILE_URLS: return user == self or (user.rank.atLeast(UserRank.MODERATOR) and user.rank.atLeast(self.rank)) + elif perm == Permission.CREATE_TOKEN: + if user == self: + return user.rank.atLeast(UserRank.MEMBER) + else: + return user.rank.atLeast(UserRank.MODERATOR) and user.rank.atLeast(self.rank) else: raise Exception("Permission {} is not related to users".format(perm.name)) + def canCommentRL(self): + one_min_ago = datetime.datetime.utcnow() - datetime.timedelta(minutes=1) + if ThreadReply.query.filter_by(author=self) \ + .filter(ThreadReply.created_at > one_min_ago).count() >= 3: + return False + + hour_ago = datetime.datetime.utcnow() - datetime.timedelta(hours=1) + if ThreadReply.query.filter_by(author=self) \ + .filter(ThreadReply.created_at > hour_ago).count() >= 20: + return False + + return True + + def canOpenThreadRL(self): + hour_ago = datetime.datetime.utcnow() - datetime.timedelta(hours=1) + return Thread.query.filter_by(author=self) \ + .filter(Thread.created_at > hour_ago).count() < 2 + + def __eq__(self, other): + if other is None: + return False + + if not self.is_authenticated or not other.is_authenticated: + return False + + assert self.id > 0 + return self.id == other.id + class UserEmailVerification(db.Model): id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey("user.id")) @@ -180,20 +244,31 @@ class UserEmailVerification(db.Model): user = db.relationship("User", foreign_keys=[user_id]) class Notification(db.Model): - id = db.Column(db.Integer, primary_key=True) - user_id = db.Column(db.Integer, db.ForeignKey("user.id")) - causer_id = db.Column(db.Integer, db.ForeignKey("user.id")) - user = db.relationship("User", foreign_keys=[user_id]) - causer = db.relationship("User", foreign_keys=[causer_id]) + id = db.Column(db.Integer, primary_key=True) + + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) + user = db.relationship("User", foreign_keys=[user_id]) + + causer_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) + causer = db.relationship("User", foreign_keys=[causer_id]) - title = db.Column(db.String(100), nullable=False) - url = db.Column(db.String(200), nullable=True) + title = db.Column(db.String(100), nullable=False) + url = db.Column(db.String(200), nullable=True) + + package_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=True) + package = db.relationship("Package", foreign_keys=[package_id]) - def __init__(self, us, cau, titl, ur): - self.user = us - self.causer = cau - self.title = titl - self.url = ur + created_at = db.Column(db.DateTime, nullable=True, default=datetime.datetime.utcnow) + + def __init__(self, user, causer, title, url, package=None): + if len(title) > 100: + title = title[:99] + "…" + + self.user = user + self.causer = causer + self.title = title + self.url = url + self.package = package class License(db.Model): @@ -239,7 +314,7 @@ class PackageType(enum.Enum): class PackagePropertyKey(enum.Enum): name = "Name" title = "Title" - shortDesc = "Short Description" + short_desc = "Short Description" desc = "Description" type = "Type" license = "License" @@ -269,6 +344,11 @@ tags = db.Table("tags", db.Column("package_id", db.Integer, db.ForeignKey("package.id"), primary_key=True) ) +maintainers = db.Table("maintainers", + db.Column("user_id", db.Integer, db.ForeignKey("user.id"), primary_key=True), + db.Column("package_id", db.Integer, db.ForeignKey("package.id"), primary_key=True) +) + class Dependency(db.Model): id = db.Column(db.Integer, primary_key=True) depender_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=True) @@ -276,7 +356,7 @@ class Dependency(db.Model): package = db.relationship("Package", foreign_keys=[package_id]) meta_package_id = db.Column(db.Integer, db.ForeignKey("meta_package.id"), nullable=True) optional = db.Column(db.Boolean, nullable=False, default=False) - __table_args__ = (db.UniqueConstraint('depender_id', 'package_id', 'meta_package_id', name='_dependency_uc'), ) + __table_args__ = (db.UniqueConstraint("depender_id", "package_id", "meta_package_id", name="_dependency_uc"), ) def __init__(self, depender=None, package=None, meta=None): if depender is None: @@ -294,6 +374,14 @@ class Dependency(db.Model): else: raise Exception("Either meta or package must be given, but not both!") + def getName(self): + if self.meta_package: + return self.meta_package.name + elif self.package: + return self.package.name + else: + assert False + def __str__(self): if self.package is not None: return self.package.author.username + "/" + self.package.name @@ -336,18 +424,24 @@ class Dependency(db.Model): return retval - class Package(db.Model): + query_class = ArticleQuery + id = db.Column(db.Integer, primary_key=True) # Basic details author_id = db.Column(db.Integer, db.ForeignKey("user.id")) - name = db.Column(db.String(100), nullable=False) - title = db.Column(db.String(100), nullable=False) - shortDesc = db.Column(db.String(200), nullable=False) - desc = db.Column(db.Text, nullable=True) + name = db.Column(db.Unicode(100), nullable=False) + title = db.Column(db.Unicode(100), nullable=False) + short_desc = db.Column(db.Unicode(200), nullable=False) + desc = db.Column(db.UnicodeText, nullable=True) type = db.Column(db.Enum(PackageType)) - created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) + created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow) + + name_valid = db.CheckConstraint("name ~* '^[a-z0-9_]+$'") + + search_vector = db.Column(TSVectorType("name", "title", "short_desc", "desc", \ + weights={ "name": "A", "title": "B", "short_desc": "C", "desc": "D" })) license_id = db.Column(db.Integer, db.ForeignKey("license.id"), nullable=False, default=1) license = db.relationship("License", foreign_keys=[license_id]) @@ -358,6 +452,8 @@ class Package(db.Model): soft_deleted = db.Column(db.Boolean, nullable=False, default=False) score = db.Column(db.Float, nullable=False, default=0) + score_downloads = db.Column(db.Float, nullable=False, default=0) + downloads = db.Column(db.Integer, nullable=False, default=0) review_thread_id = db.Column(db.Integer, db.ForeignKey("thread.id"), nullable=True, default=None) review_thread = db.relationship("Thread", foreign_keys=[review_thread_id]) @@ -368,12 +464,13 @@ class Package(db.Model): issueTracker = db.Column(db.String(200), nullable=True) forums = db.Column(db.Integer, nullable=True) - provides = db.relationship("MetaPackage", secondary=provides, lazy="subquery", - backref=db.backref("packages", lazy="dynamic")) + provides = db.relationship("MetaPackage", \ + secondary=provides, lazy="select", order_by=db.asc("name"), \ + backref=db.backref("packages", lazy="dynamic", order_by=db.desc("score"))) dependencies = db.relationship("Dependency", backref="depender", lazy="dynamic", foreign_keys=[Dependency.depender_id]) - tags = db.relationship("Tag", secondary=tags, lazy="subquery", + tags = db.relationship("Tag", secondary=tags, lazy="select", backref=db.backref("packages", lazy=True)) releases = db.relationship("PackageRelease", backref="package", @@ -385,6 +482,8 @@ class Package(db.Model): requests = db.relationship("EditRequest", backref="package", lazy="dynamic") + maintainers = db.relationship("User", secondary=maintainers, lazy="subquery") + def __init__(self, package=None): if package is None: return @@ -396,29 +495,99 @@ class Package(db.Model): for e in PackagePropertyKey: setattr(self, e.name, getattr(package, e.name)) - def getAsDictionaryShort(self, base_url): + def getId(self): + return "{}/{}".format(self.author.username, self.name) + + def getIsFOSS(self): + return self.license.is_foss and self.media_license.is_foss + + def getIsOnGitHub(self): + if self.repo is None: + return False + + url = urlparse(self.repo) + return url.netloc == "github.com" + + def getGitHubFullName(self): + if self.repo is None: + return None + + url = urlparse(self.repo) + if url.netloc != "github.com": + return None + + import re + m = re.search(r"^\/([^\/]+)\/([^\/]+)\/?$", url.path) + if m is None: + return + + user = m.group(1) + repo = m.group(2).replace(".git", "") + + return (user,repo) + + def getSortedDependencies(self, is_hard=None): + query = self.dependencies + if is_hard is not None: + query = query.filter_by(optional=not is_hard) + + deps = query.all() + deps.sort(key = lambda x: x.getName()) + return deps + + def getSortedHardDependencies(self): + return self.getSortedDependencies(True) + + def getSortedOptionalDependencies(self): + return self.getSortedDependencies(False) + + def getState(self): + if self.approved: + return "approved" + elif self.review_thread_id: + return "thread" + elif (self.type == PackageType.GAME or \ + self.type == PackageType.TXP) and \ + self.screenshots.count() == 0: + return "wip" + elif not self.getDownloadRelease(): + return "wip" + elif "Other" in self.license.name or "Other" in self.media_license.name: + return "license" + else: + return "ready" + + def getAsDictionaryKey(self): + return { + "name": self.name, + "author": self.author.display_name, + "type": self.type.toName(), + } + + def getAsDictionaryShort(self, base_url, version=None): tnurl = self.getThumbnailURL(1) + release = self.getDownloadRelease(version=version) return { "name": self.name, "title": self.title, - "author": self.author.display_name, - "short_description": self.shortDesc, + "author": self.author.username, + "short_description": self.short_desc, "type": self.type.toName(), - "release": self.getDownloadRelease().id if self.getDownloadRelease() is not None else None, - "thumbnail": (base_url + tnurl) if tnurl is not None else None, - "score": round(self.score * 10) / 10 + "release": release and release.id, + "thumbnail": (base_url + tnurl) if tnurl is not None else None } - def getAsDictionary(self, base_url): + def getAsDictionary(self, base_url, version=None): tnurl = self.getThumbnailURL(1) + release = self.getDownloadRelease(version=version) return { - "author": self.author.display_name, + "author": self.author.username, "name": self.name, "title": self.title, - "short_description": self.shortDesc, + "short_description": self.short_desc, "desc": self.desc, "type": self.type.toName(), - "created_at": self.created_at, + "created_at": self.created_at.isoformat(), "license": self.license.name, "media_license": self.media_license.name, @@ -433,7 +602,7 @@ class Package(db.Model): "screenshots": [base_url + ss.url for ss in self.screenshots], "url": base_url + self.getDownloadURL(), - "release": self.getDownloadRelease().id if self.getDownloadRelease() is not None else None, + "release": release and release.id, "score": round(self.score * 10) / 10 } @@ -442,45 +611,75 @@ class Package(db.Model): screenshot = self.screenshots.filter_by(approved=True).order_by(db.asc(PackageScreenshot.id)).first() return screenshot.getThumbnailURL(level) if screenshot is not None else None - def getMainScreenshotURL(self): + def getMainScreenshotURL(self, absolute=False): screenshot = self.screenshots.filter_by(approved=True).order_by(db.asc(PackageScreenshot.id)).first() - return screenshot.url if screenshot is not None else None + if screenshot is None: + return None - def getDetailsURL(self): - return url_for("package_page", - author=self.author.username, name=self.name) + if absolute: + from app.utils import abs_url + return abs_url(screenshot.url) + else: + return screenshot.url + + def getDetailsURL(self, absolute=False): + if absolute: + from app.utils import abs_url_for + return abs_url_for("packages.view", + author=self.author.username, name=self.name) + else: + return url_for("packages.view", + author=self.author.username, name=self.name) def getEditURL(self): - return url_for("create_edit_package_page", + return url_for("packages.create_edit", author=self.author.username, name=self.name) def getApproveURL(self): - return url_for("approve_package_page", + return url_for("packages.approve", author=self.author.username, name=self.name) def getRemoveURL(self): - return url_for("remove_package_page", + return url_for("packages.remove", author=self.author.username, name=self.name) def getNewScreenshotURL(self): - return url_for("create_screenshot_page", + return url_for("packages.create_screenshot", author=self.author.username, name=self.name) def getCreateReleaseURL(self): - return url_for("create_release_page", + return url_for("packages.create_release", author=self.author.username, name=self.name) def getCreateEditRequestURL(self): return url_for("create_edit_editrequest_page", author=self.author.username, name=self.name) + def getBulkReleaseURL(self): + return url_for("packages.bulk_change_release", + author=self.author.username, name=self.name) + def getDownloadURL(self): - return url_for("package_download_page", + return url_for("packages.download", + author=self.author.username, name=self.name) + + def getEditMaintainersURL(self): + return url_for("packages.edit_maintainers", author=self.author.username, name=self.name) - def getDownloadRelease(self): + def getRemoveSelfMaintainerURL(self): + return url_for("packages.remove_self_maintainers", + author=self.author.username, name=self.name) + + def getReviewURL(self): + return url_for('packages.review', + author=self.author.username, name=self.name) + + def getDownloadRelease(self, version=None): for rel in self.releases: - if rel.approved: + if rel.approved and (version is None or + ((rel.min_rel is None or rel.min_rel_id <= version.id) and \ + (rel.max_rel is None or rel.max_rel_id >= version.id))): return rel return None @@ -495,50 +694,55 @@ class Package(db.Model): raise Exception("Unknown permission given to Package.checkPerm()") isOwner = user == self.author + isMaintainer = isOwner or user.rank.atLeast(UserRank.EDITOR) or user in self.maintainers + + if perm == Permission.CREATE_THREAD: + return user.rank.atLeast(UserRank.MEMBER) # Members can edit their own packages, and editors can edit any packages - if perm == Permission.MAKE_RELEASE or perm == Permission.ADD_SCREENSHOTS or perm == Permission.CREATE_THREAD: - return isOwner or user.rank.atLeast(UserRank.EDITOR) + elif perm == Permission.MAKE_RELEASE or perm == Permission.ADD_SCREENSHOTS: + return isMaintainer - if perm == Permission.EDIT_PACKAGE or perm == Permission.APPROVE_CHANGES: - if isOwner: - return user.rank.atLeast(UserRank.MEMBER if self.approved else UserRank.NEW_MEMBER) - else: - return user.rank.atLeast(UserRank.EDITOR) + elif perm == Permission.EDIT_PACKAGE or perm == Permission.APPROVE_CHANGES or perm == Permission.APPROVE_RELEASE: + return isMaintainer and user.rank.atLeast(UserRank.MEMBER if self.approved else UserRank.NEW_MEMBER) + + # Anyone can change the package name when not approved, but only editors when approved + elif perm == Permission.CHANGE_NAME: + return not self.approved or user.rank.atLeast(UserRank.EDITOR) # Editors can change authors and approve new packages elif perm == Permission.APPROVE_NEW or perm == Permission.CHANGE_AUTHOR: return user.rank.atLeast(UserRank.EDITOR) - elif perm == Permission.APPROVE_RELEASE or perm == Permission.APPROVE_SCREENSHOT: - return user.rank.atLeast(UserRank.TRUSTED_MEMBER if isOwner else UserRank.EDITOR) + elif perm == Permission.APPROVE_SCREENSHOT: + return isMaintainer and user.rank.atLeast(UserRank.TRUSTED_MEMBER if self.approved else UserRank.NEW_MEMBER) + + elif perm == Permission.EDIT_MAINTAINERS: + return isOwner or user.rank.atLeast(UserRank.MODERATOR) + + elif perm == Permission.UNAPPROVE_PACKAGE or perm == Permission.DELETE_PACKAGE: + return user.rank.atLeast(UserRank.EDITOR) - # Moderators can delete packages - elif perm == Permission.DELETE_PACKAGE or perm == Permission.UNAPPROVE_PACKAGE \ - or perm == Permission.CHANGE_RELEASE_URL: + elif perm == Permission.CHANGE_RELEASE_URL: return user.rank.atLeast(UserRank.MODERATOR) else: raise Exception("Permission {} is not related to packages".format(perm.name)) - def recalcScore(self): - import datetime - - self.score = 10 - - if self.forums is not None: - topic = ForumTopic.query.get(self.forums) - if topic: - days = (datetime.datetime.now() - topic.created_at).days - months = days / 30 - years = days / 365 - self.score = topic.views / max(years, 0.0416) + 80*min(max(months, 0.5), 6) + def getScoreDict(self): + return { + "author": self.author.username, + "name": self.name, + "score": self.score, + "score_downloads": self.score_downloads, + "score_reviews": self.score - self.score_downloads, + "downloads": self.downloads + } - if self.getMainScreenshotURL() is None: - self.score *= 0.8 + def recalcScore(self): + review_scores = [ 100 * r.asSign() for r in self.reviews ] + self.score = self.score_downloads + sum(review_scores) - if not self.license.is_foss or not self.media_license.is_foss: - self.score *= 0.1 class MetaPackage(db.Model): id = db.Column(db.Integer, primary_key=True) @@ -604,40 +808,138 @@ class Tag(db.Model): regex = re.compile("[^a-z_]") self.name = regex.sub("", self.title.lower().replace(" ", "_")) + +class MinetestRelease(db.Model): + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(100), unique=True, nullable=False) + protocol = db.Column(db.Integer, nullable=False, default=0) + + def __init__(self, name=None, protocol=0): + self.name = name + self.protocol = protocol + + def getActual(self): + return None if self.name == "None" else self + + @classmethod + def get(cls, version, protocol_num): + if version: + parts = version.strip().split(".") + if len(parts) >= 2: + major_minor = parts[0] + "." + parts[1] + query = MinetestRelease.query.filter(MinetestRelease.name.like("{}%".format(major_minor))) + if protocol_num: + query = query.filter_by(protocol=protocol_num) + + release = query.one_or_none() + if release: + return release + + if protocol_num: + return MinetestRelease.query.filter_by(protocol=protocol_num).first() + + return None + + class PackageRelease(db.Model): id = db.Column(db.Integer, primary_key=True) package_id = db.Column(db.Integer, db.ForeignKey("package.id")) title = db.Column(db.String(100), nullable=False) - releaseDate = db.Column(db.DateTime, nullable=False) + releaseDate = db.Column(db.DateTime, nullable=False) url = db.Column(db.String(200), nullable=False) approved = db.Column(db.Boolean, nullable=False, default=False) task_id = db.Column(db.String(37), nullable=True) commit_hash = db.Column(db.String(41), nullable=True, default=None) + downloads = db.Column(db.Integer, nullable=False, default=0) + + min_rel_id = db.Column(db.Integer, db.ForeignKey("minetest_release.id"), nullable=True, server_default=None) + min_rel = db.relationship("MinetestRelease", foreign_keys=[min_rel_id]) + + max_rel_id = db.Column(db.Integer, db.ForeignKey("minetest_release.id"), nullable=True, server_default=None) + max_rel = db.relationship("MinetestRelease", foreign_keys=[max_rel_id]) + # If the release is approved, then the task_id must be null and the url must be present + CK_approval_valid = db.CheckConstraint("not approved OR (task_id IS NULL AND (url = '') IS NOT FALSE)") + + def getAsDictionary(self): + return { + "id": self.id, + "title": self.title, + "url": self.url if self.url != "" else None, + "release_date": self.releaseDate.isoformat(), + "commit": self.commit_hash, + "downloads": self.downloads, + "min_protocol": self.min_rel and self.min_rel.protocol, + "max_protocol": self.max_rel and self.max_rel.protocol + } def getEditURL(self): - return url_for("edit_release_page", + return url_for("packages.edit_release", author=self.package.author.username, name=self.package.name, id=self.id) - def getDownloadURL(self): - return url_for("download_release_page", + def getDeleteURL(self): + return url_for("packages.delete_release", author=self.package.author.username, name=self.package.name, id=self.id) + def getDownloadURL(self): + return url_for("packages.download_release", + author=self.package.author.username, + name=self.package.name, + id=self.id) def __init__(self): - self.releaseDate = datetime.now() + self.releaseDate = datetime.datetime.now() + def approve(self, user): + if not self.package.checkPerm(user, Permission.APPROVE_RELEASE): + return False -class PackageReview(db.Model): - id = db.Column(db.Integer, primary_key=True) - package_id = db.Column(db.Integer, db.ForeignKey("package.id")) - thread_id = db.Column(db.Integer, db.ForeignKey("thread.id"), nullable=False) - recommend = db.Column(db.Boolean, nullable=False, default=True) + assert self.task_id is None and self.url is not None and self.url != "" + + self.approved = True + return True + + def checkPerm(self, user, perm): + if not user.is_authenticated: + return False + + if type(perm) == str: + perm = Permission[perm] + elif type(perm) != Permission: + raise Exception("Unknown permission given to PackageRelease.checkPerm()") + + isOwner = user == self.package.author + + if perm == Permission.DELETE_RELEASE: + if user.rank.atLeast(UserRank.ADMIN): + return True + + if not (isOwner or user.rank.atLeast(UserRank.EDITOR)): + return False + + if not self.package.approved or self.task_id is not None: + return True + + count = PackageRelease.query \ + .filter_by(package_id=self.package_id) \ + .filter(PackageRelease.id > self.id) \ + .count() + + return count > 0 + else: + raise Exception("Permission {} is not related to releases".format(perm.name)) + + +# class PackageReview(db.Model): +# id = db.Column(db.Integer, primary_key=True) +# package_id = db.Column(db.Integer, db.ForeignKey("package.id")) +# thread_id = db.Column(db.Integer, db.ForeignKey("thread.id"), nullable=False) +# recommend = db.Column(db.Boolean, nullable=False, default=True) class PackageScreenshot(db.Model): @@ -649,7 +951,7 @@ class PackageScreenshot(db.Model): def getEditURL(self): - return url_for("edit_screenshot_page", + return url_for("packages.edit_screenshot", author=self.package.author.username, name=self.package.name, id=self.id) @@ -658,6 +960,25 @@ class PackageScreenshot(db.Model): return self.url.replace("/uploads/", ("/thumbnails/{:d}/").format(level)) +class APIToken(db.Model): + id = db.Column(db.Integer, primary_key=True) + access_token = db.Column(db.String(34), unique=True) + + name = db.Column(db.String(100), nullable=False) + owner_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) + # owner is created using backref + + created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow) + + package_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=True) + package = db.relationship("Package", foreign_keys=[package_id]) + + def canOperateOnPackage(self, package): + if self.package and self.package != package: + return False + + return package.author == self.owner + class EditRequest(db.Model): id = db.Column(db.Integer, primary_key=True) @@ -758,39 +1079,54 @@ class Thread(db.Model): package_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=True) package = db.relationship("Package", foreign_keys=[package_id]) + review_id = db.Column(db.Integer, db.ForeignKey("package_review.id"), nullable=True) + review = db.relationship("PackageReview", foreign_keys=[review_id]) + author_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) title = db.Column(db.String(100), nullable=False) - private = db.Column(db.Boolean, server_default="0") + private = db.Column(db.Boolean, server_default="0", nullable=False) - created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) + locked = db.Column(db.Boolean, server_default="0", nullable=False) + + created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow) replies = db.relationship("ThreadReply", backref="thread", lazy="dynamic") watchers = db.relationship("User", secondary=watchers, lazy="subquery", \ backref=db.backref("watching", lazy=True)) + def getViewURL(self): + return url_for("threads.view", id=self.id) def getSubscribeURL(self): - return url_for("thread_subscribe_page", - id=self.id) + return url_for("threads.subscribe", id=self.id) def getUnsubscribeURL(self): - return url_for("thread_unsubscribe_page", - id=self.id) + return url_for("threads.unsubscribe", id=self.id) def checkPerm(self, user, perm): if not user.is_authenticated: - return not self.private + return perm == Permission.SEE_THREAD and not self.private if type(perm) == str: perm = Permission[perm] elif type(perm) != Permission: raise Exception("Unknown permission given to Thread.checkPerm()") - isOwner = user == self.author or (self.package is not None and self.package.author == user) + isMaintainer = user == self.author or (self.package is not None and self.package.author == user) + if self.package: + isMaintainer = isMaintainer or user in self.package.maintainers + + canSee = not self.private or isMaintainer or user.rank.atLeast(UserRank.EDITOR) if perm == Permission.SEE_THREAD: - return not self.private or isOwner or user.rank.atLeast(UserRank.EDITOR) + return canSee + + elif perm == Permission.COMMENT_THREAD: + return canSee and (not self.locked or user.rank.atLeast(UserRank.MODERATOR)) + + elif perm == Permission.LOCK_THREAD: + return user.rank.atLeast(UserRank.MODERATOR) else: raise Exception("Permission {} is not related to threads".format(perm.name)) @@ -800,7 +1136,105 @@ class ThreadReply(db.Model): thread_id = db.Column(db.Integer, db.ForeignKey("thread.id"), nullable=False) comment = db.Column(db.String(500), nullable=False) author_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) - created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) + created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow) + + def checkPerm(self, user, perm): + if not user.is_authenticated: + return False + + if type(perm) == str: + perm = Permission[perm] + elif type(perm) != Permission: + raise Exception("Unknown permission given to ThreadReply.checkPerm()") + + if perm == Permission.EDIT_REPLY: + return user == self.author and user.rank.atLeast(UserRank.MEMBER) and not self.thread.locked + + elif perm == Permission.DELETE_REPLY: + return user.rank.atLeast(UserRank.MODERATOR) and self.thread.replies[0] != self + + else: + raise Exception("Permission {} is not related to threads".format(perm.name)) + + +class PackageReview(db.Model): + id = db.Column(db.Integer, primary_key=True) + + package_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=True) + package = db.relationship("Package", foreign_keys=[package_id], backref=db.backref("reviews", lazy=True)) + + created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow) + + author_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) + author = db.relationship("User", foreign_keys=[author_id], backref=db.backref("reviews", lazy=True)) + + recommends = db.Column(db.Boolean, nullable=False) + + thread = db.relationship("Thread", uselist=False, back_populates="review") + + def asSign(self): + return 1 if self.recommends else -1 + + def getEditURL(self): + return self.package.getReviewURL() + + def getDeleteURL(self): + return url_for("packages.delete_review", + author=self.package.author.username, + name=self.package.name) + + +class AuditSeverity(enum.Enum): + NORMAL = 0 # Normal user changes + EDITOR = 1 # Editor changes + MODERATION = 2 # Destructive / moderator changes + + def __str__(self): + return self.name + + def getTitle(self): + return self.name.replace("_", " ").title() + + @classmethod + def choices(cls): + return [(choice, choice.getTitle()) for choice in cls] + + @classmethod + def coerce(cls, item): + return item if type(item) == AuditSeverity else AuditSeverity[item] + + + +class AuditLogEntry(db.Model): + id = db.Column(db.Integer, primary_key=True) + + created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow) + + causer_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) + causer = db.relationship("User", foreign_keys=[causer_id]) + + severity = db.Column(db.Enum(AuditSeverity), nullable=False) + + title = db.Column(db.String(100), nullable=False) + url = db.Column(db.String(200), nullable=True) + + package_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=True) + package = db.relationship("Package", foreign_keys=[package_id]) + + description = db.Column(db.Text, nullable=True, default=None) + + def __init__(self, causer, severity, title, url, package=None, description=None): + if len(title) > 100: + title = title[:99] + "…" + + self.causer = causer + self.severity = severity + self.title = title + self.url = url + self.package = package + self.description = description + + REPO_BLACKLIST = [".zip", "mediafire.com", "dropbox.com", "weebly.com", \ @@ -824,7 +1258,7 @@ class ForumTopic(db.Model): posts = db.Column(db.Integer, nullable=False) views = db.Column(db.Integer, nullable=False) - created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) + created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow) def getRepoURL(self): if self.link is None: @@ -868,5 +1302,9 @@ class ForumTopic(db.Model): # Setup Flask-User -db_adapter = SQLAlchemyAdapter(db, User) # Register the User model -user_manager = UserManager(db_adapter, app) # Initialize Flask-User +user_manager = UserManager(app, db, User) + +if app.config.get("LOG_SQL"): + import logging + logging.basicConfig() + logging.getLogger('sqlalchemy.engine').setLevel(logging.INFO)