diff --git a/src/bpp/admin/uczelnia.py b/src/bpp/admin/uczelnia.py index e9c0367cc..f3632c060 100644 --- a/src/bpp/admin/uczelnia.py +++ b/src/bpp/admin/uczelnia.py @@ -180,8 +180,11 @@ class UczelniaAdmin( { "classes": ("grp-collapse grp-opened",), "fields": ( - "wymagaj_informacji_o_oplatach", "wymagaj_logowania_zglos_publikacje", + "wymagaj_oplatach_artykul", + "wymagaj_oplatach_monografia", + "wymagaj_oplatach_rozdzial", + "wymagaj_oplatach_inne", ), }, ), diff --git a/src/bpp/admin/zglos_publikacje_helpers.py b/src/bpp/admin/zglos_publikacje_helpers.py index 4cdc97db2..75170a72c 100644 --- a/src/bpp/admin/zglos_publikacje_helpers.py +++ b/src/bpp/admin/zglos_publikacje_helpers.py @@ -61,9 +61,19 @@ def get_changeform_initial_data(self, request): for pole in MODEL_Z_OPLATA_ZA_PUBLIKACJE: ret[pole] = getattr(z, pole) + # Wydawca z nowego formularza + if z.wydawca_bpp_id: + ret["wydawca"] = z.wydawca_bpp_id + + # Wydawnictwo nadrzędne z nowego formularza + if z.wydawnictwo_nadrzedne_bpp_id: + ret["wydawnictwo_nadrzedne"] = z.wydawnictwo_nadrzedne_bpp_id + ret["adnotacje"] = ( - f"E-mail zgłaszającego: <{z.email}>.\nNumer zgłoszenia: {z.id} -- {str(z)}\n" - f"Pole 'Dostęp dnia' ustawione automatycznie na datę utworzenia rekordu. " + f"E-mail zgłaszającego: <{z.email}>.\n" + f"Numer zgłoszenia: {z.id} -- {z}\n" + f"Pole 'Dostęp dnia' ustawione automatycznie" + f" na datę utworzenia rekordu. " ) return ret diff --git a/src/bpp/migrations/0411_nowy_formularz_zgloszenia.py b/src/bpp/migrations/0411_nowy_formularz_zgloszenia.py new file mode 100644 index 000000000..8791d67e5 --- /dev/null +++ b/src/bpp/migrations/0411_nowy_formularz_zgloszenia.py @@ -0,0 +1,74 @@ +# Generated by Django 4.2.25 on 2026-04-02 08:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bpp", "0410_set_polish_skrot_crossref"), + ] + + operations = [ + migrations.AddField( + model_name="uczelnia", + name="wymagaj_oplatach_artykul", + field=models.BooleanField( + default=True, + help_text="Gdy zaznaczone, formularz zgłaszania publikacji będzie pytać o opłaty za artykuły naukowe.", + verbose_name="Wymagaj informacji o opłatach: artykuł", + ), + ), + migrations.AddField( + model_name="uczelnia", + name="wymagaj_oplatach_inne", + field=models.BooleanField( + default=False, + help_text="Gdy zaznaczone, formularz zgłaszania publikacji będzie pytać o opłaty za pozostałe publikacje.", + verbose_name="Wymagaj informacji o opłatach: inne", + ), + ), + migrations.AddField( + model_name="uczelnia", + name="wymagaj_oplatach_monografia", + field=models.BooleanField( + default=True, + help_text="Gdy zaznaczone, formularz zgłaszania publikacji będzie pytać o opłaty za monografie.", + verbose_name="Wymagaj informacji o opłatach: monografia", + ), + ), + migrations.AddField( + model_name="uczelnia", + name="wymagaj_oplatach_rozdzial", + field=models.BooleanField( + default=False, + help_text="Gdy zaznaczone, formularz zgłaszania publikacji będzie pytać o opłaty za rozdziały.", + verbose_name="Wymagaj informacji o opłatach: rozdział", + ), + ), + migrations.AlterField( + model_name="jezyk", + name="skrot_crossref", + field=models.CharField( + blank=True, + choices=[ + ("en", "en - angielski"), + ("es", "es - hiszpański"), + ("pl", "pl - polski"), + ], + max_length=10, + null=True, + unique=True, + verbose_name="Skrót nazwy języka wg API CrossRef", + ), + ), + migrations.AlterField( + model_name="uczelnia", + name="wymagaj_informacji_o_oplatach", + field=models.BooleanField( + default=True, + help_text="LEGACY: używaj pól wymagaj_oplatach_* poniżej. To pole zachowane dla kompatybilności.", + verbose_name="Wymagaj informacji o opłatach (legacy)", + ), + ), + ] diff --git a/src/bpp/migrations/0413_merge_20260416_2124.py b/src/bpp/migrations/0413_merge_20260416_2124.py new file mode 100644 index 000000000..f36bf253d --- /dev/null +++ b/src/bpp/migrations/0413_merge_20260416_2124.py @@ -0,0 +1,13 @@ +# Generated by Django 4.2.30 on 2026-04-16 19:24 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("bpp", "0411_nowy_formularz_zgloszenia"), + ("bpp", "0412_uczelnia_orcid_staff_only"), + ] + + operations = [] diff --git a/src/bpp/migrations/0414_merge_20260427_1123.py b/src/bpp/migrations/0414_merge_20260427_1123.py new file mode 100644 index 000000000..e41f498f3 --- /dev/null +++ b/src/bpp/migrations/0414_merge_20260427_1123.py @@ -0,0 +1,13 @@ +# Generated by Django 5.2.13 on 2026-04-27 09:23 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("bpp", "0413_bppuser_autor_onetoone"), + ("bpp", "0413_merge_20260416_2124"), + ] + + operations = [] diff --git a/src/bpp/migrations/0415_merge_20260504_0907.py b/src/bpp/migrations/0415_merge_20260504_0907.py new file mode 100644 index 000000000..8312dbf6f --- /dev/null +++ b/src/bpp/migrations/0415_merge_20260504_0907.py @@ -0,0 +1,13 @@ +# Generated by Django 5.2.13 on 2026-05-04 07:07 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("bpp", "0414_code_review_2026_05_fixes"), + ("bpp", "0414_merge_20260427_1123"), + ] + + operations = [] diff --git a/src/bpp/models/uczelnia.py b/src/bpp/models/uczelnia.py index 597381140..c25584de3 100644 --- a/src/bpp/models/uczelnia.py +++ b/src/bpp/models/uczelnia.py @@ -223,12 +223,45 @@ class Uczelnia(ModelZAdnotacjami, ModelZPBN_ID, NazwaISkrot, NazwaWDopelniaczu): ) wymagaj_informacji_o_oplatach = models.BooleanField( - "Wymagaj informacji o opłatach", + "Wymagaj informacji o opłatach (legacy)", default=True, - help_text="Gdy zaznaczone, moduł 'Zgłaszanie publikacji' będzie wyświetlać użytkownikowi formularz " - "informacji o opłatach za publikację w przypadku zgłaszania artykułu lub monografii. " - "Gdy odznaczone, taki formularz nie bedzie wyświetlany, niezależnie od rodzaju " - "zgłaszanej publikacji. ", + help_text=( + "LEGACY: używaj pól wymagaj_oplatach_* poniżej." + " To pole zachowane dla kompatybilności." + ), + ) + + wymagaj_oplatach_artykul = models.BooleanField( + "Wymagaj informacji o opłatach: artykuł", + default=True, + help_text=( + "Gdy zaznaczone, formularz zgłaszania publikacji" + " będzie pytać o opłaty za artykuły naukowe." + ), + ) + wymagaj_oplatach_monografia = models.BooleanField( + "Wymagaj informacji o opłatach: monografia", + default=True, + help_text=( + "Gdy zaznaczone, formularz zgłaszania publikacji" + " będzie pytać o opłaty za monografie." + ), + ) + wymagaj_oplatach_rozdzial = models.BooleanField( + "Wymagaj informacji o opłatach: rozdział", + default=False, + help_text=( + "Gdy zaznaczone, formularz zgłaszania publikacji" + " będzie pytać o opłaty za rozdziały." + ), + ) + wymagaj_oplatach_inne = models.BooleanField( + "Wymagaj informacji o opłatach: inne", + default=False, + help_text=( + "Gdy zaznaczone, formularz zgłaszania publikacji" + " będzie pytać o opłaty za pozostałe publikacje." + ), ) wydruk_logo = models.BooleanField("Pokazuj logo na wydrukach", default=False) diff --git a/src/bpp/newsfragments/+zglos-publikacje-uwagi-do-pol-help-text.feature.rst b/src/bpp/newsfragments/+zglos-publikacje-uwagi-do-pol-help-text.feature.rst new file mode 100644 index 000000000..9e7a56943 --- /dev/null +++ b/src/bpp/newsfragments/+zglos-publikacje-uwagi-do-pol-help-text.feature.rst @@ -0,0 +1,7 @@ +Formularz zgłaszania publikacji: pomocniczy tekst pola „Link do publikacji +lub DOI" jest teraz dobierany w zależności od kombinacji rodzaju publikacji +i formy dostępu — m.in. fragment o katalogach BN/NUKAT pojawia się tylko +przy monografii i rozdziale w dostępie ograniczonym, a wzmianka o PBN +znika dla publikacji typu „Inne". Dla rodzaju „Inne" pole nie jest +wymagane (ponieważ tych publikacji nie wysyłamy do PBN); dla dostępu +ograniczonego pozostaje wymagany plik PDF. diff --git a/src/bpp/static/scss/_select2-custom.scss b/src/bpp/static/scss/_select2-custom.scss index e068a7ac7..b80b59604 100644 --- a/src/bpp/static/scss/_select2-custom.scss +++ b/src/bpp/static/scss/_select2-custom.scss @@ -15,6 +15,27 @@ max-height: 400px !important; } +// Foundation daje `select { margin: 0 0 1rem }`. Select2 ukrywa +// oryginalny : 1rem dolnego oddechu, +// dodatkowo wymuszamy `display: block` żeby kontener faktycznie zajął +// pełną wysokość zamiast inline-block (margin-bottom na inline-block +// w niektórych kontekstach nie tworzy widocznego odstępu). +.select2-container { + display: block !important; + margin-bottom: 1rem !important; +} + +// Foundation help-text bez własnego marginesu wpada wprost pod +// poprzednika — daj mu trochę powietrza nad sobą gdy idzie po select2. +.select2-container + .help-text, +.select2-container ~ .help-text { + margin-top: 0.25rem; + display: block; +} + ::-ms-check { color: red; background: black; diff --git a/src/bpp/static/scss/_wizard_forms.scss b/src/bpp/static/scss/_wizard_forms.scss new file mode 100644 index 000000000..6f73f9d2d --- /dev/null +++ b/src/bpp/static/scss/_wizard_forms.scss @@ -0,0 +1,152 @@ +// Wizard form styles for zglos_publikacje +// Uses theme's $primary-color for consistent theming + +.tile-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + margin: 1.5rem 0; +} + +.tile-grid--stacked { + grid-template-columns: 1fr; +} + +.wizard-wybor-info { + display: flex; + align-items: center; + justify-content: center; + gap: 1rem; + margin: 1rem 0 0.5rem; + padding: 1.25rem 1.25rem; + background: rgba($primary-color, 0.08); + border: 1px solid rgba($primary-color, 0.3); + border-radius: 8px; + + .wizard-wybor-ikona { + font-size: 3rem; + line-height: 1; + } + + .wizard-wybor-label { + font-size: 1.4rem; + font-weight: bold; + color: $primary-color; + } +} + +.tile-card { + border: 2px solid #e0e0e0; + border-radius: 8px; + padding: 1.5rem; + cursor: pointer; + text-align: center; + transition: border-color 0.2s, box-shadow 0.2s; + background: #fff; + + &:hover { + text-decoration: none; + border-color: $primary-color; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + } + + &:focus { + outline: 2px solid $primary-color; + outline-offset: 2px; + text-decoration: none; + border-color: $primary-color; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + } + + &.selected { + border-color: $primary-color; + background: rgba($primary-color, 0.08); + box-shadow: 0 2px 12px rgba($primary-color, 0.2); + } + + input[type="radio"] { + display: none; + } + + .tile-icon { + display: block; + font-size: 3rem; + line-height: 1.2; + } + + .tile-title { + display: block; + font-size: 1.2rem; + font-weight: bold; + margin-top: 0.5rem; + } + + .tile-desc { + display: block; + font-size: 0.9rem; + color: #666; + margin-top: 0.3rem; + } + + &.tile-back { + border-color: #ccc; + background: #fff; + grid-column: 1 / -1; + + &:hover { + border-color: #999; + } + + &:focus { + outline: 2px solid #999; + outline-offset: 2px; + } + } +} + +.wizard-nav { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + margin: 1.5rem 0; +} + +.wizard-nav-btn { + border: 2px solid #e0e0e0; + border-radius: 8px; + padding: 1.2rem; + cursor: pointer; + text-align: center; + transition: border-color 0.2s, box-shadow 0.2s; + background: #fff; + font-family: inherit; + text-decoration: none; + + &:hover { + border-color: $primary-color; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + text-decoration: none; + } + + &:focus { + outline: 2px solid $primary-color; + outline-offset: 2px; + } + + .tile-icon { + display: block; + font-size: 2rem; + line-height: 1.2; + } + + .tile-title { + display: block; + font-size: 1.1rem; + font-weight: bold; + margin-top: 0.3rem; + } + + &.nav-single { + grid-column: 1 / -1; + } +} diff --git a/src/bpp/static/scss/app-blue.scss b/src/bpp/static/scss/app-blue.scss index 0e7632ab2..bc6c5be60 100644 --- a/src/bpp/static/scss/app-blue.scss +++ b/src/bpp/static/scss/app-blue.scss @@ -8,6 +8,7 @@ @import "external_links"; @import "ranking_autorow"; @import "pagination"; +@import "wizard_forms"; @import 'foundation'; @include foundation-global-styles; diff --git a/src/bpp/static/scss/app-green.scss b/src/bpp/static/scss/app-green.scss index c5a4d7bca..7e8d455e5 100644 --- a/src/bpp/static/scss/app-green.scss +++ b/src/bpp/static/scss/app-green.scss @@ -11,6 +11,8 @@ @import "external_links"; @import "ranking_autorow"; @import "pagination"; +@import "wizard_forms"; + @import 'foundation'; @include foundation-global-styles; diff --git a/src/bpp/static/scss/app-orange.scss b/src/bpp/static/scss/app-orange.scss index 41dda62ef..e802b7d5c 100644 --- a/src/bpp/static/scss/app-orange.scss +++ b/src/bpp/static/scss/app-orange.scss @@ -10,6 +10,8 @@ @import "external_links"; @import "ranking_autorow"; @import "pagination"; +@import "wizard_forms"; + @import 'foundation'; @include foundation-global-styles; diff --git a/src/bpp/views/autocomplete/simple.py b/src/bpp/views/autocomplete/simple.py index 0d2b60c71..897ed4b98 100644 --- a/src/bpp/views/autocomplete/simple.py +++ b/src/bpp/views/autocomplete/simple.py @@ -33,6 +33,31 @@ from .mixins import SanitizedAutocompleteMixin +class _OrderedSlicedQuerySetSequence(QuerySetSequence): + """QuerySetSequence wykrywający uporządkowanie przez sub-querysety. + + Oryginalna klasa raportuje `ordered=True` tylko gdy na samej + sekwencji zawołano `.order_by()`. To nie obejmuje przypadku, + w którym każdy sub-queryset jest już posortowany (a między nimi + obowiązuje deterministyczna kolejność priorytetowa) — a takiego + układu nie można wyrazić przez `.order_by()` na sekwencji, gdy + sub-querysety są sliced (Django 4+: „Cannot reorder a query + once a slice has been taken"). + + Raportujemy ordered=True wtedy i tylko wtedy, gdy każdy + sub-queryset jest rzeczywiście uporządkowany (albo gdy + .order_by() zostało wywołane na sekwencji) — żaden fake. + Dzięki temu paginator (django-autocomplete-light) nie emituje + UnorderedObjectListWarning dla uzasadnionych przypadków. + """ + + @property + def ordered(self): + if bool(self._order_by): + return True + return all(qs.ordered for qs in self._querysets) + + class PublicTaggitTagAutocomplete( SanitizedAutocompleteMixin, autocomplete.Select2QuerySetView ): @@ -258,15 +283,14 @@ def get_queryset(self): qs_without_pbn = qs.filter(pbn_uid__isnull=True)[:10] # Use QuerySetSequence to chain querysets with priority. - # UWAGA: nie wołamy tu .order_by() na sekwencji, bo - # sub-querysety są już sliced (`[:10]`), a QuerySetSequence - # propaguje order_by do każdego z nich — a Django od 4.x - # rzuca "Cannot reorder a query once a slice has been taken". # Bazowy qs ma już .order_by("nazwa", "pk") z # _get_base_queryset, więc każde filtrowane `[:10]` zachowuje - # porządek; jedynie kolejność między trzema gałęziami PBN - # jest priorytetowa (celowo, nie alfabetyczna). - res = QuerySetSequence( + # porządek; kolejność między trzema gałęziami PBN jest + # priorytetowa (celowo, nie alfabetyczna). + # _OrderedSlicedQuerySetSequence — sygnalizuje paginatorowi + # że sekwencja jest ordered (nie da się wołać .order_by() + # na sliced sub-querysetach w Django 4+). + res = _OrderedSlicedQuerySetSequence( qs_with_full_pbn, qs_with_pbn_no_mnisw, qs_without_pbn ) res.model = Zrodlo # django-autocomplete-light needs this diff --git a/src/deduplikator_autorow/views/duplicates.py b/src/deduplikator_autorow/views/duplicates.py index 259ab5dac..ce086695b 100644 --- a/src/deduplikator_autorow/views/duplicates.py +++ b/src/deduplikator_autorow/views/duplicates.py @@ -326,7 +326,7 @@ def _respond(success, message, status=200, level="success"): except Autor.DoesNotExist: return _respond(False, "Nie znaleziono autora o podanym ID.", status=404) - except Exception as e: + except Exception: traceback.print_exc() rollbar.report_exc_info(sys.exc_info()) return _respond( @@ -447,7 +447,7 @@ def _respond(success, message, status=200): except DuplicateCandidate.DoesNotExist: return _respond(False, "Nie znaleziono kandydata o podanym ID.", status=404) - except Exception as e: + except Exception: traceback.print_exc() rollbar.report_exc_info(sys.exc_info()) return _respond( diff --git a/src/deduplikator_autorow/views/merge.py b/src/deduplikator_autorow/views/merge.py index 89f0f5af3..d699d0317 100644 --- a/src/deduplikator_autorow/views/merge.py +++ b/src/deduplikator_autorow/views/merge.py @@ -82,7 +82,7 @@ def scal_autorow_view(request): try: main_autor = Autor.objects.get(pk=main_autor_id) duplicate_autor = Autor.objects.get(pk=duplicate_autor_id) - except Autor.DoesNotExist as e: + except Autor.DoesNotExist: rollbar.report_exc_info(sys.exc_info()) return JsonResponse( {"success": False, "error": "Nie znaleziono autora."}, @@ -113,7 +113,7 @@ def scal_autorow_view(request): return JsonResponse({"success": result.get("success", False), "result": result}) except NotImplementedError as e: return JsonResponse({"success": False, "error": str(e)}, status=501) - except Exception as e: + except Exception: traceback.print_exc() rollbar.report_exc_info(sys.exc_info()) return JsonResponse( diff --git a/src/zglos_publikacje/SPEC_NOWY_FORMULARZ.md b/src/zglos_publikacje/SPEC_NOWY_FORMULARZ.md new file mode 100644 index 000000000..0d1ce761a --- /dev/null +++ b/src/zglos_publikacje/SPEC_NOWY_FORMULARZ.md @@ -0,0 +1,137 @@ +# Specyfikacja: nowy formularz zgłaszania publikacji + +## Przegląd zmian + +Obecny formularz jednoetapowy zastępujemy wielokrokowym wizardem z dwoma +krokami wstępnymi (kafelki wyboru) oraz formularzem danych zależnym od +dokonanych wyborów. + +--- + +## Krok 1 — Rodzaj publikacji (kafelki) + +Użytkownik wybiera jeden z czterech kafelków: + +| Kafelek | Wartość wewnętrzna | +|---------------|--------------------| +| Artykuł | `ARTYKUL` | +| Monografia | `MONOGRAFIA` | +| Rozdział | `ROZDZIAL` | +| Inne | `POZOSTALE` | + +Wybór zastępuje dotychczasowe pole `rodzaj_zglaszanej_publikacji` w formularzu. +Pole `rodzaj_zglaszanej_publikacji` w modelu pozostaje (kompatybilność), +ale jest ustawiane automatycznie na podstawie wybranego kafelka. + +--- + +## Krok 2 — Forma dostępu (kafelki) + +Użytkownik wybiera jeden z dwóch kafelków: + +| Kafelek | Znaczenie | +|----------------------|------------------------------------------------------------| +| Otwarty dostęp | Pełny tekst jest dostępny online (link lub DOI) | +| Dostęp ograniczony | Pełny tekst nie jest swobodnie dostępny w internecie | + +Ten krok dotyczy wszystkich czterech typów publikacji jednakowo. + +--- + +## Krok 3 — Formularz danych o publikacji + +Pola formularza zależą od kombinacji wyboru z kroków 1 i 2. + +### Pola wspólne (zawsze obecne, niezależnie od wyborów) + +| Pole | Typ | Wymagane | Uwagi | +|----------------------------------------------|---------------|----------|--------------------------------------------------------------| +| `tytul_oryginalny` | textarea | tak | Tytuł pracy | +| `rok` | integer | tak | Rok publikacji | +| `email` | email | tak | E-mail zgłaszającego; prefill jeśli user zalogowany | +| `zgoda_na_publikacje_pelnego_tekstu` | choice (tak/nie) | tak* | *Pole widoczne tylko gdy uczelnia ma włączone ustawienie `pytaj_o_zgode_na_publikacje_pelnego_tekstu`. Opis: "Zgoda na publikację pełnego tekstu w lokalnym repozytorium". Help text wyjaśnia, że dotyczy lokalnego repozytorium uczelni, niezależnie od formy dostępu do publikacji w internecie. | + +### Pola zależne od kroku 2 (forma dostępu) + +#### Wariant: Otwarty dostęp + +| Pole | Typ | Wymagane | Uwagi | +|---------------|-----------|----------|-----------------------------------------------------------------------| +| `strona_www` | URL/text | tak | Link do pełnego tekstu lub identyfikator DOI. Jedno pole; system sam wykrywa, czy podano DOI czy URL. | + +Pliki PDF: **nie są wymagane, pole nie pojawia się**. + +#### Wariant: Dostęp ograniczony + +| Pole | Typ | Wymagane | Uwagi | +|---------------|-----------|----------|--------------------------------------------------------------| +| `strona_www` | URL/text | nie | Opcjonalny link (np. zajawka na stronie wydawcy) | +| `pliki` | file/PDF (wiele) | tak | Pliki PDF z pełnym tekstem pracy; wymagany min. 1 plik; możliwość dodania wielu plików | + +### Pola zależne od kroku 1 (rodzaj publikacji) + +#### Artykuł + +Brak dodatkowych pól ponad pola wspólne + pola z kroku 2. + +#### Monografia + +| Pole | Typ | Wymagane | Uwagi | +|-----------|------------------------------|----------|-------------------------------------------------------------------------| +| `wydawca` | autocomplete + freetext | nie | Autocomplete z tabeli wydawców BPP. Jeśli brak pasującego — freetext. | + +#### Rozdział + +| Pole | Typ | Wymagane | Uwagi | +|----------------------------|------------------------------|----------|-----------------------------------------------------------------------------------------------------------------------| +| `wydawnictwo_nadrzedne` | autocomplete + freetext | tak | Tytuł monografii, w której jest rozdział. Autocomplete z wydawnictw zwartych BPP i/lub wydawnictw nadrzędnych PBN. Jeśli brak pasującego — freetext. | +| `wydawca` | autocomplete + freetext | nie | Nazwa wydawcy/oficyny. Autocomplete z tabeli wydawców BPP. Jeśli brak — freetext. | + +#### Inne + +Brak dodatkowych pól ponad pola wspólne + pola z kroku 2. + +--- + +## Podsumowanie: macierz pól na formularzu (krok 3) + +Legenda: **W** = wymagane, **O** = opcjonalne, **—** = nie pojawia się + +| Pole | Artykuł OA | Artykuł ogr. | Monografia OA | Monografia ogr. | Rozdział OA | Rozdział ogr. | Inne OA | Inne ogr. | +|--------------------------------------|:----------:|:------------:|:-------------:|:---------------:|:-----------:|:-------------:|:-------:|:---------:| +| `tytul_oryginalny` | W | W | W | W | W | W | W | W | +| `rok` | W | W | W | W | W | W | W | W | +| `email` | W | W | W | W | W | W | W | W | +| `zgoda_na_publikacje_pelnego_tekstu` | O* | O* | O* | O* | O* | O* | O* | O* | +| `strona_www` (link/DOI) | W | W | W | W | W | W | O | O | +| `pliki` (PDF, wiele) | — | W | — | W | — | W | — | W | +| `wydawca` | — | — | O | O | O | O | — | — | +| `wydawnictwo_nadrzedne` | — | — | — | — | W | W | — | — | + +*O\* = widoczne tylko gdy uczelnia ma włączone ustawienie* + +--- + +## Kolejne kroki wizarda (bez zmian) + +- **Krok 4 — Autorzy**: bez zmian względem obecnego formularza. +- **Krok 5 — Opłaty za publikację**: bez zmian; wyświetlany warunkowo + w zależności od ustawień uczelni i rodzaju publikacji. + +--- + +## Uwagi techniczne + +1. Pole `rodzaj_zglaszanej_publikacji` w modelu `Zgloszenie_Publikacji` + wymaga rozszerzenia wartości enum `Rodzaje` — rozdzielenie + `ARTYKUL_LUB_MONOGRAFIA` na `ARTYKUL` i `MONOGRAFIA`. +2. Migracja danych: istniejące rekordy `ARTYKUL_LUB_MONOGRAFIA` mogą + wymagać decyzji, jak je przeklasyfikować (lub zostawić z obecną wartością + jako legacy). +3. Pola `wydawca` i `wydawnictwo_nadrzedne` to nowe pola na modelu + `Zgloszenie_Publikacji`. +4. Autocomplete z możliwością freetext: implementacja przez + django-autocomplete-light z opcją `create` lub analogiczny mechanizm + pozwalający wpisać wartość spoza listy. +5. Wizardy (django-formtools SessionWizard) pozostają jako mechanizm + nawigacji między krokami. diff --git a/src/zglos_publikacje/admin/zgloszenie_publikacji.py b/src/zglos_publikacje/admin/zgloszenie_publikacji.py index 04aee81a4..2bcd168bf 100644 --- a/src/zglos_publikacje/admin/zgloszenie_publikacji.py +++ b/src/zglos_publikacje/admin/zgloszenie_publikacji.py @@ -17,7 +17,11 @@ from bpp.admin.core import DynamicAdminFilterMixin from bpp.admin.helpers.fieldsets import MODEL_Z_OPLATA_ZA_PUBLIKACJE -from zglos_publikacje.models import Zgloszenie_Publikacji, Zgloszenie_Publikacji_Autor +from zglos_publikacje.models import ( + Zgloszenie_Publikacji, + Zgloszenie_Publikacji_Autor, + Zgloszenie_Publikacji_Zalacznik, +) from .filters import ( DzienTygodniaFilter, @@ -30,12 +34,21 @@ class Zgloszenie_Publikacji_AutorInline(admin.StackedInline): model = Zgloszenie_Publikacji_Autor - # form = Zgloszenie_Publikacji_AutorInlineForm - # readonly_fields = ["autor", "jednostka", "dyscyplina_naukowa"] fields = ["autor", "jednostka", "dyscyplina_naukowa"] extra = 0 +class Zgloszenie_Publikacji_ZalacznikInline(admin.TabularInline): + model = Zgloszenie_Publikacji_Zalacznik + fields = [ + "plik", + "oryginalna_nazwa_pliku", + "kolejnosc", + ] + readonly_fields = ["plik", "oryginalna_nazwa_pliku"] + extra = 0 + + @admin.register(Zgloszenie_Publikacji) class Zgloszenie_PublikacjiAdmin( DjangoQLSearchMixin, DynamicAdminFilterMixin, admin.ModelAdmin @@ -73,12 +86,19 @@ class Zgloszenie_PublikacjiAdmin( "tytul_oryginalny", "rok", "rodzaj_zglaszanej_publikacji", + "forma_dostepu", ) + MODEL_Z_OPLATA_ZA_PUBLIKACJE + ( "email", "strona_www", "plik_do_pobrania", + "wydawca_zgloszenia", + "wydawca_bpp", + "wydawca_pbn", + "wydawnictwo_nadrzedne_tekst", + "wydawnictwo_nadrzedne_bpp", + "wydawnictwo_nadrzedne_pbn", "status", "przyczyna_zwrotu", "kod_do_edycji", @@ -86,10 +106,11 @@ class Zgloszenie_PublikacjiAdmin( ) ) - readonly_fields = ["plik_do_pobrania"] + readonly_fields = ["pliki_do_pobrania"] inlines = [ Zgloszenie_Publikacji_AutorInline, + Zgloszenie_Publikacji_ZalacznikInline, ] def has_add_permission(self, request): @@ -120,6 +141,11 @@ def wrapper(*args, **kwargs): wrap(self.pobierz_plik_view), name=f"{info[0]}_{info[1]}_pobierz_plik", ), + url( + r"^(.+)/pobierz_zalacznik/(\d+)/$", + wrap(self.pobierz_zalacznik_view), + name=f"{info[0]}_{info[1]}_pobierz_zalacznik", + ), ] super_urls = super().get_urls() @@ -219,7 +245,7 @@ def _(): return render(request, self.zwroc_view_template, context) def pobierz_plik_view(self, request, id): - """Pobierz plik załącznika przez X-Accel-Redirect.""" + """Pobierz plik załącznika przez X-Accel-Redirect (legacy).""" obj = self.get_object(request, id) if obj is None: raise Http404("Zgłoszenie nie istnieje") @@ -236,8 +262,27 @@ def pobierz_plik_view(self, request, id): attachment_filename=filename, ) + def pobierz_zalacznik_view(self, request, id, zalacznik_id): + """Pobierz konkretny załącznik przez X-Accel-Redirect.""" + obj = self.get_object(request, id) + if obj is None: + raise Http404("Zgłoszenie nie istnieje") + + try: + zalacznik = obj.zalaczniki.get(pk=zalacznik_id) + except Zgloszenie_Publikacji_Zalacznik.DoesNotExist: + raise Http404("Załącznik nie istnieje") from None + + return sendfile( + request, + zalacznik.plik.path, + attachment=True, + attachment_filename=zalacznik.oryginalna_nazwa_pliku, + ) + @admin.display(description="Plik załącznika") def plik_do_pobrania(self, obj): + """Legacy - pokazuje tylko stare pole plik.""" if not obj.plik: return "-" from django.urls import reverse @@ -254,6 +299,47 @@ def plik_do_pobrania(self, obj): display_name, ) + @admin.display(description="Pliki") + def pliki_do_pobrania(self, obj): + """Wyświetla wszystkie pliki - stare pole plik + nowe załączniki.""" + from django.urls import reverse + + parts = [] + + # Stare pole plik (legacy) + if obj.plik: + url = reverse( + "admin:zglos_publikacje_zgloszenie_publikacji_pobierz_plik", + args=[obj.pk], + ) + display_name = obj.oryginalna_nazwa_pliku or obj.plik.name.split("/")[-1] + parts.append( + format_html( + '📄 {} (legacy)', + url, + display_name, + ) + ) + + # Nowe załączniki + for zalacznik in obj.zalaczniki.all(): + url = reverse( + "admin:zglos_publikacje_zgloszenie_publikacji_pobierz_zalacznik", + args=[obj.pk, zalacznik.pk], + ) + parts.append( + format_html( + '📎 {}', + url, + zalacznik.oryginalna_nazwa_pliku, + ) + ) + + if not parts: + return "-" + + return format_html("
".join(parts)) + def wydzial_pierwszego_autora(self, obj: Zgloszenie_Publikacji): try: return ( diff --git a/src/zglos_publikacje/autocomplete.py b/src/zglos_publikacje/autocomplete.py new file mode 100644 index 000000000..a34e26438 --- /dev/null +++ b/src/zglos_publikacje/autocomplete.py @@ -0,0 +1,115 @@ +"""Autocomplete views for publication submission form. + +Uses QuerySetSequence to combine BPP and PBN data sources. +""" + +from dal_select2_queryset_sequence.views import ( + Select2QuerySetSequenceView, +) +from django.contrib.postgres.search import TrigramSimilarity +from django.utils.safestring import mark_safe +from queryset_sequence import QuerySetSequence + +from bpp.const import CHARAKTER_OGOLNY_KSIAZKA +from bpp.models.wydawca import Wydawca +from bpp.models.wydawnictwo_zwarte import Wydawnictwo_Zwarte +from bpp.views.autocomplete.mixins import SanitizedAutocompleteMixin +from pbn_api.models.publication import Publication as PBN_Publication +from pbn_api.models.publisher import Publisher as PBN_Publisher + +MIN_TRIGRAM_MATCH = 0.05 +MAX_RESULTS = 10 + + +class PublicWydawcaAutocomplete( + SanitizedAutocompleteMixin, Select2QuerySetSequenceView +): + """Public autocomplete for publishers. + + Combines results from bpp.Wydawca (local) and + pbn_api.Publisher (PBN) using QuerySetSequence. + """ + + def get_queryset(self): + bpp_wydawcy = Wydawca.objects.all() + pbn_publishers = PBN_Publisher.objects.all() + + if self.q: + q = self.q.strip() + bpp_wydawcy = ( + bpp_wydawcy.annotate(similarity=TrigramSimilarity("nazwa", q)) + .filter(similarity__gte=MIN_TRIGRAM_MATCH) + .order_by("-similarity")[:MAX_RESULTS] + ) + + pbn_publishers = ( + pbn_publishers.annotate( + similarity=TrigramSimilarity("publisherName", q) + ) + .filter(similarity__gte=MIN_TRIGRAM_MATCH) + .order_by("-similarity")[:MAX_RESULTS] + ) + else: + bpp_wydawcy = bpp_wydawcy.none() + pbn_publishers = pbn_publishers.none() + + qs = QuerySetSequence(bpp_wydawcy, pbn_publishers) + qs = self.mixup_querysets(qs) + return qs + + def get_result_label(self, result): + if isinstance(result, Wydawca): + return mark_safe(f"{result.nazwa} [BPP]") + return mark_safe(f"{result.publisherName} [PBN]") + + # NIE nadpisuj `get_result_value` — domyślna implementacja + # `dal_queryset_sequence.views` zwraca `-`, czego + # oczekuje QSS widget przy POST-cie. Zwrócenie z get_result_value + # samego label-a sprawia, że