From 87bd94a94dc6cc3dbf47db2bdd17761b3f400138 Mon Sep 17 00:00:00 2001 From: Radek Czajka <rczajka@rczajka.pl> Date: Tue, 25 Jul 2023 12:57:27 +0200 Subject: [PATCH] Add audience and thema data to catalogue. Also: nicer cover view. --- src/catalogue/admin.py | 14 +- ...ptions_thema_plural_thema_slug_and_more.py | 102 +++++++++++++++ .../0049_thema_public_description.py | 21 +++ src/catalogue/models.py | 35 ++++- src/cover/forms.py | 2 +- src/cover/migrations/0006_image_example.py | 25 ++++ src/cover/models.py | 66 ++++++++++ src/cover/templates/cover/image_detail.html | 121 ++++++++++++------ src/cover/views.py | 13 +- src/documents/models/listeners.py | 7 + src/wlxml/views.py | 2 +- 11 files changed, 361 insertions(+), 47 deletions(-) create mode 100644 src/catalogue/migrations/0048_audience_alter_thema_options_thema_plural_thema_slug_and_more.py create mode 100644 src/catalogue/migrations/0049_thema_public_description.py create mode 100644 src/cover/migrations/0006_image_example.py diff --git a/src/catalogue/admin.py b/src/catalogue/admin.py index 77f1078c..614450eb 100644 --- a/src/catalogue/admin.py +++ b/src/catalogue/admin.py @@ -556,6 +556,14 @@ class PlaceAdmin(WikidataAdminMixin, TabbedTranslationAdmin): @admin.register(models.Thema) class ThemaAdmin(admin.ModelAdmin): - list_display = ['code', 'name', 'usable', 'hidden'] - list_filter = ['usable', 'hidden'] - search_fields = ['code', 'name', 'description'] + list_display = ['code', 'name', 'usable', 'hidden', 'woblink_category'] + list_filter = ['usable', 'usable_as_main', 'hidden'] + search_fields = ['code', 'name', 'description', 'public_description'] + prepopulated_fields = {"slug": ["name"]} + + +@admin.register(models.Audience) +class ThemaAdmin(admin.ModelAdmin): + list_display = ['code', 'name', 'thema'] + search_fields = ['code', 'name', 'description', 'thema'] + prepopulated_fields = {"slug": ["name"]} diff --git a/src/catalogue/migrations/0048_audience_alter_thema_options_thema_plural_thema_slug_and_more.py b/src/catalogue/migrations/0048_audience_alter_thema_options_thema_plural_thema_slug_and_more.py new file mode 100644 index 00000000..e9f255a4 --- /dev/null +++ b/src/catalogue/migrations/0048_audience_alter_thema_options_thema_plural_thema_slug_and_more.py @@ -0,0 +1,102 @@ +# Generated by Django 4.1.9 on 2023-07-25 12:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("catalogue", "0047_author_woblink"), + ] + + operations = [ + migrations.CreateModel( + name="Audience", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "code", + models.CharField( + help_text="Techniczny identifyikator. W miarÄ możliwoÅci nie należy zmieniaÄ.", + max_length=128, + unique=True, + ), + ), + ( + "name", + models.CharField( + help_text="W formie: âdla ⦠(kogo?)â", max_length=1024 + ), + ), + ( + "slug", + models.SlugField( + blank=True, + help_text="Element adresu na WL, w postaci: /dla/slug/. Można zmieniaÄ.", + max_length=255, + null=True, + unique=True, + ), + ), + ("description", models.TextField(blank=True)), + ( + "thema", + models.CharField( + blank=True, + help_text="OdpowiadajÄ cy kwalifikator Thema.", + max_length=32, + ), + ), + ], + options={ + "ordering": ("code",), + }, + ), + migrations.AlterModelOptions( + name="thema", + options={"ordering": ("code",), "verbose_name_plural": "Thema"}, + ), + migrations.AddField( + model_name="thema", + name="plural", + field=models.CharField( + blank=True, max_length=255, verbose_name="liczba mnoga" + ), + ), + migrations.AddField( + model_name="thema", + name="slug", + field=models.SlugField( + blank=True, + help_text="Element adresu na WL, w postaci: /tag/slug/. Można zmieniaÄ.", + max_length=255, + null=True, + unique=True, + ), + ), + migrations.AddField( + model_name="thema", + name="usable_as_main", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="thema", + name="woblink_category", + field=models.IntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name="genre", + name="plural", + field=models.CharField( + blank=True, max_length=255, verbose_name="liczba mnoga" + ), + ), + ] diff --git a/src/catalogue/migrations/0049_thema_public_description.py b/src/catalogue/migrations/0049_thema_public_description.py new file mode 100644 index 00000000..9ccde991 --- /dev/null +++ b/src/catalogue/migrations/0049_thema_public_description.py @@ -0,0 +1,21 @@ +# Generated by Django 4.1.9 on 2023-07-25 12:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "catalogue", + "0048_audience_alter_thema_options_thema_plural_thema_slug_and_more", + ), + ] + + operations = [ + migrations.AddField( + model_name="thema", + name="public_description", + field=models.TextField(blank=True), + ), + ] diff --git a/src/catalogue/models.py b/src/catalogue/models.py index d2befb90..85757a65 100644 --- a/src/catalogue/models.py +++ b/src/catalogue/models.py @@ -217,7 +217,6 @@ class Epoch(Category): class Genre(Category): plural = models.CharField( 'liczba mnoga', max_length=255, blank=True, - help_text='dotyczy gatunków' ) is_epoch_specific = models.BooleanField( default=False, @@ -564,9 +563,43 @@ class BookMonthlyStats(models.Model): class Thema(models.Model): code = models.CharField(max_length=128, unique=True) name = models.CharField(max_length=1024) + slug = models.SlugField( + max_length=255, null=True, blank=True, unique=True, + help_text='Element adresu na WL, w postaci: /tag/slug/. Można zmieniaÄ.' + ) + plural = models.CharField( + 'liczba mnoga', max_length=255, blank=True, + ) description = models.TextField(blank=True) + public_description = models.TextField(blank=True) usable = models.BooleanField() + usable_as_main = models.BooleanField(default=False) hidden = models.BooleanField(default=False) + woblink_category = models.IntegerField(null=True, blank=True) + + class Meta: + ordering = ('code',) + verbose_name_plural = 'Thema' + + +class Audience(models.Model): + code = models.CharField( + max_length=128, unique=True, + help_text='Techniczny identifyikator. W miarÄ możliwoÅci nie należy zmieniaÄ.' + ) + name = models.CharField( + max_length=1024, + help_text='W formie: âdla ⦠(kogo?)â' + ) + slug = models.SlugField( + max_length=255, null=True, blank=True, unique=True, + help_text='Element adresu na WL, w postaci: /dla/slug/. Można zmieniaÄ.' + ) + description = models.TextField(blank=True) + thema = models.CharField( + max_length=32, blank=True, + help_text='OdpowiadajÄ cy kwalifikator Thema.' + ) class Meta: ordering = ('code',) diff --git a/src/cover/forms.py b/src/cover/forms.py index 7a855940..aad80c4d 100644 --- a/src/cover/forms.py +++ b/src/cover/forms.py @@ -77,7 +77,7 @@ class ImageEditForm(forms.ModelForm): """Form used for editing a Book.""" class Meta: model = Image - exclude = ['download_url'] + exclude = ['download_url', 'use_file', 'example',] def clean(self): cleaned_data = super(ImageEditForm, self).clean() diff --git a/src/cover/migrations/0006_image_example.py b/src/cover/migrations/0006_image_example.py new file mode 100644 index 00000000..2ae53cb4 --- /dev/null +++ b/src/cover/migrations/0006_image_example.py @@ -0,0 +1,25 @@ +# Generated by Django 4.1.9 on 2023-07-24 16:33 + +import cover.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("cover", "0005_alter_image_download_url"), + ] + + operations = [ + migrations.AddField( + model_name="image", + name="example", + field=models.ImageField( + default="", + editable=False, + storage=cover.models.OverwriteStorage(), + upload_to="cover/example", + ), + preserve_default=False, + ), + ] diff --git a/src/cover/models.py b/src/cover/models.py index 87ac0369..d7662227 100644 --- a/src/cover/models.py +++ b/src/cover/models.py @@ -11,6 +11,9 @@ from django.urls import reverse from django.utils.translation import gettext_lazy as _ from django.contrib.sites.models import Site from PIL import Image as PILImage +from librarian.dcparser import BookInfo +from librarian.meta.types.person import Person +from librarian.cover import make_cover from cover.utils import URLOpener @@ -39,6 +42,13 @@ class Image(models.Model): editable=True, verbose_name=_('file for use') ) + + example = models.ImageField( + upload_to='cover/example', + storage=OverwriteStorage(), + editable=False, + ) + cut_top = models.IntegerField(default=0, ) cut_bottom = models.IntegerField(default=0) cut_left = models.IntegerField(default=0) @@ -73,7 +83,28 @@ class Image(models.Model): img, save=False ) + super().save(update_fields=['use_file']) + + self.example.save( + "%d.jpg" % self.pk, + ContentFile(self.build_example().get_bytes()), + save=False + ) + super().save(update_fields=['example']) + + + def build_example(self): + class A: + pass + info = A() + info.authors = [] + info.translators = [] + info.cover_class = None + info.cover_box_position = None + info.title = '?' + info.cover_url = 'file://' + self.use_file.path + return make_cover(info, width=200).output_file() def get_absolute_url(self): return reverse('cover_image', args=[self.id]) @@ -81,6 +112,41 @@ class Image(models.Model): def get_full_url(self): return "http://%s%s" % (Site.objects.get_current().domain, self.get_absolute_url()) + def cut_percentages(self): + img = PILImage.open(self.file) + max_w, max_h = 600, 600 + w, h = img.size + scale = min(max_w / w, max_h / h) + ws, hs = round(w * scale), round(h * scale) + + return { + 'left': 100 * self.cut_left / w, + 'right': 100 * self.cut_right / w, + 'top': 100 * self.cut_top / h, + 'bottom': 100 * self.cut_bottom / h, + 'width': ws, + 'height': hs, + 'th': f'{ws}x{hs}', + } + + @property + def etag(self): + return f'{self.cut_top}.{self.cut_bottom}.{self.cut_left}.{self.cut_right}' + + @property + def attribution(self): + pieces = [] + if self.title: + pieces.append(self.title) + if self.author: + pieces.append(self.author) + if self.license_name: + pieces.append(self.license_name) + if self.source_url: + pieces.append(self.source_url.split('/')[2]) + return ', '.join(pieces) + + @receiver(post_save, sender=Image) def download_image(sender, instance, **kwargs): diff --git a/src/cover/templates/cover/image_detail.html b/src/cover/templates/cover/image_detail.html index 4b8f73f7..2d43f9e0 100644 --- a/src/cover/templates/cover/image_detail.html +++ b/src/cover/templates/cover/image_detail.html @@ -1,53 +1,94 @@ {% extends "documents/base.html" %} -{% load i18n %} +{% load i18n l10n %} {% load thumbnail %} {% load build_absolute_uri from fnp_common %} +{% load bootstrap4 %} {% block titleextra %}{% trans "Cover image" %}{% endblock %} {% block content %} -<h1>{% trans "Cover image" %}</h1> + <h1>{% trans "Cover image" %}</h1> + <div class="row"> + <div class="col-md-5"> -<div style="float: right; margin-bottom:1em;"> -<a href="{{ object.use_file.url }}"><img style="max-width: 565px; max-height: 833px" - src="{{ object.use_file.url }}?{{ object.cut_top }}.{{ object.cut_bottom }}.{{ object.cut_left }}.{{ object.cut_right }}" /> - </a> -<br/><a href="{{ object.source_url }}">{{ object.title }}</a> by {{ object.author }}, - {% if object.license_url %}<a href="{{ object.license_url }}">{% endif %} - {{ object.license_name }} - {% if object.license_url %}</a>{% endif %} -</div> -{% if editable %} - <form method="post" enctype="multipart/form-data"> - {% csrf_token %} -{% endif %} -<table class='editable'><tbody> - {{ form.as_table }} - {% if editable %} - <tr><td></td><td><button type="submit">{% trans "Change" %}</button></td></tr> - {% endif %} -</tbody></table> -{% if editable %}</form>{% endif %} - - -<h2>{% trans "Used in:" %}</h2> -{% if object.book_set %} -<ul> - {% for book in object.book_set.all %} - <li><a href="{{ book.get_absolute_url }}">{{ book }}</a></li> - {% endfor %} -</ul> -{% else %} - <p>{% trans "None" %}</p> -{% endif %} - - -<textarea style="width:100%" rows="5"> + + {% if editable %} + <form method="post" enctype="multipart/form-data"> + {% csrf_token %} + {% endif %} + <table class='editable'><tbody> + {% bootstrap_form form %} + {% if editable %} + {% buttons %} + <button type="submit" class="btn btn-primary">{% trans "Change" %}</button> + {% endbuttons %} + {% endif %} + </tbody></table> + {% if editable %}</form>{% endif %} + </div> + + + <div class="col-md-7"> + {% with perc=object.cut_percentages %} + <a href="{{ object.use_file.url }}" style="position: relative; display: block; width: {{ perc.width }}px; height: {{ perc.height }}px;"> + {% thumbnail object.file perc.th as th %} + <img src="{{ th.url }}" /> + {% endthumbnail %} + {% localize off %} + <div style="background: black; opacity: .5; display: block; position: absolute; top:0; left: 0; right: 0; height: {{ perc.top }}%"></div> + <div style="background: black; opacity: .5; display: block; position: absolute; bottom:0; left: 0; right: 0; height: {{ perc.bottom }}%"></div> + <div style="background: black; opacity: .5; display: block; position: absolute; top: {{ perc.top }}%; bottom: {{ perc.bottom }}%; left: 0; width: {{ perc.left }}%"></div> + <div style="background: black; opacity: .5; display: block; position: absolute; top: {{ perc.top }}%; bottom: {{ perc.bottom }}%; right: 0; width: {{ perc.right }}%"></div> + {% endlocalize %} + {% endwith %} + </a> + <br/><a href="{{ object.source_url }}">{{ object.title }}</a> by {{ object.author }}, + {% if object.license_url %}<a href="{{ object.license_url }}">{% endif %} + {{ object.license_name }} + {% if object.license_url %}</a>{% endif %} + + {% if object.example %} + <div class="mt-4"> + <img src="{{ object.example.url }}?{{ object.etag }}"> + </div> + {% endif %} + </div> + </div> + + + <textarea style="width:100%" rows="6" class="form-control mt-4" disabled> <dc:relation.coverImage.url xmlns:dc="http://purl.org/dc/elements/1.1/">{{ object.use_file.url|build_absolute_uri:request }}</dc:relation.coverImage.url> -<dc:relation.coverImage.attribution xmlns:dc="http://purl.org/dc/elements/1.1/">{% if object.title %}{{ object.title }}, {% endif %}{% if object.author %}{{ object.author }}, {% endif %}{{ object.license_name }}</dc:relation.coverImage.attribution> -<dc:relation.coverImage.source xmlns:dc="http://purl.org/dc/elements/1.1/">{{ object.get_full_url }}</dc:relation.coverImage.source> -</textarea> +<dc:relation.coverImage.attribution xmlns:dc="http://purl.org/dc/elements/1.1/">{{ object.attribution }}</dc:relation.coverImage.attribution> +<dc:relation.coverImage.source xmlns:dc="http://purl.org/dc/elements/1.1/">{{ object.get_full_url }}</dc:relation.coverImage.source></textarea> + + <div class="card mt-4"> + <div class="card-header"> + <h2>{% trans "Used in:" %}</h2> + </div> + <div class="card-body"> + {% if object.book_set.exists %} + <ul style="list-style: none; padding: 0; display: flex; gap: 10px;"> + {% for book in object.book_set.all %} + <li> + <a href="{{ book.get_absolute_url }}"> + <img src="{{ book.cover.url }}?{{ object.etag }}" alt="{{ book }}"> + </a> + </li> + <li> + <a href="{{ book.get_absolute_url }}" title="{{ book }}"> + <img src="{{ book.cover.url }}?{{ object.etag }}" alt="{{ book }}"> + </a> + </li> + {% endfor %} + </ul> + {% else %} + <p>{% trans "None" %}</p> + {% endif %} + </div> +</div> + + {% endblock %} diff --git a/src/cover/views.py b/src/cover/views.py index d00123cc..82bc911f 100644 --- a/src/cover/views.py +++ b/src/cover/views.py @@ -7,7 +7,7 @@ import os.path import PIL.Image from django.conf import settings from django.contrib.auth.decorators import permission_required -from django.http import HttpResponse, HttpResponseRedirect, Http404 +from django.http import HttpResponse, HttpResponseRedirect, Http404, JsonResponse from django.shortcuts import get_object_or_404, render from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_POST @@ -103,6 +103,17 @@ def preview_from_xml(request): def image(request, pk): img = get_object_or_404(Image, pk=pk) + if not request.accepts('text/html') and request.accepts('application/json') or request.GET.get('format') == 'json': + return JsonResponse({ + 'attribution': img.attribution, + 'cut_left': img.cut_left, + 'cut_right': img.cut_right, + 'cut_top': img.cut_top, + 'cut_bottom': img.cut_bottom, + 'file': img.file.url, + 'use_file': img.use_file.url, + }) + if request.user.has_perm('cover.change_image'): if request.method == "POST": form = forms.ImageEditForm(request.POST, request.FILES, instance=img) diff --git a/src/documents/models/listeners.py b/src/documents/models/listeners.py index d306e4e9..1a022fb2 100644 --- a/src/documents/models/listeners.py +++ b/src/documents/models/listeners.py @@ -3,6 +3,7 @@ # from django.contrib.auth.models import User from django.db import models +import cover.models from documents.models import (Book, Chunk, Image, BookPublishRecord, ImagePublishRecord) from documents.signals import post_publish @@ -53,3 +54,9 @@ def listener_create(sender, instance, created, **kwargs): instance.chunk_set.create(number=1, slug='1') models.signals.post_save.connect(listener_create, sender=Book) + +def cover_changed(sender, instance, created, **kwargs): + for book in instance.book_set.all(): + book.build_cover() +models.signals.post_save.connect(cover_changed, sender=cover.models.Image) + diff --git a/src/wlxml/views.py b/src/wlxml/views.py index ebc837dc..f1f5dae3 100644 --- a/src/wlxml/views.py +++ b/src/wlxml/views.py @@ -119,7 +119,7 @@ VALUE_TYPES = { }, "publisher": { "autocomplete": { - "source": ["Fundacja Nowoczesna Polska"] + "source": ["Fundacja Wolne Lektury"] } }, -- 2.20.1