[Patchew-devel] [PATCH 07/10] testing: switch to Result model

Paolo Bonzini pbonzini at redhat.com
Wed Jun 13 10:37:41 UTC 2018


---
 .../0029_populate_testing_results.py          | 220 ++++++++++++++++++
 mods/testing.py                               | 182 ++++++---------
 tests/test_testing.py                         |  53 +++--
 3 files changed, 325 insertions(+), 130 deletions(-)
 create mode 100644 api/migrations/0029_populate_testing_results.py

diff --git a/api/migrations/0029_populate_testing_results.py b/api/migrations/0029_populate_testing_results.py
new file mode 100644
index 0000000..0607936
--- /dev/null
+++ b/api/migrations/0029_populate_testing_results.py
@@ -0,0 +1,220 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.conf import settings
+from django.db import migrations
+from django.db.models import Count
+from api.migrations import (get_property, get_property_raw,
+        load_property, set_property, delete_property_blob)
+
+import abc
+from collections import defaultdict
+from copy import copy
+import datetime
+import lzma
+
+# For Result's constant status values
+import api.models
+
+class Converter(object, metaclass=abc.ABCMeta):
+    def __init__(self, apps, schema_editor):
+        # We can't import the models directly as they may be a newer
+        # version than this migration expects. We use the historical version.
+        self.Project = apps.get_model('api', 'Project')
+        self.Message = apps.get_model('api', 'Message')
+        self.MessageProperty = apps.get_model('api', 'MessageProperty')
+        self.ProjectProperty = apps.get_model('api', 'ProjectProperty')
+        self.MessageResult = apps.get_model('api', 'MessageResult')
+        self.ProjectResult = apps.get_model('api', 'ProjectResult')
+        self.LogEntry = apps.get_model('api', 'LogEntry')
+
+    @abc.abstractmethod
+    def get_objects_for_project(self, po):
+        pass
+
+    @abc.abstractmethod
+    def set_property(self, obj, name, value):
+        pass
+
+    @abc.abstractmethod
+    def get_property(self, obj, name):
+        pass
+
+    @abc.abstractmethod
+    def get_properties(self, obj, **kwargs):
+        pass
+
+    @abc.abstractmethod
+    def delete_property(self, obj, name):
+        pass
+
+    @abc.abstractmethod
+    def create_result(self, obj, **kwargs):
+        pass
+
+    def get_projects_with_tests(self):
+        return self.Project.objects.filter(
+                projectproperty__name__startswith='testing.').distinct()
+
+    def get_tests(self, po):
+        # Based on TestingModule.get_tests
+        ret = {}
+        props = self.ProjectProperty.objects.filter(name__startswith='testing.tests.',
+                                                    project=po)
+        for p in props:
+            k = p.name
+            v = load_property(p)
+            tn = k[len("testing.tests."):]
+            if "." not in tn:
+                continue
+            an = tn[tn.find(".") + 1:]
+            tn = tn[:tn.find(".")]
+            ret.setdefault(tn, {})
+            ret[tn][an] = v
+        return ret
+
+    def do_result_from_properties(self):
+        for po in self.Project.objects.all():
+            tests = self.get_tests(po)
+            for obj in self.get_objects_for_project(po):
+                pending_status = api.models.Result.RUNNING \
+                        if self.get_property(obj, "testing.started") \
+                        else api.models.Result.PENDING
+                done_tests = set()
+                results = []
+                for prop in self.get_properties(obj, name__startswith='testing.report.'):
+                    tn = prop.name[len('testing.report.'):]
+                    done_tests.add(tn)
+                    r = self.create_result(obj)
+                    report = load_property(prop)
+                    passed = report["passed"]
+                    del report["passed"]
+                    log = self.get_property(obj, "testing.log." + tn)
+                    r.name = 'testing.' + tn
+                    r.status = api.models.Result.SUCCESS if passed else api.models.Result.FAILURE
+                    r.last_update = datetime.datetime.utcnow()
+                    r.data = report
+                    if log:
+                        log_xz = lzma.compress(log.encode("utf-8"))
+                        log_entry = self.LogEntry(data_xz=log_xz)
+                        log_entry.save()
+                        r.log_entry = log_entry
+                        self.delete_property(obj, "testing.log." + tn)
+                    r.save()
+                    results.append(r)
+                    self.delete_property(obj, "testing.report." + tn)
+                if self.get_property(obj, "testing.ready"):
+                    for tn, test in tests.items():
+                        if tn in done_tests:
+                            continue
+                        r = self.create_result(obj)
+                        r.name = 'testing.' + tn
+                        r.status = pending_status
+                        r.last_update = datetime.datetime.utcnow()
+                        r.save()
+                        results.append(r)
+                    self.delete_property(obj, "testing.ready")
+                #print(obj, len(done_tests), len(tests) - len(done_tests))
+                obj.results.add(*results)
+                try:
+                    self.delete_property(obj, "testing.started")
+                    self.delete_property(obj, "testing.failed")
+                    self.delete_property(obj, "testing.start-time")
+                except:
+                    pass
+
+    def do_result_to_properties(self):
+        for po in self.Project.objects.all():
+            for obj in self.get_objects_for_project(po):
+                by_status = defaultdict(lambda: 0)
+                start_time = datetime.datetime.utcnow()
+                for r in obj.results.filter(name__startswith='testing.'):
+                    by_status[r.status] += 1
+                    if r.status in (api.models.Result.SUCCESS, api.models.Result.FAILURE):
+                        tn = r.name[len('testing.'):]
+                        report = copy(r.data)
+                        report['passed'] = (r.status == api.models.Result.SUCCESS)
+                        self.set_property(obj, "testing.report." + tn, report)
+                        if r.log_entry:
+                            log = lzma.decompress(r.log_entry.data_xz).decode("utf-8")
+                            self.set_property(obj, "testing.log." + tn, log)
+                    else:
+                        started = started or r.status == api.models.Result.RUNNING
+                        if r.last_update < start_time:
+                            start_time = r.last_update
+                #print(obj, dict(by_status))
+                if by_status[api.models.Result.FAILURE]:
+                    self.set_property(obj, "testing.failed", True)
+                if by_status[api.models.Result.RUNNING]:
+                    self.set_property(obj, "testing.started", True)
+                    self.set_property(obj, "testing.start-time", d.timestamp())
+                if by_status[api.models.Result.RUNNING] + by_status[api.models.Result.PENDING]:
+                    self.set_property(obj, "testing.ready", 1)
+                else:
+                    self.set_property(obj, "testing.done", True)
+                obj.results.filter(name__startswith='testing.').delete()
+
+class ProjectConverter(Converter):
+    def get_objects_for_project(self, po):
+        yield po
+
+    def set_property(self, obj, name, value):
+        set_property(self.ProjectProperty, name, value, project=obj)
+
+    def get_property(self, obj, name):
+        try:
+            return get_property(self.ProjectProperty, name, project=obj)
+        except:
+            return None
+
+    def get_properties(self, obj, **kwargs):
+        return self.ProjectProperty.objects.filter(project=obj, **kwargs)
+
+    def delete_property(self, obj, name):
+        delete_property_blob(self.ProjectProperty, name, project=obj)
+        get_property_raw(self.ProjectProperty, name, project=obj).delete()
+
+    def create_result(self, obj, **kwargs):
+        return self.ProjectResult(project=obj, **kwargs)
+
+class MessageConverter(Converter):
+    def get_objects_for_project(self, po):
+        yield from self.Message.objects.filter(is_series_head=True, project=po)
+
+    def set_property(self, obj, name, value):
+        set_property(self.MessageProperty, name, value, message=obj)
+
+    def get_property(self, obj, name):
+        try:
+            return get_property(self.MessageProperty, name, message=obj)
+        except:
+            return None
+
+    def get_properties(self, obj, **kwargs):
+        return self.MessageProperty.objects.filter(message=obj, **kwargs)
+
+    def delete_property(self, obj, name):
+        delete_property_blob(self.MessageProperty, name, message=obj)
+        get_property_raw(self.MessageProperty, name, message=obj).delete()
+
+    def create_result(self, obj, **kwargs):
+        return self.MessageResult(message=obj, **kwargs)
+
+def result_from_properties(apps, schema_editor):
+    ProjectConverter(apps, schema_editor).do_result_from_properties()
+    MessageConverter(apps, schema_editor).do_result_from_properties()
+
+def result_to_properties(apps, schema_editor):
+    ProjectConverter(apps, schema_editor).do_result_to_properties()
+    MessageConverter(apps, schema_editor).do_result_to_properties()
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('api', '0028_populate_git_results'),
+    ]
+
+    operations = [
+        migrations.RunPython(result_from_properties,
+                             reverse_code=result_to_properties),
+    ]
diff --git a/mods/testing.py b/mods/testing.py
index 8328a7c..9c053a7 100644
--- a/mods/testing.py
+++ b/mods/testing.py
@@ -11,13 +11,15 @@
 from django.conf.urls import url
 from django.http import HttpResponseForbidden, Http404, HttpResponseRedirect
 from django.core.exceptions import PermissionDenied
+from django.db.models import Q
 from django.urls import reverse
 from django.utils.html import format_html
 from mod import PatchewModule
 import time
 import math
 from api.views import APILoginRequiredView
-from api.models import Message, MessageProperty, Project, Result, ResultTuple
+from api.models import (Message, MessageProperty, MessageResult,
+        Project, ProjectResult, Result, ResultTuple)
 from api.search import SearchEngine
 from event import emit_event, declare_event, register_handler
 from patchew.logviewer import LogView
@@ -42,10 +44,8 @@ class TestingLogViewer(LogView):
             obj = Message.objects.find_series(project_or_series)
         if not obj:
             raise Http404("Object not found: " + project_or_series)
-        log = obj.get_property("testing.log." + testing_name)
-        if log is None:
-            raise Http404("Testing log not found: " + testing_name)
-        return log
+        r = _instance.get_testing_result(obj, testing_name)
+        return r.log
 
 
 class TestingModule(PatchewModule):
@@ -122,31 +122,40 @@ class TestingModule(PatchewModule):
             and old_value != value:
             self.recalc_pending_tests(obj)
 
-    def is_testing_done(self, obj, tn):
-        return obj.get_property("testing.report." + tn)
+    def get_testing_results(self, obj, *args, **kwargs):
+        return obj.results.filter(name__startswith='testing.', *args, **kwargs)
+
+    def get_testing_result(self, obj, name):
+        try:
+            return obj.results.get(name='testing.' + name)
+        except:
+            raise Http404("Test doesn't exist")
+
+    def get_test_name(self, result):
+        return result.name[len('testing.'):]
 
     def recalc_pending_tests(self, obj):
         test_dict = self.get_tests(obj)
         all_tests = set((k for k, v in test_dict.items() if v.get("enabled", False)))
+        for r in self.get_testing_results(obj, status=Result.PENDING):
+            r.delete()
         if len(all_tests):
-            done_tests = set((tn for tn in all_tests if self.is_testing_done(obj, tn)))
+            done_tests = [self.get_test_name(r) for r in self.get_testing_results(obj)]
+            for tn in all_tests:
+                if not tn in done_tests:
+                    obj.create_result(name='testing.' + tn, status=Result.PENDING).save()
             if len(done_tests) < len(all_tests):
                 obj.set_property("testing.done", None)
-                obj.set_property("testing.ready", 1)
                 return
         obj.set_property("testing.done", True)
-        obj.set_property("testing.ready", None)
 
     def clear_and_start_testing(self, obj, test=""):
         for k in list(obj.get_properties().keys()):
-            if (not test and k == "testing.started") or \
-               (not test and k == "testing.start-time") or \
-               (not test and k == "testing.failed") or \
-               k == "testing.done" or \
-               k == "testing.tested-head" or \
-               k.startswith("testing.report." + test) or \
-               k.startswith("testing.log." + test):
+            if k == "testing.done" or \
+               k == "testing.tested-head":
                 obj.set_property(k, None)
+        for r in self.get_testing_results(obj):
+            r.delete()
         self.recalc_pending_tests(obj)
 
     def www_view_testing_reset(self, request, project_or_series):
@@ -202,24 +211,21 @@ class TestingModule(PatchewModule):
                 raise Exception("Series doesn't exist")
             project = obj.project.name
         user = request.user
-        log_url = self.reverse_testing_log(obj, test, request=request)
-        html_log_url = self.reverse_testing_log(obj, test, request=request, html=True)
-        obj.set_property("testing.report." + test,
-                         {"passed": passed,
-                          "is_timeout": is_timeout,
-                          "user": user.username,
-                          "tester": tester or user.username,
-                         })
-        obj.set_property("testing.log." + test, log)
-        if not passed:
-            obj.set_property("testing.failed", True)
-        reports = [x for x in obj.get_properties() if x.startswith("testing.report.")]
-        done_tests = set([x[len("testing.report."):] for x in reports])
-        all_tests = set([k for k, v in self.get_tests(obj).items() if v["enabled"]])
-        if all_tests.issubset(done_tests):
+
+        r = self.get_testing_result(obj, test)
+        r.data = {"is_timeout": is_timeout,
+                  "user": user.username,
+                  "tester": tester or user.username}
+        r.log = log
+        r.status = Result.SUCCESS if passed else Result.FAILURE
+        r.save()
+        if not self.get_testing_results(obj,
+                       status__in=(Result.PENDING, Result.RUNNING)).exists():
             obj.set_property("testing.done", True)
-            obj.set_property("testing.ready", None)
             obj.set_property("testing.tested-head", head)
+
+        log_url = self.reverse_testing_log(obj, test, request=request)
+        html_log_url = self.reverse_testing_log(obj, test, request=request, html=True)
         emit_event("TestingReport", tester=tester, user=user.username,
                     obj=obj, passed=passed, test=test, log=log, log_url=log_url,
                     html_log_url=html_log_url, is_timeout=is_timeout)
@@ -257,11 +263,8 @@ class TestingModule(PatchewModule):
                 "class": "warning",
                 "icon": "refresh",
                }]
-        for pn, p in obj.get_properties().items():
-            if not pn.startswith("testing.report."):
-                continue
-            tn = pn[len("testing.report."):]
-            failed = not p["passed"]
+        for r in self.get_testing_results(obj, ~Q(status=Result.PENDING)):
+            tn = self.get_test_name(r)
             ret.append({"url": url + "&test=" + tn,
                         "title": format_html("Reset <b>{}</b> testing state", tn),
                         "class": "warning",
@@ -270,39 +273,16 @@ class TestingModule(PatchewModule):
         return ret
 
     def rest_results_hook(self, obj, results, detailed=False):
-        all_tests = set([k for k, v in _instance.get_tests(obj).items() if v["enabled"]])
-        for pn, p in obj.get_properties().items():
-            if not pn.startswith("testing.report."):
-                continue
-            tn = pn[len("testing.report."):]
-            try:
-                all_tests.remove(tn)
-            except:
-                pass
-            failed = not p["passed"]
-            passed_str = Result.FAILURE if failed else Result.SUCCESS
-            if detailed:
-                log = obj.get_property("testing.log." + tn)
-            else:
-                log = None
-
-            data = p.copy()
-            del data['passed']
-            results.append(ResultTuple(name='testing.' + tn, obj=obj, status=passed_str,
-                                  log=log, data=data, renderer=self))
-
-        if obj.get_property("testing.ready"):
-            for tn in all_tests:
-                results.append(ResultTuple(name='testing.' + tn, obj=obj, status='pending'))
+        Result.get_result_tuples(obj, "testing", results)
 
     def prepare_message_hook(self, request, message, detailed):
         if not message.is_series_head:
             return
         if message.project.maintained_by(request.user) \
-                and message.get_property("testing.started"):
+                and self.get_testing_results(message, ~Q(status=Result.PENDING)).exists():
             message.extra_ops += self._build_reset_ops(message)
 
-        if message.get_property("testing.failed"):
+        if self.get_testing_results(message, status=Result.FAILURE).exists():
             message.status_tags.append({
                 "title": "Testing failed",
                 "url": reverse("series_detail",
@@ -421,61 +401,45 @@ class TestingGetView(APILoginRequiredView):
                                         },
                                         test=test)
 
-    def _find_applicable_test(self, user, project, tester, capabilities, obj):
-        for tn, t in _instance.get_tests(project).items():
-            if not t.get("enabled"):
-                continue
-            if _instance.is_testing_done(obj, tn):
+    def _find_applicable_test(self, queryset, user, po, tester, capabilities):
+        # Prefer non-running tests, or tests that started the earliest
+        q = queryset.filter(status__in=(Result.PENDING, Result.RUNNING),
+                            name__startswith='testing.').order_by('status', 'last_update')
+        tests = _instance.get_tests(po)
+        for r in q:
+            tn = _instance.get_test_name(r)
+            t = tests.get(tn, None)
+            # Shouldn't happen, but let's protect against it
+            if not t:
                 continue
-            # TODO: group?
-            ok = True
             reqs = t.get("requirements", "")
             for r in [x.strip() for x in reqs.split(",") if x]:
                 if r not in capabilities:
-                    ok = False
                     break
-            if not ok:
-                continue
-            return t
+            else:
+                yield r, t
 
     def _find_project_test(self, request, po, tester, capabilities):
-        if not po.get_property("testing.ready"):
-            return
         head = po.get_property("git.head")
         repo = po.git
         tested = po.get_property("testing.tested-head")
         if not head or not repo:
-            return
-        test = self._find_applicable_test(request.user, po,
-                                          tester, capabilities, po)
-        if not test:
-            return
-        td = self._generate_project_test_data(po.name, repo, head, tested, test)
-        return po, td
+            return None
+        candidates = self._find_applicable_test(ProjectResult.objects.filter(project=po),
+                                                request.user, po, tester, capabilities)
+        for r, test in candidates:
+            td = self._generate_project_test_data(po.name, repo, head, tested, test)
+            return r, po, td
+        return None
 
     def _find_series_test(self, request, po, tester, capabilities):
-        q = MessageProperty.objects.filter(name="testing.ready",
-                                           value=1,
-                                           message__project=po)
-        candidate = None
-        for prop in q:
-            s = prop.message
-            test = self._find_applicable_test(request.user, po,
-                                              tester, capabilities, s)
-            if not test:
-                continue
-            if not s.get_property("testing.started"):
-                candidate = s, test
-                break
-            # Pick one series that started test the earliest
-            if not candidate or \
-                    s.get_property("testing.start-time") < \
-                    candidate[0].get_property("testing.start-time"):
-                candidate = s, test
-        if not candidate:
-            return None
-        return candidate[0], \
-               self._generate_series_test_data(candidate[0], candidate[1])
+        candidates = self._find_applicable_test(MessageResult.objects.filter(message__project=po),
+                                                request.user, po, tester, capabilities)
+        for r, test in candidates:
+            s = r.message
+            td = self._generate_series_test_data(s, test)
+            return r, s, td
+        return None
 
     def handle(self, request, project, tester, capabilities):
         # Try project head test first
@@ -486,9 +450,9 @@ class TestingGetView(APILoginRequiredView):
             candidate = self._find_series_test(request, po, tester, capabilities)
         if not candidate:
             return
-        obj, test_data = candidate
-        obj.set_property("testing.started", True)
-        obj.set_property("testing.start-time", time.time())
+        r, obj, test_data = candidate
+        r.status = Result.RUNNING
+        r.save()
         return test_data
 
 class TestingReportView(APILoginRequiredView):
diff --git a/tests/test_testing.py b/tests/test_testing.py
index 8f831a1..9e61a45 100755
--- a/tests/test_testing.py
+++ b/tests/test_testing.py
@@ -14,7 +14,7 @@ import os
 import subprocess
 sys.path.append(os.path.dirname(__file__))
 from patchewtest import PatchewTestCase, main
-from api.models import Message
+from api.models import Message, Result
 
 def create_test(project, name):
     prefix = "testing.tests." + name + "."
@@ -35,14 +35,24 @@ class TestingTestCase(PatchewTestCase, metaclass=abc.ABCMeta):
 
         create_test(self.p, "a")
 
-    def _do_testing_done(self, obj, log, report):
-        if not 'passed' in report:
-            report['passed'] = True
-        obj.set_property("testing.report.tests", report)
-        if log is not None:
-            obj.set_property("testing.log.tests", log)
+    def modify_test_result(self, obj, **kwargs):
+        try:
+            r = obj.results.get(name='testing.a')
+        except:
+            r = obj.create_result(name='testing.a')
+            if not 'status' in kwargs:
+                kwargs['status'] = Result.PENDING
+
+        if len(kwargs):
+            for k, v in kwargs.items():
+                setattr(r, k, v)
+            r.save()
+
+    def _do_testing_done(self, obj, **kwargs):
+        if not 'status' in kwargs:
+            kwargs['status'] = Result.SUCCESS
+        self.modify_test_result(obj, **kwargs)
         obj.set_property("testing.done", True)
-        obj.set_property("testing.ready", None)
 
     def do_testing_report(self, **report):
         self.api_login()
@@ -81,6 +91,8 @@ class TestingTestCase(PatchewTestCase, metaclass=abc.ABCMeta):
                            tester="dummy tester",
                            capabilities=[])
         self.assertIn("head", td)
+        resp = self.get_test_result('a')
+        self.assertEquals(resp.data['status'], 'running')
 
     def test_done(self):
         self.do_testing_done()
@@ -96,8 +108,8 @@ class TestingTestCase(PatchewTestCase, metaclass=abc.ABCMeta):
         self.assertEquals(resp.data['status'], 'pending')
 
     def test_rest_done_success(self):
-        self.do_testing_done(log='everything good!', passed=True)
-        resp = self.get_test_result('tests')
+        self.do_testing_done(log='everything good!', status=Result.SUCCESS)
+        resp = self.get_test_result('a')
         self.assertEquals(resp.data['status'], 'success')
         self.assertEquals(resp.data['log'], 'everything good!')
         log = self.client.get(resp.data['log_url'])
@@ -105,10 +117,9 @@ class TestingTestCase(PatchewTestCase, metaclass=abc.ABCMeta):
         self.assertEquals(log.content, b'everything good!')
 
     def test_rest_done_failure(self):
-        self.do_testing_done(log='sorry no good', passed=False, random_stuff='xyz')
-        resp = self.get_test_result('tests')
+        self.do_testing_done(log='sorry no good', status=Result.FAILURE)
+        resp = self.get_test_result('a')
         self.assertEquals(resp.data['status'], 'failure')
-        self.assertEquals(resp.data['data']['random_stuff'], 'xyz')
         self.assertEquals(resp.data['log'], 'sorry no good')
         log = self.client.get(resp.data['log_url'])
         self.assertEquals(log.status_code, 200)
@@ -151,8 +162,8 @@ class MessageTestingTest(TestingTestCase):
         self.msg.set_property("git.tag", "dummy tag")
         self.msg.set_property("git.base", "dummy base")
 
-    def do_testing_done(self, log=None, **report):
-        self._do_testing_done(self.msg, log, report)
+    def do_testing_done(self, **kwargs):
+        self._do_testing_done(self.msg, **kwargs)
 
     def do_testing_report(self, **report):
         r = super(MessageTestingTest, self).do_testing_report(**report)
@@ -164,17 +175,18 @@ class MessageTestingTest(TestingTestCase):
                                        self.PROJECT_BASE, self.msg.message_id, test_name))
 
     def test_testing_ready(self):
-        self.assertTrue(self.msg.get_property("testing.ready"))
         # Set property through series_heads elements must be handled the same
         self.msg.set_property("git.repo", None)
         self.msg.set_property("git.tag", None)
-        self.msg.set_property("testing.ready", None)
+        self.assertEqual(self.msg.results.filter(name='testing.a').first().status,
+                         Result.PENDING)
         msg = Message.objects.series_heads()[0]
         self.assertEqual(self.msg.message_id, msg.message_id)
         msg.set_property("git.repo", "dummy repo")
         msg.set_property("git.tag", "dummy tag")
         msg.set_property("git.base", "dummy base")
-        self.assertTrue(msg.get_property("testing.ready"))
+        self.assertEqual(self.msg.results.filter(name='testing.a').first().status,
+                         Result.PENDING)
 
 class ProjectTestingTest(TestingTestCase):
 
@@ -182,10 +194,9 @@ class ProjectTestingTest(TestingTestCase):
         super(ProjectTestingTest, self).setUp()
         self.p.set_property("git.head", "5678")
         self.p.set_property("testing.tested-head", "1234")
-        self.p.set_property("testing.ready", 1)
 
-    def do_testing_done(self, log=None, **report):
-        self._do_testing_done(self.p, log, report)
+    def do_testing_done(self, **kwargs):
+        self._do_testing_done(self.p, **kwargs)
 
     def do_testing_report(self, **report):
         r = super(ProjectTestingTest, self).do_testing_report(**report)
-- 
2.17.0





More information about the Patchew-devel mailing list