a72556f6c481c96f00a9b7b6dae9b8e01eb19777
[redakcja.git] / apps / dvcs / models.py
1 from datetime import datetime
2
3 from django.db import models
4 from django.contrib.auth.models import User
5 from django.utils.translation import ugettext_lazy as _
6 from mercurial import mdiff, simplemerge
7 import pickle
8
9
10 class Tag(models.Model):
11     """
12         a tag (e.g. document stage) which can be applied to a change
13     """
14
15     name = models.CharField(_('name'), max_length=64)
16     slug = models.SlugField(_('slug'), unique=True, max_length=64, 
17             null=True, blank=True)
18     ordering = models.IntegerField(_('ordering'))
19
20     _object_cache = {}
21
22     class Meta:
23         ordering = ['ordering']
24
25     def __unicode__(self):
26         return self.name
27
28     @classmethod
29     def get(cls, slug):
30         if slug in cls._object_cache:
31             return cls._object_cache[slug]
32         else:
33             obj = cls.objects.get(slug=slug)
34             cls._object_cache[slug] = obj
35             return obj
36
37     @staticmethod
38     def listener_changed(sender, instance, **kwargs):
39         sender._object_cache = {}
40
41     def next(self):
42         Tag.objects.filter(ordering__gt=self.ordering)
43
44 models.signals.pre_save.connect(Tag.listener_changed, sender=Tag)
45
46
47 class Change(models.Model):
48     """
49         Single document change related to previous change. The "parent"
50         argument points to the version against which this change has been 
51         recorded. Initial text will have a null parent.
52         
53         Data contains a pickled diff needed to reproduce the initial document.
54     """
55     author = models.ForeignKey(User, null=True, blank=True)
56     author_desc = models.CharField(max_length=128, null=True, blank=True)
57     patch = models.TextField(blank=True)
58     tree = models.ForeignKey('Document')
59     revision = models.IntegerField(db_index=True)
60
61     parent = models.ForeignKey('self',
62                         null=True, blank=True, default=None,
63                         related_name="children")
64
65     merge_parent = models.ForeignKey('self',
66                         null=True, blank=True, default=None,
67                         related_name="merge_children")
68
69     description = models.TextField(blank=True, default='')
70     created_at = models.DateTimeField(editable=False, db_index=True, 
71                         default=datetime.now)
72     publishable = models.BooleanField(default=False)
73
74     tags = models.ManyToManyField(Tag)
75
76     class Meta:
77         ordering = ('created_at',)
78         unique_together = ['tree', 'revision']
79
80     def __unicode__(self):
81         return u"Id: %r, Tree %r, Parent %r, Patch '''\n%s'''" % (self.id, self.tree_id, self.parent_id, self.patch)
82
83     def author_str(self):
84         if self.author:
85             return "%s %s <%s>" % (
86                 self.author.first_name,
87                 self.author.last_name, 
88                 self.author.email)
89         else:
90             return self.author_desc
91
92
93     def save(self, *args, **kwargs):
94         """
95             take the next available revision number if none yet
96         """
97         if self.revision is None:
98             self.revision = self.tree.revision() + 1
99         return super(Change, self).save(*args, **kwargs)
100
101     @staticmethod
102     def make_patch(src, dst):
103         if isinstance(src, unicode):
104             src = src.encode('utf-8')
105         if isinstance(dst, unicode):
106             dst = dst.encode('utf-8')
107         return pickle.dumps(mdiff.textdiff(src, dst))
108
109     def materialize(self):
110         # special care for merged nodes
111         if self.parent is None and self.merge_parent is not None:
112             return self.apply_to(self.merge_parent.materialize())
113
114         changes = Change.objects.exclude(parent=None).filter(
115                         tree=self.tree,
116                         revision__lte=self.revision).order_by('revision')
117         text = u''
118         for change in changes:
119             text = change.apply_to(text)
120         return text
121
122     def make_child(self, patch, description, author=None,
123             author_desc=None, tags=None):
124         ch = self.children.create(patch=patch,
125                         tree=self.tree, author=author,
126                         author_desc=author_desc,
127                         description=description)
128         if tags is not None:
129             ch.tags = tags
130         return ch
131
132     def make_merge_child(self, patch, description, author=None, 
133             author_desc=None, tags=None):
134         ch = self.merge_children.create(patch=patch,
135                         tree=self.tree, author=author,
136                         author_desc=author_desc,
137                         description=description,
138                         tags=tags)
139         if tags is not None:
140             ch.tags = tags
141         return ch
142
143     def apply_to(self, text):
144         return mdiff.patch(text, pickle.loads(self.patch.encode('ascii')))
145
146     def merge_with(self, other, author=None, author_desc=None,
147             description=u"Automatic merge."):
148         assert self.tree_id == other.tree_id  # same tree
149         if other.parent_id == self.pk:
150             # immediate child 
151             return other
152
153         local = self.materialize()
154         base = other.merge_parent.materialize()
155         remote = other.apply_to(base)
156
157         merge = simplemerge.Merge3Text(base, local, remote)
158         result = ''.join(merge.merge_lines())
159         patch = self.make_patch(local, result)
160         return self.children.create(
161                     patch=patch, merge_parent=other, tree=self.tree,
162                     author=author, author_desc=author_desc,
163                     description=description)
164
165     def revert(self, **kwargs):
166         """ commit this version of a doc as new head """
167         self.tree.commit(text=self.materialize(), **kwargs)
168
169
170 class Document(models.Model):
171     """
172         File in repository.        
173     """
174     creator = models.ForeignKey(User, null=True, blank=True, editable=False,
175                 related_name="created_documents")
176     head = models.ForeignKey(Change,
177                     null=True, blank=True, default=None,
178                     help_text=_("This document's current head."),
179                     editable=False)
180
181     user = models.ForeignKey(User, null=True, blank=True)
182     stage = models.ForeignKey(Tag, null=True, blank=True)
183
184     def __unicode__(self):
185         return u"{0}, HEAD: {1}".format(self.id, self.head_id)
186
187     @models.permalink
188     def get_absolute_url(self):
189         return ('dvcs.views.document_data', (), {
190                         'document_id': self.id,
191                         'version': self.head_id,
192         })
193
194     def materialize(self, change=None):
195         if self.head is None:
196             return u''
197         if change is None:
198             change = self.head
199         elif not isinstance(change, Change):
200             change = self.change_set.get(pk=change)
201         return change.materialize()
202
203     def commit(self, **kwargs):
204         if 'parent' not in kwargs:
205             parent = self.head
206         else:
207             parent = kwargs['parent']
208             if not isinstance(parent, Change):
209                 parent = Change.objects.get(pk=kwargs['parent'])
210
211         if 'patch' not in kwargs:
212             if 'text' not in kwargs:
213                 raise ValueError("You must provide either patch or target document.")
214             patch = Change.make_patch(self.materialize(change=parent), kwargs['text'])
215         else:
216             if 'text' in kwargs:
217                 raise ValueError("You can provide only text or patch - not both")
218             patch = kwargs['patch']
219
220         author = kwargs.get('author', None)
221         author_desc = kwargs.get('author_desc', None)
222         tags = kwargs.get('tags', [])
223
224         old_head = self.head
225         if parent != old_head:
226             change = parent.make_merge_child(patch, author=author, 
227                     author_desc=author_desc,
228                     description=kwargs.get('description', ''),
229                     tags=tags)
230             # not Fast-Forward - perform a merge
231             self.head = old_head.merge_with(change, author=author,
232                     author_desc=author_desc)
233         else:
234             self.head = parent.make_child(patch, author=author, 
235                     author_desc=author_desc, 
236                     description=kwargs.get('description', ''),
237                     tags=tags)
238
239         self.save()
240         return self.head
241
242     def history(self):
243         return self.change_set.filter(revision__gt=-1)
244
245     def revision(self):
246         rev = self.change_set.aggregate(
247                 models.Max('revision'))['revision__max']
248         return rev if rev is not None else -1
249
250     def at_revision(self, rev):
251         if rev is not None:
252             return self.change_set.get(revision=rev)
253         else:
254             return self.head
255
256     def publishable(self):
257         changes = self.change_set.filter(publishable=True).order_by('-created_at')[:1]
258         if changes.count():
259             return changes[0]
260         else:
261             return None
262
263     @staticmethod
264     def listener_initial_commit(sender, instance, created, **kwargs):
265         # run for Document and its subclasses
266         if not isinstance(instance, Document):
267             return
268         if created:
269             instance.head = Change.objects.create(
270                     revision=-1,
271                     author=instance.creator,
272                     patch=Change.make_patch('', ''),
273                     tree=instance)
274             instance.save()
275
276 models.signals.post_save.connect(Document.listener_initial_commit)