epub covers
[redakcja.git] / apps / dvcs / models.py
index 3991efc..19e7d1e 100644 (file)
@@ -1,75 +1,38 @@
+from __future__ import unicode_literals, print_function
+
 from datetime import datetime
 from datetime import datetime
-import os.path
+import os
+import re
+from subprocess import PIPE, Popen
+from tempfile import NamedTemporaryFile
 
 
-from django.contrib.auth.models import User
+from django.conf import settings
 from django.core.files.base import ContentFile
 from django.core.files.storage import FileSystemStorage
 from django.db import models, transaction
 from django.db.models.base import ModelBase
 from django.core.files.base import ContentFile
 from django.core.files.storage import FileSystemStorage
 from django.db import models, transaction
 from django.db.models.base import ModelBase
+from django.utils.encoding import python_2_unicode_compatible
 from django.utils.translation import ugettext_lazy as _
 from django.utils.translation import ugettext_lazy as _
-from mercurial import mdiff, simplemerge
 
 
-from django.conf import settings
-from dvcs.signals import post_commit, post_publishable
+from dvcs.signals import post_commit, post_merge
 from dvcs.storage import GzipFileSystemStorage
 
 from dvcs.storage import GzipFileSystemStorage
 
+# default repository path; make a setting for it
+REPO_PATH = os.path.join(settings.MEDIA_ROOT, 'dvcs')
+repo = GzipFileSystemStorage(location=REPO_PATH)
 
 
-class Tag(models.Model):
-    """A tag (e.g. document stage) which can be applied to a Change."""
-    name = models.CharField(_('name'), max_length=64)
-    slug = models.SlugField(_('slug'), unique=True, max_length=64, 
-            null=True, blank=True)
-    ordering = models.IntegerField(_('ordering'))
-
-    _object_cache = {}
-
-    class Meta:
-        abstract = True
-        ordering = ['ordering']
-        verbose_name = _("tag")
-        verbose_name_plural = _("tags")
-
-    def __unicode__(self):
-        return self.name
-
-    @classmethod
-    def get(cls, slug):
-        if slug in cls._object_cache:
-            return cls._object_cache[slug]
-        else:
-            obj = cls.objects.get(slug=slug)
-            cls._object_cache[slug] = obj
-            return obj
-
-    @staticmethod
-    def listener_changed(sender, instance, **kwargs):
-        sender._object_cache = {}
-
-    def next(self):
-        """
-            Returns the next tag - stage to work on.
-            Returns None for the last stage.
-        """
-        try:
-            return type(self).objects.filter(ordering__gt=self.ordering)[0]
-        except IndexError:
-            return None
-
-models.signals.pre_save.connect(Tag.listener_changed, sender=Tag)
-
-
-def data_upload_to(instance, filename):
-    return "%d/%d" % (instance.tree.pk, instance.pk)
 
 
-class Change(models.Model):
+@python_2_unicode_compatible
+class Revision(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 file contains a gzipped text of the document.
+    A document revision. The "parent"
+    argument points to the version against which this change has been 
+    recorded. Initial text will have a null parent.
+
+    Gzipped text of the document is stored in a file.
     """
     """
-    author = models.ForeignKey(User, null=True, blank=True, verbose_name=_('author'))
+    author = models.ForeignKey(settings.AUTH_USER_MODEL,
+        null=True, blank=True, verbose_name=_('author'))
     author_name = models.CharField(_('author name'), max_length=128,
                         null=True, blank=True,
                         help_text=_("Used if author is not set.")
     author_name = models.CharField(_('author name'), max_length=128,
                         null=True, blank=True,
                         help_text=_("Used if author is not set.")
@@ -78,7 +41,8 @@ class Change(models.Model):
                         null=True, blank=True,
                         help_text=_("Used if author is not set.")
                         )
                         null=True, blank=True,
                         help_text=_("Used if author is not set.")
                         )
-    revision = models.IntegerField(_('revision'), db_index=True)
+    # Any other author data?
+    # How do we identify an author?
 
     parent = models.ForeignKey('self',
                         null=True, blank=True, default=None,
 
     parent = models.ForeignKey('self',
                         null=True, blank=True, default=None,
@@ -93,17 +57,23 @@ class Change(models.Model):
     description = models.TextField(_('description'), blank=True, default='')
     created_at = models.DateTimeField(editable=False, db_index=True, 
                         default=datetime.now)
     description = models.TextField(_('description'), blank=True, default='')
     created_at = models.DateTimeField(editable=False, db_index=True, 
                         default=datetime.now)
-    publishable = models.BooleanField(_('publishable'), default=False)
 
     class Meta:
 
     class Meta:
-        abstract = True
         ordering = ('created_at',)
         ordering = ('created_at',)
-        unique_together = ['tree', 'revision']
-        verbose_name = _("change")
-        verbose_name_plural = _("changes")
+        verbose_name = _("revision")
+        verbose_name_plural = _("revisions")
+
+    def __str__(self):
+        return "Id: %r, Parent %r, Data: %s" % (self.id, self.parent_id, self.get_text_path())
 
 
-    def __unicode__(self):
-        return u"Id: %r, Tree %r, Parent %r, Data: %s" % (self.id, self.tree_id, self.parent_id, self.data)
+    def get_text_path(self):
+        if self.pk:
+            return re.sub(r'([0-9a-f]{2})([^\.])', r'\1/\2', '%x.gz' % self.pk)
+        else:
+            return None
+
+    def save_text(self, content):
+        return repo.save(self.get_text_path(), ContentFile(content.encode('utf-8')))
 
     def author_str(self):
         if self.author:
 
     def author_str(self):
         if self.author:
@@ -117,221 +87,192 @@ class Change(models.Model):
                 self.author_email
                 )
 
                 self.author_email
                 )
 
-
-    def save(self, *args, **kwargs):
-        """
-            take the next available revision number if none yet
-        """
-        if self.revision is None:
-            tree_rev = self.tree.revision()
-            if tree_rev is None:
-                self.revision = 1
-            else:
-                self.revision = tree_rev + 1
-        return super(Change, self).save(*args, **kwargs)
+    @classmethod
+    def create(cls, text, parent=None, merge_parent=None,
+            author=None, author_name=None, author_email=None,
+            description=''):
+
+        if text:
+            text = text.replace(
+                '<dc:></dc:>', '').replace(
+                '<div class="img">', '<div>')
+
+        revision = cls.objects.create(
+            parent=parent,
+            merge_parent=merge_parent,
+            author=author,
+            author_name=author_name,
+            author_email=author_email,
+            description=description
+        )
+        revision.save_text(text)
+        return revision
 
     def materialize(self):
 
     def materialize(self):
-        f = self.data.storage.open(self.data)
-        text = f.read()
+        f = repo.open(self.get_text_path())
+        text = f.read().decode('utf-8')
         f.close()
         f.close()
-        return unicode(text, 'utf-8')
-
-    def merge_with(self, other, author=None, 
-            author_name=None, author_email=None, 
-            description=u"Automatic merge."):
-        """Performs an automatic merge after straying commits."""
-        assert self.tree_id == other.tree_id  # same tree
-        if other.parent_id == self.pk:
-            # immediate child - fast forward
+        if text:
+            text = text.replace(
+                '<dc:></dc:>', '').replace(
+                '<div class="img">', '<div>')
+        return text
+
+    def is_descendant_of(self, other):
+        # Naive approach.
+        return (
+            (
+                self.parent is not None and (
+                    self.parent.pk == other.pk or
+                    self.parent.is_descendant_of(other)
+                )
+            ) or (
+                self.merge_parent is not None and (
+                    self.merge_parent.pk == other.pk or
+                    self.merge_parent.is_descendant_of(other)
+                )
+            )
+        )
+
+    def get_common_ancestor_with(self, other):
+        # VERY naive approach.
+        if self.pk == other.pk:
+            return self
+        if self.is_descendant_of(other):
             return other
             return other
+        if other.is_descendant_of(self):
+            return self
 
 
-        local = self.materialize().encode('utf-8')
-        base = other.parent.materialize().encode('utf-8')
-        remote = other.materialize().encode('utf-8')
-
-        merge = simplemerge.Merge3Text(base, local, remote)
-        result = ''.join(merge.merge_lines())
-        merge_node = self.children.create(
-                    merge_parent=other, tree=self.tree,
-                    author=author,
-                    author_name=author_name,
-                    author_email=author_email,
-                    description=description)
-        merge_node.data.save('', ContentFile(result))
-        return merge_node
-
-    def revert(self, **kwargs):
-        """ commit this version of a doc as new head """
-        self.tree.commit(text=self.materialize(), **kwargs)
-
-    def set_publishable(self, publishable):
-        self.publishable = publishable
-        self.save()
-        post_publishable.send(sender=self, publishable=publishable)
-
-
-def create_tag_model(model):
-    name = model.__name__ + 'Tag'
-
-    class Meta(Tag.Meta):
-        app_label = model._meta.app_label
-
-    attrs = {
-        '__module__': model.__module__,
-        'Meta': Meta,
-    }
-    return type(name, (Tag,), attrs)
-
-
-def create_change_model(model):
-    name = model.__name__ + 'Change'
-    repo = GzipFileSystemStorage(location=model.REPO_PATH)
-
-    class Meta(Change.Meta):
-        app_label = model._meta.app_label
-
-    attrs = {
-        '__module__': model.__module__,
-        'tree': models.ForeignKey(model, related_name='change_set', verbose_name=_('document')),
-        'tags': models.ManyToManyField(model.tag_model, verbose_name=_('tags'), related_name='change_set'),
-        'data': models.FileField(_('data'), upload_to=data_upload_to, storage=repo),
-        'Meta': Meta,
-    }
-    return type(name, (Change,), attrs)
-
-
-class DocumentMeta(ModelBase):
-    "Metaclass for Document models."
-    def __new__(cls, name, bases, attrs):
-
-        model = super(DocumentMeta, cls).__new__(cls, name, bases, attrs)
-        if not model._meta.abstract:
-            # create a real Tag object and `stage' fk
-            model.tag_model = create_tag_model(model)
-            models.ForeignKey(model.tag_model, verbose_name=_('stage'),
-                null=True, blank=True).contribute_to_class(model, 'stage')
-
-            # create real Change model and `head' fk
-            model.change_model = create_change_model(model)
-
-            models.ForeignKey(model.change_model,
-                    null=True, blank=True, default=None,
-                    verbose_name=_('head'), 
-                    help_text=_("This document's current head."),
-                    editable=False).contribute_to_class(model, 'head')
-
-            models.ForeignKey(User, null=True, blank=True, editable=False,
-                verbose_name=_('creator'), related_name="created_%s" % name.lower()
-                ).contribute_to_class(model, 'creator')
-
-        return model
-
-
-class Document(models.Model):
-    """File in repository. Subclass it to use version control in your app."""
-
-    __metaclass__ = DocumentMeta
-
-    # default repository path
-    REPO_PATH = os.path.join(settings.MEDIA_ROOT, 'dvcs')
-
-    user = models.ForeignKey(User, null=True, blank=True,
-        verbose_name=_('user'), help_text=_('Work assignment.'))
+        if self.parent is not None:
+            parent_ca = self.parent.get_common_ancestor_with(other)
+        else:
+            parent_ca = None
 
 
-    class Meta:
-        abstract = True
+        if self.merge_parent is not None:
+            merge_parent_ca = self.merge_parent.get_common_ancestor_with(other)
+        else:
+            return parent_ca
+
+        if parent_ca is None or parent_ca.created_at < merge_parent_ca.created_at:
+            return merge_parent_ca
+
+        return parent_ca
+
+    def get_ancestors(self):
+        revs = set()
+        if self.parent is not None:
+            revs.add(self.parent)
+            revs.update(self.parent.get_ancestors())
+        if self.merge_parent is not None:
+            revs.add(self.merge_parent)
+            revs.update(self.merge_parent.get_ancestors())
+        return revs
+
+@python_2_unicode_compatible
+class Ref(models.Model):
+    """A reference pointing to a specific revision."""
+
+    revision = models.ForeignKey(Revision,
+            null=True, blank=True, default=None,
+            verbose_name=_('revision'), 
+            help_text=_("The document's revision."),
+            editable=False)
+
+    def __str__(self):
+        return "ref:{0}->rev:{1}".format(self.id, self.revision_id)
+
+    def merge_text(self, base, local, remote):
+        """Override in subclass to have different kinds of merges."""
+        files = []
+        for f in local, base, remote:
+            temp = NamedTemporaryFile(delete=False)
+            temp.write(f)
+            temp.close()
+            files.append(temp.name)
+        p = Popen(['/usr/bin/diff3', '-mE', '-L', 'old', '-L', '', '-L', 'new'] + files, stdout=PIPE)
+        result, errs = p.communicate()
+
+        for f in files:
+            os.unlink(f)
+        
+        return result.decode('utf-8')
 
 
-    def __unicode__(self):
-        return u"{0}, HEAD: {1}".format(self.id, self.head_id)
+    def merge_with(self, revision, 
+            author=None, author_name=None, author_email=None, 
+            description="Automatic merge."):
+        """Merges a given revision into the ref."""
+        if self.revision is None:
+            fast_forward = True
+            self.revision = revision
+        elif self.revision.pk == revision.pk or self.revision.is_descendant_of(revision):
+            # Already merged.
+            return
+        elif revision.is_descendant_of(self.revision):
+            # Fast forward.
+            fast_forward = True
+            self.revision = revision
+        else:
+            # Need to create a merge revision.
+            fast_forward = False
+            base = self.revision.get_common_ancestor_with(revision)
+
+            local_text = self.materialize().encode('utf-8')
+            base_text = base.materialize().encode('utf-8')
+            other_text = revision.materialize().encode('utf-8')
+
+            merge_text = self.merge_text(base_text, local_text, other_text)
+
+            merge_revision = Revision.create(
+                text=merge_text,
+                parent=self.revision,
+                merge_parent=revision,
+                author=author,
+                author_name=author_name,
+                author_email=author_email,
+                description=description
+            )
+            self.revision = merge_revision
+        self.save()
+        post_merge.send(sender=type(self), instance=self, fast_forward=fast_forward)
 
 
-    def materialize(self, change=None):
-        if self.head is None:
-            return u''
-        if change is None:
-            change = self.head
-        elif not isinstance(change, Change):
-            change = self.change_set.get(pk=change)
-        return change.materialize()
+    def materialize(self):
+        return self.revision.materialize() if self.revision is not None else ''
 
 
-    def commit(self, text, author=None, author_name=None, author_email=None,
-            publishable=False, **kwargs):
-        """Commits a new revision.
+    def commit(self, text, parent=False,
+            author=None, author_name=None, author_email=None,
+            description=''):
+        """Creates a new revision and sets it as the ref.
 
         This will automatically merge the commit into the main branch,
         if parent is not document's head.
 
         :param unicode text: new version of the document
 
         This will automatically merge the commit into the main branch,
         if parent is not document's head.
 
         :param unicode text: new version of the document
-        :param parent: parent revision (head, if not specified)
-        :type parent: Change or None
+        :param base: parent revision (head, if not specified)
+        :type base: Revision or None
         :param User author: the commiter
         :param unicode author_name: commiter name (if ``author`` not specified)
         :param unicode author_email: commiter e-mail (if ``author`` not specified)
         :param User author: the commiter
         :param unicode author_name: commiter name (if ``author`` not specified)
         :param unicode author_email: commiter e-mail (if ``author`` not specified)
-        :param Tag[] tags: list of tags to apply to the new commit
-        :param bool publishable: set new commit as ready to publish
         :returns: new head
         """
         :returns: new head
         """
-        if 'parent' not in kwargs:
-            parent = self.head
-        else:
-            parent = kwargs['parent']
-            if parent is not None and not isinstance(parent, Change):
-                parent = self.change_set.objects.get(pk=kwargs['parent'])
-
-        tags = kwargs.get('tags', [])
-        if tags:
-            # set stage to next tag after the commited one
-            self.stage = max(tags, key=lambda t: t.ordering).next()
-
-        change = self.change_set.create(author=author,
-                    author_name=author_name,
-                    author_email=author_email,
-                    description=kwargs.get('description', ''),
-                    publishable=publishable,
-                    parent=parent)
-
-        change.tags = tags
-        change.data.save('', ContentFile(text.encode('utf-8')))
-        change.save()
-
-        if self.head:
-            # merge new change as new head
-            self.head = self.head.merge_with(change, author=author,
-                    author_name=author_name,
-                    author_email=author_email)
-        else:
-            self.head = change
-        self.save()
-
-        post_commit.send(sender=self.head)
-
-        return self.head
+        if parent is False:
+            # If parent revision not set explicitly, use your head.
+            parent = self.revision
+
+        # Warning: this will silently leave revs unreferenced.
+        rev = Revision.create(
+                text=text,
+                author=author,
+                author_name=author_name,
+                author_email=author_email,
+                description=description,
+                parent=parent
+            )
+        self.merge_with(rev, author=author, author_name=author_name,
+            author_email=author_email)
+
+        post_commit.send(sender=type(self), instance=self)
 
     def history(self):
 
     def history(self):
-        return self.change_set.filter(revision__gt=-1)
-
-    def revision(self):
-        rev = self.change_set.aggregate(
-                models.Max('revision'))['revision__max']
-        return rev
-
-    def at_revision(self, rev):
-        """Returns a Change with given revision number."""
-        return self.change_set.get(revision=rev)
-
-    def publishable(self):
-        changes = self.change_set.filter(publishable=True)
-        if changes.exists():
-            return changes.order_by('-created_at')[0]
-        else:
-            return None
-
-    @transaction.commit_on_success
-    def prepend_history(self, other):
-        """Takes over the the other document's history and prepends to own."""
-
-        assert self != other
-        other_revs = other.change_set.all().count()
-        self.change_set.all().update(revision=models.F('revision') + other_revs)
-        other.change_set.all().update(tree=self)
-        assert not other.change_set.exists()
-        other.delete()
+        revs = self.revision.get_ancestors()
+        revs.add(self.revision)
+        return sorted(revs, key=lambda x: x.created_at)