X-Git-Url: https://git.mdrn.pl/wolnelektury.git/blobdiff_plain/27891467b143d11c13ae44e5051d740be6e6303c..e086b48a37bcbcc7d19890b5954ec93213440b32:/apps/catalogue/models/book.py diff --git a/apps/catalogue/models/book.py b/apps/catalogue/models/book.py index a8b342d74..e499afc7b 100644 --- a/apps/catalogue/models/book.py +++ b/apps/catalogue/models/book.py @@ -2,54 +2,84 @@ # This file is part of Wolnelektury, licensed under GNU Affero GPLv3 or later. # Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information. # +from collections import OrderedDict +from random import randint import re from django.conf import settings -from django.core.cache import get_cache -from django.db import models +from django.core.cache import caches +from django.db import connection, models, transaction from django.db.models import permalink import django.dispatch -from django.utils.datastructures import SortedDict +from django.contrib.contenttypes.fields import GenericRelation +from django.core.urlresolvers import reverse from django.utils.translation import ugettext_lazy as _ import jsonfield +from fnpdjango.storage import BofhFileSystemStorage +from catalogue import constants +from catalogue.fields import EbookField from catalogue.models import Tag, Fragment, BookMedia -from catalogue.utils import create_zip, split_tags, truncate_html_words, book_upload_path +from catalogue.utils import create_zip +from catalogue import app_settings from catalogue import tasks from newtagging import managers +bofh_storage = BofhFileSystemStorage() -permanent_cache = get_cache('permanent') +permanent_cache = caches['permanent'] + + +def _cover_upload_to(i, n): + return 'book/cover/%s.jpg' % i.slug + +def _cover_thumb_upload_to(i, n): + return 'book/cover_thumb/%s.jpg' % i.slug, + +def _ebook_upload_to(upload_path): + def _upload_to(i, n): + return upload_path % i.slug + return _upload_to class Book(models.Model): """Represents a book imported from WL-XML.""" title = models.CharField(_('title'), max_length=120) sort_key = models.CharField(_('sort key'), max_length=120, db_index=True, editable=False) + sort_key_author = models.CharField(_('sort key by author'), max_length=120, db_index=True, editable=False, default=u'') slug = models.SlugField(_('slug'), max_length=120, db_index=True, unique=True) common_slug = models.SlugField(_('slug'), max_length=120, db_index=True) language = models.CharField(_('language code'), max_length=3, db_index=True, - default=settings.CATALOGUE_DEFAULT_LANGUAGE) + default=app_settings.DEFAULT_LANGUAGE) description = models.TextField(_('description'), blank=True) created_at = models.DateTimeField(_('creation date'), auto_now_add=True, db_index=True) changed_at = models.DateTimeField(_('creation date'), auto_now=True, db_index=True) parent_number = models.IntegerField(_('parent number'), default=0) - extra_info = jsonfield.JSONField(_('extra information'), default='{}') + extra_info = jsonfield.JSONField(_('extra information'), default={}) gazeta_link = models.CharField(blank=True, max_length=240) wiki_link = models.CharField(blank=True, max_length=240) # files generated during publication - cover = models.FileField(_('cover'), upload_to=book_upload_path('png'), - null=True, blank=True) - ebook_formats = ['pdf', 'epub', 'mobi', 'fb2', 'txt'] + cover = EbookField('cover', _('cover'), + null=True, blank=True, + upload_to=_cover_upload_to, + storage=bofh_storage, max_length=255) + # Cleaner version of cover for thumbs + cover_thumb = EbookField('cover_thumb', _('cover thumbnail'), + null=True, blank=True, + upload_to=_cover_thumb_upload_to, + max_length=255) + ebook_formats = constants.EBOOK_FORMATS formats = ebook_formats + ['html', 'xml'] - parent = models.ForeignKey('self', blank=True, null=True, related_name='children') - - _related_info = jsonfield.JSONField(blank=True, null=True, editable=False) + parent = models.ForeignKey('self', blank=True, null=True, + related_name='children') + ancestor = models.ManyToManyField('self', blank=True, null=True, + editable=False, related_name='descendant', symmetrical=False) objects = models.Manager() tagged = managers.ModelTaggedItemManager(Tag) tags = managers.TagDescriptor(Tag) + tag_relations = GenericRelation(Tag.intermediary_table_model) html_built = django.dispatch.Signal() published = django.dispatch.Signal() @@ -70,8 +100,9 @@ class Book(models.Model): from sortify import sortify self.sort_key = sortify(self.title) + self.title = unicode(self.title) # ??? - ret = super(Book, self).save(force_insert, force_update) + ret = super(Book, self).save(force_insert, force_update, **kwargs) if reset_short_html: self.reset_short_html() @@ -82,21 +113,20 @@ class Book(models.Model): def get_absolute_url(self): return ('catalogue.views.book_detail', [self.slug]) + @staticmethod + @permalink + def create_url(slug): + return ('catalogue.views.book_detail', [slug]) + @property def name(self): return self.title - def book_tag_slug(self): - return ('l-' + self.slug)[:120] + def language_code(self): + return constants.LANGUAGES_3TO2.get(self.language, self.language) - def book_tag(self): - slug = self.book_tag_slug() - book_tag, created = Tag.objects.get_or_create(slug=slug, category='book') - if created: - book_tag.name = self.title[:50] - book_tag.sort_key = self.title.lower() - book_tag.save() - return book_tag + def language_name(self): + return dict(settings.LANGUAGES).get(self.language_code(), "") def has_media(self, type_): if type_ in Book.formats: @@ -108,7 +138,7 @@ class Book(models.Model): if self.has_media(type_): if type_ in Book.formats: return getattr(self, "%s_file" % type_) - else: + else: return self.media.filter(type=type_) else: return None @@ -120,17 +150,22 @@ class Book(models.Model): def get_ogg(self): return self.get_media("ogg") def get_daisy(self): - return self.get_media("daisy") + return self.get_media("daisy") def reset_short_html(self): if self.id is None: return - type(self).objects.filter(pk=self.pk).update(_related_info=None) # Fragment.short_html relies on book's tags, so reset it here too for fragm in self.fragments.all().iterator(): fragm.reset_short_html() + try: + author = self.tags.filter(category='author')[0].sort_key + except IndexError: + author = u'' + type(self).objects.filter(pk=self.pk).update(sort_key_author=author) + def has_description(self): return len(self.description) > 0 has_description.short_description = _('description') @@ -152,137 +187,53 @@ class Book(models.Model): has_daisy_file.short_description = 'DAISY' has_daisy_file.boolean = True - def wldocument(self, parse_dublincore=True): + def wldocument(self, parse_dublincore=True, inherit=True): from catalogue.import_utils import ORMDocProvider from librarian.parser import WLDocument + if inherit and self.parent: + meta_fallbacks = self.parent.cover_info() + else: + meta_fallbacks = None + return WLDocument.from_file(self.xml_file.path, provider=ORMDocProvider(self), - parse_dublincore=parse_dublincore) - - def build_cover(self, book_info=None): - """(Re)builds the cover image.""" - from StringIO import StringIO - from django.core.files.base import ContentFile - from librarian.cover import WLCover - - if book_info is None: - book_info = self.wldocument().book_info - - cover = WLCover(book_info).image() - imgstr = StringIO() - cover.save(imgstr, 'png') - self.cover.save(None, ContentFile(imgstr.getvalue())) - - def build_html(self): - from django.core.files.base import ContentFile - from slughifi import slughifi - from sortify import sortify - from librarian import html - - meta_tags = list(self.tags.filter( - category__in=('author', 'epoch', 'genre', 'kind'))) - book_tag = self.book_tag() - - html_output = self.wldocument(parse_dublincore=False).as_html() - if html_output: - self.html_file.save('%s.html' % self.slug, - ContentFile(html_output.get_string())) - - # get ancestor l-tags for adding to new fragments - ancestor_tags = [] - p = self.parent - while p: - ancestor_tags.append(p.book_tag()) - p = p.parent - - # Delete old fragments and create them from scratch - self.fragments.all().delete() - # Extract fragments - closed_fragments, open_fragments = html.extract_fragments(self.html_file.path) - for fragment in closed_fragments.values(): - try: - theme_names = [s.strip() for s in fragment.themes.split(',')] - except AttributeError: - continue - themes = [] - for theme_name in theme_names: - if not theme_name: - continue - tag, created = Tag.objects.get_or_create(slug=slughifi(theme_name), category='theme') - if created: - tag.name = theme_name - tag.sort_key = sortify(theme_name.lower()) - tag.save() - themes.append(tag) - if not themes: - continue - - text = fragment.to_string() - short_text = truncate_html_words(text, 15) - if text == short_text: - short_text = '' - new_fragment = Fragment.objects.create(anchor=fragment.id, book=self, - text=text, short_text=short_text) - - new_fragment.save() - new_fragment.tags = set(meta_tags + themes + [book_tag] + ancestor_tags) - self.save() - self.html_built.send(sender=self) - return True - return False - - # Thin wrappers for builder tasks - def build_pdf(self, *args, **kwargs): - """(Re)builds PDF.""" - return tasks.build_pdf.delay(self.pk, *args, **kwargs) - def build_epub(self, *args, **kwargs): - """(Re)builds EPUB.""" - return tasks.build_epub.delay(self.pk, *args, **kwargs) - def build_mobi(self, *args, **kwargs): - """(Re)builds MOBI.""" - return tasks.build_mobi.delay(self.pk, *args, **kwargs) - def build_fb2(self, *args, **kwargs): - """(Re)build FB2""" - return tasks.build_fb2.delay(self.pk, *args, **kwargs) - def build_txt(self, *args, **kwargs): - """(Re)builds TXT.""" - return tasks.build_txt.delay(self.pk, *args, **kwargs) + parse_dublincore=parse_dublincore, + meta_fallbacks=meta_fallbacks) @staticmethod def zip_format(format_): def pretty_file_name(book): return "%s/%s.%s" % ( - b.extra_info['author'], - b.slug, + book.extra_info['author'], + book.slug, format_) field_name = "%s_file" % format_ books = Book.objects.filter(parent=None).exclude(**{field_name: ""}) paths = [(pretty_file_name(b), getattr(b, field_name).path) for b in books.iterator()] - return create_zip(paths, - getattr(settings, "ALL_%s_ZIP" % format_.upper())) + return create_zip(paths, app_settings.FORMAT_ZIPS[format_]) def zip_audiobooks(self, format_): bm = BookMedia.objects.filter(book=self, type=format_) paths = map(lambda bm: (None, bm.file.path), bm) return create_zip(paths, "%s_%s" % (self.slug, format_)) - def search_index(self, book_info=None, reuse_index=False, index_tags=True): - import search - if reuse_index: - idx = search.ReusableIndex() - else: - idx = search.Index() - - idx.open() + def search_index(self, book_info=None, index=None, index_tags=True, commit=True): + if index is None: + from search.index import Index + index = Index() try: - idx.index_book(self, book_info) + index.index_book(self, book_info) if index_tags: - idx.index_tags() - finally: - idx.close() + index.index_tags() + if commit: + index.index.commit() + except Exception, e: + index.index.rollback() + raise e + @classmethod def from_xml_file(cls, xml_file, **kwargs): @@ -302,8 +253,11 @@ class Book(models.Model): @classmethod def from_text_and_meta(cls, raw_file, book_info, overwrite=False, - build_epub=True, build_txt=True, build_pdf=True, build_mobi=True, build_fb2=True, - search_index=True, search_index_tags=True, search_index_reuse=False): + dont_build=None, search_index=True, + search_index_tags=True): + if dont_build is None: + dont_build = set() + dont_build = set.union(set(dont_build), set(app_settings.DONT_BUILD)) # check for parts before we do anything children = [] @@ -315,7 +269,6 @@ class Book(models.Model): raise Book.DoesNotExist(_('Book "%s" does not exist.') % part_url.slug) - # Read book metadata book_slug = book_info.url.slug if re.search(r'[^a-z0-9-]', book_slug): @@ -324,12 +277,17 @@ class Book(models.Model): if created: book_shelves = [] + old_cover = None else: if not overwrite: raise Book.AlreadyExists(_('Book %s already exists') % ( book_slug)) # Save shelves for this book book_shelves = list(book.tags.filter(category='set')) + old_cover = book.cover_info() + + # Save XML file + book.xml_file.save('%s.xml' % book.slug, raw_file, save=False) book.language = book_info.language book.title = book_info.title @@ -344,195 +302,145 @@ class Book(models.Model): book.tags = set(meta_tags + book_shelves) - obsolete_children = set(b for b in book.children.all() if b not in children) + cover_changed = old_cover != book.cover_info() + obsolete_children = set(b for b in book.children.all() + if b not in children) + notify_cover_changed = [] for n, child_book in enumerate(children): + new_child = child_book.parent != book child_book.parent = book child_book.parent_number = n child_book.save() + if new_child or cover_changed: + notify_cover_changed.append(child_book) # Disown unfaithful children and let them cope on their own. for child in obsolete_children: child.parent = None child.parent_number = 0 child.save() tasks.fix_tree_tags.delay(child) + if old_cover: + notify_cover_changed.append(child) - # Save XML and HTML files - book.xml_file.save('%s.xml' % book.slug, raw_file, save=False) - book.build_cover(book_info) - - # delete old fragments when overwriting - book.fragments.all().delete() - - if book.build_html(): - # No direct saves behind this point. - if not settings.NO_BUILD_TXT and build_txt: - book.build_txt() - - if not settings.NO_BUILD_EPUB and build_epub: - book.build_epub() + cls.fix_tree_tags() - if not settings.NO_BUILD_PDF and build_pdf: - book.build_pdf() + # No saves beyond this point. - if not settings.NO_BUILD_MOBI and build_mobi: - book.build_mobi() + # Build cover. + if 'cover' not in dont_build: + book.cover.build_delay() + book.cover_thumb.build_delay() - if not settings.NO_BUILD_FB2 and build_fb2: - book.build_fb2() + # Build HTML and ebooks. + book.html_file.build_delay() + if not children: + for format_ in constants.EBOOK_FORMATS_WITHOUT_CHILDREN: + if format_ not in dont_build: + getattr(book, '%s_file' % format_).build_delay() + for format_ in constants.EBOOK_FORMATS_WITH_CHILDREN: + if format_ not in dont_build: + getattr(book, '%s_file' % format_).build_delay() if not settings.NO_SEARCH_INDEX and search_index: - book.search_index(index_tags=search_index_tags, reuse_index=search_index_reuse) - #index_book.delay(book.id, book_info) + tasks.index_book.delay(book.id, book_info=book_info, index_tags=search_index_tags) + + for child in notify_cover_changed: + child.parent_cover_changed() - tasks.fix_tree_tags.delay(book) cls.published.send(sender=book) return book - def fix_tree_tags(self): - """Fixes the l-tags on the book's subtree. + @classmethod + def fix_tree_tags(cls): + """Fixes the ancestry cache.""" + # TODO: table names + with transaction.atomic(): + cursor = connection.cursor() + if connection.vendor == 'postgres': + cursor.execute("TRUNCATE catalogue_book_ancestor") + cursor.execute(""" + WITH RECURSIVE ancestry AS ( + SELECT book.id, book.parent_id + FROM catalogue_book AS book + WHERE book.parent_id IS NOT NULL + UNION + SELECT ancestor.id, book.parent_id + FROM ancestry AS ancestor, catalogue_book AS book + WHERE ancestor.parent_id = book.id + AND book.parent_id IS NOT NULL + ) + INSERT INTO catalogue_book_ancestor + (from_book_id, to_book_id) + SELECT id, parent_id + FROM ancestry + ORDER BY id; + """) + else: + cursor.execute("DELETE FROM catalogue_book_ancestor") + for b in cls.objects.exclude(parent=None): + parent = b.parent + while parent is not None: + b.ancestor.add(parent) + parent = parent.parent + + def cover_info(self, inherit=True): + """Returns a dictionary to serve as fallback for BookInfo. - Makes sure that: - * the book has its parents book-tags, - * its fragments have the book's and its parents book-tags, - * runs those for every child book too, - * touches all relevant tags, - * resets tag and theme counter on the book and its ancestry. + For now, the only thing inherited is the cover image. """ - def fix_subtree(book, parent_tags): - affected_tags = set(book.tags) - book.tags = list(book.tags.exclude(category='book')) + parent_tags - sub_parent_tags = parent_tags + [book.book_tag()] - for frag in book.fragments.all(): - affected_tags.update(frag.tags) - frag.tags = list(frag.tags.exclude(category='book')) + sub_parent_tags - for child in book.children.all(): - affected_tags.update(fix_subtree(child, sub_parent_tags)) - return affected_tags - - parent_tags = [] + need = False + info = {} + for field in ('cover_url', 'cover_by', 'cover_source'): + val = self.extra_info.get(field) + if val: + info[field] = val + else: + need = True + if inherit and need and self.parent is not None: + parent_info = self.parent.cover_info() + parent_info.update(info) + info = parent_info + return info + + def related_themes(self): + return Tag.objects.usage_for_queryset( + Fragment.objects.filter(models.Q(book=self) | models.Q(book__ancestor=self)), + counts=True).filter(category='theme') + + def parent_cover_changed(self): + """Called when parent book's cover image is changed.""" + if not self.cover_info(inherit=False): + if 'cover' not in app_settings.DONT_BUILD: + self.cover.build_delay() + self.cover_thumb.build_delay() + for format_ in constants.EBOOK_FORMATS_WITH_COVERS: + if format_ not in app_settings.DONT_BUILD: + getattr(self, '%s_file' % format_).build_delay() + for child in self.children.all(): + child.parent_cover_changed() + + def other_versions(self): + """Find other versions (i.e. in other languages) of the book.""" + return type(self).objects.filter(common_slug=self.common_slug).exclude(pk=self.pk) + + def parents(self): + books = [] parent = self.parent while parent is not None: - parent_tags.append(parent.book_tag()) + books.insert(0, parent) parent = parent.parent - - affected_tags = fix_subtree(self, parent_tags) - for tag in affected_tags: - tasks.touch_tag(tag) - - book = self - while book is not None: - book.reset_tag_counter() - book.reset_theme_counter() - book = book.parent - - def related_info(self): - """Keeps info about related objects (tags, media) in cache field.""" - if self._related_info is not None: - return self._related_info - else: - rel = {'tags': {}, 'media': {}} - - tags = self.tags.filter(category__in=( - 'author', 'kind', 'genre', 'epoch')) - tags = split_tags(tags) - for category in tags: - rel['tags'][category] = [ - (t.name, t.slug) for t in tags[category]] - - for media_format in BookMedia.formats: - rel['media'][media_format] = self.has_media(media_format) - - book = self - parents = [] - while book.parent: - parents.append((book.parent.title, book.parent.slug)) - book = book.parent - parents = parents[::-1] - if parents: - rel['parents'] = parents - - if self.pk: - type(self).objects.filter(pk=self.pk).update(_related_info=rel) - return rel - - def related_themes(self): - theme_counter = self.theme_counter - book_themes = list(Tag.objects.filter(pk__in=theme_counter.keys())) - for tag in book_themes: - tag.count = theme_counter[tag.pk] - return book_themes - - def reset_tag_counter(self): - if self.id is None: - return - - cache_key = "Book.tag_counter/%d" % self.id - permanent_cache.delete(cache_key) - if self.parent: - self.parent.reset_tag_counter() - - @property - def tag_counter(self): - if self.id: - cache_key = "Book.tag_counter/%d" % self.id - tags = permanent_cache.get(cache_key) - else: - tags = None - - if tags is None: - tags = {} - for child in self.children.all().order_by().iterator(): - for tag_pk, value in child.tag_counter.iteritems(): - tags[tag_pk] = tags.get(tag_pk, 0) + value - for tag in self.tags.exclude(category__in=('book', 'theme', 'set')).order_by().iterator(): - tags[tag.pk] = 1 - - if self.id: - permanent_cache.set(cache_key, tags) - return tags - - def reset_theme_counter(self): - if self.id is None: - return - - cache_key = "Book.theme_counter/%d" % self.id - permanent_cache.delete(cache_key) - if self.parent: - self.parent.reset_theme_counter() - - @property - def theme_counter(self): - if self.id: - cache_key = "Book.theme_counter/%d" % self.id - tags = permanent_cache.get(cache_key) - else: - tags = None - - if tags is None: - tags = {} - for fragment in Fragment.tagged.with_any([self.book_tag()]).order_by().iterator(): - for tag in fragment.tags.filter(category='theme').order_by().iterator(): - tags[tag.pk] = tags.get(tag.pk, 0) + 1 - - if self.id: - permanent_cache.set(cache_key, tags) - return tags + return books def pretty_title(self, html_links=False): - book = self - names = list(book.tags.filter(category='author')) - - books = [] - while book: - books.append(book) - book = book.parent - names.extend(reversed(books)) + names = [(tag.name, tag.get_absolute_url()) + for tag in self.tags.filter(category='author')] + books = self.parents() + [self] + names.extend([(b.title, b.get_absolute_url()) for b in books]) if html_links: - names = ['%s' % (tag.get_absolute_url(), tag.name) for tag in names] + names = ['%s' % (tag[1], tag[0]) for tag in names] else: - names = [tag.name for tag in names] - + names = [tag[0] for tag in names] return ', '.join(names) @classmethod @@ -543,16 +451,8 @@ class Book(models.Model): also tagged with those tags. """ - # get relevant books and their tags objects = cls.tagged.with_all(tags) - # eliminate descendants - l_tags = Tag.objects.filter(category='book', - slug__in=[book.book_tag_slug() for book in objects.iterator()]) - descendants_keys = [book.pk for book in cls.tagged.with_any(l_tags).iterator()] - if descendants_keys: - objects = objects.exclude(pk__in=descendants_keys) - - return objects + return objects.exclude(ancestor__in=objects) @classmethod def book_list(cls, filter=None): @@ -567,7 +467,7 @@ class Book(models.Model): 'title', 'parent', 'slug') if filter: books = books.filter(filter).distinct() - + book_ids = set(b['pk'] for b in books.values("pk").iterator()) for book in books.iterator(): parent = book.parent_id @@ -579,11 +479,11 @@ class Book(models.Model): books_by_parent.setdefault(book.parent_id, []).append(book) orphans = [] - books_by_author = SortedDict() + books_by_author = OrderedDict() for tag in Tag.objects.filter(category='author').iterator(): books_by_author[tag] = [] - for book in books_by_parent.get(None,()): + for book in books_by_parent.get(None, ()): authors = list(book.tags.filter(category='author')) if authors: for author in authors: @@ -594,6 +494,7 @@ class Book(models.Model): return books_by_author, orphans, books_by_parent _audiences_pl = { + "SP": (1, u"szkoła podstawowa"), "SP1": (1, u"szkoła podstawowa"), "SP2": (1, u"szkoła podstawowa"), "P": (1, u"szkoła podstawowa"), @@ -603,34 +504,43 @@ class Book(models.Model): } def audiences_pl(self): audiences = self.extra_info.get('audiences', []) - audiences = sorted(set([self._audiences_pl[a] for a in audiences])) + audiences = sorted(set([self._audiences_pl.get(a, (99, a)) for a in audiences])) return [a[1] for a in audiences] + def stage_note(self): + stage = self.extra_info.get('stage') + if stage and stage < '0.4': + return (_('This work needs modernisation'), + reverse('infopage', args=['wymagajace-uwspolczesnienia'])) + else: + return None, None + def choose_fragment(self): - tag = self.book_tag() - fragments = Fragment.tagged.with_any([tag]) - if fragments.exists(): - return fragments.order_by('?')[0] + fragments = self.fragments.order_by() + fragments_count = fragments.count() + if not fragments_count and self.children.exists(): + fragments = Fragment.objects.filter(book__ancestor=self).order_by() + fragments_count = fragments.count() + if fragments_count: + return fragments[randint(0, fragments_count - 1)] elif self.parent: return self.parent.choose_fragment() else: return None -def _has_factory(ftype): - has = lambda self: bool(getattr(self, "%s_file" % ftype)) - has.short_description = ftype.upper() - has.__doc__ = None - has.boolean = True - has.__name__ = "has_%s_file" % ftype - return has - - # add the file fields -for t in Book.formats: - field_name = "%s_file" % t - models.FileField(_("%s file" % t.upper()), - upload_to=book_upload_path(t), - blank=True).contribute_to_class(Book, field_name) - - setattr(Book, "has_%s_file" % t, _has_factory(t)) +for format_ in Book.formats: + field_name = "%s_file" % format_ + # This weird globals() assignment makes Django migrations comfortable. + _upload_to = _ebook_upload_to('book/%s/%%s.%s' % (format_, format_)) + _upload_to.__name__ = '_%s_upload_to' % format_ + globals()[_upload_to.__name__] = _upload_to + + EbookField(format_, _("%s file" % format_.upper()), + upload_to=_upload_to, + storage=bofh_storage, + max_length=255, + blank=True, + default='' + ).contribute_to_class(Book, field_name)