Add package update configuration for polling

This commit is contained in:
rubenwardy 2020-12-15 19:05:29 +00:00
parent 7461acdd1f
commit 14a67b99ba
17 changed files with 327 additions and 10 deletions

View File

@ -24,7 +24,7 @@ from wtforms.ext.sqlalchemy.fields import QuerySelectField
from wtforms.validators import * from wtforms.validators import *
from app.rediscache import has_key, set_key, make_download_key from app.rediscache import has_key, set_key, make_download_key
from app.tasks.importtasks import makeVCSRelease, checkZipRelease from app.tasks.importtasks import makeVCSRelease, checkZipRelease, updateMetaFromRelease, check_for_updates
from app.utils import * from app.utils import *
from . import bp from . import bp
@ -247,3 +247,34 @@ def delete_release(package, id):
db.session.commit() db.session.commit()
return redirect(package.getDetailsURL()) return redirect(package.getDetailsURL())
class PackageUpdateConfigFrom(FlaskForm):
trigger = SelectField("Trigger", [InputRequired()], choices=PackageUpdateTrigger.choices(), coerce=PackageUpdateTrigger.coerce,
default=PackageUpdateTrigger.COMMIT)
make_release = BooleanField("Create Release")
submit = SubmitField("Save")
@bp.route("/packages/<author>/<name>/update-config/", methods=["GET", "POST"])
@login_required
@is_package_page
def update_config(package):
package.update_config = package.update_config or PackageUpdateConfig()
if not package.checkPerm(current_user, Permission.MAKE_RELEASE):
return redirect(package.getDetailsURL())
form = PackageUpdateConfigFrom(obj=package.update_config)
if form.validate_on_submit():
flash("Changed update configuration", "success")
form.populate_obj(package.update_config)
db.session.add(package.update_config)
check_for_updates.delay()
db.session.commit()
return redirect(package.getDetailsURL())
return render_template("packages/update_config.html", package=package, form=form)

View File

@ -317,7 +317,7 @@ class Package(db.Model):
maintainers = db.relationship("User", secondary=maintainers) maintainers = db.relationship("User", secondary=maintainers)
threads = db.relationship("Thread", back_populates="package", order_by=db.desc("thread_created_at"), threads = db.relationship("Thread", back_populates="package", order_by=db.desc("thread_created_at"),
foreign_keys="Thread.package_id", cascade="all, delete, delete-orphan") foreign_keys="Thread.package_id", cascade="all, delete, delete-orphan", lazy="dynamic")
reviews = db.relationship("PackageReview", back_populates="package", order_by=db.desc("package_review_created_at"), reviews = db.relationship("PackageReview", back_populates="package", order_by=db.desc("package_review_created_at"),
cascade="all, delete, delete-orphan") cascade="all, delete, delete-orphan")
@ -331,6 +331,9 @@ class Package(db.Model):
tokens = db.relationship("APIToken", foreign_keys="APIToken.package_id", back_populates="package", tokens = db.relationship("APIToken", foreign_keys="APIToken.package_id", back_populates="package",
cascade="all, delete, delete-orphan") cascade="all, delete, delete-orphan")
update_config = db.relationship("PackageUpdateConfig", uselist=False, back_populates="package",
cascade="all, delete, delete-orphan")
def __init__(self, package=None): def __init__(self, package=None):
if package is None: if package is None:
return return
@ -507,6 +510,10 @@ class Package(db.Model):
return url_for("packages.bulk_change_release", return url_for("packages.bulk_change_release",
author=self.author.username, name=self.name) author=self.author.username, name=self.name)
def getUpdateConfigURL(self):
return url_for("packages.update_config",
author=self.author.username, name=self.name)
def getDownloadURL(self): def getDownloadURL(self):
return url_for("packages.download", return url_for("packages.download",
author=self.author.username, name=self.name) author=self.author.username, name=self.name)
@ -790,7 +797,7 @@ class PackageRelease(db.Model):
title = db.Column(db.String(100), nullable=False) 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) url = db.Column(db.String(200), nullable=False, default="")
approved = db.Column(db.Boolean, nullable=False, default=False) approved = db.Column(db.Boolean, nullable=False, default=False)
task_id = db.Column(db.String(37), nullable=True) task_id = db.Column(db.String(37), nullable=True)
commit_hash = db.Column(db.String(41), nullable=True, default=None) commit_hash = db.Column(db.String(41), nullable=True, default=None)
@ -903,3 +910,39 @@ class PackageScreenshot(db.Model):
def getThumbnailURL(self, level=2): def getThumbnailURL(self, level=2):
return self.url.replace("/uploads/", "/thumbnails/{:d}/".format(level)) return self.url.replace("/uploads/", "/thumbnails/{:d}/".format(level))
class PackageUpdateTrigger(enum.Enum):
COMMIT = "New Commit"
TAG = "New Tag"
def toName(self):
return self.name.lower()
def __str__(self):
return self.name
@classmethod
def get(cls, name):
try:
return PackageUpdateTrigger[name.upper()]
except KeyError:
return None
@classmethod
def choices(cls):
return [(choice, choice.value) for choice in cls]
@classmethod
def coerce(cls, item):
return item if type(item) == PackageUpdateTrigger else PackageUpdateTrigger[item]
class PackageUpdateConfig(db.Model):
package_id = db.Column(db.Integer, db.ForeignKey("package.id"), primary_key=True)
package = db.relationship("Package", back_populates="update_config", foreign_keys=[package_id])
last_commit = db.Column(db.String(41), nullable=True, default=None)
trigger = db.Column(db.Enum(PackageUpdateTrigger), nullable=False, default=PackageUpdateTrigger.COMMIT)
make_release = db.Column(db.Boolean, nullable=False, default=False)

View File

@ -56,7 +56,7 @@ class Thread(db.Model):
watchers = db.relationship("User", secondary=watchers, backref="watching") watchers = db.relationship("User", secondary=watchers, backref="watching")
def getViewURL(self): def getViewURL(self):
return url_for("threads.view", id=self.id) return url_for("threads.view", id=self.id, _external=False)
def getSubscribeURL(self): def getSubscribeURL(self):
return url_for("threads.subscribe", id=self.id) return url_for("threads.subscribe", id=self.id)

View File

@ -32,8 +32,9 @@ class UserRank(enum.Enum):
MEMBER = 3 MEMBER = 3
TRUSTED_MEMBER = 4 TRUSTED_MEMBER = 4
EDITOR = 5 EDITOR = 5
MODERATOR = 6 BOT = 6
ADMIN = 7 MODERATOR = 7
ADMIN = 8
def atLeast(self, min): def atLeast(self, min):
return self.value >= min.value return self.value >= min.value
@ -192,6 +193,8 @@ class User(db.Model, UserMixin):
def getProfilePicURL(self): def getProfilePicURL(self):
if self.profile_pic: if self.profile_pic:
return self.profile_pic return self.profile_pic
elif self.rank == UserRank.BOT:
return "/static/bot_avatar.png"
else: else:
return gravatar(self.email or "") return gravatar(self.email or "")

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -26,4 +26,10 @@
border-width: 14px; border-width: 14px;
} }
} }
.user-photo {
width: 60px;
height: 60px;
object-fit: cover;
}
} }

View File

@ -81,6 +81,10 @@
color: #2c2 !important; color: #2c2 !important;
} }
.BOT a, .BOT {
color: #FFDF00 !important;
}
.wiptopic a:not(.btn) { .wiptopic a:not(.btn) {
color: #7ac; color: #7ac;
} }

View File

@ -73,6 +73,11 @@ CELERYBEAT_SCHEDULE = {
'task': 'app.tasks.pkgtasks.updatePackageScores', 'task': 'app.tasks.pkgtasks.updatePackageScores',
'schedule': crontab(minute=10, hour=1), 'schedule': crontab(minute=10, hour=1),
}, },
'package_score_update': {
'task': 'app.tasks.importtasks.check_for_updates',
'schedule': crontab(minute=10, hour=1),
},
'send_pending_notifications': { 'send_pending_notifications': {
'task': 'app.tasks.emails.send_pending_notifications', 'task': 'app.tasks.emails.send_pending_notifications',
'schedule': crontab(minute='*/5'), 'schedule': crontab(minute='*/5'),

View File

@ -22,9 +22,12 @@ from urllib.error import HTTPError
import urllib.request import urllib.request
from urllib.parse import urlsplit from urllib.parse import urlsplit
from zipfile import ZipFile from zipfile import ZipFile
from kombu import uuid
from app.models import * from app.models import *
from app.tasks import celery, TaskError from app.tasks import celery, TaskError
from app.utils import randomString, getExtension from app.utils import randomString, getExtension, post_system_thread
from .minetestcheck import build_tree, MinetestCheckError, ContentType from .minetestcheck import build_tree, MinetestCheckError, ContentType
@ -85,6 +88,40 @@ def clone_repo(urlstr, ref=None, recursive=False):
.strip()) .strip())
def get_commit_hash(urlstr, ref=None):
gitDir = os.path.join(tempfile.gettempdir(), randomString(10))
err = None
try:
gitUrl = generateGitURL(urlstr)
print("Cloning from " + gitUrl)
assert ref != ""
repo = git.Repo.init(gitDir)
origin: git.Remote = repo.create_remote("origin", url=gitUrl)
assert origin.exists()
origin.fetch()
if ref:
ref: git.Reference = origin.refs[ref]
else:
ref: git.Reference = origin.refs[0]
return ref.commit.hexsha
except GitCommandError as e:
# This is needed to stop the backtrace being weird
err = e.stderr
except gitdb.exc.BadName as e:
err = "Unable to find the reference " + (ref or "?") + "\n" + e.stderr
raise TaskError(err.replace("stderr: ", "") \
.replace("Cloning into '" + gitDir + "'...", "") \
.strip())
@celery.task() @celery.task()
def getMeta(urlstr, author): def getMeta(urlstr, author):
with clone_repo(urlstr, recursive=True) as repo: with clone_repo(urlstr, recursive=True) as repo:
@ -274,3 +311,50 @@ def importForeignDownloads(self, id):
release.task_id = self.request.id release.task_id = self.request.id
release.approved = False release.approved = False
db.session.commit() db.session.commit()
@celery.task
def check_update_config(package_id):
package: Package = Package.query.get(package_id)
if package is None:
raise TaskError("No such package!")
elif package.update_config is None:
raise TaskError("No update config attached to package")
config = package.update_config
ref = None
hash = get_commit_hash(package.repo, ref)
if config.last_commit != hash:
if config.make_release:
rel = PackageRelease()
rel.package = package
rel.title = hash[0:5]
rel.url = ""
rel.task_id = uuid()
db.session.add(rel)
db.session.commit()
makeVCSRelease.apply_async((rel.id, ref), task_id=rel.task_id)
else:
post_system_thread(package, "New commit detected, package outdated?",
"Commit {} was detected on the Git repository.\n\n[Change update configuration]({})" \
.format(hash[0:5], package.getUpdateConfigURL()))
config.last_commit = hash
db.session.commit()
@celery.task
def check_for_updates():
for update_config in PackageUpdateConfig.query.all():
update_config: PackageUpdateConfig
if update_config.package.repo is None:
db.session.delete(update_config)
continue
check_update_config.delay(update_config.package_id)
db.session.commit()

View File

@ -11,10 +11,22 @@
<div class="col pr-0"> <div class="col pr-0">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<a class="author {{ r.author.rank.name }}" <a class="author {{ r.author.rank.name }} mr-3"
href="{{ url_for('users.profile', username=r.author.username) }}"> href="{{ url_for('users.profile', username=r.author.username) }}">
{{ r.author.display_name }} {{ r.author.display_name }}
</a> </a>
{% if r.author in thread.package.maintainers %}
<span class="badge badge-dark">
{{ _("Maintainer") }}
</span>
{% endif %}
{% if r.author.rank == r.author.rank.BOT %}
<span class="badge badge-dark">
{{ r.author.rank.getTitle() }}
</span>
{% endif %}
<a name="reply-{{ r.id }}" class="text-muted float-right" <a name="reply-{{ r.id }}" class="text-muted float-right"
href="{{ url_for('threads.view', id=thread.id) }}#reply-{{ r.id }}"> href="{{ url_for('threads.view', id=thread.id) }}#reply-{{ r.id }}">
{{ r.created_at | datetime }} {{ r.created_at | datetime }}

View File

@ -0,0 +1,29 @@
{% extends "base.html" %}
{% block title %}
Configure Update Detection | {{ package.title }}
{% endblock %}
{% block content %}
<h1>{{ _("Configure Update Detection") }}</h1>
<p>
{{ _("ContentDB will poll your Git repository at 2am UTC every day.") }}
{{ _("You should consider using webhooks or the API for faster rollouts.") }}
</p>
{% from "macros/forms.html" import render_field, render_submit_field, render_checkbox_field %}
<form method="POST" action="">
{{ form.hidden_tag() }}
<h3>Triggers</h3>
{{ render_field(form.trigger) }}
<h3 class="mt-5">Actions</h3>
{{ render_checkbox_field(form.make_release) }}
<p class="mt-5">
{{ render_submit_field(form.submit) }}
</p>
</form>
{% endblock %}

View File

@ -38,6 +38,8 @@
<i class="fas fa-user-shield mr-2"></i> <i class="fas fa-user-shield mr-2"></i>
{% elif user.rank == user.rank.EDITOR %} {% elif user.rank == user.rank.EDITOR %}
<i class="fas fa-user-edit mr-2"></i> <i class="fas fa-user-edit mr-2"></i>
{% elif user.rank == user.rank.BOT %}
<i class="fas fa-robot mr-2"></i>
{% else %} {% else %}
<i class="fas fa-user mr-2"></i> <i class="fas fa-user mr-2"></i>
{% endif %} {% endif %}

View File

@ -23,7 +23,7 @@
</a> </a>
{% endif %} {% endif %}
<h1 class="ml-3 my-0"> <h1 class="ml-3 my-0 {{ user.rank.name }}">
{{ user.display_name }} {{ user.display_name }}
</h1> </h1>

View File

@ -253,3 +253,30 @@ def nonEmptyOrNone(str):
return None return None
return str return str
def post_system_thread(package: Package, title: str, message: str):
system_user = User.query.filter_by(username="ContentDB").first()
assert system_user
thread = package.threads.filter_by(author=system_user).first()
if not thread:
thread = Thread()
thread.package = package
thread.title = "System Notifications"
thread.author = system_user
thread.private = True
thread.watchers.append(package.author)
db.session.add(thread)
db.session.flush()
reply = ThreadReply()
reply.thread = thread
reply.author = system_user
reply.comment = "# {}\n\n{}".format(title, message)
db.session.add(reply)
addNotification(thread.watchers, system_user, NotificationType.THREAD_REPLY,
title, thread.getViewURL(), thread.package)
thread.replies.append(reply)

View File

@ -0,0 +1,36 @@
"""empty message
Revision ID: 105d4c740ad6
Revises: 886c92dc6eaa
Create Date: 2020-12-15 17:28:56.559801
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
from sqlalchemy import orm
from app.models import User, UserRank
revision = '105d4c740ad6'
down_revision = '886c92dc6eaa'
branch_labels = None
depends_on = None
def upgrade():
op.execute("COMMIT")
op.execute("ALTER TYPE userrank ADD VALUE 'BOT' AFTER 'EDITOR'")
conn = op.get_bind()
system_user = User("ContentDB", active=False)
system_user.rank = UserRank.BOT
session = orm.Session(bind=conn)
session.add(system_user)
session.commit()
def downgrade():
pass

View File

@ -0,0 +1,35 @@
"""empty message
Revision ID: 886c92dc6eaa
Revises: 8d22def23c8b
Create Date: 2020-12-15 16:38:54.114559
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '886c92dc6eaa'
down_revision = '8d22def23c8b'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('package_update_config',
sa.Column('package_id', sa.Integer(), nullable=False),
sa.Column('last_commit', sa.String(length=41), nullable=True),
sa.Column('trigger', sa.Enum('COMMIT', 'TAG', name='packageupdatetrigger'), nullable=False),
sa.Column('make_release', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['package_id'], ['package.id'], ),
sa.PrimaryKeyConstraint('package_id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('package_update_config')
# ### end Alembic commands ###

View File

@ -2,7 +2,7 @@
# Create a database migration, and copy it back to the host. # Create a database migration, and copy it back to the host.
docker exec contentdb_app_1 sh -c "FLASK_CONFIG=../config.cfg FLASK_APP=app/__init__.py flask db migrate" docker exec contentdb_app_1 sh -c "FLASK_CONFIG=../config.cfg FLASK_APP=app/__init__.py flask db revision"
docker exec -u root contentdb_app_1 sh -c "cp /home/cdb/migrations/versions/* /source/migrations/versions/" docker exec -u root contentdb_app_1 sh -c "cp /home/cdb/migrations/versions/* /source/migrations/versions/"
USER=$(whoami) USER=$(whoami)