19e7d1e40537017ce3b1667ecbd1ffed80f0b42c
[redakcja.git] / apps / dvcs / models.py
1 from __future__ import unicode_literals, print_function
2
3 from datetime import datetime
4 import os
5 import re
6 from subprocess import PIPE, Popen
7 from tempfile import NamedTemporaryFile
8
9 from django.conf import settings
10 from django.core.files.base import ContentFile
11 from django.core.files.storage import FileSystemStorage
12 from django.db import models, transaction
13 from django.db.models.base import ModelBase
14 from django.utils.encoding import python_2_unicode_compatible
15 from django.utils.translation import ugettext_lazy as _
16
17 from dvcs.signals import post_commit, post_merge
18 from dvcs.storage import GzipFileSystemStorage
19
20 # default repository path; make a setting for it
21 REPO_PATH = os.path.join(settings.MEDIA_ROOT, 'dvcs')
22 repo = GzipFileSystemStorage(location=REPO_PATH)
23
24
25 @python_2_unicode_compatible
26 class Revision(models.Model):
27     """
28     A document revision. The "parent"
29     argument points to the version against which this change has been 
30     recorded. Initial text will have a null parent.
31
32     Gzipped text of the document is stored in a file.
33     """
34     author = models.ForeignKey(settings.AUTH_USER_MODEL,
35         null=True, blank=True, verbose_name=_('author'))
36     author_name = models.CharField(_('author name'), max_length=128,
37                         null=True, blank=True,
38                         help_text=_("Used if author is not set.")
39                         )
40     author_email = models.CharField(_('author email'), max_length=128,
41                         null=True, blank=True,
42                         help_text=_("Used if author is not set.")
43                         )
44     # Any other author data?
45     # How do we identify an author?
46
47     parent = models.ForeignKey('self',
48                         null=True, blank=True, default=None,
49                         verbose_name=_('parent'),
50                         related_name="children")
51
52     merge_parent = models.ForeignKey('self',
53                         null=True, blank=True, default=None,
54                         verbose_name=_('merge parent'),
55                         related_name="merge_children")
56
57     description = models.TextField(_('description'), blank=True, default='')
58     created_at = models.DateTimeField(editable=False, db_index=True, 
59                         default=datetime.now)
60
61     class Meta:
62         ordering = ('created_at',)
63         verbose_name = _("revision")
64         verbose_name_plural = _("revisions")
65
66     def __str__(self):
67         return "Id: %r, Parent %r, Data: %s" % (self.id, self.parent_id, self.get_text_path())
68
69     def get_text_path(self):
70         if self.pk:
71             return re.sub(r'([0-9a-f]{2})([^\.])', r'\1/\2', '%x.gz' % self.pk)
72         else:
73             return None
74
75     def save_text(self, content):
76         return repo.save(self.get_text_path(), ContentFile(content.encode('utf-8')))
77
78     def author_str(self):
79         if self.author:
80             return "%s %s <%s>" % (
81                 self.author.first_name,
82                 self.author.last_name, 
83                 self.author.email)
84         else:
85             return "%s <%s>" % (
86                 self.author_name,
87                 self.author_email
88                 )
89
90     @classmethod
91     def create(cls, text, parent=None, merge_parent=None,
92             author=None, author_name=None, author_email=None,
93             description=''):
94
95         if text:
96             text = text.replace(
97                 '<dc:></dc:>', '').replace(
98                 '<div class="img">', '<div>')
99
100         revision = cls.objects.create(
101             parent=parent,
102             merge_parent=merge_parent,
103             author=author,
104             author_name=author_name,
105             author_email=author_email,
106             description=description
107         )
108         revision.save_text(text)
109         return revision
110
111     def materialize(self):
112         f = repo.open(self.get_text_path())
113         text = f.read().decode('utf-8')
114         f.close()
115         if text:
116             text = text.replace(
117                 '<dc:></dc:>', '').replace(
118                 '<div class="img">', '<div>')
119         return text
120
121     def is_descendant_of(self, other):
122         # Naive approach.
123         return (
124             (
125                 self.parent is not None and (
126                     self.parent.pk == other.pk or
127                     self.parent.is_descendant_of(other)
128                 )
129             ) or (
130                 self.merge_parent is not None and (
131                     self.merge_parent.pk == other.pk or
132                     self.merge_parent.is_descendant_of(other)
133                 )
134             )
135         )
136
137     def get_common_ancestor_with(self, other):
138         # VERY naive approach.
139         if self.pk == other.pk:
140             return self
141         if self.is_descendant_of(other):
142             return other
143         if other.is_descendant_of(self):
144             return self
145
146         if self.parent is not None:
147             parent_ca = self.parent.get_common_ancestor_with(other)
148         else:
149             parent_ca = None
150
151         if self.merge_parent is not None:
152             merge_parent_ca = self.merge_parent.get_common_ancestor_with(other)
153         else:
154             return parent_ca
155
156         if parent_ca is None or parent_ca.created_at < merge_parent_ca.created_at:
157             return merge_parent_ca
158
159         return parent_ca
160
161     def get_ancestors(self):
162         revs = set()
163         if self.parent is not None:
164             revs.add(self.parent)
165             revs.update(self.parent.get_ancestors())
166         if self.merge_parent is not None:
167             revs.add(self.merge_parent)
168             revs.update(self.merge_parent.get_ancestors())
169         return revs
170
171 @python_2_unicode_compatible
172 class Ref(models.Model):
173     """A reference pointing to a specific revision."""
174
175     revision = models.ForeignKey(Revision,
176             null=True, blank=True, default=None,
177             verbose_name=_('revision'), 
178             help_text=_("The document's revision."),
179             editable=False)
180
181     def __str__(self):
182         return "ref:{0}->rev:{1}".format(self.id, self.revision_id)
183
184     def merge_text(self, base, local, remote):
185         """Override in subclass to have different kinds of merges."""
186         files = []
187         for f in local, base, remote:
188             temp = NamedTemporaryFile(delete=False)
189             temp.write(f)
190             temp.close()
191             files.append(temp.name)
192         p = Popen(['/usr/bin/diff3', '-mE', '-L', 'old', '-L', '', '-L', 'new'] + files, stdout=PIPE)
193         result, errs = p.communicate()
194
195         for f in files:
196             os.unlink(f)
197         
198         return result.decode('utf-8')
199
200     def merge_with(self, revision, 
201             author=None, author_name=None, author_email=None, 
202             description="Automatic merge."):
203         """Merges a given revision into the ref."""
204         if self.revision is None:
205             fast_forward = True
206             self.revision = revision
207         elif self.revision.pk == revision.pk or self.revision.is_descendant_of(revision):
208             # Already merged.
209             return
210         elif revision.is_descendant_of(self.revision):
211             # Fast forward.
212             fast_forward = True
213             self.revision = revision
214         else:
215             # Need to create a merge revision.
216             fast_forward = False
217             base = self.revision.get_common_ancestor_with(revision)
218
219             local_text = self.materialize().encode('utf-8')
220             base_text = base.materialize().encode('utf-8')
221             other_text = revision.materialize().encode('utf-8')
222
223             merge_text = self.merge_text(base_text, local_text, other_text)
224
225             merge_revision = Revision.create(
226                 text=merge_text,
227                 parent=self.revision,
228                 merge_parent=revision,
229                 author=author,
230                 author_name=author_name,
231                 author_email=author_email,
232                 description=description
233             )
234             self.revision = merge_revision
235         self.save()
236         post_merge.send(sender=type(self), instance=self, fast_forward=fast_forward)
237
238     def materialize(self):
239         return self.revision.materialize() if self.revision is not None else ''
240
241     def commit(self, text, parent=False,
242             author=None, author_name=None, author_email=None,
243             description=''):
244         """Creates a new revision and sets it as the ref.
245
246         This will automatically merge the commit into the main branch,
247         if parent is not document's head.
248
249         :param unicode text: new version of the document
250         :param base: parent revision (head, if not specified)
251         :type base: Revision or None
252         :param User author: the commiter
253         :param unicode author_name: commiter name (if ``author`` not specified)
254         :param unicode author_email: commiter e-mail (if ``author`` not specified)
255         :returns: new head
256         """
257         if parent is False:
258             # If parent revision not set explicitly, use your head.
259             parent = self.revision
260
261         # Warning: this will silently leave revs unreferenced.
262         rev = Revision.create(
263                 text=text,
264                 author=author,
265                 author_name=author_name,
266                 author_email=author_email,
267                 description=description,
268                 parent=parent
269             )
270         self.merge_with(rev, author=author, author_name=author_name,
271             author_email=author_email)
272
273         post_commit.send(sender=type(self), instance=self)
274
275     def history(self):
276         revs = self.revision.get_ancestors()
277         revs.add(self.revision)
278         return sorted(revs, key=lambda x: x.created_at)