diff --git a/admin.py b/admin.py
index 877c359..1acc042 100644
--- a/admin.py
+++ b/admin.py
@@ -1,35 +1,94 @@
from django.contrib import admin
-from plugins.books.models import *
+import plugins.books.models as models
+@admin.register(models.BookSetting)
class BookSettingAdmin(admin.ModelAdmin):
list_display = ('book_page_title',)
+class ContributorLinkInline(admin.TabularInline):
+ model = models.ContributorLink
+ extra = 1
+ raw_id_fields = ('contributor',)
+
+
+@admin.register(models.Chapter)
class ChapterAdmin(admin.ModelAdmin):
- """Displays objects in the Django admin interface."""
list_display = ('title', 'number', 'book', 'pages', 'doi')
list_filter = ('book',)
- search_fields = ('title',)
+ search_fields = ('title', 'book__title', 'doi')
raw_id_fields = ('book',)
- filter_horizontal = ('contributors',)
+ inlines = [ContributorLinkInline]
+@admin.register(models.BookAccess)
class BookAccessAdmin(admin.ModelAdmin):
list_display = ('book', 'chapter', 'type', 'format', 'country', 'accessed')
+ list_filter = ('book', 'type', 'format', 'country')
+ search_fields = ('book__title', 'chapter__title')
+
+
+@admin.register(models.Book)
+class BookAdmin(admin.ModelAdmin):
+ list_display = ('title', 'publisher_name', 'date_published', 'is_open_access')
+ list_filter = ('is_open_access', 'peer_reviewed', 'date_published', 'categories')
+ search_fields = ('title', 'subtitle', 'publisher_name', 'isbn', 'doi')
+ filter_horizontal = ('keywords', 'publisher_notes', 'linked_repository_objects', 'categories')
+
+
+@admin.register(models.Contributor)
+class ContributorAdmin(admin.ModelAdmin):
+ list_display = ('last_name', 'first_name', 'affiliation')
+ search_fields = ('first_name', 'last_name', 'affiliation', 'email')
+
+
+@admin.register(models.ContributorLink)
+class ContributorLinkAdmin(admin.ModelAdmin):
+ list_display = ('contributor', 'book', 'chapter', 'order')
+ list_filter = ('book', 'chapter')
+ raw_id_fields = ('contributor', 'book', 'chapter')
+
+
+@admin.register(models.Format)
+class FormatAdmin(admin.ModelAdmin):
+ list_display = ('title', 'book', 'sequence')
list_filter = ('book',)
- search_fields = ('book__title',)
+ search_fields = ('title', 'filename')
+ raw_id_fields = ('book',)
+
+
+@admin.register(models.Category)
+class CategoryAdmin(admin.ModelAdmin):
+ list_display = ('name', 'slug', 'display_title')
+ search_fields = ('name', 'slug')
+
+
+@admin.register(models.BookPreprint)
+class BookPreprintAdmin(admin.ModelAdmin):
+ list_display = ('book', 'preprint', 'order')
+ list_filter = ('book',)
+ raw_id_fields = ('book', 'preprint')
+
+
+@admin.register(models.KeywordBook)
+class KeywordBookAdmin(admin.ModelAdmin):
+ list_display = ('keyword', 'book', 'order')
+ list_filter = ('book',)
+ search_fields = ('keyword__word',)
+ raw_id_fields = ('keyword', 'book')
+
+@admin.register(models.KeywordChapter)
+class KeywordChapterAdmin(admin.ModelAdmin):
+ list_display = ('keyword', 'chapter', 'order')
+ list_filter = ('chapter',)
+ search_fields = ('keyword__word',)
+ raw_id_fields = ('keyword', 'chapter')
-admin_list = [
- (Book, ),
- (Contributor,),
- (Format,),
- (BookAccess, BookAccessAdmin),
- (Chapter, ChapterAdmin),
- (Category,),
- (BookSetting, BookSettingAdmin)
-]
-[admin.site.register(*t) for t in admin_list]
+@admin.register(models.PublisherNote)
+class PublisherNoteAdmin(admin.ModelAdmin):
+ list_display = ('note',)
+ search_fields = ('note',)
diff --git a/files.py b/files.py
index 0802163..9a08a90 100644
--- a/files.py
+++ b/files.py
@@ -58,28 +58,26 @@ def serve_book_file(book_format):
raise Http404
-def server_chapter_file(book_chapter):
+def serve_chapter_format_file(chapter_format):
file_path = os.path.join(
settings.BASE_DIR,
'files',
'press',
'books',
- book_chapter.filename,
+ chapter_format.filename,
)
if os.path.isfile(file_path):
- filename, extension = os.path.splitext(book_chapter.filename)
+ filename, extension = os.path.splitext(chapter_format.filename)
response = StreamingHttpResponse(
FileWrapper(open(file_path, 'rb'), 8192),
- content_type=files.guess_mime(book_chapter.filename),
+ content_type=files.guess_mime(chapter_format.filename),
)
response['Content-Length'] = os.path.getsize(file_path)
- response['Content-Disposition'] = 'attachment;' \
- ' filename="{0}{1}"'.format(
- slugify(book_chapter.title),
- extension
+ response['Content-Disposition'] = 'attachment; filename="{0}{1}"'.format(
+ slugify(chapter_format.chapter.title),
+ extension,
)
-
return response
else:
raise Http404
diff --git a/forms.py b/forms.py
index 2b00376..481d573 100644
--- a/forms.py
+++ b/forms.py
@@ -6,6 +6,7 @@
from django_summernote.widgets import SummernoteWidget
from plugins.books import models, files
+from repository import models as repository_models
class DateInput(forms.DateInput):
@@ -44,9 +45,10 @@ class BookForm(forms.ModelForm):
class Meta:
model = models.Book
- exclude = ('keywords', 'publisher_notes')
+ exclude = ('keywords', 'publisher_notes', 'linked_repository_objects', 'contributors')
widgets = {
'description': SummernoteWidget(),
+ 'notes': SummernoteWidget(),
'date_published': DateInput(),
'date_embargo': DateInput(),
}
@@ -54,21 +56,33 @@ class Meta:
class ContributorForm(forms.ModelForm):
- def __init__(self, *args, **kwargs):
- book = kwargs.pop('book', None)
- super(ContributorForm, self).__init__(*args, **kwargs)
-
- self.fields['sequence'].initial = book.get_next_contributor_sequence()
-
class Meta:
model = models.Contributor
- exclude = ('book',)
+ fields = (
+ 'is_corporate',
+ 'corporate_name',
+ 'first_name',
+ 'middle_name',
+ 'last_name',
+ 'affiliation',
+ 'email',
+ 'bio',
+ 'headshot',
+ )
+ widgets = {
+ 'bio': SummernoteWidget(),
+ }
class FormatForm(forms.ModelForm):
file = forms.FileField()
+ def __init__(self, *args, **kwargs):
+ super(FormatForm, self).__init__(*args, **kwargs)
+ if self.instance and self.instance.pk and self.instance.filename:
+ self.fields['file'].required = False
+
class Meta:
model = models.Format
exclude = ('book', 'filename')
@@ -76,8 +90,10 @@ class Meta:
def save(self, commit=True, *args, **kwargs):
save_format = super(FormatForm, self).save(commit=False)
file = self.cleaned_data["file"]
- filename = files.save_file_to_disk(file, save_format)
- save_format.filename = filename
+
+ if file:
+ filename = files.save_file_to_disk(file, save_format)
+ save_format.filename = filename
if commit:
save_format.save()
@@ -90,15 +106,56 @@ def clean(self):
return cleaned_data
+class ChapterFormatForm(forms.ModelForm):
+
+ file = forms.FileField()
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ if self.instance and self.instance.pk and self.instance.filename:
+ self.fields['file'].required = False
+
+ class Meta:
+ model = models.ChapterFormat
+ exclude = ('chapter', 'filename')
+
+ def save(self, commit=True, *args, **kwargs):
+ chapter_format = super().save(commit=False)
+ file = self.cleaned_data.get('file')
+
+ if file:
+ filename = files.save_file_to_disk(file, chapter_format)
+ chapter_format.filename = filename
+
+ if commit:
+ chapter_format.save()
+
+ return chapter_format
+
+
class ChapterForm(forms.ModelForm):
+ contributors = forms.ModelMultipleChoiceField(
+ queryset=models.Contributor.objects.none(),
+ required=False,
+ )
+
def __init__(self, *args, **kwargs):
items = kwargs.pop('items', None)
super(ChapterForm, self).__init__(*args, **kwargs)
self.fields['contributors'].widget = TableMultiSelect(items=items)
- self.fields['contributors'].required = False
-
- file = forms.FileField(required=False)
+ # Set queryset from items
+ pks = [row.get('object').pk for row in items if row.get('object')]
+ self.fields['contributors'].queryset = models.Contributor.objects.filter(
+ pk__in=pks,
+ )
+ # Set initial from existing ContributorLinks
+ if self.instance and self.instance.pk:
+ self.fields['contributors'].initial = list(
+ models.ContributorLink.objects.filter(
+ chapter=self.instance,
+ ).values_list('contributor_id', flat=True)
+ )
class Meta:
model = models.Chapter
@@ -111,7 +168,6 @@ class Meta:
'date_embargo',
'date_published',
'sequence',
- 'contributors',
'license_information',
'custom_how_to_cite',
]
@@ -122,16 +178,39 @@ def save(self, commit=True, book=None, *args, **kwargs):
if book:
save_chapter.book = book
- file = self.cleaned_data["file"]
- if file:
- filename = files.save_file_to_disk(file, save_chapter)
- save_chapter.filename = filename
-
if commit:
save_chapter.save()
return save_chapter
+ def save_chapter_contributors(self, chapter):
+ selected_contributors = self.cleaned_data.get('contributors', [])
+ existing_links = models.ContributorLink.objects.filter(chapter=chapter)
+
+ # Remove links for contributors no longer selected
+ existing_links.exclude(
+ contributor__in=selected_contributors,
+ ).delete()
+
+ # Add links for newly selected contributors
+ existing_contributor_ids = set(
+ existing_links.values_list('contributor_id', flat=True)
+ )
+ next_order = (
+ existing_links.order_by('-order').values_list(
+ 'order', flat=True,
+ ).first() or 0
+ ) + 1
+
+ for contributor in selected_contributors:
+ if contributor.pk not in existing_contributor_ids:
+ models.ContributorLink.objects.create(
+ contributor=contributor,
+ chapter=chapter,
+ order=next_order,
+ )
+ next_order += 1
+
class DateForm(forms.Form):
start_date = forms.DateField(widget=DateInput())
@@ -166,3 +245,17 @@ def save(self, commit=True):
save_category.save()
return save_category
+
+
+class PreprintSelectionForm(forms.Form):
+ preprint_id = forms.ModelChoiceField(
+ queryset=repository_models.Preprint.objects.none(),
+ label="Select a Preprint",
+ required=True,
+ )
+
+ def __init__(self, *args, **kwargs):
+ available_preprints = kwargs.pop('available_preprints', None)
+ super().__init__(*args, **kwargs)
+ if available_preprints is not None:
+ self.fields['preprint_id'].queryset = available_preprints
\ No newline at end of file
diff --git a/hooks.py b/hooks.py
index 9cc57f0..c6a2c0c 100644
--- a/hooks.py
+++ b/hooks.py
@@ -1,10 +1,35 @@
from django.urls import reverse
+from django.template.loader import render_to_string
+from django.utils.safestring import mark_safe
from utils.function_cache import cache
+
@cache(600)
def nav_hook(context):
return '
Books '.format(
url=reverse('books_admin')
)
+
+
+def linked_books(context):
+ request = context.get("request")
+ preprint = context.get("preprint")
+
+ if not preprint or not hasattr(preprint, "get_linked_books"):
+ return ""
+
+ theme = getattr(preprint.repository, "theme", "OLH")
+ template_path = f"books/{theme}/repository_linked_books.html"
+
+ books = preprint.get_linked_books()
+
+ return mark_safe(render_to_string(
+ template_path,
+ {
+ "preprint": preprint,
+ "linked_books": books,
+ },
+ request=request,
+ ))
\ No newline at end of file
diff --git a/logic.py b/logic.py
index 036d080..67f1812 100644
--- a/logic.py
+++ b/logic.py
@@ -1,3 +1,5 @@
+import json
+
from plugins.books import models
from datetime import date, timedelta, datetime
from dateutil.relativedelta import relativedelta
@@ -151,10 +153,42 @@ def book_metrics_by_month(books, date_parts):
return data, dates, current_year, previous_year
-def get_chapter_contributor_items(book):
- contributors = models.Contributor.objects.filter(
- book=book,
+def swap_order(item, direction, queryset, order_field='order'):
+ """Swap an item's order with its neighbour in an ordered queryset."""
+ # Normalize orders to be sequential
+ for index, obj in enumerate(queryset):
+ if getattr(obj, order_field) != index:
+ setattr(obj, order_field, index)
+ obj.save()
+ item.refresh_from_db()
+
+ current_order = getattr(item, order_field)
+
+ if direction == 'up':
+ neighbour = queryset.filter(**{order_field: current_order - 1}).first()
+ if neighbour:
+ setattr(neighbour, order_field, current_order)
+ neighbour.save()
+ setattr(item, order_field, current_order - 1)
+ item.save()
+ elif direction == 'down':
+ neighbour = queryset.filter(**{order_field: current_order + 1}).first()
+ if neighbour:
+ setattr(neighbour, order_field, current_order)
+ neighbour.save()
+ setattr(item, order_field, current_order + 1)
+ item.save()
+
+
+def trigger_message(name, direction):
+ """Build a JSON HX-Trigger-After-Swap header value with proper escaping."""
+ return json.dumps(
+ {"showMessage": {"value": "{} moved {}.".format(name, direction)}}
)
+
+
+def get_chapter_contributor_items(book):
+ contributors = book.contributors.all()
items = list()
items.append({'object': None, 'cells': ['First Name', 'Last Name', 'Email']})
diff --git a/management/__init__.py b/management/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/management/commands/__init__.py b/management/commands/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/management/commands/generate_random_books.py b/management/commands/generate_random_books.py
new file mode 100644
index 0000000..c35cce1
--- /dev/null
+++ b/management/commands/generate_random_books.py
@@ -0,0 +1,152 @@
+import random
+import string
+import os
+from django.core.management.base import BaseCommand
+from django.utils import timezone
+from django.conf import settings
+from faker import Faker
+import svgwrite
+
+from plugins.books import models as book_models
+
+fake = Faker()
+
+# Define the SVG cover creation path
+COVERS_DIR = os.path.join(settings.MEDIA_ROOT, 'cover_images')
+
+
+class Command(BaseCommand):
+ help = 'Generate a specified number of random Book instances with contributors and chapters.'
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ 'num_books',
+ type=int,
+ help='The number of books to generate.',
+ )
+
+ def handle(self, *args, **kwargs):
+ num_books = kwargs['num_books']
+ categories = book_models.Category.objects.all()
+ if not categories:
+ self.stdout.write(self.style.ERROR(
+ "No categories available. Please add categories first."))
+ return
+
+ # Ensure the cover images directory exists
+ os.makedirs(COVERS_DIR, exist_ok=True)
+
+ books_created = []
+ for _ in range(num_books):
+ category = random.choice(categories)
+ title = fake.sentence(nb_words=3).title()
+ subtitle = fake.sentence(nb_words=5).title() if random.choice(
+ [True, False]) else None
+ publisher_name = fake.company()
+ publisher_loc = fake.city()
+
+ # Generate SVG cover
+ cover_filename = f"{title.replace(' ', '_')}_{random.randint(1000, 9999)}.svg"
+ cover_path = os.path.join(COVERS_DIR, cover_filename)
+ self.create_svg_cover(title, cover_path)
+
+ book = book_models.Book.objects.create(
+ title=title,
+ subtitle=subtitle,
+ description=fake.text(max_nb_chars=200),
+ pages=random.randint(100, 500),
+ is_edited_volume=random.choice([True, False]),
+ is_open_access=random.choice([True, False]),
+ date_published=fake.date_this_century(),
+ publisher_name=publisher_name,
+ publisher_loc=publisher_loc,
+ doi=f'10.{random.randint(1000, 9999)}/{random.randint(1000000, 9999999)}',
+ isbn=''.join(random.choices(string.digits, k=13)),
+ purchase_url=fake.url(),
+ license_information=fake.sentence(nb_words=10),
+ cover=f'cover_images/{cover_filename}',
+ )
+ if category:
+ book.categories.add(category)
+
+ # Add random contributors
+ num_contributors = random.randint(2, 10)
+ self.create_contributors(book, num_contributors)
+
+ # Add random chapters
+ num_chapters = random.randint(5, 15)
+ self.create_chapters(book, num_chapters)
+
+ books_created.append(book)
+
+ self.stdout.write(
+ self.style.SUCCESS(
+ f'Successfully created {len(books_created)} books with contributors and chapters.')
+ )
+
+ def create_svg_cover(self, title, filepath):
+ # Create an SVG drawing with a black background and the title in white
+ svg = svgwrite.Drawing(filepath, size=("200px", "300px"))
+ svg.add(svg.rect(insert=(0, 0), size=("100%", "100%"), fill="black"))
+
+ # Add title text to the SVG, centered
+ svg.add(
+ svg.text(
+ title,
+ insert=("50%", "50%"),
+ text_anchor="middle",
+ alignment_baseline="middle",
+ fill="white",
+ font_size="20px",
+ font_family="Arial",
+ )
+ )
+ svg.save()
+
+ def create_contributors(self, book, num_contributors):
+ for order in range(1, num_contributors + 1):
+ first_name = fake.first_name()
+ last_name = fake.last_name()
+ middle_name = fake.first_name() if random.choice(
+ [True, False]) else None
+
+ contributor = book_models.Contributor.objects.create(
+ first_name=first_name,
+ middle_name=middle_name,
+ last_name=last_name,
+ affiliation=fake.company(),
+ email=fake.email(),
+ )
+ book_models.ContributorLink.objects.create(
+ contributor=contributor,
+ book=book,
+ order=order,
+ )
+
+ def create_chapters(self, book, num_chapters):
+ for sequence in range(1, num_chapters + 1):
+ title = fake.sentence(nb_words=5).title()
+ description = fake.text(max_nb_chars=200)
+ doi = f'10.{random.randint(1000, 9999)}/{random.randint(100000, 999999)}'
+ pages = random.randint(5, 20)
+
+ chapter = book_models.Chapter.objects.create(
+ book=book,
+ title=title,
+ description=description,
+ pages=pages,
+ doi=doi,
+ number=str(sequence),
+ date_published=fake.date_this_century(),
+ sequence=sequence,
+ )
+
+ # Add contributors to each chapter
+ num_contributors = random.randint(1, 3)
+ chapter_contributors = book.contributors.order_by('?')[:num_contributors]
+ for order, contributor in enumerate(chapter_contributors, start=1):
+ book_models.ContributorLink.objects.create(
+ contributor=contributor,
+ chapter=chapter,
+ order=order,
+ )
diff --git a/migrations/0021_alter_bookaccess_chapter_alter_bookaccess_country_and_more.py b/migrations/0021_alter_bookaccess_chapter_alter_bookaccess_country_and_more.py
new file mode 100644
index 0000000..f9fcc2c
--- /dev/null
+++ b/migrations/0021_alter_bookaccess_chapter_alter_bookaccess_country_and_more.py
@@ -0,0 +1,48 @@
+# Generated by Django 4.2.15 on 2024-11-06 14:56
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('core', '0099_alter_accountrole_options'),
+ ('repository', '0045_historicalrepository_display_public_metrics_and_more'),
+ ('books', '0020_auto_20220823_0931'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='bookaccess',
+ name='chapter',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='books.chapter'),
+ ),
+ migrations.AlterField(
+ model_name='bookaccess',
+ name='country',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='core.country'),
+ ),
+ migrations.AlterField(
+ model_name='bookaccess',
+ name='format',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='books.format'),
+ ),
+ migrations.CreateModel(
+ name='BookPreprint',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('order', models.IntegerField(default=0)),
+ ('book', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='books.book')),
+ ('preprint', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='repository.preprint')),
+ ],
+ options={
+ 'ordering': ['order'],
+ },
+ ),
+ migrations.AddField(
+ model_name='book',
+ name='linked_repository_objects',
+ field=models.ManyToManyField(blank=True, help_text='Repository objects linked to this book.', through='books.BookPreprint', to='repository.preprint'),
+ ),
+ ]
diff --git a/migrations/0022_book_peer_reviewed_booksetting_display_review_status.py b/migrations/0022_book_peer_reviewed_booksetting_display_review_status.py
new file mode 100644
index 0000000..28ff323
--- /dev/null
+++ b/migrations/0022_book_peer_reviewed_booksetting_display_review_status.py
@@ -0,0 +1,23 @@
+# Generated by Django 4.2.15 on 2024-11-13 15:16
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('books', '0021_alter_bookaccess_chapter_alter_bookaccess_country_and_more'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='book',
+ name='peer_reviewed',
+ field=models.BooleanField(default=False, help_text='Mark if this book has been peer reviewed.'),
+ ),
+ migrations.AddField(
+ model_name='booksetting',
+ name='display_review_status',
+ field=models.BooleanField(default=False, help_text="Mark this to display a book's review status."),
+ ),
+ ]
diff --git a/migrations/0023_book_edition.py b/migrations/0023_book_edition.py
new file mode 100644
index 0000000..4da6e2c
--- /dev/null
+++ b/migrations/0023_book_edition.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.2.20 on 2025-07-22 12:27
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('books', '0022_book_peer_reviewed_booksetting_display_review_status'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='book',
+ name='edition',
+ field=models.CharField(blank=True, null=True),
+ ),
+ ]
diff --git a/migrations/0024_format_display_read_link.py b/migrations/0024_format_display_read_link.py
new file mode 100644
index 0000000..1c50fb7
--- /dev/null
+++ b/migrations/0024_format_display_read_link.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.2.20 on 2025-07-22 14:14
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('books', '0023_book_edition'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='format',
+ name='display_read_link',
+ field=models.BooleanField(default=True, help_text='For EPUB only, check if you want a Read this Book link'),
+ ),
+ ]
diff --git a/migrations/0025_contributorlink_and_book_contributors.py b/migrations/0025_contributorlink_and_book_contributors.py
new file mode 100644
index 0000000..3c46a02
--- /dev/null
+++ b/migrations/0025_contributorlink_and_book_contributors.py
@@ -0,0 +1,37 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+import core.model_utils
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('books', '0024_format_display_read_link'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='ContributorLink',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('order', models.PositiveIntegerField(default=1)),
+ ('contributor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='books.contributor')),
+ ('book', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='books.book')),
+ ('chapter', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='books.chapter')),
+ ],
+ options={
+ 'ordering': ['order'],
+ },
+ ),
+ migrations.AddField(
+ model_name='book',
+ name='contributors',
+ field=core.model_utils.M2MOrderedThroughField(
+ blank=True,
+ through='books.ContributorLink',
+ through_fields=('book', 'contributor'),
+ to='books.contributor',
+ ),
+ ),
+ ]
diff --git a/migrations/0026_migrate_contributor_data.py b/migrations/0026_migrate_contributor_data.py
new file mode 100644
index 0000000..7be93ed
--- /dev/null
+++ b/migrations/0026_migrate_contributor_data.py
@@ -0,0 +1,54 @@
+from django.db import migrations
+
+
+def forwards(apps, schema_editor):
+ Contributor = apps.get_model('books', 'Contributor')
+ ContributorLink = apps.get_model('books', 'ContributorLink')
+ Chapter = apps.get_model('books', 'Chapter')
+
+ # Book contributors: copy FK relationship into ContributorLink
+ for contributor in Contributor.objects.all():
+ ContributorLink.objects.create(
+ contributor=contributor,
+ book=contributor.book,
+ order=contributor.sequence,
+ )
+
+ # Chapter contributors: copy old M2M into ContributorLink
+ for chapter in Chapter.objects.all():
+ for order, contributor in enumerate(
+ chapter.contributors.all(), start=1
+ ):
+ # Use get_or_create to avoid duplicates if a contributor
+ # is linked to both a book and a chapter
+ ContributorLink.objects.get_or_create(
+ contributor=contributor,
+ chapter=chapter,
+ defaults={'order': order},
+ )
+
+
+def backwards(apps, schema_editor):
+ ContributorLink = apps.get_model('books', 'ContributorLink')
+ Contributor = apps.get_model('books', 'Contributor')
+
+ # Restore Contributor.book FK from ContributorLink book entries
+ for link in ContributorLink.objects.filter(book__isnull=False):
+ contributor = link.contributor
+ contributor.book = link.book
+ contributor.sequence = link.order
+ contributor.save()
+
+ # Chapter M2M will be restored automatically when the field
+ # is reverted to a plain M2M in the reverse schema migration
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('books', '0025_contributorlink_and_book_contributors'),
+ ]
+
+ operations = [
+ migrations.RunPython(forwards, backwards),
+ ]
diff --git a/migrations/0027_alter_chapter_contributors_remove_contributor_fk.py b/migrations/0027_alter_chapter_contributors_remove_contributor_fk.py
new file mode 100644
index 0000000..77d2ee4
--- /dev/null
+++ b/migrations/0027_alter_chapter_contributors_remove_contributor_fk.py
@@ -0,0 +1,41 @@
+from django.db import migrations, models
+
+import core.model_utils
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('books', '0026_migrate_contributor_data'),
+ ]
+
+ operations = [
+ # Cannot AlterField on M2M to add through=, so remove and re-add.
+ migrations.RemoveField(
+ model_name='chapter',
+ name='contributors',
+ ),
+ migrations.AddField(
+ model_name='chapter',
+ name='contributors',
+ field=core.model_utils.M2MOrderedThroughField(
+ blank=True,
+ through='books.ContributorLink',
+ through_fields=('chapter', 'contributor'),
+ to='books.contributor',
+ related_name='chapter_contributors',
+ ),
+ ),
+ migrations.RemoveField(
+ model_name='contributor',
+ name='book',
+ ),
+ migrations.RemoveField(
+ model_name='contributor',
+ name='sequence',
+ ),
+ migrations.AlterModelOptions(
+ name='contributor',
+ options={'ordering': ('last_name', 'first_name')},
+ ),
+ ]
diff --git a/migrations/0028_book_language_and_notes.py b/migrations/0028_book_language_and_notes.py
new file mode 100644
index 0000000..c0f20a1
--- /dev/null
+++ b/migrations/0028_book_language_and_notes.py
@@ -0,0 +1,23 @@
+# Generated by Django 4.2.20 on 2026-03-13 15:37
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('books', '0027_alter_chapter_contributors_remove_contributor_fk'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='book',
+ name='language',
+ field=models.CharField(blank=True, max_length=100, null=True),
+ ),
+ migrations.AddField(
+ model_name='book',
+ name='notes',
+ field=models.TextField(blank=True, help_text='Additional notes displayed below the description.', null=True),
+ ),
+ ]
diff --git a/migrations/0029_contributor_bio_and_headshot.py b/migrations/0029_contributor_bio_and_headshot.py
new file mode 100644
index 0000000..ec6ed43
--- /dev/null
+++ b/migrations/0029_contributor_bio_and_headshot.py
@@ -0,0 +1,25 @@
+# Generated by Django 4.2.20 on 2026-03-13 16:52
+
+import core.file_system
+from django.db import migrations, models
+import plugins.books.models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('books', '0028_book_language_and_notes'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='contributor',
+ name='bio',
+ field=models.TextField(blank=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='contributor',
+ name='headshot',
+ field=models.FileField(blank=True, null=True, storage=core.file_system.JanewayFileSystemStorage(), upload_to=plugins.books.models.headshot_upload_path),
+ ),
+ ]
diff --git a/migrations/0030_contributor_corporate_author.py b/migrations/0030_contributor_corporate_author.py
new file mode 100644
index 0000000..4404156
--- /dev/null
+++ b/migrations/0030_contributor_corporate_author.py
@@ -0,0 +1,38 @@
+# Generated by Django 4.2.20 on 2026-03-13 17:04
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('books', '0029_contributor_bio_and_headshot'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='contributor',
+ name='corporate_name',
+ field=models.CharField(blank=True, help_text='Full name of the organisation (used when Is Corporate is checked).', max_length=255, null=True),
+ ),
+ migrations.AddField(
+ model_name='contributor',
+ name='is_corporate',
+ field=models.BooleanField(default=False, help_text='Check if this contributor is a corporate/organisational author.'),
+ ),
+ migrations.AlterField(
+ model_name='contributor',
+ name='affiliation',
+ field=models.TextField(blank=True, null=True),
+ ),
+ migrations.AlterField(
+ model_name='contributor',
+ name='first_name',
+ field=models.CharField(blank=True, max_length=100, null=True),
+ ),
+ migrations.AlterField(
+ model_name='contributor',
+ name='last_name',
+ field=models.CharField(blank=True, max_length=100, null=True),
+ ),
+ ]
diff --git a/migrations/0031_book_categories_m2m.py b/migrations/0031_book_categories_m2m.py
new file mode 100644
index 0000000..6fcd160
--- /dev/null
+++ b/migrations/0031_book_categories_m2m.py
@@ -0,0 +1,32 @@
+# Generated by Django 4.2.20 on 2026-03-13 20:43
+
+from django.db import migrations, models
+
+
+def migrate_category_to_categories(apps, schema_editor):
+ Book = apps.get_model('books', 'Book')
+ for book in Book.objects.filter(category__isnull=False):
+ book.categories.add(book.category)
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('books', '0030_contributor_corporate_author'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='book',
+ name='categories',
+ field=models.ManyToManyField(blank=True, to='books.category'),
+ ),
+ migrations.RunPython(
+ migrate_category_to_categories,
+ migrations.RunPython.noop,
+ ),
+ migrations.RemoveField(
+ model_name='book',
+ name='category',
+ ),
+ ]
diff --git a/migrations/0032_chapter_format.py b/migrations/0032_chapter_format.py
new file mode 100644
index 0000000..562171e
--- /dev/null
+++ b/migrations/0032_chapter_format.py
@@ -0,0 +1,42 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+def migrate_chapter_filename(apps, schema_editor):
+ Chapter = apps.get_model('books', 'Chapter')
+ ChapterFormat = apps.get_model('books', 'ChapterFormat')
+ for chapter in Chapter.objects.exclude(filename='').exclude(filename__isnull=True):
+ ChapterFormat.objects.create(
+ chapter=chapter,
+ title='Download',
+ filename=chapter.filename,
+ sequence=10,
+ )
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('books', '0031_book_categories_m2m'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='ChapterFormat',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('title', models.CharField(max_length=100)),
+ ('filename', models.CharField(max_length=100)),
+ ('sequence', models.PositiveIntegerField(default=10)),
+ ('chapter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='books.chapter')),
+ ],
+ options={
+ 'ordering': ('sequence',),
+ },
+ ),
+ migrations.RunPython(migrate_chapter_filename, migrations.RunPython.noop),
+ migrations.RemoveField(
+ model_name='chapter',
+ name='filename',
+ ),
+ ]
diff --git a/models.py b/models.py
index 7b68f86..0846717 100644
--- a/models.py
+++ b/models.py
@@ -8,11 +8,10 @@
from user_agents import parse as parse_ua_string
from django.db import models
-from django.conf import settings
-from django.core.files.storage import FileSystemStorage
from django.core.files.images import get_image_dimensions
from django.utils import timezone
from django.core.exceptions import ValidationError
+from django.urls import reverse
from core import models as core_models
from core.file_system import JanewayFileSystemStorage
@@ -20,11 +19,20 @@
from metrics.logic import get_iso_country_code
from utils.shared import get_ip_address
from plugins.books import files
+from press import models as press_models
fs = JanewayFileSystemStorage()
+def headshot_upload_path(instance, filename):
+ try:
+ filename = str(uuid.uuid4()) + '.' + str(filename.split('.')[1])
+ except IndexError:
+ filename = str(uuid.uuid4())
+ return os.path.join("contributor_headshots/", filename)
+
+
def cover_images_upload_path(instance, filename):
try:
filename = str(uuid.uuid4()) + '.' + str(filename.split('.')[1])
@@ -40,6 +48,10 @@ class BookSetting(models.Model):
max_length=255,
default="Published Books",
)
+ display_review_status = models.BooleanField(
+ default=False,
+ help_text="Mark this to display a book's review status."
+ )
def save(self, *args, **kwargs):
if not self.pk and BookSetting.objects.exists():
@@ -82,11 +94,9 @@ class Book(models.Model):
prefix = models.CharField(max_length=20, blank=True, null=True)
title = models.CharField(max_length=300)
subtitle = models.CharField(max_length=300, blank=True, null=True)
- category = models.ForeignKey(
+ categories = models.ManyToManyField(
Category,
blank=True,
- null=True,
- on_delete=models.SET_NULL,
)
description = models.TextField(null=True, blank=True)
pages = models.PositiveIntegerField(null=True, blank=True)
@@ -143,11 +153,41 @@ class Book(models.Model):
help_text='Free-text public publisher notes regarding this book',
)
+ language = models.CharField(
+ max_length=100,
+ blank=True,
+ null=True,
+ )
+ notes = models.TextField(
+ blank=True,
+ null=True,
+ help_text='Additional notes displayed below the description.',
+ )
custom_how_to_cite = models.TextField(
blank=True, null=True,
help_text="Custom 'how to cite' text. To be used only if the block"
" generated by Janeway is not suitable.",
)
+ linked_repository_objects = models.ManyToManyField(
+ 'repository.Preprint',
+ help_text='Repository objects linked to this book.',
+ blank=True,
+ through='BookPreprint'
+ )
+ peer_reviewed = models.BooleanField(
+ default=False,
+ help_text='Mark if this book has been peer reviewed.',
+ )
+ edition = models.CharField(
+ blank=True,
+ null=True,
+ )
+ contributors = M2MOrderedThroughField(
+ 'books.Contributor',
+ through='books.ContributorLink',
+ through_fields=('book', 'contributor'),
+ blank=True,
+ )
def __str__(self):
return self.title
@@ -164,27 +204,20 @@ def citation(self):
)
def contributors_citation(self):
- contributors = self.contributor_set.all()
- contributors_count = contributors.count()
-
- if contributors_count == 0:
- return ''
-
- if contributors_count == 1:
- return '{contributor} '.format(
- contributor=contributors[0].citation_name(),
- )
-
- if contributors_count == 2:
- return '{one} & {two} '.format(
- one=contributors[0].citation_name(),
- two=contributors[1].citation_name(),
- )
-
- return '{contributor} et al. '.format(
- contributor=contributors[0].citation_name(),
- )
-
+ contributors = self.contributors.all()
+ if contributors:
+ if contributors.count() == 1:
+ return '{contributor} '.format(
+ contributor=contributors[0].citation_name()
+ )
+ elif contributors.count() == 2:
+ return '{contributor_one} & {contributor_two} '.format(
+ contributor_one=contributors[0].citation_name(),
+ contributor_two=contributors[1].citation_name(),
+ )
+ else:
+ return '{contributor} et al. '.format(contributor=contributors[0])
+ return ''
def full_title(self):
if self.prefix and self.subtitle:
@@ -197,18 +230,19 @@ def full_title(self):
return self.title
def first_contributor(self):
- contributors = self.contributor_set.all()
+ contributors = self.contributors.all()
if contributors:
return contributors[0]
else:
return 'No Authors'
- def get_next_contributor_sequence(self):
- if self.contributor_set.all():
- last_contributor = self.contributor_set.all().reverse()[0]
- return last_contributor.sequence + 1
- else:
- return 1
+ def get_next_contributor_order(self):
+ last_link = ContributorLink.objects.filter(
+ book=self,
+ ).order_by('-order').first()
+ if last_link:
+ return last_link.order + 1
+ return 1
def get_next_chapter_sequence(self):
chapter_sequences = [c.sequence for c in self.chapter_set.all()]
@@ -257,40 +291,103 @@ def remote_book_label(self):
urlparse(self.remote_url).netloc
)
+ @property
+ def press(self):
+ press = press_models.Press.objects.all().first()
+ return press
+
+ def url(self):
+ # Cross Repo/Press URLs are not renderable so we need to
+ # generate this manually. Bad practice but it gets the job done.
+ return "//{press_domain}/plugins/books/{book_id}".format(
+ press_domain=self.press.domain,
+ book_id=self.pk,
+ )
+
+
+class BookPreprint(models.Model):
+ book = models.ForeignKey(Book, on_delete=models.CASCADE)
+ preprint = models.ForeignKey('repository.Preprint', on_delete=models.CASCADE)
+ order = models.IntegerField(default=0)
+
+ class Meta:
+ ordering = ['order']
+
class Contributor(models.Model):
- book = models.ForeignKey(
- Book,
- on_delete=models.CASCADE,
+ is_corporate = models.BooleanField(
+ default=False,
+ help_text='Check if this contributor is a corporate/organisational author.',
)
- first_name = models.CharField(max_length=100)
+ corporate_name = models.CharField(
+ max_length=255,
+ blank=True,
+ null=True,
+ help_text='Full name of the organisation (used when Is Corporate is checked).',
+ )
+ first_name = models.CharField(max_length=100, blank=True, null=True)
middle_name = models.CharField(max_length=100, blank=True, null=True)
- last_name = models.CharField(max_length=100)
+ last_name = models.CharField(max_length=100, blank=True, null=True)
- affiliation = models.TextField()
+ affiliation = models.TextField(blank=True, null=True)
email = models.EmailField(blank=True, null=True)
- sequence = models.PositiveIntegerField(default=10)
+ bio = models.TextField(blank=True, null=True)
+ headshot = models.FileField(
+ upload_to=headshot_upload_path,
+ blank=True,
+ null=True,
+ storage=fs,
+ )
class Meta:
- ordering = ('sequence',)
+ ordering = ('last_name', 'first_name')
def __str__(self):
- if not self.middle_name:
- return "{0} {1}".format(self.first_name, self.last_name)
- else:
+ if self.is_corporate:
+ return self.corporate_name or ''
+ if self.middle_name:
return "{0} {1} {2}".format(self.first_name, self.middle_name, self.last_name)
+ return "{0} {1}".format(self.first_name, self.last_name)
def middle_initial(self):
if self.middle_name:
return '{middle_initial}.'.format(middle_initial=self.middle_name[0])
def citation_name(self):
+ if self.is_corporate:
+ return self.corporate_name or ''
return '{last_name} {first_initial}.'.format(
last_name=self.last_name,
first_initial=self.first_name[0],
)
+class ContributorLink(models.Model):
+ contributor = models.ForeignKey(
+ Contributor,
+ on_delete=models.CASCADE,
+ )
+ book = models.ForeignKey(
+ Book,
+ null=True,
+ blank=True,
+ on_delete=models.CASCADE,
+ )
+ chapter = models.ForeignKey(
+ 'Chapter',
+ null=True,
+ blank=True,
+ on_delete=models.CASCADE,
+ )
+ order = models.PositiveIntegerField(default=1)
+
+ class Meta:
+ ordering = ["order"]
+
+ def __str__(self):
+ return "{} - {}".format(self.contributor, self.order)
+
+
class Format(models.Model):
book = models.ForeignKey(
Book,
@@ -300,6 +397,10 @@ class Format(models.Model):
title = models.CharField(max_length=100)
filename = models.CharField(max_length=100)
sequence = models.PositiveIntegerField(default=10)
+ display_read_link = models.BooleanField(
+ default=True,
+ help_text="For EPUB only, check if you want a Read this Book link",
+ )
class Meta:
ordering = ('sequence',)
@@ -351,6 +452,22 @@ def add_book_access(self, request, access_type='download'):
)
+class ChapterFormat(models.Model):
+ chapter = models.ForeignKey(
+ 'Chapter',
+ on_delete=models.CASCADE,
+ )
+ title = models.CharField(max_length=100)
+ filename = models.CharField(max_length=100)
+ sequence = models.PositiveIntegerField(default=10)
+
+ class Meta:
+ ordering = ('sequence',)
+
+ def __str__(self):
+ return self.title
+
+
def access_choices():
return (
('download', 'Download'), # A download of any book format
@@ -427,14 +544,13 @@ class Chapter(models.Model):
sequence = models.PositiveIntegerField(
help_text='The order in which the chapters should appear.',
)
- contributors = models.ManyToManyField(
+ contributors = M2MOrderedThroughField(
Contributor,
- null=True,
+ blank=True,
+ through='books.ContributorLink',
+ through_fields=('chapter', 'contributor'),
related_name='chapter_contributors',
)
- filename = models.CharField(
- max_length=255,
- )
license_information = models.TextField(
blank=True,
null=True,
@@ -522,17 +638,19 @@ def citation(self):
def contributors_citation(self):
contributors = self.contributors.all()
- if contributors.count() == 1:
- return '{contributor} '.format(
- contributor=contributors[0].citation_name()
- )
- elif contributors.count() == 2:
- return '{contributor_one} & {contributor_two} '.format(
- contributor_one=contributors[0].citation_name(),
- contributor_two=contributors[1].citation_name(),
- )
- else:
- return '{contributor} et al. '.format(contributor=contributors[0])
+ if contributors:
+ if contributors.count() == 1:
+ return '{contributor} '.format(
+ contributor=contributors[0].citation_name()
+ )
+ elif contributors.count() == 2:
+ return '{contributor_one} & {contributor_two} '.format(
+ contributor_one=contributors[0].citation_name(),
+ contributor_two=contributors[1].citation_name(),
+ )
+ else:
+ return '{contributor} et al. '.format(contributor=contributors[0])
+ return ''
class KeywordBook(models.Model):
diff --git a/partial_views.py b/partial_views.py
new file mode 100644
index 0000000..89665d1
--- /dev/null
+++ b/partial_views.py
@@ -0,0 +1,154 @@
+from django.shortcuts import render, get_object_or_404
+from django.contrib.admin.views.decorators import staff_member_required
+from django.views.decorators.http import require_POST, require_GET
+
+from plugins.books import models, logic
+
+
+@require_GET
+@staff_member_required
+def contributor_name_fields(request):
+ is_corporate = request.GET.get('is_corporate') == 'on'
+ return render(
+ request,
+ 'books/partials/contributor_name_fields.html',
+ {'is_corporate': is_corporate},
+ )
+
+
+@require_POST
+@staff_member_required
+def move_preprint(request, book_id, book_preprint_id, direction):
+ book = get_object_or_404(models.Book, pk=book_id)
+ book_preprint = get_object_or_404(
+ models.BookPreprint, id=book_preprint_id, book=book,
+ )
+ queryset = models.BookPreprint.objects.filter(book=book).order_by('order')
+ logic.swap_order(book_preprint, direction, queryset)
+
+ linked_preprints = models.BookPreprint.objects.filter(
+ book=book,
+ ).order_by('order')
+ return render(
+ request,
+ 'books/partials/linked_preprints.html',
+ {'book': book, 'linked_preprints': linked_preprints},
+ )
+
+
+@require_POST
+@staff_member_required
+def remove_preprint(request, book_id, book_preprint_id):
+ book = get_object_or_404(models.Book, pk=book_id)
+ book_preprint = get_object_or_404(
+ models.BookPreprint,
+ id=book_preprint_id,
+ book=book,
+ )
+
+ book_preprint.delete()
+
+ linked_preprints = models.BookPreprint.objects.filter(
+ book=book
+ ).order_by('order')
+
+ return render(
+ request,
+ 'books/partials/linked_preprints.html',
+ {'book': book, 'linked_preprints': linked_preprints},
+ )
+
+
+@require_POST
+@staff_member_required
+def move_book_contributor(request, book_id, contributor_link_id, direction):
+ book = get_object_or_404(models.Book, pk=book_id)
+ link = get_object_or_404(
+ models.ContributorLink, id=contributor_link_id, book=book,
+ )
+ queryset = models.ContributorLink.objects.filter(
+ book=book,
+ ).order_by('order')
+ logic.swap_order(link, direction, queryset)
+
+ contributor_links = models.ContributorLink.objects.filter(
+ book=book,
+ ).order_by('order')
+ response = render(
+ request,
+ 'books/partials/book_contributors.html',
+ {'book': book, 'contributor_links': contributor_links},
+ )
+ response['HX-Trigger-After-Swap'] = logic.trigger_message(
+ link.contributor, direction,
+ )
+ return response
+
+
+@require_POST
+@staff_member_required
+def move_chapter_contributor(
+ request, book_id, chapter_id, contributor_link_id, direction,
+):
+ book = get_object_or_404(models.Book, pk=book_id)
+ chapter = get_object_or_404(models.Chapter, pk=chapter_id, book=book)
+ link = get_object_or_404(
+ models.ContributorLink, id=contributor_link_id, chapter=chapter,
+ )
+ queryset = models.ContributorLink.objects.filter(
+ chapter=chapter,
+ ).order_by('order')
+ logic.swap_order(link, direction, queryset)
+
+ contributor_links = models.ContributorLink.objects.filter(
+ chapter=chapter,
+ ).order_by('order')
+ response = render(
+ request,
+ 'books/partials/chapter_contributors.html',
+ {'book': book, 'chapter': chapter, 'contributor_links': contributor_links},
+ )
+ response['HX-Trigger-After-Swap'] = logic.trigger_message(
+ link.contributor, direction,
+ )
+ return response
+
+
+@require_POST
+@staff_member_required
+def move_format(request, book_id, format_id, direction):
+ book = get_object_or_404(models.Book, pk=book_id)
+ book_format = get_object_or_404(models.Format, pk=format_id, book=book)
+ queryset = models.Format.objects.filter(book=book).order_by('sequence')
+ logic.swap_order(book_format, direction, queryset, order_field='sequence')
+
+ formats = models.Format.objects.filter(book=book).order_by('sequence')
+ response = render(
+ request,
+ 'books/partials/book_formats.html',
+ {'book': book, 'formats': formats},
+ )
+ response['HX-Trigger-After-Swap'] = logic.trigger_message(
+ book_format.title, direction,
+ )
+ return response
+
+
+@require_POST
+@staff_member_required
+def move_chapter(request, book_id, chapter_id, direction):
+ book = get_object_or_404(models.Book, pk=book_id)
+ chapter = get_object_or_404(models.Chapter, pk=chapter_id, book=book)
+ queryset = models.Chapter.objects.filter(book=book).order_by('sequence')
+ logic.swap_order(chapter, direction, queryset, order_field='sequence')
+
+ chapters = models.Chapter.objects.filter(book=book).order_by('sequence')
+ response = render(
+ request,
+ 'books/partials/book_chapters.html',
+ {'book': book, 'chapters': chapters},
+ )
+ response['HX-Trigger-After-Swap'] = logic.trigger_message(
+ chapter.title, direction,
+ )
+ return response
diff --git a/plugin_settings.py b/plugin_settings.py
index 6cfbf25..1812a5d 100644
--- a/plugin_settings.py
+++ b/plugin_settings.py
@@ -25,6 +25,8 @@ def install():
def hook_registry():
return {
- 'press_admin_nav_block': {'module': 'plugins.books.hooks', 'function': 'nav_hook'}
+ 'press_admin_nav_block': {'module': 'plugins.books.hooks', 'function': 'nav_hook'},
+ 'preprint_sidebar': {'module': 'plugins.books.hooks',
+ 'function': 'linked_books'}
}
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..9efe26c
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,2 @@
+Faker==18.9.0
+svgwrite==1.4.1
\ No newline at end of file
diff --git a/static/books/books.css b/static/books/books.css
new file mode 100644
index 0000000..a72cae4
--- /dev/null
+++ b/static/books/books.css
@@ -0,0 +1,184 @@
+.olh-book {
+
+.book {
+ background-color: #f7f7f7;
+ border-radius: 0.75rem;
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
+ margin-top: 2rem;
+ overflow: hidden;
+}
+
+.book-cover {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.book-info {
+ padding: 2rem;
+}
+
+.category {
+ color: #e6194B;
+ font-size: 0.875rem;
+ font-weight: 600;
+ text-transform: uppercase;
+}
+
+.book-title {
+ color: #1a202c;
+ font-size: 1.5rem;
+ font-weight: 700;
+ margin-top: 0.25rem;
+}
+
+.book-title a {
+ color: #1a202c;
+}
+
+.authors {
+ color: #4a5568;
+ margin-top: 0.5rem;
+ margin-bottom: 0;
+}
+
+.read-button {
+ background-color: #e6194B;
+ color: white;
+ border-radius: 0.375rem;
+ display: inline-flex;
+ align-items: center;
+ margin-top: 1rem;
+}
+
+.read-button:hover {
+ background-color: #c81032;
+}
+
+.section {
+ padding: 1.2rem;
+ border-top: 1px solid #e2e8f0;
+}
+
+.section-title {
+ color: #e6194B;
+ font-size: 1.25rem;
+ font-weight: 600;
+ margin-bottom: 1rem;
+}
+
+.section-content {
+ color: #4a5568;
+}
+
+.label-text {
+ color: #900C3F;
+ font-weight: 600;
+}
+
+.associated-files {
+ margin-top: 1rem;
+}
+
+.associated-files li {
+ margin-bottom: 0.5rem;
+}
+
+.associated-files a {
+ color: #3182ce;
+ display: flex;
+ align-items: center;
+}
+
+.associated-files a:hover {
+ color: #2c5282;
+}
+
+.file-icon {
+ margin-right: 0.5rem;
+}
+
+.description-preview {
+ margin-top: 1rem;
+ max-height: 200px;
+ overflow: hidden;
+}
+
+.expand-description, .collapse-description {
+ display: inline-block;
+ margin-top: 0.5rem;
+ color: #1779ba;
+ text-decoration: underline;
+}
+
+.full-description {
+ margin-top: 1rem;
+}
+
+/* Styles for Chapters and Linked Preprints with fixed two columns and vertical scroll */
+.chapter-list,
+.preprint-list {
+ display: flex;
+ flex-wrap: wrap; /* Allow items to wrap within two columns */
+ overflow-x: hidden; /* Prevent horizontal scrolling */
+ list-style-type: none; /* Remove default list styling */
+ padding: 0; /* Remove padding for clean alignment */
+}
+
+/* Each item takes up 50% width to create two columns */
+.chapter-item,
+.preprint-item {
+ width: 50%; /* Ensure exactly two columns */
+ padding: 0.2rem 0; /* Optional spacing for each item */
+ box-sizing: border-box; /* Include padding in width */
+}
+
+.chapter-item p,
+.preprint-item p {
+ margin-bottom: 0;
+}
+
+/* Scrollbar styling (optional for better UX) */
+.chapter-list::-webkit-scrollbar,
+.preprint-list::-webkit-scrollbar {
+ width: 8px;
+}
+
+.chapter-list::-webkit-scrollbar-thumb,
+.preprint-list::-webkit-scrollbar-thumb {
+ background-color: #c81032; /* Scrollbar thumb color */
+ border-radius: 4px;
+}
+
+.chapter-list::-webkit-scrollbar-track,
+.preprint-list::-webkit-scrollbar-track {
+ background-color: #f1f1f1; /* Scrollbar track color */
+}
+
+/* Section titles */
+.section-title {
+ color: #e6194B;
+ font-size: 1.25rem;
+ font-weight: 600;
+ margin-bottom: 1rem;
+}
+
+.label-text {
+ color: #900C3F;
+ font-weight: 600;
+}
+
+/* Responsive adjustment for smaller screens */
+@media (max-width: 768px) {
+ .chapter-item,
+ .preprint-item {
+ width: 100%; /* Use single column on small screens */
+ }
+ .chapter-list,
+ .preprint-list {
+ max-height: 200px; /* Adjust height for smaller screens */
+ }
+}
+
+
+}
\ No newline at end of file
diff --git a/static/books/onix.xsl b/static/books/onix.xsl
new file mode 100644
index 0000000..575d084
--- /dev/null
+++ b/static/books/onix.xsl
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+
+
+ ONIX Book Export
+
+
+
+ ONIX Book Export
+
+
+
+
+
+
+
+
+
+
+
+
+
+
ISBN:
+
+
+
+
Contributors:
+
+
+
+
+
+
Description:
+
+
+
+
Publishing Details:
+
Publisher:
+
Location:
+
Date Published:
+
+
+
diff --git a/templates/books/OLH/book.html b/templates/books/OLH/book.html
new file mode 100644
index 0000000..99fd217
--- /dev/null
+++ b/templates/books/OLH/book.html
@@ -0,0 +1,23 @@
+{% extends "core/base.html" %}
+{% load static %}
+
+{% block title %}{{ book.title }}{% endblock %}
+
+{% block css %}
+
+{% endblock css %}
+
+{% block body %}
+
+
+ {% include "books/OLH/breadcrumb.html" %}
+
+ {% include "books/OLH/book_detail.html" with book=book %}
+
+
+
+{% endblock %}
+
+{% block js %}
+ {% include "books/OLH/read_more.js" %}
+{% endblock %}
\ No newline at end of file
diff --git a/templates/books/OLH/book_detail.html b/templates/books/OLH/book_detail.html
new file mode 100644
index 0000000..66e06e9
--- /dev/null
+++ b/templates/books/OLH/book_detail.html
@@ -0,0 +1,90 @@
+{% load static %}
+
+
+
+
+
+ {% if book.categories.exists %}
+
{% for cat in book.categories.all %}{{ cat.name }} {% if not forloop.last %}, {% endif %}{% endfor %}
+ {% endif %}
+
+
+ {% for contributor in book.contributors.all %}{% if contributor.bio or contributor.headshot %}{{ contributor }} {% else %}{{ contributor }}{% endif %}{% if not forloop.last %}, {% endif %}
+ {% endfor %}
+
+
+ {% if book.keyword_set.exists %}
+
+
Keywords
+
{% for keyword in book.keyword_set.all %}
+ {{ keyword.word }}{% endfor %}
+
+ {% endif %}
+
+
Description
+
+ {{ book.description|safe }}
+
+
Read
+ more
+
+ {% if index %}
+
+ View detail
+
+ {% endif %}
+
+
+
+ {% if not books %}
+ {% include "books/OLH/detail.html" %}
+ {% endif %}
+
+ {% with contributors=book.contributors.all %}
+ {% include "books/partials/contributor_modal_olh.html" %}
+ {% endwith %}
+
+
+
diff --git a/templates/books/OLH/breadcrumb.html b/templates/books/OLH/breadcrumb.html
new file mode 100644
index 0000000..d371069
--- /dev/null
+++ b/templates/books/OLH/breadcrumb.html
@@ -0,0 +1,23 @@
+
+
+ Book List
+
+ {% if book %}
+ {% for cat in book.categories.all %}
+
+
+ {{ cat.name }}
+
+
+ {% endfor %}
+
+
+ {{ book.full_title }}
+
+ {% else %}
+ {% if category %}
+ {{ category.name }}
+ {% endif %}
+ {% endif %}
+
+
diff --git a/templates/books/OLH/detail.html b/templates/books/OLH/detail.html
new file mode 100644
index 0000000..576b017
--- /dev/null
+++ b/templates/books/OLH/detail.html
@@ -0,0 +1,118 @@
+
+
+
Publication Details
+
Published:
+ {{ book.date_published|date:"F j, Y" }}
+ {% if book.edition %}
+
Edition: {{ book.edition }}
+
+ {% endif %}
+
Publisher: {{ book.publisher_name }}
+
+
Location: {{ book.publisher_loc }}
+ {% if book.doi %}
+
DOI: https://doi.org/{{ book.doi }}
+ {% endif %}
+ {% if book.isbn %}
+
ISBN: {{ book.isbn }}
+ {% endif %}
+ {% if book.language %}
+
Language: {{ book.language }}
+ {% endif %}
+ {% if book.peer_reviewed %}
+
Peer Review: Yes
+ {% endif %}
+ {% if book_settings.display_review_status %}
+
+
+ Peer Reviewed:
+ {% if book.peer_reviewed %}Yes{% else %}No{% endif %}
+
+ {% endif %}
+ {% if book.format_set.exists %}
+ {% if book.metrics.views %}
+
+
+ Views:
+ {{ book.metrics.views }}
+
+ {% endif %}
+ {% if book.metrics.downloads %}
+
+
+ Downloads:
+ {{ book.metrics.downloads }}
+
+ {% endif %}
+ {% endif %}
+ {% if book.license_information %}
+
License Information: {{ book.license_information|safe }}
+
+ {% endif %}
+
+
+
+ {% if book.chapter_set.exists %}
+
+ {% endif %}
+
+
+
+ {% if book.bookpreprint_set.exists %}
+
+ {% endif %}
+ {% if book.notes %}
+
+
Notes
+ {{ book.notes|safe }}
+
+ {% endif %}
+
+
+
+
+
+
Citation
+
{{ book.citation|safe }}
+
+
+ {% if book.publisher_notes.exists %}
+
+
Publisher Notes
+ {% for note in book.publisher_notes.all %}
+
{{ note.note|safe }}
+ {% endfor %}
+
+ {% endif %}
+
\ No newline at end of file
diff --git a/templates/books/OLH/index.html b/templates/books/OLH/index.html
new file mode 100644
index 0000000..35b95bd
--- /dev/null
+++ b/templates/books/OLH/index.html
@@ -0,0 +1,41 @@
+{% extends "core/base.html" %}
+{% load static %}
+
+{% block title %}{% if not category %}Published Books{% else %}
+ {{ category.name }}{% endif %}{% endblock %}
+
+{% block css %}
+
+{% endblock css %}
+
+{% block body %}
+
+
+
+ {% include "books/OLH/breadcrumb.html" %}
+ {% if not category %}
+
{{ book_settings.book_page_title }}
+ {% else %}
+ {% if category.display_title %}
+
Category: {{ category.name }}
+ {% endif %}
+ {{ category.description|safe }}
+
< Back to All
+ {% endif %}
+
+ {% for book in books %}
+
+ {% include "books/OLH/book_detail.html" with book=book index=True %}
+
+ {% empty %}
+
There are no published books to display.
+ {% endfor %}
+
+
+
+
+{% endblock body %}
+
+{% block js %}
+ {% include "books/OLH/read_more.js" %}
+{% endblock %}
\ No newline at end of file
diff --git a/templates/books/OLH/read_more.js b/templates/books/OLH/read_more.js
new file mode 100644
index 0000000..643d508
--- /dev/null
+++ b/templates/books/OLH/read_more.js
@@ -0,0 +1,46 @@
+
diff --git a/templates/books/OLH/repository_linked_books.html b/templates/books/OLH/repository_linked_books.html
new file mode 100644
index 0000000..a5ec710
--- /dev/null
+++ b/templates/books/OLH/repository_linked_books.html
@@ -0,0 +1,12 @@
+{% load fqdn %}
+
+{% if linked_books %}
+ Linked Books
+
+{% endif %}
\ No newline at end of file
diff --git a/templates/books/OLH/view_chapter.html b/templates/books/OLH/view_chapter.html
new file mode 100644
index 0000000..6504584
--- /dev/null
+++ b/templates/books/OLH/view_chapter.html
@@ -0,0 +1,129 @@
+{% extends "core/base.html" %}
+{% load static %}
+
+{% block title %}
+ {% if chapter.number %}[{{ chapter.number }}] {% endif %}{{ chapter.title }}
+{% endblock %}
+
+{% block css %}
+
+{% endblock %}
+
+{% block body %}
+
+
+ {% include "books/OLH/breadcrumb.html" %}
+
+
+
+
+
+
+ {% if book.categories.exists %}
+
{% for cat in book.categories.all %}{{ cat.name }} {% if not forloop.last %}, {% endif %}{% endfor %}
+ {% endif %}
+
+
+ {% if chapter.number %}{{ chapter.number }}. {% endif %}{{ chapter.title }}
+
+
+ {% if chapter.contributors.exists %}
+
+ {% for contributor in chapter.contributors.all %}{% if contributor.bio or contributor.headshot %}{{ contributor }} {% else %}{{ contributor }}{% endif %}{% if not forloop.last %}, {% endif %}
+ {% endfor %}
+
+ {% endif %}
+
+
+ This {% with cat=book.categories.first %}{% if cat %}{{ cat.chapter_name }}{% else %}chapter{% endif %}{% endwith %}
+ is part of: {{ book.citation|safe }}
+
+
+
+
+ {% if chapter.description %}
+
+
Description
+
+ {{ chapter.description|safe }}
+
+
Read
+ more
+
+ {% endif %}
+
+
+
Publication Details
+
Published:
+ {{ book.date_published|date:"F j, Y" }}
+
Publisher: {{ book.publisher_name }}
+
+ {% if chapter.pages %}
+
Pages: {{ chapter.pages }}
+ {% endif %}
+ {% if chapter.doi %}
+
DOI: {{ chapter.doi }}
+ {% endif %}
+ {% if chapter.license_information or book.license_information %}
+
License Information:
+ {% if chapter.license_information %}
+ {{ chapter.license_information|safe }}
+ {% else %}
+ {{ book.license_information|safe }}
+ {% endif %}
+
+ {% endif %}
+
+
+
+
Citation
+
{{ chapter.citation|safe }}
+
+
+ {% if chapter.publisher_notes.exists %}
+
+
Publisher Notes
+ {% for note in chapter.publisher_notes.all %}
+
{{ note.note|safe }}
+ {% endfor %}
+
+ {% endif %}
+
+
+
+
+
+
+{% endblock %}
+
+{% with contributors=chapter.contributors.all %}
+ {% include "books/partials/contributor_modal_olh.html" %}
+{% endwith %}
+
+{% block js %}
+ {% include "books/OLH/read_more.js" %}
+{% endblock %}
diff --git a/templates/books/book_preprint_manager.html b/templates/books/book_preprint_manager.html
new file mode 100644
index 0000000..1d3aa90
--- /dev/null
+++ b/templates/books/book_preprint_manager.html
@@ -0,0 +1,114 @@
+{% extends "admin/core/base.html" %}
+
+{% block title %}Manage Preprints for {{ book.title }}{% endblock %}
+{% block admin-header %}Manage Preprints for {{ book.title }}{% endblock %}
+
+{% block breadcrumbs %}
+ {{ block.super }}
+ Books Admin
+ {{ book.title }}
+ Manage Preprints for {{ book.title }}
+{% endblock breadcrumbs %}
+
+{% block css %}
+
+
+
+{% endblock %}
+
+{% block body %}
+
+
+
+
+
+
+
Linked Preprints
+
+
+
+ {% include "books/partials/linked_preprints.html" %}
+
+
+
+
+
+
Available Preprints
+
+
+
+
+ Title
+ Action
+
+
+
+ {% for preprint in available_preprints %}
+
+ {{ preprint.title }}
+
+
+
+
+ {% endfor %}
+
+
+
+
+
+{% endblock %}
+
+{% block js %}
+
+
+
+ {% include "elements/datatables.html" with target="#available-preprints #linked-preprints" %}
+
+
+{% endblock js %}
\ No newline at end of file
diff --git a/templates/books/chapter.html b/templates/books/chapter.html
index a9e4941..86e972c 100644
--- a/templates/books/chapter.html
+++ b/templates/books/chapter.html
@@ -5,31 +5,93 @@
{% block admin-header %}Books Admin{% endblock %}
{% block breadcrumbs %}
- {{ block.super }}
- Books Admin
- {{ book.title }}
- {% if chapter %}Edit Chapter{% else %}Add Chapter{% endif %}
+ {{ block.super }}
+ Books Admin
+ {{ book.title }}
+ {% if chapter %}Edit Chapter{% else %}Add Chapter{% endif %}
+{% endblock %}
+
+{% block css %}
+{% include "books/partials/htmx_setup.html" %}
{% endblock %}
{% block body %}
+
+
-
-
-
{% if chapter %}Edit Chapter{% else %}Add Chapter{% endif %}
-
-
+
+
{% if chapter %}Edit Chapter{% else %}Add Chapter{% endif %}
+
Back
+
+
+
+
+
+
+ {% if chapter %}
+
+
+
+
Chapter Contributors
+
+
+ {% include "books/partials/chapter_contributors.html" %}
+
+
+
+
+
+ {% if chapter_formats %}
+
+ {% else %}
+
No formats added yet.
+ {% endif %}
+
+
+
+
Delete Chapter
+
+
+
Delete this chapter using the button below. Please not you cannot recover this chapter once you delete it.
+
+
+
+ {% endif %}
+
+
{% endblock %}
+
+{% block js %}
+{% include "books/partials/htmx_reorder_js.html" %}
+{% endblock js %}
diff --git a/templates/books/edit_book.html b/templates/books/edit_book.html
index eefa429..f0347c7 100644
--- a/templates/books/edit_book.html
+++ b/templates/books/edit_book.html
@@ -10,6 +10,9 @@
{% if book %}
{{ book.title }} {% else %}
New Book {% endif %}
{% endblock %}
+{% block css %}
+{% include "books/partials/htmx_setup.html" %}
+{% endblock %}
{% block body %}
-
{% if not book %}
- Save book before adding contributors.
+
+ Save book before adding contributors.
+
{% else %}
- {% for cont in book.contributor_set.all %}
- {{ cont }}
- {% empty %}
- No contributors
- {% endfor %}
+ {% include "books/partials/book_contributors.html" %}
{% endif %}
-
-
- {% if not book %}
+ {% if not book %}
+
Save book before adding formats.
- {% else %}
- {% for format in book.format_set.all %}
- {{ format }}
- {% empty %}
- No formats
- {% endfor %}
- {% endif %}
-
+
+ {% else %}
+ {% include "books/partials/book_formats.html" %}
+ {% endif %}
Chapters
@@ -68,20 +63,28 @@ Chapters
{% endif %}
-
+ {% else %}
+ {% include "books/partials/book_chapters.html" %}
+ {% endif %}
+
+ {% if book %}
+
+
Linked Preprints
+
+ {% endif %}
-{% endblock body %}
\ No newline at end of file
+{% endblock body %}
+
+{% block js %}
+{% include "books/partials/htmx_reorder_js.html" %}
+{% endblock js %}
diff --git a/templates/books/edit_chapter_format.html b/templates/books/edit_chapter_format.html
new file mode 100644
index 0000000..0d90693
--- /dev/null
+++ b/templates/books/edit_chapter_format.html
@@ -0,0 +1,53 @@
+{% extends "admin/core/base.html" %}
+{% load foundation %}
+
+{% block title %}Books Admin{% endblock title %}
+{% block admin-header %}Books Admin{% endblock %}
+
+{% block breadcrumbs %}
+ {{ block.super }}
+ Books Admin
+ {{ book.title }}
+ {{ chapter.title }}
+ {% if chapter_format %}Edit Format{% else %}Add Format{% endif %}
+{% endblock %}
+
+{% block body %}
+
+
+
+
+
{% if chapter_format %}Editing {{ chapter_format }}{% else %}Adding Format to {{ chapter.title }}{% endif %}
+
Back
+
+
+ {% include "elements/forms/errors.html" %}
+
+
+
+
+ {% if chapter_format %}
+
+
+
+
Delete Format
+
+
Delete this format using the button below. You cannot recover it once deleted.
+
+
+
+ {% endif %}
+
+{% endblock %}
diff --git a/templates/books/edit_contributor.html b/templates/books/edit_contributor.html
index a54c597..235c6a8 100644
--- a/templates/books/edit_contributor.html
+++ b/templates/books/edit_contributor.html
@@ -5,26 +5,66 @@
{% block admin-header %}Books Admin{% endblock %}
{% block breadcrumbs %}
- {{ block.super }}
- Books Admin
- {{ book.title }}
- Edit Contributor
+ {{ block.super }}
+ Books Admin
+ {{ book.title }}
+ Edit Contributor
+{% endblock %}
+
+{% block css %}
+{% include "books/partials/htmx_setup.html" %}
{% endblock %}
{% block body %}
-
-
-
-
{% if contributor %}Editing {{ contributor }}{% else %}Adding Contributor to {{ book }}{% endif %}
-
Back
+
+
+
+
+
{% if contributor %}Editing {{ contributor }}{% else %}Adding Contributor
+ to {{ book }}{% endif %}
+
Back
+
+
+
+ {% if contributor %}
+
+
+
+
Delete Contributor
+
Delete this contributor using the button below. Please note you cannot recover this contributor once you delete it.
+
+ {% csrf_token %}
+ Delete Contributor
+
+
+ {% endif %}
+
{% endblock %}
diff --git a/templates/books/edit_format.html b/templates/books/edit_format.html
index 73fb3df..e1c6d54 100644
--- a/templates/books/edit_format.html
+++ b/templates/books/edit_format.html
@@ -5,30 +5,53 @@
{% block admin-header %}Books Admin{% endblock %}
{% block breadcrumbs %}
- {{ block.super }}
-
Books Admin
-
{{ book.title }}
-
Edit Format
+ {{ block.super }}
+
Books Admin
+
{{ book.title }}
+
Edit Format
{% endblock %}
{% block body %}
-
-
-
-
{% if contributor %}Editing {{ format }}{% else %}Adding Format to {{ book }}{% endif %}
-
Back
-
-
- {% include "elements/forms/errors.html" %}
-
- {% csrf_token %}
- {{ form|foundation }}
-
- Save
-
+
+
+
+
+
{% if contributor %}Editing {{ format }}{% else %}Adding Format to
+ {{ book }}{% endif %}
+
Back
+
+
+ {% include "elements/forms/errors.html" %}
+
+ {% csrf_token %}
+ {{ form|foundation }}
+
+ Save
+
+
+ {% if format %}
+
+
+
+
Delete Format
+
+
Delete this format using the button below. Please not you cannot recover this format once you delete it.
+
+ {% csrf_token %}
+ Delete format
+
+
+
+ {% endif %}
+
+
{% endblock %}
diff --git a/templates/books/material/book_detail.html b/templates/books/material/book_detail.html
index f2926e7..aacbc0c 100644
--- a/templates/books/material/book_detail.html
+++ b/templates/books/material/book_detail.html
@@ -12,8 +12,8 @@
{% include "books/material/oa_header.html" %}
{% else %}
-
{% for contributor in book.contributor_set.all %}{% if not forloop.first and not forloop.last %},
- {% elif forloop.last and not forloop.first %} & {% endif %}{{ contributor }}{% endfor %}
+
{% for contributor in book.contributors.all %}{% if not forloop.first and not forloop.last %},
+ {% elif forloop.last and not forloop.first %} & {% endif %}{% if contributor.bio or contributor.headshot %}{{ contributor }} {% else %}{{ contributor }}{% endif %}{% endfor %}
{% endif %}
{% if book.remote_url and book.remote_book_label %}
@@ -26,13 +26,13 @@
+{% with contributors=book.contributors.all %}
+ {% include "books/partials/contributor_modal_material.html" %}
+{% endwith %}
+
{% block css %}