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