1 from datetime import datetime
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
10 class Tag(models.Model):
12 a tag (e.g. document stage) which can be applied to a change
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'))
23 ordering = ['ordering']
25 def __unicode__(self):
30 if slug in cls._object_cache:
31 return cls._object_cache[slug]
33 obj = cls.objects.get(slug=slug)
34 cls._object_cache[slug] = obj
38 def listener_changed(sender, instance, **kwargs):
39 sender._object_cache = {}
42 Tag.objects.filter(ordering__gt=self.ordering)
44 models.signals.pre_save.connect(Tag.listener_changed, sender=Tag)
47 class Change(models.Model):
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.
53 Data contains a pickled diff needed to reproduce the initial document.
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)
61 parent = models.ForeignKey('self',
62 null=True, blank=True, default=None,
63 related_name="children")
65 merge_parent = models.ForeignKey('self',
66 null=True, blank=True, default=None,
67 related_name="merge_children")
69 description = models.TextField(blank=True, default='')
70 created_at = models.DateTimeField(editable=False, db_index=True,
72 publishable = models.BooleanField(default=False)
74 tags = models.ManyToManyField(Tag)
77 ordering = ('created_at',)
78 unique_together = ['tree', 'revision']
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)
85 return "%s %s <%s>" % (
86 self.author.first_name,
87 self.author.last_name,
90 return self.author_desc
93 def save(self, *args, **kwargs):
95 take the next available revision number if none yet
97 if self.revision is None:
98 self.revision = self.tree.revision() + 1
99 return super(Change, self).save(*args, **kwargs)
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))
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())
114 changes = Change.objects.exclude(parent=None).filter(
116 revision__lte=self.revision).order_by('revision')
118 for change in changes:
119 text = change.apply_to(text)
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)
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,
143 def apply_to(self, text):
144 return mdiff.patch(text, pickle.loads(self.patch.encode('ascii')))
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:
153 local = self.materialize()
154 base = other.merge_parent.materialize()
155 remote = other.apply_to(base)
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)
165 def revert(self, **kwargs):
166 """ commit this version of a doc as new head """
167 self.tree.commit(text=self.materialize(), **kwargs)
170 class Document(models.Model):
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."),
181 user = models.ForeignKey(User, null=True, blank=True)
182 stage = models.ForeignKey(Tag, null=True, blank=True)
184 def __unicode__(self):
185 return u"{0}, HEAD: {1}".format(self.id, self.head_id)
188 def get_absolute_url(self):
189 return ('dvcs.views.document_data', (), {
190 'document_id': self.id,
191 'version': self.head_id,
194 def materialize(self, change=None):
195 if self.head is None:
199 elif not isinstance(change, Change):
200 change = self.change_set.get(pk=change)
201 return change.materialize()
203 def commit(self, **kwargs):
204 if 'parent' not in kwargs:
207 parent = kwargs['parent']
208 if not isinstance(parent, Change):
209 parent = Change.objects.get(pk=kwargs['parent'])
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'])
217 raise ValueError("You can provide only text or patch - not both")
218 patch = kwargs['patch']
220 author = kwargs.get('author', None)
221 author_desc = kwargs.get('author_desc', None)
222 tags = kwargs.get('tags', [])
225 if parent != old_head:
226 change = parent.make_merge_child(patch, author=author,
227 author_desc=author_desc,
228 description=kwargs.get('description', ''),
230 # not Fast-Forward - perform a merge
231 self.head = old_head.merge_with(change, author=author,
232 author_desc=author_desc)
234 self.head = parent.make_child(patch, author=author,
235 author_desc=author_desc,
236 description=kwargs.get('description', ''),
243 return self.change_set.filter(revision__gt=-1)
246 rev = self.change_set.aggregate(
247 models.Max('revision'))['revision__max']
248 return rev if rev is not None else -1
250 def at_revision(self, rev):
252 return self.change_set.get(revision=rev)
256 def publishable(self):
257 changes = self.change_set.filter(publishable=True).order_by('-created_at')[:1]
264 def listener_initial_commit(sender, instance, created, **kwargs):
265 # run for Document and its subclasses
266 if not isinstance(instance, Document):
269 instance.head = Change.objects.create(
271 author=instance.creator,
272 patch=Change.make_patch('', ''),
276 models.signals.post_save.connect(Document.listener_initial_commit)