added XmlUpdater for massive XML updates
authorRadek Czajka <radoslaw.czajka@nowoczesnapolska.org.pl>
Tue, 14 Aug 2012 11:58:49 +0000 (13:58 +0200)
committerRadek Czajka <radoslaw.czajka@nowoczesnapolska.org.pl>
Tue, 14 Aug 2012 11:59:39 +0000 (13:59 +0200)
also: fixdc command instead of fix_rdf_about, now fixes wluri-s.

15 files changed:
apps/catalogue/feeds.py [changed mode: 0755->0644]
apps/catalogue/helpers.py
apps/catalogue/management/__init__.py [changed mode: 0755->0644]
apps/catalogue/management/commands/__init__.py [changed mode: 0755->0644]
apps/catalogue/management/commands/assign_from_redmine.py [changed mode: 0755->0644]
apps/catalogue/management/commands/fix_rdf_about.py [deleted file]
apps/catalogue/management/commands/fixdc.py [new file with mode: 0644]
apps/catalogue/management/commands/import_wl.py [changed mode: 0755->0644]
apps/catalogue/management/commands/merge_books.py [changed mode: 0755->0644]
apps/catalogue/test_utils.py [new file with mode: 0644]
apps/catalogue/tests/__init__.py [changed mode: 0755->0644]
apps/catalogue/tests/book.py [new file with mode: 0644]
apps/catalogue/tests/gallery.py [new file with mode: 0644]
apps/catalogue/tests/publish.py [new file with mode: 0644]
apps/catalogue/tests/xml_updater.py [new file with mode: 0644]

old mode 100755 (executable)
new mode 100644 (file)
index bfc842f..0009c6d 100644 (file)
@@ -139,8 +139,6 @@ class GalleryMerger(object):
                 renamed_files_other[f] = self.set_prefix(f, 1, True)
 
         # finally, move / rename files.
                 renamed_files_other[f] = self.set_prefix(f, 1, True)
 
         # finally, move / rename files.
-        from nose.tools import set_trace
-        #        set_trace()
         for frm, to in renamed_files.items():
             move(join(self.path(self.dest), frm),
                         join(self.path(self.dest), to))
         for frm, to in renamed_files.items():
             move(join(self.path(self.dest), frm),
                         join(self.path(self.dest), to))
old mode 100755 (executable)
new mode 100644 (file)
index e69de29..f7731d7
@@ -0,0 +1,122 @@
+# -*- coding: utf-8 -*-
+#
+# This file is part of FNP-Redakcja, licensed under GNU Affero GPLv3 or later.
+# Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information.
+#
+from collections import defaultdict
+from django.db import transaction
+from lxml import etree
+
+
+class XmlUpdater(object):
+    """A base class for massive XML updates.
+
+    In a subclass, override `fix_tree` and/or use `fixes_field` decorator.
+    Attributes:
+    * commit_desc: commits description
+    * retain_publishable: set publishable if head is (default: True)
+    * only_first_chunk: process only first chunks of books (default: False)
+    """
+    commit_desc = "auto-update"
+    retain_publishable = True
+    only_first_chunk = False
+
+    _element_fixers = defaultdict(list)
+
+    def __init__(self):
+        self.counters = defaultdict(lambda: 0)
+
+    @classmethod
+    def fixes_elements(cls, xpath):
+        """Decorator, registering a function as a fixer for given field type.
+
+        Any decorated function will be called like
+            f(element, change=..., verbose=...)
+        providing changeset as context.
+
+        :param xpath: element lookup, e.g. ".//{namespace-uri}tag-name"
+        :returns: True if anything changed
+        """
+        def wrapper(fixer):
+            cls._element_fixers[xpath].append(fixer)
+            return fixer
+        return wrapper
+
+    def fix_tree(self, tree, verbose):
+        """Override to provide general tree-fixing mechanism.
+
+        :param tree: the parsed XML tree
+        :param verbose: verbosity level
+        :returns: True if anythig changed
+        """
+        return False
+
+    def fix_chunk(self, chunk, user, verbose=0, dry_run=False):
+        """Runs the update for a single chunk."""
+        if verbose >= 2:
+            print chunk.get_absolute_url()
+        old_head = chunk.head
+        src = old_head.materialize()
+        try:
+            tree = etree.fromstring(src)
+        except:
+            if verbose:
+                print "%s: invalid XML" % chunk.get_absolute_url()
+            self.counters['Bad XML'] += 1
+            return
+
+        dirty = False
+        # Call the general fixing function.
+        if self.fix_tree(tree, verbose=verbose):
+            dirty = True
+        # Call the registered fixers.
+        for xpath, fixers in self._element_fixers.items():
+            for elem in tree.findall(xpath):
+                for fixer in fixers:
+                    if fixer(elem, change=old_head, verbose=verbose):
+                        dirty = True
+
+        if not dirty:
+            self.counters['Clean'] += 1
+            return
+
+        if not dry_run:
+            new_head = chunk.commit(
+                etree.tostring(tree, encoding=unicode),
+                author=user,
+                description=self.commit_desc
+            )
+            if self.retain_publishable:
+                if old_head.publishable:
+                    new_head.set_publishable(True)
+        if verbose >= 2:
+            print "done"
+        self.counters['Updated chunks'] += 1
+
+    def run(self, user, verbose=0, dry_run=False, books=None):
+        """Runs the actual update."""
+        if books is None:
+            from catalogue.models import Book
+            books = Book.objects.all()
+
+        # Start transaction management.
+        transaction.commit_unless_managed()
+        transaction.enter_transaction_management()
+        transaction.managed(True)
+
+        for book in books:
+            self.counters['All books'] += 1
+            chunks = book.chunk_set.all()
+            if self.only_first_chunk:
+                chunks = chunks[:1]
+            for chunk in chunks:
+                self.counters['All chunks'] += 1
+                self.fix_chunk(chunk, user, verbose, dry_run)
+
+        transaction.commit()
+        transaction.leave_transaction_management()
+
+    def print_results(self):
+        """Prints the counters."""
+        for item in sorted(self.counters.items()):
+            print "%s: %d" % item
old mode 100755 (executable)
new mode 100644 (file)
index e69de29..e6f146f
@@ -0,0 +1,43 @@
+# -*- coding: utf-8 -*-
+#
+# This file is part of FNP-Redakcja, licensed under GNU Affero GPLv3 or later.
+# Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information.
+#
+import sys
+from optparse import make_option
+from django.contrib.auth.models import User
+from django.core.management.base import BaseCommand
+from catalogue.models import Book
+
+
+class XmlUpdaterCommand(BaseCommand):
+    """Base class for creating massive XML-updating commands.
+
+    In a subclass, provide an XmlUpdater class in the `updater' attribute.
+    """
+    option_list = BaseCommand.option_list + (
+        make_option('-q', '--quiet', action='store_false', dest='verbose',
+            default=True, help='Less output'),
+        make_option('-d', '--dry-run', action='store_true', dest='dry_run',
+            default=False, help="Don't actually touch anything"),
+        make_option('-u', '--username', dest='username', metavar='USER',
+            help='Assign commits to this user (required, preferably yourself).'),
+    )
+    args = "[slug]..."
+
+    def handle(self, *args, **options):
+        verbose = options.get('verbose')
+        dry_run = options.get('dry_run')
+        username = options.get('username')
+
+        if username:
+            user = User.objects.get(username=username)
+        else:
+            print 'Please provide a username.'
+            sys.exit(1)
+
+        books = Book.objects.filter(slug__in=args) if args else None
+
+        updater = self.updater()
+        updater.run(user, verbose=verbose, dry_run=dry_run, books=books)
+        updater.print_results()
diff --git a/apps/catalogue/management/commands/fix_rdf_about.py b/apps/catalogue/management/commands/fix_rdf_about.py
deleted file mode 100755 (executable)
index c252c20..0000000
+++ /dev/null
@@ -1,89 +0,0 @@
-# -*- coding: utf-8 -*-
-
-from optparse import make_option
-
-from django.contrib.auth.models import User
-from django.core.management.base import BaseCommand
-from django.db import transaction
-
-from catalogue.models import Book
-
-
-class Command(BaseCommand):
-    option_list = BaseCommand.option_list + (
-        make_option('-q', '--quiet', action='store_false', dest='verbose',
-            default=True, help='Less output'),
-        make_option('-d', '--dry-run', action='store_true', dest='dry_run',
-            default=False, help="Don't actually touch anything"),
-    )
-    help = 'Updates the rdf:about metadata field.'
-
-    def handle(self, *args, **options):
-        from lxml import etree
-
-        verbose = options.get('verbose')
-        dry_run = options.get('dry_run')
-
-        # Start transaction management.
-        transaction.commit_unless_managed()
-        transaction.enter_transaction_management()
-        transaction.managed(True)
-
-        all_books = 0
-        nonxml = 0
-        nordf = 0
-        already = 0
-        done = 0
-
-        for b in Book.objects.all():
-            all_books += 1
-            if verbose:
-                print "%s: " % b.title,
-            chunk = b[0]
-            old_head = chunk.head
-            src = old_head.materialize()
-
-            try:
-                t = etree.fromstring(src)
-            except:
-                nonxml += 1
-                if verbose:
-                    print "invalid XML"
-                continue
-            desc = t.find(".//{http://www.w3.org/1999/02/22-rdf-syntax-ns#}Description")
-            if desc is None:
-                nordf += 1
-                if verbose:
-                    print "no RDF found"
-                continue
-
-            correct_about = b.correct_about()
-            attr_name = "{http://www.w3.org/1999/02/22-rdf-syntax-ns#}about"
-            if desc.get(attr_name) == correct_about:
-                already += 1
-                if verbose:
-                    print "already correct"
-                continue
-            desc.set(attr_name, correct_about)
-            if not dry_run:
-                new_head = chunk.commit(etree.tostring(t, encoding=unicode),
-                    author_name='platforma redakcyjna',
-                    description='auto-update rdf:about'
-                    )
-                # retain the publishable status
-                if old_head.publishable:
-                    new_head.set_publishable(True)
-            if verbose:
-                print "done"
-            done += 1
-
-        # Print results
-        print "All books: ", all_books
-        print "Invalid XML: ", nonxml
-        print "No RDF found: ", nordf
-        print "Already correct: ", already
-        print "Books updated: ", done
-
-        transaction.commit()
-        transaction.leave_transaction_management()
-
diff --git a/apps/catalogue/management/commands/fixdc.py b/apps/catalogue/management/commands/fixdc.py
new file mode 100644 (file)
index 0000000..80b341c
--- /dev/null
@@ -0,0 +1,48 @@
+# -*- coding: utf-8 -*-
+#
+# This file is part of FNP-Redakcja, licensed under GNU Affero GPLv3 or later.
+# Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information.
+#
+from librarian import RDFNS, WLURI, ValidationError
+from librarian.dcparser import BookInfo
+from catalogue.management import XmlUpdater
+from catalogue.management.commands import XmlUpdaterCommand
+
+
+class FixDC(XmlUpdater):
+    commit_desc = "auto-fixing DC"
+    retain_publishable = True
+    only_first_chunk = True
+
+    def fix_wluri(elem, change, verbose):
+        try:
+            WLURI.strict(elem.text)
+        except ValidationError:
+            correct_field = unicode(WLURI.from_slug(WLURI(elem.text).slug))
+            if verbose:
+                print "Changing %s from %s to %s" % (
+                        elem.tag, elem.text, correct_field
+                    )
+            elem.text = correct_field
+            return True
+    for field in BookInfo.FIELDS:
+        if field.validator == WLURI:
+            XmlUpdater.fixes_elements('.//' + field.uri)(fix_wluri)
+
+    @XmlUpdater.fixes_elements(".//" + RDFNS("Description"))
+    def fix_rdfabout(elem, change, verbose):
+        correct_about = change.tree.book.correct_about()
+        attr_name = RDFNS("about")
+        current_about = elem.get(attr_name)
+        if current_about != correct_about:
+            if verbose:
+                print "Changing rdf:about from %s to %s" % (
+                        current_about, correct_about
+                    )
+            elem.set(attr_name, correct_about)
+            return True
+
+
+class Command(XmlUpdaterCommand):
+    updater = FixDC
+    help = 'Fixes obvious errors in DC: rdf:about and WLURI format.'
old mode 100755 (executable)
new mode 100644 (file)
diff --git a/apps/catalogue/test_utils.py b/apps/catalogue/test_utils.py
new file mode 100644 (file)
index 0000000..2b08545
--- /dev/null
@@ -0,0 +1,14 @@
+# -*- coding: utf-8 -*-
+#
+# This file is part of FNP-Redakcja, licensed under GNU Affero GPLv3 or later.
+# Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information.
+#
+"""Testing utilities."""
+
+from os.path import abspath, dirname, join
+
+
+def get_fixture(path):
+    f_path = join(dirname(abspath(__file__)), 'tests/files', path)
+    with open(f_path) as f:
+        return unicode(f.read(), 'utf-8')
old mode 100755 (executable)
new mode 100644 (file)
index ae7bef7..533a6c5
@@ -1,224 +1,9 @@
-from os.path import abspath, dirname, join, basename, exists
-from os import makedirs, listdir
-from nose.tools import *
-from mock import patch
-from django.test import TestCase
-from django.contrib.auth.models import User
-from catalogue.models import Book, BookPublishRecord
-from tempfile import mkdtemp
-from django.conf import settings
-
-def get_fixture(path):
-    f_path = join(dirname(abspath(__file__)), 'files', path)
-    with open(f_path) as f:
-        return unicode(f.read(), 'utf-8')
-
-
-class PublishTests(TestCase):
-
-    def setUp(self):
-        self.user = User.objects.create(username='tester')
-        self.text1 = get_fixture('chunk1.xml')
-        self.book = Book.create(self.user, self.text1, slug='test-book')
-
-    @patch('apiclient.api_call')
-    def test_unpublishable(self, api_call):
-        with self.assertRaises(AssertionError):
-            self.book.publish(self.user)
-
-    @patch('apiclient.api_call')
-    def test_publish(self, api_call):
-        self.book[0].head.set_publishable(True)
-        self.book.publish(self.user)
-        api_call.assert_called_with(self.user, 'books/', {"book_xml": self.text1})
-
-    @patch('apiclient.api_call')
-    def test_publish_multiple(self, api_call):
-        self.book[0].head.set_publishable(True)
-        self.book[0].split(slug='part-2')
-        self.book[1].commit(get_fixture('chunk2.xml'))
-        self.book[1].head.set_publishable(True)
-        self.book.publish(self.user)
-        api_call.assert_called_with(self.user, 'books/', {"book_xml": get_fixture('expected.xml')})
-
-
-class ManipulationTests(TestCase):
-
-    def setUp(self):
-        self.user = User.objects.create(username='tester')
-        self.book1 = Book.create(self.user, 'book 1', slug='book1')
-        self.book2 = Book.create(self.user, 'book 2', slug='book2')
-
-    def test_append(self):
-        self.book1.append(self.book2)
-        self.assertEqual(Book.objects.all().count(), 1)
-        self.assertEqual(len(self.book1), 2)
-
-    def test_append_to_self(self):
-        with self.assertRaises(AssertionError):
-            self.book1.append(Book.objects.get(pk=self.book1.pk))
-        self.assertEqual(Book.objects.all().count(), 2)
-        self.assertEqual(len(self.book1), 1)
-
-    def test_prepend_history(self):
-        self.book1.prepend_history(self.book2)
-        self.assertEqual(Book.objects.all().count(), 1)
-        self.assertEqual(len(self.book1), 1)
-        self.assertEqual(self.book1.materialize(), 'book 1')
-
-    def test_prepend_history_to_self(self):
-        with self.assertRaises(AssertionError):
-            self.book1.prepend_history(self.book1)
-        self.assertEqual(Book.objects.all().count(), 2)
-        self.assertEqual(self.book1.materialize(), 'book 1')
-        self.assertEqual(self.book2.materialize(), 'book 2')
-
-    def test_split_book(self):
-        self.book1.chunk_set.create(number=2, title='Second chunk',
-                slug='book3')
-        self.book1[1].commit('I survived!')
-        self.assertEqual(len(self.book1), 2)
-        self.book1.split()
-        self.assertEqual(set([b.slug for b in Book.objects.all()]),
-                set(['book2', '1', 'book3']))
-        self.assertEqual(
-                Book.objects.get(slug='book3').materialize(),
-                'I survived!')
-
-
-class GalleryAppendTests(TestCase):
-    def setUp(self):
-        self.user = User.objects.create(username='tester')
-        self.book1 = Book.create(self.user, 'book 1', slug='book1')
-        self.book1.chunk_set.create(number=2, title='Second chunk',
-                slug='book 1 / 2')
-        c=self.book1[0]
-        c.gallery_start=1
-        c=self.book1[1]
-        c.gallery_start=3
-        
-        self.scandir = join(settings.MEDIA_ROOT, settings.IMAGE_DIR)
-        if not exists(self.scandir):
-            makedirs(self.scandir)
-
-    def make_gallery(self, book, files):
-        d = mkdtemp('gallery', dir=self.scandir)
-        for named, cont in files.items():
-            f = open(join(d, named), 'w')
-            f.write(cont)
-            f.close()
-        book.gallery = basename(d)
-
-
-    def test_both_indexed(self):
-        self.book2 = Book.create(self.user, 'book 2', slug='book2')
-        self.book2.chunk_set.create(number=2, title='Second chunk of second book',
-                slug='book 2 / 2')
-
-        c = self.book2[0]
-        c.gallery_start = 1
-        c.save()
-        c = self.book2[1]
-        c.gallery_start = 3
-        c.save()
-        
-        print "gallery starts:",self.book2[0].gallery_start, self.book2[1].gallery_start
-
-        self.make_gallery(self.book1, {
-            '1-0001_1l' : 'aa',
-            '1-0001_2r' : 'bb',
-            '1-0002_1l' : 'cc',
-            '1-0002_2r' : 'dd',
-            })
-
-        self.make_gallery(self.book2, {
-            '1-0001_1l' : 'dd', # the same, should not be moved
-            '1-0001_2r' : 'ff',
-            '2-0002_1l' : 'gg',
-            '2-0002_2r' : 'hh',
-            })
-
-        self.book1.append(self.book2)
-
-        files = listdir(join(self.scandir, self.book1.gallery))
-        files.sort()
-        print files
-        self.assertEqual(files, [
-            '1-0001_1l',
-            '1-0001_2r',
-            '1-0002_1l',
-            '1-0002_2r',
-            #            '2-0001_1l',
-            '2-0001_2r',
-            '3-0002_1l',
-            '3-0002_2r',
-            ])        
-
-        self.assertEqual((4, 6), (self.book1[2].gallery_start, self.book1[3].gallery_start))
-        
-        
-    def test_none_indexed(self):
-        self.book2 = Book.create(self.user, 'book 2', slug='book2')
-        self.make_gallery(self.book1, {
-            '0001_1l' : 'aa',
-            '0001_2r' : 'bb',
-            '0002_1l' : 'cc',
-            '0002_2r' : 'dd',
-            })
-
-        self.make_gallery(self.book2, {
-            '0001_1l' : 'ee',
-            '0001_2r' : 'ff',
-            '0002_1l' : 'gg',
-            '0002_2r' : 'hh',
-            })
-
-        self.book1.append(self.book2)
-
-        files = listdir(join(self.scandir, self.book1.gallery))
-        files.sort()
-        print files
-        self.assertEqual(files, [
-            '0-0001_1l',
-            '0-0001_2r',
-            '0-0002_1l',
-            '0-0002_2r',
-            '1-0001_1l',
-            '1-0001_2r',
-            '1-0002_1l',
-            '1-0002_2r',
-            ])        
-
-
-    def test_none_indexed(self):
-        import nose.tools
-        self.book2 = Book.create(self.user, 'book 2', slug='book2')
-        self.make_gallery(self.book1, {
-            '1-0001_1l' : 'aa',
-            '1-0001_2r' : 'bb',
-            '1002_1l' : 'cc',
-            '1002_2r' : 'dd',
-            })
-
-        self.make_gallery(self.book2, {
-            '0001_1l' : 'ee',
-            '0001_2r' : 'ff',
-            '0002_1l' : 'gg',
-            '0002_2r' : 'hh',
-            })
-
-        self.book1.append(self.book2)
-
-        files = listdir(join(self.scandir, self.book1.gallery))
-        files.sort()
-        print files
-        self.assertEqual(files, [
-            '0-1-0001_1l',
-            '0-1-0001_2r',
-            '0-1002_1l',
-            '0-1002_2r',
-            '1-0001_1l',
-            '1-0001_2r',
-            '1-0002_1l',
-            '1-0002_2r',
-            ])        
+# -*- coding: utf-8 -*-
+#
+# This file is part of FNP-Redakcja, licensed under GNU Affero GPLv3 or later.
+# Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information.
+#
+from catalogue.tests.book import *
+from catalogue.tests.gallery import *
+from catalogue.tests.publish import *
+from catalogue.tests.xml_updater import *
diff --git a/apps/catalogue/tests/book.py b/apps/catalogue/tests/book.py
new file mode 100644 (file)
index 0000000..df6f3b4
--- /dev/null
@@ -0,0 +1,54 @@
+# -*- coding: utf-8 -*-
+#
+# This file is part of FNP-Redakcja, licensed under GNU Affero GPLv3 or later.
+# Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information.
+#
+"""Tests for manipulating books in the catalogue."""
+
+from django.test import TestCase
+from django.contrib.auth.models import User
+from catalogue.models import Book
+
+
+class ManipulationTests(TestCase):
+
+    def setUp(self):
+        self.user = User.objects.create(username='tester')
+        self.book1 = Book.create(self.user, 'book 1', slug='book1')
+        self.book2 = Book.create(self.user, 'book 2', slug='book2')
+
+    def test_append(self):
+        self.book1.append(self.book2)
+        self.assertEqual(Book.objects.all().count(), 1)
+        self.assertEqual(len(self.book1), 2)
+
+    def test_append_to_self(self):
+        with self.assertRaises(AssertionError):
+            self.book1.append(Book.objects.get(pk=self.book1.pk))
+        self.assertEqual(Book.objects.all().count(), 2)
+        self.assertEqual(len(self.book1), 1)
+
+    def test_prepend_history(self):
+        self.book1.prepend_history(self.book2)
+        self.assertEqual(Book.objects.all().count(), 1)
+        self.assertEqual(len(self.book1), 1)
+        self.assertEqual(self.book1.materialize(), 'book 1')
+
+    def test_prepend_history_to_self(self):
+        with self.assertRaises(AssertionError):
+            self.book1.prepend_history(self.book1)
+        self.assertEqual(Book.objects.all().count(), 2)
+        self.assertEqual(self.book1.materialize(), 'book 1')
+        self.assertEqual(self.book2.materialize(), 'book 2')
+
+    def test_split_book(self):
+        self.book1.chunk_set.create(number=2, title='Second chunk',
+                slug='book3')
+        self.book1[1].commit('I survived!')
+        self.assertEqual(len(self.book1), 2)
+        self.book1.split()
+        self.assertEqual(set([b.slug for b in Book.objects.all()]),
+                set(['book2', '1', 'book3']))
+        self.assertEqual(
+                Book.objects.get(slug='book3').materialize(),
+                'I survived!')
diff --git a/apps/catalogue/tests/gallery.py b/apps/catalogue/tests/gallery.py
new file mode 100644 (file)
index 0000000..e1f636d
--- /dev/null
@@ -0,0 +1,153 @@
+# -*- coding: utf-8 -*-
+#
+# This file is part of FNP-Redakcja, licensed under GNU Affero GPLv3 or later.
+# Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information.
+#
+"""Tests for galleries of scans."""
+
+from os.path import join, basename, exists
+from os import makedirs, listdir
+from django.test import TestCase
+from django.contrib.auth.models import User
+from catalogue.models import Book
+from tempfile import mkdtemp
+from django.conf import settings
+
+
+class GalleryAppendTests(TestCase):
+    def setUp(self):
+        self.user = User.objects.create(username='tester')
+        self.book1 = Book.create(self.user, 'book 1', slug='book1')
+        self.book1.chunk_set.create(number=2, title='Second chunk',
+                slug='book 1 / 2')
+        c=self.book1[0]
+        c.gallery_start=1
+        c=self.book1[1]
+        c.gallery_start=3
+        
+        self.scandir = join(settings.MEDIA_ROOT, settings.IMAGE_DIR)
+        if not exists(self.scandir):
+            makedirs(self.scandir)
+
+    def make_gallery(self, book, files):
+        d = mkdtemp('gallery', dir=self.scandir)
+        for named, cont in files.items():
+            f = open(join(d, named), 'w')
+            f.write(cont)
+            f.close()
+        book.gallery = basename(d)
+
+
+    def test_both_indexed(self):
+        self.book2 = Book.create(self.user, 'book 2', slug='book2')
+        self.book2.chunk_set.create(number=2, title='Second chunk of second book',
+                slug='book 2 / 2')
+
+        c = self.book2[0]
+        c.gallery_start = 1
+        c.save()
+        c = self.book2[1]
+        c.gallery_start = 3
+        c.save()
+        
+        print "gallery starts:",self.book2[0].gallery_start, self.book2[1].gallery_start
+
+        self.make_gallery(self.book1, {
+            '1-0001_1l' : 'aa',
+            '1-0001_2r' : 'bb',
+            '1-0002_1l' : 'cc',
+            '1-0002_2r' : 'dd',
+            })
+
+        self.make_gallery(self.book2, {
+            '1-0001_1l' : 'dd', # the same, should not be moved
+            '1-0001_2r' : 'ff',
+            '2-0002_1l' : 'gg',
+            '2-0002_2r' : 'hh',
+            })
+
+        self.book1.append(self.book2)
+
+        files = listdir(join(self.scandir, self.book1.gallery))
+        files.sort()
+        print files
+        self.assertEqual(files, [
+            '1-0001_1l',
+            '1-0001_2r',
+            '1-0002_1l',
+            '1-0002_2r',
+            #            '2-0001_1l',
+            '2-0001_2r',
+            '3-0002_1l',
+            '3-0002_2r',
+            ])        
+
+        self.assertEqual((4, 6), (self.book1[2].gallery_start, self.book1[3].gallery_start))
+        
+        
+    def test_none_indexed(self):
+        self.book2 = Book.create(self.user, 'book 2', slug='book2')
+        self.make_gallery(self.book1, {
+            '0001_1l' : 'aa',
+            '0001_2r' : 'bb',
+            '0002_1l' : 'cc',
+            '0002_2r' : 'dd',
+            })
+
+        self.make_gallery(self.book2, {
+            '0001_1l' : 'ee',
+            '0001_2r' : 'ff',
+            '0002_1l' : 'gg',
+            '0002_2r' : 'hh',
+            })
+
+        self.book1.append(self.book2)
+
+        files = listdir(join(self.scandir, self.book1.gallery))
+        files.sort()
+        print files
+        self.assertEqual(files, [
+            '0-0001_1l',
+            '0-0001_2r',
+            '0-0002_1l',
+            '0-0002_2r',
+            '1-0001_1l',
+            '1-0001_2r',
+            '1-0002_1l',
+            '1-0002_2r',
+            ])        
+
+
+    def test_none_indexed(self):
+        import nose.tools
+        self.book2 = Book.create(self.user, 'book 2', slug='book2')
+        self.make_gallery(self.book1, {
+            '1-0001_1l' : 'aa',
+            '1-0001_2r' : 'bb',
+            '1002_1l' : 'cc',
+            '1002_2r' : 'dd',
+            })
+
+        self.make_gallery(self.book2, {
+            '0001_1l' : 'ee',
+            '0001_2r' : 'ff',
+            '0002_1l' : 'gg',
+            '0002_2r' : 'hh',
+            })
+
+        self.book1.append(self.book2)
+
+        files = listdir(join(self.scandir, self.book1.gallery))
+        files.sort()
+        print files
+        self.assertEqual(files, [
+            '0-1-0001_1l',
+            '0-1-0001_2r',
+            '0-1002_1l',
+            '0-1002_2r',
+            '1-0001_1l',
+            '1-0001_2r',
+            '1-0002_1l',
+            '1-0002_2r',
+            ])        
+
diff --git a/apps/catalogue/tests/publish.py b/apps/catalogue/tests/publish.py
new file mode 100644 (file)
index 0000000..9f0b8ca
--- /dev/null
@@ -0,0 +1,40 @@
+# -*- coding: utf-8 -*-
+#
+# This file is part of FNP-Redakcja, licensed under GNU Affero GPLv3 or later.
+# Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information.
+#
+"""Tests for the publishing process."""
+
+from catalogue.test_utils import get_fixture
+
+from mock import patch
+from django.test import TestCase
+from django.contrib.auth.models import User
+from catalogue.models import Book
+
+
+class PublishTests(TestCase):
+    def setUp(self):
+        self.user = User.objects.create(username='tester')
+        self.text1 = get_fixture('chunk1.xml')
+        self.book = Book.create(self.user, self.text1, slug='test-book')
+
+    @patch('apiclient.api_call')
+    def test_unpublishable(self, api_call):
+        with self.assertRaises(AssertionError):
+            self.book.publish(self.user)
+
+    @patch('apiclient.api_call')
+    def test_publish(self, api_call):
+        self.book[0].head.set_publishable(True)
+        self.book.publish(self.user)
+        api_call.assert_called_with(self.user, 'books/', {"book_xml": self.text1})
+
+    @patch('apiclient.api_call')
+    def test_publish_multiple(self, api_call):
+        self.book[0].head.set_publishable(True)
+        self.book[0].split(slug='part-2')
+        self.book[1].commit(get_fixture('chunk2.xml'))
+        self.book[1].head.set_publishable(True)
+        self.book.publish(self.user)
+        api_call.assert_called_with(self.user, 'books/', {"book_xml": get_fixture('expected.xml')})
diff --git a/apps/catalogue/tests/xml_updater.py b/apps/catalogue/tests/xml_updater.py
new file mode 100644 (file)
index 0000000..9fb5a4a
--- /dev/null
@@ -0,0 +1,35 @@
+# -*- coding: utf-8 -*-
+#
+# This file is part of FNP-Redakcja, licensed under GNU Affero GPLv3 or later.
+# Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information.
+#
+"""XmlUpdater tests."""
+
+from catalogue.test_utils import get_fixture
+from django.test import TestCase
+from django.contrib.auth.models import User
+from catalogue.models import Book
+from catalogue.management import XmlUpdater
+from librarian import DCNS
+
+
+class XmlUpdaterTests(TestCase):
+    class SimpleUpdater(XmlUpdater):
+        @XmlUpdater.fixes_elements('.//' + DCNS('title'))
+        def fix_title(element, **kwargs):
+            element.text = element.text + " fixed"
+            return True
+
+    def setUp(self):
+        self.user = User.objects.create(username='tester')
+        text = get_fixture('chunk1.xml')
+        Book.create(self.user, text, slug='test-book')
+        self.title = "Do M***"
+
+    def test_xml_updater(self):
+        self.SimpleUpdater().run(self.user)
+        self.assertEqual(
+            Book.objects.get(slug='test-book').wldocument(
+                publishable=False).book_info.title,
+            self.title + " fixed"
+            )