1 # This file is part of Wolnelektury, licensed under GNU Affero GPLv3 or later.
2 # Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information.
4 from collections import OrderedDict
6 from datetime import date, timedelta
7 from random import randint
10 from urllib.request import urlretrieve
11 from django.conf import settings
12 from django.db import connection, models, transaction
13 import django.dispatch
14 from django.contrib.contenttypes.fields import GenericRelation
15 from django.urls import reverse
16 from django.utils.translation import ugettext_lazy as _, get_language
17 from django.utils.deconstruct import deconstructible
18 from fnpdjango.storage import BofhFileSystemStorage
20 from librarian.cover import WLCover
21 from librarian.html import transform_abstrakt
22 from newtagging import managers
23 from catalogue import constants
24 from catalogue.fields import EbookField
25 from catalogue.models import Tag, Fragment, BookMedia
26 from catalogue.utils import create_zip, gallery_url, gallery_path, split_tags, get_random_hash
27 from catalogue.models.tag import prefetched_relations
28 from catalogue import app_settings
29 from catalogue import tasks
30 from wolnelektury.utils import makedirs, cached_render, clear_cached_renders
32 bofh_storage = BofhFileSystemStorage()
36 class UploadToPath(object):
37 def __init__(self, path):
40 def __call__(self, instance, filename):
41 return self.path % instance.slug
44 _cover_upload_to = UploadToPath('book/cover/%s.jpg')
45 _cover_thumb_upload_to = UploadToPath('book/cover_thumb/%s.jpg')
46 _cover_api_thumb_upload_to = UploadToPath('book/cover_api_thumb/%s.jpg')
47 _simple_cover_upload_to = UploadToPath('book/cover_simple/%s.jpg')
48 _cover_ebookpoint_upload_to = UploadToPath('book/cover_ebookpoint/%s.jpg')
51 def _ebook_upload_to(upload_path):
52 return UploadToPath(upload_path)
55 class Book(models.Model):
56 """Represents a book imported from WL-XML."""
57 title = models.CharField(_('title'), max_length=32767)
58 sort_key = models.CharField(_('sort key'), max_length=120, db_index=True, editable=False)
59 sort_key_author = models.CharField(
60 _('sort key by author'), max_length=120, db_index=True, editable=False, default='')
61 slug = models.SlugField(_('slug'), max_length=120, db_index=True, unique=True)
62 common_slug = models.SlugField(_('slug'), max_length=120, db_index=True)
63 language = models.CharField(_('language code'), max_length=3, db_index=True, default=app_settings.DEFAULT_LANGUAGE)
64 description = models.TextField(_('description'), blank=True)
65 abstract = models.TextField(_('abstract'), blank=True)
66 created_at = models.DateTimeField(_('creation date'), auto_now_add=True, db_index=True)
67 changed_at = models.DateTimeField(_('change date'), auto_now=True, db_index=True)
68 parent_number = models.IntegerField(_('parent number'), default=0)
69 extra_info = models.TextField(_('extra information'), default='{}')
70 gazeta_link = models.CharField(blank=True, max_length=240)
71 wiki_link = models.CharField(blank=True, max_length=240)
72 print_on_demand = models.BooleanField(_('print on demand'), default=False)
73 recommended = models.BooleanField(_('recommended'), default=False)
74 audio_length = models.CharField(_('audio length'), blank=True, max_length=8)
75 preview = models.BooleanField(_('preview'), default=False)
76 preview_until = models.DateField(_('preview until'), blank=True, null=True)
77 preview_key = models.CharField(max_length=32, blank=True, null=True)
78 findable = models.BooleanField(_('findable'), default=True, db_index=True)
80 # files generated during publication
83 null=True, blank=True,
84 upload_to=_cover_upload_to,
85 storage=bofh_storage, max_length=255)
86 cover_etag = models.CharField(max_length=255, editable=False, default='', db_index=True)
87 # Cleaner version of cover for thumbs
88 cover_thumb = EbookField(
89 'cover_thumb', _('cover thumbnail'),
90 null=True, blank=True,
91 upload_to=_cover_thumb_upload_to,
93 cover_thumb_etag = models.CharField(max_length=255, editable=False, default='', db_index=True)
94 cover_api_thumb = EbookField(
95 'cover_api_thumb', _('cover thumbnail for mobile app'),
96 null=True, blank=True,
97 upload_to=_cover_api_thumb_upload_to,
99 cover_api_thumb_etag = models.CharField(max_length=255, editable=False, default='', db_index=True)
100 simple_cover = EbookField(
101 'simple_cover', _('cover for mobile app'),
102 null=True, blank=True,
103 upload_to=_simple_cover_upload_to,
105 simple_cover_etag = models.CharField(max_length=255, editable=False, default='', db_index=True)
106 cover_ebookpoint = EbookField(
107 'cover_ebookpoint', _('cover for Ebookpoint'),
108 null=True, blank=True,
109 upload_to=_cover_ebookpoint_upload_to,
111 cover_ebookpoint_etag = models.CharField(max_length=255, editable=False, default='', db_index=True)
112 ebook_formats = constants.EBOOK_FORMATS
113 formats = ebook_formats + ['html', 'xml']
115 parent = models.ForeignKey('self', models.CASCADE, blank=True, null=True, related_name='children')
116 ancestor = models.ManyToManyField('self', blank=True, editable=False, related_name='descendant', symmetrical=False)
118 cached_author = models.CharField(blank=True, max_length=240, db_index=True)
119 has_audience = models.BooleanField(default=False)
121 objects = models.Manager()
122 tagged = managers.ModelTaggedItemManager(Tag)
123 tags = managers.TagDescriptor(Tag)
124 tag_relations = GenericRelation(Tag.intermediary_table_model)
126 html_built = django.dispatch.Signal()
127 published = django.dispatch.Signal()
131 class AlreadyExists(Exception):
135 ordering = ('sort_key_author', 'sort_key')
136 verbose_name = _('book')
137 verbose_name_plural = _('books')
138 app_label = 'catalogue'
143 def get_extra_info_json(self):
144 return json.loads(self.extra_info or '{}')
146 def get_initial(self):
148 return re.search(r'\w', self.title, re.U).group(0)
149 except AttributeError:
153 return self.tags.filter(category='author')
156 return self.tags.filter(category='epoch')
159 return self.tags.filter(category='genre')
162 return self.tags.filter(category='kind')
164 def tag_unicode(self, category):
165 relations = prefetched_relations(self, category)
167 return ', '.join(rel.tag.name for rel in relations)
169 return ', '.join(self.tags.filter(category=category).values_list('name', flat=True))
171 def tags_by_category(self):
172 return split_tags(self.tags.exclude(category__in=('set', 'theme')))
174 def author_unicode(self):
175 return self.cached_author
177 def kind_unicode(self):
178 return self.tag_unicode('kind')
180 def epoch_unicode(self):
181 return self.tag_unicode('epoch')
183 def genre_unicode(self):
184 return self.tag_unicode('genre')
186 def translators(self):
187 translators = self.get_extra_info_json().get('translators') or []
189 '\xa0'.join(reversed(translator.split(', ', 1))) for translator in translators
192 def translator(self):
193 translators = self.get_extra_info_json().get('translators')
196 if len(translators) > 3:
197 translators = translators[:2]
201 return ', '.join('\xa0'.join(reversed(translator.split(', ', 1))) for translator in translators) + others
203 def cover_source(self):
204 return self.get_extra_info_json().get('cover_source', self.parent.cover_source() if self.parent else '')
208 return self.get_extra_info_json().get('isbn_pdf')
212 return self.get_extra_info_json().get('isbn_epub')
216 return self.get_extra_info_json().get('isbn_mobi')
219 def save(self, force_insert=False, force_update=False, **kwargs):
220 from sortify import sortify
222 self.sort_key = sortify(self.title)[:120]
223 self.title = str(self.title) # ???
226 author = self.authors().first().sort_key
227 except AttributeError:
229 self.sort_key_author = author
231 self.cached_author = self.tag_unicode('author')
232 self.has_audience = 'audience' in self.get_extra_info_json()
234 if self.preview and not self.preview_key:
235 self.preview_key = get_random_hash(self.slug)[:32]
237 ret = super(Book, self).save(force_insert, force_update, **kwargs)
241 def get_absolute_url(self):
242 return reverse('book_detail', args=[self.slug])
244 def gallery_path(self):
245 return gallery_path(self.slug)
247 def gallery_url(self):
248 return gallery_url(self.slug)
254 def language_code(self):
255 return constants.LANGUAGES_3TO2.get(self.language, self.language)
257 def language_name(self):
258 return dict(settings.LANGUAGES).get(self.language_code(), "")
260 def is_foreign(self):
261 return self.language_code() != settings.LANGUAGE_CODE
263 def set_audio_length(self):
264 length = self.get_audio_length()
266 self.audio_length = self.format_audio_length(length)
270 def format_audio_length(seconds):
272 >>> Book.format_audio_length(1)
274 >>> Book.format_audio_length(3661)
278 minutes = seconds // 60
279 seconds = seconds % 60
280 return '%d:%02d' % (minutes, seconds)
282 hours = seconds // 3600
283 minutes = seconds % 3600 // 60
284 seconds = seconds % 60
285 return '%d:%02d:%02d' % (hours, minutes, seconds)
287 def get_audio_length(self):
289 for media in self.get_mp3() or ():
290 total += app_settings.GET_MP3_LENGTH(media.file.path)
293 def has_media(self, type_):
294 if type_ in Book.formats:
295 return bool(getattr(self, "%s_file" % type_))
297 return self.media.filter(type=type_).exists()
300 return self.has_media('mp3')
302 def get_media(self, type_):
303 if self.has_media(type_):
304 if type_ in Book.formats:
305 return getattr(self, "%s_file" % type_)
307 return self.media.filter(type=type_)
312 return self.get_media("mp3")
315 return self.get_media("odt")
318 return self.get_media("ogg")
321 return self.get_media("daisy")
323 def media_url(self, format_):
324 media = self.get_media(format_)
327 return reverse('embargo_link', kwargs={'key': self.preview_key, 'slug': self.slug, 'format_': format_})
334 return self.media_url('html')
337 return self.media_url('pdf')
340 return self.media_url('epub')
343 return self.media_url('mobi')
346 return self.media_url('txt')
349 return self.media_url('fb2')
352 return self.media_url('xml')
354 def has_description(self):
355 return len(self.description) > 0
356 has_description.short_description = _('description')
357 has_description.boolean = True
359 def has_mp3_file(self):
360 return self.has_media("mp3")
361 has_mp3_file.short_description = 'MP3'
362 has_mp3_file.boolean = True
364 def has_ogg_file(self):
365 return self.has_media("ogg")
366 has_ogg_file.short_description = 'OGG'
367 has_ogg_file.boolean = True
369 def has_daisy_file(self):
370 return self.has_media("daisy")
371 has_daisy_file.short_description = 'DAISY'
372 has_daisy_file.boolean = True
374 def get_audiobooks(self):
376 for m in self.media.filter(type='ogg').order_by().iterator():
377 ogg_files[m.name] = m
381 for mp3 in self.media.filter(type='mp3').iterator():
382 # ogg files are always from the same project
383 meta = mp3.get_extra_info_json()
384 project = meta.get('project')
387 project = 'CzytamySłuchając'
389 projects.add((project, meta.get('funded_by', '')))
393 ogg = ogg_files.get(mp3.name)
396 audiobooks.append(media)
398 projects = sorted(projects)
399 return audiobooks, projects
401 def wldocument(self, parse_dublincore=True, inherit=True):
402 from catalogue.import_utils import ORMDocProvider
403 from librarian.parser import WLDocument
405 if inherit and self.parent:
406 meta_fallbacks = self.parent.cover_info()
408 meta_fallbacks = None
410 return WLDocument.from_file(
412 provider=ORMDocProvider(self),
413 parse_dublincore=parse_dublincore,
414 meta_fallbacks=meta_fallbacks)
417 def zip_format(format_):
418 def pretty_file_name(book):
419 return "%s/%s.%s" % (
420 book.get_extra_info_json()['author'],
424 field_name = "%s_file" % format_
425 books = Book.objects.filter(parent=None).exclude(**{field_name: ""}).exclude(preview=True).exclude(findable=False)
426 paths = [(pretty_file_name(b), getattr(b, field_name).path) for b in books.iterator()]
427 return create_zip(paths, app_settings.FORMAT_ZIPS[format_])
429 def zip_audiobooks(self, format_):
430 bm = BookMedia.objects.filter(book=self, type=format_)
431 paths = map(lambda bm: (None, bm.file.path), bm)
432 return create_zip(paths, "%s_%s" % (self.slug, format_))
434 def search_index(self, book_info=None, index=None, index_tags=True, commit=True):
435 if not self.findable:
438 from search.index import Index
441 index.index_book(self, book_info)
446 except Exception as e:
447 index.index.rollback()
450 # will make problems in conjunction with paid previews
451 def download_pictures(self, remote_gallery_url):
452 gallery_path = self.gallery_path()
453 # delete previous files, so we don't include old files in ebooks
454 if os.path.isdir(gallery_path):
455 for filename in os.listdir(gallery_path):
456 file_path = os.path.join(gallery_path, filename)
458 ilustr_elements = list(self.wldocument().edoc.findall('//ilustr'))
460 makedirs(gallery_path)
461 for ilustr in ilustr_elements:
462 ilustr_src = ilustr.get('src')
463 ilustr_path = os.path.join(gallery_path, ilustr_src)
464 urlretrieve('%s/%s' % (remote_gallery_url, ilustr_src), ilustr_path)
466 def load_abstract(self):
467 abstract = self.wldocument(parse_dublincore=False).edoc.getroot().find('.//abstrakt')
468 if abstract is not None:
469 self.abstract = transform_abstrakt(abstract)
474 def from_xml_file(cls, xml_file, **kwargs):
475 from django.core.files import File
476 from librarian import dcparser
478 # use librarian to parse meta-data
479 book_info = dcparser.parse(xml_file)
481 if not isinstance(xml_file, File):
482 xml_file = File(open(xml_file))
485 return cls.from_text_and_meta(xml_file, book_info, **kwargs)
490 def from_text_and_meta(cls, raw_file, book_info, overwrite=False, dont_build=None, search_index=True,
491 search_index_tags=True, remote_gallery_url=None, days=0, findable=True):
492 if dont_build is None:
494 dont_build = set.union(set(dont_build), set(app_settings.DONT_BUILD))
496 # check for parts before we do anything
498 if hasattr(book_info, 'parts'):
499 for part_url in book_info.parts:
501 children.append(Book.objects.get(slug=part_url.slug))
502 except Book.DoesNotExist:
503 raise Book.DoesNotExist(_('Book "%s" does not exist.') % part_url.slug)
506 book_slug = book_info.url.slug
507 if re.search(r'[^a-z0-9-]', book_slug):
508 raise ValueError('Invalid characters in slug')
509 book, created = Book.objects.get_or_create(slug=book_slug)
514 book.preview = bool(days)
516 book.preview_until = date.today() + timedelta(days)
519 raise Book.AlreadyExists(_('Book %s already exists') % book_slug)
520 # Save shelves for this book
521 book_shelves = list(book.tags.filter(category='set'))
522 old_cover = book.cover_info()
525 book.xml_file.save('%s.xml' % book.slug, raw_file, save=False)
527 book.xml_file.set_readable(False)
529 book.findable = findable
530 book.language = book_info.language
531 book.title = book_info.title
532 if book_info.variant_of:
533 book.common_slug = book_info.variant_of.slug
535 book.common_slug = book.slug
536 book.extra_info = json.dumps(book_info.to_dict())
540 meta_tags = Tag.tags_from_info(book_info)
542 for tag in meta_tags:
543 if not tag.for_books:
547 book.tags = set(meta_tags + book_shelves)
548 book.save() # update sort_key_author
550 cover_changed = old_cover != book.cover_info()
551 obsolete_children = set(b for b in book.children.all()
552 if b not in children)
553 notify_cover_changed = []
554 for n, child_book in enumerate(children):
555 new_child = child_book.parent != book
556 child_book.parent = book
557 child_book.parent_number = n
559 if new_child or cover_changed:
560 notify_cover_changed.append(child_book)
561 # Disown unfaithful children and let them cope on their own.
562 for child in obsolete_children:
564 child.parent_number = 0
567 notify_cover_changed.append(child)
569 cls.repopulate_ancestors()
570 tasks.update_counters.delay()
572 if remote_gallery_url:
573 book.download_pictures(remote_gallery_url)
575 # No saves beyond this point.
578 if 'cover' not in dont_build:
579 book.cover.build_delay()
580 book.cover_thumb.build_delay()
581 book.cover_api_thumb.build_delay()
582 book.simple_cover.build_delay()
583 book.cover_ebookpoint.build_delay()
585 # Build HTML and ebooks.
586 book.html_file.build_delay()
588 for format_ in constants.EBOOK_FORMATS_WITHOUT_CHILDREN:
589 if format_ not in dont_build:
590 getattr(book, '%s_file' % format_).build_delay()
591 for format_ in constants.EBOOK_FORMATS_WITH_CHILDREN:
592 if format_ not in dont_build:
593 getattr(book, '%s_file' % format_).build_delay()
595 if not settings.NO_SEARCH_INDEX and search_index and findable:
596 tasks.index_book.delay(book.id, book_info=book_info, index_tags=search_index_tags)
598 for child in notify_cover_changed:
599 child.parent_cover_changed()
601 book.update_popularity()
602 cls.published.send(sender=cls, instance=book)
607 def repopulate_ancestors(cls):
608 """Fixes the ancestry cache."""
610 cursor = connection.cursor()
611 if connection.vendor == 'postgres':
612 cursor.execute("TRUNCATE catalogue_book_ancestor")
614 WITH RECURSIVE ancestry AS (
615 SELECT book.id, book.parent_id
616 FROM catalogue_book AS book
617 WHERE book.parent_id IS NOT NULL
619 SELECT ancestor.id, book.parent_id
620 FROM ancestry AS ancestor, catalogue_book AS book
621 WHERE ancestor.parent_id = book.id
622 AND book.parent_id IS NOT NULL
624 INSERT INTO catalogue_book_ancestor
625 (from_book_id, to_book_id)
631 cursor.execute("DELETE FROM catalogue_book_ancestor")
632 for b in cls.objects.exclude(parent=None):
634 while parent is not None:
635 b.ancestor.add(parent)
636 parent = parent.parent
638 def clear_cache(self):
639 clear_cached_renders(self.mini_box)
640 clear_cached_renders(self.mini_box_nolink)
642 def cover_info(self, inherit=True):
643 """Returns a dictionary to serve as fallback for BookInfo.
645 For now, the only thing inherited is the cover image.
649 for field in ('cover_url', 'cover_by', 'cover_source'):
650 val = self.get_extra_info_json().get(field)
655 if inherit and need and self.parent is not None:
656 parent_info = self.parent.cover_info()
657 parent_info.update(info)
661 def related_themes(self):
662 return Tag.objects.usage_for_queryset(
663 Fragment.objects.filter(models.Q(book=self) | models.Q(book__ancestor=self)),
664 counts=True).filter(category='theme')
666 def parent_cover_changed(self):
667 """Called when parent book's cover image is changed."""
668 if not self.cover_info(inherit=False):
669 if 'cover' not in app_settings.DONT_BUILD:
670 self.cover.build_delay()
671 self.cover_thumb.build_delay()
672 self.cover_api_thumb.build_delay()
673 self.simple_cover.build_delay()
674 for format_ in constants.EBOOK_FORMATS_WITH_COVERS:
675 if format_ not in app_settings.DONT_BUILD:
676 getattr(self, '%s_file' % format_).build_delay()
677 for child in self.children.all():
678 child.parent_cover_changed()
680 def other_versions(self):
681 """Find other versions (i.e. in other languages) of the book."""
682 return type(self).objects.filter(common_slug=self.common_slug, findable=True).exclude(pk=self.pk)
687 while parent is not None:
688 books.insert(0, parent)
689 parent = parent.parent
692 def pretty_title(self, html_links=False):
693 names = [(tag.name, tag.get_absolute_url()) for tag in self.authors().only('name', 'category', 'slug')]
694 books = self.parents() + [self]
695 names.extend([(b.title, b.get_absolute_url()) for b in books])
698 names = ['<a href="%s">%s</a>' % (tag[1], tag[0]) for tag in names]
700 names = [tag[0] for tag in names]
701 return ', '.join(names)
704 publisher = self.get_extra_info_json()['publisher']
705 if isinstance(publisher, str):
707 elif isinstance(publisher, list):
708 return ', '.join(publisher)
711 def tagged_top_level(cls, tags):
712 """ Returns top-level books tagged with `tags`.
714 It only returns those books which don't have ancestors which are
715 also tagged with those tags.
718 objects = cls.tagged.with_all(tags)
719 return objects.filter(findable=True).exclude(ancestor__in=objects)
722 def book_list(cls, book_filter=None):
723 """Generates a hierarchical listing of all books.
725 Books are optionally filtered with a test function.
730 books = cls.objects.filter(findable=True).order_by('parent_number', 'sort_key').only('title', 'parent', 'slug', 'extra_info')
732 books = books.filter(book_filter).distinct()
734 book_ids = set(b['pk'] for b in books.values("pk").iterator())
735 for book in books.iterator():
736 parent = book.parent_id
737 if parent not in book_ids:
739 books_by_parent.setdefault(parent, []).append(book)
741 for book in books.iterator():
742 books_by_parent.setdefault(book.parent_id, []).append(book)
745 books_by_author = OrderedDict()
746 for tag in Tag.objects.filter(category='author').iterator():
747 books_by_author[tag] = []
749 for book in books_by_parent.get(None, ()):
750 authors = list(book.authors().only('pk'))
752 for author in authors:
753 books_by_author[author].append(book)
757 return books_by_author, orphans, books_by_parent
760 "SP": (1, "szkoła podstawowa"),
761 "SP1": (1, "szkoła podstawowa"),
762 "SP2": (1, "szkoła podstawowa"),
763 "SP3": (1, "szkoła podstawowa"),
764 "P": (1, "szkoła podstawowa"),
765 "G": (2, "gimnazjum"),
770 def audiences_pl(self):
771 audiences = self.get_extra_info_json().get('audiences', [])
772 audiences = sorted(set([self._audiences_pl.get(a, (99, a)) for a in audiences]))
773 return [a[1] for a in audiences]
775 def stage_note(self):
776 stage = self.get_extra_info_json().get('stage')
777 if stage and stage < '0.4':
778 return (_('This work needs modernisation'),
779 reverse('infopage', args=['wymagajace-uwspolczesnienia']))
783 def choose_fragment(self):
784 fragments = self.fragments.order_by()
785 fragments_count = fragments.count()
786 if not fragments_count and self.children.exists():
787 fragments = Fragment.objects.filter(book__ancestor=self).order_by()
788 fragments_count = fragments.count()
790 return fragments[randint(0, fragments_count - 1)]
792 return self.parent.choose_fragment()
796 def fragment_data(self):
797 fragment = self.choose_fragment()
800 'title': fragment.book.pretty_title(),
801 'html': re.sub('</?blockquote[^>]*>', '', fragment.get_short_text()),
806 def update_popularity(self):
807 count = self.tags.filter(category='set').values('user').order_by('user').distinct().count()
809 pop = self.popularity
812 except BookPopularity.DoesNotExist:
813 BookPopularity.objects.create(book=self, count=count)
815 def ridero_link(self):
816 return 'https://ridero.eu/%s/books/wl_%s/' % (get_language(), self.slug.replace('-', '_'))
818 def like(self, user):
819 from social.utils import likes, get_set, set_sets
820 if not likes(user, self):
821 tag = get_set(user, '')
822 set_sets(user, self, [tag])
824 def unlike(self, user):
825 from social.utils import likes, set_sets
826 if likes(user, self):
827 set_sets(user, self, [])
829 def full_sort_key(self):
830 return self.SORT_KEY_SEP.join((self.sort_key_author, self.sort_key, str(self.id)))
832 def cover_color(self):
833 return WLCover.epoch_colors.get(self.get_extra_info_json().get('epoch'), '#000000')
835 @cached_render('catalogue/book_mini_box.html')
841 @cached_render('catalogue/book_mini_box.html')
842 def mini_box_nolink(self):
848 def add_file_fields():
849 for format_ in Book.formats:
850 field_name = "%s_file" % format_
851 # This weird globals() assignment makes Django migrations comfortable.
852 _upload_to = _ebook_upload_to('book/%s/%%s.%s' % (format_, format_))
853 _upload_to.__name__ = '_%s_upload_to' % format_
854 globals()[_upload_to.__name__] = _upload_to
857 format_, _("%s file" % format_.upper()),
858 upload_to=_upload_to,
859 storage=bofh_storage,
863 ).contribute_to_class(Book, field_name)
865 models.CharField(max_length=255, editable=False, default='', db_index=True).contribute_to_class(Book, f'{field_name}_etag')
871 class BookPopularity(models.Model):
872 book = models.OneToOneField(Book, models.CASCADE, related_name='popularity')
873 count = models.IntegerField(default=0, db_index=True)