[extensions-web] review: Automatically approve harmless extension versions
- From: Jasper St. Pierre <jstpierre src gnome org>
- To: commits-list gnome org
- Cc:
- Subject: [extensions-web] review: Automatically approve harmless extension versions
- Date: Mon, 2 Apr 2012 04:35:04 +0000 (UTC)
commit d1b70ccd4c2a595d581c461376f9d3c3c4b5c8c6
Author: Jasper St. Pierre <jstpierre mecheye net>
Date: Mon Apr 2 00:05:09 2012 -0400
review: Automatically approve harmless extension versions
When a new extension version is uploaded, we diff it against the last
approved version -- if the changes to its files are "safe", we automatically
approve the new version.
Currently, safe changes are defined by filenames and file types -- any
updates to images, translation files, or metadata.json is considered safe,
and more may be added to the list in the future.
A similar feature is planned that checks for future 'safe' versions
upon approval of other versions of the same extension.
...uto__add_field_changestatuslog_auto_approved.py | 110 ++++++++++++++++++++
sweettooth/review/models.py | 1 +
.../review/templates/review/auto_approved_mail.txt | 13 +++
.../review/auto_approved_mail_subject.txt | 1 +
sweettooth/review/tests.py | 20 ++++-
sweettooth/review/views.py | 89 ++++++++++++++--
6 files changed, 222 insertions(+), 12 deletions(-)
---
diff --git a/sweettooth/review/migrations/0005_auto__add_field_changestatuslog_auto_approved.py b/sweettooth/review/migrations/0005_auto__add_field_changestatuslog_auto_approved.py
new file mode 100644
index 0000000..6eeb2da
--- /dev/null
+++ b/sweettooth/review/migrations/0005_auto__add_field_changestatuslog_auto_approved.py
@@ -0,0 +1,110 @@
+# encoding: utf-8
+import datetime
+from south.db import db
+from south.v2 import SchemaMigration
+from django.db import models
+
+class Migration(SchemaMigration):
+
+ def forwards(self, orm):
+
+ # Adding field 'ChangeStatusLog.auto_approved'
+ db.add_column('review_changestatuslog', 'auto_approved', self.gf('django.db.models.fields.BooleanField')(default=False), keep_default=False)
+
+
+ def backwards(self, orm):
+
+ # Deleting field 'ChangeStatusLog.auto_approved'
+ db.delete_column('review_changestatuslog', 'auto_approved')
+
+
+ models = {
+ 'auth.group': {
+ 'Meta': {'object_name': 'Group'},
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
+ 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
+ },
+ 'auth.permission': {
+ 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
+ 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
+ },
+ 'auth.user': {
+ 'Meta': {'object_name': 'User'},
+ 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+ 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
+ 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+ 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+ 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+ 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+ 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
+ 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
+ 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
+ },
+ 'contenttypes.contenttype': {
+ 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
+ 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
+ },
+ 'extensions.extension': {
+ 'Meta': {'object_name': 'Extension'},
+ 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
+ 'creator': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}),
+ 'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
+ 'downloads': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+ 'icon': ('django.db.models.fields.files.ImageField', [], {'default': "'/static/images/plugin.png'", 'max_length': '100', 'blank': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '200'}),
+ 'popularity': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
+ 'screenshot': ('sorl.thumbnail.fields.ImageField', [], {'max_length': '100', 'blank': 'True'}),
+ 'slug': ('autoslug.fields.AutoSlugField', [], {'unique_with': '()', 'max_length': '50', 'populate_from': 'None', 'db_index': 'True'}),
+ 'url': ('django.db.models.fields.URLField', [], {'max_length': '200', 'blank': 'True'}),
+ 'uuid': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '200', 'db_index': 'True'})
+ },
+ 'extensions.extensionversion': {
+ 'Meta': {'unique_together': "(('extension', 'version'),)", 'object_name': 'ExtensionVersion'},
+ 'extension': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'versions'", 'to': "orm['extensions.Extension']"}),
+ 'extra_json_fields': ('django.db.models.fields.TextField', [], {}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'shell_versions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['extensions.ShellVersion']", 'symmetrical': 'False'}),
+ 'source': ('django.db.models.fields.files.FileField', [], {'max_length': '223'}),
+ 'status': ('django.db.models.fields.PositiveIntegerField', [], {}),
+ 'version': ('django.db.models.fields.IntegerField', [], {'default': '0'})
+ },
+ 'extensions.shellversion': {
+ 'Meta': {'object_name': 'ShellVersion'},
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'major': ('django.db.models.fields.PositiveIntegerField', [], {}),
+ 'minor': ('django.db.models.fields.PositiveIntegerField', [], {}),
+ 'point': ('django.db.models.fields.IntegerField', [], {})
+ },
+ 'review.changestatuslog': {
+ 'Meta': {'object_name': 'ChangeStatusLog'},
+ 'auto_approved': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'newstatus': ('django.db.models.fields.PositiveIntegerField', [], {}),
+ 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}),
+ 'version': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'status_log'", 'to': "orm['extensions.ExtensionVersion']"})
+ },
+ 'review.codereview': {
+ 'Meta': {'object_name': 'CodeReview'},
+ 'changelog': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['review.ChangeStatusLog']", 'unique': 'True', 'null': 'True'}),
+ 'comments': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
+ 'date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'reviewer': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}),
+ 'version': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'reviews'", 'to': "orm['extensions.ExtensionVersion']"})
+ }
+ }
+
+ complete_apps = ['review']
diff --git a/sweettooth/review/models.py b/sweettooth/review/models.py
index 2c49f7e..25e7be6 100644
--- a/sweettooth/review/models.py
+++ b/sweettooth/review/models.py
@@ -30,6 +30,7 @@ class ChangeStatusLog(models.Model):
date = models.DateTimeField(auto_now_add=True)
newstatus = models.PositiveIntegerField(choices=STATUSES.items())
version = models.ForeignKey(ExtensionVersion, related_name="status_log")
+ auto_approved = models.BooleanField(default=False)
def get_newstatus_class(self):
return STATUSES[self.newstatus].lower()
diff --git a/sweettooth/review/templates/review/auto_approved_mail.txt b/sweettooth/review/templates/review/auto_approved_mail.txt
new file mode 100644
index 0000000..a11ea07
--- /dev/null
+++ b/sweettooth/review/templates/review/auto_approved_mail.txt
@@ -0,0 +1,13 @@
+A new extension version, "{{ extension.name }}", version {{ version.version }}, was auto-approved.
+
+The diff between this version and the last approved version contained these changed files:
+{% for file in changes %}
+ * {{ file }}
+{% endfor %}
+
+The full review details can be found at {{ review_url }}, and the extension's version page
+can be found at {{ version_url }}.
+
+â
+
+This email was sent automatically by GNOME Shell Extensions. Do not reply.
diff --git a/sweettooth/review/templates/review/auto_approved_mail_subject.txt b/sweettooth/review/templates/review/auto_approved_mail_subject.txt
new file mode 100644
index 0000000..48dc9d6
--- /dev/null
+++ b/sweettooth/review/templates/review/auto_approved_mail_subject.txt
@@ -0,0 +1 @@
+GNOME Shell Extensions â Auto-approved: "{{ extension.name }}", v{{ version.version }}
diff --git a/sweettooth/review/tests.py b/sweettooth/review/tests.py
index 7313da3..8ea8eb3 100644
--- a/sweettooth/review/tests.py
+++ b/sweettooth/review/tests.py
@@ -3,7 +3,7 @@ from django.test import TestCase
from django.core.files.base import File, ContentFile, StringIO
from extensions import models
-from review.views import get_old_version
+from review.views import get_old_version, safe_to_auto_approve
from testutils import BasicUserTestCase
@@ -28,3 +28,21 @@ class DiffViewTest(BasicUserTestCase, TestCase):
source=File(ContentFile("doot doo"), name="bb"),
status=models.STATUS_NEW)
self.assertEquals(version1, get_old_version(version3))
+
+class TestAutoApproveLogic(TestCase):
+ def build_changeset(self, added=None, deleted=None, changed=None, unchanged=None):
+ return dict(added=added or [],
+ deleted=deleted or [],
+ changed=changed or [],
+ unchanged=unchanged or [])
+
+ def test_auto_approve_logic(self):
+ self.assertTrue(safe_to_auto_approve(self.build_changeset()))
+ self.assertTrue(safe_to_auto_approve(self.build_changeset(changed=['metadata.json'])))
+ self.assertTrue(safe_to_auto_approve(self.build_changeset(changed=['metadata.json', 'po/en_GB.po', 'images/new_fedora.png', 'stylesheet.css'])))
+ self.assertTrue(safe_to_auto_approve(self.build_changeset(changed=['stylesheet.css'], added=['po/zn_CH.po'])))
+
+ self.assertFalse(safe_to_auto_approve(self.build_changeset(changed=['extension.js'])))
+ self.assertFalse(safe_to_auto_approve(self.build_changeset(changed=['secret_keys.json'])))
+ self.assertFalse(safe_to_auto_approve(self.build_changeset(changed=['libbignumber/BigInteger.js'])))
+ self.assertFalse(safe_to_auto_approve(self.build_changeset(added=['libbignumber/BigInteger.js'])))
diff --git a/sweettooth/review/views.py b/sweettooth/review/views.py
index c26f2cd..a36ab02 100644
--- a/sweettooth/review/views.py
+++ b/sweettooth/review/views.py
@@ -1,5 +1,6 @@
import base64
+import itertools
import os.path
import pygments
@@ -104,6 +105,16 @@ def get_old_version(version):
return old_version
+def get_latest_active_version(version):
+ extension = version.extension
+
+ try:
+ old_version = extension.versions.filter(version__lt=version.version, status__in=models.VISIBLE_STATUSES).latest()
+ except models.ExtensionVersion.DoesNotExist:
+ return None
+
+ return old_version
+
def get_zipfiles(*args):
for version in args:
if version is None:
@@ -146,13 +157,7 @@ def get_fake_chunks(numlines, tag):
def get_file_list(zipfile):
return set(n for n in zipfile.namelist() if not n.endswith('/'))
- ajax_view
- model_view(models.ExtensionVersion)
-def ajax_get_file_list_view(request, obj):
- version, extension = obj, obj.extension
-
- old_zipfile, new_zipfile = get_zipfiles(version, get_old_version(version))
-
+def get_file_changeset(old_zipfile, new_zipfile):
new_filelist = get_file_list(new_zipfile)
if old_zipfile is None:
@@ -186,9 +191,13 @@ def ajax_get_file_list_view(request, obj):
@ajax_view
@model_view(models.ExtensionVersion)
-def ajax_get_file_diff_view(request, obj):
- version, extension = obj, obj.extension
+def ajax_get_file_list_view(request, version):
+ old_zipfile, new_zipfile = get_zipfiles(get_old_version(version), version)
+ return get_file_changeset(old_zipfile, new_zipfile)
+ ajax_view
+ model_view(models.ExtensionVersion)
+def ajax_get_file_diff_view(request, version):
filename = request.GET['filename']
file_base, file_extension = os.path.splitext(filename)
@@ -315,7 +324,7 @@ def render_mail(template, data):
return subject.strip(), body.strip()
-def send_email_on_submitted(sender, request, version, **kwargs):
+def send_email_submitted(request, version):
extension = version.extension
url = request.build_absolute_uri(reverse('review-version',
@@ -335,7 +344,65 @@ def send_email_on_submitted(sender, request, version, **kwargs):
message = EmailMessage(subject=subject, body=body, to=recipient_list, headers=extra_headers)
message.send()
-models.submitted_for_review.connect(send_email_on_submitted)
+def send_email_auto_approved(request, version, changeset):
+ extension = version.extension
+
+ review_url = request.build_absolute_uri(reverse('review-version',
+ kwargs=dict(pk=version.pk)))
+ version_url = request.build_absolute_uri(version.get_absolute_url())
+
+ data = dict(version=version,
+ extension=extension,
+ changes=changeset['changed'],
+ version_url=version_url)
+
+ subject, body = render_mail('auto_approved', data)
+
+ recipient_list = list(get_all_reviewers().values_list('email', flat=True))
+ recipient_list.append(extension.creator.email)
+
+ extra_headers = {'X-SweetTooth-Purpose': 'AutoApproved',
+ 'X-SweetTooth-ExtensionCreator': extension.creator.username}
+
+ message = EmailMessage(subject=subject, body=body, to=recipient_list, headers=extra_headers)
+ message.send()
+
+def safe_to_auto_approve(changes):
+ for filename in itertools.chain(changes['changed'], changes['added']):
+ # metadata.json updates are safe.
+ if filename == 'metadata.json':
+ continue
+
+ name, ext = os.path.splitext(filename)
+
+ # Translations and stylesheet updates are safe.
+ if ext in set(['.mo', '.po', '.css']):
+ continue
+
+ # Image updates are safe.
+ if ext in IMAGE_TYPES:
+ continue
+
+ return False
+
+ return True
+
+def extension_submitted(sender, request, version, **kwargs):
+ old_zipfile, new_zipfile = get_zipfiles(get_latest_active_version(version), version)
+ changeset = get_file_changeset(old_zipfile, new_zipfile)
+
+ if safe_to_auto_approve(changeset):
+ ChangeStatusLog.objects.create(user=request.user,
+ version=version,
+ newstatus=models.STATUS_ACTIVE,
+ auto_approved=True)
+ version.status = models.STATUS_ACTIVE
+ version.save()
+ send_email_auto_approved(request, version, changeset)
+ else:
+ send_email_submitted(request, version)
+
+models.submitted_for_review.connect(extension_submitted)
def send_email_on_reviewed(sender, request, version, review, **kwargs):
extension = version.extension
[
Date Prev][
Date Next] [
Thread Prev][
Thread Next]
[
Thread Index]
[
Date Index]
[
Author Index]