From: Ɓukasz Rekucki Date: Tue, 22 Jun 2010 00:08:18 +0000 (+0200) Subject: Added lqc's DVCS application - mercurial on database. X-Git-Url: https://git.mdrn.pl/redakcja.git/commitdiff_plain/db50b75e23a5cd6b29d1315f3a8ed2b7befeb81f?hp=77d054cf52faf3ac0a56040cb29f40016a3c7453 Added lqc's DVCS application - mercurial on database. Distilled from backend-rewrite branch. --- diff --git a/apps/dvcs/__init__.py b/apps/dvcs/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/dvcs/admin.py b/apps/dvcs/admin.py new file mode 100644 index 00000000..c81d3b7b --- /dev/null +++ b/apps/dvcs/admin.py @@ -0,0 +1,5 @@ +from django.contrib.admin import site +from dvcs.models import Document, Change + +site.register(Document) +site.register(Change) diff --git a/apps/dvcs/models.py b/apps/dvcs/models.py new file mode 100644 index 00000000..47e7c26d --- /dev/null +++ b/apps/dvcs/models.py @@ -0,0 +1,151 @@ +from django.db import models +from django.contrib.auth.models import User +from django.utils.translation import ugettext_lazy as _ +from mercurial import mdiff, simplemerge +import pickle + +class Change(models.Model): + """ + Single document change related to previous change. The "parent" + argument points to the version against which this change has been + recorded. Initial text will have a null parent. + + Data contains a pickled diff needed to reproduce the initial document. + """ + author = models.ForeignKey(User) + patch = models.TextField(blank=True) + tree = models.ForeignKey('Document') + + parent = models.ForeignKey('self', + null=True, blank=True, default=None, + related_name="children") + + merge_parent = models.ForeignKey('self', + null=True, blank=True, default=None, + related_name="merge_children") + + description = models.TextField(blank=True, default='') + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ('created_at',) + + def __unicode__(self): + return u"Id: %r, Tree %r, Parent %r, Patch '''\n%s'''" % (self.id, self.tree_id, self.parent_id, self.patch) + + @staticmethod + def make_patch(src, dst): + return pickle.dumps(mdiff.textdiff(src, dst)) + + def materialize(self): + changes = Change.objects.exclude(parent=None).filter( + tree=self.tree, + created_at__lte=self.created_at).order_by('created_at') + text = u'' + for change in changes: + text = change.apply_to(text) + return text + + def make_child(self, patch, author, description): + return self.children.create(patch=patch, + tree=self.tree, author=author, + description=description) + + def make_merge_child(self, patch, author, description): + return self.merge_children.create(patch=patch, + tree=self.tree, author=author, + description=description) + + def apply_to(self, text): + return mdiff.patch(text, pickle.loads(self.patch.encode('ascii'))) + + def merge_with(self, other, author, description=u"Automatic merge."): + assert self.tree_id == other.tree_id # same tree + if other.parent_id == self.pk: + # immediate child + return other + + local = self.materialize() + base = other.merge_parent.materialize() + remote = other.apply_to(base) + + merge = simplemerge.Merge3Text(base, local, remote) + result = ''.join(merge.merge_lines()) + patch = self.make_patch(local, result) + return self.children.create( + patch=patch, merge_parent=other, tree=self.tree, + author=author, description=description) + + +class Document(models.Model): + """ + File in repository. + """ + creator = models.ForeignKey(User) + head = models.ForeignKey(Change, + null=True, blank=True, default=None, + help_text=_("This document's current head.")) + + # Some meta-data + name = models.CharField(max_length=200, + help_text=_("Name for this file to display.")) + + def __unicode__(self): + return u"{0}, HEAD: {1}".format(self.name, self.head_id) + + @models.permalink + def get_absolute_url(self): + return ('dvcs.views.document_data', (), { + 'document_id': self.id, + 'version': self.head_id, + }) + + def materialize(self, version=None): + if self.head is None: + return u'' + if version is None: + version = self.head + elif not isinstance(version, Change): + version = self.change_set.get(pk=version) + return version.materialize() + + def commit(self, **kwargs): + if 'parent' not in kwargs: + parent = self.head + else: + parent = kwargs['parent'] + if not isinstance(parent, Change): + parent = Change.objects.get(pk=kwargs['parent']) + + if 'patch' not in kwargs: + if 'text' not in kwargs: + raise ValueError("You must provide either patch or target document.") + patch = Change.make_patch(self.materialize(version=parent), kwargs['text']) + else: + if 'text' in kwargs: + raise ValueError("You can provide only text or patch - not both") + patch = kwargs['patch'] + + old_head = self.head + if parent != old_head: + change = parent.make_merge_child(patch, kwargs['author'], kwargs.get('description', '')) + # not Fast-Forward - perform a merge + self.head = old_head.merge_with(change, author=kwargs['author']) + else: + self.head = parent.make_child(patch, kwargs['author'], kwargs.get('description', '')) + self.save() + return self.head + + def history(self): + return self.changes.all() + + @staticmethod + def listener_initial_commit(sender, instance, created, **kwargs): + if created: + instance.head = Change.objects.create( + author=instance.creator, + patch=pickle.dumps(mdiff.textdiff('', '')), + tree=instance) + instance.save() + +models.signals.post_save.connect(Document.listener_initial_commit, sender=Document) diff --git a/apps/dvcs/tests.py b/apps/dvcs/tests.py new file mode 100644 index 00000000..0c712957 --- /dev/null +++ b/apps/dvcs/tests.py @@ -0,0 +1,164 @@ +from django.test import TestCase +from dvcs.models import Change, Document +from django.contrib.auth.models import User + +class DocumentModelTests(TestCase): + + def setUp(self): + self.user = User.objects.create_user("tester", "tester@localhost.local") + + def assertTextEqual(self, given, expected): + return self.assertEqual(given, expected, + "Expected '''%s'''\n differs from text: '''%s'''" % (expected, given) + ) + + def test_empty_file(self): + doc = Document.objects.create(name=u"Sample Document", creator=self.user) + self.assert_(doc.head is not None) + self.assertEqual(doc.materialize(), u"") + + def test_single_commit(self): + doc = Document.objects.create(name=u"Sample Document", creator=self.user) + doc.commit(text=u"Ala ma kota", description="Commit #1", author=self.user) + self.assert_(doc.head is not None) + self.assertEqual(doc.change_set.count(), 2) + self.assertEqual(doc.materialize(), u"Ala ma kota") + + def test_chained_commits(self): + doc = Document.objects.create(name=u"Sample Document", creator=self.user) + c1 = doc.commit(description="Commit #1", text=u""" + Line #1 + Line #2 is cool + """, author=self.user) + c2 = doc.commit(description="Commit #2", text=u""" + Line #1 + Line #2 is hot + """, author=self.user) + c3 = doc.commit(description="Commit #3", text=u""" + Line #1 + ... is hot + Line #3 ate Line #2 + """, author=self.user) + self.assert_(doc.head is not None) + self.assertEqual(doc.change_set.count(), 4) + + self.assertEqual(doc.materialize(), u""" + Line #1 + ... is hot + Line #3 ate Line #2 + """) + self.assertEqual(doc.materialize(version=c3), u""" + Line #1 + ... is hot + Line #3 ate Line #2 + """) + self.assertEqual(doc.materialize(version=c2), u""" + Line #1 + Line #2 is hot + """) + self.assertEqual(doc.materialize(version=c1), """ + Line #1 + Line #2 is cool + """) + + + def test_parallel_commit_noconflict(self): + doc = Document.objects.create(name=u"Sample Document", creator=self.user) + self.assert_(doc.head is not None) + base = doc.head + base = doc.commit(description="Commit #1", text=u""" + Line #1 + Line #2 +""", author=self.user) + + c1 = doc.commit(description="Commit #2", text=u""" + Line #1 is hot + Line #2 +""", parent=base, author=self.user) + self.assertTextEqual(c1.materialize(), u""" + Line #1 is hot + Line #2 +""") + c2 = doc.commit(description="Commit #3", text=u""" + Line #1 + Line #2 + Line #3 +""", parent=base, author=self.user) + self.assertEqual(doc.change_set.count(), 5) + self.assertTextEqual(doc.materialize(), u""" + Line #1 is hot + Line #2 + Line #3 +""") + + def test_parallel_commit_conflict(self): + doc = Document.objects.create(name=u"Sample Document", creator=self.user) + self.assert_(doc.head is not None) + base = doc.head + base = doc.commit(description="Commit #1", text=u""" +Line #1 +Line #2 +Line #3 +""", author=self.user) + + c1 = doc.commit(description="Commit #2", text=u""" +Line #1 +Line #2 is hot +Line #3 +""", parent=base, author=self.user) + c2 = doc.commit(description="Commit #3", text=u""" +Line #1 +Line #2 is cool +Line #3 +""", parent=base, author=self.user) + self.assertEqual(doc.change_set.count(), 5) + self.assertTextEqual(doc.materialize(), u""" +Line #1 +<<<<<<< +Line #2 is hot +======= +Line #2 is cool +>>>>>>> +Line #3 +""") + + def test_multiply_parallel_commits(self): + doc = Document.objects.create(name=u"Sample Document", creator=self.user) + self.assert_(doc.head is not None) + c1 = doc.commit(description="Commit A1", text=u""" +Line #1 + +Line #2 + +Line #3 +""", author=self.user) + c2 = doc.commit(description="Commit A2", text=u""" +Line #1 * + +Line #2 + +Line #3 +""", author=self.user) + c3 = doc.commit(description="Commit B1", text=u""" +Line #1 + +Line #2 ** + +Line #3 +""", parent=c1, author=self.user) + c4 = doc.commit(description="Commit C1", text=u""" +Line #1 * + +Line #2 + +Line #3 *** +""", parent=c2, author=self.user) + self.assertEqual(doc.change_set.count(), 7) + self.assertTextEqual(doc.materialize(), u""" +Line #1 * + +Line #2 ** + +Line #3 *** +""") + diff --git a/apps/dvcs/urls.py b/apps/dvcs/urls.py new file mode 100644 index 00000000..d1e1e296 --- /dev/null +++ b/apps/dvcs/urls.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 +from django.conf.urls.defaults import * + +urlpatterns = patterns('dvcs.views', + url(r'^data/(?P[^/]+)/(?P.*)$', 'document_data', name='storage_document_data'), +) diff --git a/apps/dvcs/views.py b/apps/dvcs/views.py new file mode 100644 index 00000000..7918e96c --- /dev/null +++ b/apps/dvcs/views.py @@ -0,0 +1,21 @@ +# Create your views here. +from django.views.generic.simple import direct_to_template +from django import http +from dvcs.models import Document + +def document_list(request, template_name="dvcs/document_list.html"): + return direct_to_template(request, template_name, { + "documents": Document.objects.all(), + }) + +def document_data(request, document_id, version=None): + doc = Document.objects.get(pk=document_id) + return http.HttpResponse(doc.materialize(version or None), content_type="text/plain") + +def document_history(request, docid, template_name="dvcs/document_history.html"): + document = Document.objects.get(pk=docid) + return direct_to_template(request, template_name, { + "document": document, + "changes": document.history(), + }) + diff --git a/redakcja/settings/common.py b/redakcja/settings/common.py index 8d032ef6..587374be 100644 --- a/redakcja/settings/common.py +++ b/redakcja/settings/common.py @@ -117,6 +117,7 @@ INSTALLED_APPS = ( 'south', 'sorl.thumbnail', 'filebrowser', + 'dvcs', 'wiki', 'toolbar', @@ -145,3 +146,4 @@ try: from redakcja.settings.compress import * except ImportError: pass + diff --git a/redakcja/urls.py b/redakcja/urls.py index dd7f884d..20994ce1 100644 --- a/redakcja/urls.py +++ b/redakcja/urls.py @@ -20,6 +20,7 @@ urlpatterns = patterns('', url(r'^$', 'django.views.generic.simple.redirect_to', {'url': '/documents/'}), url(r'^documents/', include('wiki.urls')), + url(r'^storage/', include('dvcs.urls')), # Static files (should be served by Apache) url(r'^%s(?P.+)$' % settings.MEDIA_URL[1:], 'django.views.static.serve',