diff --git a/report.md b/report.md new file mode 100644 index 000000000..20e294081 --- /dev/null +++ b/report.md @@ -0,0 +1,63 @@ +# Ticket #316: Możliwość stworzenia rozdziału bez wydawnictwa nadrzędnego + +## Problem + +The publication importer (`importer_publikacji`) allowed creating chapters +(`Wydawnictwo_Zwarte` records with `charakter_formalny.charakter_ogolny == "roz"`) +without a parent publication (`wydawnictwo_nadrzedne`). This created orphan chapter +records — chapters that don't belong to any book — which violates the data model's +semantic constraint. The bug was triggered by BibTeX imports of `@inbook`/`@incollection` +entries, where the importer's source step (step 3) had no UI for selecting a parent +publication. + +## Changes + +### 1. `src/importer_publikacji/models.py` +- Added two nullable ForeignKey fields to `ImportSession`: + - `wydawnictwo_nadrzedne` → `bpp.Wydawnictwo_Zwarte` + - `wydawnictwo_nadrzedne_w_pbn` → `pbn_api.Publication` + +### 2. `src/importer_publikacji/migrations/0005_importsession_wydawnictwo_nadrzedne.py` +- New migration adding both FK fields to `ImportSession`. + +### 3. `src/importer_publikacji/forms.py` +- Added `wydawnictwo_nadrzedne` and `wydawnictwo_nadrzedne_w_pbn` `ModelChoiceField` + fields to `SourceForm` (both `required=False`, conditional validation in view). + +### 4. `src/importer_publikacji/views.py` +- Added `_is_chapter()` helper to detect chapters by `charakter_ogolny`. +- Modified `_source_context()` to pass `is_chapter` flag and parent publication + objects for Select2 pre-population. +- Modified `SourceView.post()` to validate that chapters have exactly one parent + publication (either BPP or PBN, not both, not neither). +- Modified `_create_wydawnictwo_zwarte()` to set `wydawnictwo_nadrzedne` and/or + `wydawnictwo_nadrzedne_w_pbn` on the created record. + +### 5. `src/importer_publikacji/templates/.../step_source.html` +- Added conditional UI block for chapters: shows two Select2 autocomplete fields + for parent publication selection (BPP book or PBN publication). +- Added JavaScript to initialize Select2 AJAX on both fields using existing + autocomplete URLs (`wydawnictwo-nadrzedne-autocomplete`, + `wydawnictwo-nadrzedne-w-pbn-autocomplete`). + +### 6. `src/importer_publikacji/templates/.../step_review.html` +- Added display of parent publication in the review step summary table. + +## How to Verify + +1. Start the development server and open the publication importer. +2. Import a BibTeX `@inbook` or `@incollection` entry. +3. On step 2 (Verify), confirm the charakter formalny is set to a chapter type. +4. On step 3 (Source), verify: + - The parent publication section appears with "Rozdział wymaga wydawnictwa + nadrzędnego" callout. + - Two Select2 fields are shown: "Wydawnictwo nadrzędne (BPP)" and + "Wydawnictwo nadrzędne (PBN)". + - Attempting to proceed without selecting a parent shows validation error. + - Selecting both a BPP and PBN parent shows mutual exclusivity error. + - Selecting exactly one parent allows proceeding. +5. On step 5 (Review), verify the parent publication appears in the summary. +6. After creation, verify the `Wydawnictwo_Zwarte` record has `wydawnictwo_nadrzedne` + set correctly. +7. Import a regular book (non-chapter) and verify step 3 does NOT show the parent + publication fields. diff --git a/src/importer_publikacji/forms.py b/src/importer_publikacji/forms.py index 88e6f5f25..b4ad32cc1 100644 --- a/src/importer_publikacji/forms.py +++ b/src/importer_publikacji/forms.py @@ -12,8 +12,10 @@ Jezyk, Typ_KBN, Wydawca, + Wydawnictwo_Zwarte, Zrodlo, ) +from pbn_api.models import Publication as PBNPublication from .providers import ( get_available_providers, @@ -135,6 +137,16 @@ class SourceForm(forms.Form): max_length=256, required=False, ) + wydawnictwo_nadrzedne = forms.ModelChoiceField( + queryset=Wydawnictwo_Zwarte.objects.all(), + label="Wydawnictwo nadrzędne", + required=False, + ) + wydawnictwo_nadrzedne_w_pbn = forms.ModelChoiceField( + queryset=PBNPublication.objects.all(), + label="Wydawnictwo nadrzędne w PBN", + required=False, + ) class AuthorMatchForm(forms.Form): diff --git a/src/importer_publikacji/migrations/0005_importsession_wydawnictwo_nadrzedne.py b/src/importer_publikacji/migrations/0005_importsession_wydawnictwo_nadrzedne.py new file mode 100644 index 000000000..700a99776 --- /dev/null +++ b/src/importer_publikacji/migrations/0005_importsession_wydawnictwo_nadrzedne.py @@ -0,0 +1,39 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("bpp", "0001_initial"), + ("pbn_api", "0001_initial"), + ( + "importer_publikacji", + "0004_rename_user_to_created_by_add_modified_by", + ), + ] + + operations = [ + migrations.AddField( + model_name="importsession", + name="wydawnictwo_nadrzedne", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="bpp.wydawnictwo_zwarte", + verbose_name="wydawnictwo nadrzędne", + ), + ), + migrations.AddField( + model_name="importsession", + name="wydawnictwo_nadrzedne_w_pbn", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="pbn_api.publication", + verbose_name="wydawnictwo nadrzędne w PBN", + ), + ), + ] diff --git a/src/importer_publikacji/models.py b/src/importer_publikacji/models.py index 3bb79422c..75bf73ec3 100644 --- a/src/importer_publikacji/models.py +++ b/src/importer_publikacji/models.py @@ -90,6 +90,20 @@ class Status(models.TextChoices): blank=True, verbose_name="wydawca", ) + wydawnictwo_nadrzedne = models.ForeignKey( + "bpp.Wydawnictwo_Zwarte", + on_delete=models.SET_NULL, + null=True, + blank=True, + verbose_name="wydawnictwo nadrzędne", + ) + wydawnictwo_nadrzedne_w_pbn = models.ForeignKey( + "pbn_api.Publication", + on_delete=models.SET_NULL, + null=True, + blank=True, + verbose_name="wydawnictwo nadrzędne w PBN", + ) jezyk = models.ForeignKey( "bpp.Jezyk", on_delete=models.SET_NULL, diff --git a/src/importer_publikacji/templates/importer_publikacji/partials/step_review.html b/src/importer_publikacji/templates/importer_publikacji/partials/step_review.html index fe299c00b..93de78232 100644 --- a/src/importer_publikacji/templates/importer_publikacji/partials/step_review.html +++ b/src/importer_publikacji/templates/importer_publikacji/partials/step_review.html @@ -64,6 +64,24 @@
Dane publikacji
{{ session.wydawca }} {% endif %} + {% if session.wydawnictwo_nadrzedne %} + + Wydawnictwo nadrzędne + + {{ session.wydawnictwo_nadrzedne }} + + + {% endif %} + {% if session.wydawnictwo_nadrzedne_w_pbn %} + + + Wydawnictwo nadrzędne (PBN) + + + {{ session.wydawnictwo_nadrzedne_w_pbn }} + + + {% endif %} {% if data.volume %} Tom diff --git a/src/importer_publikacji/templates/importer_publikacji/partials/step_source.html b/src/importer_publikacji/templates/importer_publikacji/partials/step_source.html index 7149fbac9..8d7a3498a 100644 --- a/src/importer_publikacji/templates/importer_publikacji/partials/step_source.html +++ b/src/importer_publikacji/templates/importer_publikacji/partials/step_source.html @@ -47,6 +47,58 @@

Podaj wydawcę lub wpisz szczegóły wydawcy.

+ + {% if is_chapter %} +
+
+ + Rozdział wymaga wydawnictwa + nadrzędnego. + + Wybierz istniejącą książkę z BPP + lub publikację z PBN (jedno z dwóch). +
+
+
+ + +
+
+ + +
+
+ {% endif %} {% else %} {# Wydawnictwo ciągłe: źródło wymagane #}
@@ -112,3 +164,39 @@

Przetwarzanie...

+ +{% if is_chapter %} + +{% endif %} diff --git a/src/importer_publikacji/views.py b/src/importer_publikacji/views.py index a21e1c371..b431c8099 100644 --- a/src/importer_publikacji/views.py +++ b/src/importer_publikacji/views.py @@ -10,6 +10,7 @@ from django.urls import reverse from django.views import View +from bpp.const import CHARAKTER_OGOLNY_ROZDZIAL from bpp.models import ( Autor, Crossref_Mapper, @@ -425,6 +426,25 @@ def post(self, request, session_id): "Podaj wydawcę lub wpisz szczegóły wydawcy.", ) return _render_source_step(request, session, form=form) + + # Rozdział wymaga wydawnictwa nadrzędnego + if _is_chapter(session): + wn = form.cleaned_data.get("wydawnictwo_nadrzedne") + wn_pbn = form.cleaned_data.get("wydawnictwo_nadrzedne_w_pbn") + if not wn and not wn_pbn: + form.add_error( + "wydawnictwo_nadrzedne", + "Dla rozdziału wymagane jest wydawnictwo nadrzędne.", + ) + return _render_source_step(request, session, form=form) + if wn and wn_pbn: + form.add_error( + "wydawnictwo_nadrzedne", + "Podaj tylko jedno: wydawnictwo" + " nadrzędne lub wydawnictwo" + " nadrzędne w PBN.", + ) + return _render_source_step(request, session, form=form) else: if not form.cleaned_data.get("zrodlo"): form.add_error( @@ -436,6 +456,10 @@ def post(self, request, session_id): session.zrodlo = form.cleaned_data["zrodlo"] session.wydawca = form.cleaned_data["wydawca"] session.matched_data["wydawca_opis"] = form.cleaned_data.get("wydawca_opis", "") + session.wydawnictwo_nadrzedne = form.cleaned_data.get("wydawnictwo_nadrzedne") + session.wydawnictwo_nadrzedne_w_pbn = form.cleaned_data.get( + "wydawnictwo_nadrzedne_w_pbn" + ) session.status = ImportSession.Status.SOURCE_MATCHED session.modified_by = request.user session.save() @@ -850,40 +874,68 @@ def _render_verify_full(request, session, form=None): return _render_full_page(request, STEP_VERIFY, ctx) +def _is_chapter(session): + """Czy sesja dotyczy rozdziału (charakter_ogolny == 'roz').""" + return ( + session.charakter_formalny_id + and session.charakter_formalny.charakter_ogolny == CHARAKTER_OGOLNY_ROZDZIAL + ) + + +def _source_initial_from_session(session): + """Odczytaj initial z zapisanych wartości sesji.""" + initial = {} + if session.zrodlo_id: + initial["zrodlo"] = session.zrodlo_id + if session.wydawca_id: + initial["wydawca"] = session.wydawca_id + wydawca_opis = session.matched_data.get("wydawca_opis", "") + if wydawca_opis: + initial["wydawca_opis"] = wydawca_opis + if session.wydawnictwo_nadrzedne_id: + initial["wydawnictwo_nadrzedne"] = session.wydawnictwo_nadrzedne_id + if session.wydawnictwo_nadrzedne_w_pbn_id: + initial["wydawnictwo_nadrzedne_w_pbn"] = session.wydawnictwo_nadrzedne_w_pbn_id + return initial + + +def _source_initial_auto_match(session): + """Auto-matching źródła i wydawcy z normalized_data.""" + initial = {} + nd = session.normalized_data + source_title = nd.get("source_title") + if source_title: + src = Komparator.porownaj_container_title(source_title) + if src.rekord_po_stronie_bpp: + initial["zrodlo"] = src.rekord_po_stronie_bpp.pk + + publisher = nd.get("publisher") + if publisher: + pub = Komparator.porownaj_publisher(publisher) + if pub.rekord_po_stronie_bpp: + initial["wydawca"] = pub.rekord_po_stronie_bpp.pk + else: + initial["wydawca_opis"] = publisher + return initial + + def _source_context(request, session, form=None): """Przygotuj kontekst dla kroku źródła.""" - if form is None: - initial = {} + is_chapter = _is_chapter(session) - # Użyj wartości sesji gdy istnieją (user już submitował) - if session.zrodlo_id: - initial["zrodlo"] = session.zrodlo_id - if session.wydawca_id: - initial["wydawca"] = session.wydawca_id - wydawca_opis = session.matched_data.get("wydawca_opis", "") - if wydawca_opis: - initial["wydawca_opis"] = wydawca_opis - - # Auto-matching tylko gdy brak zapisanych wartości + if form is None: + initial = _source_initial_from_session(session) if not initial: - nd = session.normalized_data - source_title = nd.get("source_title") - if source_title: - src = Komparator.porownaj_container_title(source_title) - if src.rekord_po_stronie_bpp: - initial["zrodlo"] = src.rekord_po_stronie_bpp.pk - - publisher = nd.get("publisher") - if publisher: - pub = Komparator.porownaj_publisher(publisher) - if pub.rekord_po_stronie_bpp: - initial["wydawca"] = pub.rekord_po_stronie_bpp.pk - else: - initial["wydawca_opis"] = publisher - + initial = _source_initial_auto_match(session) form = SourceForm(initial=initial) - return {"session": session, "form": form} + return { + "session": session, + "form": form, + "is_chapter": is_chapter, + "wydawnictwo_nadrzedne_obj": (session.wydawnictwo_nadrzedne), + "wydawnictwo_nadrzedne_w_pbn_obj": (session.wydawnictwo_nadrzedne_w_pbn), + } def _render_source_step(request, session, form=None): @@ -1318,6 +1370,14 @@ def _create_wydawnictwo_zwarte(session, common_fields, normalized_data): common_fields["isbn"] = normalized_data.get("isbn") or "" common_fields["e_isbn"] = normalized_data.get("e_isbn") or "" + # Wydawnictwo nadrzędne (dla rozdziałów) + if session.wydawnictwo_nadrzedne_id: + common_fields["wydawnictwo_nadrzedne"] = session.wydawnictwo_nadrzedne + if session.wydawnictwo_nadrzedne_w_pbn_id: + common_fields["wydawnictwo_nadrzedne_w_pbn"] = ( + session.wydawnictwo_nadrzedne_w_pbn + ) + issue = normalized_data.get("issue") if issue: existing = common_fields.get("szczegoly", "")