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:

    + +
    + +

    Role:

    +

    Bio:

    +
    +
    + + +

    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 %} +

    + + {{ book.full_title }} + +

    +

    + {% for contributor in book.contributors.all %}{% if contributor.bio or contributor.headshot %}{{ contributor }}{% else %}{{ contributor }}{% endif %}{% if not forloop.last %}, {% endif %} + {% endfor %} +

    +
    + {% for format in book.format_set.all %} + +   + Download {{ format.title }} + + {% if format.is_epub and format.display_read_link %} + + Read this Book + + {% endif %} + {% endfor %} + {% if book.remote_url %} + + {{ book.remote_book_label }} + + + {% endif %} + {% if book.purchase_url %} + + {% with cat=book.categories.first %}{% if cat %}{{ cat.buy_button_text }}{% else %}Buy this Book{% endif %}{% endwith %} +   + + {% endif %} +
    + {% 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 @@ + 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 %} +
    +

    Chapters

    + +
    + {% endif %} +
    + +
    + {% if book.bookpreprint_set.exists %} +
    +

    Linked Items

    + +
    + {% 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.cover %} + {{ book.title }} book cover + {% else %} + Default book cover + {% endif %} + +

    + +  Back to {{ book.full_title }} + +

    +
    + +
    + {% 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 }} +

    + +
    + {% with cat=book.categories.first %} + {% for fmt in chapter.chapterformat_set.all %} + +   + Download {% if cat %}{{ cat.chapter_name|capfirst }}{% else %}Chapter{% endif %}{% if chapter.chapterformat_set.count > 1 %}: {{ fmt.title }}{% endif %} + + {% endfor %} + {% endwith %} +
    + + {% 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

    +
    + + + + + + + + + {% for preprint in available_preprints %} + + + + + {% endfor %} + +
    TitleAction
    {{ preprint.title }} +
    + {% csrf_token %} + +
    +
    +
    +
    +
    +{% 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 %}

    -
    -
    -
    - {% csrf_token %} - {% include "admin/elements/forms/errors.html" %} - {{ form|foundation }} -
    -
    - {% if chapter.filename %}

    Current file: {{ chapter.filename }}

    {% endif %} -
    -
    - -
    -
    +
    +

    {% if chapter %}Edit Chapter{% else %}Add Chapter{% endif %}

    +  Back +
    +
    +
    + {% csrf_token %} + {% include "admin/elements/forms/errors.html" %} + {{ form|foundation }} + +
    +
    +
    +
    + {% if chapter %} +
    +
    +
    +

    Chapter Contributors

    +
    +
    + {% include "books/partials/chapter_contributors.html" %} +
    +
    +
    +
    +

    Chapter Formats

    +  Add Format +
    +
    + {% if chapter_formats %} + + + + + + + + + + {% for fmt in chapter_formats %} + + + + + + {% endfor %} + +
    TitleFile
    {{ fmt.title }}{{ fmt.filename }}Edit
    + {% 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.

    +
    + {% csrf_token %} + +
    +
    +
    + {% 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 %}
    @@ -32,34 +35,26 @@

    Contributors

    {% if book %} Add Contributor{% endif %}
    -
      {% 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 %} -

    Formats

    {% if book %} Add Format{% 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" %} +
    + {% csrf_token %} + {{ form|foundation }} + {% if chapter_format.filename %} +
    +

    Current File: {{ chapter_format.filename }}

    +
    + {% endif %} + +
    +
    +
    +
    + {% if chapter_format %} +
    +
    +
    +

    Delete Format

    +
    +

    Delete this format using the button below. You cannot recover it once deleted.

    +
    + {% csrf_token %} + +
    +
    +
    + {% 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 +
    +
    +
    + {% csrf_token %} +
    +
    + +
    -
    - - {% csrf_token %} - {{ form|foundation }} - - +
    + {% include "books/partials/contributor_name_fields.html" with is_corporate=form.instance.is_corporate %}
    + {{ form.affiliation|foundation }} + {{ form.email|foundation }} + {{ form.bio|foundation }} + {{ form.headshot|foundation }} + + +
    +
    +
    + {% if contributor %} +
    +
    +
    +

    Delete Contributor

    +

    Delete this contributor using the button below. Please note you cannot recover this contributor once you delete it.

    +
    + {% csrf_token %} + +
    +
    + {% 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 }} -
    -

    {% if format.filename %}Current File:  {{ format.filename }}{% endif %}

    -
    - -
    +
    +
    +
    +
    +

    {% if contributor %}Editing {{ format }}{% else %}Adding Format to + {{ book }}{% endif %}

    +  Back +
    +
    + {% include "elements/forms/errors.html" %} +
    + {% csrf_token %} + {{ form|foundation }} +
    +

    {% if format.filename %}Current File: +  {{ format.filename }} + {% endif %} +

    + +
    +
    + {% if format %} +
    +
    +
    +

    Delete Format

    +
    +

    Delete this format using the button below. Please not you cannot recover this format once you delete it.

    +
    + {% csrf_token %} + +
    +
    +
    + {% 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 %}

    {% if books %}{% endif %}{{ book.full_title }}{% if books %}{% endif %}

    -
    {% 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 @@
    {% for contributor in book.contributor_set.all %}{% if not forloop.first {% if book.purchase_url %} - {% if book.category %}{{ book.category.buy_button_text }}{% else %}Buy this Book{% endif %} + {% with cat=book.categories.first %}{% if cat %}{{ cat.buy_button_text }}{% else %}Buy this Book{% endif %}{% endwith %} {% endif %} - {% if book.category %} -

    Category

    -

    {{ book.category.name }}

    + {% if book.categories.exists %} +

    {% if book.categories.count == 1 %}Category{% else %}Categories{% endif %}

    +

    {% for cat in book.categories.all %}{{ cat.name }}{% if not forloop.last %}, {% endif %}{% endfor %}

    {% endif %}

    Description

    {{ book.description|safe }} @@ -64,6 +64,10 @@

    {{ book.metrics.views }}

    +{% with contributors=book.contributors.all %} + {% include "books/partials/contributor_modal_material.html" %} +{% endwith %} + {% block css %}