1 from datetime import datetime
3 from django.db import models
4 from django.db.models.base import ModelBase
5 from django.contrib.auth.models import User
6 from django.utils.translation import ugettext_lazy as _
7 from mercurial import mdiff, simplemerge
11 class Tag(models.Model):
13 a tag (e.g. document stage) which can be applied to a change
16 name = models.CharField(_('name'), max_length=64)
17 slug = models.SlugField(_('slug'), unique=True, max_length=64,
18 null=True, blank=True)
19 ordering = models.IntegerField(_('ordering'))
25 ordering = ['ordering']
27 def __unicode__(self):
32 if slug in cls._object_cache:
33 return cls._object_cache[slug]
35 obj = cls.objects.get(slug=slug)
36 cls._object_cache[slug] = obj
40 def listener_changed(sender, instance, **kwargs):
41 sender._object_cache = {}
45 Returns the next tag - stage to work on.
46 Returns None for the last stage.
49 return Tag.objects.filter(ordering__gt=self.ordering)[0]
53 models.signals.pre_save.connect(Tag.listener_changed, sender=Tag)
56 class Change(models.Model):
58 Single document change related to previous change. The "parent"
59 argument points to the version against which this change has been
60 recorded. Initial text will have a null parent.
62 Data contains a pickled diff needed to reproduce the initial document.
64 author = models.ForeignKey(User, null=True, blank=True)
65 author_name = models.CharField(max_length=128, null=True, blank=True)
66 author_email = models.CharField(max_length=128, null=True, blank=True)
67 patch = models.TextField(blank=True)
68 revision = models.IntegerField(db_index=True)
70 parent = models.ForeignKey('self',
71 null=True, blank=True, default=None,
72 related_name="children")
74 merge_parent = models.ForeignKey('self',
75 null=True, blank=True, default=None,
76 related_name="merge_children")
78 description = models.TextField(blank=True, default='')
79 created_at = models.DateTimeField(editable=False, db_index=True,
81 publishable = models.BooleanField(default=False)
85 ordering = ('created_at',)
86 unique_together = ['tree', 'revision']
88 def __unicode__(self):
89 return u"Id: %r, Tree %r, Parent %r, Patch '''\n%s'''" % (self.id, self.tree_id, self.parent_id, self.patch)
93 return "%s %s <%s>" % (
94 self.author.first_name,
95 self.author.last_name,
104 def save(self, *args, **kwargs):
106 take the next available revision number if none yet
108 if self.revision is None:
109 self.revision = self.tree.revision() + 1
110 return super(Change, self).save(*args, **kwargs)
113 def make_patch(src, dst):
114 if isinstance(src, unicode):
115 src = src.encode('utf-8')
116 if isinstance(dst, unicode):
117 dst = dst.encode('utf-8')
118 return pickle.dumps(mdiff.textdiff(src, dst))
120 def materialize(self):
121 # special care for merged nodes
122 if self.parent is None and self.merge_parent is not None:
123 return self.apply_to(self.merge_parent.materialize())
125 changes = self.tree.change_set.exclude(parent=None).filter(
126 revision__lte=self.revision).order_by('revision')
128 for change in changes:
129 text = change.apply_to(text)
130 return text.decode('utf-8')
132 def make_child(self, patch, description, author=None,
133 author_name=None, author_email=None, tags=None):
134 ch = self.children.create(patch=patch,
135 tree=self.tree, author=author,
136 author_name=author_name,
137 author_email=author_email,
138 description=description)
143 def make_merge_child(self, patch, description, author=None,
144 author_name=None, author_email=None, tags=None):
145 ch = self.merge_children.create(patch=patch,
146 tree=self.tree, author=author,
147 author_name=author_name,
148 author_email=author_email,
149 description=description,
155 def apply_to(self, text):
156 return mdiff.patch(text, pickle.loads(self.patch.encode('ascii')))
158 def merge_with(self, other, author=None,
159 author_name=None, author_email=None,
160 description=u"Automatic merge."):
161 assert self.tree_id == other.tree_id # same tree
162 if other.parent_id == self.pk:
166 local = self.materialize()
167 base = other.merge_parent.materialize()
168 remote = other.apply_to(base)
170 merge = simplemerge.Merge3Text(base, local, remote)
171 result = ''.join(merge.merge_lines())
172 patch = self.make_patch(local, result)
173 return self.children.create(
174 patch=patch, merge_parent=other, tree=self.tree,
176 author_name=author_name,
177 author_email=author_email,
178 description=description)
180 def revert(self, **kwargs):
181 """ commit this version of a doc as new head """
182 self.tree.commit(text=self.materialize(), **kwargs)
185 def create_tag_model(model):
186 name = model.__name__ + 'Tag'
188 '__module__': model.__module__,
190 return type(name, (Tag,), attrs)
193 def create_change_model(model):
194 name = model.__name__ + 'Change'
197 '__module__': model.__module__,
198 'tree': models.ForeignKey(model, related_name='change_set'),
199 'tags': models.ManyToManyField(model.tag_model, related_name='change_set'),
201 return type(name, (Change,), attrs)
205 class DocumentMeta(ModelBase):
206 "Metaclass for Document models."
207 def __new__(cls, name, bases, attrs):
208 model = super(DocumentMeta, cls).__new__(cls, name, bases, attrs)
209 if not model._meta.abstract:
210 # create a real Tag object and `stage' fk
211 model.tag_model = create_tag_model(model)
212 models.ForeignKey(model.tag_model,
213 null=True, blank=True).contribute_to_class(model, 'stage')
215 # create real Change model and `head' fk
216 model.change_model = create_change_model(model)
217 models.ForeignKey(model.change_model,
218 null=True, blank=True, default=None,
219 help_text=_("This document's current head."),
220 editable=False).contribute_to_class(model, 'head')
226 class Document(models.Model):
230 __metaclass__ = DocumentMeta
232 creator = models.ForeignKey(User, null=True, blank=True, editable=False,
233 related_name="created_documents")
235 user = models.ForeignKey(User, null=True, blank=True)
240 def __unicode__(self):
241 return u"{0}, HEAD: {1}".format(self.id, self.head_id)
244 def get_absolute_url(self):
245 return ('dvcs.views.document_data', (), {
246 'document_id': self.id,
247 'version': self.head_id,
250 def materialize(self, change=None):
251 if self.head is None:
255 elif not isinstance(change, Change):
256 change = self.change_set.get(pk=change)
257 return change.materialize()
259 def commit(self, **kwargs):
260 if 'parent' not in kwargs:
263 parent = kwargs['parent']
264 if not isinstance(parent, Change):
265 parent = self.change_set.objects.get(pk=kwargs['parent'])
267 if 'patch' not in kwargs:
268 if 'text' not in kwargs:
269 raise ValueError("You must provide either patch or target document.")
270 patch = Change.make_patch(self.materialize(change=parent), kwargs['text'])
273 raise ValueError("You can provide only text or patch - not both")
274 patch = kwargs['patch']
276 author = kwargs.get('author', None)
277 author_name = kwargs.get('author_name', None)
278 author_email = kwargs.get('author_email', None)
279 tags = kwargs.get('tags', [])
281 # set stage to next tag after the commited one
282 self.stage = max(tags, key=lambda t: t.ordering).next()
285 if parent != old_head:
286 change = parent.make_merge_child(patch, author=author,
287 author_name=author_name,
288 author_email=author_email,
289 description=kwargs.get('description', ''),
291 # not Fast-Forward - perform a merge
292 self.head = old_head.merge_with(change, author=author,
293 author_name=author_name,
294 author_email=author_email)
296 self.head = parent.make_child(patch, author=author,
297 author_name=author_name,
298 author_email=author_email,
299 description=kwargs.get('description', ''),
306 return self.change_set.filter(revision__gt=-1)
309 rev = self.change_set.aggregate(
310 models.Max('revision'))['revision__max']
311 return rev if rev is not None else -1
313 def at_revision(self, rev):
315 return self.change_set.get(revision=rev)
319 def publishable(self):
320 changes = self.change_set.filter(publishable=True).order_by('-created_at')[:1]
327 def listener_initial_commit(sender, instance, created, **kwargs):
328 # run for Document and its subclasses
329 if not isinstance(instance, Document):
332 instance.head = instance.change_model.objects.create(
334 author=instance.creator,
335 patch=Change.make_patch('', ''),
339 models.signals.post_save.connect(Document.listener_initial_commit)